From 8afaec51a1c59bf651ed14b340693880af3e6848 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:23:18 +0000 Subject: [PATCH 1/6] test(plugin-security): pin the Layer 0 tenant wall on the stored row (failing first) Measurement on the base, isolated posture, driver-sql and driver-sqlite-wasm: a beforeInsert/beforeUpdate hook that writes organization_id outside the caller's organization scope is admitted on a by-id update, an insert, an array insert and a predicate update, and the row is stored there. A supplied out-of-scope value is refused before next(), as the controls show. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../src/tenant-wall-post-hook-image.test.ts | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) create mode 100644 packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts diff --git a/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts b/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts new file mode 100644 index 00000000000..e62e1c06ffd --- /dev/null +++ b/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts @@ -0,0 +1,510 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20013] The Layer 0 tenant write wall holds for the row that is STORED, + * after the `beforeInsert` / `beforeUpdate` chain — for an insert, a by-id + * update and a predicate update. + * + * ## The guarantee + * + * Step 3.7 of the security middleware (ADR-0095 D1, ADR-0105 D5) says a + * write's `organization_id` must satisfy the SAME Layer 0 filter the read side + * uses, so that in a non-platform user context the only organization a write + * can place a row in is one the caller holds. The middleware judged that on + * `opCtx.data` — the payload as the caller SENT it — before `next()` runs the + * engine's hook chain. A value a hook wrote into `organization_id` after that + * point was never judged by the wall, and the row was stored in whatever + * organization the hook named. + * + * The fix judges the stored image as well, through the engine-run seam the + * row-level `check` already uses (`OperationContext.postHookWriteImageCheck`), + * installed whenever the wall applies to the write — with or without a + * business `check`. The judgement of the payload as sent STAYS, so the change + * only ever refuses more. + * + * ## What this file pins, on both SQL driver families, in the `isolated` + * posture unless a cell says otherwise + * + * - the refusals: a hook-written `organization_id` outside the caller's + * organization scope is refused on a by-id update, an insert, an array + * insert and a predicate update, with step 3.7's own refusal + * (`PERMISSION_DENIED` / 403), and the driver's table is unchanged; the + * same when a business `check` is installed beside it (one composed seam), + * and under the `group` posture against the membership set; + * - fail-closed: a host that never runs the installed judgement is refused + * rather than vouched for; + * - the controls: an in-scope hook write is admitted; a supplied + * out-of-scope value is refused as before; the payload judgement still + * refuses a value a hook would have replaced (only refuses MORE); an update + * that does not touch the column is admitted on both update paths; an + * insert that leaves the column to the platform is admitted (ADR-0095 D1's + * "Not touched" concern: an ABSENT value is never judged); a platform + * administrator on a posture-permitting object and a system context are + * ungated as before; the `single` posture is unchanged; and the ADR-0094 + * permission-set data door, which executes its write without the engine, + * still admits a platform administrator's write. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import type { PermissionSet } from '@objectstack/spec/security'; +import { SecurityPlugin } from './security-plugin.js'; +import { SysPermissionSet } from './objects/index.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +/** The organization the caller holds (its active organization). */ +const OWN_ORG = 'org_a'; +/** An organization the caller does NOT hold. */ +const OTHER_ORG = 'org_b'; +/** A second organization the caller holds under `group` only. */ +const SISTER_ORG = 'org_c'; + +/** + * The hook shape: an app derives `organization_id` from another payload field + * (`target_org`), so the payload as sent never carries the column itself. + */ +const HOOKED_FIELDS = { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + target_org: { name: 'target_org', type: 'text' }, +}; + +const OBJECTS = [ + // A public tenant object: Layer 0 walls every non-system caller on it. + { name: 'qa_account', label: 'Account', fields: HOOKED_FIELDS }, + // The same shape under a business `check`, so step 3.6 installs its own + // stored-row judgement beside the wall's. + { name: 'qa_checked', label: 'Checked', fields: HOOKED_FIELDS }, + // A PRIVATE object: its posture permits a platform administrator to cross + // the wall (ADR-0095 D1 exemption). + { name: 'qa_vault', label: 'Vault', access: { default: 'private' }, fields: HOOKED_FIELDS }, +]; + +/** Plain CRUD, no row-level policy on the wall's objects — Layer 0 is the enforcer under test. */ +const MEMBER: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [ + { + name: 'checked_not_forbidden', + object: 'qa_checked', + operation: 'all', + using: "record.name != 'forbidden'", + check: "record.name != 'forbidden'", + }, + ], +} as unknown as PermissionSet; + +/** A platform operator: the superuser bit AND a platform-exclusive capability (ADR-0095 D3). */ +const PLATFORM_ADMIN: PermissionSet = { + name: 'admin_full_access', + label: 'Platform Administrator', + objects: { + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, viewAllRecords: true, modifyAllRecords: true }, + }, + systemPermissions: ['manage_platform_settings', 'manage_metadata'], +} as unknown as PermissionSet; + +const SYS_CTX = { isSystem: true, userId: 'usr_system', tenantId: OWN_ORG }; +const MEMBER_CTX = { userId: 'usr_a', tenantId: OWN_ORG, positions: [], permissions: [], posture: 'MEMBER' }; +const GROUP_CTX = { ...MEMBER_CTX, accessible_org_ids: [OWN_ORG, SISTER_ORG] }; +const ADMIN_CTX = { + userId: 'usr_admin', + tenantId: OWN_ORG, + positions: [], + permissions: ['admin_full_access'], + posture: 'PLATFORM_ADMIN', +}; + +type Posture = 'isolated' | 'group' | 'single'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +interface Booted { + engine: ObjectQL; + /** Every `organization_id` a hook wrote, so "the hook ran" is measured. */ + hookWrites: unknown[]; + /** A table's rows read straight off the driver, past every scope. */ + table: (name: string) => Promise>>; +} + +async function boot(makeDriver: () => unknown, posture: Posture = 'isolated'): Promise { + const engine = new ObjectQL(); + engine.registerDriver(makeDriver() as never, true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.tenant-wall-post-hook-image', + name: 'Layer 0 tenant wall on the stored row', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: OBJECTS, + } as never); + await engine.syncSchemas(); + engines.push(engine); + + const hookWrites: unknown[] = []; + const stamp = (async (ctx: { input: { data: Record } }) => { + const target = ctx.input.data.target_org; + if (typeof target !== 'string' || target === '') return; + hookWrites.push(target); + ctx.input.data.organization_id = target; + }) as never; + for (const object of ['qa_account', 'qa_checked', 'qa_vault']) { + engine.on('beforeInsert', object, stamp); + engine.on('beforeUpdate', object, stamp); + } + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [MEMBER, PLATFORM_ADMIN], + }, + }; + if (posture !== 'single') { + services['org-scoping'] = { name: 'com.objectstack.org-scoping' }; + services.tenancy = { posture }; + } + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + // The expected refusals log at WARN through the engine's own logger. + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + for (const object of ['qa_account', 'qa_checked', 'qa_vault']) { + await engine.insert( + object, + [ + { id: 'r1', name: 'one', organization_id: OWN_ORG }, + { id: 'r2', name: 'two', organization_id: OWN_ORG }, + ], + { context: SYS_CTX } as never, + ); + } + + const table = async (name: string) => { + const driver = (engine as unknown as { getDriver(o: string): { knex: unknown } }).getDriver(name); + const knex = driver.knex as (t: string) => { select: (...c: string[]) => Promise>> }; + const rows = await knex(name).select('id', 'name', 'organization_id'); + return [...rows].sort((a, b) => String(a.id).localeCompare(String(b.id))); + }; + + return { engine, hookWrites, table }; +} + +const DRIVERS: Array<[string, () => unknown]> = [ + ['driver-sql (better-sqlite3 :memory:)', () => + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true } as never)], + ['driver-sqlite-wasm (:memory:)', () => new SqliteWasmDriver({ filename: ':memory:' } as never)], +]; + +interface Outcome { ok: boolean; code?: string; status?: number; message?: string; developerMessage?: string } + +const attempt = async (run: () => Promise): Promise => { + try { + await run(); + return { ok: true }; + } catch (e) { + const err = e as { code?: string; statusCode?: number; status?: number; message?: string; developerMessage?: string }; + return { + ok: false, + code: err.code, + status: err.statusCode ?? err.status, + message: String(err.message ?? e), + developerMessage: err.developerMessage, + }; + } +}; + +/** Step 3.7's refusal, on the ADR-0112 envelope, never a bare throw. */ +const expectTenantRefusal = (outcome: Outcome, operation: 'insert' | 'update', object = 'qa_account') => { + expect(outcome.ok, 'expected a refusal, got a completed write').toBe(false); + expect(outcome.code, 'ADR-0112 error code').toBe('PERMISSION_DENIED'); + expect(outcome.status, 'ADR-0112 HTTP status').toBe(403); + expect(outcome.message, 'the refusal names the tenant wall and the verb') + .toContain(`the ${operation} would place '${object}' in another tenant`); +}; + +const expectAdmitted = (outcome: Outcome) => + expect(outcome.ok, `expected the write to be admitted: ${outcome.developerMessage ?? outcome.message}`).toBe(true); + +const SEEDED = [ + { id: 'r1', name: 'one', organization_id: OWN_ORG }, + { id: 'r2', name: 'two', organization_id: OWN_ORG }, +]; + +const byId = (b: Booted, object: string, data: Record, context: object = MEMBER_CTX) => + b.engine.update(object, data as never, { context } as never); +const insert = (b: Booted, object: string, data: unknown, context: object = MEMBER_CTX) => + b.engine.insert(object, data as never, { context } as never); +const predicate = (b: Booted, object: string, data: Record, context: object = MEMBER_CTX) => + b.engine.update(object, data as never, { where: { name: 'one' }, multi: true, context } as never); + +for (const [driverName, makeDriver] of DRIVERS) { + describe(`[#20013] a hook-written organization_id is judged by the Layer 0 wall on the stored row — ${driverName}`, () => { + it('by-id update: a hook-written organization outside the caller’s scope is refused, and nothing is stored', async () => { + const b = await boot(makeDriver); + + const outcome = await attempt(() => byId(b, 'qa_account', { id: 'r1', name: 'moved', target_org: OTHER_ORG })); + + expectTenantRefusal(outcome, 'update'); + expect(b.hookWrites, 'the hook ran and wrote the column').toEqual([OTHER_ORG]); + expect(await b.table('qa_account')).toEqual(SEEDED); + }); + + it('insert: a hook-written organization outside the caller’s scope is refused, and no row is stored', async () => { + const b = await boot(makeDriver); + + const outcome = await attempt(() => insert(b, 'qa_account', { id: 'r3', name: 'new', target_org: OTHER_ORG })); + + expectTenantRefusal(outcome, 'insert'); + expect(b.hookWrites).toEqual([OTHER_ORG]); + expect(await b.table('qa_account')).toEqual(SEEDED); + }); + + it('array insert: one hook-written row outside the scope refuses the whole write, and no row is stored', async () => { + const b = await boot(makeDriver); + + const outcome = await attempt(() => insert(b, 'qa_account', [ + { id: 'r3', name: 'in scope' }, + { id: 'r4', name: 'out of scope', target_org: OTHER_ORG }, + ])); + + expectTenantRefusal(outcome, 'insert'); + expect(b.hookWrites).toEqual([OTHER_ORG]); + expect(await b.table('qa_account')).toEqual(SEEDED); + }); + + it('predicate update: a hook-written organization outside the scope is refused over the matched rows, and nothing is stored', async () => { + const b = await boot(makeDriver); + + const outcome = await attempt(() => predicate(b, 'qa_account', { target_org: OTHER_ORG })); + + expectTenantRefusal(outcome, 'update'); + expect(b.hookWrites.length, 'the per-row hook ran').toBeGreaterThan(0); + expect(await b.table('qa_account')).toEqual(SEEDED); + }); + + it('with a business `check` installed beside it: the check admits the row, and the wall still refuses it (one composed seam)', async () => { + const b = await boot(makeDriver); + + const byIdOutcome = await attempt(() => byId(b, 'qa_checked', { id: 'r1', name: 'allowed', target_org: OTHER_ORG })); + expectTenantRefusal(byIdOutcome, 'update', 'qa_checked'); + const insertOutcome = await attempt(() => insert(b, 'qa_checked', { id: 'r3', name: 'allowed', target_org: OTHER_ORG })); + expectTenantRefusal(insertOutcome, 'insert', 'qa_checked'); + + expect(await b.table('qa_checked')).toEqual(SEEDED); + }); + + it('`group` posture: a hook-written organization outside the membership set is refused, and one inside it is admitted', async () => { + const b = await boot(makeDriver, 'group'); + + expectTenantRefusal( + await attempt(() => byId(b, 'qa_account', { id: 'r1', target_org: OTHER_ORG }, GROUP_CTX)), + 'update', + ); + expect(await b.table('qa_account')).toEqual(SEEDED); + + expectAdmitted(await attempt(() => byId(b, 'qa_account', { id: 'r1', target_org: SISTER_ORG }, GROUP_CTX))); + expect((await b.table('qa_account'))[0]).toEqual({ id: 'r1', name: 'one', organization_id: SISTER_ORG }); + }); + }); + + describe(`[#20013] fail-closed — ${driverName}`, () => { + it('a host that never runs the installed wall judgement does not have its write vouched for', async () => { + const b = await boot(makeDriver); + // A middleware INSIDE the security one takes the installed judgement off + // the operation before the engine sees it: the shape of a host that runs + // the write without the seam. No business `check` applies to + // `qa_account`, so the judgement it removes is the wall's alone, and + // the payload would PASS it: the refusal is about the judgement not + // running, not about values. + let seen: { honoured?: boolean } | undefined; + b.engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.operation === 'update' && opCtx.postHookWriteImageCheck) { + seen = opCtx.postHookWriteImageCheck; + delete opCtx.postHookWriteImageCheck; + } + await next(); + }); + + const outcome = await attempt(() => byId(b, 'qa_account', { id: 'r1', name: 'renamed' })); + + expect(outcome.ok, 'an unjudged write must not be reported as an allowed one').toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.developerMessage).toContain( + "the update on 'qa_account' was executed without the Layer 0 tenant wall being evaluated on the stored row", + ); + expect(seen, 'the judgement was installed with no business check in play').toBeTruthy(); + expect(seen?.honoured).not.toBe(true); + }); + }); + + describe(`[#20013] controls — ${driverName}`, () => { + it('⭐ an in-scope hook write is admitted and stored on every path', async () => { + const b = await boot(makeDriver); + + expectAdmitted(await attempt(() => byId(b, 'qa_account', { id: 'r1', name: 'renamed', target_org: OWN_ORG }))); + expectAdmitted(await attempt(() => insert(b, 'qa_account', { id: 'r3', name: 'new', target_org: OWN_ORG }))); + expectAdmitted(await attempt(() => predicate(b, 'qa_account', { target_org: OWN_ORG }))); + + expect(await b.table('qa_account')).toEqual([ + { id: 'r1', name: 'renamed', organization_id: OWN_ORG }, + { id: 'r2', name: 'two', organization_id: OWN_ORG }, + { id: 'r3', name: 'new', organization_id: OWN_ORG }, + ]); + }); + + it('a SUPPLIED out-of-scope organization_id is refused before anything runs, as before', async () => { + const b = await boot(makeDriver); + + expectTenantRefusal(await attempt(() => byId(b, 'qa_account', { id: 'r1', organization_id: OTHER_ORG })), 'update'); + expectTenantRefusal(await attempt(() => insert(b, 'qa_account', { id: 'r3', name: 'x', organization_id: OTHER_ORG })), 'insert'); + expect(b.hookWrites, 'refused before the hook chain').toEqual([]); + expect(await b.table('qa_account')).toEqual(SEEDED); + }); + + it('only refuses MORE: a supplied out-of-scope value stays refused even when a hook would replace it with an in-scope one', async () => { + const b = await boot(makeDriver); + + expectTenantRefusal( + await attempt(() => byId(b, 'qa_account', { id: 'r1', organization_id: OTHER_ORG, target_org: OWN_ORG })), + 'update', + ); + expect(await b.table('qa_account')).toEqual(SEEDED); + }); + + it('⭐ an update that does not touch the column is admitted on both update paths (the stored row keeps its organization)', async () => { + const b = await boot(makeDriver); + + expectAdmitted(await attempt(() => byId(b, 'qa_account', { id: 'r2', name: 'second' }))); + expectAdmitted(await attempt(() => predicate(b, 'qa_account', { name: 'first' }))); + + expect(await b.table('qa_account')).toEqual([ + { id: 'r1', name: 'first', organization_id: OWN_ORG }, + { id: 'r2', name: 'second', organization_id: OWN_ORG }, + ]); + }); + + it('⭐ an insert that leaves the column to the platform is admitted and lands in the caller’s organization (an absent value is never judged)', async () => { + const b = await boot(makeDriver); + + expectAdmitted(await attempt(() => insert(b, 'qa_account', { id: 'r3', name: 'new' }))); + + expect((await b.table('qa_account'))[2]).toEqual({ id: 'r3', name: 'new', organization_id: OWN_ORG }); + }); + + it('⭐ a platform administrator on a posture-permitting object is exempt, as today', async () => { + const b = await boot(makeDriver); + + expectAdmitted(await attempt(() => byId(b, 'qa_vault', { id: 'r1', target_org: OTHER_ORG }, ADMIN_CTX))); + + expect((await b.table('qa_vault'))[0]).toEqual({ id: 'r1', name: 'one', organization_id: OTHER_ORG }); + }); + + it('⭐ a system-context write is ungated, as today', async () => { + const b = await boot(makeDriver); + + expectAdmitted(await attempt(() => byId(b, 'qa_account', { id: 'r1', target_org: OTHER_ORG }, SYS_CTX))); + + expect((await b.table('qa_account'))[0]).toEqual({ id: 'r1', name: 'one', organization_id: OTHER_ORG }); + }); + + it('⭐ the `single` posture is unchanged: no wall, so the hook write lands', async () => { + const b = await boot(makeDriver, 'single'); + + expectAdmitted(await attempt(() => byId(b, 'qa_account', { id: 'r1', target_org: OTHER_ORG }))); + + expect((await b.table('qa_account'))[0]).toEqual({ id: 'r1', name: 'one', organization_id: OTHER_ORG }); + }); + }); + + describe(`[#20013] the ADR-0094 permission-set data door — ${driverName}`, () => { + // The data door executes an insert/update of `sys_permission_set` ITSELF, + // through the metadata protocol, and never calls `next()`: no engine + // write, so no hook chain and no seam. Measured on the base: a platform + // administrator's insert under the `isolated` posture is admitted while + // Layer 0 walls the object (`organization_id = `). + it('⭐ a platform administrator’s permission-set insert under a walled posture is still admitted', async () => { + const engine = new ObjectQL(); + engine.registerDriver(makeDriver() as never, true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.tenant-wall-post-hook-image.data-door', + name: 'Data door under a walled posture', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [SysPermissionSet], + } as never); + await engine.syncSchemas(); + engines.push(engine); + const saved = new Map(); + const protocol = { + registerMutationProjector() { /* not exercised here */ }, + async saveMetaItem(req: { name: string; item: unknown }) { saved.set(req.name, req.item); return { success: true }; }, + async deleteMetaItem() { return { success: true }; }, + async getMetaItemLayered(req: { name: string }) { + const body = saved.get(req.name) ?? null; + return { type: 'permission', name: req.name, code: null, overlay: body, overlayScope: null, effective: body }; + }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + protocol, + metadata: { + get: async (_type: string, name: string) => engine.getSchema(name) ?? null, + list: async () => [...defaultPermissionSets], + }, + 'org-scoping': { name: 'com.objectstack.org-scoping' }, + tenancy: { posture: 'isolated' }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + let engineWriteRan = false; + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.object === 'sys_permission_set' && opCtx.operation === 'insert' && !opCtx.context?.isSystem) { + engineWriteRan = true; + } + await next(); + }); + + const outcome = await attempt(() => + engine.insert('sys_permission_set', { name: 'door_set', label: 'Door set' } as never, { context: ADMIN_CTX } as never)); + + expectAdmitted(outcome); + expect(engineWriteRan, 'the data door executed the write itself').toBe(false); + expect(saved.has('door_set'), 'the definition reached the metadata store').toBe(true); + }); + }); +} From 47b147d3320f1709a14581dd4bf848477e072b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:25:33 +0000 Subject: [PATCH 2/6] fix(plugin-security)!: the Layer 0 tenant wall judges the row a write stores Step 3.7 judged only an organization_id present in the payload as sent, before next() runs the engine's beforeInsert/beforeUpdate chain, so a value a hook wrote was never judged by the wall. The wall now also judges the stored image through OperationContext.postHookWriteImageCheck, installed whenever Layer 0 applies to the write (not only when a business check does), composed after step 3.6's check when both apply. The payload judgement stays, so the change only refuses more. The post-next() fail-closed guard covers the new installation, with one observed stand-down: a write the ADR-0094 permission-set data door executed itself, without the engine. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../plugin-security/src/security-plugin.ts | 198 ++++++++++++++---- 1 file changed, 161 insertions(+), 37 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 6361ded7a2e..278b25f19ee 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -214,7 +214,8 @@ export function hasPlatformAdminCapability(held: ReadonlySet): boolean { * `beforeInsert` chain has produced it, [#19950] every row a predicate * (`multi`) update selects, merged with the final payload, and [#19989] the one * row a by-id update writes, merged with the payload the `beforeUpdate` chain - * left. + * left. [#20013] The Layer 0 tenant wall's stored-row judgement rides the same + * handle (step 3.7), alone or composed after the `check`. * * Structurally identical to `OperationContext.postHookWriteImageCheck` in * `@objectstack/objectql`, and deliberately declared here rather than imported: @@ -1854,6 +1855,14 @@ export class SecurityPlugin implements Plugin { }); } + // [#20013] The writes the ADR-0094 permission-set data door executed + // ITSELF — it returned without calling `next()`, so no engine write ran. + // Filled by the wrapper that registers the door (below), read by the + // security middleware's post-`next()` assertion. Keyed by the operation + // context the two middlewares share, and private to this plugin, so no + // other middleware can put a write in it. + const writesExecutedByDataDoor = new WeakSet(); + // Register security middleware ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { // [insert-check commit a016f08b8a] (the original card no longer resolves) @@ -1864,7 +1873,14 @@ export class SecurityPlugin implements Plugin { // seam was honoured — an installed judgement that never ran is a write // this middleware did not gate, and it fails CLOSED and loudly rather // than passing for an allowed one. + // + // [#20013] Step 3.7 composes the Layer 0 tenant wall's stored-row + // judgement into the same seam, so this one handle can carry either + // judgement or both; the two flags say which, for the assertion's + // wording and for its one stand-down (the data door, see there). let writeImageCheckSeam: WriteImageCheckSeam | null = null; + let businessCheckSeamInstalled = false; + let tenantWallSeamInstalled = false; // [#10757] Retire every memoized permission-set resolution the moment a // WRITE passes through the engine. Deliberately the FIRST statement in // the middleware — ahead of the `isSystem` bypass immediately below — @@ -3142,6 +3158,7 @@ export class SecurityPlugin implements Plugin { // produced — [#19964] every row of an array insert. writeImageCheckSeam = newWriteImageCheck(); opCtx.postHookWriteImageCheck = writeImageCheckSeam; + businessCheckSeamInstalled = true; } else { const targetId = this.extractSingleId(opCtx); if (targetId != null) { @@ -3211,6 +3228,7 @@ export class SecurityPlugin implements Plugin { // fails CLOSED after `next()`. writeImageCheckSeam = newWriteImageCheck(); opCtx.postHookWriteImageCheck = writeImageCheckSeam; + businessCheckSeamInstalled = true; } } } @@ -3245,12 +3263,14 @@ export class SecurityPlugin implements Plugin { // pre-image already scoped the target to that org — equals the row's current // org), so a re-point to any OTHER tenant is denied. A bulk update carrying a // cross-tenant `organization_id` change-set is caught too (the check inspects - // the change-set value, not a per-row post-image). + // the change-set value here, and [#20013] every stored row in the engine). // // Scope: only a SUPPLIED `organization_id` is validated — an ABSENT value on // insert is the organizations-plugin auto-stamp's responsibility, and an // update that doesn't touch `organization_id` carries no value here, so - // ordinary writes are unaffected. A pure plugin-security deployment has no + // ordinary writes are unaffected. [#20013] "Supplied" includes a value the + // engine's `beforeInsert` / `beforeUpdate` chain wrote: that half is judged + // on the stored row, below. A pure plugin-security deployment has no // isolation active (Layer 0 → null), so this is ordering-independent w.r.t. // the auto-stamp middleware. System / boot writes carry `isSystem` and // short-circuited the whole middleware above, so legitimate cross-org moves @@ -3357,10 +3377,19 @@ export class SecurityPlugin implements Plugin { ); } - const suppliedRows = writeRows.filter( - (r) => r.organization_id != null && r.organization_id !== '', - ); - if (suppliedRows.length > 0) { + // A row carries an organization to judge only when it NAMES one: an + // absent or empty value is the auto-stamp's to fill (ADR-0095 D1, + // "Not touched": judging it would refuse a legitimate insert before + // the stamp runs). One predicate, read by both halves below. + const namesOrganization = (r: Record): boolean => + r.organization_id != null && r.organization_id !== ''; + const suppliedRows = writeRows.filter(namesOrganization); + // The wall is computed when a payload names an organization (as + // before) or when a walled posture may have a hook name one ([#20013] + // below). The `single` posture composes no Layer 0 at all, so it pays + // for nothing it did not already pay for. + let tenantParts: Record[] = []; + if (suppliedRows.length > 0 || this.orgScopingEnabled) { const tenantCheck = await this.computeWriteTenantCheckFilter( permissionSets, opCtx.object, @@ -3373,23 +3402,80 @@ export class SecurityPlugin implements Plugin { const delTenantCheck = delegatorSets ? await this.computeWriteTenantCheckFilter(delegatorSets, opCtx.object, opCtx.operation, delegatorContext) : null; - const tenantParts = [tenantCheck, delTenantCheck].filter(Boolean) as Record[]; - // EVERY supplied row must clear the wall — one forged row in a bulk - // payload denies the whole write (fail closed, no partial landing). - if ( - tenantParts.length > 0 && - !suppliedRows.every((row) => tenantParts.every((f) => matchesFilterCondition(row as any, f as any))) - ) { - this.logger.warn?.( - `[Security] Layer 0 tenant CHECK FAILED on ${opCtx.operation} '${opCtx.object}' — write denied ` + - `(fail-closed); a supplied organization_id is outside the caller's organization scope`, - ); - throw new PermissionDeniedError( - `[Security] Access denied: the ${opCtx.operation} would place '${opCtx.object}' in another tenant ` + - `(organization_id is outside the caller's organization scope)`, - { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, - ); - } + tenantParts = [tenantCheck, delTenantCheck].filter(Boolean) as Record[]; + } + const clearsWall = (row: Record): boolean => + tenantParts.every((f) => matchesFilterCondition(row as any, f as any)); + // The ONE refusal, shared by both halves — so a value the caller sent + // and a value a hook wrote are refused identically. + const denyTenantPlacement = (source: string): never => { + this.logger.warn?.( + `[Security] Layer 0 tenant CHECK FAILED on ${opCtx.operation} '${opCtx.object}' — write denied ` + + `(fail-closed); ${source} organization_id is outside the caller's organization scope`, + ); + throw new PermissionDeniedError( + `[Security] Access denied: the ${opCtx.operation} would place '${opCtx.object}' in another tenant ` + + `(organization_id is outside the caller's organization scope)`, + { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, + ); + }; + // EVERY supplied row must clear the wall — one forged row in a bulk + // payload denies the whole write (fail closed, no partial landing). + if (tenantParts.length > 0 && !suppliedRows.every(clearsWall)) denyTenantPlacement('a supplied'); + + // ── [#20013] The wall on the STORED row, after the hooks ───────────── + // + // Everything above judges `opCtx.data`, the payload as the caller SENT + // it, and runs before `next()` runs the engine's `beforeInsert` / + // `beforeUpdate` chain. A hook that writes `organization_id` (derived + // from another payload field, a parent, a lookup) therefore produced a + // value the wall never saw, and the row was stored in whatever + // organization it named. Measured on driver-sql and driver-sqlite-wasm, + // `isolated` posture: an insert, an array insert, a by-id update and a + // predicate update were all admitted with the row stored outside the + // caller's organization scope. + // + // So the wall also judges the row the engine is about to store, in the + // seam the row-level `check` already uses + // (`OperationContext.postHookWriteImageCheck`): the rows the insert + // chain produced, the prior row merged with the final payload on a + // by-id update, and every matched row merged with it on a predicate + // update. The predicate is the SAME `tenantParts` — computed here, with + // this request's caller and delegator contexts — so a stored row is + // held to exactly the wall a supplied value is. On an update the image + // carries the row's current organization whenever the final payload + // leaves the column alone; that row was selected under this same wall + // (step 2.7's pre-image on the by-id path, the composed AST on the + // predicate path), so an update that does not touch the column clears + // it and nothing ordinary moves. + // + // The seam is installed whenever the wall applies to the write — a + // walled posture, a tenant object, a caller Layer 0 does not exempt — + // and NOT only when a business `check` does: step 3.6 installs its own + // judgement only when a `check` applies, and the common tenant object + // declares none. When step 3.6 installed one, the two are composed + // into the one handle the engine runs, in the order this middleware + // judges in (the `check`, then the wall). + // + // The judgement above STAYS. It refuses a supplied value a hook would + // have overwritten with an in-scope one; dropping it would admit writes + // refused today, and this change only ever refuses more. + // + // Array UPDATE payloads are left as step 3.6 leaves them: the engine + // takes one payload per update, so the seam judges a single payload. + if (tenantParts.length > 0 && (opCtx.operation === 'insert' || !Array.isArray(opCtx.data))) { + const businessCheck = writeImageCheckSeam; + writeImageCheckSeam = { + evaluate: (rows) => { + businessCheck?.evaluate(rows); + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + if (namesOrganization(row) && !clearsWall(row)) denyTenantPlacement("the stored row's"); + } + }, + }; + opCtx.postHookWriteImageCheck = writeImageCheckSeam; + tenantWallSeamInstalled = true; } } @@ -3576,11 +3662,35 @@ export class SecurityPlugin implements Plugin { // that silently stops gating and a deployment that never finds out. // ⛔ Do not soften this into a warning: a middleware that cannot say a // write was checked must not report that it was. - if (writeImageCheckSeam && writeImageCheckSeam.honoured !== true) { - const developerMessage = - `[Security] Access denied: the ${opCtx.operation} on '${opCtx.object}' was executed without the row-level CHECK ` + - `being evaluated — the engine did not run OperationContext.postHookWriteImageCheck. ` + - `The write is NOT vouched for by this gate.`; + // + // [#20013] It covers the Layer 0 tenant wall's stored-row judgement too, + // with ONE stand-down, and only for a seam that carries the wall alone: + // the ADR-0094 permission-set data door (registered below, inside this + // middleware) executes an insert or update of `sys_permission_set` + // ITSELF, through the metadata protocol, and never calls `next()`. No + // engine write runs there, so no hook chain runs, and the only + // `organization_id` such a write can carry is the one the payload + // judgement above already cleared; the stored-row half has nothing left + // to judge. Measured on the base: a platform administrator's insert + // there, under the `isolated` posture, is admitted while Layer 0 walls + // the object, so without this stand-down the guard would refuse a write + // it has no reason to. The fact is OBSERVED by the wrapper that + // registers the data door (it records a write the door never passed on + // to `next()`), never declared by an operation field another middleware + // could set. A seam carrying a row-level `check` is NOT stood down: that + // judgement has no payload half for an insert, and the data door keeps + // failing closed under one exactly as before. + const standsDown = + !businessCheckSeamInstalled && tenantWallSeamInstalled && writesExecutedByDataDoor.has(opCtx); + if (writeImageCheckSeam && writeImageCheckSeam.honoured !== true && !standsDown) { + const developerMessage = businessCheckSeamInstalled + ? `[Security] Access denied: the ${opCtx.operation} on '${opCtx.object}' was executed without the row-level CHECK ` + + `being evaluated — the engine did not run OperationContext.postHookWriteImageCheck. ` + + (tenantWallSeamInstalled ? `The Layer 0 tenant wall was not evaluated on the stored row either. ` : '') + + `The write is NOT vouched for by this gate.` + : `[Security] Access denied: the ${opCtx.operation} on '${opCtx.object}' was executed without the Layer 0 ` + + `tenant wall being evaluated on the stored row — the engine did not run ` + + `OperationContext.postHookWriteImageCheck. The write is NOT vouched for by this gate.`; // Contract arg order (#5637): `error(message, error?: Error, meta?)` — // the structured fields ride in the THIRD position. There is no `Error` // to carry here: nothing threw, the seam simply never ran. @@ -3638,15 +3748,29 @@ export class SecurityPlugin implements Plugin { // all passed before a write is translated. Kernels without a capable // metadata protocol pass through to the legacy direct write (single // store — no split brain to prevent). + // + // [#20013] Wrapped so the security middleware can tell a write the door + // executed itself from one it passed on: the wrapper hands the door a + // `next` that records the pass-on, and a write that returns without it is + // recorded in `writesExecutedByDataDoor`. Observed, not declared — the + // door's own code is unchanged. + const permissionSetWriteThrough = createPermissionSetWriteThrough({ + ql, + metadata, + getProtocol: () => { + try { return (ctx as any).getService?.('protocol') ?? null; } catch { return null; } + }, + logger: ctx.logger, + }); ql.registerMiddleware( - createPermissionSetWriteThrough({ - ql, - metadata, - getProtocol: () => { - try { return (ctx as any).getService?.('protocol') ?? null; } catch { return null; } - }, - logger: ctx.logger, - }), + async (opCtx: any, next: () => Promise) => { + let passedOn = false; + await permissionSetWriteThrough(opCtx, async () => { + passedOn = true; + await next(); + }); + if (!passedOn && opCtx && typeof opCtx === 'object') writesExecutedByDataDoor.add(opCtx); + }, { object: 'sys_permission_set' }, ); From 6bb02ac3e58fa3f97f96e0e41a19fb4c06bcafed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:31:11 +0000 Subject: [PATCH 3/6] test(plugin-security): engine doubles run the stored-row seam on walled writes The Layer 0 tenant wall now installs OperationContext.postHookWriteImageCheck on every walled insert and update, so the terminals that stand in for the engine run it as the engine does (an insert's rows as sent, the by-id row or the matched rows merged with the payload) instead of being refused by the fail-closed guard. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../src/authz-matrix-gate.test.ts | 10 +++ .../src/can-write-object-admission.test.ts | 14 +++- .../src/check-only-write-scope.test.ts | 13 +++- ...d-by-parent-detail-write-authority.test.ts | 9 +++ ...ontrolled-by-parent-master-widener.test.ts | 9 +++ .../src/explain-write-verdict-inputs.test.ts | 16 ++++- ...-active-organization-write-refusal.test.ts | 16 ++++- .../src/row-write-widener-composition.test.ts | 16 ++++- .../src/select-only-write-visibility.test.ts | 5 +- ...tenant-layer0-verdict-on-operation.test.ts | 66 ++++++++++++------- 10 files changed, 141 insertions(+), 33 deletions(-) diff --git a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts index c5e33193aaf..0a44069c3c4 100644 --- a/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts +++ b/packages/plugins/plugin-security/src/authz-matrix-gate.test.ts @@ -161,8 +161,18 @@ function makeHarness(opts: { // merged with the payload) before the statement, and a terminal that skipped // it would be refused fail-closed by the middleware. Only the by-id path is // modelled, through the producer's own dispatch predicate. + // + // [#20013] …and the INSERT path, because the Layer 0 tenant wall now + // installs the same seam on a walled insert: `ObjectQL.insert` runs it on + // the rows the `beforeInsert` chain produced, which here (no hooks) are the + // rows as sent. const runByIdWriteImageCheck = async (opCtx: any) => { const seam = opCtx?.postHookWriteImageCheck; + if (seam && opCtx.operation === 'insert') { + seam.honoured = true; + await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]); + return; + } if (!seam || opCtx.operation !== 'update') return; const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options); if (dispatch.kind !== 'by-id') return; diff --git a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts index 8d726ffba79..376f77e3850 100644 --- a/packages/plugins/plugin-security/src/can-write-object-admission.test.ts +++ b/packages/plugins/plugin-security/src/can-write-object-admission.test.ts @@ -388,7 +388,19 @@ async function middlewareAdmits( data, }; try { - await middleware(opCtx, async () => {}); + // [#20013] The terminal stands in for the engine, and the engine runs the + // installed `postHookWriteImageCheck` on an INSERT's rows once its + // `beforeInsert` chain has run — here (no hooks) the rows as sent. Under a + // walled posture the Layer 0 tenant wall installs that seam on every + // insert it walls, and a terminal that skipped it would be refused + // fail-closed. Only the insert path is modelled: every walled case in this + // file that reaches the terminal is an insert. + await middleware(opCtx, async () => { + const seam = opCtx.postHookWriteImageCheck; + if (!seam || opCtx.operation !== 'insert') return; + seam.honoured = true; + await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]); + }); return true; } catch { return false; diff --git a/packages/plugins/plugin-security/src/check-only-write-scope.test.ts b/packages/plugins/plugin-security/src/check-only-write-scope.test.ts index 810cb41f7e2..ca9dbb5911d 100644 --- a/packages/plugins/plugin-security/src/check-only-write-scope.test.ts +++ b/packages/plugins/plugin-security/src/check-only-write-scope.test.ts @@ -283,7 +283,16 @@ function makeEngine() { const rows = await this.find(object, { ...options, limit: 1 }); return rows[0] ?? null; }, - async insert(object: string, data: any) { + // [#20013] The INSERT twin of the seam run below: the engine hands an + // installed write-image check the rows its `beforeInsert` chain produced — + // here (no hooks) the rows as sent. The Layer 0 tenant wall installs that + // seam on every walled insert, with or without a `check`. + async insert(object: string, data: any, opCtx?: any) { + const seam = opCtx?.postHookWriteImageCheck; + if (seam) { + seam.honoured = true; + await seam.evaluate(Array.isArray(data) ? data : [data]); + } (tables[object] ??= []).push({ ...data }); return data; }, @@ -390,7 +399,7 @@ async function makeStack(opts: { orgScoping?: boolean } = {}): Promise { await securityMw(opCtx, async () => { await sharingMw(opCtx, async () => { if (opCtx.operation === 'delete') await engine.delete(opCtx.object, opCtx.options); - else if (opCtx.operation === 'insert') await engine.insert(opCtx.object, opCtx.data); + else if (opCtx.operation === 'insert') await engine.insert(opCtx.object, opCtx.data, opCtx); else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx); reached = true; }); diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts index 7441c7afbb2..987447b7c12 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-detail-write-authority.test.ts @@ -387,9 +387,18 @@ function makeStore(rows: Record) { * the payload, before the statement; a terminal that skipped it would be * refused fail-closed by the security middleware. Only the by-id path is * modelled, through the producer's own dispatch predicate. + * + * [#20013] …and the INSERT path: the Layer 0 tenant wall installs the same + * seam on every walled insert, and `ObjectQL.insert` runs it on the rows + * its `beforeInsert` chain produced — here (no hooks) the rows as sent. */ async runByIdWriteImageCheck(opCtx: any) { const seam = opCtx?.postHookWriteImageCheck; + if (seam && opCtx.operation === 'insert') { + seam.honoured = true; + await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]); + return; + } if (!seam || opCtx.operation !== 'update') return; const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options); if (dispatch.kind !== 'by-id') return; diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-master-widener.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-master-widener.test.ts index be40b831a2e..8ac018cdcd5 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-master-widener.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-master-widener.test.ts @@ -283,9 +283,18 @@ function makeStore(rows: Record) { * the payload, before the statement; a terminal that skipped it would be * refused fail-closed by the security middleware. Only the by-id path is * modelled, through the producer's own dispatch predicate. + * + * [#20013] …and the INSERT path: the Layer 0 tenant wall installs the same + * seam on every walled insert, and `ObjectQL.insert` runs it on the rows + * its `beforeInsert` chain produced — here (no hooks) the rows as sent. */ async runByIdWriteImageCheck(opCtx: any) { const seam = opCtx?.postHookWriteImageCheck; + if (seam && opCtx.operation === 'insert') { + seam.honoured = true; + await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]); + return; + } if (!seam || opCtx.operation !== 'update') return; const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options); if (dispatch.kind !== 'by-id') return; diff --git a/packages/plugins/plugin-security/src/explain-write-verdict-inputs.test.ts b/packages/plugins/plugin-security/src/explain-write-verdict-inputs.test.ts index 95772f2def8..9862ca46214 100644 --- a/packages/plugins/plugin-security/src/explain-write-verdict-inputs.test.ts +++ b/packages/plugins/plugin-security/src/explain-write-verdict-inputs.test.ts @@ -172,12 +172,24 @@ function makeEngine(schema: Record, rowOverrides: Partial r.id === dispatch.id) : rows.filter((r) => matches(r, options?.where)); + const seam = opCtx?.postHookWriteImageCheck; + if (seam) { + seam.honoured = true; + await seam.evaluate(targets.map((r) => ({ ...r, ...data }))); + } for (const r of targets) Object.assign(r, data); return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; }, @@ -265,7 +277,7 @@ async function makeStack( await securityMw(opCtx, async () => { await sharingMw(opCtx, async () => { if (operation === 'delete') await engine.delete(opCtx.object, opCtx.options); - else await engine.update(opCtx.object, opCtx.data, opCtx.options); + else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx); reached = true; }); }); diff --git a/packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts b/packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts index b58e6f49115..12126e8d6b2 100644 --- a/packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts +++ b/packages/plugins/plugin-security/src/no-active-organization-write-refusal.test.ts @@ -124,8 +124,22 @@ async function boot(opts: BootOpts = {}) { warn, // eslint-disable-next-line @typescript-eslint/no-explicit-any readFilter: (object: string, context: any) => (plugin as any).getReadFilter(object, context), + // [#20013] The terminal stands in for the engine, which runs the installed + // `postHookWriteImageCheck` on an INSERT's rows once its `beforeInsert` + // chain has run — here (no hooks) the rows as sent. The Layer 0 tenant wall + // installs that seam on every insert it walls, and a terminal that skipped + // it would be refused fail-closed. Only the insert path is modelled: every + // walled write in this file that reaches the terminal is an insert. // eslint-disable-next-line @typescript-eslint/no-explicit-any - run: async (opCtx: any) => { await middleware!(opCtx, async () => {}); return opCtx; }, + run: async (opCtx: any) => { + await middleware!(opCtx, async () => { + const seam = opCtx.postHookWriteImageCheck; + if (!seam || opCtx.operation !== 'insert') return; + seam.honoured = true; + await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]); + }); + return opCtx; + }, }; } diff --git a/packages/plugins/plugin-security/src/row-write-widener-composition.test.ts b/packages/plugins/plugin-security/src/row-write-widener-composition.test.ts index 9e2ed4210b3..d1b00fb2d75 100644 --- a/packages/plugins/plugin-security/src/row-write-widener-composition.test.ts +++ b/packages/plugins/plugin-security/src/row-write-widener-composition.test.ts @@ -256,12 +256,24 @@ function makeEngine() { // (#4550 / #5480 / #6277), never a hand-mirrored guard: a fixture that // drifts to a call shape `ObjectQL` would refuse fails loudly here instead // of collecting a green from gates that never ran. - async update(object: string, data: any, options?: any) { + // + // [#20013] `opCtx` is the operation the middleware chain ran on. The + // engine hands an installed write-image check the rows it is about to + // store — the by-id row, or every matched row, merged with the payload — + // before it writes. The Layer 0 tenant wall installs that seam on every + // walled update (this stack is walled), and a double that skipped it would + // be refused, fail-closed, by the security middleware. + async update(object: string, data: any, options?: any, opCtx?: any) { const dispatch = assertEngineUpdateDispatch(data, options); const rows = (tables[object] ??= []); const targets = dispatch.kind === 'by-id' ? rows.filter((r) => r.id === dispatch.id) : rows.filter((r) => matches(r, options?.where)); + const seam = opCtx?.postHookWriteImageCheck; + if (seam) { + seam.honoured = true; + await seam.evaluate(targets.map((r) => ({ ...r, ...data }))); + } for (const r of targets) Object.assign(r, data); return dispatch.kind === 'by-id' ? (targets[0] ?? null) : targets.length; }, @@ -354,7 +366,7 @@ async function makeStack(): Promise { await securityMw(opCtx, async () => { await sharingMw(opCtx, async () => { if (operation === 'delete') await engine.delete(opCtx.object, opCtx.options); - else await engine.update(opCtx.object, opCtx.data, opCtx.options); + else await engine.update(opCtx.object, opCtx.data, opCtx.options, opCtx); reached = true; }); }); diff --git a/packages/plugins/plugin-security/src/select-only-write-visibility.test.ts b/packages/plugins/plugin-security/src/select-only-write-visibility.test.ts index 43273dada74..e58d50b0307 100644 --- a/packages/plugins/plugin-security/src/select-only-write-visibility.test.ts +++ b/packages/plugins/plugin-security/src/select-only-write-visibility.test.ts @@ -612,8 +612,11 @@ describe('[#7665] the bulk write path is scoped by the same derived visibility', ast: { where: {} }, }; const securityMw = stack.engine._middlewares[0]; + // [#20013] `opCtx` rides along so the double runs the installed stored-row + // seam over the matched rows, as the engine does: the Layer 0 tenant wall + // installs it on this walled predicate update. await securityMw(opCtx, async () => { - await stack.engine.update(opCtx.object, opCtx.data, { ...opCtx.options, where: opCtx.ast.where, multi: true }); + await stack.engine.update(opCtx.object, opCtx.data, { ...opCtx.options, where: opCtx.ast.where, multi: true }, opCtx); }); expect(rowById(stack, 'qa_ticket', TICKET_C2.id)?.next_step).toBe('bulk-edit'); expect(rowById(stack, 'qa_ticket', TICKET_C1.id)?.next_step, 'the unreadable row must stay untouched').toBe('call'); diff --git a/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts b/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts index cf697659585..1799bb6fc3b 100644 --- a/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts +++ b/packages/plugins/plugin-security/src/tenant-layer0-verdict-on-operation.test.ts @@ -238,6 +238,24 @@ async function boot(opts: { return { plugin, logger, middleware: middlewares[0] }; } +/** + * [#20013] The terminal every case hands the middleware, standing in for the + * engine's write. The engine runs an installed `postHookWriteImageCheck` on + * the rows it is about to store: an insert's rows (here, with no hooks, the + * rows as sent), and on a predicate update every matched row merged with the + * payload — this double stores no rows, so that is an EMPTY judgement, still a + * run. The Layer 0 tenant wall installs the seam on every walled write, and a + * terminal that skipped it would be refused fail-closed after `next()`. A read + * or a delete carries no seam, so this terminal does nothing for them. + */ +const engineTerminal = (opCtx: any) => async (): Promise => { + const seam = opCtx.postHookWriteImageCheck; + if (!seam) return; + seam.honoured = true; + if (opCtx.operation === 'insert') await seam.evaluate(Array.isArray(opCtx.data) ? opCtx.data : [opCtx.data]); + else await seam.evaluate([]); +}; + /** A predicate write — the `multi: true` shape, which carries an `ast`. */ function sweep(object: string, operation: 'update' | 'delete', context: Record) { const where = { status: 'open' }; @@ -299,7 +317,7 @@ describe('[#15813] the middleware records the Layer 0 verdict it composed — on it('`isolated`, a member with an active organization: `organization`, and the injected wall is that equality', async () => { const { middleware } = await boot(); const opCtx = sweep('crm_task', 'update', MEMBER_CTX); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); expect(injectedOrgWall(opCtx)).toBe('org-1'); }); @@ -307,7 +325,7 @@ describe('[#15813] the middleware records the Layer 0 verdict it composed — on it('the predicate DELETE path records it too', async () => { const { middleware } = await boot(); const opCtx = sweep('crm_task', 'delete', MEMBER_CTX); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); expect(injectedOrgWall(opCtx)).toBe('org-1'); }); @@ -315,7 +333,7 @@ describe('[#15813] the middleware records the Layer 0 verdict it composed — on it('`group`: `organizations` is the membership SET, and the injected wall is the same set', async () => { const { middleware } = await boot({ tenancy: { posture: 'group' } }); const opCtx = sweep('crm_task', 'update', { ...MEMBER_CTX, accessible_org_ids: ['org-1', 'org-2'] }); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organizations', organizationIds: ['org-1', 'org-2'] }); expect(injectedOrgWall(opCtx)).toEqual({ $in: ['org-1', 'org-2'] }); }); @@ -323,7 +341,7 @@ describe('[#15813] the middleware records the Layer 0 verdict it composed — on it('`group` with a repeated membership records the organization ONCE — a set, so a reader may test length === 1', async () => { const { middleware } = await boot({ tenancy: { posture: 'group' } }); const opCtx = sweep('crm_task', 'update', { ...MEMBER_CTX, accessible_org_ids: ['org-1', 'org-1'] }); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organizations', organizationIds: ['org-1'] }); expect(injectedOrgWall(opCtx)).toEqual({ $in: ['org-1'] }); }); @@ -331,7 +349,7 @@ describe('[#15813] the middleware records the Layer 0 verdict it composed — on it('`single`: `none` — the wall ran and contributed nothing; nothing is injected', async () => { const { middleware } = await boot({ tenancy: { posture: 'single' } }); const opCtx = sweep('crm_task', 'update', MEMBER_CTX); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); expect(injectedOrgWall(opCtx)).toBeUndefined(); }); @@ -341,7 +359,7 @@ describe('[#15813] the middleware records the Layer 0 verdict it composed — on // places no row), so the operation reaches the wall and the wall denies. const { middleware } = await boot(); const opCtx = sweep('crm_task', 'delete', { userId: 'u1', positions: [], permissions: [], posture: 'MEMBER' }); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'deny' }); }); }); @@ -350,26 +368,26 @@ describe('[#15813] the populations the engine could never answer are answered wh it('the deployment\'s #12699 carve-out: an exempted object under an armed wall records `none` — the #15706 population', async () => { const { middleware } = await boot({ entitlement: { platformGlobalObjects: ['sys_widget_registry'] } }); const exempted = sweep('sys_widget_registry', 'update', MEMBER_CTX); - await middleware(exempted, async () => {}); + await middleware(exempted, engineTerminal(exempted)); expect(exempted.tenantLayer0Verdict).toEqual({ kind: 'none' }); expect(injectedOrgWall(exempted)).toBeUndefined(); // Firing control on the same deployment: the sibling is walled and says so. const sibling = sweep('crm_task', 'update', MEMBER_CTX); - await middleware(sibling, async () => {}); + await middleware(sibling, engineTerminal(sibling)); expect(sibling.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); }); it('an object that opted out of tenancy (`tenancy.enabled: false`) records `none`', async () => { const { middleware } = await boot(); const opCtx = sweep('sys_catalog', 'update', MEMBER_CTX); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); }); it('a `PLATFORM_ADMIN` rung on a PUBLIC tenant object records `organization` — the wall STANDS there (the engine answered this absent)', async () => { const { middleware } = await boot({ sets: [PLAIN_MEMBER, ADMIN_SET as PermissionSet] }); const opCtx = sweep('crm_task', 'update', { ...MEMBER_CTX, permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN' }); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); expect(injectedOrgWall(opCtx)).toBe('org-1'); }); @@ -377,7 +395,7 @@ describe('[#15813] the populations the engine could never answer are answered wh it('a `PLATFORM_ADMIN` on a posture-permitting (private) object records `none` — the wall was crossed, so no organization is asserted', async () => { const { middleware } = await boot({ sets: [PLAIN_MEMBER, ADMIN_SET as PermissionSet] }); const opCtx = sweep('crm_secret', 'update', { ...MEMBER_CTX, permissions: [ADMIN_FULL_ACCESS], posture: 'PLATFORM_ADMIN' }); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); expect(injectedOrgWall(opCtx)).toBeUndefined(); }); @@ -386,12 +404,12 @@ describe('[#15813] the populations the engine could never answer are answered wh const { middleware } = await boot({ sets: [PLAIN_MEMBER, ADMIN_SET as PermissionSet, SECRET_EDITOR] }); const { posture: _drop, ...rungless } = MEMBER_CTX; const admin = sweep('crm_secret', 'update', { ...rungless, permissions: [ADMIN_FULL_ACCESS] }); - await middleware(admin, async () => {}); + await middleware(admin, engineTerminal(admin)); expect(admin.tenantLayer0Verdict).toEqual({ kind: 'none' }); // Same private object, same absent rung, an explicit grant instead of the // platform set: the probe finds no platform capability and the wall stands. const member = sweep('crm_secret', 'update', { ...rungless, permissions: ['secret_editor'] }); - await middleware(member, async () => {}); + await middleware(member, engineTerminal(member)); expect(member.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); }); }); @@ -400,7 +418,7 @@ describe('[#15813] nothing is recorded where no wall was composed — absence is it('a system context takes the middleware\'s first exit: no verdict member at all', async () => { const { middleware } = await boot(); const opCtx = sweep('crm_task', 'update', { isSystem: true, userId: 'usr_system', tenantId: 'org-1' }); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(hasVerdict(opCtx)).toBe(false); }); @@ -410,7 +428,7 @@ describe('[#15813] nothing is recorded where no wall was composed — absence is // The pre-image gate re-reads the row through the (fake) engine and refuses // a row it cannot see — irrelevant here: the assertion is about what the // middleware left on the context, and step 3 is never reached without an ast. - await middleware(opCtx, async () => {}).catch(() => undefined); + await middleware(opCtx, engineTerminal(opCtx)).catch(() => undefined); expect(hasVerdict(opCtx)).toBe(false); }); @@ -444,21 +462,21 @@ describe('[#15887] the three object shapes record their verdict HERE, not one la expect((SCHEMAS.shared_catalog.fields as Record).organization_id).toBeDefined(); const opCtx = sweep('shared_catalog', 'update', MEMBER_CTX); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); expect(injectedOrgWall(opCtx)).toBeUndefined(); // Firing control on the same boot: the sibling with no opt-out IS walled, // so `none` above is this object's verdict and not a dead middleware. const sibling = sweep('crm_task', 'update', MEMBER_CTX); - await middleware(sibling, async () => {}); + await middleware(sibling, engineTerminal(sibling)); expect(sibling.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); }); it('[#7835] a FEDERATED object carrying the PLATFORM\'s injected anchor records `none` — the phantom column is not a wall', async () => { const { middleware } = await boot(); const opCtx = sweep('ext_customer', 'update', MEMBER_CTX); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); expect(injectedOrgWall(opCtx)).toBeUndefined(); }); @@ -468,7 +486,7 @@ describe('[#15887] the three object shapes record their verdict HERE, not one la // every federated object", which would delete a wall that is doing its job. const { middleware } = await boot(); const opCtx = sweep('ext_ledger', 'update', MEMBER_CTX); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); expect(injectedOrgWall(opCtx)).toBe('org-1'); }); @@ -481,7 +499,7 @@ describe('[#15887] the three object shapes record their verdict HERE, not one la // `organization_id`, never `workspace_id`. const { middleware } = await boot(); const opCtx = sweep('workspace_doc', 'update', MEMBER_CTX); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organization', organizationId: 'org-1' }); expect(injectedOrgWall(opCtx)).toBe('org-1'); expect(JSON.stringify(opCtx.ast.where)).not.toContain('workspace_id'); @@ -495,7 +513,7 @@ describe('[#15887] the three object shapes record their verdict HERE, not one la const { middleware } = await boot(); expect((SCHEMAS.workspace_note.fields as Record).organization_id).toBeUndefined(); const opCtx = sweep('workspace_note', 'update', MEMBER_CTX); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'none' }); expect(injectedOrgWall(opCtx)).toBeUndefined(); }); @@ -527,7 +545,7 @@ describe('[#15887 / ADR-0090 D10] the on-behalf-of INTERSECTION is recorded at t accessible_org_ids: CALLER_ORGS, onBehalfOf: { userId: DELEGATOR }, }); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); // Both walls really were composed onto this one operation, in order. expect(injectedOrgWalls(opCtx)).toEqual([ @@ -550,7 +568,7 @@ describe('[#15887 / ADR-0090 D10] the on-behalf-of INTERSECTION is recorded at t delegator: { userId: DELEGATOR, memberOf: ['org-2', 'org-3', 'org-9'] }, }); const opCtx = sweep('crm_task', 'update', { ...MEMBER_CTX, accessible_org_ids: CALLER_ORGS }); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(injectedOrgWalls(opCtx)).toEqual([{ $in: ['org-1', 'org-2', 'org-3'] }]); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'organizations', organizationIds: CALLER_ORGS }); }); @@ -570,7 +588,7 @@ describe('[#15887 / ADR-0090 D10] the on-behalf-of INTERSECTION is recorded at t accessible_org_ids: CALLER_ORGS, onBehalfOf: { userId: DELEGATOR }, }); - await middleware(opCtx, async () => {}); + await middleware(opCtx, engineTerminal(opCtx)); expect(opCtx.tenantLayer0Verdict).toEqual({ kind: 'deny' }); }); }); From db01082804b8bfd1cfac89a2f0d1d59f6daf5956 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:53:04 +0000 Subject: [PATCH 4/6] test(plugin-security): pin the business check's half of the composed seam under a walled posture Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../src/tenant-wall-post-hook-image.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts b/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts index e62e1c06ffd..36fd6aab3ed 100644 --- a/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts +++ b/packages/plugins/plugin-security/src/tenant-wall-post-hook-image.test.ts @@ -29,8 +29,9 @@ * organization scope is refused on a by-id update, an insert, an array * insert and a predicate update, with step 3.7's own refusal * (`PERMISSION_DENIED` / 403), and the driver's table is unchanged; the - * same when a business `check` is installed beside it (one composed seam), - * and under the `group` posture against the membership set; + * same when a business `check` is installed beside it (one composed seam, + * which still runs the check), and under the `group` posture against the + * membership set; * - fail-closed: a host that never runs the installed judgement is refused * rather than vouched for; * - the controls: an in-scope hook write is admitted; a supplied @@ -314,6 +315,20 @@ for (const [driverName, makeDriver] of DRIVERS) { expect(await b.table('qa_checked')).toEqual(SEEDED); }); + it('the composed seam still runs the business `check`: an in-scope insert the check refuses is refused, and no row is stored', async () => { + const b = await boot(makeDriver); + + // An insert's `check` has no payload half: only the seam judges it, so + // this cell holds the check's half of the composed judgement. + const outcome = await attempt(() => insert(b, 'qa_checked', { id: 'r3', name: 'forbidden', target_org: OWN_ORG })); + + expect(outcome.ok, 'expected a refusal, got a completed write').toBe(false); + expect(outcome.code).toBe('PERMISSION_DENIED'); + expect(outcome.status).toBe(403); + expect(outcome.developerMessage).toContain('the insert would violate a row-level CHECK'); + expect(await b.table('qa_checked')).toEqual(SEEDED); + }); + it('`group` posture: a hook-written organization outside the membership set is refused, and one inside it is admitted', async () => { const b = await boot(makeDriver, 'group'); From e43cda19b456dc01518e30a8760708a0c8436b96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 21:54:14 +0000 Subject: [PATCH 5/6] chore(changeset): the Layer 0 tenant wall on the stored row Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .changeset/20013-tenant-wall-post-hook.md | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .changeset/20013-tenant-wall-post-hook.md diff --git a/.changeset/20013-tenant-wall-post-hook.md b/.changeset/20013-tenant-wall-post-hook.md new file mode 100644 index 00000000000..fe9914bbefd --- /dev/null +++ b/.changeset/20013-tenant-wall-post-hook.md @@ -0,0 +1,29 @@ +--- +"@objectstack/plugin-security": minor +--- + +fix(plugin-security)!: the Layer 0 tenant write wall now holds for the row a write stores, after the `beforeInsert` / `beforeUpdate` chain (#20013) + +Clause-②: no (narrowing) + + + +**BREAKING**: this narrows the set of writes the write gate accepts. Under a walled tenancy posture (`isolated` or `group`), a write whose `beforeInsert` or `beforeUpdate` hook sets `organization_id` to an organization outside the caller's organization scope is now refused. It is admitted today, and the row is stored in that organization. It ships as `minor` under the launch-window convention, as the row-level `check` changes did (#19950, #19989). + +The Layer 0 tenant wall (ADR-0095 D1, ADR-0105 D5) holds a write's `organization_id` to the same filter the read side uses, so a non-platform user can only place a row in an organization they hold. The wall judged the payload as the caller sent it, before the engine ran the hook chain. A value a hook wrote into `organization_id` after that point was never judged, whether the hook derived it from another field, a parent record or a lookup. + +The wall now also judges the row the engine is about to store, through the seam the row-level `check` already uses (`OperationContext.postHookWriteImageCheck`): an insert's rows once the `beforeInsert` chain has run (every row of an array insert), the one row of a by-id update, and every matched row of a predicate update, each merged with the final payload. It is installed whenever the wall applies to the write, with or without a business `check`. The existing judgement of the payload as sent stays, so this change only ever refuses more. + +**Writes that are now refused.** Each refusal is the wall's existing denial, `403 PERMISSION_DENIED` ("the insert/update would place '…' in another tenant"), and nothing is stored. There is no transition switch. + +- **An insert, a by-id update or a predicate update whose hook chain leaves `organization_id` outside the caller's organization scope**: another organization under `isolated`, one outside the membership set under `group`. For an on-behalf-of write the delegator's scope applies too (ADR-0090 D10). +- **A walled write on a host that installs the judgement and never runs it**, for example a custom write executor in place of the engine. It is refused after the write with an `error` log saying the tenant wall was not evaluated on the stored row, as a write with an uncalled row-level `check` already is. The platform's own permission-set data door (ADR-0094), which executes its writes without the engine and so runs no hook chain, is not affected. + +**Remedy.** A hook that must place a row in another organization does so in a separate write under a system context, which the wall does not gate. Otherwise fix the hook, or the data it derives the organization from, so the stored row stays in the caller's organization scope. + +**What does not change.** + +- A write whose hooks leave `organization_id` in the caller's scope, or leave it alone, is admitted as before. An update that does not touch the column keeps the row's current organization. +- An insert that leaves `organization_id` absent is not judged on it: the platform fills it with the caller's active organization, as before. +- A supplied out-of-scope `organization_id` is refused before anything runs, as before, even when a hook would replace it with an in-scope one. +- A system-context write, a platform administrator on an object whose posture lets them cross the wall, and every write under the `single` posture are not gated by the wall, as before. From cce969cf4d513607fb0b1747d378014d4a1c99da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 22:18:14 +0000 Subject: [PATCH 6/6] test(plugin-security): the explain-fault stack's engine door runs the stored-row seam The merged-in explain-dependency-fault stack is walled, so the Layer 0 tenant wall installs the stored-row seam on its by-id update. Its terminal now runs it on the prior row merged with the payload, as the engine does, through the producer's dispatch predicate. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../src/explain-dependency-fault.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts b/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts index 4ed05cfdcf2..de483b75086 100644 --- a/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts +++ b/packages/plugins/plugin-security/src/explain-dependency-fault.test.ts @@ -27,7 +27,7 @@ // controls pin that a dependency that ANSWERS — including one that answers // "no restriction" — is reported exactly as before. import { describe, it, expect, vi } from 'vitest'; -import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { SharingService, buildSharingMiddleware } from '@objectstack/plugin-sharing'; import { PermissionSetSchema } from '@objectstack/spec/security'; import type { PermissionSet } from '@objectstack/spec/security'; @@ -244,6 +244,19 @@ async function makeStack( // A write stops at the engine's door: reaching it is the admission // under test, and no write verb is exercised on this double. if (operation === 'read') rows = await engine.find(OBJECT, { where: opCtx.ast.where }); + // [#20013] …except the one step the engine takes at that door + // before its statement: it runs the installed stored-row seam + // (`postHookWriteImageCheck`) on the row it writes, the prior row + // merged with the payload. The Layer 0 tenant wall installs that + // seam on every walled update (this stack is walled), and a door + // that skipped it would be refused fail-closed after `next()`. + const seam = opCtx.postHookWriteImageCheck; + if (operation === 'update' && seam) { + const dispatch = assertEngineUpdateDispatch(opCtx.data, opCtx.options); + const prior = dispatch.kind === 'by-id' ? await engine.find(OBJECT, { where: { id: dispatch.id } }) : []; + seam.honoured = true; + await seam.evaluate(prior.map((r: Record) => ({ ...r, ...opCtx.data }))); + } }); }); } catch (error) {