Every asset, scan, finding, event and verdict belongs to an organisation. This page describes how that boundary is enforced, who may do what inside it, and — honestly — what of the organisation surface does not exist yet.

The boundary is a type, not a convention

The failure this design exists to prevent is specific. An early audit found three endpoints that authenticated the caller and then loaded a resource by ID with no ownership check at all: any authenticated user could read another tenant’s data by guessing a UUID. The root cause was structural rather than a forgotten line. Authentication established who the caller was and nothing established what they could reach, so each handler was free to skip the check — and three of them did. The fix makes the tenant a required argument:
  • A Scope is proof that a caller’s membership of a tenant has been checked.
  • Its fields are unexported, so a handler cannot construct one.
  • It is issued only by the authorizer, and only after a membership lookup.
  • Every tenant-scoped data method takes one:
A handler cannot call that without a Scope, cannot obtain a Scope without passing through the authorizer, and the authorizer does not issue one without checking membership. A handler that forgets to authorise does not compile. That required inverting a dependency: the tenancy package depends on nothing but the domain models, so the data layer can import it. With the arrow the other way the data layer could only accept a bare organisation ID — a string any caller can invent.

The job plane is a different type

A worker acts on behalf of the system and has no authenticated user, so it cannot hold a Scope. Those operations live on separate types, which take the organisation ID from the row the worker already claimed. Separate types rather than extra methods, so that “this method has no scope” is a visible architectural decision rather than something that looks like a forgotten argument — and so a request handler cannot call one by accident, because it does not hold the type.

A CI check, verified by planting the violation

A build guard asserts three properties: every tenant-plane method takes a Scope, Scope has no exported fields, and no job-plane type is reachable from the HTTP layer. Each check was verified by planting the violation it claims to catch. A guard that cannot fail is worse than no guard, because it is trusted.

Row-level security is not in use

Stated as the gap it is. The application connects as the schema owner, and PostgreSQL’s row-level security does not apply to a table’s owner. Using it properly means a separate non-owner role, which is deployment work that has not been done. Until then, isolation rests on the scoped-query structure above plus the tests below — which is enforcement, but one layer of it rather than two.

”Not yours” and “does not exist” are the same answer

Both return 404, never 403. A 403 confirms that the resource exists and merely belongs to someone else, which turns a UUID guess into reconnaissance about who else is a customer. The status code and the response body match byte-for-byte, and a test asserts both — a body that differed would leak exactly what the status code was chosen not to. The same rule holds in bulk operations: another tenant’s finding returns not_found, identical to one that never existed.

What is actually tested

Every fixture has two tenants, always. A single-tenant test cannot distinguish “the query is scoped” from “there was only one tenant’s data to return”, and that distinction is the whole of the original defect. The properties asserted at the SQL layer, the HTTP layer, and against real scan data:
  • A tenant cannot read another’s asset, scan, finding or finding events.
  • Listing endpoints return zero rows for another tenant’s data.
  • A tenant cannot filter by another tenant’s asset ID to reach their scans.
  • A tenant’s scan cannot resolve another tenant’s findings — the subtle one, because resolution by absence is a bulk UPDATE and an unscoped one would silently close a stranger’s backlog.
  • A tenant cannot mutate another’s finding, and a refused mutation changes nothing and writes no event.
  • The suppression expiry sweep spans tenants and files each event in the finding’s own organisation. It is the system acting on its own schedule, and the one place a bulk write legitimately crosses the boundary.
  • The same image in two tenants produces two rows with one identity and one fingerprint, proving isolation does not depend on the fingerprint — see Findings and identity.

Two kinds of principal

A person and a pipeline are different things, and TRUSTIVAN models them as different things. The shortcut is to mint a hidden user row per API key and let the rest of the system keep saying “user”. It works on the first day and is close to impossible to undo: every audit row, every created_by, every membership check then treats a credential sitting in a CI runner as indistinguishable from a person who typed a password. A machine principal has no user ID to write. Every column recording who acted — requested_by, suppressed_by, actor_user_id — is NULL for a machine action, and the key’s identity is recorded in its own column. No audit row ever claims a person did what a pipeline did. An API key’s creator is recorded for attribution only. It does not confer that person’s authority on the key, and the key does not stop working if they leave. The creator’s current role is deliberately never consulted: doing so would make a key’s authority mutable by an unrelated HR action, where a demotion silently breaks a pipeline and a promotion silently widens a credential’s blast radius. What a key was granted at issue time is what it has until it is revoked or expires.

Roles

Four roles, cumulative by rank — each has everything below it. Authorisation asks about permissions, never about a role string. That question has an answer for both kinds of principal — a human’s permissions are derived from their role, a key’s are exactly what it was granted — so an endpoint cannot tell which it is talking to, which is what stops the two paths drifting into two policies.

findings:suppress is separate from findings:write

Triage moves a finding around a queue everyone can still see. Suppression makes a real, confirmed vulnerability stop appearing in the backlog, the severity counts, and eventually the gate that blocks a release. Folding it into findings:write would hand every CI key the ability to silence its own failures.

What a machine credential may never hold

Several permissions exist and are not grantable to an API key, at issue time or afterwards. Three are below; Scopes lists all eight, including organization:manage, which renames the organisation: Granted scopes are filtered again at authentication, not merely validated at issue time. A permission that stopped being grantable — or a row written before a rule tightened — must not confer authority just because it is sitting in a column. Unknown scope names are an error rather than being ignored. Silently dropping one would issue a key that is quietly less capable than the caller asked for, and they would discover it as a 403 in a pipeline at some later date.
That endpoint is the single definition of the grantable set, so the UI menu and the API’s validation cannot drift apart.

What does not exist yet

Stated plainly, because a security product being vague about its own boundaries is worse than having narrow ones. There is no way to delete an organisation. Signup gives every account an organisation of its own, and a signed-in person can create more with POST /api/v1/orgs — they become its owner, and it starts on the Free plan with no trial. An administrator renames the current one with PATCH /api/v1/orgs/current. Deleting one would have to revoke its keys and credentials, stop its scheduled work and remove its NHI Security tenant, and none of that exists yet; erasing a tenant is an operator action.

Working in more than one organisation

A person can belong to several organisations — their own, ones they were invited into, ones a directory provisioned them into. Every request acts in exactly one:
  • the one they chose with PUT /api/v1/me/active-org (the organisation menu at the top of the dashboard), while they are still a member of it;
  • otherwise the oldest organisation they belong to.
The choice belongs to the person, not to a browser tab. It survives a token refresh and the next sign-in, and it moves every session they hold at once — including NHI Security, which asks the same session which organisation it is in. It is a preference and never authority: membership is checked again on every request, so an organisation somebody has been removed from is unreachable from their next request (removal through the Members screen also ends their sessions), and when they sign in again they land in one they still belong to. Choosing an organisation you do not belong to is a 404, the same answer a nonexistent one gets, and the attempt is recorded in the audit trail of the organisation you were acting in. An API key or a workload credential is not affected by any of this. It acts in the organisation that issued it, always, whoever created it and wherever they are working now. Because the choice moves every tab, the dashboard names the organisation it is showing on every request (X-Expected-Organization). A tab left open on one organisation after a switch elsewhere is refused (409) and reloads into the current one, rather than creating an invitation or a policy in an organisation it was not showing. Membership within an organisation is another matter — there are three ways a second person joins:
  • An invitation. An administrator names an address and a role; TRUSTIVAN issues a token that the recipient presents while signed in as that address. TRUSTIVAN does not send it. No mail sender is wired, so the token is shown to the administrator once and delivering it is theirs to do. It is stored only as a hash, so a lost invitation is withdrawn and re-issued.
  • SCIM provisioning. A directory credential writes the membership table through /api/scim/v2/Users, so creating and disabling a user in your IdP creates and removes access here.
  • Just-in-time on first SSO login, when the provider has JIT enabled. A user who authenticates against it on a verified domain is provisioned on arrival, with the provider’s configured default role.
All three are bounded by the plan’s seats. A seat is held by a member or by an invitation that can still be redeemed, which is why the refusal comes when an invitation is issued — to the administrator who can withdraw an offer, remove somebody or change plan — rather than when it is accepted. Accepting redeems the seat the offer already held, and is refused only if the organisation is over its limit because its plan shrank. A SCIM create or reactivation past the limit gets a SCIM error (status 402) and writes nothing; a first SSO login is refused and the reason is recorded in the identity audit trail. A user staged inactive by SCIM holds no seat, and nothing that removes access is ever refused. Creating an organisation seats its creator unconditionally. The addresses in your verified domain belong to your organisation. If somebody registered one with a password before your directory or SSO reached that person, never verified it, and was never admitted to your organisation, the first SCIM push or SSO login retires that account — its password, sessions and social logins stop working — and gives the person a fresh account. The identity audit trail records it as user.unverified_account_retired. When SSO is enforced, a password or social login for an address in the verified domain is refused with sso_required, and existing password sessions end at their next renewal. One exception keeps an identity-provider outage from locking you out of the setting that would fix it: an owner who has enrolled a second factor can still sign in with password and code. Every such sign-in is recorded as sso_enforcement.bypassed; admins and everyone else must use SSO. Roles can be set per person, and for directory-sourced membership they are also assigned by mapping a directory group to a role, under Settings → Identity or with PATCH /api/v1/identity/groups/{id}. A group is listed once a single sign-on assertion names it, and grants nothing until it is mapped. Both exist because they answer different questions: a directory is the system of record for who is in which team, and a per-person change is what an administrator reaches for when somebody’s responsibilities change today. A membership that arrived from a directory is marked as such on the roster, because a group mapping re-asserted on the next sync is what explains a role that an administrator changed and that then changed back. An invitation cannot offer owner, and neither JIT nor a group mapping can confer it. Ownership is the authority to destroy the tenant, so it is transferred deliberately — PATCH /api/v1/members/{userId} accepts owner for somebody already present, and only from an owner. Offering it by email could leave a tenant with an owner who never agreed to be one, and a change in somebody else’s directory must never reach it. Two invariants bound all of this. A caller cannot grant a role above their own, re-checked when an invitation is accepted as well as when it is issued — the administrator who sent it may have been demoted in between. And the last owner cannot be demoted or removed: an organisation with no owner has nobody who can restore one, so the mistake would not be repairable through the product. Audit coverage is broad but not total. Findings and verdicts are audited in append-only tables, and so are the privileged changes an enterprise review asks about: sign-ins (including refused passwords and second factors), sign-outs and replayed refresh tokens; policy authoring; webhook endpoints, secret rotations and automatic disables; registry, cloud and cluster credentials; API keys and workloads; asset creation and deletion; identity-provider configuration and enforcement; domain verification; SCIM tokens; and membership and role changes — including deprovisioning, which is the evidence that offboarding worked. A sign-in or sign-out is recorded in the person’s personal organization; an SSO sign-in in the identity provider’s. Every administrative entry records where it came from: source_ip (the client address as TRUSTED_PROXIES resolves it), user_agent, and request_id — the X-Request-ID of the request, which is also on its access-log line. Identity entries live in identity_audit_events rather than in the finding timeline, and are read through GET /api/v1/identity/audit (filterable by actor, action, since and until) or in the dashboard under Settings → Audit log; both need identity:read, which owners and admins hold and which can be granted to an API key for a compliance pipeline. An administrative identity change is neither about a finding nor something every member of the tenant should be able to read.

Where to go next