Skip to content

fix(driver-sql): route every read door through the tenant chokepoint (#6792) - #6908

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-6792-tenant-scope-read-doors
Aug 9, 2026
Merged

fix(driver-sql): route every read door through the tenant chokepoint (#6792)#6908
os-zhuang merged 2 commits into
mainfrom
claude/issue-6792-tenant-scope-read-doors

Conversation

@claude

@claudeclaudeBot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes#6792

What was open

SqlDriver.applyTenantScope() owns read-side tenant isolation for the whole SQL family:
the tenantId early-out, the "object has no tenant field" early-out, the NULL-org
platform-row rule (#2734) and the ADR-0105 D2 union posture (#3623). Its own docstring
said "every CRUD method routes through it". Nothing ever checked that, and it was false
for as long as it had existed.

Three read doors built their query through getBuilder() and never arrived.

DoorReturnsWhat it did
findWithWindowFunctions()ROWSReturned every tenant's rows to a caller that passed options.tenantId.
analyzeQuery() / explain()PLANCompiled a statement find() would never run.
distinct()VALUESReturned one column's values across every tenant.

The third was in no card — see "What the card got wrong" below.

The exposure, measured end to end

The filing was explicit that it had not done this ("not measured end-to-end"), so this PR
does. packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts seeds two
tenants plus one NULL-org platform row and reads through each door. On main @ 6595262,
before any line moved:

find {} tenantId=org_a -> [a1, a2, p1] (3 rows — correct)
window {} tenantId=org_a -> [a1, a2, b1, b2, p1] (5 rows — org_b's rows)
distinct 'name' tenantId=org_a -> [A1, A2, B1, B2, P1] (org_b's values)
analyze {} tenantId=org_a -> select * from `os6792_account`
find {} tenantId=org_a -> select * from `os6792_account`
where (`organization_id` = ? or `organization_id` is null)
order by `id` asc

21 assertions: 9 red before, all green after.

The fix

Each door now calls this.applyTenantScope(builder, object, options) beside its
getBuilder() line — the position findRows() uses. They route through the chokepoint
rather than re-deriving a predicate: a local equality would silently drop NULL-org platform
rows (#2734) and collapse group reads to active-org reach (#3623). Both early-outs are
inherited unchanged, so an unscoped admin/seed read and any object without a tenant field
behave exactly as before — each of those is pinned as its own test.

The two doors are not blurred together. findWithWindowFunctions is a security fix.
analyzeQuery is a correctness fix of lower severity, argued on its own merits in its own
comment: it is the same defect #6577 fixed on these methods one builder line lower, and a
plan missing the tenant predicate has different selectivity and picks different indexes.

The durable half: a gate, not three lines

Both the filing and triage concluded the lasting fix is enforcement — nothing made a new
read door route through the chokepoint, which is how these got out. pnpm check:tenant-chokepoint (scripts/check-tenant-chokepoint.mjs, wired into lint.yml
beside its neighbours) re-derives the invariant from the AST on every run.

Keyed on the builder, not the signature. The card sketches "every method taking
(object, …, options) and returning rows". That criterion is measurably too narrow — it
misses distinct (no query parameter) and analyzeQuery (returns a plan, not rows), two
of the three real doors. getBuilder() is the single constructor of every statement this
driver sends, so the gate classifies every builder; one it cannot classify is a fatal
error, never a pass (#4690's family). Insert builders are exempt structurally — write-side
tenancy is injectTenantOnInsert — not by a name list.

Evidence it works, not just that it is green:

ExperimentResult
Pre-fix treeRED — names all three doors
Row door fixed, plan door notRED — names analyzeQuery only
A newly-added unscoped doorRED — names findRecentlyTouched()
That same door, scopedGREEN — 20 bindings
--self-test6 reporting shapes, 6 silent counterparts, both directions

A limitation, stated rather than left to be discovered. The gate asserts the call
exists on that binding, never where it sits. Measured: with the call relocated below
builder.toSQL() and below await builder, the gate reported clean (19/19) while nine
assertions in the fixture went red. Making it position-aware would have it re-implement
knex's evaluation order from the AST and be wrong in a new way. The gate proves the call
is there; only the fixture proves it works
— both are in this PR, and neither is
redundant. This is written into the script header.

What the card got wrong

distinct() was found by measuring the invariant rather than re-reading it, and the card
says the opposite: it lists distinct among the 13 scoped call sites. It is not one —
the 13th read site is aggregate(). The triage comment repeated the count without
re-deriving which methods it covered, and both rounds of PM measurement inherited that
sentence. Two of the three doors were found by a human reading the file for another reason;
the third needed a machine.

distinct() is documented with a runnable example
(content/docs/protocol/objectql/query-syntax.mdx), so it is exposed the same way the
window door is. It is fixed here because the in-scope gate is red without it, and exempting
a live documented disclosure to make my own gate green would be exactly the fake green this
repo's gate conventions exist to prevent.

Frozen drivers (#5499)

No DEBT row, and the reason is a real absence rather than an omission: driver-memory and
driver-mongodb do not use this mechanism at all — neither file contains getBuilder or
applyTenantScope (mongodb enforces its own wall in mongodb-tenancy-guard.ts, a different
shape). The gate's scan set is the SqlDriver family only: driver-sql plus the two
subclasses that inherit the chokepoint, so a new door added one layer down is caught too.

Docs

The three pages that teach these doors with runnable examples now say that tenancy works as
it does on find() — the predicate applies only when the call carries options.tenantId,
and the examples omit it. No content/docs/releases/ edits.

The docstring

It no longer asserts the invariant; it enumerates the doors, explains why the write path is
deliberately a different mechanism, records that the old sentence was false, and names the
gate that now proves it.


🤖 Generated with Claude Code

https://claude.ai/code/session_011p3oMjCGif84dGt4zBQLxL


Generated by Claude Code

…#6792)
Not yet measured red/green and no fix applied — checkpoint only, so the
work survives container reclamation during a scheduled pause.
…6792)
`applyTenantScope()` claimed to be the single chokepoint every CRUD method
routes through. Nothing checked it, and it was false: three read doors built
through `getBuilder()` and never arrived.
- `findWithWindowFunctions()` returns ROWS — a caller passing `options.tenantId`
got every tenant's rows. Measured with two tenants seeded: `[a1,a2,b1,b2,p1]`
here against `find()`'s `[a1,a2,p1]`.
- `analyzeQuery()`/`explain()` returns a PLAN whose statement `find()` would
never run — the same defect #6577 fixed on these methods one builder line
lower, argued on its own merits rather than folded into the row door's.
- `distinct()` returns one column's VALUES for every tenant. In no card; #6792
states the opposite. The 13th read site is `aggregate()`, not `distinct`.
Found by measuring the invariant rather than re-reading it.
All three now call `applyTenantScope` beside their `getBuilder()` line, the
position `findRows()` uses — routing through the chokepoint rather than
re-deriving a predicate, since a local equality drops NULL-org platform rows
(#2734) and collapses the group posture (#3623). Both early-outs are inherited,
so unscoped admin reads and objects with no tenant field are unchanged.
The durable half is `pnpm check:tenant-chokepoint`, wired into lint.yml: it
re-derives the invariant from the AST across the SqlDriver family. Keyed on the
BUILDER, not the method signature — the signature criterion the card sketches
misses `distinct` (no query parameter) and `analyzeQuery` (returns a plan).
Verified red on the pre-fix tree, red on a newly-added unscoped door, silent
once that door is scoped. It asserts the call EXISTS, never where it sits, so
the fixture is what proves the call works; that limit is stated in its header.
The docstring no longer asserts the invariant — it names the gate that proves it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p3oMjCGif84dGt4zBQLxL
@vercel

vercelBot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 9, 2026 3:33am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via @objectstack/driver-sql)
  • content/docs/getting-started/glossary.mdx(via @objectstack/driver-sql)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/driver-sql)
  • content/docs/plugins/anatomy.mdx(via @objectstack/driver-sql)
  • content/docs/plugins/packages.mdx(via @objectstack/driver-sql)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/driver-sql)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/driver-sql)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/driver-sql)
  • content/docs/releases/implementation-status.mdx(via @objectstack/driver-sql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file tests tooling labels Aug 9, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 9, 2026 03:52
@os-zhuang
os-zhuang added this pull request to the merge queueAug 9, 2026
Merged via the queue into main with commit bee5ffeAug 9, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6792-tenant-scope-read-doors branch August 9, 2026 04:08
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

2 participants

@os-zhuang@claude