Skip to content

RFC: Tenant Isolation via Postgres RLS

Make the database refuse cross-tenant rows by default — one enforcement point that covers reads, writes, and raw SQL — instead of two ORM listeners that each cover half the problem and neither covers raw SQL.

Owner: Jeff (jeffg)Created: 2026-06-26Status: Draft

🔀 This is a competing alternative to Tenant-Isolation Enforcement (Ross, 2026-06-16). Same problem, same goal (default-on tenancy, no per-endpoint code) — different primary lever. That RFC makes ORM session listeners the primary control and relegates RLS to "a defense-in-depth backstop on a separate track." This RFC argues the polarity is backwards: RLS should be the backbone, with a thin ORM layer for error ergonomics. The two reasons that RFC gives for demoting RLS no longer hold once the policy is written correctly — see Rebutting the demotion.

💬 Feedback wanted — draft. A GitHub Discussion (Ideas) will be linked here before we commit. Open questions are in Risks & open questions.

TL;DR

🅰 The same flood

397 genuine cross-tenant IDOR

621 live IDOR defects found so far. After dropping 224 false-positives (see below), 397 are real — and 68% of them are writes, not reads.

🅱 Two halves ≠ one wall

ORM hooks cover reads, then writes, but never raw SQL

The sibling RFC needs L1 (SELECT-only) plus a separate XL/high-effort L2 write guard — and ~174 raw-text() call sites still slip past both.

🅲 One control, one place

A restrictive RLS policy with WITH CHECK

One DB policy per table, keyed on a per-request GUC, scopes reads and writes and raw SQL — and prevents 378 of the 397 (95%) genuine bugs. Generated from a tenancy resolver that already ships in the repo.

The data behind the claim

Every live IDOR defect found so far was classified by whether a vendor/customer-scoped RLS policy on the owning table would have prevented it.

RLS coverage classCount% of liveWhat it means
RLS-CATCHES37860.9%Tenant-scoped table, missing/0-bypassed scope → policy returns/blocks zero cross-tenant rows
R1 — global table30.5%No tenant column (AI prompts, webhook schemas) → RLS can't scope
R2 — path / external40.6%URL-path tenant id drives an external side-effect before any DB row is touched
R3 — object ACL121.9%Object is in-tenant; bug is missing per-object/owner authorization
R4 — likely false-positive22436.1%if project_id: truthiness on a BIGSERIAL id that can't be 0 — a smell, not a leak

Of the 397 genuine bugs (everything except R4), RLS prevents 378 — 95%. The residual is 19 defects in three small, well-understood buckets. And the caught set is 68% writes (260) / 31% reads (118) — the half an SELECT-only control structurally cannot touch.

Confidence: 401 of 621 classifications are deterministic (HIGH) — the false-positive rule and the "unscoped lookup of a known tenant-scoped model" rule. 197 of the 378 RLS-CATCHES are title/pattern-keyword inferences (MED); a 12-defect deep-read sample (reading the actual vulnerable code) validated the pattern and is summarized in Sample validation.

Motivation

✍️ The bug is in the data layer, not the route

A cross-tenant leak is select(ORTask).where(id == request_id) deep in a service, with no vendor_id filter. The fix has to live where the query executes — the database — or it has to be re-remembered at every one of hundreds of call sites.

⚖️ Writes are the majority and the hard part

68% of the genuine bugs are POST/PUT/DELETE. A read-scoping mechanism (with_loader_criteria, USING) is SELECT-only by construction — it needs a second, different mechanism bolted on for writes. Two mechanisms, two ways to drift.

🕳 Raw SQL is the silent gap

~174 session.execute(text(...)) sites bypass the ORM entirely. An ORM-listener control can never see them; the next dev who reaches for raw SQL for performance silently re-opens the hole. Only a DB-level control is blind to how the query was written.

Proposal

One control: every tenant-scoped table gets a restrictive FOR ALL policy keyed on a per-request session GUC. The web app runs under a role that is subject to RLS. The GUC is set once per request transaction. Nothing per-endpoint.

mermaid
flowchart LR
    REQ["HTTP request"] --> GUC["before_request → SET LOCAL app.vendor_id / app.account_id<br/>(from resolve_scope(current_user))"]
    GUC --> Q1["ORM query"]
    GUC --> Q2["raw text() query"]
    GUC --> Q3["bulk update/delete"]
    Q1 --> DB[(Postgres)]
    Q2 --> DB
    Q3 --> DB
    DB --> POL["RLS: restrictive FOR ALL<br/>USING (tenant = GUC)  +  WITH CHECK (tenant = GUC)"]
    POL --> OUT["cross-tenant rows: invisible (read) / rejected (write)"]
    style GUC fill:#e6f3ff,color:#000
    style POL fill:#e6f3ff,color:#000

Four pieces, three of which already exist in the repo:

  1. Put the app role under RLS. Today the app role owns the tables (owners bypass RLS) and is further waved through by a permissive FOR ALL TO public USING(true) policy. Replace that with ALTER TABLE … FORCE ROW LEVEL SECURITY (or move the app to a dedicated non-owner role) and a restrictive policy scoped to the request GUC. Migrations/admin tooling run under a separate BYPASSRLS role.

  2. Set the GUC per request. A before_request hook emits SET LOCAL "app.vendor_id" = :v (and app.account_id for portal callers) inside the request transaction. SET LOCAL is mandatory under Neon's PgBouncer transaction-mode pooling — this exact pattern already runs for the agent SQL tool (app/api/agent/services/sql_read_service.py). Unset → ''NULL → zero rows: fail-closed, already the migration's default.

  3. Generate policies from the resolver that already exists. resolve_vendor_owner / resolve_customer_owner (app/api/agent/services/sql_rewriter/owner_resolution.py) already map any model to its vendor/customer-owner column, including multi-hop FK walks, with boot-time invariants and a CI gate. The existing app/api/agent/rls_policies/ framework (helpers, registry, drift test) already applies policies to 16 tables. We extend the same machinery to every tenant-scoped table — generated, not hand-written. Multi-hop tenancy (e.g. task → project → vendor) compiles to an EXISTS subquery, or we denormalize vendor_id onto hot tables.

  4. Model the exception as a scope, not a bypass. Borrowed wholesale from the sibling RFC, because it's the right idea: a pure resolve_scope(principal) → frozenset[int] | ALL_VENDORS decides the GUC value. {0} is the self-tenant (membership, never truthiness — vendor 0 is real). Legit cross-tenant principals (ONRAMP_ADMIN, EasyCron, SSO sync, the internal agent, reporting) resolve to a wider set or to a privileged app.bypass_rls GUC, set only by an enumerated, audited list. Principal-less batch jobs use a narrow, audit-logged context.

Current vs proposed

The web-app role becomes subject to a restrictive FOR ALL policy — reads and writes, one statement:

sql
ALTER TABLE "<t>" FORCE ROW LEVEL SECURITY;
CREATE POLICY "<t>_tenant" ON "<t>" AS RESTRICTIVE FOR ALL TO app_role
  USING      (vendor_id = current_setting('app.vendor_id', true)::int)   -- read + update/delete visibility
  WITH CHECK (vendor_id = current_setting('app.vendor_id', true)::int);  -- insert/update target tenant

USING hides cross-tenant rows from SELECT/UPDATE/DELETE; WITH CHECK rejects an INSERT/UPDATE that would land a row in another tenant. Same fail-closed GUC. The migration/admin role keeps BYPASSRLS.

Why one mechanism beats two

The sibling RFC's own structure is the argument. It needs L1 read-scoping (with_loader_criteria, SELECT-only) and a separate L2 before_flush write guard (it grades L2 "XL / high effort") — because the read mechanism cannot stop a write. RLS collapses both into one policy, and the thing that makes that work — WITH CHECK — is precisely the write half the sibling RFC says RLS lacks.

ConcernCovered byEffort
Cross-tenant readUSING clause(one policy)
Cross-tenant writeWITH CHECK clause — same policy(one policy)
Over-broad app filterclamped — restrictive policy is ANDed onto every queryfree
Raw text() SQL✅ enforced — DB doesn't care who wrote the queryfree
Bulk delete() / update()✅ enforced at the rowfree

One mechanism, generated per table. The restrictive-policy intersection property is a bonus the ORM approach can't match: even a deliberateWHERE vendor_id IS NOT NULL gets clamped to … AND vendor_id = GUC.

Rebutting the RLS demotion

The sibling RFC rejects RLS-as-primary for two stated reasons. Both describe the current inert configuration, not RLS itself:

“App role has BYPASSRLS + permissive USING(true)”

True today — by design, because RLS was shipped to scope only the agent's read-only role. It's a config choice, not a property of RLS. FORCE ROW LEVEL SECURITY + a restrictive app-role policy removes both the owner-bypass and the permissive escape. The inertness is the thing we're proposing to change.

“USING is SELECT-only — same write asymmetry”

USING is read-side; WITH CHECK is the write side. A FOR ALL policy carries both, so one policy scopes SELECT/UPDATE/DELETE and blocks cross-tenant INSERT/UPDATE. There is no asymmetry — and no need for a separate XL write listener.

Coverage & the residual 19

The systemic policy handles 378. The remaining genuine bugs fall into the same small buckets the ORM approach also can't fully close — so RLS is not worse on the residual, and strictly better everywhere else.

BucketnDisposition
R1 — global tables (no tenant column: AI prompts, webhook schemas, a lookup table)3Add a vendor_id column then RLS covers it, or gate the endpoint to ONRAMP_ADMIN.
R2 — path / external (SSO sync, Prismatic exec, Bedrock session, executor namespace)4Thin route guard comparing URL-path tenant to session — the one place a vendor-limit middleware earns its keep.
R3 — object ACL (within-tenant ownership on detail/mutate paths)12Enforce the existing object_access grant/owner checks on detail + mutate paths.
R4 — likely false-positive (truthiness on project_id/task_id/playbook_idBIGSERIAL ≥ 1, can't be 0)224Bulk-triage to false_positive; not security bugs. The CI sentinel test_no_vendor_zero_regression.py already scopes only the real *_id-can-be-0 set.

Sample validation

12 defects were deep-read (actual vulnerable code) to validate the keyword classifier. RLS-prevented: unscoped reads of a tenant-scoped row by request id; write-triggers that mutate a row fetched without a tenant filter; vendor_id=0 truthiness cases (the DB enforces despite the app's skipped filter); and a deliberate over-broad WHERE vendor_id IS NOT NULL that the restrictive intersection clamps. Confirmed residual: one global table (R1), one path/external side-effect (R2), and two within-tenant object-ACL bugs (R3). The sample's split matched the population classifier.

Where the ORM layer still earns a place

This is RLS-primary, not RLS-only. Postgres enforcement returns "0 rows" or a policy violation — correct, but a poor developer/UX signal. So keep a thin ORM layer, inverted from the sibling RFC:

  • Keep L3 (bind allowlist) and L4 (default-deny auth) as-is — they're orthogonal to RLS and both RFCs want them.
  • Demote L1/L2 from primary enforcement to an optional before_flush/error shim that turns a would-be RLS violation into a clean 403 CrossTenantWrite with context — UX, not the wall. If it regresses, RLS still holds the line.

This shim is one centralized mechanism, never per-endpoint — endpoints carry only business logic (idiot-proof for human and agent authors alike). A single SQLAlchemy hook (handle_error / do_orm_execute) catches the RLS denial — the Postgres insufficient_privilege error (SQLSTATE 42501) on a blocked write, or the empty-result case on a read — and raises a typed CrossTenantError that one Flask error handler maps to a 403. The request GUC is set the same way: one before_request hook from resolve_scope(current_user), never in a route. This deliberately reuses the exact session-event hook points the sibling RFC's L1/L2 propose — inverted: RLS does the enforcing, the listener only translates the failure into a good error.

Alternatives considered

  • ORM session listeners as the primary control (the sibling RFC) — rejected as primary: two mechanisms for reads vs writes, no raw-SQL coverage (it concedes RLS is needed anyway as the backstop), and it can't clamp an over-broad app filter. Excellent as the error-ergonomics shim on top of RLS.
  • Route middleware enforcing a vendor limit — rejected as primary: a before_request can't see which model/column a deep service query targets. Only works for the R2 bucket (tenant id in the URL) — where this RFC does use it.
  • Keep merging point-fixes one at a time — rejected: every merge is a fresh vendor_id=0 regression risk and prevents nothing future.

Risks & open questions

HighFail-closed breaks a forgotten cross-tenant flow

With the app role under RLS, any legitimate cross-tenant path not enumerated in resolve_scope (cron, SSO, agent, reporting, migrations) returns zero rows — an outage, not a leak.

Mitigation: enumerate the principals first (the inventory exists — JWT service:*, EasyCron shared-secret, ONRAMP_ADMIN, internal agent); ship in shadow against the self-tenant test users (vendor_id=0, ~500 portal accounts in stage); roll table-by-table behind the existing drift test.

Highvendor_id = 0 self-tenant

Any truthiness on the GUC or the policy predicate bricks the OnRamp self-tenant; dev seed (vendor 1) hides it.

Mitigation: membership, never truthiness; cast current_setting(...,true)::int so ''→NULL→deny but '0'→0 works; pin a vendor_id=0 fixture in the policy test.

MedMulti-hop predicate cost

Tables that reach vendor only via a parent (task → project) need an EXISTS subquery in the policy, evaluated per row.

Mitigation: denormalize vendor_id onto hot child tables (the resolver already knows which are multi-hop); benchmark the top query paths before enforce.

MedRole / ownership split + local dev

FORCE ROW LEVEL SECURITY or a non-owner app role changes migrations and local ergonomics.

Mitigation: dedicated BYPASSRLS migration role; a dev helper that sets the GUC in shells; document the two-role model in the runbook.

Open questions

Confirm the 197 MED-confidence RLS-CATCHES. They're classified by title/pattern keyword. A deep-read pass would firm up the 95% number — though the 12-sample validation and the structural argument already support it.

Denormalize vs EXISTS per multi-hop table. Which hot child tables (ORTaskStep, ORModuleMapping, ORProjectUserMapping, …) get a denormalized vendor_id vs a subquery predicate?

Customer/account axis policies. Portal tables scope on account as well as vendor. Appendix A3·B proposes "vendor always, account only when the caller is a portal user" — confirm that shape against the resolver's CustomerOwner output and the multi-hop account cases (e.g. or_tasks).

How loud should denials be? Decide the contract for surfacing an RLS violation as a 403 (the optional ORM shim) vs letting "0 rows" propagate.

Appendix: schema audit & policy plan

Grounded in the repo at time of writing. Read it as two coverage layers: the resolver-backed registry (exact, already proven in code) and a full-schema census (approximate — a sweep of all ORM models; the authoritative per-table class is whatever the owner resolver returns). The census tells us the scale; the resolver tells us the answer.

A1 · What already exists vs. what's left

The mechanism is built. The gap is who it applies to and how many tables are registered.

LayerScope todayState
Column-classification gate (every ORM column carries a Public/Internal/VendorOwner/… marker)all ~180 modelsenforced at boot + CI (test_every_column_on_orm_model_is_classified)
Owner resolution (resolve_vendor_owner / resolve_customer_owner → tenant column or FK hop)46 allowlisted tablesproven; the walk generalizes by construction (max hop depth 5, bridge map for relationship hops)
RLS policies installed17 tableslive — but scope only or_readonly, and only FOR SELECT
Web-app role subject to RLS0this RFC

So the work is: (1) flip the app role to be RLS-subject, (2) turn each existing FOR SELECT TO or_readonly policy into a FOR ALL TO app_role policy with WITH CHECK, and (3) run the resolver over the remaining tenant tables and register them. (1) and (2) are config + a helper change; (3) is mechanical — every candidate already has a fully classified column set.

A2 · Census — tables by tenancy shape

Approximate counts from a sweep of ~180 ORM models. Exact per-table class is produced by resolve_vendor_owner / resolve_customer_owner; the buckets below are the kinds of predicate we'll generate.

Bucket~CountPolicy approachDifficulty
A · Direct vendor (own vendor_id / vendor column)~82one-line equality predicatetrivial
B · Account / dual axis (portal-scoped, often vendor and account)~5dual-GUC predicate (vendor always; account when caller is a portal user)low
C · Multi-hop child (vendor only via a parent FK)~14IN (SELECT … FROM parent …) subquery, or denormalize vendor_idmedium
D · Mapping / junction (2+ parent FKs, no own tenant column)~24OR over per-parent subqueries (fail-closed on orphans)medium
E · Global / reference (no tenant linkage — type/status/lookup tables)~39no restrictive policy; explicit allow-read, admin-gated writestrivial
F · Unclear (no resolver pass run yet)~24resolve → falls into A–Emedium

The "F" bucket is a work item, not a blocker. Each already passes the column-classification gate; we just haven't run owner-resolution on the non-allowlisted models. And _build_table_entry's Edge-2 check already refuses to boot if a non-global table exposes columns without a resolvable vendor owner — so for the 46 allowlisted tables, "has a tenant owner" is a proven invariant, not a hope. Extending that guarantee to all ~180 is the registration task.

A3 · Policy templates

All four follow the existing generated shape (verbatim predicates from app/api/agent/rls_policies/), with two changes per the proposal: FOR ALL (not FOR SELECT), and a WITH CHECK mirroring the USING clause. Column names differ per table (or_projects.vendor vs or_comment.vendor_id vs or_vendors.id) — which is exactly why these are generated from the resolver, not hand-written.

A · Direct vendor (e.g. or_comment, or_ramps, or_resources):

sql
ALTER TABLE or_comment ENABLE ROW LEVEL SECURITY;
ALTER TABLE or_comment FORCE ROW LEVEL SECURITY;          -- owner no longer bypasses
CREATE POLICY or_comment_tenant ON or_comment AS RESTRICTIVE FOR ALL TO app_role
  USING      (vendor_id = current_setting('app.vendor_id', true)::int)
  WITH CHECK (vendor_id = current_setting('app.vendor_id', true)::int);

B · Dual axis (portal table with both a vendor and an account column, e.g. or_projects): always clamp to vendor; additionally clamp to account only when the caller is a portal/customer user (the app.is_customer_user / app.customer_id GUCs already exist and are set today):

sql
CREATE POLICY or_projects_tenant ON or_projects AS RESTRICTIVE FOR ALL TO app_role
  USING (
    vendor = current_setting('app.vendor_id', true)::int
    AND (
      current_setting('app.is_customer_user', true)::bool IS NOT TRUE
      OR account = current_setting('app.customer_id', true)::int
    )
  )
  WITH CHECK ( … same … );

(For a table that reaches the account axis only via a parent — e.g. or_tasks through project.account — the account clause becomes an EXISTS subquery, per bucket C.)

C · Multi-hop child (vendor via parent FK — the existing or_project_user_mappings template, now FOR ALL):

sql
CREATE POLICY or_task_steps_tenant ON or_task_steps AS RESTRICTIVE FOR ALL TO app_role
  USING (
    or_task_id IN (
      SELECT id FROM or_tasks
      WHERE vendor = current_setting('app.vendor_id', true)::int
        AND deleted_at IS NULL
    )
  )
  WITH CHECK ( … same … );

D · Mapping / junction (two parents — the existing or_module_mappings template; row visible if either parent is in-tenant, orphan rows fail closed):

sql
CREATE POLICY or_module_mappings_tenant ON or_module_mappings AS RESTRICTIVE FOR ALL TO app_role
  USING (
    or_playbook_id IN (SELECT id FROM or_playbooks WHERE vendor = current_setting('app.vendor_id', true)::int AND deleted_at IS NULL)
    OR or_project_id IN (SELECT id FROM or_projects  WHERE vendor = current_setting('app.vendor_id', true)::int AND deleted_at IS NULL)
  )
  WITH CHECK ( … same … );

E · Global / reference: do not FORCE ROW LEVEL SECURITY; leave readable by all tenants. Writes to these tables are schema/admin operations — gate them to the BYPASSRLS migration/admin role, not the request path.

A4 · Tables that need a vendor_id added

Two reasons to add a column, both small and mechanical:

  1. Tenant-ize a "global" table that actually holds tenant data (the R1 residual: AI prompts, webhook schemas, one lookup table). Add vendor_id, backfill from the owning row, then it drops into bucket A. The alternative is gating the endpoint to ONRAMP_ADMIN — fine where the data really is shared.

  2. Denormalize for performance on hot multi-hop children. A per-row EXISTS in the policy is fine for cold tables, costly on high-traffic ones. Add a denormalized vendor_id (kept current by the write path or a trigger) so the policy is a plain equality. Candidates from the census: or_task_steps, or_activity_email, or_folder_object_mapping, or_event, plus the existing mapping tables if they get hot. The resolver already knows which tables are multi-hop, so this list is generated, not guessed.

A5 · Mapping tables — the strategy in one place

  • Default: scope through the parent(s) with an IN (SELECT id FROM parent WHERE vendor = GUC AND deleted_at IS NULL) subquery — the pattern already shipped for or_project_user_mappings (one parent) and or_module_mappings (two parents, OR-combined).
  • Orphans fail closed: a row with all parent FKs NULL yields NULL IN (…)FALSE → invisible. That's the desired default for a junction row with no owner.
  • WITH CHECK on writes: the same subquery on WITH CHECK blocks inserting a mapping row that points at another tenant's parent — the write half the SELECT-only or_readonly policy can't do today.
  • Denormalize when hot (A4 #2): a junction table on a hot path gets its own vendor_id so the policy is an equality, not two subqueries.
  • Many-to-many across tenants is the one place to think: if a mapping legitimately joins rows from different vendors (rare — e.g. an integrator-org bridge), the OR shape already allows it; encode the intended visibility explicitly rather than relying on the default.

A6 · Global / reference tables

~39 tables carry no tenant linkage (status codes, field types, integration-type enums, etc.). They stay readable by everyone and must not be forced under a restrictive policy or they'd deny all reads. Two guardrails:

  • Writes are admin-only — they're schema-shaped data; route mutations through the BYPASSRLS role, never the request path.
  • Verify none secretly carry tenant data before exempting. For the 46 allowlisted tables the Edge-2 invariant already guarantees this; for the rest, the resolver pass in A2/F is the check — a "global" table that turns out to have an owner column moves to bucket A.

A7 · Role & GUC plumbing

  • Make the app role RLS-subject. Either ALTER TABLE … FORCE ROW LEVEL SECURITY (owner stops bypassing) or move the app to a dedicated non-owner app_role without BYPASSRLS. Migrations and admin tooling run under a separate BYPASSRLS role.
  • Set the GUCs per request. A before_request hook issues SET LOCAL "app.vendor_id" = :v (plus app.customer_id / app.is_customer_user for portal callers) inside the request transaction — the exact SET LOCAL pattern already running in app/api/agent/services/sql_read_service.py, mandatory under PgBouncer transaction-mode pooling. Values come from resolve_scope(current_user). GUCs already default to '' at the database level → unset → NULL → zero rows.
  • Extend the drift test. test_rls_policy_drift already asserts every registered policy exists and RLS is enabled. Add assertions that each tenant table is FORCEd, carries a FOR ALL policy for app_role, and that the policy has a non-trivial WITH CHECK.

A8 · Generation & rollout

  • Extend apply_policy (one helper) to emit, alongside today's or_readonly SELECT policy, a RESTRICTIVE FOR ALL TO app_role policy whose WITH CHECK mirrors its USING. The migration already iterates the registry, so new tables are just new registry entries.
  • Register the rest. Run resolve_vendor_owner / resolve_customer_owner over every model; auto-generate a PolicyDefinition per tenant-scoped table (A/B/C/D), skip globals (E). This converts the ~46 + 17 head start into full coverage.
  • Roll out table-by-table. Postgres RLS has no log-only/dry-run mode, so de-risk by: enabling on staging first against the self-tenant fixtures (vendor_id=0 and the portal accounts), pinning a vendor_id=0 row in the policy test, and flipping one table at a time behind the drift test rather than a big-bang migration.
  • Flip opt-in → opt-out (secure by default). The migration vehicle is a dedicated DB role: stand up the RLS-subject app_role, enable policies table-by-table, and validate traffic routed through it (opt-in — only the new role is enforced). Once a table is validated, the end state makes that role the app default under FORCE ROW LEVEL SECURITY (opt-out — a forgotten or unmigrated table is denied, not open; the only escape is the enumerated BYPASSRLS / scope GUC). Keep the opt-in window short: any endpoint still on the old bypass role is unprotected until the flip, so track per-table migration status. To guarantee the flip actually completes, the drift test gates it — CI fails if any tenant table is left permissive or un-FORCEd, so "secure by default" is enforced, not remembered. The non-owner app_role is the permanent model, not just a migration step; BYPASSRLS stays reserved for migrations/admin tooling.

Internal documentation — gated behind Cloudflare Access.