Skip to main content

Tenancy & Row-Level Security

See tenant-context and route-wrapping for 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);
Never cast the GUC directly

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:

  1. Membership validation at the route layer. Every catalog and directory route uses the per-app authedRoute / authedRouteWithParams helpers (which compose withAuth + withTenantAuth). withTenantAuth resolves the active tenant (x-tenant-id header → jwt.tenant_id fallback) and verifies the user has an active membership for it. No SET LOCAL happens here.
  2. DB session scoping at the tools layer. When a tool opens a transaction via withTenantContext() from @constellation-platform/db, the helper executes SELECT 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 use createTenantClient(...).

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 403 FORBIDDEN, 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.
  • Entries made inside shared packages. auth-next and coordinator open tenant scope themselves, so Project Tracker passes them one entry statement, PT_TENANT_ENTRY (apps/project-tracker/src/lib/tenant-entry.ts, PLT-1352 and PLT-1406), which calls the same function. The middleware's principal check on every authenticated request answers a suspended tenant with 401, not a 500, and the directory-admin flag falls back to false. Both keep a result cache of up to 30 seconds per instance, so a suspension reaches a request that was already verified, or an admin already cached as true, within that window rather than immediately. A coordinator transition for a suspended tenant fails as a transaction error.
  • Pending-action expiry. The hourly sweep runs the same statement on its dedicated login, whose authority role holds the privileges the function needs (ADR-041 amendment, PLT-1406). Discovery returns only ACTIVE tenants (ADR-041 amendment, PLT-1710), so a suspended or decommissioned tenant is skipped: its pending actions stay untouched, none of them can be claimed once expired, and after a reactivation the next run picks the tenant up again and resumes expiring them. A tenant suspended between discovery and its first transaction is refused there and turns that one run red.
  • 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 not ACTIVE.

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.

The three functions also attest what they enter (ADR-062, PLT-1413). Each writes app.tenant_scope_attestation, a hash over a secret only the functions' owner can read, bound to the value, the connection and the transaction. Since PLT-1730, every projects RLS policy honours app.tenant_id only when that attestation verifies, through projects.rls_attested_tenant_scope(), evaluated once per statement. A writer the static check cannot see therefore reads and writes nothing in projects, for example one whose function and setting names are both built at run time. An unattested scope behaves like a tenant that owns no rows: reads return nothing, updates and deletes affect nothing, and inserts fail the policy check. Other schemas, BYPASSRLS connections and app.cron_bypass are not covered.

A read that never enters a tenant scope is not covered by this check: a lookup under withoutAutoScope() with no resource tenant runs under the read-bypass sentinel and never calls the function. Where such a read returns content from the row, Project Tracker checks the row's own tenant at the read instead, through isRowTenantLive in lib/project-access.ts (PLT-1407). It asks only when the row's tenant differs from one the request has already checked, usually the parent project's, so a task or issue whose tenant has drifted from its project's is still caught. A row in a suspended tenant then gets the answer an absent or inaccessible one gets: an invitation link answers 404 "Invalid invitation token" (400 "Invalid or expired invitation" at registration), a task's comment list leaves its comments out, a key short link is not redirected, and conflict messages withhold the key. The per-site inventory is .ai/specs/SPEC-plt-1407-sentinel-read-liveness.md.

Since PT-1332, the access-resolution lookups themselves (verifyProjectAccess and its siblings, issue and initiative access, the key short links) no longer read under the sentinel. They go through Project Tracker's tenant resolver (ADR-072): SECURITY DEFINER functions, owned by a role nothing can log in as, that return only a resource's id, its own tenant and its ownership columns, called from one module (src/server/db/tenant-resolver.ts). The remaining sentinel readers move onto it in later slices of PT-1088.

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.