Skip to content

Commit 4b59a38

Browse files
fix(driver-turso)!: the remote face's doors carry the caller's tenant scope, and a remote create stamps the organization (#21226) (#21245)
Fixes #21226 Clause-②: no (narrowing) The remote (libSQL) face of `TursoDriver` now applies the caller's tenant scope at every door that reads rows or picks rows to write, and its `create` stamps the caller's organization. Each door now answers the same row set as the local face for the same `DriverOptions`. This body names classes and positions only; the measured detail stays with the seats. ## Reach (step 1 of the card), by class Measured at the REST data doors over a real engine with the security layer installed, remote face against local face, as a member of one organization against another organization's rows, at base `95e24b0096`: - **Walled postures.** The engine's Layer 0 wall held other organizations' rows back at every data door on both faces. The remote `create` diverged: its row landed with no organization. - **The posture in which Layer 0 is inert, and an elevated engine caller that carries its organization.** The driver scope is the only fence here. The remote face answered across organizations where the local face did not. The seat applied triage's raise rule on the card. After this change, the same measurement shows no difference between the faces at any measured door, in all three postures and for the elevated caller. **Step 2.** The hosted cloud topology is one database per environment (ADR-0002). But nothing in the repository keeps a walled multi-organization posture off a remote libSQL primary database, so one organization per remote database is not guaranteed by construction. ## Mechanism - **`TursoDriver.remoteTenantScope`** (`turso-driver.ts`) hands the local face's own chokepoint, `SqlDriver.applyTenantScope`, a bare Knex query builder and compiles what it added. Compiling needs no connection, and the remote face's Knex has none. The predicate is therefore not a copy: the NULL-organization arm, the group posture's `tenantIds` union and the no-tenant-context exit all stay in that one method. If the probe compiles to a shape it cannot read (anything beside `where` terms), the call is refused with `INTERNAL_ERROR` / 500. It is never sent unscoped. - **`RemoteTransport`** (`remote-transport.ts`) takes that fragment as an optional `RemoteTenantScope` on each door and ANDs it onto the statement's `WHERE` (`scopedWhereSQL`, `byIdWhereSQL`). The caller's filter is parenthesized, so a top-level `$or` cannot escape the scope. With no scope, every statement is byte-identical to before. This is the per-call shape the upsert `fence` already uses. That fence is equality-only and cannot carry the NULL arm or the union, so the scope is its own parameter. - **The remote `create`** calls `injectTenantOnInsert` on its copy of the row before the record numbers are issued, as the local `create` does. ## One conclusion per door | door | remote face before | now | |:--|:--|:--| | `find` | unscoped | scoped | | `findOne` | unscoped | scoped | | `count` | unscoped | scoped | | `aggregate` | unscoped | scoped (bounded in-place fix, below) | | `update` (by id, and its read-back) | key only | key AND scope; out of scope answers `null` | | `delete` (by id) | key only | key AND scope; out of scope answers `false` | | `updateMany` / `deleteMany` | the caller's filter only | filter AND scope | | `bulkUpdate` / `bulkDelete` | key / id set only | AND scope (bounded in-place fix, below) | | `create` / `bulkCreate` | no organization stamp | stamps the caller's organization; `bulkCreate` goes through `create` | | `upsert` | already conformant: the fence and the stamp landed in `95e24b009` | unchanged | | `distinct` | refuses a tenant-scoped call | still refuses; answering it would accept a call refused today, a widening this change does not make | | `findWithWindowFunctions`, `analyzeQuery` | refused on the remote face | unchanged | No door refuses instead of scoping: the compiled predicate carries the full local scope, the group posture's union included. **Bounded in-place fix.** The card names `find`, `findOne`, `count`, `update`, `delete`, `updateMany`, `deleteMany` and `create`. `aggregate`, `bulkUpdate` and `bulkDelete` have the same defect, in the same two files. The same helper fixes them, the same file pins them, and the same gate families cover them. Leaving them unscoped would keep the invariant false. Their pins go red with the fix reverted, as listed below. ## Pins `packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts` runs on both faces: a local `TursoDriver`, and a remote one over the SQLite-backed libSQL stub. It uses the option shapes the engine sends when nothing above the driver composes a tenant predicate: `tenantId`, and `tenantId` with `bypassTenantAudit`. It asserts: - **Each door**, as a member of one organization against another organization's rows, answers excluded, `null`, `false` or `0`, or leaves the row untouched on disk. A same-organization control answers as an unscoped call would. - **A platform row with no organization** is inside the scope on both faces. - **A top-level `$or`** in the caller's filter does not escape the scope. - **The group posture's `tenantIds`** scopes to the union and no further. - **`create` and `bulkCreate`** stamp the caller's organization and keep an explicit one. - **A call with no tenant context** reaches every row, as before. - **A scope the remote face cannot read** is refused (`code` and `status`) and writes nothing. - **One script run on both faces** gives equal answers, and they are the scoped ones. **Reverse verification.** The fix was committed first (`e47eee6dcb`). Then the two source files were restored from the base commit, with the pin file kept. The fix was then restored from `HEAD`, proven by blob hash and an empty `git diff HEAD`. - With the fix reverted: 17 failed and 19 passed of 36. Every remote-face door pin, the refusal pin and the parity pin went red. Every local-face pin and the two remote-face controls stayed green. - With the fix restored: 36 of 36 passed. ## The `packages/spec` edit The changeset's FROM → TO is a migration prescription, so the ADR-0087 disposition is `registered driver-remote-doors-tenant-scoped`, following the precedent landed in `95e24b009`: - one semantic entry, `packages/spec/src/migrations/entries/semantic/18.driver-remote-doors-tenant-scoped.ts`; - the `registry.ts` region that `gen:migration-registry` regenerates; - `'@objectstack/spec': patch`. `check:generated` is green before and after. `driver-sql` is not touched. ## Verification (head `71d3bcb35e`, after merging `origin/main`) - **`pnpm --filter @objectstack/driver-turso test`:** 87 files, 2349 passed, 33 skipped (all in files this diff does not touch); exit 0. - **`pnpm --filter @objectstack/driver-turso typecheck`:** exit 0. The pin file is in the program, counted with `--listFiles`. - **`pnpm --filter @objectstack/spec check:generated`:** all 15 artifacts up to date. After the merge, `gen:migration-registry` reproduced `registry.ts` byte for byte. - **Spec:** `vitest run --project local src/migrations` 169 passed; `tsc --noEmit` exit 0. - **`pnpm check:driver-conformance`:** before the first edit and after the last commit, both `OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt`. - **`pnpm check:tenant-chokepoint`:** green before and after (22 bindings across 3 files). - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives 90 families. All 90 ran with exit 0 on this head, and `--ran` reconciles 90 run, 0 NOT MEASURED. - **Lint, narrowed:** `eslint --no-inline-config --format json` over the 5 changed source files. All 5 are in the config's population (no ignored-file warnings), with 0 errors and 0 warnings. The config enables no type-aware linting, so this diff cannot change the verdict on a file it does not touch. The repo-wide `pnpm lint` is CI's. ## Acceptance notes - The remote face's write doors do not call `auditMissingTenant`, which the local face's write doors call. It is a warning log, not the scope, and is outside this card. Not filed; no carrier. - `distinct` could now carry the scope through the same helper. That would turn a refusal into an answer, which is a widening, so it is left for the seat. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d150c30 commit 4b59a38

6 files changed

Lines changed: 834 additions & 49 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
'@objectstack/spec': patch
4+
---
5+
6+
fix(driver-turso)!: a tenant-scoped call on the remote (libSQL) face reaches the rows the local face reaches, and a remote `create` stamps the caller's organization (#21226)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered driver-remote-doors-tenant-scoped -->
11+
12+
**BREAKING for callers of a remote-mode `TursoDriver` that pass `tenantId`.**
13+
14+
**What changed.** The engine hands every driver the caller's organization as
15+
`DriverOptions.tenantId`, and the group posture's membership set as `tenantIds`
16+
(ADR-0131 D8). The local face applies them through `SqlDriver.applyTenantScope`
17+
on every read and on every update and delete predicate, and stamps the
18+
organization on a new row. The remote face's doors received no driver options,
19+
so their statements carried the caller's filter and nothing else. Now:
20+
21+
- **`find`, `findOne`, `count`, `aggregate`, `update`, `delete`, `bulkUpdate`,
22+
`bulkDelete`, `updateMany` and `deleteMany` carry the caller's tenant scope on
23+
the remote face.** The predicate is not a second copy: the remote face asks the
24+
local face's own chokepoint for it and ANDs what that compiles to onto each
25+
statement. So the rows a scoped call reaches are the same on both faces: the
26+
caller's organization, rows with no organization, and, under the group posture,
27+
the caller's membership set.
28+
- **A remote `create` (and `bulkCreate`) stamps the caller's organization** on a
29+
row that names none, as the local `create` does. An explicit value on the row
30+
is kept.
31+
- **`distinct` still refuses a tenant-scoped call on the remote face**, as before.
32+
- A scope the remote face cannot read is refused (`INTERNAL_ERROR` / 500), never
33+
sent without the scope.
34+
35+
Where the engine's tenant wall composes a predicate above the driver, it already
36+
kept other organizations' rows out of these answers. Where it composes none (the
37+
posture in which that wall is inert, or an elevated caller that carries its
38+
organization), the driver scope is the only fence, and the remote face had none.
39+
40+
## FROM → TO
41+
42+
| you relied on | now |
43+
|:--|:--|
44+
| a tenant-scoped remote `find` / `findOne` / `count` / `aggregate` reading another organization's rows | those rows are excluded (`findOne` answers `null`); call without `tenantId` to read every organization, as on the local face |
45+
| a tenant-scoped remote `update` / `delete` by id reaching another organization's row | `update` answers `null` and `delete` answers `false`, and the row is untouched |
46+
| a tenant-scoped remote `updateMany` / `deleteMany` / `bulkUpdate` / `bulkDelete` reaching another organization's rows | only rows in scope are written; the count reports them |
47+
| a tenant-scoped remote `create` landing a row with no organization | the row carries the caller's organization; to write a row with none, call without `tenantId` |
48+
49+
**What is not affected.** A call without `tenantId`, or on an object with no
50+
tenant column, sends the same statement as before. The local and embedded-replica
51+
faces are unchanged. A scoped call's answer for the caller's own rows, and for
52+
rows with no organization, is unchanged.

‎packages/drivers/driver-turso/src/remote-transport.ts‎

Lines changed: 147 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,25 @@ export type DeclaredValueShapeResolver = (object: string, field: string) => Valu
949949
*/
950950
export type JsonColumnResolver = (object: string, field: string) => boolean;
951951

952+
/**
953+
* [#21226] The caller's tenant scope, as ONE compiled predicate: a SQL
954+
* fragment and its bind values, which a scoped door ANDs onto its own `WHERE`.
955+
*
956+
* The driver builds it, and this class never decides it. `TursoDriver` asks the
957+
* local face's own chokepoint, `SqlDriver.applyTenantScope`, for the predicate
958+
* and hands over what that compiles to: the NULL-organization arm, the `group`
959+
* posture's membership set, and the no-tenant-context exit are all that
960+
* method's. This class is told WHICH rows, not WHY, as with the upsert `fence`
961+
* (#21185). Absent means the call carries no scope (no tenant context, or no
962+
* tenant column), and the statement is the one it was before.
963+
*/
964+
export interface RemoteTenantScope {
965+
/** A self-contained boolean SQL expression with `?` placeholders. */
966+
readonly sql: string;
967+
/** The values for the fragment's placeholders, in order. */
968+
readonly args: readonly unknown[];
969+
}
970+
952971
/**
953972
* Remote transport that executes all queries via @libsql/client.
954973
*
@@ -1255,10 +1274,15 @@ export class RemoteTransport {
12551274
// the backend's own `no such table`, and `aggregate` answered an empty list,
12561275
// while the local face of the same driver answered from the mapped table.
12571276

1258-
async find(object: string, query: any, table: string = object): Promise<Record<string, unknown>[]> {
1277+
async find(
1278+
object: string,
1279+
query: any,
1280+
table: string = object,
1281+
scope?: RemoteTenantScope,
1282+
): Promise<Record<string, unknown>[]> {
12591283
await this.ensureConnected();
12601284

1261-
const { sql, args } = this.buildSelectSQL(object, query, table);
1285+
const { sql, args } = this.buildSelectSQL(object, query, table, scope);
12621286

12631287
try {
12641288
const result = await this.client!.execute({ sql, args });
@@ -1302,7 +1326,8 @@ export class RemoteTransport {
13021326
let lastError: unknown = error;
13031327
for (const rung of rungs) {
13041328
try {
1305-
const fallback = this.buildSelectSQL(object, rung, table);
1329+
// [#21226] Each rung keeps the tenant scope, as it keeps the WHERE.
1330+
const fallback = this.buildSelectSQL(object, rung, table, scope);
13061331
const result = await this.client!.execute({ sql: fallback.sql, args: fallback.args });
13071332
return this.mapRows(result);
13081333
} catch (rungError) {
@@ -1328,9 +1353,14 @@ export class RemoteTransport {
13281353
* missing. Neither the contract nor any caller outside these tests spelled it
13291354
* that way, so the branch is gone here too: one driver, one spelling.
13301355
*/
1331-
async findOne(object: string, query: any, table: string = object): Promise<Record<string, unknown> | null> {
1356+
async findOne(
1357+
object: string,
1358+
query: any,
1359+
table: string = object,
1360+
scope?: RemoteTenantScope,
1361+
): Promise<Record<string, unknown> | null> {
13321362
if (query && typeof query === 'object') {
1333-
const results = await this.find(object, { ...query, limit: 1 }, table);
1363+
const results = await this.find(object, { ...query, limit: 1 }, table, scope);
13341364
return results[0] || null;
13351365
}
13361366

@@ -1347,7 +1377,12 @@ export class RemoteTransport {
13471377
* `SqlDriver.aggregate` and `TursoDriver.aggregate` took, because all three are
13481378
* one door and a caller may not be told three different things about it.
13491379
*/
1350-
async aggregate(object: string, query: DriverQuery, table: string = object): Promise<Record<string, unknown>[]> {
1380+
async aggregate(
1381+
object: string,
1382+
query: DriverQuery,
1383+
table: string = object,
1384+
scope?: RemoteTenantScope,
1385+
): Promise<Record<string, unknown>[]> {
13511386
await this.ensureConnected();
13521387
this.assertSafeIdentifier(object);
13531388

@@ -1502,7 +1537,9 @@ export class RemoteTransport {
15021537
let sql = `SELECT ${selectParts.join(', ')} FROM ${this.tableSql(table)}`;
15031538
const args: any[] = [];
15041539

1505-
const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query?.where);
1540+
// [#21226] The tenant scope narrows the rows BEFORE they are grouped, as
1541+
// `SqlDriver.aggregate` scopes its builder.
1542+
const { whereClauses, args: whereArgs } = this.scopedWhereSQL(object, query?.where, scope);
15061543
if (whereClauses) {
15071544
sql += ` WHERE ${whereClauses}`;
15081545
args.push(...whereArgs);
@@ -1626,25 +1663,40 @@ export class RemoteTransport {
16261663
id: string | number,
16271664
data: Record<string, unknown>,
16281665
table: string = object,
1666+
scope?: RemoteTenantScope,
16291667
): Promise<Record<string, unknown> | null> {
16301668
await this.ensureConnected();
16311669

16321670
const columns = Object.keys(data);
16331671
const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
16341672
const values = columns.map((col) => this.serializeValue(data[col]));
16351673

1636-
const sql = `UPDATE ${this.tableSql(table)} SET ${setClauses} WHERE "id" = ?`;
1637-
await this.client!.execute({ sql, args: [...values, id] });
1674+
// [#21226] The key AND, on a scoped call, the tenant scope — on the write
1675+
// and on the read-back, as `SqlDriver.update` scopes both. A row outside
1676+
// the scope is neither written nor read, and the answer is the miss arm
1677+
// (`null`), the local face's answer for it.
1678+
const byId = this.byIdWhereSQL(id, scope);
1679+
const sql = `UPDATE ${this.tableSql(table)} SET ${setClauses} WHERE ${byId.sql}`;
1680+
await this.client!.execute({ sql, args: [...values, ...byId.args] });
16381681

16391682
// Fetch updated row
16401683
const result = await this.client!.execute({
1641-
sql: `SELECT * FROM ${this.tableSql(table)} WHERE "id" = ?`,
1642-
args: [id],
1684+
sql: `SELECT * FROM ${this.tableSql(table)} WHERE ${byId.sql}`,
1685+
args: [...byId.args],
16431686
});
16441687
const rows = this.mapRows(result);
16451688
return rows[0] ?? null;
16461689
}
16471690

1691+
/**
1692+
* [#21226] `"id" = ?`, AND the tenant scope on a scoped call. Unscoped, the
1693+
* clause is the one the by-id doors always sent.
1694+
*/
1695+
private byIdWhereSQL(id: string | number, scope: RemoteTenantScope | undefined): { sql: string; args: any[] } {
1696+
if (!scope) return { sql: `"id" = ?`, args: [id] };
1697+
return { sql: `"id" = ? AND (${scope.sql})`, args: [id, ...scope.args] };
1698+
}
1699+
16481700
/**
16491701
* `insertOnlyColumns` — columns written on the INSERT leg and left alone on
16501702
* the MERGE leg, so a value the caller (or the driver, on its behalf) put on
@@ -1736,9 +1788,11 @@ export class RemoteTransport {
17361788
// `['id']` target the two readings are the same statement.
17371789
const keyColumns = mergeKeys.every((k) => toUpsert[k] !== undefined && toUpsert[k] !== null) ? mergeKeys : ['id'];
17381790
// [#21185] A fenced call reads back under the written organization exactly
1739-
// — this face applies no tenant scope to any other read, and a read-back
1740-
// that found another organization's row would hand its columns to the
1741-
// caller. Not found means the predicate above left the row alone.
1791+
// (the local face's own read-back, not the chokepoint scope the other
1792+
// doors carry since #21226: that scope admits a row with no organization),
1793+
// and a read-back that found another organization's row would hand its
1794+
// columns to the caller. Not found means the predicate above left the row
1795+
// alone.
17421796
const fenceSql = fence ? ` AND "${fence.column}" = ?` : '';
17431797
const fenceArgs = fence ? [this.serializeValue(fence.value)] : [];
17441798
const result = await this.client!.execute({
@@ -1750,19 +1804,31 @@ export class RemoteTransport {
17501804
return rows[0] || toUpsert;
17511805
}
17521806

1753-
async delete(object: string, id: string | number, table: string = object): Promise<boolean> {
1807+
async delete(
1808+
object: string,
1809+
id: string | number,
1810+
table: string = object,
1811+
scope?: RemoteTenantScope,
1812+
): Promise<boolean> {
17541813
await this.ensureConnected();
1814+
// [#21226] The key AND the tenant scope, as `SqlDriver.delete` scopes it.
1815+
const byId = this.byIdWhereSQL(id, scope);
17551816
const result = await this.client!.execute({
1756-
sql: `DELETE FROM ${this.tableSql(table)} WHERE "id" = ?`,
1757-
args: [id],
1817+
sql: `DELETE FROM ${this.tableSql(table)} WHERE ${byId.sql}`,
1818+
args: byId.args,
17581819
});
17591820
return result.rowsAffected > 0;
17601821
}
17611822

1762-
async count(object: string, query?: any, table: string = object): Promise<number> {
1823+
async count(
1824+
object: string,
1825+
query?: any,
1826+
table: string = object,
1827+
scope?: RemoteTenantScope,
1828+
): Promise<number> {
17631829
await this.ensureConnected();
17641830

1765-
const { whereClauses, args } = this.buildWhereSQL(object, query?.where);
1831+
const { whereClauses, args } = this.scopedWhereSQL(object, query?.where, scope);
17661832
let sql = `SELECT COUNT(*) as count FROM ${this.tableSql(table)}`;
17671833
if (whereClauses) sql += ` WHERE ${whereClauses}`;
17681834

@@ -1829,45 +1895,64 @@ export class RemoteTransport {
18291895
object: string,
18301896
updates: Array<{ id: string | number; data: Record<string, unknown> }>,
18311897
table: string = object,
1898+
scope?: RemoteTenantScope,
18321899
): Promise<Record<string, unknown>[]> {
18331900
const results: Record<string, unknown>[] = [];
18341901
for (const { id, data } of updates) {
1835-
const updated = await this.update(object, id, data, table);
1902+
const updated = await this.update(object, id, data, table, scope);
18361903
if (updated) results.push(updated);
18371904
}
18381905
return results;
18391906
}
18401907

1841-
async bulkDelete(object: string, ids: Array<string | number>, table: string = object): Promise<void> {
1908+
async bulkDelete(
1909+
object: string,
1910+
ids: Array<string | number>,
1911+
table: string = object,
1912+
scope?: RemoteTenantScope,
1913+
): Promise<void> {
18421914
await this.ensureConnected();
18431915
if (ids.length === 0) return;
18441916

18451917
const placeholders = ids.map(() => '?').join(', ');
1918+
// [#21226] The id set AND the tenant scope, as `SqlDriver.bulkDelete`.
1919+
const scopeSql = scope ? ` AND (${scope.sql})` : '';
18461920
await this.client!.execute({
1847-
sql: `DELETE FROM ${this.tableSql(table)} WHERE "id" IN (${placeholders})`,
1848-
args: ids as any[],
1921+
sql: `DELETE FROM ${this.tableSql(table)} WHERE "id" IN (${placeholders})${scopeSql}`,
1922+
args: [...ids, ...(scope ? scope.args : [])] as any[],
18491923
});
18501924
}
18511925

1852-
async updateMany(object: string, query: any, data: Record<string, unknown>, table: string = object): Promise<number> {
1926+
async updateMany(
1927+
object: string,
1928+
query: any,
1929+
data: Record<string, unknown>,
1930+
table: string = object,
1931+
scope?: RemoteTenantScope,
1932+
): Promise<number> {
18531933
await this.ensureConnected();
18541934

18551935
const columns = Object.keys(data);
18561936
const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
18571937
const setValues = columns.map((col) => this.serializeValue(data[col]));
18581938

1859-
const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query?.where);
1939+
const { whereClauses, args: whereArgs } = this.scopedWhereSQL(object, query?.where, scope);
18601940
let sql = `UPDATE ${this.tableSql(table)} SET ${setClauses}`;
18611941
if (whereClauses) sql += ` WHERE ${whereClauses}`;
18621942

18631943
const result = await this.client!.execute({ sql, args: [...setValues, ...whereArgs] });
18641944
return result.rowsAffected;
18651945
}
18661946

1867-
async deleteMany(object: string, query: any, table: string = object): Promise<number> {
1947+
async deleteMany(
1948+
object: string,
1949+
query: any,
1950+
table: string = object,
1951+
scope?: RemoteTenantScope,
1952+
): Promise<number> {
18681953
await this.ensureConnected();
18691954

1870-
const { whereClauses, args } = this.buildWhereSQL(object, query?.where);
1955+
const { whereClauses, args } = this.scopedWhereSQL(object, query?.where, scope);
18711956
let sql = `DELETE FROM ${this.tableSql(table)}`;
18721957
if (whereClauses) sql += ` WHERE ${whereClauses}`;
18731958

@@ -2608,16 +2693,21 @@ export class RemoteTransport {
26082693
* `orderBy` is likewise an ANSWER — #4363's carve-out for an unpaged
26092694
* unordered read — and emitting no ORDER BY for it is correct.
26102695
*/
2611-
private buildSelectSQL(object: string, query: any, table: string): { sql: string; args: any[] } {
2696+
private buildSelectSQL(
2697+
object: string,
2698+
query: any,
2699+
table: string,
2700+
scope?: RemoteTenantScope,
2701+
): { sql: string; args: any[] } {
26122702
const fields = query.fields && Array.isArray(query.fields) && query.fields.length > 0
26132703
? query.fields.map((f: string) => `"${this.mapSortField(f)}"`).join(', ')
26142704
: '*';
26152705

26162706
let sql = `SELECT ${fields} FROM ${this.tableSql(table)}`;
26172707
const allArgs: any[] = [];
26182708

2619-
// WHERE
2620-
const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query.where);
2709+
// WHERE — the caller's filter and, on a scoped call, the tenant scope.
2710+
const { whereClauses, args: whereArgs } = this.scopedWhereSQL(object, query.where, scope);
26212711
if (whereClauses) {
26222712
sql += ` WHERE ${whereClauses}`;
26232713
allArgs.push(...whereArgs);
@@ -2712,15 +2802,39 @@ export class RemoteTransport {
27122802
* every refusal that predates it keeps its own wording and its own way of
27132803
* naming a location, so threading this parameter changes no existing message.
27142804
*/
2805+
/**
2806+
* [#21226] The caller's `WHERE`, compiled by {@link buildWhereSQL}, AND the
2807+
* tenant scope the driver handed over. With no scope the answer is
2808+
* {@link buildWhereSQL}'s own, so an unscoped statement is byte-identical to
2809+
* the one this transport sent before. With one, both sides are
2810+
* parenthesized: a top-level `OR` in the caller's filter must not escape the
2811+
* scope. An empty caller filter is vacuously TRUE (#1073), so the scope
2812+
* stands alone.
2813+
*/
2814+
private scopedWhereSQL(
2815+
object: string,
2816+
filters: unknown,
2817+
scope: RemoteTenantScope | undefined,
2818+
): { whereClauses: string; args: any[] } {
2819+
const compiled = this.buildWhereSQL(object, filters);
2820+
if (!scope) return compiled;
2821+
if (!compiled.whereClauses) return { whereClauses: `(${scope.sql})`, args: [...scope.args] };
2822+
return {
2823+
whereClauses: `(${compiled.whereClauses}) AND (${scope.sql})`,
2824+
args: [...compiled.args, ...scope.args],
2825+
};
2826+
}
2827+
27152828
private buildWhereSQL(
27162829
object: string,
27172830
filters: any,
27182831
path = 'where',
27192832
): { whereClauses: string; args: any[] } {
27202833
// [#8220] Resolve a redacted refusal's provenance at the OUTERMOST frame
2721-
// only — `path === 'where'` is true exactly for the six external call
2722-
// sites, and the root they hand over is the tree the read-scope merge
2723-
// boundaries marked. Recursive frames rethrow untouched so one refusal is
2834+
// only — `path === 'where'` is true exactly for the external call sites
2835+
// (`compileDistinct`, and `scopedWhereSQL` for every other door, #21226),
2836+
// and the root they hand over is the caller's own filter, the tree the
2837+
// read-scope merge boundaries marked; the tenant scope is ANDed on after. Recursive frames rethrow untouched so one refusal is
27242838
// resolved once, against the whole tree. Fail-closed like the SqlDriver
27252839
// seam: 'author' swaps in the full text; 'policy', unmarked, unreachable
27262840
// and ambiguous all keep the redaction.

0 commit comments

Comments
 (0)