Skip to main content
Trigger: the first tenant-owned table. Stackra has none today and no database. This law is binding from the moment one appears, and the checks below must exist first. It is stated now so the first table is built right, not retrofitted.

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.
Hand-written RLS is not allowed. The scoped-table pattern emits the columns, indexes and policy. The mechanism is decided in ADR 0014, not here. Migrations that backfill tenant tables use a dedicated bypass clause in the policy. Decide it in the migration template, not on the first backfill.

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:
  1. Tenant B gets zero rows or a 404 for tenant A’s data on list, get, update and delete.
  2. A handler written without withTenant() returns zero rows (RLS fails closed).

Enforcement: planned

History

  • 2026-10-20, 0125: the scope tree moved out of the base law into a Pro section (0088, owner decisions S2, S3, S12).