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
Scopeis 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:
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 aScope. 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 aScope, 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 returnsnot_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
UPDATEand 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, everycreated_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, includingorganization: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.
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 withPOST /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.
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.
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
- Findings and identity — the timeline, and the four actor kinds recorded in it.
- Policies and verdicts — why
policies:manageis ungrantable. - Quickstart — issuing a key with a specific set of scopes.

