Defence in depth: both layers, always
1
Application layer
Every query against a tenant-owned table runs inside
withTenant(ctx, db => …) from @stackra/tenancy (or the Pro scope driver’s withScope, below). It opens a transaction and runs SELECT set_config('app.tenant_id', $1, true) with the tenant from the authenticated principal, never from request input. A direct query in a handler is a lint failure.2
Database layer
Every tenant-owned table has
tenant_id uuid NOT NULL, ENABLE ROW LEVEL SECURITY and FORCE ROW LEVEL SECURITY (the role that owns the table would otherwise bypass RLS), and a policy on current_setting('app.tenant_id', true). A forgotten filter returns zero rows, never another tenant’s data.Scope tree (Pro)
The scope tree is a Pro driver,@stackra-pro/scope; free tenancy has no scope dependency (0088, owner decisions S2, S3 and S12). When a product installs it, a tenant’s data is organised in a tree of scopes (for example organisation, branch, team), each with an ltree path, and these rules bind. withScope also appends the scope-path predicate from the principal’s granted paths.
Use the OR-expansion
(scope_path <@ $1::ltree OR scope_path <@ $2::ltree …). Both scope_path <@ ANY($paths) and $paths::ltree[] @> scope_path seq-scan on GiST and are forbidden. An empty granted-paths list yields false.
Tenant context
Resolved once per request by the single auth handler into the principal (Security). The tenant is the session’s active tenant, a token claim, or an API key’s bound tenant. It is never taken from body, query string or a client header. With the Pro scope driver, the one client header a handler reads,X-Scope-Path, is validated against the granted paths and dropped otherwise.
Cross-tenant access exists only in an explicitly privileged, audited endpoint named by an ADR. Tables that are global by design (a person registry, an auth account store) have no tenant_id. They enforce the boundary in code and a migration test pins that.
Isolation tests
Every service with tenant-owned tables ships an isolation test that proves:- Tenant B gets zero rows or a 404 for tenant A’s data on list, get, update and delete.
- A handler written without
withTenant()returns zero rows (RLS fails closed).