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:
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 aScope. 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_foundfor 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.
Cursors are bound to a tenant
A pagination cursor is a signed payload: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
UPDATEand 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.
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/invitationsissues one,GETlists them,DELETE /api/v1/members/invitations/{id}revokes one, andPOST /api/v1/invitations/acceptredeems 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}.
- Organisations can be created and renamed, not deleted.
POST /api/v1/orgscreates one owned by the caller;PATCH /api/v1/orgs/currentrenames the current one and needsorganization: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 a404that 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.
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
- Tenancy and isolation — the same boundary from the object-model side.
- Security model — what else is treated as hostile.
- Pagination — cursors from a client’s perspective.

