From 501e121d984dbd05d0de0282f25c5d7210a53d72 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 18:31:49 +0000 Subject: [PATCH 1/7] fix(objectql,driver-sql): skip the tenant audit on an insert whose rows carry their organization; declare the D8 wall-exemption to the driver The SQL driver's [tenant-audit] line no longer fires for a create or bulkCreate whose every row already carries its tenant column: an insert is confined to the organizations its rows name. Update, delete, upsert and the predicate writes with no tenantId still warn. The engine fills in bypassTenantAudit when the recorded Layer 0 verdict is none and the same ladder without the exemption would have walled the operation, so an exempt platform admin's predicate write is no longer told to declare an option it cannot set. Diagnostics only: no write changes. Claude-Session: https://claude.ai/code/session_01S9iQu7nAw2Ce5EXSzDVTho Co-authored-by: Claude --- ...tenant-audit-insert-carries-tenant.test.ts | 135 ++++++++++++++++ .../sql-driver-tenant-audit-posture.test.ts | 20 +-- packages/drivers/driver-sql/src/sql-driver.ts | 24 ++- ...engine-tenant-audit-wall-exemption.test.ts | 148 ++++++++++++++++++ packages/objectql/src/engine.ts | 88 ++++++++--- 5 files changed, 386 insertions(+), 29 deletions(-) create mode 100644 packages/drivers/driver-sql/src/sql-driver-tenant-audit-insert-carries-tenant.test.ts create mode 100644 packages/objectql/src/engine-tenant-audit-wall-exemption.test.ts 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 0000000000..3811b4b3dd --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-audit-insert-carries-tenant.test.ts @@ -0,0 +1,135 @@ +// 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('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 0ae24266a2..f401b9f364 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.ts b/packages/drivers/driver-sql/src/sql-driver.ts index faca2f370e..5fa16c49f6 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/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 0000000000..7d5652ddc1 --- /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 d327f44080..ac142e36dc 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,56 @@ 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), read from the + * recorded value: no posture, schema or permission rung is re-derived here, + * and a `single` posture answers `none` both ways, so it is no crossing. + * + * 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 From b57e83d5e7cb67a4379c375431fe2daa1eec314c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 18:34:59 +0000 Subject: [PATCH 2/7] chore(changeset): patch changesets for the tenant-audit insert skip and the engine's wall-exemption declaration Claude-Session: https://claude.ai/code/session_01S9iQu7nAw2Ce5EXSzDVTho Co-authored-by: Claude --- .changeset/25230-engine-declares-wall-exemption.md | 14 ++++++++++++++ ...230-tenant-audit-insert-carries-organization.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .changeset/25230-engine-declares-wall-exemption.md create mode 100644 .changeset/25230-tenant-audit-insert-carries-organization.md diff --git a/.changeset/25230-engine-declares-wall-exemption.md b/.changeset/25230-engine-declares-wall-exemption.md new file mode 100644 index 0000000000..931f4dcbde --- /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 0000000000..c8d5fbbbf6 --- /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. From fa4caae8c9f858720099a7ee381e9e7743155dc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 18:48:17 +0000 Subject: [PATCH 3/7] test(driver-sql): exercise the tenant-audit throttle with deletes, since an insert carrying its organization is no longer audited Claude-Session: https://claude.ai/code/session_01S9iQu7nAw2Ce5EXSzDVTho Co-authored-by: Claude --- .../driver-sql/src/sql-driver-tenant-scope.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 049f31eb78..9784807025 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; From d32333040217f6dc2092341d3a1bc0b1ca641e42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:01:14 +0000 Subject: [PATCH 4/7] docs(objectql): say exactly which second asking of the Layer 0 ladder reads the wall-exemption Claude-Session: https://claude.ai/code/session_01S9iQu7nAw2Ce5EXSzDVTho Co-authored-by: Claude --- packages/objectql/src/engine.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index ac142e36dc..728e3076bc 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -7886,9 +7886,12 @@ export class ObjectQL implements IObjectQLEngine { * 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), read from the - * recorded value: no posture, schema or permission rung is re-derived here, - * and a `single` posture answers `none` both ways, so it is no crossing. + * 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 From 0d69599888e0aafb25db1911143f17372fb6d1bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:27:47 +0000 Subject: [PATCH 5/7] docs(permissions): the tenant-audit census page names the insert-row guard and the wall-exemption declaration Claude-Session: https://claude.ai/code/session_01S9iQu7nAw2Ce5EXSzDVTho Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index f5d9260705..052e9a4f59 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -43,19 +43,27 @@ 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` — the engine sets this for **every** - `ExecutionContext.isSystem` write (ObjectQL's `buildDriverOptions`); +2. `options.bypassTenantAudit` — ObjectQL's `buildDriverOptions` fills it in + for an `ExecutionContext.isSystem` write on an object with no tenant column + or a federated one (ADR-0131 D13), and for 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 also pass it; 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 From 652114639b022d083ecf335877e944e3e84369f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 19:39:16 +0000 Subject: [PATCH 6/7] test(driver-sql): a null organization on an inserted row still warns Claude-Session: https://claude.ai/code/session_01S9iQu7nAw2Ce5EXSzDVTho Co-authored-by: Claude --- .../sql-driver-tenant-audit-insert-carries-tenant.test.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 index 3811b4b3dd..097209e0a5 100644 --- 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 @@ -86,6 +86,11 @@ describe('[ADR-0131 8c-1] an insert whose row carries its organization is not au 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' }, From 1313821b578acc2702e364c74aac8c7b1400b4b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:16:24 +0000 Subject: [PATCH 7/7] test(driver-sqlite-wasm): exercise the tenant-audit throttle with deletes, as the SqlDriver twin does The SQLite WASM driver inherits SqlDriver.create, so an insert whose row carries its organization is no longer audited there either. Claude-Session: https://claude.ai/code/session_01S9iQu7nAw2Ce5EXSzDVTho Co-authored-by: Claude --- .../src/sqlite-wasm-driver-tenant-scope.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 c27c0e3f4c..df79116a5e 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;