Your authorisation layer is correct and your query still leaks
A permission check answers "may this user do this". It doesn't answer "to which rows". In multi-tenant software the second question is the one that leaks data, and it fails in a way that passes every test you've.
Here's a handler that's wrong. It has a correct permission check, it passes its tests, and it'll return another customer's data.
func GetInvoice(ctx context.Context, id uuid.UUID) (Invoice, error) {
if err := twad.Can(ctx, ActionInvoiceRead); err != nil {
return Invoice{}, err
}
return dbx.Get[Invoice](ctx, "select * from invoices where id = $1", id)
}
The check is real. It denies by default, it fails closed, and it correctly establishes that this user is allowed to read invoices.
It says nothing whatsoever about whose invoices.
Two questions, and everyone builds the first one
"May this identity perform this action?" Roles, permissions, policies. This is the part that gets designed, reviewed, threat-modelled and tested, because it is legible - it has a name, it has a library, it has a page in the architecture doc.
"Of the rows this action could touch, which may they see?" This one usually
lives as a WHERE clause somebody remembered to type.
The first question has an owner. The second is a convention.
A good authz layer doesn't save you
Ours is better than most and it makes no difference here.
It's three-valued rather than boolean. Allow, Deny, and MustDeny - where
MustDeny short-circuits and propagates, beating an Allow from the other
dimension, which is what makes a suspension absolute rather than something a
generous role can override. Checks evaluate two dimensions, space roles and user
roles, and OR them. Default is Deny; nothing is permitted implicitly. Unknown
role grants nothing. A membership query that errors denies rather than skipping
the dimension.
All of which is good, and all of which answers question one.
The handler above passes every one of those checks and still hands over another tenant's invoice, because a permission system that returns a boolean cannot constrain a query. To constrain a query you need a predicate, and that's a different shape of answer.
Why your tests pass
Test fixtures usually contain one tenant. With one tenant in the database, a query that forgets to filter by tenant returns exactly the right rows. The test is green, and it's green for a reason that has nothing to do with the code being correct.
It stays green through code review, because the reviewer is looking at a handler with a permission check at the top of it. It stays green in a penetration test scoped to one account. It goes red the first time two customers exist and one of them enumerates ids, which is usually long after everyone has stopped thinking about that handler.
The fixture is the bug. If your multi-tenant test data has one tenant in it, you have no coverage of the property that matters most.
What an auditor actually looks at
Nobody inspects the SQL, which is the reason this class of defect reaches production in organisations with a clean audit history. An assessment examines your access control design, your permission matrices, your joiners-movers-leavers process. All of those can be correct while every query in the application returns other tenants' rows.
Two things satisfy an assessor here, and only two:
A tested control. Not a design document and not a policy - a test that proves tenant A cannot read tenant B's rows, running in CI, with retained results. That's evidence in the form an auditor can use, and it's the artefact that makes the control real rather than intended.
Regular penetration testing. The tested control proves the case you thought of. A pen test is what finds the endpoint nobody wrote a test for, and it's the only part of the process that approaches the problem the way an attacker does.
If you've both, the finding shows up as a fixed defect. If you've neither, the authorisation layer reviews beautifully and the leak is discovered by somebody else.
Make it impossible to forget, where you can
The fix isn't a rule about remembering. Rules about remembering have a half-life measured in staff turnover.
Our database layer takes two pieces of information and then does it for you.
Pin the tenant at the request boundary, once:
ctx = dbx.ForSpace(ctx, tok.Space.ID)
Declare which tables are tenant-scoped, at init:
dbx.RegisterSpaceScoped("invoices", "space_id")
dbx.RegisterSpaceScoped("line_items", "space_id")
When both are true - a space is pinned and the table is registered - the
structured helpers inject the predicate themselves. Inserts and upserts set the
column. Update-by-id, get-by-id and the builder select add it to the WHERE.
The guarantee is narrow and precise: a registered table reached through those
helpers under ForSpace cannot omit the space predicate. Not "should not".
Cannot.
Everything else passes through untouched. Global tables, reference data, the identity tables - not registered, so not affected, and adoption is incremental rather than a migration.
Which calls are guarded, and which only look it
The guarantee has an edge and you need to know exactly where it is.
Scoped automatically: InsertOne, InsertOneReturning, UpsertOne,
UpsertOneReturning, UpdateOne, UpdateOneReturning, SelectAndScanOne,
SelectAndScanPaginatedFromBuilder, SelectKeyset.
Not scoped - yours to handle: Get, Select, Query, QueryRow, Exec,
QueryReturning, QueryReturningOne, QueryAndScanFromBuilder,
QueryAndScanPaginatedFromBuilder.
The second list takes caller-authored SQL, and the
library has no way to know which table you meant or which column holds the
tenant. It cannot inject a predicate into a string it didn't write. On that
surface you read the tenant back with SpaceFromContext and add the clause
yourself.
So the trap: the safe and unsafe surfaces look
identical at the call site. SelectAndScanOne is guarded. Select isn't.
They sit next to each other in the same package, take similar arguments, and one
of them silently protects you.
We still have around 211 hand-written tenant predicates across the estate. Those are all fine, and every one of them is a place where somebody could haven't typed it.
Four changes that make the bug visible
Count your tenants in test fixtures. Two, minimum, in any fixture used by a multi-tenant test. This single change turns the entire class of bug from invisible to obvious, and it costs an afternoon.
Write the negative test. Not "tenant A can read their invoice" - that passes either way. "Tenant A gets not-found for tenant B's invoice id." That's the assertion that fails on the broken handler.
Make the safe path the default path and audit the escape hatch. Whatever your equivalent of the raw-SQL surface is, know how many uses it has, and grep it periodically. A number you can quote is a number somebody is looking at.
Prefer not-found over forbidden. Returning 403 for a row that exists in another tenant confirms that it exists, which is an enumeration oracle. 404.
Cross-tenant paths, and row-level security
Reporting, admin tooling and support views legitimately need to cross tenants, so they cannot use the guarded path, and they're exactly the code paths where a mistake is worst. Those need their own review rather than a library guarantee.
Postgres row-level security is the other answer, and it's a stronger one - the database enforces it regardless of which query surface you used, so there's no unguarded list. The cost is that policies live away from the code that reasons about them, connection-level session variables have to be set correctly on a pooled connection every single time, and getting that wrong fails in the same quiet direction. It's a real trade rather than an obvious upgrade, and if you are starting fresh with tenancy as a hard requirement, I'd look at it first.
If you're running RLS in anger on a pooled connection and it has been uneventful, I'd like to hear about it, because the failure modes I imagine may just be imaginary.