Multi-Tenant Codebases: Make the Unsafe Query Impossible to Write
A prompt influences a model. Tenant isolation has to hold regardless of what the model does, because one failure is a breach notification rather than a bug report.
Tenant isolation is not a prompting problem
The instinct when an agent works on multi-tenant code is to write careful instructions: always filter by tenant, never cross a boundary, be careful with shared tables. Those instructions are worth writing and they are not a security control.
A prompt influences a model. Multi-tenant isolation is a property that must hold regardless of what any model does, because the consequence of a single failure is one customer reading another's data — which is a breach notification, not a bug report.
So the instructions are the second line. The first is making the wrong thing hard to write.
Make the unsafe query the awkward one
The dangerous pattern is a query that can omit the tenant filter. If your data access layer allows it, an agent will eventually write it — not maliciously, just while focused on something else.
# Nothing stops a caller forgetting the filter.
def get_orders(session, status: str) -> list[Order]:
return session.query(Order).filter_by(status=status).all()
# Tenant comes from the request context, not from an argument,
# and there is no code path that omits it.
def get_orders(status: str) -> list[Order]:
return (
tenant_session() # raises if no tenant in context
.query(Order)
.filter_by(status=status)
.all()
)
def tenant_session() -> Session:
tid = current_tenant.get() # ContextVar, set by middleware
if tid is None:
raise NoTenantContext("No tenant bound to this request.")
return session_factory(tenant_id=tid) # filter applied at the session
The second version cannot produce an unfiltered query, because the filter is not a parameter anyone can forget. That is a structural property, and it holds whatever the agent writes.
Row-level security enforces the same invariant one layer lower, where application bugs cannot reach it. If your database supports it, an RLS policy on every tenant-scoped table is the strongest version of this control and the one that survives a mistake in your data access layer.
Then write the instructions
With structure in place, instructions become a useful second layer — they stop the agent writing something that would be rejected, rather than being the only thing standing between you and a breach.
## Multi-tenancy — read before touching data access
Every row in `orders`, `invoices`, `documents`, `audit_log` and
`attachments` belongs to exactly one tenant.
- NEVER construct a query against these tables directly. Use the
helpers in `db/tenant_scope.py`, which bind the tenant from the
request context.
- NEVER pass tenant_id as a function argument. It comes from the
authenticated principal, and an argument is something a caller
can get wrong.
- NEVER use `session.execute(text(...))` on a tenant table. Raw SQL
bypasses the scoping. If you genuinely need raw SQL, stop and ask.
- Cache keys MUST include the tenant. `f"orders:{status}"` is a
cross-tenant cache poisoning bug. Use `cache_key()`.
- Background jobs: the tenant must be an explicit part of the job
payload and re-bound at the start of the handler. Context does
not survive the queue.
If a task seems to require reading across tenants, stop and say so.
That is an admin operation with a separate, audited code path.
The cache line is the one teams most often miss, and it is a genuine cross-tenant leak with no database query involved at all. The background-job line is second — context variables do not cross a queue boundary, and a handler that assumes they do reads whichever tenant happened to be bound last.
The places isolation actually leaks
| Surface | The leak | Structural fix |
|---|---|---|
| Cache keys | Key omits tenant; second tenant reads first's value | A cache_key() helper that always includes it |
| Background jobs | Context lost across the queue | Tenant in the payload; handler re-binds and asserts |
| Search indexes | One index, filter applied at query time | Index per tenant, or a mandatory filter in the client wrapper |
| File storage | Predictable paths, no ownership check | Tenant in the path prefix and checked on read |
| Error messages | "Order ord_123 not found" confirms it exists | Identical response for not-found and not-yours |
| Aggregates and reports | A COUNT across all rows | Same scoped session as everything else |
The error-message row is subtle and real. Distinguishing "does not exist" from "exists but is not yours" is an enumeration oracle, and it is exactly the sort of helpful distinction generated code adds without being asked.
Admin paths need the same treatment
Every multi-tenant system has legitimate cross-tenant operations: support tooling, billing reconciliation, platform analytics. They are where isolation is supposed to be crossed, which makes them the most dangerous code in the repository.
## Cross-tenant code
Lives ONLY in `src/admin/`. Nothing outside that directory may
construct an unscoped session.
- Every function there is decorated `@requires_platform_role` and
`@audited`. Both. No exceptions.
- The audit record includes: actor, tenants touched, justification
string, timestamp. The justification is a required argument.
- Never import anything from `src/admin/` into request-path code.
Adding a function here needs a second reviewer. Say so in the PR.
The required justification argument is the useful detail. It forces the caller to state why, it lands in the audit log, and it makes an unexplained cross-tenant read impossible to write without deliberately fabricating a reason.
Test the invariant, do not trust it
@pytest.mark.parametrize("endpoint", ALL_TENANT_SCOPED_ENDPOINTS)
def test_cannot_read_other_tenants_resource(client, endpoint):
a = make_tenant("a"); b = make_tenant("b")
resource = create_resource(tenant=a)
r = client.get(endpoint.format(id=resource.id), auth=token(b))
# 404, not 403 — 403 confirms the resource exists.
assert r.status_code == 404
assert str(resource.id) not in r.text
Parametrising over every tenant-scoped endpoint is what makes this durable: a new endpoint added later is covered automatically, including one an agent adds while you are not looking. A test written per endpoint is a test somebody forgets to write.
#!/bin/bash
HITS=$(git diff origin/main...HEAD -U0 | grep -E '^\+' \
| grep -E 'text\(|execute\(' \
| grep -Ei 'orders|invoices|documents|attachments|audit_log' || true)
if [ -n "$HITS" ]; then
echo "Raw SQL against a tenant-scoped table:" >&2
echo "$HITS" >&2
echo "Use db/tenant_scope.py, or add 'raw-sql-reviewed' with a reason." >&2
exit 1
fi
Tenant isolation must hold regardless of what any model writes, so make the unsafe query structurally impossible first — tenant bound from request context, never a parameter, ideally enforced by row-level security. Then write the instructions as a second layer. Watch cache keys, background jobs and error messages, and parametrise the cross-tenant test over every endpoint so new ones are covered automatically.