ADR-033: Tenancy enforcement — app-layer primary, RLS backstop
Status: Accepted (2026-04-20) Supersedes: ADR-002 on the RLS-primary framing for tenant isolation (the rest of ADR-002 stands)
Context
ADR-002 implicitly framed Supabase RLS as the primary mechanism for tenant isolation, paired with a direct-Supabase-SDK-from-frontend access pattern. Adversarial research during TA-1 surfaced two findings that decisively reframed the question:
- The pro-RLS argument’s strongest case requires an exposed anon key. When the anon key is publicly reachable, RLS is the only thing standing between an attacker and other tenants’ data; the RLS-primary framing earns its keep there.
- The anti-RLS case bites hardest when there is a server-side API in the trust path. CVE-2025-48757 / the Lovable incident documented 170+ apps with 13k users where RLS was the only control and 10.3% of deployments mis-configured at least one policy. Bypass surfaces (SECURITY DEFINER functions, view inheritance, planner cliffs,
service_role) compound the failure modes. RLS as a backstop is cheap; RLS as the only boundary is a load-bearing single point of failure.
Spectral has FastAPI in the trust path (per ADR-002’s API tier). ADR-034’s decision to remove the direct-SDK frontend pattern eliminates the “anon key reachable” concern. With both conditions in place, the right tenancy posture is app-layer primary, RLS backstop — and ADR-002’s RLS-primary framing is partially superseded.
Decision
D1 — App-layer tenancy filtering is the primary enforcement boundary
FastAPI validates the Supabase-issued JWT on every authenticated request, loads tenant context (account_id from the JWT, workspace_id from the request scope), and builds queries via a typed TenantScopedQuery helper that the architecture validator refuses to let bypass. This is where 95% of isolation actually lives.
Raw psycopg query construction in application code touching tenant-scoped tables is a validator failure; helpers are the only path. The helper composes queries with tenant predicates injected by middleware so a programmer cannot accidentally write a query that ignores tenancy.
D2 — RLS stays as backstop, not primary
Simple policies (workspace_id = current_setting('app.workspace_id')::uuid) are set from the FastAPI connection-checkout hook. They defend against app-layer regressions, not against a public anon key — there isn’t one (per ADR-034). Policies stay dumb enough that the anti-RLS bypass surfaces (SECURITY DEFINER, view inheritance, planner cliffs) are not biting.
service_role is disciplined to worker and ops-only contexts; customer-request paths never use it. RLS applies to every tenant-scoped app table regardless of schema (core, worlds, platform). Tenant columns (account_id plus workspace_id) remain non-negotiable on every tenant-scoped table.
The session-var convention is captured in spectral.core.db.session_vars:
SESSION_VAR_ACCOUNT_ID = "app.account_id"(customer tenancy)SESSION_VAR_WORKSPACE_ID = "app.workspace_id"(workspace scoping)SESSION_VAR_USER_ID = "app.user_id"(per-user identity, added by TA-13 D6)
D3 — Identity, not capability, in session vars
Per TA-13 D6, session vars name identity layers (account, workspace, user); they do not name capability classes (operator, admin). Capability lives in JWT scopes, not in session vars. This rule is architectural — it constrains how future RLS use cases are wired up.
Alternatives considered
RLS-primary with direct-Supabase-SDK-from-frontend (ADR-002’s implicit model). Rejected: CVE-2025-48757 class; RLS becomes a single point of tenancy failure when the anon key is reachable; business-logic gravity scatters across policies plus frontend plus API.
RLS-only (no app-layer filter). Rejected: silent failure mode (UPDATE blocked returns 0 rows; SELECT returns empty); test coverage burden; bypass surfaces (SECURITY DEFINER, service_role, views).
App-layer only (no RLS). Rejected: loses the cheap backstop against app-layer regression; the belt-and-suspenders tax is minimal when RLS is kept simple and service_role-disciplined.
Per-tenant database isolation. Rejected at this scale: Supabase Auth binds 1:1 to a DB (per ADR-032 D1); tenant-per-DB would require a project-per-tenant which does not match the multi-tenant SaaS shape.
Consequences
- ADR-002 is partially superseded on the RLS-primary framing. The Supabase-as-platform commitment, Auth commitment, and pgvector commitment all stand.
- A typed
TenantScopedQueryhelper scaffolds in the first scanning-domain epic post-TA-review. Architecture validator rejects raw psycopg query construction in application code that touches tenant-scoped tables. - Session-var contract lives in
spectral.core.db.session_varswith contract tests pinning the exact strings (app.account_id,app.workspace_id,app.user_id). Per ADR-065 admission discipline. - The connection-pool checkout hook (TA-3) carries the binding: every checked-out connection has
search_path(per-context) +SET LOCAL app.account_id+SET LOCAL app.workspace_id+ (in operator-context transactions)SET LOCAL app.user_id. service_rolediscipline carries forward to TA-19 D7 (key-exchange middleware ensuresservice_roledoes not leak into customer-request paths).- Codex
system-design/foundations/access-control.mdxclose-pass captures D5/D6 alongside the OPERATIONS scope taxonomy and the TA-19 D7 key-exchange middleware.
References
- ADR-002 — retired; original Supabase platform decision distilled into the ADR-046 addendum
- ADR-032 — storage topology (per-context schemas; per-context roles)
- ADR-034 — frontend data access via API proxy (eliminates “anon key reachable”)
- ADR-039 — Supabase Auth confirmation + JWT validation surface
- ADR-041 — pool checkout hook binds session vars
- ADR-048 — TA-19 D7 key-exchange middleware
- ADR-059 — TA-13 D6
SESSION_VAR_USER_ID(identity, not capability) - TA-1 disposition — SPEC-304 comment
5c9c25f0 src/spectral/core/db/session_vars.py— session-var contract surface- Codex
system-design/foundations/access-control.mdx— close-pass updates - CVE-2025-48757 / Lovable incident — primary anti-RLS evidence (cited in TA-1 evidence section)