diff --git a/.changeset/memory-driver-read-side-tenant-scope.md b/.changeset/memory-driver-read-side-tenant-scope.md new file mode 100644 index 0000000000..907ba92f8b --- /dev/null +++ b/.changeset/memory-driver-read-side-tenant-scope.md @@ -0,0 +1,23 @@ +--- +'@objectstack/driver-memory': minor +--- + +**BREAKING** — a caller that passes `DriverOptions.tenantId` to `@objectstack/driver-memory` now receives FEWER rows. The narrowed accept set was documented behaviour, not merely a defect: this package's own published docblock told that caller the driver never reads `DriverOptions.tenantId`. Same package, same subject and the same declaration as the released precedent #6915 / PR #7924 (`feat(driver-memory)!`), which shipped a refusal that was always owed and still declared it, so the release notes could say so. The bump level stays `minor` because the launch-window convention forbids `major`; during that window this banner, not the level, is the carrier. + +The in-memory driver now honours `DriverOptions.tenantId` / `tenantIds` instead of discarding them, so a scoped read no longer returns other organizations' rows. + +Two predicates decided "is this object tenant-scoped" and they disagreed on the default case. `Engine.buildDriverOptions` scopes unless the object opts OUT (`tenantId !== undefined && !isTenancyDisabled(schema) && !isFederated`); this driver's boot guard refuses only an explicit opt-IN (`tenancy.enabled === true`). An object that omits the `tenancy` block — the common case — was therefore scoped by the engine and invisible to the guard, and the driver did nothing with the scope: `tenantId`, `tenantIds` and `organization_id` occurred nowhere in `memory-driver.ts`. The read path knew nothing about tenants; the unique-constraint path did. + +Measured on one app across two drivers, same build, same seed, same account: the four objects that omit the block returned 12 / 30 / 40 / 14 rows on this driver against **0** on sqlite, and the three that declare `tenancy.enabled: false` agreed exactly. The split line was the declaration. Neither driver said a word about the disagreement. + +⚠️ **Every isolation measurement previously taken on this driver is void and must be re-taken.** The failure direction was toward exposure in the place where isolation is tested: a suite asserting "tenant A cannot see tenant B's rows" passed here not because isolation worked, but because both tenants' rows came back to everyone and the assertion had been written against a single tenant's fixture. + +The semantics are `driver-sql`'s, read off `applyTenantScope` and reproduced arm for arm rather than invented — `col = :tenantId OR col IS NULL` for the equality path, `col IN (…) OR col IS NULL` under the ADR-0105 D2 union posture, and the NULL arm keeps the #2734 global-row carve-out so a platform row that belongs to no organization stays visible to all of them. Every door that **selects rows** routes through one chokepoint: `find`, `findOne`, `count`, `aggregate` (both arms), `update`, `upsert`, `delete`, `updateMany`, `deleteMany`, `bulkUpdate` and `bulkDelete`. Four doors that take a `DriverOptions` are deliberately **not** routed through it: `create` and `bulkCreate` are the insert doors, which `driver-sql` scopes through `injectTenantOnInsert` rather than `applyTenantScope` and which the write half left out of this change does not stamp; `syncSchema` and `dropTable` are DDL, which `driver-sql` does not scope either. `distinct()` accepts no `DriverOptions` at all and is therefore still unscoped — the one selecting door named rather than left to be discovered. + +An `upsert` addressed by an explicit `id` refuses when a row carrying that id exists outside the caller's scope, on this driver's own "not found" contract, rather than inserting: `id` is the primary id here and `create` checks only declared unique constraints, so falling through would leave two rows carrying one id. `driver-sql` cannot reach that state — it merges on the PRIMARY KEY regardless of tenant and scopes only the readback. + +**What changes for an existing consumer.** A caller that passes no `tenantId` — every seed script, admin path and legacy call — is unaffected down to the array it allocates. A caller that does pass one, on an object carrying a tenant column and no `tenancy` declaration, now sees its own organization's rows plus organization-less rows, where it previously saw everything. That is the fix, and it is the reason a `single`-posture deployment is affected at all: `single` constrains the wall, not the number of organizations — one measured run held 13 `sys_organization` rows. + +Write-side tenancy is deliberately not included: nothing stamps a tenant column on insert the way `SqlDriver.injectTenantOnInsert` does, so the boot guard still refuses a walled posture and still refuses an object declaring `tenancy.enabled: true`. `declaresTenantScope`'s docstring is corrected in the same change — its load-bearing sentence, "every object in a single-tenant deployment omits the block", was false. + + diff --git a/packages/drivers/driver-memory/src/index.ts b/packages/drivers/driver-memory/src/index.ts index 644bae66da..ae97651866 100644 --- a/packages/drivers/driver-memory/src/index.ts +++ b/packages/drivers/driver-memory/src/index.ts @@ -47,6 +47,15 @@ export type { UniqueAwareSchema, } from './memory-unique-constraint.js'; +// [#16589] Read-side tenant scoping, exported for the same reason the +// uniqueness helpers above are: a consumer verifying an isolation property on +// this driver can assert the PREDICATE directly instead of inferring it from a +// row count. ⚠️ Every isolation measurement taken on this driver BEFORE #16589 +// is void — it was taken against a driver that returned every organization's +// rows to everyone — and has to be re-taken. +export { recordTenantField, tenantScopePredicate } from './memory-tenant-scope.js'; +export type { TenantRowPredicate } from './memory-tenant-scope.js'; + export default { id: 'com.objectstack.driver.memory', version: '1.0.0', diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index 1f2dc5ce39..b72bd1db86 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -49,6 +49,15 @@ import { uniqueConstraintsFromFields, type MemoryUniqueEnforcement, } from './memory-unique-constraint.js'; +// [#16589] Read-side tenant scoping — the half of this driver that used to +// discard `DriverOptions.tenantId` in silence while the engine threaded it on +// every call. Semantics read off `SqlDriver.applyTenantScope`; see the module +// docblock for the measurement and for what this deliberately is NOT. +import { + recordTenantField, + tenantScopePredicate, + type TenantRowPredicate, +} from './memory-tenant-scope.js'; /** * [#13524] The canonical rank of an authorable field operator — the tie-break @@ -448,6 +457,20 @@ export class InMemoryDriver implements IDataDriver { private tablesCreatedHere: Set = new Set(); /** Tables that were already populated when this driver first synced them. */ private tablesFoundExisting: Set = new Set(); + /** + * [#16589] The column each object is tenant-scoped by, learned in + * `syncSchema` — the in-memory twin of `SqlDriver.tenantFieldByTable`. An + * object never synced here has no entry and is therefore unscoped, which is + * the same answer `resolveTenantField` gives for a table it never registered. + */ + private tenantFieldByObject: Map = new Map(); + /** + * [#16589 / #3249] The sticky explicit `tenancy.enabled: false` record, the + * twin of `SqlDriver.tenantOptOutByTable`: a later PARTIAL re-registration + * (`{ name, fields }`, no `tenancy`) must not let the implicit + * `organization_id` heuristic re-scope a platform-global table. + */ + private tenantOptOutByObject: Set = new Set(); // =================================== // Lifecycle @@ -565,7 +588,13 @@ export class InMemoryDriver implements IDataDriver { this.logger.debug('Find operation', { object, query }); const table = this.getTable(object); - let results = [...table]; // Work on copy + // [#16589] 0. Tenant scope, ahead of the caller's own filter — the engine + // threads `tenantId` here on every read of a tenant-scoped object and this + // driver used to drop it, so a `single`-posture deployment holding many + // organizations (the posture constrains the WALL, not the number of orgs) + // read every organization's rows back. + const scope = this.tenantScope(object, options); + let results = scope ? table.filter(scope) : [...table]; // Work on copy // 1. Filter using Mingo if (query.where) { @@ -702,10 +731,16 @@ export class InMemoryDriver implements IDataDriver { */ async update(object: string, id: string | number, data: Record, options?: DriverOptions): Promise | null> { this.logger.debug('Update operation', { object, id }); - + const table = this.getTable(object); - const index = table.findIndex(r => r.id == id); - + // [#16589] An id-addressed update is scoped too — `driver-sql` applies the + // same predicate to its UPDATE builder, so a row belonging to another + // organization is simply not there. That lands on this method's OWN + // existing "not found" contract (strictMode throws, otherwise `null`); + // ⛔ no new refusal shape is invented for the cross-tenant case. + const scope = this.tenantScope(object, options); + const index = table.findIndex(r => r.id == id && (!scope || scope(r))); + if (index === -1) { if (this.config.strictMode) { this.logger.warn('Record not found for update', { object, id }); @@ -738,10 +773,45 @@ export class InMemoryDriver implements IDataDriver { const table = this.getTable(object); let existingRecord: any = null; + // [#16589] The conflict lookup is a read: a row belonging to another + // organization is not a conflict for this caller, so an upsert keyed on + // `conflictKeys` lands as an INSERT rather than silently rewriting a row it + // was never allowed to see. + // + // ⛔ `driver-sql` is NOT the precedent for that fall-through on the `id` + // arm. Its `INSERT … ON CONFLICT(id)` merges on the PRIMARY KEY regardless + // of tenant — "the verdict itself is tenant-independent regardless: `id` is + // the PRIMARY KEY, so at most one row in the table can carry it" — and only + // the READBACK is scoped. This store has no such key, so the `id` arm is + // handled separately below. + const scope = this.tenantScope(object, options); + const visible = scope ? table.filter(scope) : table; + if (data.id) { - existingRecord = table.find(r => r.id === data.id); + existingRecord = visible.find(r => r.id === data.id); + // [#16589] The scope is the only thing that can have hidden the row: a + // row carrying `data.id` may sit in `table` and outside `visible`. + // Falling through to `create` there lands a SECOND row with the same + // primary id — `create` checks only DECLARED unique constraints and + // `id` is not one (pinned by `memory-bulk-create-atomicity.test.ts`) — + // and a duplicate primary id then corrupts every id-addressed door for + // BOTH tenants, since `update`/`delete` take the first matching index + // and `deleteMany` rebuilds the table from a matched-id set. Refuse on + // this driver's OWN existing "not found" contract instead, the same + // shape `update` and `delete` land on for a cross-tenant id. + // + // The refusal is raised in `strictMode` and outside it alike: unlike + // `update` (`| null`) and `delete` (`false`), `upsert`'s declared + // return carries no miss arm (#13878), so the quiet non-`strictMode` + // miss is not expressible here. The two alternatives were widening this + // door's declared return with an arm no caller was ever asked to + // narrow, or landing the duplicate id; both are worse than throwing. + if (!existingRecord && table.some(r => r.id === data.id)) { + this.logger.warn('Record not found for upsert', { object, id: data.id }); + throw new Error(`Record with ID ${data.id} not found in ${object}`); + } } else if (conflictKeys && conflictKeys.length > 0) { - existingRecord = table.find(r => conflictKeys.every(key => r[key] === data[key])); + existingRecord = visible.find(r => conflictKeys.every(key => r[key] === data[key])); } if (existingRecord) { @@ -764,10 +834,13 @@ export class InMemoryDriver implements IDataDriver { async delete(object: string, id: string | number, options?: DriverOptions) { this.logger.debug('Delete operation', { object, id }); - + const table = this.getTable(object); - const index = table.findIndex(r => r.id == id); - + // [#16589] Scoped on the same reading as `update` above, and on the same + // "not found" contract. + const scope = this.tenantScope(object, options); + const index = table.findIndex(r => r.id == id && (!scope || scope(r))); + if (index === -1) { if (this.config.strictMode) { throw new Error(`Record with ID ${id} not found in ${object}`); @@ -783,7 +856,9 @@ export class InMemoryDriver implements IDataDriver { } async count(object: string, query?: DriverQuery, options?: DriverOptions) { - let records = this.getTable(object); + // [#16589] Scoped like `find`, and for the same reason: a count is a read. + const scope = this.tenantScope(object, options); + let records = scope ? this.getTable(object).filter(scope) : this.getTable(object); if (query?.where) { const mongoQuery = this.convertToMongoQuery(query.where, object); if (mongoQuery && Object.keys(mongoQuery).length > 0) { @@ -848,8 +923,14 @@ export class InMemoryDriver implements IDataDriver { this.logger.debug('UpdateMany operation', { object, query }); const table = this.getTable(object); - let targetRecords = table; - + // [#16589] The PREDICATE of an updateMany is a read, and `driver-sql` + // routes it through `applyTenantScope` for exactly that reason: without + // it a caller scoped to one organization rewrites every organization's + // rows. `settled` below is still drawn from the WHOLE table, so rows this + // caller cannot see are untouched AND still contested for uniqueness. + const scope = this.tenantScope(object, options); + let targetRecords = scope ? table.filter(scope) : table; + if (query && query.where) { const mongoQuery = this.convertToMongoQuery(query.where, object); if (mongoQuery && Object.keys(mongoQuery).length > 0) { @@ -891,21 +972,28 @@ export class InMemoryDriver implements IDataDriver { const table = this.getTable(object); const initialLength = table.length; - + // [#16589] The predicate of a deleteMany is a read too — and this is the + // door where the old silence cost the most: a caller scoped to one + // organization asking to "delete all" emptied the table for EVERY + // organization. Scoped, the delete-all arm removes exactly the rows this + // caller can see and leaves the rest where they are. + const scope = this.tenantScope(object, options); + const visible = scope ? table.filter(scope) : table; + if (query && query.where) { const mongoQuery = this.convertToMongoQuery(query.where, object); if (mongoQuery && Object.keys(mongoQuery).length > 0) { const mingoQuery = new Query(mongoQuery); - const matched = mingoQuery.find(table).all(); + const matched = mingoQuery.find(visible).all(); const matchedIds = new Set(matched.map((r: any) => r.id)); this.db[object] = table.filter(r => !matchedIds.has(r.id)); } else { // Empty query = delete all - this.db[object] = []; + this.db[object] = scope ? table.filter(r => !scope(r)) : []; } } else { // No where clause = delete all - this.db[object] = []; + this.db[object] = scope ? table.filter(r => !scope(r)) : []; } const count = initialLength - this.db[object].length; @@ -963,7 +1051,14 @@ export class InMemoryDriver implements IDataDriver { // with a false `UNIQUE_VIOLATION`. Both lookups now read the same stored // value, so they cannot disagree — the property `updateMany` gets for free // by drawing its `targetIds` from table rows. - const resolvedIndexes = updates.map((u) => table.findIndex((r) => r.id == u.id)); + // [#16589] Every id resolves through the tenant scope, so an id naming + // another organization's row resolves to -1 and takes this method's OWN + // missing-id arm (refuse the batch under `strictMode`, skip it otherwise) — + // the same landing `update` gives the single-row case. + const scope = this.tenantScope(object, options); + const resolvedIndexes = updates.map( + (u) => table.findIndex((r) => r.id == u.id && (!scope || scope(r))), + ); const touchedIds = new Set( resolvedIndexes.filter((index) => index !== -1).map((index) => table[index].id), ); @@ -1043,9 +1138,11 @@ export class InMemoryDriver implements IDataDriver { // Resolve every id to a table index BEFORE removing any of them, so a // strict-mode refusal on a later id cannot leave an earlier one already // spliced out. A `Set` absorbs a duplicate id naming the same index twice. + // [#16589] Scoped like `bulkUpdate`, onto `delete`'s own missing-id arm. + const scope = this.tenantScope(object, options); const indices = new Set(); for (const id of ids) { - const index = table.findIndex((r) => r.id == id); + const index = table.findIndex((r) => r.id == id && (!scope || scope(r))); if (index === -1) { if (this.config.strictMode) { throw new Error(`Record with ID ${id} not found in ${object}`); @@ -1188,6 +1285,12 @@ export class InMemoryDriver implements IDataDriver { // the pipeline arm is fed by `memory-analytics.ts` (`this.driver.aggregate( // tableName, pipeline)`), the AST arm by objectql's engine and // `@objectstack/verify`'s date-bucket parity probe. + // [#16589] One scope for both arms — an aggregate is a read, and the spec's + // own `DriverOptions.tenantIds` docblock names aggregates in the same + // breath as reads for any driver that implements native scoping. The + // pipeline arm's live producer (`memory-analytics.ts`) passes no options + // and is therefore unscoped exactly as before. + const scope = this.tenantScope(object, options); if (!Array.isArray(pipeline)) { const query = pipeline; this.logger.debug('Aggregate operation (QueryAST)', { @@ -1195,7 +1298,8 @@ export class InMemoryDriver implements IDataDriver { groupBy: (query as any).groupBy, aggregations: (query as any).aggregations?.length ?? 0, }); - let results = this.getTable(object).map((r) => ({ ...r })); + const scoped = scope ? this.getTable(object).filter(scope) : this.getTable(object); + let results = scoped.map((r) => ({ ...r })); if (query.where) { const mongoQuery = this.convertToMongoQuery(query.where, object); if (mongoQuery && Object.keys(mongoQuery).length > 0) { @@ -1207,7 +1311,8 @@ export class InMemoryDriver implements IDataDriver { this.logger.debug('Aggregate operation', { object, stageCount: pipeline.length }); - const records = this.getTable(object).map(r => ({ ...r })); + const source = scope ? this.getTable(object).filter(scope) : this.getTable(object); + const records = source.map(r => ({ ...r })); const aggregator = new Aggregator(pipeline); const results = aggregator.run(records); @@ -1939,6 +2044,16 @@ export class InMemoryDriver implements IDataDriver { ...uniqueConstraintsFromFields(schema), ...uniqueConstraintsFromDeclaredIndexes(schema), ]); + // [#16589] Learn the tenant column in the same pass, from the same schema + // and through the same resolver the uniqueness key above already uses — + // `driver-sql` records it here too (`computeAndRecordTenantField`, called + // from `initObjects` / `registerObjectMetadata`). Deliberately NOT + // retroactive, for the reason stated one comment up: rows already in the + // table arrived from `initialData` or a persistence adapter, before any + // schema existed, and this driver does not rewrite them. They carry + // whatever organization they were written with — including none, which the + // scope reads as a global row. + this.tenantFieldByObject.set(object, recordTenantField(object, schema, this.tenantOptOutByObject)); if (kinds.size > 0) { const table = this.db[object]; for (let i = 0; i < table.length; i++) { @@ -1956,6 +2071,12 @@ export class InMemoryDriver implements IDataDriver { // would be enforced over a table nobody declared — the inverse of the // gap this closes, and just as invisible. this.uniqueConstraints.delete(object); + // [#16589] Same reasoning for the tenant column and its sticky opt-out: + // a scope left behind would partition a table nobody declared, and a + // stale opt-out would silence the scope on the NEXT object to take this + // name. + this.tenantFieldByObject.delete(object); + this.tenantOptOutByObject.delete(object); this.logger.info('Dropped in-memory table', { object, recordCount }); } } @@ -2160,6 +2281,29 @@ export class InMemoryDriver implements IDataDriver { return this.db[name]; } + /** + * [#16589] The one place this driver turns `DriverOptions.tenantId` / + * `tenantIds` into a decision about rows — the in-memory counterpart of + * `SqlDriver.applyTenantScope`, and like it the single chokepoint every door + * routes through. + * + * Returns `null` for "nothing to scope", which every call that exists today + * gets: no `tenantId`, or an object with no tenant column. Each door keeps + * its original code on that arm, so an unscoped call is unchanged down to the + * array it allocates. + * + * ⚠️ `distinct()` is the one read door that cannot come here: its signature + * (`object, field, query?`) accepts no `DriverOptions` at all, so a caller has + * nowhere to pass a tenant even deliberately. `driver-sql`'s `distinct` DOES + * scope — that asymmetry is stated rather than left to be discovered, and it + * is not closed here because nothing in this repository calls + * `driver.distinct()`, so widening the signature would add a parameter no + * producer supplies (Prime Directive #10, from the other side). + */ + private tenantScope(object: string, options?: DriverOptions): TenantRowPredicate | null { + return tenantScopePredicate(this.tenantFieldByObject.get(object) ?? null, options); + } + private generateId(objectName?: string) { const key = objectName || '_global'; const counter = (this.idCounters.get(key) || 0) + 1; diff --git a/packages/drivers/driver-memory/src/memory-tenancy-guard.ts b/packages/drivers/driver-memory/src/memory-tenancy-guard.ts index 4696c2181a..e0e630c640 100644 --- a/packages/drivers/driver-memory/src/memory-tenancy-guard.ts +++ b/packages/drivers/driver-memory/src/memory-tenancy-guard.ts @@ -3,22 +3,33 @@ /** * In-Memory Driver — multi-tenancy boot guard (#6915, mirroring #3724). * - * This driver implements **no row-level tenant isolation**: it never reads - * `DriverOptions.tenantId`, so reads carry no tenant predicate and writes are - * never stamped with a tenant column. The SQL family's `resolveTenantField()` + - * `applyTenantScope()` layer does not exist here at all — which is why - * `scripts/check-tenant-chokepoint.mjs` scans `driver-sql` / - * `driver-sqlite-wasm` / `driver-turso` and not this package: a driver that - * REFUSES multi-tenant has no read-side chokepoint for that gate to re-derive. + * This driver implements **half** of row-level tenant isolation, and the guard + * below exists because of the missing half. Since #16589 it DOES read + * `DriverOptions.tenantId` / `tenantIds`: `memory-tenant-scope.ts` is the read + * side, and every door that takes a `DriverOptions` routes through it. What is + * still absent is the WRITE side — nothing stamps a tenant column on insert the + * way `SqlDriver.injectTenantOnInsert` does, so a row created without an + * explicit organization lands org-less and is then global to every caller. + * Running walled on that is worse than refusing, which is what this guard does. + * + * The SQL family's `getBuilder()` + `applyTenantScope()` layer still does not + * exist here — this driver filters an array rather than building a query — so + * `scripts/check-tenant-chokepoint.mjs` continues to scan `driver-sql` / + * `driver-sqlite-wasm` / `driver-turso` and not this package: its criterion is + * the knex builder, which has nothing to key on here. The in-memory doors are + * held by `memory-tenant-scope.test.ts` instead. * `distinct(object, field, query?)` does not even accept a `DriverOptions`, so a - * caller has nowhere to pass a tenant even deliberately. + * caller has nowhere to pass a tenant even deliberately — it is the one read + * door the scope above cannot reach, named here rather than left to be found. * * The platform above the driver assumes tenant isolation is a *platform* * guarantee (object metadata's `tenancy` block, `applySystemFields` injecting * `organization_id`, the engine threading `tenantId` into every driver call). * Booting this driver into a multi-tenant deployment therefore produces - * **silent** cross-tenant reads, updates and deletes — the exact - * "declared ≠ enforced" shape Prime Directive #10 forbids. + * **silently unstamped writes** — rows that belong to no organization and are + * consequently readable by all of them — the exact "declared ≠ enforced" shape + * Prime Directive #10 forbids. Until #16589 the reads were silently + * cross-tenant as well. * * So the driver refuses to run there. It is positioned as a **dev / demo / * in-process** driver (#5704 moved the project's own test backends to sqlite @@ -74,14 +85,18 @@ export class MemoryMultiTenantUnsupportedError extends Error { constructor(detected: string, remedy: string) { super( - `[driver-memory] Refusing to start: this driver has NO row-level tenant isolation.\n` + + // ⛔ No tracker id in this text: it is a RUNTIME string an operator reads, + // and `#NNNN` resolves to nothing for them (`pnpm check:doc-authoring`). + // The anchor for this driver's tenancy work is `Tracking:` below. + `[driver-memory] Refusing to start: this driver has only HALF of row-level tenant isolation.\n` + `\n` + ` Detected: ${detected}\n` + `\n` + - ` InMemoryDriver never reads \`DriverOptions.tenantId\` — reads carry no tenant\n` + - ` predicate and writes are not stamped with a tenant column, so queries would\n` + - ` read, update and delete OTHER tenants' records. Rather than run unisolated,\n` + - ` the driver fails at startup.\n` + + ` InMemoryDriver scopes reads, updates and deletes by \`DriverOptions.tenantId\`,\n` + + ` but it does NOT stamp a tenant column on writes: a record created without an\n` + + ` explicit organization lands with none, and a record with no organization is\n` + + ` visible to EVERY tenant. Rather than run half-isolated, the driver fails at\n` + + ` startup.\n` + `\n` + ` Fix one of:\n` + ` • Use @objectstack/driver-sql (PostgreSQL / MySQL / SQLite) for multi-tenant\n` + @@ -102,13 +117,32 @@ export interface TenancyAwareSchema { } /** - * Whether an object definition asks for row-level tenant isolation. + * Whether an object definition asks for row-level tenant isolation **loudly + * enough that this driver must refuse to allocate its table at all**. + * + * Only an **explicit** `tenancy.enabled === true` counts, because that is the + * declaration asking for the half this driver does not have: a tenant column + * stamped on every insert. Platform-wide posture is checked separately by + * {@link assertSingleTenantPosture}. + * + * ⚠️ This is deliberately NOT the engine's predicate, and that difference is + * where #16589 lived. `Engine.buildDriverOptions` scopes unless the object opts + * OUT (`tenantId !== undefined && !isTenancyDisabled(schema) && !isFederated`), + * so an object that OMITS the `tenancy` block — the common case — is scoped by + * the engine while this function answers `false` about it. That gap used to be + * silence: the driver discarded the `tenantId` and handed back every + * organization's rows. It is no longer silence — `memory-tenant-scope.ts` + * honours the scope on the read path — so what is left here is only the + * refusal, which is narrower than the scope on purpose. * - * Only an **explicit** `tenancy.enabled === true` counts. An absent `tenancy` - * block is not treated as a multi-tenant signal here: platform-wide tenant - * scoping is driven by the deployment posture (checked separately by - * {@link assertSingleTenantPosture}), and every object in a single-tenant - * deployment omits the block. + * ⛔ The sentence this docstring used to carry — *"every object in a + * single-tenant deployment omits the block"* — was FALSE, and it was + * load-bearing, so it is recorded here rather than quietly deleted. `single` + * constrains the **wall**, not the number of organizations: a `single`-posture + * run was measured holding **13** `sys_organization` rows (twelve seeded by the + * app, one the platform mints for the admin), and rows carry whichever + * `organization_id` they were written with. The engine's scope is therefore + * meaningful under `single`, and discarding it changed results. */ export function declaresTenantScope(schema: unknown): boolean { return (schema as TenancyAwareSchema | null | undefined)?.tenancy?.enabled === true; diff --git a/packages/drivers/driver-memory/src/memory-tenant-scope.test.ts b/packages/drivers/driver-memory/src/memory-tenant-scope.test.ts new file mode 100644 index 0000000000..4b254b15d6 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-tenant-scope.test.ts @@ -0,0 +1,466 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Read-side tenant scoping on the in-memory driver (#16589). + * + * ## The control has to be able to fail, and this is what it takes + * + * The defect being closed is precisely a suite that could not fail: an app + * asserting "tenant A cannot see tenant B's rows" passed on this driver not + * because isolation worked but because every tenant's rows came back to + * everyone and the assertion had been written against a SINGLE tenant's + * fixture. Reproducing that shape here would be self-parody, so the fixture is + * built the other way round and every case asserts BOTH directions: + * + * - **two organizations are always seeded**, so "returns nothing" and + * "returns everything" are distinguishable answers; + * - **an org-less row is always seeded**, so the #2734 global-row carve-out + * has something to be right or wrong about — a fixture without one cannot + * tell correct scoping from `WHERE org = :tenant`, which would hide every + * platform row from every tenant; + * - every scoped case asserts the caller's OWN rows are still returned, so a + * scope that simply answers empty fails here rather than reading as a pass. + * + * ⚠️ What the fixture DROPS, stated rather than implied: it is a driver-level + * fixture, so it exercises `DriverOptions` and not the engine that fills them + * (`Engine.buildDriverOptions` — the producer half is `packages/objectql`'s), + * and it never boots a walled posture, because the boot guard refuses one and + * that refusal has its own suite (`memory-tenancy-guard.test.ts`). `distinct()` + * is absent for a structural reason named in its own case below. + * + * ## Which of these cases can actually fail, MEASURED + * + * Ablating the driver-side chokepoint (`InMemoryDriver.tenantScope` forced to + * `null`, rebuilt, marker verified in `dist/`) turns **16 of 23** red. The + * seven that stay green are accounted for rather than assumed: + * + * - **five are negative controls whose SUBJECT is the unscoped answer** — no + * `tenantId`, `tenancy.enabled: false`, no tenant column, the sticky + * opt-out, and unscoped `distinct()`. A mutation that forces "no scope" + * cannot redden a case that expects no scope, and that is the point of + * them: they are the half that catches OVER-scoping, which is the failure + * direction the ablation cannot produce. + * - **two test the pure predicate**, `tenantScopePredicate`, which the + * chokepoint ablation deliberately does not touch — a different layer, + * named rather than left to look like coverage of the doors. + * + * ⭐ An eighth case used to be in that list for a BAD reason: with only two + * organizations seeded, the union case's `[ORG_A, ORG_B]` covered the whole + * table, so "the union widened the scope" and "no scope ran" were the same + * answer. It carries its own third organization now. The ablation is what + * found it; ⛔ do not remove that row. + */ + +import { describe, it, expect } from 'vitest'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { InMemoryDriver } from './memory-driver.js'; +import { tenantScopePredicate } from './memory-tenant-scope.js'; + +/** The shape the fixtures below declare — no wider than they need. */ +interface SeedSchema { + name: string; + fields: Record; + tenancy?: { enabled?: boolean; tenantField?: string }; +} + +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; + +/** The card's case exactly: a tenant column, and NO `tenancy` block at all. */ +const EMPLOYER_SCHEMA: SeedSchema = { + name: 'ats_employer', + fields: { + id: { type: 'string' }, + name: { type: 'string' }, + // The column `applySystemFields` injects into every object it registers. + organization_id: { type: 'string' }, + }, +}; + +/** ADR-0066's platform-global posture — the objects that AGREED across drivers. */ +const LICENSE_SCHEMA: SeedSchema = { + name: 'sys_license', + fields: { + id: { type: 'string' }, + organization_id: { type: 'string' }, + }, + tenancy: { enabled: false }, +}; + +/** No tenant column at all: nothing to scope by, on any driver. */ +const NOTE_SCHEMA: SeedSchema = { + name: 'note', + fields: { id: { type: 'string' }, body: { type: 'string' } }, +}; + +/** A wall drawn by a column that deliberately is not the platform's. */ +const WORKSPACE_ITEM_SCHEMA: SeedSchema = { + name: 'workspace_item', + fields: { + id: { type: 'string' }, + workspace_id: { type: 'string' }, + organization_id: { type: 'string' }, + }, + tenancy: { tenantField: 'workspace_id' }, +}; + +/** + * Two organizations plus one org-less row, on every object. + * + * `a1`/`a2` belong to A, `b1` to B, `g1` to nobody. A correct scope for A + * answers `[a1, a2, g1]`; the pre-#16589 driver answered all four; a scope + * without the global carve-out answers `[a1, a2]`; a broken scope answers `[]`. + * All four are distinguishable, which is the point. + */ +async function seed(driver: InMemoryDriver, schema: SeedSchema = EMPLOYER_SCHEMA) { + const object = schema.name; + await driver.syncSchema(object, schema); + const tenantField = schema.tenancy?.tenantField ?? 'organization_id'; + await driver.bulkCreate(object, [ + { id: 'a1', name: 'A one', [tenantField]: ORG_A }, + { id: 'a2', name: 'A two', [tenantField]: ORG_A }, + { id: 'b1', name: 'B one', [tenantField]: ORG_B }, + { id: 'g1', name: 'global' }, + ]); + return object; +} + +function ids(rows: Array>): string[] { + return rows.map((r) => String(r.id)).sort(); +} + +function makeDriver() { + return new InMemoryDriver({ persistence: false }); +} + +describe('#16589 — the in-memory driver honours DriverOptions.tenantId', () => { + describe('the binding control: a cross-organization read', () => { + it('returns ONLY this organization plus org-less rows — never the other organization', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + const scoped = await driver.find(object, {}, { tenantId: ORG_A }); + + // The half that fails when the fix is absent: B's row must not be here. + expect(ids(scoped)).not.toContain('b1'); + // The half that fails when the scope is merely "return nothing": A's own + // rows, and the org-less platform row, must still be here. + expect(ids(scoped)).toEqual(['a1', 'a2', 'g1']); + + // And the mirror image, so a scope hard-wired to one organization fails. + const other = await driver.find(object, {}, { tenantId: ORG_B }); + expect(ids(other)).toEqual(['b1', 'g1']); + }); + + it('leaves an unscoped caller reading everything — the seed / admin path', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + expect(ids(await driver.find(object, {}))).toEqual(['a1', 'a2', 'b1', 'g1']); + // An empty tenantId is the same "no tenant supplied" fact, spelled the + // way `applyTenantScope` early-outs on it. + expect(ids(await driver.find(object, {}, { tenantId: '' }))).toEqual(['a1', 'a2', 'b1', 'g1']); + }); + + it('composes with the caller\'s own filter instead of replacing it', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + const rows = await driver.find( + object, + { where: { type: 'comparison', field: 'name', operator: '!=', value: 'global' } }, + { tenantId: ORG_A }, + ); + expect(ids(rows)).toEqual(['a1', 'a2']); + }); + }); + + describe('the declaration decides, exactly as it does on driver-sql', () => { + it('scopes an object that OMITS the tenancy block — the case the engine scopes', async () => { + const driver = makeDriver(); + const object = await seed(driver, EMPLOYER_SCHEMA); + expect(ids(await driver.find(object, {}, { tenantId: ORG_A }))).toEqual(['a1', 'a2', 'g1']); + }); + + it('does NOT scope an explicit `tenancy.enabled: false` (ADR-0066 platform-global)', async () => { + const driver = makeDriver(); + const object = await seed(driver, LICENSE_SCHEMA); + expect(ids(await driver.find(object, {}, { tenantId: ORG_A }))).toEqual(['a1', 'a2', 'b1', 'g1']); + }); + + it('does NOT scope an object with no tenant column', async () => { + const driver = makeDriver(); + await driver.syncSchema('note', NOTE_SCHEMA); + await driver.bulkCreate('note', [ + { id: 'n1', body: 'one', organization_id: ORG_A }, + { id: 'n2', body: 'two', organization_id: ORG_B }, + ]); + // `organization_id` is present in the DATA but not in the DECLARED + // fields, so there is no tenant column and nothing to scope by — the same + // answer `SqlDriver.computeTenantField` gives. + expect(ids(await driver.find('note', {}, { tenantId: ORG_A }))).toEqual(['n1', 'n2']); + }); + + it('honours a declared `tenancy.tenantField` over the implicit organization_id', async () => { + const driver = makeDriver(); + const object = await seed(driver, WORKSPACE_ITEM_SCHEMA); + // Rows were stamped on `workspace_id`; `organization_id` is absent from + // all of them. Scoping by the declared column answers A's rows; scoping + // by the implicit one would answer all four (every row org-less). + expect(ids(await driver.find(object, {}, { tenantId: ORG_A }))).toEqual(['a1', 'a2', 'g1']); + }); + + it('keeps a sticky opt-out across a PARTIAL re-registration (#3249)', async () => { + const driver = makeDriver(); + const object = await seed(driver, LICENSE_SCHEMA); + // The lifecycle archive path re-syncs with `{ name, fields }` and no + // `tenancy`. Letting the implicit heuristic re-scope here would hide + // every org-less platform row from every tenant. + await driver.syncSchema(object, { name: object, fields: LICENSE_SCHEMA.fields }); + expect(ids(await driver.find(object, {}, { tenantId: ORG_A }))).toEqual(['a1', 'a2', 'b1', 'g1']); + }); + + it('re-scopes when a later schema DECLARES tenancy again', async () => { + const driver = makeDriver(); + const object = await seed(driver, LICENSE_SCHEMA); + await driver.syncSchema(object, { ...LICENSE_SCHEMA, tenancy: {} }); + expect(ids(await driver.find(object, {}, { tenantId: ORG_A }))).toEqual(['a1', 'a2', 'g1']); + }); + }); + + describe('ADR-0105 D2 union scope (`group` posture)', () => { + it('widens to the whole membership set instead of ANDing the active org', async () => { + const driver = makeDriver(); + const object = await seed(driver); + // ⚠️ A THIRD organization, outside the membership set, and it is + // load-bearing: with only A and B seeded their union IS the whole table, + // so this case answered the same rows whether the union widened the scope + // or no scope ran at all. Measured — under the two-leg ablation that + // disables the chokepoint it stayed GREEN, alone among the scoped cases. + // `org_c` is what makes "widened" and "disabled" different answers. + await driver.create(object, { id: 'c1', name: 'C one', organization_id: 'org_c' }); + + const rows = await driver.find(object, {}, { tenantId: ORG_A, tenantIds: [ORG_A, ORG_B] }); + expect(ids(rows)).toEqual(['a1', 'a2', 'b1', 'g1']); + }); + + it('falls back to equality on a malformed or empty set — toward isolation', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + expect(ids(await driver.find(object, {}, { tenantId: ORG_A, tenantIds: [] }))) + .toEqual(['a1', 'a2', 'g1']); + expect( + ids(await driver.find(object, {}, { tenantId: ORG_A, tenantIds: ['', ''] as string[] })), + ).toEqual(['a1', 'a2', 'g1']); + }); + }); + + describe('every door that takes DriverOptions', () => { + it('findOne cannot reach another organization row by id', async () => { + const driver = makeDriver(); + const object = await seed(driver); + const where = { type: 'comparison', field: 'id', operator: '=', value: 'b1' } as const; + + expect(await driver.findOne(object, { where }, { tenantId: ORG_A })).toBeNull(); + expect(await driver.findOne(object, { where }, { tenantId: ORG_B })).toMatchObject({ id: 'b1' }); + }); + + it('count counts only what this organization can read', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + expect(await driver.count(object, {}, { tenantId: ORG_A })).toBe(3); + expect(await driver.count(object, {}, { tenantId: ORG_B })).toBe(2); + expect(await driver.count(object, {})).toBe(4); + }); + + it('aggregate answers over the scoped rows on BOTH of its arms', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + // AST arm — what objectql's engine sends. + const ast = await driver.aggregate( + object, + { aggregations: [{ function: 'count', field: 'id', alias: 'n' }] } satisfies DriverQuery, + { tenantId: ORG_A }, + ); + expect(ast[0]?.n).toBe(3); + + // Pipeline arm — what `memory-analytics.ts` sends. + const pipeline = await driver.aggregate( + object, + [{ $group: { _id: null, n: { $sum: 1 } } }], + { tenantId: ORG_A }, + ); + expect(pipeline[0]?.n).toBe(3); + }); + + it('update by id treats another organization row as not found', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + expect(await driver.update(object, 'b1', { name: 'hijacked' }, { tenantId: ORG_A })).toBeNull(); + const [b1] = await driver.find( + object, + { where: { type: 'comparison', field: 'id', operator: '=', value: 'b1' } }, + ); + expect(b1.name).toBe('B one'); + + // Positive control: the same call inside the organization still works. + expect(await driver.update(object, 'a1', { name: 'renamed' }, { tenantId: ORG_A })) + .toMatchObject({ id: 'a1', name: 'renamed' }); + }); + + it('delete by id treats another organization row as not found', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + expect(await driver.delete(object, 'b1', { tenantId: ORG_A })).toBe(false); + expect(await driver.delete(object, 'a1', { tenantId: ORG_A })).toBe(true); + expect(ids(await driver.find(object, {}))).toEqual(['a2', 'b1', 'g1']); + }); + + it('updateMany rewrites only this organization', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + expect(await driver.updateMany(object, {}, { name: 'stamped' }, { tenantId: ORG_A })).toBe(3); + const all = await driver.find(object, {}); + expect(all.find((r) => r.id === 'b1')?.name).toBe('B one'); + expect(all.find((r) => r.id === 'a1')?.name).toBe('stamped'); + }); + + it('deleteMany with no filter empties only this organization', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + // The worst-shaped case of the old silence: "delete all" scoped to one + // organization used to empty the table for every organization. + expect(await driver.deleteMany(object, {}, { tenantId: ORG_A })).toBe(3); + expect(ids(await driver.find(object, {}))).toEqual(['b1']); + }); + + it('deleteMany WITH a `where` deletes only this organization\'s matches', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + // A different code path from the delete-all arm above, and the one worth + // pinning separately: it filters `visible`, collects `matchedIds`, then + // rebuilds the whole table from that set — so an id that crossed the wall + // would take the other organization's row with it. `contains 'one'` + // matches exactly one row in A (`a1`) and one in B (`b1`), which makes + // "scoped" and "unscoped" two different NUMBERS, not just two row lists. + expect( + await driver.deleteMany( + object, + { where: { type: 'comparison', field: 'name', operator: 'contains', value: 'one' } }, + { tenantId: ORG_A }, + ), + ).toBe(1); + // `b1` matched the filter and is the row that must survive it. + expect(ids(await driver.find(object, {}))).toEqual(['a2', 'b1', 'g1']); + }); + + it('bulkUpdate and bulkDelete skip ids belonging to another organization', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + const updated = await driver.bulkUpdate( + object, + [{ id: 'a1', data: { name: 'mine' } }, { id: 'b1', data: { name: 'theirs' } }], + { tenantId: ORG_A }, + ); + expect(ids(updated)).toEqual(['a1']); + + await driver.bulkDelete(object, ['a2', 'b1'], { tenantId: ORG_A }); + expect(ids(await driver.find(object, {}))).toEqual(['a1', 'b1', 'g1']); + }); + + it('upsert inserts rather than rewriting a row it cannot see', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + await driver.upsert(object, { name: 'A three' }, ['name'], { tenantId: ORG_A }); + expect((await driver.find(object, {})).length).toBe(5); + + // Conflicting on B's row from inside A does not touch it. + await driver.upsert(object, { name: 'B one', organization_id: ORG_A }, ['name'], { tenantId: ORG_A }); + const b1 = (await driver.find(object, {})).find((r) => r.id === 'b1'); + expect(b1).toMatchObject({ organization_id: ORG_B }); + }); + + it('upsert by an id that exists OUTSIDE the scope refuses — never a second row with one primary id', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + // The `conflictKeys` arm above may insert; the `id` arm may NOT. `id` is + // this store's primary id, and falling through to `create` here landed a + // SECOND row carrying `b1` — `create` checks only DECLARED unique + // constraints and `id` is not one. A duplicate primary id then corrupts + // every id-addressed door for BOTH tenants. ⛔ `driver-sql` is not the + // precedent for falling through: it merges on the PRIMARY KEY regardless + // of tenant and scopes only the readback, so the duplicate is unreachable + // there. + await expect( + driver.upsert(object, { id: 'b1', name: 'hijacked' }, undefined, { tenantId: ORG_A }), + ).rejects.toThrow(/Record with ID b1 not found/); + + const after = await driver.find(object, {}); + expect(ids(after)).toEqual(['a1', 'a2', 'b1', 'g1']); + // The assertion the whole finding is about — one row, not two. + expect(after.filter((r) => r.id === 'b1')).toHaveLength(1); + expect(after.find((r) => r.id === 'b1')).toMatchObject({ name: 'B one', organization_id: ORG_B }); + + // Two positive controls, so this reads as "the cross-wall id is refused" + // rather than "upsert by id is broken": inside the organization the same + // door still MERGES, and an id no row in the table carries still INSERTS. + expect( + await driver.upsert(object, { id: 'a1', name: 'renamed' }, undefined, { tenantId: ORG_A }), + ).toMatchObject({ id: 'a1', name: 'renamed' }); + expect( + await driver.upsert( + object, + { id: 'a3', name: 'A three', organization_id: ORG_A }, + undefined, + { tenantId: ORG_A }, + ), + ).toMatchObject({ id: 'a3' }); + expect(ids(await driver.find(object, {}))).toEqual(['a1', 'a2', 'a3', 'b1', 'g1']); + }); + + it('distinct() is NOT scoped — the one door with no DriverOptions to scope by', async () => { + const driver = makeDriver(); + const object = await seed(driver); + + // Pinned as a KNOWN unscoped face rather than left to be discovered: + // `distinct(object, field, query?)` accepts no `DriverOptions`, so a + // caller has nowhere to pass a tenant. `driver-sql`'s `distinct` DOES + // scope. Nothing in this repository calls `driver.distinct()`; if a + // producer ever appears, this expectation is the thing that has to change + // with it. + const values = await driver.distinct(object, 'organization_id'); + expect([...values].sort()).toEqual([ORG_A, ORG_B]); + }); + }); + + describe('the predicate itself', () => { + it('is null — no per-row work at all — on every unscoped arm', () => { + expect(tenantScopePredicate('organization_id', undefined)).toBeNull(); + expect(tenantScopePredicate('organization_id', {})).toBeNull(); + expect(tenantScopePredicate('organization_id', { tenantId: '' })).toBeNull(); + expect(tenantScopePredicate(null, { tenantId: ORG_A })).toBeNull(); + }); + + it('reads an absent key and an explicit null as the same global row', () => { + const predicate = tenantScopePredicate('organization_id', { tenantId: ORG_A }); + expect(predicate).not.toBeNull(); + expect(predicate!({ id: 'x' })).toBe(true); + expect(predicate!({ id: 'x', organization_id: null })).toBe(true); + expect(predicate!({ id: 'x', organization_id: ORG_A })).toBe(true); + expect(predicate!({ id: 'x', organization_id: ORG_B })).toBe(false); + // An empty string is NOT null — it is a value, and it is not this org. + expect(predicate!({ id: 'x', organization_id: '' })).toBe(false); + }); + }); +}); diff --git a/packages/drivers/driver-memory/src/memory-tenant-scope.ts b/packages/drivers/driver-memory/src/memory-tenant-scope.ts new file mode 100644 index 0000000000..0a16291943 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-tenant-scope.ts @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Read-side tenant scoping for the in-memory driver (#16589). + * + * ## The defect this closes + * + * Two predicates decided "is this object tenant-scoped", and they disagreed on + * the DEFAULT case. `Engine.buildDriverOptions` scopes unless the object opts + * OUT (`execCtx?.tenantId !== undefined && !isTenancyDisabled(objectSchema) && + * !isFederated`), while this package's `declaresTenantScope` refuses only + * an explicit opt-IN (`tenancy.enabled === true`). An object that omits the + * `tenancy` block — the common case — is therefore scoped by the engine and + * invisible to the boot guard, and this driver then did nothing with the scope: + * `tenantId`, `tenantIds` and `organization_id` occurred nowhere in + * `memory-driver.ts`. The read path knew nothing about tenants; the + * unique-constraint path did. + * + * The measured consequence: on one app, seven objects, same build, same seed, + * same account, the four objects that omit the block returned 12/30/40/14 rows + * on the in-memory driver against 0 on sqlite, and neither driver said a word. + * The three that declare `tenancy.enabled: false` agreed exactly — the split + * line WAS the declaration. + * + * ## Why implement rather than refuse + * + * The standing criterion for one operation with two implementations that + * disagree is that the GOVERNED side wins — here `driver-sql`, which enforces + * the scope. Making the boot guard adopt the engine's predicate instead would + * refuse every app that omits the block, which is refusal, not alignment. + * + * The failure direction is what makes it worth code rather than a doc note: + * toward exposure in the place where isolation is TESTED. A suite asserting + * "tenant A cannot see tenant B's rows" passed trivially here — not because + * isolation worked, but because both tenants' rows came back to everyone and + * the assertion had been written against a single tenant's fixture. ⚠️ Every + * isolation measurement previously taken on this driver is void and has to be + * re-taken. + * + * ## The semantics are `driver-sql`'s, not a simpler invention + * + * Read off `SqlDriver.applyTenantScope` (`packages/drivers/driver-sql/src/ + * sql-driver.ts`) and reproduced arm for arm, because the spec's own + * `DriverOptions.tenantIds` docblock states them once for every driver that + * implements native scoping — "scope reads/updates/deletes/aggregates with + * `IN`, keeping any NULL-tenant global-row carve-out the equality path has; + * absent or empty → fall back to `tenantId` equality (fail toward isolation, + * never toward exposure)": + * + * | fact | `driver-sql` | here | + * |:---|:---|:---| + * | no `tenantId` (`undefined` / `null` / `''`) | builder untouched | no predicate | + * | object has no tenant column | builder untouched | no predicate | + * | non-empty `tenantIds` | `col IN (…) OR col IS NULL` | membership OR global | + * | otherwise | `col = :tenantId OR col IS NULL` | equality OR global | + * + * The NULL arm is the #2734 rule and it is load-bearing rather than lenient: a + * row with no organization is a GLOBAL/platform row (bootstrap-seeded + * permission sets, business units, pre-org first-boot seeds), it belongs to no + * OTHER tenant, and strict equality made every tenant admin read ZERO RBAC rows + * on a fresh deployment. A row stamped with a DIFFERENT organization stays + * invisible. In this store the absence of the key is that same fact, so + * `undefined` and `null` are one arm. + * + * ## What this is NOT + * + * ⛔ Not write-side tenancy. `driver-sql` stamps the tenant column on insert + * (`injectTenantOnInsert`); nothing here does, so a row created without an + * explicit organization lands org-less and is then global by the rule above. + * That asymmetry is exactly why the boot guard still refuses a walled posture + * and an object declaring `tenancy.enabled: true` — this module is the read + * half of #6915's Route A, not the whole of it, and ⛔ it does not weaken that + * gate. + * + * ⛔ Not a chokepoint `scripts/check-tenant-chokepoint.mjs` can re-derive. That + * gate keys on `this.getBuilder(object, options)`, the single constructor of + * every knex query in the `SqlDriver` family; this driver builds no query at + * all, it filters an array, so the gate's criterion has nothing to key on here + * and its scope paragraph stays accurate. The doors are held instead by + * `memory-tenant-scope.test.ts`, which exercises each one. + */ + +import type { DriverOptions } from '@objectstack/spec/data'; +import { isTenancyDisabled } from '@objectstack/spec/data'; +import { tenantFieldOf, type UniqueAwareSchema } from './memory-unique-constraint.js'; + +/** A row of the backing store. */ +type StoredRow = Record; + +/** + * Answers "may this caller see this row" — `null` when nothing is scoped, which + * is the unscoped/admin path and the overwhelmingly common one. + */ +export type TenantRowPredicate = (row: StoredRow) => boolean; + +/** + * The tenant column for `object`, recorded with the sticky explicit-opt-out + * that `SqlDriver.computeAndRecordTenantField` keeps (#3249). + * + * A schema carrying a `tenancy` declaration is authoritative: it sets or clears + * the opt-out. A schema WITHOUT one is a partial re-registration — the + * lifecycle archive path calls `syncSchema` with only `{ name, fields }` — and + * must not let the implicit `organization_id` heuristic re-scope a table that + * was declared platform-global, which would HIDE its org-less rows from every + * caller that carries a tenant. + * + * The column itself is resolved by {@link tenantFieldOf}, this package's one + * spelling of "what is this object walled by" — already pinned arm for arm + * against `SqlDriver.computeTenantField` by `memory-unique-constraint.test.ts`. + * ⛔ Never add a second spelling here: a disagreement between the uniqueness + * key and the read scope would partition the same table two different ways. + * + * @param object the object name, keyed the same way the driver keys its store + * @param schema the schema as `syncSchema` received it + * @param optOut the driver's sticky opt-out record, mutated here + */ +export function recordTenantField( + object: string, + schema: unknown, + optOut: Set, +): string | null { + const declared = (schema as { tenancy?: unknown } | null | undefined)?.tenancy; + if (declared != null) { + if (isTenancyDisabled(schema)) optOut.add(object); + else optOut.delete(object); + return tenantFieldOf(schema as UniqueAwareSchema | null | undefined); + } + if (optOut.has(object)) return null; + return tenantFieldOf(schema as UniqueAwareSchema | null | undefined); +} + +/** + * The row predicate a caller's `DriverOptions` asks for, or `null` for no + * scoping at all. + * + * `null` rather than a tautology on purpose: it lets each door keep its + * existing fast path byte for byte, so an unscoped call — every call that + * exists today — does no per-row work and cannot change behaviour. + */ +export function tenantScopePredicate( + tenantField: string | null, + options?: DriverOptions, +): TenantRowPredicate | null { + const tenantId = options?.tenantId; + // Same early-out as `applyTenantScope`: without a tenant this is the + // unscoped/admin path — legacy callers, seed scripts and cross-org tooling + // keep working. + if (tenantId === undefined || tenantId === null || tenantId === '') return null; + if (!tenantField) return null; + + // [ADR-0105 D2 / #3623] Union scope under the `group` posture. A malformed or + // empty set falls through to the equality path: fail toward isolation, never + // toward exposure. + const raw = (options as { tenantIds?: unknown } | undefined)?.tenantIds; + const union = Array.isArray(raw) + ? raw.filter((v: unknown): v is string => typeof v === 'string' && v !== '') + : []; + + if (union.length > 0) { + const allowed = new Set(union.map(String)); + return (row: StoredRow) => { + const value = row?.[tenantField]; + if (value === undefined || value === null) return true; + return allowed.has(String(value)); + }; + } + + const wanted = String(tenantId); + return (row: StoredRow) => { + const value = row?.[tenantField]; + if (value === undefined || value === null) return true; + return String(value) === wanted; + }; +}