From 99a86867e01a646f28611b7e4ed1210152247113 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:27:19 +0000 Subject: [PATCH 1/3] fix(driver-turso): the remote face's read and write doors carry the caller's tenant scope, and create stamps the organization The remote doors compile the tenant predicate through the local face's own chokepoint (SqlDriver.applyTenantScope) and AND it onto each statement; the remote create stamps the caller's organization as the local create does. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../driver-turso/src/remote-transport.ts | 180 ++++++++++++++---- .../drivers/driver-turso/src/turso-driver.ts | 171 +++++++++++++++-- 2 files changed, 302 insertions(+), 49 deletions(-) diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 9642fb53666..d96b6cd5de6 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -949,6 +949,25 @@ export type DeclaredValueShapeResolver = (object: string, field: string) => Valu */ export type JsonColumnResolver = (object: string, field: string) => boolean; +/** + * [#21226] The caller's tenant scope, as ONE compiled predicate: a SQL + * fragment and its bind values, which a scoped door ANDs onto its own `WHERE`. + * + * The driver builds it, and this class never decides it. `TursoDriver` asks the + * local face's own chokepoint, `SqlDriver.applyTenantScope`, for the predicate + * and hands over what that compiles to: the NULL-organization arm, the `group` + * posture's membership set, and the no-tenant-context exit are all that + * method's. This class is told WHICH rows, not WHY, as with the upsert `fence` + * (#21185). Absent means the call carries no scope (no tenant context, or no + * tenant column), and the statement is the one it was before. + */ +export interface RemoteTenantScope { + /** A self-contained boolean SQL expression with `?` placeholders. */ + readonly sql: string; + /** The values for the fragment's placeholders, in order. */ + readonly args: readonly unknown[]; +} + /** * Remote transport that executes all queries via @libsql/client. * @@ -1255,10 +1274,15 @@ export class RemoteTransport { // the backend's own `no such table`, and `aggregate` answered an empty list, // while the local face of the same driver answered from the mapped table. - async find(object: string, query: any, table: string = object): Promise[]> { + async find( + object: string, + query: any, + table: string = object, + scope?: RemoteTenantScope, + ): Promise[]> { await this.ensureConnected(); - const { sql, args } = this.buildSelectSQL(object, query, table); + const { sql, args } = this.buildSelectSQL(object, query, table, scope); try { const result = await this.client!.execute({ sql, args }); @@ -1302,7 +1326,8 @@ export class RemoteTransport { let lastError: unknown = error; for (const rung of rungs) { try { - const fallback = this.buildSelectSQL(object, rung, table); + // [#21226] Each rung keeps the tenant scope, as it keeps the WHERE. + const fallback = this.buildSelectSQL(object, rung, table, scope); const result = await this.client!.execute({ sql: fallback.sql, args: fallback.args }); return this.mapRows(result); } catch (rungError) { @@ -1328,9 +1353,14 @@ export class RemoteTransport { * missing. Neither the contract nor any caller outside these tests spelled it * that way, so the branch is gone here too: one driver, one spelling. */ - async findOne(object: string, query: any, table: string = object): Promise | null> { + async findOne( + object: string, + query: any, + table: string = object, + scope?: RemoteTenantScope, + ): Promise | null> { if (query && typeof query === 'object') { - const results = await this.find(object, { ...query, limit: 1 }, table); + const results = await this.find(object, { ...query, limit: 1 }, table, scope); return results[0] || null; } @@ -1347,7 +1377,12 @@ export class RemoteTransport { * `SqlDriver.aggregate` and `TursoDriver.aggregate` took, because all three are * one door and a caller may not be told three different things about it. */ - async aggregate(object: string, query: DriverQuery, table: string = object): Promise[]> { + async aggregate( + object: string, + query: DriverQuery, + table: string = object, + scope?: RemoteTenantScope, + ): Promise[]> { await this.ensureConnected(); this.assertSafeIdentifier(object); @@ -1502,7 +1537,9 @@ export class RemoteTransport { let sql = `SELECT ${selectParts.join(', ')} FROM ${this.tableSql(table)}`; const args: any[] = []; - const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query?.where); + // [#21226] The tenant scope narrows the rows BEFORE they are grouped, as + // `SqlDriver.aggregate` scopes its builder. + const { whereClauses, args: whereArgs } = this.scopedWhereSQL(object, query?.where, scope); if (whereClauses) { sql += ` WHERE ${whereClauses}`; args.push(...whereArgs); @@ -1626,6 +1663,7 @@ export class RemoteTransport { id: string | number, data: Record, table: string = object, + scope?: RemoteTenantScope, ): Promise | null> { await this.ensureConnected(); @@ -1633,18 +1671,32 @@ export class RemoteTransport { const setClauses = columns.map((col) => `"${col}" = ?`).join(', '); const values = columns.map((col) => this.serializeValue(data[col])); - const sql = `UPDATE ${this.tableSql(table)} SET ${setClauses} WHERE "id" = ?`; - await this.client!.execute({ sql, args: [...values, id] }); + // [#21226] The key AND, on a scoped call, the tenant scope — on the write + // and on the read-back, as `SqlDriver.update` scopes both. A row outside + // the scope is neither written nor read, and the answer is the miss arm + // (`null`), the local face's answer for it. + const byId = this.byIdWhereSQL(id, scope); + const sql = `UPDATE ${this.tableSql(table)} SET ${setClauses} WHERE ${byId.sql}`; + await this.client!.execute({ sql, args: [...values, ...byId.args] }); // Fetch updated row const result = await this.client!.execute({ - sql: `SELECT * FROM ${this.tableSql(table)} WHERE "id" = ?`, - args: [id], + sql: `SELECT * FROM ${this.tableSql(table)} WHERE ${byId.sql}`, + args: [...byId.args], }); const rows = this.mapRows(result); return rows[0] ?? null; } + /** + * [#21226] `"id" = ?`, AND the tenant scope on a scoped call. Unscoped, the + * clause is the one the by-id doors always sent. + */ + private byIdWhereSQL(id: string | number, scope: RemoteTenantScope | undefined): { sql: string; args: any[] } { + if (!scope) return { sql: `"id" = ?`, args: [id] }; + return { sql: `"id" = ? AND (${scope.sql})`, args: [id, ...scope.args] }; + } + /** * `insertOnlyColumns` — columns written on the INSERT leg and left alone on * the MERGE leg, so a value the caller (or the driver, on its behalf) put on @@ -1736,9 +1788,11 @@ export class RemoteTransport { // `['id']` target the two readings are the same statement. const keyColumns = mergeKeys.every((k) => toUpsert[k] !== undefined && toUpsert[k] !== null) ? mergeKeys : ['id']; // [#21185] A fenced call reads back under the written organization exactly - // — this face applies no tenant scope to any other read, and a read-back - // that found another organization's row would hand its columns to the - // caller. Not found means the predicate above left the row alone. + // (the local face's own read-back, not the chokepoint scope the other + // doors carry since #21226: that scope admits a row with no organization), + // and a read-back that found another organization's row would hand its + // columns to the caller. Not found means the predicate above left the row + // alone. const fenceSql = fence ? ` AND "${fence.column}" = ?` : ''; const fenceArgs = fence ? [this.serializeValue(fence.value)] : []; const result = await this.client!.execute({ @@ -1750,19 +1804,31 @@ export class RemoteTransport { return rows[0] || toUpsert; } - async delete(object: string, id: string | number, table: string = object): Promise { + async delete( + object: string, + id: string | number, + table: string = object, + scope?: RemoteTenantScope, + ): Promise { await this.ensureConnected(); + // [#21226] The key AND the tenant scope, as `SqlDriver.delete` scopes it. + const byId = this.byIdWhereSQL(id, scope); const result = await this.client!.execute({ - sql: `DELETE FROM ${this.tableSql(table)} WHERE "id" = ?`, - args: [id], + sql: `DELETE FROM ${this.tableSql(table)} WHERE ${byId.sql}`, + args: byId.args, }); return result.rowsAffected > 0; } - async count(object: string, query?: any, table: string = object): Promise { + async count( + object: string, + query?: any, + table: string = object, + scope?: RemoteTenantScope, + ): Promise { await this.ensureConnected(); - const { whereClauses, args } = this.buildWhereSQL(object, query?.where); + const { whereClauses, args } = this.scopedWhereSQL(object, query?.where, scope); let sql = `SELECT COUNT(*) as count FROM ${this.tableSql(table)}`; if (whereClauses) sql += ` WHERE ${whereClauses}`; @@ -1829,34 +1895,48 @@ export class RemoteTransport { object: string, updates: Array<{ id: string | number; data: Record }>, table: string = object, + scope?: RemoteTenantScope, ): Promise[]> { const results: Record[] = []; for (const { id, data } of updates) { - const updated = await this.update(object, id, data, table); + const updated = await this.update(object, id, data, table, scope); if (updated) results.push(updated); } return results; } - async bulkDelete(object: string, ids: Array, table: string = object): Promise { + async bulkDelete( + object: string, + ids: Array, + table: string = object, + scope?: RemoteTenantScope, + ): Promise { await this.ensureConnected(); if (ids.length === 0) return; const placeholders = ids.map(() => '?').join(', '); + // [#21226] The id set AND the tenant scope, as `SqlDriver.bulkDelete`. + const scopeSql = scope ? ` AND (${scope.sql})` : ''; await this.client!.execute({ - sql: `DELETE FROM ${this.tableSql(table)} WHERE "id" IN (${placeholders})`, - args: ids as any[], + sql: `DELETE FROM ${this.tableSql(table)} WHERE "id" IN (${placeholders})${scopeSql}`, + args: [...ids, ...(scope ? scope.args : [])] as any[], }); } - async updateMany(object: string, query: any, data: Record, table: string = object): Promise { + async updateMany( + object: string, + query: any, + data: Record, + table: string = object, + scope?: RemoteTenantScope, + ): Promise { await this.ensureConnected(); const columns = Object.keys(data); const setClauses = columns.map((col) => `"${col}" = ?`).join(', '); const setValues = columns.map((col) => this.serializeValue(data[col])); - const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query?.where); + const { whereClauses, args: whereArgs } = this.scopedWhereSQL(object, query?.where, scope); let sql = `UPDATE ${this.tableSql(table)} SET ${setClauses}`; if (whereClauses) sql += ` WHERE ${whereClauses}`; @@ -1864,10 +1944,15 @@ export class RemoteTransport { return result.rowsAffected; } - async deleteMany(object: string, query: any, table: string = object): Promise { + async deleteMany( + object: string, + query: any, + table: string = object, + scope?: RemoteTenantScope, + ): Promise { await this.ensureConnected(); - const { whereClauses, args } = this.buildWhereSQL(object, query?.where); + const { whereClauses, args } = this.scopedWhereSQL(object, query?.where, scope); let sql = `DELETE FROM ${this.tableSql(table)}`; if (whereClauses) sql += ` WHERE ${whereClauses}`; @@ -2608,7 +2693,12 @@ export class RemoteTransport { * `orderBy` is likewise an ANSWER — #4363's carve-out for an unpaged * unordered read — and emitting no ORDER BY for it is correct. */ - private buildSelectSQL(object: string, query: any, table: string): { sql: string; args: any[] } { + private buildSelectSQL( + object: string, + query: any, + table: string, + scope?: RemoteTenantScope, + ): { sql: string; args: any[] } { const fields = query.fields && Array.isArray(query.fields) && query.fields.length > 0 ? query.fields.map((f: string) => `"${this.mapSortField(f)}"`).join(', ') : '*'; @@ -2616,8 +2706,8 @@ export class RemoteTransport { let sql = `SELECT ${fields} FROM ${this.tableSql(table)}`; const allArgs: any[] = []; - // WHERE - const { whereClauses, args: whereArgs } = this.buildWhereSQL(object, query.where); + // WHERE — the caller's filter and, on a scoped call, the tenant scope. + const { whereClauses, args: whereArgs } = this.scopedWhereSQL(object, query.where, scope); if (whereClauses) { sql += ` WHERE ${whereClauses}`; allArgs.push(...whereArgs); @@ -2712,15 +2802,39 @@ export class RemoteTransport { * every refusal that predates it keeps its own wording and its own way of * naming a location, so threading this parameter changes no existing message. */ + /** + * [#21226] The caller's `WHERE`, compiled by {@link buildWhereSQL}, AND the + * tenant scope the driver handed over. With no scope the answer is + * {@link buildWhereSQL}'s own, so an unscoped statement is byte-identical to + * the one this transport sent before. With one, both sides are + * parenthesized: a top-level `OR` in the caller's filter must not escape the + * scope. An empty caller filter is vacuously TRUE (#1073), so the scope + * stands alone. + */ + private scopedWhereSQL( + object: string, + filters: unknown, + scope: RemoteTenantScope | undefined, + ): { whereClauses: string; args: any[] } { + const compiled = this.buildWhereSQL(object, filters); + if (!scope) return compiled; + if (!compiled.whereClauses) return { whereClauses: `(${scope.sql})`, args: [...scope.args] }; + return { + whereClauses: `(${compiled.whereClauses}) AND (${scope.sql})`, + args: [...compiled.args, ...scope.args], + }; + } + private buildWhereSQL( object: string, filters: any, path = 'where', ): { whereClauses: string; args: any[] } { // [#8220] Resolve a redacted refusal's provenance at the OUTERMOST frame - // only — `path === 'where'` is true exactly for the six external call - // sites, and the root they hand over is the tree the read-scope merge - // boundaries marked. Recursive frames rethrow untouched so one refusal is + // only — `path === 'where'` is true exactly for the external call sites + // (`compileDistinct`, and `scopedWhereSQL` for every other door, #21226), + // and the root they hand over is the caller's own filter, the tree the + // read-scope merge boundaries marked; the tenant scope is ANDed on after. Recursive frames rethrow untouched so one refusal is // resolved once, against the whole tree. Fail-closed like the SqlDriver // seam: 'author' swaps in the full text; 'policy', unmarked, unreachable // and ambiguous all keep the redaction. diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 6a4232ddfc7..d8ed759338d 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -40,7 +40,7 @@ import { StandardErrorCode } from '@objectstack/spec/api'; import type { DriverQuery } from '@objectstack/spec/contracts'; import type { DriverOptions, FilterCondition } from '@objectstack/spec/data'; import type { Client } from '@libsql/client'; -import { RemoteTransport } from './remote-transport.js'; +import { RemoteTransport, type RemoteTenantScope } from './remote-transport.js'; import { backfillRemoteCanonicalColumns, type RemoteBackfillClient, @@ -807,6 +807,35 @@ const REMOTE_HAS_NO_KNEX_CONNECTION = 'Remote mode builds the SQL driver\'s Knex with no connection and sends every statement to the ' + 'remote database through the libSQL client instead.'; +// ── The tenant scope on the remote face ────────────────────────────────────── + +/** + * [#21226] What Knex's SQLite compiler answers for the bare probe + * {@link TursoDriver.remoteTenantScope} hands the tenant-scope chokepoint: + * `select *` when the chokepoint added nothing (no tenant context, or no + * tenant column), and `select * where ` when it scoped the call. + */ +const REMOTE_SCOPE_PROBE_UNSCOPED = 'select *'; +const REMOTE_SCOPE_PROBE_PREFIX = 'select * where '; + +/** + * [#21226] The refusal for a tenant-scoped remote call whose scope compiled to + * a shape {@link TursoDriver.remoteTenantScope} does not read. Unreachable while + * `SqlDriver.applyTenantScope` only adds a `where` group. It exists so a change + * there fails a call instead of sending it without the scope: fail toward + * isolation, never toward exposure. + */ +function refuseUnreadableRemoteTenantScope(object: string): never { + const err = new Error( + `The tenant scope for "${object}" could not be put on the remote statement: the SQL driver's ` + + 'tenant-scope chokepoint compiled to a shape the remote face does not read, so the call is ' + + 'refused rather than sent without the scope. This is a defect in the driver, not in the request.', + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.INTERNAL_ERROR; + err.status = 500; + throw err; +} + /** How the REMOTE face answers one public `SqlDriver` member. */ export type RemoteFaceAnswer = /** `TursoDriver` redeclares it, and its remote arm answers from the remote database or from this face's own state. */ @@ -851,7 +880,8 @@ export const REMOTE_FACE_ANSWERS = { aggregate: 'remote', execute: 'remote', // `SELECT DISTINCT`, compiled by the transport. A tenant-scoped call is - // refused: no remote read applies the tenant scope. + // refused: this door's statement carries no tenant scope (the other read + // doors carry it since #21226). distinct: 'remote', // Transactions: none on this face (ADR-0119 D1). beginTransaction: 'refused', @@ -2113,9 +2143,13 @@ export class TursoDriver extends SqlDriver { if (this.isRemote) { const table = this.remoteTableFor(object, 'find'); const remoteQuery = this.toRemoteReadQuery(object, query); + // [#21226] The caller's tenant scope, as the local face scopes `findRows`. + const scope = this.remoteTenantScope(object, options); return this.formatRemoteRows( object, - await this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.find(object, remoteQuery, table)), + await this.remoteReadExit(object, { where: query?.where }, () => + this.remoteTransport!.find(object, remoteQuery, table, scope), + ), ); } return super.find(object, query, options); @@ -2132,9 +2166,12 @@ export class TursoDriver extends SqlDriver { if (this.isRemote) { const table = this.remoteTableFor(object, 'findOne'); const remoteQuery = this.toRemoteReadQuery(object, query, { singleRowLookup: true }); + const scope = this.remoteTenantScope(object, options); return this.formatRemoteRow( object, - await this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.findOne(object, remoteQuery, table)), + await this.remoteReadExit(object, { where: query?.where }, () => + this.remoteTransport!.findOne(object, remoteQuery, table, scope), + ), ); } return super.findOne(object, query, options); @@ -2243,6 +2280,58 @@ export class TursoDriver extends SqlDriver { return this.physicalTableByObject[object] ?? object; } + /** + * [#21226] The caller's tenant scope for a REMOTE statement on `object`, + * compiled by the local face's own chokepoint, or `undefined` when the call + * is unscoped. Every remote door that reads rows or picks rows to write + * hands it to `RemoteTransport`, which ANDs it onto the statement's `WHERE`. + * + * # The gap this closes + * + * The engine threads the caller's organization to every driver as + * `DriverOptions.tenantId` (ADR-0131 D8). On the local face + * {@link SqlDriver.applyTenantScope} puts it on every read and on every + * update and delete predicate. The remote branches of `find`, `findOne`, + * `count`, `aggregate`, `update`, `delete`, `bulkUpdate`, `bulkDelete`, + * `updateMany` and `deleteMany` handed `RemoteTransport` no `DriverOptions`, + * so their statements carried the caller's filter and nothing else, and + * `create` stamped no organization on the row. Only `distinct()` refused, + * because its source stated the gap. Where the engine's Layer 0 wall composes + * a predicate above the driver, it held other organizations' rows back. Where + * it composes none (the `single` posture, or an elevated caller that carries + * its organization), the driver scope is the only fence, and this face had + * none. + * + * # One rule, compiled, not a copy + * + * This method does not restate the predicate. It hands + * {@link SqlDriver.applyTenantScope} a bare Knex query builder and compiles + * what the chokepoint added. So the rule's decisions all stay in that one + * method: + * - the NULL-organization arm (a platform row with no organization is no + * other tenant's); + * - the `group` posture's membership set (`tenantIds`, the union Layer 0 + * enforces); + * - the exits for a call with no tenant context and for an object with no + * tenant column. + * The two faces therefore answer the same row set by construction. Compiling + * needs no connection: this face's Knex has none + * (`REMOTE_HAS_NO_KNEX_CONNECTION`), and nothing here runs a statement on + * it. The fragment is SQLite, the dialect the remote database speaks, in the + * identifier quoting Knex's SQLite compiler uses. + * + * A compiled shape this method cannot read is refused, never sent unscoped + * ({@link refuseUnreadableRemoteTenantScope}). + */ + private remoteTenantScope(object: string, options: DriverOptions | undefined): RemoteTenantScope | undefined { + const probe = this.knex.queryBuilder(); + this.applyTenantScope(probe, object, options); + const { sql, bindings } = probe.toSQL(); + if (sql === REMOTE_SCOPE_PROBE_UNSCOPED) return undefined; + if (!sql.startsWith(REMOTE_SCOPE_PROBE_PREFIX)) refuseUnreadableRemoteTenantScope(object); + return { sql: sql.slice(REMOTE_SCOPE_PROBE_PREFIX.length), args: [...bindings] }; + } + /** * [#20107] The terminal of the remote `find`, `findOne` and `count` exits: * the local face's own read-exit envelope, {@link SqlDriver.backendStatementFault}. @@ -2635,11 +2724,17 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'create'); if (this.isRemote) { const table = this.remoteTableFor(object, 'create'); + // [#21226] The caller's organization is stamped on the row, as the local + // face's `create` stamps it (`injectTenantOnInsert`, before the record + // numbers, which are issued per organization). Without it the row landed + // with no organization. An explicit value on the row is kept. + const row: Record = { ...data }; + this.injectTenantOnInsert(object, row, options); // The record numbers are issued HERE, on the layer that holds the // schema, before the transport builds its INSERT — see "Autonumber on // the remote face" at the top of this file. A row that already carries // its number (seed replay, import) is written through unchanged. - const written = await this.writeRemoteRowWithAutoNumbers(object, { ...data }, options, (filled) => + const written = await this.writeRemoteRowWithAutoNumbers(object, row, options, (filled) => this.remoteTransport!.create(object, this.toRemoteWriteForms(object, filled), table), ); return this.formatRemoteRow(object, written); @@ -2656,7 +2751,14 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'update'); if (this.isRemote) { const table = this.remoteTableFor(object, 'update'); - return this.formatRemoteRow(object, await this.remoteTransport!.update(object, id, this.toRemoteWriteForms(object, data), table)); + // [#21226] The key AND the caller's tenant scope, on the write and on its + // read-back, as `SqlDriver.update` scopes both: a row outside the scope + // is untouched and the answer is `null`. + const scope = this.remoteTenantScope(object, options); + return this.formatRemoteRow( + object, + await this.remoteTransport!.update(object, id, this.toRemoteWriteForms(object, data), table, scope), + ); } return super.update(object, id, data, options); } @@ -2735,7 +2837,11 @@ export class TursoDriver extends SqlDriver { override async delete(object: string, id: string | number, options?: DriverOptions): Promise { this.assertRemoteTransactionUnsupported(options, 'delete'); - if (this.isRemote) return this.remoteTransport!.delete(object, id, this.remoteTableFor(object, 'delete')); + if (this.isRemote) { + const table = this.remoteTableFor(object, 'delete'); + // [#21226] The key AND the caller's tenant scope, as `SqlDriver.delete`. + return this.remoteTransport!.delete(object, id, table, this.remoteTenantScope(object, options)); + } return super.delete(object, id, options); } @@ -2744,7 +2850,10 @@ export class TursoDriver extends SqlDriver { if (this.isRemote) { const table = this.remoteTableFor(object, 'count'); const remoteQuery = this.toRemoteQuery(object, query); - return this.remoteReadExit(object, { where: query?.where }, () => this.remoteTransport!.count(object, remoteQuery, table)); + const scope = this.remoteTenantScope(object, options); + return this.remoteReadExit(object, { where: query?.where }, () => + this.remoteTransport!.count(object, remoteQuery, table, scope), + ); } return super.count(object, query, options); } @@ -2778,8 +2887,12 @@ export class TursoDriver extends SqlDriver { // [#20424] The caller's own query is what the fault is attributed // against, as `SqlDriver.aggregate` does locally: its groupBy and // aggregation fields, and the `where` before `toRemoteFilter` rewrote it. + // + // [#21226] The rows are narrowed to the caller's tenant scope before they + // are grouped, as `SqlDriver.aggregate` scopes its builder. + const scope = this.remoteTenantScope(object, options); return this.remoteReadExit(object, query, () => - this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query), table), + this.remoteTransport!.aggregate(object, this.toRemoteQuery(object, query), table, scope), ); } return super.aggregate(object, query, options); @@ -3333,14 +3446,21 @@ export class TursoDriver extends SqlDriver { const formatted = Array.isArray(updates) ? updates.map((u) => ({ ...u, data: this.toRemoteWriteForms(object, u.data) })) : updates; - return this.formatRemoteRows(object, await this.remoteTransport!.bulkUpdate(object, formatted, table)); + // [#21226] Each row's update carries the caller's tenant scope, as each of + // `SqlDriver.bulkUpdate`'s goes through the scoped `update`. + const scope = this.remoteTenantScope(object, options); + return this.formatRemoteRows(object, await this.remoteTransport!.bulkUpdate(object, formatted, table, scope)); } return super.bulkUpdate(object, updates, options); } override async bulkDelete(object: string, ids: Array, options?: DriverOptions): Promise { this.assertRemoteTransactionUnsupported(options, 'bulkDelete'); - if (this.isRemote) return this.remoteTransport!.bulkDelete(object, ids, this.remoteTableFor(object, 'bulkDelete')); + if (this.isRemote) { + const table = this.remoteTableFor(object, 'bulkDelete'); + // [#21226] The id set AND the caller's tenant scope, as `SqlDriver.bulkDelete`. + return this.remoteTransport!.bulkDelete(object, ids, table, this.remoteTenantScope(object, options)); + } return super.bulkDelete(object, ids, options); } @@ -3348,7 +3468,14 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'updateMany'); if (this.isRemote) { const table = this.remoteTableFor(object, 'updateMany'); - return this.remoteTransport!.updateMany(object, this.toRemoteQuery(object, query), this.toRemoteWriteForms(object, data), table); + // [#21226] The caller's filter AND tenant scope, as `SqlDriver.updateMany`. + return this.remoteTransport!.updateMany( + object, + this.toRemoteQuery(object, query), + this.toRemoteWriteForms(object, data), + table, + this.remoteTenantScope(object, options), + ); } return super.updateMany(object, query, data, options); } @@ -3357,7 +3484,13 @@ export class TursoDriver extends SqlDriver { this.assertRemoteTransactionUnsupported(options, 'deleteMany'); if (this.isRemote) { const table = this.remoteTableFor(object, 'deleteMany'); - return this.remoteTransport!.deleteMany(object, this.toRemoteQuery(object, query), table); + // [#21226] The caller's filter AND tenant scope, as `SqlDriver.deleteMany`. + return this.remoteTransport!.deleteMany( + object, + this.toRemoteQuery(object, query), + table, + this.remoteTenantScope(object, options), + ); } return super.deleteMany(object, query, options); } @@ -3632,9 +3765,15 @@ export class TursoDriver extends SqlDriver { * and the `find()` presentation of each value, deduplicated. * * A tenant-scoped call is refused. `SqlDriver.distinct` puts the tenant scope - * on its statement, and no remote read here applies it, so an answer would - * list every organization's values for the column. The condition is the - * scope's own: a non-empty `tenantId` on an object with a tenant field. + * on its statement, and {@link RemoteTransport.compileDistinct} carries none, + * so an answer would list every organization's values for the column. The + * condition is the scope's own: a non-empty `tenantId` on an object with a + * tenant field. + * + * [#21226] The other remote read doors now carry the scope + * ({@link remoteTenantScope}). This one still refuses: answering the scoped + * call instead would accept a call the face refuses today, a widening this + * change does not make. */ override async distinct( object: string, From e47eee6dcbc0b55d5fb8516c64694c229e2c3c09 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:29:22 +0000 Subject: [PATCH 2/3] test(driver-turso): pin one row set per tenant-scoped call on both faces, and the refusal of an unreadable scope Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .../drivers/driver-turso/src/turso-driver.ts | 15 +- ...o-local-remote-tenant-scope-parity.test.ts | 371 ++++++++++++++++++ 2 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index d8ed759338d..fce79359f14 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -820,10 +820,12 @@ const REMOTE_SCOPE_PROBE_PREFIX = 'select * where '; /** * [#21226] The refusal for a tenant-scoped remote call whose scope compiled to - * a shape {@link TursoDriver.remoteTenantScope} does not read. Unreachable while - * `SqlDriver.applyTenantScope` only adds a `where` group. It exists so a change - * there fails a call instead of sending it without the scope: fail toward - * isolation, never toward exposure. + * a shape {@link TursoDriver.remoteTenantScope} does not read: anything beside + * `where` terms (a limit, an order, a join), or a statement that does not open + * with the probe's own prefix. Unreachable while `SqlDriver.applyTenantScope` + * only adds a `where` group. It exists so a change there fails a call instead + * of sending it without the scope, or with a fragment that is not a predicate: + * fail toward isolation, never toward exposure. */ function refuseUnreadableRemoteTenantScope(object: string): never { const err = new Error( @@ -2326,6 +2328,11 @@ export class TursoDriver extends SqlDriver { private remoteTenantScope(object: string, options: DriverOptions | undefined): RemoteTenantScope | undefined { const probe = this.knex.queryBuilder(); this.applyTenantScope(probe, object, options); + // The chokepoint may add `where` terms and nothing else: a limit, an order + // or a join cannot ride into a remote `WHERE`. + if (probe.clone().clearWhere().toSQL().sql !== REMOTE_SCOPE_PROBE_UNSCOPED) { + refuseUnreadableRemoteTenantScope(object); + } const { sql, bindings } = probe.toSQL(); if (sql === REMOTE_SCOPE_PROBE_UNSCOPED) return undefined; if (!sql.startsWith(REMOTE_SCOPE_PROBE_PREFIX)) refuseUnreadableRemoteTenantScope(object); diff --git a/packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts b/packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts new file mode 100644 index 00000000000..1e814c6257a --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts @@ -0,0 +1,371 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ONE `TursoDriver`, ONE row set per tenant-scoped call: every door that reads + * rows or picks rows to write answers the SAME rows on the remote (libSQL) face + * as on the local face, and `create` stamps the caller's organization on both. + * + * # The class + * + * The engine hands every driver the caller's organization as + * `DriverOptions.tenantId` (ADR-0131 D8), and the group posture's membership + * set as `tenantIds`. On the local face `SqlDriver.applyTenantScope` puts that + * on every read and on every update and delete predicate, and + * `injectTenantOnInsert` stamps it on a new row. The remote face compiles its + * own statements in `RemoteTransport`, and its doors used to receive no + * `DriverOptions` at all: their statements carried the caller's filter and + * nothing else. Only `distinct()` refused a scoped call. + * + * What reaches the driver here is the shape the engine sends when nothing above + * the driver composes a tenant predicate: the posture in which Layer 0 is + * inert, and an elevated caller that carries its organization (whose options + * may also carry `bypassTenantAudit`, which mutes the audit and not the scope). + * There the driver scope is the only fence. + * + * # What is asserted + * + * For each door, on both faces, a call scoped to one organization against + * another organization's rows: excluded, `null`, `false`, `0` or untouched on + * disk, and the scoped call's same-organization control answers as an unscoped + * call would. A platform row (no organization) is inside the scope on both + * faces: that is the chokepoint's NULL-organization arm, kept by construction + * because the remote face compiles the chokepoint's own predicate. The final + * test runs one script on both faces and requires equal answers, and that the + * answers they agree on are the scoped ones. + * + * The local face reaches this package through the BUILT + * `@objectstack/driver-sql` (no vitest alias to `src`), so a change to + * `sql-driver.ts` is seen here only after that package is rebuilt. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { DriverOptions } from '@objectstack/spec/data'; +import { TursoDriver } from './turso-driver.js'; +import { asLibsqlClient, makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +const CALLER_ORG = 'org_os21226_caller'; +const OTHER_ORG = 'org_os21226_other'; +const THIRD_ORG = 'org_os21226_third'; + +const LEDGER = { + name: 'os21226_ledger', + fields: { + organization_id: { type: 'string' }, + title: { type: 'string' }, + amount: { type: 'number' }, + }, +} as const; + +const SEED = [ + { id: 'own_1', organization_id: CALLER_ORG, title: 'own one', amount: 10 }, + { id: 'own_2', organization_id: CALLER_ORG, title: 'own two', amount: 20 }, + { id: 'other_1', organization_id: OTHER_ORG, title: 'other one', amount: 100 }, + { id: 'other_2', organization_id: OTHER_ORG, title: 'other two', amount: 200 }, + { id: 'third_1', organization_id: THIRD_ORG, title: 'third one', amount: 300 }, + { id: 'platform_1', organization_id: null, title: 'platform', amount: 1000 }, +] as const; + +const OTHER_IDS = ['other_1', 'other_2', 'third_1']; + +/** What a scoped call to the caller's organization may reach. */ +const IN_SCOPE_IDS = ['own_1', 'own_2', 'platform_1']; + +/** The two scoped option shapes the engine sends with no Layer 0 predicate above the driver. */ +const SCOPED_SHAPES: ReadonlyArray<[string, DriverOptions]> = [ + ['tenantId', { tenantId: CALLER_ORG }], + ['tenantId with bypassTenantAudit', { tenantId: CALLER_ORG, bypassTenantAudit: true }], +]; +const SCOPED: DriverOptions = { tenantId: CALLER_ORG }; +const UNSCOPED: DriverOptions = { bypassTenantAudit: true }; + +type Face = 'local' | 'remote'; +const FACES: readonly Face[] = ['local', 'remote']; + +type Row = Record; + +const ids = (rows: ReadonlyArray): string[] => rows.map((r) => String(r.id)).sort(); + +const captureError = async (run: () => Promise): Promise => { + try { + await run(); + return null; + } catch (e) { + return e as WireBearingError; + } +}; + +describe('a tenant-scoped call on BOTH TursoDriver faces answers one row set', () => { + let drivers: Record; + let stub: LibsqlSqliteStub; + + /** Every stored row, raw from the face's own database, in a stable order. */ + const snapshot = async (face: Face): Promise => { + const rows: Row[] = + face === 'remote' + ? stub.raw.prepare(`SELECT "id", "organization_id", "title", "amount" FROM "${LEDGER.name}" ORDER BY "id"`).all() + : await (drivers.local as any).knex(LEDGER.name).select('id', 'organization_id', 'title', 'amount').orderBy('id'); + return rows.map((r) => JSON.parse(JSON.stringify(r))); + }; + + const rawRow = async (face: Face, id: string): Promise => + (await snapshot(face)).find((r) => r.id === id); + + beforeEach(async () => { + const local = new TursoDriver({ url: ':memory:' }); + expect(local.transportMode).toBe('local'); + + stub = makeLibsqlSqliteStub(); + const remote = new TursoDriver({ url: 'libsql://tenant-scope-parity.turso.io', client: asLibsqlClient(stub) }); + await remote.connect(); + expect(remote.transportMode).toBe('remote'); + + drivers = { local, remote }; + for (const driver of Object.values(drivers)) { + await driver.initObjects([{ ...LEDGER, fields: { ...LEDGER.fields } }]); + for (const row of SEED) await driver.create(LEDGER.name, { ...row }, UNSCOPED); + } + }); + + afterEach(async () => { + await drivers.local.disconnect(); + await drivers.remote.disconnect(); + stub.close(); + }); + + for (const face of FACES) { + describe(`the ${face} face`, () => { + it('control: the seed landed with each row\'s own organization', async () => { + const stored = await snapshot(face); + expect(stored.map((r) => [r.id, r.organization_id])).toEqual( + [...SEED].sort((a, b) => a.id.localeCompare(b.id)).map((r) => [r.id, r.organization_id]), + ); + }); + + for (const [shape, options] of SCOPED_SHAPES) { + it(`find answers the caller's organization and platform rows only (${shape})`, async () => { + const driver = drivers[face]; + expect(ids(await driver.find(LEDGER.name, {}, options))).toEqual(IN_SCOPE_IDS); + // A filter naming another organization's rows still answers none. + expect(await driver.find(LEDGER.name, { where: { title: 'other one' } }, options)).toEqual([]); + }); + } + + it("find: a top-level `$or` in the caller's filter does not escape the scope", async () => { + const driver = drivers[face]; + const rows = await driver.find( + LEDGER.name, + { where: { $or: [{ title: 'other one' }, { title: 'own one' }] } }, + SCOPED, + ); + expect(ids(rows)).toEqual(['own_1']); + }); + + it("findOne answers null for another organization's row, and the caller's own row as before", async () => { + const driver = drivers[face]; + for (const id of OTHER_IDS) { + expect(await driver.findOne(LEDGER.name, { where: { id } }, SCOPED)).toBeNull(); + } + expect((await driver.findOne(LEDGER.name, { where: { id: 'own_1' } }, SCOPED))?.title).toBe('own one'); + }); + + it('count counts the scoped rows only', async () => { + const driver = drivers[face]; + expect(await driver.count(LEDGER.name, {}, SCOPED)).toBe(IN_SCOPE_IDS.length); + expect(await driver.count(LEDGER.name, { where: { title: 'other one' } }, SCOPED)).toBe(0); + expect(await driver.count(LEDGER.name, {}, UNSCOPED)).toBe(SEED.length); + }); + + it('aggregate groups the scoped rows only', async () => { + const driver = drivers[face]; + const total = await driver.aggregate( + LEDGER.name, + { aggregations: [{ function: 'sum', field: 'amount', alias: 'total' }] } as never, + SCOPED, + ); + expect(Number(total[0]?.total)).toBe(10 + 20 + 1000); + + const grouped = await driver.aggregate( + LEDGER.name, + { groupBy: ['organization_id'], aggregations: [{ function: 'count', alias: 'n' }] } as never, + SCOPED, + ); + const groups = grouped.map((g) => g.organization_id ?? null); + expect(groups).not.toContain(OTHER_ORG); + expect(groups).not.toContain(THIRD_ORG); + expect(groups).toContain(CALLER_ORG); + }); + + it("update answers null for another organization's row and leaves it unchanged; the caller's own row updates", async () => { + const driver = drivers[face]; + const before = await rawRow(face, 'other_1'); + expect(await driver.update(LEDGER.name, 'other_1', { title: 'overwritten?' }, SCOPED)).toBeNull(); + expect(await rawRow(face, 'other_1')).toEqual(before); + + const own = await driver.update(LEDGER.name, 'own_1', { title: 'updated' }, SCOPED); + expect(own?.title).toBe('updated'); + expect((await rawRow(face, 'own_1'))?.title).toBe('updated'); + }); + + it("delete answers false for another organization's row and leaves it; the caller's own row is deleted", async () => { + const driver = drivers[face]; + expect(await driver.delete(LEDGER.name, 'other_2', SCOPED)).toBe(false); + expect(await rawRow(face, 'other_2')).toBeDefined(); + + expect(await driver.delete(LEDGER.name, 'own_2', SCOPED)).toBe(true); + expect(await rawRow(face, 'own_2')).toBeUndefined(); + }); + + it('updateMany writes the scoped rows only', async () => { + const driver = drivers[face]; + const affected = await driver.updateMany(LEDGER.name, { where: { amount: { $gte: 0 } } }, { title: 'swept' }, SCOPED); + expect(affected).toBe(IN_SCOPE_IDS.length); + const stored = await snapshot(face); + for (const row of stored) { + expect(row.title === 'swept', `row ${row.id}`).toBe(IN_SCOPE_IDS.includes(String(row.id))); + } + }); + + it('deleteMany deletes the scoped rows only', async () => { + const driver = drivers[face]; + expect(await driver.deleteMany(LEDGER.name, { where: { title: 'other one' } }, SCOPED)).toBe(0); + expect(await rawRow(face, 'other_1')).toBeDefined(); + + expect(await driver.deleteMany(LEDGER.name, { where: { amount: { $gte: 0 } } }, SCOPED)).toBe(IN_SCOPE_IDS.length); + expect(ids(await snapshot(face))).toEqual([...OTHER_IDS].sort()); + }); + + it("bulkUpdate answers and writes the caller's rows only", async () => { + const driver = drivers[face]; + const before = await rawRow(face, 'other_1'); + const updated = await driver.bulkUpdate( + LEDGER.name, + [ + { id: 'other_1', data: { title: 'overwritten?' } }, + { id: 'own_1', data: { title: 'bulk updated' } }, + ], + SCOPED, + ); + expect(ids(updated)).toEqual(['own_1']); + expect(await rawRow(face, 'other_1')).toEqual(before); + expect((await rawRow(face, 'own_1'))?.title).toBe('bulk updated'); + }); + + it("bulkDelete deletes the caller's rows only", async () => { + const driver = drivers[face]; + await driver.bulkDelete(LEDGER.name, ['other_1', 'third_1', 'own_2'], SCOPED); + expect(await rawRow(face, 'other_1')).toBeDefined(); + expect(await rawRow(face, 'third_1')).toBeDefined(); + expect(await rawRow(face, 'own_2')).toBeUndefined(); + }); + + it("create stamps the caller's organization on the row, and keeps an explicit one", async () => { + const driver = drivers[face]; + const created = await driver.create(LEDGER.name, { id: 'new_1', title: 'new', amount: 1 }, SCOPED); + expect(created.organization_id).toBe(CALLER_ORG); + expect((await rawRow(face, 'new_1'))?.organization_id).toBe(CALLER_ORG); + + await driver.create( + LEDGER.name, + { id: 'new_2', title: 'explicit', amount: 1, organization_id: THIRD_ORG }, + SCOPED, + ); + expect((await rawRow(face, 'new_2'))?.organization_id).toBe(THIRD_ORG); + }); + + it("bulkCreate stamps the caller's organization on every row", async () => { + const driver = drivers[face]; + await driver.bulkCreate( + LEDGER.name, + [ + { id: 'bulk_1', title: 'bulk one', amount: 1 }, + { id: 'bulk_2', title: 'bulk two', amount: 2 }, + ], + SCOPED, + ); + expect((await rawRow(face, 'bulk_1'))?.organization_id).toBe(CALLER_ORG); + expect((await rawRow(face, 'bulk_2'))?.organization_id).toBe(CALLER_ORG); + }); + + it("the group posture's membership set (`tenantIds`) scopes to the union, and no further", async () => { + const driver = drivers[face]; + const union: DriverOptions = { tenantId: CALLER_ORG, tenantIds: [CALLER_ORG, THIRD_ORG] }; + expect(ids(await driver.find(LEDGER.name, {}, union))).toEqual([...IN_SCOPE_IDS, 'third_1'].sort()); + expect(await driver.count(LEDGER.name, {}, union)).toBe(IN_SCOPE_IDS.length + 1); + expect(await driver.update(LEDGER.name, 'other_1', { title: 'overwritten?' }, union)).toBeNull(); + expect((await driver.update(LEDGER.name, 'third_1', { title: 'member write' }, union))?.title).toBe('member write'); + }); + + it('control: a call with no tenant context reaches every row, as before', async () => { + const driver = drivers[face]; + expect(ids(await driver.find(LEDGER.name, {}, UNSCOPED))).toEqual(SEED.map((r) => r.id).sort()); + expect((await driver.findOne(LEDGER.name, { where: { id: 'other_1' } }, UNSCOPED))?.title).toBe('other one'); + expect((await driver.update(LEDGER.name, 'other_1', { title: 'unscoped write' }, UNSCOPED))?.title).toBe( + 'unscoped write', + ); + expect(await driver.delete(LEDGER.name, 'other_2', UNSCOPED)).toBe(true); + }); + }); + } + + it('the remote face refuses, rather than sends unscoped, a scope it cannot read', async () => { + class UnreadableScopeDriver extends TursoDriver { + protected override applyTenantScope(builder: any, object: string, options?: DriverOptions): any { + return super.applyTenantScope(builder, object, options).limit(1); + } + } + const driver = new UnreadableScopeDriver({ + url: 'libsql://tenant-scope-parity.turso.io', + client: asLibsqlClient(stub), + }); + await driver.connect(); + await driver.initObjects([{ ...LEDGER, fields: { ...LEDGER.fields } }]); + const before = await snapshot('remote'); + + for (const run of [ + () => driver.find(LEDGER.name, {}, SCOPED), + () => driver.update(LEDGER.name, 'other_1', { title: 'overwritten?' }, SCOPED), + () => driver.deleteMany(LEDGER.name, {}, SCOPED), + ]) { + const err = await captureError(run); + expect(err, 'the call was answered').not.toBeNull(); + expect(err!.code).toBe('INTERNAL_ERROR'); + expect(err!.status).toBe(500); + } + expect(await snapshot('remote')).toEqual(before); + await driver.disconnect(); + }); + + it('both faces give one answer to the same scoped script, and it is the scoped one', async () => { + const run = async (face: Face) => { + const driver = drivers[face]; + return { + found: ids(await driver.find(LEDGER.name, {}, SCOPED)), + counted: await driver.count(LEDGER.name, {}, SCOPED), + otherOne: await driver.findOne(LEDGER.name, { where: { id: 'other_1' } }, SCOPED), + updatedOther: await driver.update(LEDGER.name, 'other_1', { title: 'x' }, SCOPED), + deletedOther: await driver.delete(LEDGER.name, 'other_2', SCOPED), + swept: await driver.updateMany(LEDGER.name, { where: { amount: { $gte: 0 } } }, { title: 'swept' }, SCOPED), + created: (await driver.create(LEDGER.name, { id: 'new_1', title: 'n', amount: 1 }, SCOPED)).organization_id, + stored: (await snapshot(face)).map((r) => [r.id, r.organization_id, r.title]), + }; + }; + + const local = await run('local'); + const remote = await run('remote'); + + expect(remote).toEqual(local); + // …and the answer they agree on is the scoped one: two faces that both + // answered across organizations would agree perfectly. + expect(local.found).toEqual(IN_SCOPE_IDS); + expect(local.otherOne).toBeNull(); + expect(local.updatedOther).toBeNull(); + expect(local.deletedOther).toBe(false); + expect(local.swept).toBe(IN_SCOPE_IDS.length); + expect(local.created).toBe(CALLER_ORG); + }); +}); From 10fa4bc9b875ce60ce4164ff935dc5dcb9d7f84c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 20:33:58 +0000 Subject: [PATCH 3/3] chore(spec,driver-turso): changeset and ADR-0087 semantic entry for the remote doors' tenant scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clause-②: no (narrowing). The disposition is registered driver-remote-doors-tenant-scoped; registry.ts regenerated with gen:migration-registry. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude --- .changeset/21226-remote-doors-tenant-scope.md | 52 ++++++++++++++++++ .../18.driver-remote-doors-tenant-scoped.ts | 53 +++++++++++++++++++ packages/spec/src/migrations/registry.ts | 49 +++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 .changeset/21226-remote-doors-tenant-scope.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.driver-remote-doors-tenant-scoped.ts diff --git a/.changeset/21226-remote-doors-tenant-scope.md b/.changeset/21226-remote-doors-tenant-scope.md new file mode 100644 index 00000000000..8b2eecb78b6 --- /dev/null +++ b/.changeset/21226-remote-doors-tenant-scope.md @@ -0,0 +1,52 @@ +--- +'@objectstack/driver-turso': minor +'@objectstack/spec': patch +--- + +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) + +Clause-②: no (narrowing) + + + +**BREAKING for callers of a remote-mode `TursoDriver` that pass `tenantId`.** + +**What changed.** The engine hands every driver the caller's organization as +`DriverOptions.tenantId`, and the group posture's membership set as `tenantIds` +(ADR-0131 D8). The local face applies them through `SqlDriver.applyTenantScope` +on every read and on every update and delete predicate, and stamps the +organization on a new row. The remote face's doors received no driver options, +so their statements carried the caller's filter and nothing else. Now: + +- **`find`, `findOne`, `count`, `aggregate`, `update`, `delete`, `bulkUpdate`, + `bulkDelete`, `updateMany` and `deleteMany` carry the caller's tenant scope on + the remote face.** The predicate is not a second copy: the remote face asks the + local face's own chokepoint for it and ANDs what that compiles to onto each + statement. So the rows a scoped call reaches are the same on both faces: the + caller's organization, rows with no organization, and, under the group posture, + the caller's membership set. +- **A remote `create` (and `bulkCreate`) stamps the caller's organization** on a + row that names none, as the local `create` does. An explicit value on the row + is kept. +- **`distinct` still refuses a tenant-scoped call on the remote face**, as before. +- A scope the remote face cannot read is refused (`INTERNAL_ERROR` / 500), never + sent without the scope. + +Where the engine's tenant wall composes a predicate above the driver, it already +kept other organizations' rows out of these answers. Where it composes none (the +posture in which that wall is inert, or an elevated caller that carries its +organization), the driver scope is the only fence, and the remote face had none. + +## FROM → TO + +| you relied on | now | +|:--|:--| +| 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 | +| 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 | +| a tenant-scoped remote `updateMany` / `deleteMany` / `bulkUpdate` / `bulkDelete` reaching another organization's rows | only rows in scope are written; the count reports them | +| 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` | + +**What is not affected.** A call without `tenantId`, or on an object with no +tenant column, sends the same statement as before. The local and embedded-replica +faces are unchanged. A scoped call's answer for the caller's own rows, and for +rows with no organization, is unchanged. diff --git a/packages/spec/src/migrations/entries/semantic/18.driver-remote-doors-tenant-scoped.ts b/packages/spec/src/migrations/entries/semantic/18.driver-remote-doors-tenant-scoped.ts new file mode 100644 index 00000000000..9fdb4e967e7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.driver-remote-doors-tenant-scoped.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21226 — registered by the change that put the caller's tenant scope on the +// remote libSQL face's doors, not by a later reconciliation. A driver call is +// code, never stack metadata, so there is no authored source for the chain to +// rewrite and no schema tombstone: this entry is the migration channel beside +// the changeset's FROM → TO table, as for its sibling +// `driver-upsert-cross-organization-conflict-refused`. +export const entry: SemanticMigration = { + id: 'driver-remote-doors-tenant-scoped', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'IDataDriver find, findOne, count, aggregate, update, delete, bulkUpdate, bulkDelete, ' + + 'updateMany, deleteMany, create and bulkCreate on TursoDriver\'s remote (libSQL) face, ' + + 'called with a tenant context', + replacement: + 'a tenant-scoped call on the remote face reaches the rows the local face reaches for the ' + + 'same options: the caller\'s organization, rows with no organization, and under the group ' + + 'posture the caller\'s membership set. A by-id `update` outside that scope answers `null`, ' + + 'a by-id `delete` answers `false`, and a predicate write counts only the rows in scope. ' + + '`create` stamps the caller\'s organization on a row that names none. To reach rows of ' + + 'every organization, call without `tenantId`, as on the local face', + reason: + 'The engine hands every driver the caller\'s organization as `DriverOptions.tenantId`, and ' + + 'the group posture\'s membership set as `tenantIds` (ADR-0131 D8, ADR-0105 D2). ' + + 'TursoDriver\'s local face applies them through `SqlDriver.applyTenantScope` on every read ' + + 'and on every update and delete predicate, and stamps the organization on insert. Its remote ' + + 'face compiles its own statements, and its doors received no driver options: their ' + + 'statements carried the caller\'s filter and nothing else, and a remote `create` wrote no ' + + 'organization. Where the engine\'s Layer 0 wall composes a predicate above the driver, that ' + + 'wall held other organizations\' rows back. Where it composes none (the posture in which ' + + 'Layer 0 is inert, or an elevated caller that carries its organization), the driver scope ' + + 'is the only fence, and on the remote face there was none. The remote doors now compile the ' + + 'local face\'s own predicate, by asking the same chokepoint, and AND it onto each ' + + 'statement, so the two faces answer the same rows by construction. The remote `create` ' + + 'stamps the organization as the local `create` does. `distinct` still refuses a ' + + 'tenant-scoped call on the remote face. The call signatures are unchanged, so nothing ' + + 'reaches the compiler. Code that relied on a tenant-scoped remote call reaching another ' + + 'organization\'s rows now gets the miss answer each door already declares, and a remote ' + + '`create` that relied on landing a row with no organization now finds it under the ' + + 'caller\'s. ADR-0131 D8 / ADR-0087.', + acceptanceCriteria: + 'No caller of a remote-mode TursoDriver passes `tenantId` and expects to read, count, ' + + 'aggregate, update or delete a row of another organization; a caller that means to reach ' + + 'every organization calls without a tenant context, as on the local face. No caller relies ' + + 'on a tenant-scoped remote `create` landing a row with no organization. Proven when a ' + + 'tenant-scoped call on each door answers the same rows on the remote face as on the local ' + + 'face for the same options: another organization\'s row excluded, `null`, `false` or ' + + 'untouched, and the caller\'s own rows and rows with no organization answered as before.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3183d0213df..d89db4a2e3d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9480,6 +9480,55 @@ const step18: MigrationStep = { + 'the rename prescription naming `timeoutMs`; `{ timeoutMs: 5000 }` parses to the same ' + 'number.', }, + // #21226 — registered by the change that put the caller's tenant scope on the + // remote libSQL face's doors, not by a later reconciliation. A driver call is + // code, never stack metadata, so there is no authored source for the chain to + // rewrite and no schema tombstone: this entry is the migration channel beside + // the changeset's FROM → TO table, as for its sibling + // `driver-upsert-cross-organization-conflict-refused`. + { + id: 'driver-remote-doors-tenant-scoped', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: + 'IDataDriver find, findOne, count, aggregate, update, delete, bulkUpdate, bulkDelete, ' + + 'updateMany, deleteMany, create and bulkCreate on TursoDriver\'s remote (libSQL) face, ' + + 'called with a tenant context', + replacement: + 'a tenant-scoped call on the remote face reaches the rows the local face reaches for the ' + + 'same options: the caller\'s organization, rows with no organization, and under the group ' + + 'posture the caller\'s membership set. A by-id `update` outside that scope answers `null`, ' + + 'a by-id `delete` answers `false`, and a predicate write counts only the rows in scope. ' + + '`create` stamps the caller\'s organization on a row that names none. To reach rows of ' + + 'every organization, call without `tenantId`, as on the local face', + reason: + 'The engine hands every driver the caller\'s organization as `DriverOptions.tenantId`, and ' + + 'the group posture\'s membership set as `tenantIds` (ADR-0131 D8, ADR-0105 D2). ' + + 'TursoDriver\'s local face applies them through `SqlDriver.applyTenantScope` on every read ' + + 'and on every update and delete predicate, and stamps the organization on insert. Its remote ' + + 'face compiles its own statements, and its doors received no driver options: their ' + + 'statements carried the caller\'s filter and nothing else, and a remote `create` wrote no ' + + 'organization. Where the engine\'s Layer 0 wall composes a predicate above the driver, that ' + + 'wall held other organizations\' rows back. Where it composes none (the posture in which ' + + 'Layer 0 is inert, or an elevated caller that carries its organization), the driver scope ' + + 'is the only fence, and on the remote face there was none. The remote doors now compile the ' + + 'local face\'s own predicate, by asking the same chokepoint, and AND it onto each ' + + 'statement, so the two faces answer the same rows by construction. The remote `create` ' + + 'stamps the organization as the local `create` does. `distinct` still refuses a ' + + 'tenant-scoped call on the remote face. The call signatures are unchanged, so nothing ' + + 'reaches the compiler. Code that relied on a tenant-scoped remote call reaching another ' + + 'organization\'s rows now gets the miss answer each door already declares, and a remote ' + + '`create` that relied on landing a row with no organization now finds it under the ' + + 'caller\'s. ADR-0131 D8 / ADR-0087.', + acceptanceCriteria: + 'No caller of a remote-mode TursoDriver passes `tenantId` and expects to read, count, ' + + 'aggregate, update or delete a row of another organization; a caller that means to reach ' + + 'every organization calls without a tenant context, as on the local face. No caller relies ' + + 'on a tenant-scoped remote `create` landing a row with no organization. Proven when a ' + + 'tenant-scoped call on each door answers the same rows on the remote face as on the local ' + + 'face for the same options: another organization\'s row excluded, `null`, `false` or ' + + 'untouched, and the caller\'s own rows and rows with no organization answered as before.', + }, // Registered by the change that removed the three methods (#20822, PR #20988), // not by a later reconciliation. {