Skip to content
GitHub
Decisions

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 TenantScopedQuery helper 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_vars with 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_role discipline carries forward to TA-19 D7 (key-exchange middleware ensures service_role does not leak into customer-request paths).
  • Codex system-design/foundations/access-control.mdx close-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)