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.
🔀 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
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.
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.
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 class | Count | % of live | What it means |
|---|---|---|---|
| RLS-CATCHES | 378 | 60.9% | Tenant-scoped table, missing/0-bypassed scope → policy returns/blocks zero cross-tenant rows |
| R1 — global table | 3 | 0.5% | No tenant column (AI prompts, webhook schemas) → RLS can't scope |
| R2 — path / external | 4 | 0.6% | URL-path tenant id drives an external side-effect before any DB row is touched |
| R3 — object ACL | 12 | 1.9% | Object is in-tenant; bug is missing per-object/owner authorization |
| R4 — likely false-positive | 224 | 36.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
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.
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.
~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.
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:#000Four pieces, three of which already exist in the repo:
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 withALTER 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 separateBYPASSRLSrole.Set the GUC per request. A
before_requesthook emitsSET LOCAL "app.vendor_id" = :v(andapp.account_idfor portal callers) inside the request transaction.SET LOCALis 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.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 existingapp/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 anEXISTSsubquery, or we denormalizevendor_idonto hot tables.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_VENDORSdecides the GUC value.{0}is the self-tenant (membership, never truthiness — vendor0is real). Legit cross-tenant principals (ONRAMP_ADMIN, EasyCron, SSO sync, the internal agent, reporting) resolve to a wider set or to a privilegedapp.bypass_rlsGUC, 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:
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 tenantUSING 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.
| Concern | Covered by | Effort |
|---|---|---|
| Cross-tenant read | USING clause | (one policy) |
| Cross-tenant write | WITH CHECK clause — same policy | (one policy) |
| Over-broad app filter | ✅ clamped — restrictive policy is ANDed onto every query | free |
Raw text() SQL | ✅ enforced — DB doesn't care who wrote the query | free |
Bulk delete() / update() | ✅ enforced at the row | free |
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:
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 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.
| Bucket | n | Disposition |
|---|---|---|
| R1 — global tables (no tenant column: AI prompts, webhook schemas, a lookup table) | 3 | Add 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) | 4 | Thin 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) | 12 | Enforce the existing object_access grant/owner checks on detail + mutate paths. |
R4 — likely false-positive (truthiness on project_id/task_id/playbook_id — BIGSERIAL ≥ 1, can't be 0) | 224 | Bulk-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 clean403 CrossTenantWritewith 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_requestcan'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=0regression risk and prevents nothing future.
Risks & open questions
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.
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.
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.
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.
| Layer | Scope today | State |
|---|---|---|
Column-classification gate (every ORM column carries a Public/Internal/VendorOwner/… marker) | all ~180 models | enforced 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 tables | proven; the walk generalizes by construction (max hop depth 5, bridge map for relationship hops) |
| RLS policies installed | 17 tables | live — but scope only or_readonly, and only FOR SELECT |
| Web-app role subject to RLS | 0 | this 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 | ~Count | Policy approach | Difficulty |
|---|---|---|---|
A · Direct vendor (own vendor_id / vendor column) | ~82 | one-line equality predicate | trivial |
| B · Account / dual axis (portal-scoped, often vendor and account) | ~5 | dual-GUC predicate (vendor always; account when caller is a portal user) | low |
| C · Multi-hop child (vendor only via a parent FK) | ~14 | IN (SELECT … FROM parent …) subquery, or denormalize vendor_id | medium |
| D · Mapping / junction (2+ parent FKs, no own tenant column) | ~24 | OR over per-parent subqueries (fail-closed on orphans) | medium |
| E · Global / reference (no tenant linkage — type/status/lookup tables) | ~39 | no restrictive policy; explicit allow-read, admin-gated writes | trivial |
| F · Unclear (no resolver pass run yet) | ~24 | resolve → falls into A–E | medium |
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):
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):
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):
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):
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:
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 toONRAMP_ADMIN— fine where the data really is shared.Denormalize for performance on hot multi-hop children. A per-row
EXISTSin the policy is fine for cold tables, costly on high-traffic ones. Add a denormalizedvendor_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 foror_project_user_mappings(one parent) andor_module_mappings(two parents,OR-combined). - Orphans fail closed: a row with all parent FKs
NULLyieldsNULL IN (…)→FALSE→ invisible. That's the desired default for a junction row with no owner. WITH CHECKon writes: the same subquery onWITH CHECKblocks inserting a mapping row that points at another tenant's parent — the write half the SELECT-onlyor_readonlypolicy can't do today.- Denormalize when hot (A4 #2): a junction table on a hot path gets its own
vendor_idso 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
ORshape 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
BYPASSRLSrole, 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-ownerapp_rolewithoutBYPASSRLS. Migrations and admin tooling run under a separateBYPASSRLSrole. - Set the GUCs per request. A
before_requesthook issuesSET LOCAL "app.vendor_id" = :v(plusapp.customer_id/app.is_customer_userfor portal callers) inside the request transaction — the exactSET LOCALpattern already running inapp/api/agent/services/sql_read_service.py, mandatory under PgBouncer transaction-mode pooling. Values come fromresolve_scope(current_user). GUCs already default to''at the database level → unset →NULL→ zero rows. - Extend the drift test.
test_rls_policy_driftalready asserts every registered policy exists and RLS is enabled. Add assertions that each tenant table isFORCEd, carries aFOR ALLpolicy forapp_role, and that the policy has a non-trivialWITH CHECK.
A8 · Generation & rollout
- Extend
apply_policy(one helper) to emit, alongside today'sor_readonlySELECT policy, aRESTRICTIVE FOR ALL TO app_rolepolicy whoseWITH CHECKmirrors itsUSING. The migration already iterates the registry, so new tables are just new registry entries. - Register the rest. Run
resolve_vendor_owner/resolve_customer_ownerover every model; auto-generate aPolicyDefinitionper 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=0and the portal accounts), pinning avendor_id=0row 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 underFORCE ROW LEVEL SECURITY(opt-out — a forgotten or unmigrated table is denied, not open; the only escape is the enumeratedBYPASSRLS/ 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-ownerapp_roleis the permanent model, not just a migration step;BYPASSRLSstays reserved for migrations/admin tooling.