|
| 1 | +--- |
| 2 | +'@objectstack/driver-sql': patch |
| 3 | +'@objectstack/driver-sqlite-wasm': patch |
| 4 | +'@objectstack/driver-turso': patch |
| 5 | +--- |
| 6 | + |
| 7 | +drivers: every SQL read door routes through the tenant chokepoint (#6792) |
| 8 | + |
| 9 | +`SqlDriver.applyTenantScope()` owns read-side tenant isolation for the whole SQL family — |
| 10 | +the `tenantId` early-out, the "object has no tenant field" early-out, the NULL-org |
| 11 | +platform-row rule (#2734) and the ADR-0105 D2 union posture (#3623). Its own docstring |
| 12 | +said "every CRUD method routes through it". Nothing ever checked that, and it was false |
| 13 | +for as long as it had existed. **Three** read doors built their query through |
| 14 | +`getBuilder()` and never arrived: |
| 15 | + |
| 16 | +- **`findWithWindowFunctions()`** — the documented #4286 window door. It returns **rows**, |
| 17 | + so on a deployment where the scope would have applied (`options.tenantId` set, object |
| 18 | + has a tenant field) it returned rows belonging to **every** tenant. Measured with two |
| 19 | + tenants seeded plus one NULL-org platform row: `tenantId: 'org_a'` returned |
| 20 | + `[a1, a2, b1, b2, p1]` here against `find()`'s `[a1, a2, p1]` — another tenant's rows, |
| 21 | + handed over at the driver layer. |
| 22 | +- **`analyzeQuery()` / `explain()`** — returns a **plan**, not rows, so this is a smaller |
| 23 | + fix and it is made on its own merits rather than folded into the one above. It is the |
| 24 | + same defect #6577 fixed on these two methods one builder line lower: a plan is only |
| 25 | + worth reading if it explains the statement `find()` would actually run, and a missing |
| 26 | + tenant predicate changes selectivity and therefore which index the planner picks. |
| 27 | + Compiled `select * from account` where `find()` sent the `organization_id` clause. |
| 28 | +- **`distinct()`** — returns one column's **values** for every tenant. This one was in no |
| 29 | + card. #6792 states the opposite, listing `distinct` among the scoped call sites; the |
| 30 | + 13th read site is `aggregate()`. It was found by measuring the invariant rather than |
| 31 | + re-reading it. |
| 32 | + |
| 33 | +All three now call `applyTenantScope()` beside their `getBuilder()` line, the position |
| 34 | +`findRows()` uses. They route through the chokepoint rather than re-deriving a predicate: |
| 35 | +a local equality would silently drop NULL-org platform rows (#2734) and collapse group |
| 36 | +reads to active-org reach (#3623). Both of the chokepoint's early-outs are inherited |
| 37 | +unchanged, so an unscoped admin/seed read (no `tenantId`) and any object without a tenant |
| 38 | +field behave exactly as before. |
| 39 | + |
| 40 | +**The durable half is a gate, not the three lines.** `pnpm check:tenant-chokepoint` |
| 41 | +(`scripts/check-tenant-chokepoint.mjs`, wired into `.github/workflows/lint.yml`) re-derives |
| 42 | +the invariant from the AST across the `SqlDriver` family on every run: a method that builds |
| 43 | +through `getBuilder(object, options)` must call `applyTenantScope()` on that builder, or |
| 44 | +carry a written exemption. Insert builders are exempt structurally — write-side tenancy is |
| 45 | +`injectTenantOnInsert` — rather than by a name list. It is keyed on the **builder** and not |
| 46 | +on the method signature, because the signature criterion the card sketches ("takes |
| 47 | +`(object, …, options)` and returns rows") misses `distinct` (no `query` parameter) and |
| 48 | +`analyzeQuery` (returns a plan). Verified red against the pre-fix tree, red against a |
| 49 | +newly-added unscoped door, and silent once that door is scoped. |
| 50 | + |
| 51 | +The chokepoint docstring no longer asserts the invariant; it names the gate that proves it. |
| 52 | + |
| 53 | +If you call these doors directly on a multi-tenant deployment, pass `options.tenantId` as |
| 54 | +you would to `find()` — that is what now takes effect. Callers that never passed it are |
| 55 | +unaffected; that remains the documented unscoped/admin path. |
0 commit comments