Tenancy & Row-Level Security
See
tenant-contextandroute-wrappingfor the canonical, citable rule statements. This page covers the mechanics behind them.
Every tenant-scoped table enforces isolation at the Postgres layer:
ALTER TABLE identity.organisations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON identity.organisations
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
current_setting('app.tenant_id', true)::uuid (without the NULLIF) is a landmine on pooled connections: once any transaction on a session has run set_config('app.tenant_id', …, true), the GUC's reset value is '' — defined-but-empty, not NULL — so the bare cast raises 22P02 and aborts every statement touching the table, because all policies on a table are evaluated even when another one would match. This took down the MCP token exchange in production (DIR-93). Use NULLIF(current_setting(…), '')::uuid, or a module helper like identity.row_in_current_tenant(tenant_id) which compares as text.
Tenant scoping happens in two distinct steps:
- Membership validation at the route layer. Every catalog and directory route uses the per-app
authedRoute/authedRouteWithParamshelpers (which composewithAuth+withTenantAuth).withTenantAuthresolves the active tenant (x-tenant-idheader →jwt.tenant_idfallback) and verifies the user has an active membership for it. NoSET LOCALhappens here. - DB session scoping at the tools layer. When a tool opens a transaction via
withTenantContext()from@constellation-platform/db, the helper executesSELECT set_config('app.tenant_id', $1, true)(transaction-local) so RLS picks up the value for every subsequent query in the transaction. The tenant-scoped Prisma client wraps this for you when you usecreateTenantClient(...).
Routes that read but never call into a tool can still be RLS-safe by going through createTenantClient(...), which does the transaction + SET LOCAL for each scoped query.
Project Tracker: entering a tenant scope checks the tenant is live
In Project Tracker, entering a tenant scope also proves the tenant is still live (ADR-044, PLT-1336). Every write of app.tenant_id goes through the SQL function projects.enter_tenant_scope. The function refuses a tenant that is not ACTIVE in the same statement that would have scoped to it:
- Requests. A refused entry raises SQLSTATE
PTL01. The route error boundary answers it with 403FORBIDDEN, the same answer the per-route liveness gates give. - Background sweeps. The milestone and cycle sweeps skip inactive tenants when they select candidates. A tenant suspended between selection and entry is contained so the rest of the run continues: the milestone and daily-snapshot sweeps roll back that tenant's savepoint, and the stale-agent-run and run-scoring sweeps skip that tenant.
- Queued jobs. A job for a suspended tenant keeps the queue's configured failure policy. It is not parked for later replay.
- Tenant creation. Creating a tenant goes through Directory's
identity.bootstrap_tenant_strict. That function refuses to resume the bootstrap of a tenant that exists and is notACTIVE.
Two narrow exception helpers exist: enter_tenant_scope_bootstrap for a tenant being created, and enter_rls_read_bypass_scope for the read-bypass sentinel. Their call sites are frozen in scripts/tenant-enter-allowlist.json. npm run check:tenant-enter fails the build on any other writer.
A read that never enters a tenant scope is not covered by this check. For example, a lookup under withoutAutoScope() never calls the function. Gating those reads needs a separate check at the read (PLT-1407).
Route wrapping
The wrap is mandatory: see route-wrapping for the rule, the canonical wrap style, the escape-hatch syntax, and the CI enforcement script.