diff --git a/.changeset/25230-engine-declares-wall-exemption.md b/.changeset/25230-engine-declares-wall-exemption.md new file mode 100644 index 00000000000..931f4dcbdef --- /dev/null +++ b/.changeset/25230-engine-declares-wall-exemption.md @@ -0,0 +1,14 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): the engine declares the organization wall's exemption to the driver, so an exempt platform admin's predicate write is not told to declare an option it cannot set + +Clause-②: no +Metadata-protocol: none + +Under a walled posture an exempt platform admin's predicate update or delete reaches the driver with no `tenantId`, because the wall lifted the scope on purpose (ADR-0131 D8). The SQL driver's `[tenant-audit]` line then told the caller to declare `bypassTenantAudit: true`, an option no REST caller can set. + +The engine now fills in `bypassTenantAudit: true` on the driver options when the Layer 0 verdict the security layer recorded for the operation is `none` and the same decision without the exemption would have walled it. It does not fill it in when no verdict was recorded (a by-id write, a system context, no security layer), under the `single` posture, for an insert, for an object outside the tenant machinery, or when the caller supplied its own value. + +Diagnostics only: the driver's scope was already the recorded verdict's, and the option only silences the warning. diff --git a/.changeset/25230-tenant-audit-insert-carries-organization.md b/.changeset/25230-tenant-audit-insert-carries-organization.md new file mode 100644 index 00000000000..c8d5fbbbf63 --- /dev/null +++ b/.changeset/25230-tenant-audit-insert-carries-organization.md @@ -0,0 +1,14 @@ +--- +'@objectstack/driver-sql': patch +--- + +fix(driver-sql): the `[tenant-audit]` warning no longer fires for an insert whose rows carry their organization + +Clause-②: no +Metadata-protocol: none + +A `create` or `bulkCreate` that reaches the SQL driver with no `options.tenantId` used to print `[tenant-audit] … without options.tenantId` even when every row already named its organization in its tenant column (`organization_id`). Such an insert is confined to the organizations its rows name, so the line had nothing to report. A walled boot (`OS_TENANCY_POSTURE=isolated`) printed one for every elevated seed insert: 32 of its 55 lines. + +The line is now skipped when every inserted row carries a non-empty tenant column. It still fires for an insert where any row lacks the column, and for every `update`, `delete`, `updateMany`, `deleteMany`, `bulkDelete` and `upsert` with no `tenantId`, whatever the payload says: there the column is a value being written, not the statement's scope. The line's text and prefix are unchanged. + +Diagnostics only: no write changes. Turso's local face and the SQLite WASM driver inherit the change; the in-memory and MongoDB drivers have no such warning. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 1466e4757de..af4bda0d9f6 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -43,21 +43,29 @@ now costs nothing, and the population is **re-derivable** rather than quoted. ## What the control does, and what a call site controls -`auditMissingTenant(object, op, options)` is gated in this order: +`auditMissingTenant(object, op, options, insertRows)` is gated in this order: 1. `OS_TENANT_AUDIT=0` — off entirely; 2. `options.bypassTenantAudit` — on an `ExecutionContext.isSystem` write the engine sets it only where the object's schema puts the write out of scope: an object with no tenant field, or a federated one (ObjectQL's - `buildDriverOptions`). A caller may still pass it explicitly; + `buildDriverOptions`). It also sets it on a predicate write the + organization wall exempted (ADR-0131 D8: the recorded Layer 0 verdict is + `none` where the wall would otherwise apply). A caller may still pass it + explicitly; 3. a present `options.tenantId` — the engine fills it from `execCtx.tenantId`; 4. the deployment posture — the warning is only meaningful on a walled install; -5. the object having a tenant field. - -Both of the guards a *caller* can move are downstream of one decision at the call -site: **whether it threads an execution context at all**. Thread an elevated one -and guard 2 silences the warning; thread a tenant-bearing one and guard 3 does; -thread none and the site is what this census counts. +5. the object having a tenant field; +6. for `create` and `bulkCreate` only, every inserted row carrying that field — + an insert is confined to the organizations its rows name. Updates, deletes + and upserts are audited whatever their payload says. + +The guards a *caller* can move are downstream of one decision at the call +site: **whether it threads an execution context at all**. Thread a +tenant-bearing one and guard 3 silences the warning; thread an elevated one and +an object with a tenant column is still audited, unless the write inserts rows +that name their organization (guard 6); thread none and the site is what this +census counts. ## How the population is decided diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-audit-insert-carries-tenant.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-audit-insert-carries-tenant.test.ts new file mode 100644 index 00000000000..097209e0a5f --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-audit-insert-carries-tenant.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 8c-1] The `[tenant-audit]` line skips an INSERT whose row carries +// its tenant column, and only an insert. +// +// An insert names its organization in two places: `options.tenantId`, which +// `injectTenantOnInsert` stamps onto the row, and the row's own tenant column. +// A `create` / `bulkCreate` whose every row already carries that column is +// confined to the organizations it names, so the line had nothing to report — +// yet a walled boot printed one for every elevated seed insert (they reach the +// driver with no `tenantId` and an `organization_id` on the row; a system +// insert with none is refused above the driver, ADR-0131 D9). +// +// The other half is what must NOT move: an update or a delete with no +// `tenantId` still warns whatever its payload says (the row's column is a value +// being written there, not the statement's scope), and so does an upsert, whose +// merge leg can reach another organization's row. The prefix is byte-identical, +// because `delivery-claim-tenant-audit.integration.test.ts` matches it. +// +// Real `SqlDriver` against a real in-memory SQLite; the logger is the +// assertion surface. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; + +let driver: SqlDriver; +let warns: Array<{ msg: string; meta: any }>; + +/** + * An organization-less row is refused by the NOT NULL organization column + * (ADR-0131 D1) — AFTER the audit has run, which is the order these controls + * read: the line is printed, then the statement fails. + */ +const refused = (write: Promise) => + write.then( + () => { throw new Error('expected the organization-less insert to be refused'); }, + () => undefined, + ); + +const lines = (op?: string) => + warns.filter((w) => w.msg.startsWith(op === undefined ? '[tenant-audit] ' : `[tenant-audit] ${op} on tenant-scoped object "account"`)); + +beforeEach(async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + warns = []; + (driver as any).logger = { warn: (msg: string, meta: any) => warns.push({ msg, meta }) }; + await driver.initObjects([ + { name: 'account', fields: { organization_id: { type: 'string' }, name: { type: 'string' } } }, + ]); +}); +afterEach(async () => { + await driver.disconnect(); + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; +}); + +describe('[ADR-0131 8c-1] an insert whose row carries its organization is not audited', () => { + it('create: the row names its organization, no tenantId — no line', async () => { + await driver.create('account', { id: 'a1', organization_id: 'org_a', name: 'A1' }); + expect(lines()).toEqual([]); + }); + + it('bulkCreate: every row names its organization — no line, across organizations too', async () => { + await driver.bulkCreate('account', [ + { id: 'a1', organization_id: 'org_a', name: 'A1' }, + { id: 'b1', organization_id: 'org_b', name: 'B1' }, + ]); + expect(lines()).toEqual([]); + }); + + it('create: a row WITHOUT its organization still warns (control)', async () => { + await refused(driver.create('account', { id: 'a1', name: 'A1' })); + expect(lines('create')).toHaveLength(1); + expect(lines('create')[0].meta).toMatchObject({ object: 'account', op: 'create', tenantField: 'organization_id' }); + }); + + it('create: an empty organization is no organization — it still warns', async () => { + await driver.create('account', { id: 'a1', organization_id: '', name: 'A1' }); + expect(lines('create')).toHaveLength(1); + }); + + it('create: a null organization is no organization — it still warns', async () => { + await refused(driver.create('account', { id: 'a1', organization_id: null, name: 'A1' })); + expect(lines('create')).toHaveLength(1); + }); + + it('bulkCreate: ONE row without its organization keeps the line', async () => { + await refused(driver.bulkCreate('account', [ + { id: 'a1', organization_id: 'org_a', name: 'A1' }, + { id: 'a2', name: 'A2' }, + ])); + expect(lines('bulkCreate')).toHaveLength(1); + }); + + it('the prefix is byte-identical', async () => { + await refused(driver.create('account', { id: 'a1', name: 'A1' })); + expect(lines()[0].msg.startsWith( + '[tenant-audit] create on tenant-scoped object "account" without options.tenantId — ', + )).toBe(true); + }); +}); + +describe('[ADR-0131 8c-1] what must not move: non-insert writes with no tenantId still warn', () => { + beforeEach(async () => { + await driver.create('account', { id: 'a1', organization_id: 'org_a', name: 'A1' }, { tenantId: 'org_a' }); + warns = []; + }); + + it('update, even when its payload names an organization', async () => { + await driver.update('account', 'a1', { organization_id: 'org_a', name: 'renamed' }); + expect(lines('update')).toHaveLength(1); + }); + + it('delete', async () => { + await driver.delete('account', 'a1'); + expect(lines('delete')).toHaveLength(1); + }); + + it('updateMany', async () => { + await driver.updateMany('account', { where: { name: 'A1' } } as any, { organization_id: 'org_a', name: 'x' }); + expect(lines('updateMany')).toHaveLength(1); + }); + + it('deleteMany', async () => { + await driver.deleteMany('account', { where: { name: 'A1' } } as any); + expect(lines('deleteMany')).toHaveLength(1); + }); + + it('upsert, even when its row names an organization — the merge leg is an update', async () => { + await driver.upsert('account', { id: 'a1', organization_id: 'org_a', name: 'merged' }); + expect(lines('upsert')).toHaveLength(1); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts index 0ae24266a25..f401b9f3641 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-audit-posture.test.ts @@ -57,9 +57,15 @@ const bootDriver = async () => { await driver.initObjects(objects); }; -/** Writes WITHOUT a tenantId — the case the audit exists to catch. */ +/** + * Writes WITHOUT a tenantId — the case the audit exists to catch. A delete: + * an insert whose row carries its organization is not audited (ADR-0131 8c-1, + * pinned in `sql-driver-tenant-audit-insert-carries-tenant.test.ts`), and one + * whose row does not is refused by the NOT NULL organization column after the + * audit has run. The row need not exist: the audit precedes the statement. + */ const writeWithoutTenant = async (id: string) => { - await driver.create('account', { id, organization_id: 'org_a', name: id }); + await driver.delete('account', id); }; const tenantAuditWarned = () => warns.some((w) => w.msg.includes('[tenant-audit]')); @@ -93,7 +99,7 @@ describe('#5262 — tenant-audit fires on a posture-only walled deployment', () await writeWithoutTenant('x1'); expect(tenantAuditWarned()).toBe(true); - expect(warns[0].meta).toMatchObject({ object: 'account', op: 'create', tenantField: 'organization_id' }); + expect(warns[0].meta).toMatchObject({ object: 'account', op: 'delete', tenantField: 'organization_id' }); }); it('`group` is a walled posture too', async () => { @@ -140,11 +146,7 @@ describe('#5262 — tenant-audit fires on a posture-only walled deployment', () it('bypassTenantAudit still silences a walled deployment', async () => { setEnv({ posture: 'isolated' }); - await driver.create( - 'account', - { id: 'x1', organization_id: 'org_a', name: 'X1' }, - { bypassTenantAudit: true }, - ); + await driver.delete('account', 'x1', { bypassTenantAudit: true }); expect(tenantAuditWarned()).toBe(false); }); @@ -210,7 +212,7 @@ describe('#5262 — the verdict is taken LIVE, never frozen at first write', () describe('the [tenant-audit] line names both readings and the remedy for each', () => { it('a wall-exempt cross-organization updateMany is not told to pass tenantId', async () => { setEnv({ posture: 'isolated' }); - await writeWithoutTenant('x1'); + await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'x1' }); warns = []; // The exempt admin's shape: a predicate update with no tenantId. await driver.updateMany('account', { where: { name: 'x1' } } as any, { name: 'renamed' }); diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts index 049f31eb785..97848070255 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts @@ -429,10 +429,12 @@ describe('SqlDriver tenant scope (organization_id)', () => { try { await driver.initObjects(objects); - await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'X1' }); - await driver.create('account', { id: 'x2', organization_id: 'org_a', name: 'X2' }); - // Second create on same object:op should NOT add another warn (throttle). - expect(warnSpy.filter(w => w.meta?.op === 'create')).toHaveLength(1); + // Deletes: an insert whose row carries its organization is not audited + // at all (ADR-0131 8c-1), so a create cannot exercise the throttle. + await driver.delete('account', 'x1'); + await driver.delete('account', 'x2'); + // Second delete on same object:op should NOT add another warn (throttle). + expect(warnSpy.filter(w => w.meta?.op === 'delete')).toHaveLength(1); } finally { if (priorPosture === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = priorPosture; diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index faca2f370e2..5fa16c49f68 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7681,7 +7681,7 @@ export class SqlDriver implements IDataDriver { toInsert.id = nanoid(DEFAULT_ID_LENGTH); } - this.auditMissingTenant(object, 'create', options); + this.auditMissingTenant(object, 'create', options, [toInsert]); this.injectTenantOnInsert(object, toInsert, options); // #5495. The autonumber counter is bootstrapped from the data table exactly @@ -10141,7 +10141,6 @@ export class SqlDriver implements IDataDriver { * contract's type now, pinned by `sql-driver-doors-declared-types.test.ts`. */ async bulkCreate(object: string, data: any[], options?: DriverOptions): Promise[]> { - this.auditMissingTenant(object, 'bulkCreate', options); // Same client-side id assignment as create() (id/_id normalization, // nanoid fallback when neither is supplied) — a row missing an id must // not be silently inserted with a NULL primary key just because the @@ -10158,6 +10157,7 @@ export class SqlDriver implements IDataDriver { } return toInsert; }); + this.auditMissingTenant(object, 'bulkCreate', options, rows); for (const row of rows) { if (row && typeof row === 'object') this.injectTenantOnInsert(object, row, options); } @@ -16085,11 +16085,24 @@ export class SqlDriver implements IDataDriver { * `tenantId` (ADR-0131 D8). Telling that second write to "pass tenantId" * prescribes the one change that would break it, so ⛔ the remedy sentence * never addresses the write as if it were only the first kind. + * + * [ADR-0131 8c-1] An INSERT names its organization in a second place: the + * row's own tenant column, the slot {@link injectTenantOnInsert} fills from + * `tenantId`. A `create` / `bulkCreate` whose every row already carries that + * column is confined to the organizations its rows name, so there is nothing + * to report — and that is how the elevated seed and replay inserts of a + * walled boot arrive (a system insert with no organization is refused above + * this driver, ADR-0131 D9). `insertRows` is those rows, passed by the two + * insert doors only; one row without the column keeps the line. ⛔ Never for + * update, delete or upsert: there the column is a value being written, not + * the statement's scope (the line says so), and an upsert's merge leg can + * reach a row of another organization. Diagnostics only: no write changes. */ protected auditMissingTenant( object: string, op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert', options?: DriverOptions, + insertRows?: readonly unknown[], ): void { if (process.env.OS_TENANT_AUDIT === '0') return; if (options?.bypassTenantAudit === true) return; @@ -16109,6 +16122,13 @@ export class SqlDriver implements IDataDriver { if (!this.isMultiTenantMode()) return; const field = this.resolveTenantField(object); if (!field) return; + // Empty is spelled as `injectTenantOnInsert` spells it: a row it would + // stamp is a row that does not carry its organization. + const carriesTenant = (row: unknown): boolean => { + const value = row && typeof row === 'object' ? (row as Record)[field] : undefined; + return value !== undefined && value !== null && value !== ''; + }; + if (insertRows !== undefined && insertRows.length > 0 && insertRows.every(carriesTenant)) return; const key = `${object}:${op}`; if (this.tenantAuditWarned.has(key)) return; this.tenantAuditWarned.add(key); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts index c27c0e3f4c1..df79116a5ea 100644 --- a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-driver-tenant-scope.test.ts @@ -304,10 +304,12 @@ describe('SqliteWasmDriver tenant scope (organization_id)', () => { try { await driver.initObjects(objects); - await driver.create('account', { id: 'x1', organization_id: 'org_a', name: 'X1' }); - await driver.create('account', { id: 'x2', organization_id: 'org_a', name: 'X2' }); - // Second create on same object:op should NOT add another warn (throttle). - expect(warnSpy.filter(w => w.meta?.op === 'create')).toHaveLength(1); + // Deletes: an insert whose row carries its organization is not audited + // at all (ADR-0131 8c-1), so a create cannot exercise the throttle. + await driver.delete('account', 'x1'); + await driver.delete('account', 'x2'); + // Second delete on same object:op should NOT add another warn (throttle). + expect(warnSpy.filter(w => w.meta?.op === 'delete')).toHaveLength(1); } finally { if (priorPosture === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = priorPosture; diff --git a/packages/objectql/src/engine-tenant-audit-wall-exemption.test.ts b/packages/objectql/src/engine-tenant-audit-wall-exemption.test.ts new file mode 100644 index 00000000000..7d5652ddc1d --- /dev/null +++ b/packages/objectql/src/engine-tenant-audit-wall-exemption.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D8 · 8c-1] The engine declares the wall's exemption to the driver. + * + * An exempt platform admin's predicate write reaches the driver with no + * `tenantId`: the wall lifted the scope on purpose, and the driver's read scope + * is the recorded verdict (`engine-driver-read-scope.test.ts`). The driver's + * `[tenant-audit]` line then told that write to declare `bypassTenantAudit: + * true` — an option no REST caller can set, because only the engine builds the + * driver's options. The engine now fills it in when the recorded verdict is + * `none` and the same ladder without the exemption would have walled the + * operation: the exemption, read from the one place it is decided. + * + * Both directions are pinned, through the `DriverOptions` the driver receives + * (the same seam and harness as `engine-driver-read-scope.test.ts`): what is + * declared (a recorded `none` under a wall, on a tenant object), and what is + * not (no verdict recorded, a walled verdict, a `single` posture, an insert, a + * tenancy-disabled object, a caller's own value, a value that does not parse). + */ + +import { describe, it, expect } from 'vitest'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { ObjectQL } from './engine.js'; + +const ORG_A = 'org_a'; +const ADMIN: ExecutionContext = { userId: 'u_admin', tenantId: ORG_A }; + +interface ObservedCall { + method: string; + options: Record | undefined; +} + +function makeDriver(observed: ObservedCall[]) { + const record = (method: string, options: any) => observed.push({ method, options }); + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(_o: string, _ast: any, options: any) { record('find', options); return []; }, + async findOne(_o: string, _ast: any, options: any) { record('findOne', options); return null; }, + async count(_o: string, _ast: any, options: any) { record('count', options); return 0; }, + async create(_o: string, data: any, options: any) { record('create', options); return { id: 'r_1', ...data }; }, + async update(_o: string, id: string, data: any, options: any) { record('update', options); return { id, ...data }; }, + async updateMany(_o: string, _ast: any, _data: any, options: any) { record('updateMany', options); return 0; }, + async deleteMany(_o: string, _ast: any, options: any) { record('deleteMany', options); return 0; }, + async delete() { return true; }, + async bulkCreate() { return []; }, async bulkUpdate() { return []; }, async bulkDelete() {}, + async syncSchema() {}, + }; + return driver; +} + +/** Tenant-scoped by default: the registry injects `organization_id`. */ +const TASK = { name: 'ds_task', fields: { name: { type: 'text' } } } as any; +/** ADR-0066 platform-global: outside the tenant machinery. */ +const GLOBAL = { name: 'ds_global', tenancy: { enabled: false }, fields: { name: { type: 'text' } } } as any; + +async function makeEngine(opts: { posture: string; recordedVerdict?: unknown }) { + const observed: ObservedCall[] = []; + const engine = new ObjectQL(); + engine.registerDriver(makeDriver(observed), true); + await engine.init(); + engine.registry.registerObject(TASK, 'com.example.wall-exemption'); + engine.registry.registerObject(GLOBAL, 'com.example.wall-exemption'); + engine.setTenancyPostureProvider(() => opts.posture); + if (opts.recordedVerdict !== undefined) { + // Where plugin-security records what its wall decided (#15813). + engine.registerMiddleware(async (opCtx, next) => { + (opCtx as any).tenantLayer0Verdict = opts.recordedVerdict; + await next(); + }); + } + const last = (method: string) => observed.filter((c) => c.method === method).at(-1)?.options; + return { engine, last }; +} + +const updateMany = (engine: ObjectQL, object = 'ds_task', extra: Record = {}) => + engine.update(object, { name: 'x' }, { where: { name: 'y' }, multi: true, context: ADMIN, ...extra } as any); + +describe('[ADR-0131 D8 · 8c-1] a wall-exempted write is declared to the driver', () => { + it('`isolated`, recorded `none` on a tenant object: updateMany carries bypassTenantAudit, and no tenantId', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'none' } }); + await updateMany(engine); + expect(last('updateMany')?.bypassTenantAudit).toBe(true); + expect(last('updateMany')?.tenantId).toBeUndefined(); + }); + + it('`isolated`, recorded `none`: deleteMany is declared too', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'none' } }); + await engine.delete('ds_task', { where: { name: 'y' }, multi: true, context: ADMIN } as any); + expect(last('deleteMany')?.bypassTenantAudit).toBe(true); + }); + + it('`group`, recorded `none`: the union wall is lifted the same way', async () => { + const { engine, last } = await makeEngine({ posture: 'group', recordedVerdict: { kind: 'none' } }); + await updateMany(engine, 'ds_task', { context: { ...ADMIN, accessible_org_ids: [ORG_A] } }); + expect(last('updateMany')?.bypassTenantAudit).toBe(true); + }); +}); + +describe('[ADR-0131 D8 · 8c-1] what is NOT declared', () => { + it('a walled verdict: the write is scoped, nothing to declare', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'organization', organizationId: ORG_A } }); + await updateMany(engine); + expect(last('updateMany')?.tenantId).toBe(ORG_A); + expect(last('updateMany')?.bypassTenantAudit).toBeUndefined(); + }); + + it('no verdict recorded (a by-id write, no security layer): absence is not exemption', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated' }); + await updateMany(engine); + expect(last('updateMany')?.bypassTenantAudit).toBeUndefined(); + }); + + it('`single`: a recorded `none` is the inert wall, not a crossing', async () => { + const { engine, last } = await makeEngine({ posture: 'single', recordedVerdict: { kind: 'none' } }); + await updateMany(engine); + expect(last('updateMany')?.bypassTenantAudit).toBeUndefined(); + }); + + it('an insert: the write target is the active organization, whatever was recorded', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'none' } }); + await engine.insert('ds_task', { name: 'A' }, { context: ADMIN }); + expect(last('create')?.tenantId).toBe(ORG_A); + expect(last('create')?.bypassTenantAudit).toBeUndefined(); + }); + + it('a tenancy-disabled object: outside the machinery, nothing to declare', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'none' } }); + await updateMany(engine, 'ds_global'); + expect(last('updateMany')?.bypassTenantAudit).toBeUndefined(); + }); + + it('a recorded value that does not parse lifts nothing', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'none', extra: 1 } }); + await updateMany(engine); + expect(last('updateMany')?.bypassTenantAudit).toBeUndefined(); + }); + + it('a caller\'s own value wins: the engine only fills in a value nobody supplied', async () => { + const { engine, last } = await makeEngine({ posture: 'isolated', recordedVerdict: { kind: 'none' } }); + await updateMany(engine, 'ds_task', { bypassTenantAudit: false }); + expect(last('updateMany')?.bypassTenantAudit).toBe(false); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d327f44080c..728e3076bcc 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7756,8 +7756,19 @@ export class ObjectQL implements IObjectQLEngine { const isTenantAuditInScope = resolveTenantFieldName(objectSchema) !== null && !isFederated; + // [ADR-0131 D8 · 8c-1] The wall's exemption, declared to the driver. An + // exempt platform admin's predicate write reaches the driver with no + // `tenantId` because the wall lifted the scope on purpose, and the + // driver's `[tenant-audit]` line tells such a write to declare + // `bypassTenantAudit: true` — an option no REST caller can set. The + // exemption is decided once, by `computeTenantLayer0Verdict`, and arrives + // here as the recorded verdict; this reads it, it does not re-decide it + // (see {@link wallExemptedOperation}). + const wallExempt = + tenantObject && isTenantAuditInScope && !scope.writeTarget && + this.wallExemptedOperation(execCtx, scope.recordedVerdict); const preserveAudit = (execCtx as any)?.preserveAudit === true; - if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit) return base; + if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit && !wallExempt) return base; const opts: any = base && typeof base === 'object' ? { ...base } : {}; if (hasTx && opts.transaction === undefined) { opts.transaction = tx; @@ -7797,6 +7808,13 @@ export class ObjectQL implements IObjectQLEngine { // line. It changes nothing about what any write touches. opts.bypassTenantAudit = true; } + if (wallExempt && opts.bypassTenantAudit === undefined) { + // The same fill-in-only rule as the isSystem mute above: a caller's own + // value wins. Diagnostics only — the driver's scope is already the + // verdict's (no `tenantId`), and this option silences a warning and + // touches nothing else; on a read it is inert. + opts.bypassTenantAudit = true; + } if (preserveAudit && opts.preserveAudit === undefined) { // Historical import (#3493): let the driver keep a supplied `updated_at` // instead of force-stamping now on the update path. @@ -7824,23 +7842,7 @@ export class ObjectQL implements IObjectQLEngine { ? String(execCtx.tenantId) : undefined; const recorded = recordedVerdict === undefined ? undefined : TenantLayer0VerdictSchema.safeParse(recordedVerdict); - const verdict = recorded?.success - ? recorded.data - : computeTenantLayer0Verdict({ - tenancyPosture: this.resolveEnginePosture(), - organizationId: activeOrganization, - accessibleOrgIds: accessibleOrganizationsOf(execCtx), - // Left to the driver, which resolves the tenant column from the - // table it actually built (`resolveTenantField`): an object with no - // such column is unscoped there whatever this says. `undefined` is - // the verdict's "assume a tenant object", as Layer 0 reads an - // unresolved field set. - objectHasOrgIdField: undefined, - tenancyDisabled: false, - // The engine holds no permission sets: no exemption is ITS to grant. - posturePermitsCrossTenant: false, - isPlatformAdmin: false, - }); + const verdict = recorded?.success ? recorded.data : this.engineLayer0Verdict(execCtx, activeOrganization); switch (verdict.kind) { case 'none': return undefined; @@ -7853,6 +7855,59 @@ export class ObjectQL implements IObjectQLEngine { } } + /** + * The Layer 0 verdict over the engine's OWN facts — what {@link driverReadScope} + * falls back to where no wall was recorded. The one ladder, + * `computeTenantLayer0Verdict`, with the exemption's facts withheld. + */ + private engineLayer0Verdict( + execCtx: ExecutionContext | undefined, + activeOrganization: string | undefined, + ): TenantLayer0Verdict { + return computeTenantLayer0Verdict({ + tenancyPosture: this.resolveEnginePosture(), + organizationId: activeOrganization, + accessibleOrgIds: accessibleOrganizationsOf(execCtx), + // Left to the driver, which resolves the tenant column from the + // table it actually built (`resolveTenantField`): an object with no + // such column is unscoped there whatever this says. `undefined` is + // the verdict's "assume a tenant object", as Layer 0 reads an + // unresolved field set. + objectHasOrgIdField: undefined, + tenancyDisabled: false, + // The engine holds no permission sets: no exemption is ITS to grant. + posturePermitsCrossTenant: false, + isPlatformAdmin: false, + }); + } + + /** + * [ADR-0131 D8 · 8c-1] Did the wall's EXEMPTION lift this operation's scope? + * True when the recorded verdict is `none` while the same ladder over the + * engine's own facts — every rung but the exemption — would have walled it. + * That is plugin-security's own `crossedByPlatformAdmin` test (the ladder + * asked a second time with only the exemption withheld), with the engine's + * existing fallback ({@link engineLayer0Verdict}, what an unrecorded + * operation is scoped by) as the second asking: no rung is computed beside + * the one ladder, and a `single` posture answers `none` both ways, so it is + * no crossing. The #12974 owner bypass records `none` on READS only, where + * the declaration is inert. + * + * Absence is not exemption: a by-id write, a system context or a stack with + * no security layer records nothing, and answers false. A recorded value + * that does not parse answers false too — junk never lifts anything. + */ + private wallExemptedOperation(execCtx: ExecutionContext | undefined, recordedVerdict: unknown): boolean { + if (recordedVerdict === undefined) return false; + const recorded = TenantLayer0VerdictSchema.safeParse(recordedVerdict); + if (!recorded.success || recorded.data.kind !== 'none') return false; + const activeOrganization = + execCtx?.tenantId !== undefined && execCtx.tenantId !== null && String(execCtx.tenantId) !== '' + ? String(execCtx.tenantId) + : undefined; + return this.engineLayer0Verdict(execCtx, activeOrganization).kind !== 'none'; + } + /** * The write target of an insert or upsert: the active organization, in every * posture, with the `group` membership set beside it — the scope the