Architecture
ADR 0009 — Row-Level Security
Date: 2026-06-13 Status: Accepted — DB layer live & verified; runtime cutover staged
Context
Tenant isolation was enforced only at the application layer (where[tenant] filters + Payload access control). At the scale we are targeting (thousands of sub-accounts), a single forgotten filter or a future query bug could expose one tenant's data to another. We want a database-level backstop that makes cross-tenant reads/writes structurally impossible, independent of app code.
The feedback DB user is a superuser with BYPASSRLS, so RLS never applies to it. A non-superuser runtime role is required for RLS to take effect.
Decision
Adopt PostgreSQL Row-Level Security on every tenant-scoped table, keyed on a per-request session variable, with a fail-closed design.
Runtime role
app_user(NOSUPERUSER, NOBYPASSRLS) — the application connects as this role.feedbackremains owner/superuser for migrations. Grants:apps/cms/rls/0002_grants.sql.Policies (
apps/cms/rls/0001_enable_rls.sql) on the 17 tenant-scoped tables (contacts, companies, deals, pipelines, services, tasks, appointments, conversations, messages, channels, message_templates, automations, automation_runs, activities, identities, roles, users_tenants). RLS isENABLEd andFORCEd. The singletenant_isolationpolicy:USING / WITH CHECK ( current_setting('app.is_platform', true) = 'on' OR tenant_id = nullif(current_setting('app.current_tenant', true), '')::int )- No context set ⇒ 0 rows (fail-closed; a wiring bug can never leak, only show empty data).
app.current_tenant = <id>⇒ only that tenant's rows; writes to any other tenant are rejected byWITH CHECK.app.is_platform = 'on'⇒ platform super-admin sees/writes all tenants.
Per-request context must be set transaction-locally (
set_config(..., true)/SET LOCAL) so it is discarded at transaction end and can never leak across pooled connections.
Verification (as app_user, via SET ROLE)
| Context | count(*) FROM contacts | Expected |
|---|---|---|
| none | 0 | fail-closed ✅ |
app.current_tenant=1 | 4 | own tenant ✅ |
app.current_tenant=99999 | 0 | others invisible ✅ |
app.is_platform=on | 4 (all) | platform bypass ✅ |
| INSERT into foreign tenant | RLS error | write-blocked ✅ |
Cutover (final, deliberate step)
The DB layer is live. To switch enforcement on for the running app:
- Wire per-request context: at the start of each authenticated request set
app.current_tenantto the caller's active tenant (andapp.is_platform=onfor platform admins), transaction-locally, on the operation's own connection. - Change
DATABASE_URIfromfeedback:...toapp_user:...and redeploy. - Smoke-test reads/writes per tenant; instant rollback = revert
DATABASE_URI.
Because the policy is fail-closed, the worst case of a wiring mistake is empty data (visible, reversible), never a cross-tenant leak.
Consequences
- Cross-tenant data exposure becomes structurally impossible once cut over.
- All app DB access must run with a tenant context (or platform flag); ad-hoc scripts must connect as
feedbackor set the context explicitly. - Migrations run as
feedback(owner), unaffected by RLS.