TRUSTIVAN’s isolation model exists because its absence was a real finding in this codebase. Three endpoints authenticated the caller and then loaded a resource by ID with no ownership check at all, so 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 every handler was free to skip the check — and three of them did. Fixing three handlers would have fixed three handlers.

Forgetting to authorise does not compile

The fix was to make the tenant a required argument rather than an optional one. tenancy.Scope is proof that a caller’s membership of a tenant has been checked. Its fields are unexported, so no code outside the package can construct one, and the only thing that returns one is the Authorizer — after a membership lookup that either succeeds or refuses. Every tenant-scoped repository method takes a Scope:
A handler cannot call it without a Scope, cannot obtain a Scope without passing through Authorizer, and Authorizer does not issue one without checking membership. The check is not something to remember; it is something the compiler insists on. Making that work required inverting a dependency. While tenancy imported the data layer, the data layer could only accept a bare organisation ID — a string any caller can invent. Giving tenancy a small membership interface instead means it depends on nothing but the models, the arrow reverses, and the query layer can require the unforgeable type.

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 — ScanJobStore, FindingWriteStore, ScheduleJobStore — which take an organisation ID taken from the row the worker already claimed. Separate types rather than extra methods, so “this method has no Scope” is a visible architectural decision rather than something that looks like a forgotten argument, and so a handler cannot call one by accident: it does not hold the type.

CI enforces all three properties

scripts/ci/check-tenant-scope.sh fails the build if a tenant-plane method takes no Scope, if Scope grows an exported field, or if a job-plane type becomes reachable from the API package. Each check was verified by planting the violation it claims to catch, which is the only way to know a guard works. A guard that cannot fail is worse than no guard, because it is trusted.

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

Both return 404, and the bodies are byte-identical. A test asserts the status code and the response body match exactly. A 403 would confirm the resource exists and merely belongs to someone else, which turns a UUID guess into reconnaissance about who else is a customer. That is a disclosure even when no data is returned. The rule holds everywhere, including in places where it costs something:
  • GET /findings/{id} on another tenant’s finding is a 404.
  • A bulk operation reports not_found for another tenant’s finding, identical to one that does not exist — otherwise a bulk endpoint would be a fast way to enumerate which UUIDs are real.
  • GET /webhooks/{id} on another tenant’s endpoint is a 404.
A 403 means something different and is safe to return: you are in this tenant and your credential lacks a permission. It names the missing scope, because the caller already knows the tenant is theirs and a machine that cannot be told which scope it lacks is a machine whose operator has to guess. See Authentication errors.

Cursors are bound to a tenant

A pagination cursor is a signed payload:
The signature is necessary but not sufficient, and the tenant binding is the part people leave out — on the reasoning that the signature already prevents forgery. It does, and forgery is not the threat. A cursor issued to tenant A is genuinely signed; if it reaches tenant B through a shared log, a copied URL or a support ticket, nothing else would stop it resuming a traversal of another organisation’s data. So a cursor is bound to the tenant, the order and the filter set, and it expires after 24 hours. Resuming across a filter change would return neither the old set nor the new one with nothing to say so; resuming a day later would silently miss everything created since, and failing loudly beats paginating through a set that no longer exists. The signing key is derived from the deployment’s master key rather than configured separately, so it cannot be forgotten and silently default to empty. The API refuses to construct the handler without a signer, making that a startup failure rather than a 500 during a listing.

Rate limiting is per credential

The limit is counted against the credential authentication has just established, never against the organisation. Limiting per tenant would make every credential inside it share one budget, so one runaway CI job could lock out every colleague and every dashboard session in the same company. That is a denial of service the customer inflicts on themselves, delivered by a control meant to protect them. Unauthenticated routes have their own per-IP limiter, because there is no credential there yet.

What is tested

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; a refused mutation changes nothing and writes no event.
  • The same image in two tenants produces two rows with one identity and one fingerprint, proving isolation does not depend on the fingerprint.
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 finding.

The gaps, stated plainly

Row-level security is not in use

The application connects as the schema owner, and PostgreSQL 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 and the tests above. That is enforcement, and it is real — but it is one layer of it rather than two.

Team management ships; organisation management does not

This section previously said team management was unavailable. That has not been true since member invitations shipped, and three of its four statements were wrong. What remains true is narrower and worth stating precisely. What ships:
  • Member invitations. POST /api/v1/members/invitations issues one, GET lists them, DELETE /api/v1/members/invitations/{id} revokes one, and POST /api/v1/invitations/accept redeems the token. The dashboard has a Members screen for all of it. The invitee is mailed a link to the accept page when the deployment has mail configured; otherwise the administrator is given the link to send.
  • Role assignment. PATCH /api/v1/members/{userId} changes a member’s role, defending the last-owner invariant inside a transaction so two concurrent demotions cannot leave a tenant with none.
  • Removal. DELETE /api/v1/members/{userId}.
What is partial:
  • Organisations can be created and renamed, not deleted. POST /api/v1/orgs creates one owned by the caller; PATCH /api/v1/orgs/current renames the current one and needs organization:manage, which no API key can hold. Deleting a tenant has to revoke its credentials, stop its scheduled work and remove its NHI Security tenant, and none of that is built — erasure is an operator action today.
  • You choose which organisation you act in. A person in several organisations switches between them from the organisation menu (PUT /api/v1/me/active-org); until they choose, a session acts in the oldest organisation they belong to. The choice is stored on the person, so it survives refresh and sign-in and moves every session they hold, NHI Security included. It is never authority: membership is re-checked on every request, a choice of an organisation you are not in is a 404 that is recorded in the audit trail of the organisation you were in, and someone removed from the organisation they chose cannot reach it from their next request — removal also ends their sessions — and lands in one they still belong to when they sign in again. API keys and workload credentials always act in the organisation that issued them.
The permission model those roles feed is not decorative — it is the same Can() question a machine principal answers, it is what makes findings:suppress distinct from findings:write, and it is assignable through the members API above. See the FAQ for the rest of the current boundary, and Tenancy for how choosing an organisation works.

Where to go next