From ec630ad22009bdcd657a3e8f4f21b90d1817f9bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:57:31 +0000 Subject: [PATCH 1/5] fix(plugin-security): judge an organization administrator's removal on the caller's own read An organization administrator's refused assignment write answers the same for a row it cannot read as for a missing one (ADR-0090 D12; ruling of record 5985885287): the organization branch of the delegated-administration gate reads the row a removal addresses under the caller's own context, and a row that read does not return is refused exactly as an id naming no row is. A row the caller reads keeps the boundary guidance. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../src/delegated-admin-gate.ts | 40 ++++++- ...-admin-assignment-boundary.posture.test.ts | 106 ++++++++++++++++++ 2 files changed, 144 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index b85d548919d..bc35ca8ac82 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -220,7 +220,11 @@ function stampWriter(opCtx: any, userId: unknown): void { } export interface DelegatedAdminGateDeps { - /** ObjectQL engine handle (system-context reads for pre-images/lookups). */ + /** + * ObjectQL engine handle: system-context reads for lookups and the delegated + * walk's pre-images; the organization branch reads its removal pre-image under + * the caller's own context ({@link DelegatedAdminGate.organizationTargets}). + */ ql: any; /** Shared permission-set resolution (same path as the CRUD middleware). */ resolveSets: (context: any) => Promise; @@ -756,6 +760,10 @@ export class DelegatedAdminGate { * Every permission set the row distributes must be inside the boundary * ({@link organizationBoundaryFailure}); for a position, every set it carries * ({@link setsCarriedByPosition}). One set outside puts the row outside. + * + * The row a removal targets is judged as the CALLER reads it + * ({@link organizationTargets}), so a row the caller cannot read is answered + * exactly as an id naming no row is (ruling of record 5985885287). */ private async organizationAdminFailure( opCtx: any, @@ -778,8 +786,10 @@ export class DelegatedAdminGate { }; } - for (const t of await this.materializeTargets(opCtx, opCtx.object)) { + for (const t of await this.organizationTargets(opCtx)) { const row = t.next ?? t.prev; + // No row answers an id naming none and a row the caller cannot read + // alike: one refusal, and never a fall-through to the engine. if (!row) return { reason: 'the assignment row to remove could not be read', meta: {} }; const rowOrg = rowOrganizationId(row); @@ -821,6 +831,32 @@ export class DelegatedAdminGate { return null; } + /** + * [ADR-0090 D12; ruling of record 5985885287] The rows an organization + * administrator's insert or removal targets: an insert's payload rows, or the + * one row a removal addresses, as the CALLER reads it. + * + * The pre-image is the caller's own by-id read through the engine — the read + * door's answer to this caller, every read visibility and wall included — + * never a system read: a row the caller cannot read answers what an id naming + * no row answers. A row it reads is judged on what it read, so its guidance + * stays. A read refusal or a store fault propagates as raised: an outage is + * neither an answer nor a refusal, and the by-id write door answers a caller + * the read door refuses with that refusal. + * + * The organization branch only: the delegated walk reads its pre-image through + * {@link materializeTargets}. + */ + private async organizationTargets(opCtx: any): Promise> { + if (opCtx.operation === 'insert') return rowsOf(opCtx).map((r) => ({ next: r, prev: null })); + const id = extractSingleId(opCtx); + const ql = this.deps.ql; + const prev = id != null && ql?.findOne + ? await ql.findOne(opCtx.object, { where: { id }, context: opCtx.context }) + : null; + return [{ next: null, prev: prev && typeof prev === 'object' ? prev : null }]; + } + /** * Is position `positionName` inside the organization boundary? `null` when * every set it carries is; otherwise the first failure, naming the set. diff --git a/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts index cf34a92d25a..99c18cbd0f8 100644 --- a/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts +++ b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts @@ -15,6 +15,12 @@ * `status` and `code` through `resolveThrownHttpError`, the resolver both HTTP * doors answer with — and an out-of-boundary refusal also names its set and * the failed condition. Every admission reads the stored row back. + * + * At the foot: under the walled postures, an organization administrator's + * update and delete of a row it cannot read answer exactly what an id naming + * no row answers (ruling of record 5985885287) — the whole refusal, sentence + * and details included; a row it reads keeps the boundary guidance, and a + * tenant-level administrator is answered by the engine as before. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -276,3 +282,103 @@ describe('[ADR-0090 D12, amended 2026-10-17] the organization administrator assi }); } }); + +/** An id that names no row in either table. */ +const NOWHERE = 'zz_nowhere_0001'; + +/** Everything a refusal carries: the envelope both doors resolve, its sentence and its details. */ +async function answerOf(run: () => Promise): Promise> { + try { + await run(); + return { status: 200 }; + } catch (e) { + const r = resolveThrownHttpError(e); + return { status: r.status, code: r.code, message: (e as Error).message, details: (e as any).details }; + } +} + +/** An in-boundary row of `organizationId`, written by the system. */ +async function seedIn(h: Harness, table: Table, id: string, organizationId: string, userId: string) { + await h.engine.insert(table, { id, user_id: userId, ...TABLES[table].in, organization_id: organizationId }, + { context: { isSystem: true, tenantId: organizationId } } as any); +} + +const ORG_ADMINS = ['orgAdmin', 'orgAdminNoBypass'] as const; +const write = (h: Harness, table: Table, op: 'update' | 'delete', id: string, context: object) => + answerOf(() => op === 'update' + ? h.engine.update(table, { reason: 'edited' }, { where: { id }, context } as any) + : h.engine.delete(table, { where: { id }, context } as any)); + +describe("[ADR-0090 D12; ruling of record 5985885287] an organization administrator's refused assignment write answers the same for a row it cannot read as for a missing one", () => { + for (const posture of ['group', 'isolated'] as const) { + describe(`posture '${posture}'`, () => { + it('update and delete of a row the caller cannot read answer exactly what an id naming no row answers, and leave the row', async () => { + const h = await boot(posture); + for (const table of Object.keys(TABLES) as Table[]) { + const id = `unreadable_${table}`; + await seedIn(h, table, id, ORG_B, 'u_other_org'); + const before = await stored(h, table, { id }); + expect(before, `${table}: the row exists`).toHaveLength(1); + for (const caller of ORG_ADMINS) { + const context = CALLERS[caller]; + // The precondition, read off the read door: this caller reads no such row. + expect(await h.engine.findOne(table, { where: { id }, context } as any), `${caller} reads ${table} ${id}`).toBeFalsy(); + for (const op of ['update', 'delete'] as const) { + const row = await write(h, table, op, id, context); + const none = await write(h, table, op, NOWHERE, context); + expect([none.status, none.code], `${caller} ${op} ${table}, no row: ${none.message}`).toEqual([403, 'PERMISSION_DENIED']); + expect(row, `${caller} ${op} ${table}: the unreadable row answers as no row`).toEqual(none); + } + } + expect(await stored(h, table, { id }), `${table}: refused means untouched`).toEqual(before); + } + }); + + it('control: a row of its own organization, which it reads, keeps the boundary guidance; the platform administrator is answered as before', async () => { + const h = await boot(posture); + for (const table of Object.keys(TABLES) as Table[]) { + await seed(h, table, 'own_out', 'out'); + for (const caller of ORG_ADMINS) { + const context = CALLERS[caller]; + expect(await h.engine.findOne(table, { where: { id: 'own_out' }, context } as any), `${caller} reads ${table}`).toBeTruthy(); + const own = await write(h, table, 'delete', 'own_out', context); + expect([own.status, own.code], `${caller} ${table}: ${own.message}`).toEqual([403, 'PERMISSION_DENIED']); + expect(own.message).toMatch(/'admin_full_access'.*by name/); + expect(own, `${caller} ${table}: a row it reads is not answered as no row`).not.toEqual(await write(h, table, 'delete', NOWHERE, context)); + } + expect(await stored(h, table, { id: 'own_out' }), `${table}: refused means untouched`).toHaveLength(1); + + // A tenant-level administrator passes the gate: the engine answers + // its by-id writes, and the row it addresses outside its active + // organization answers that engine's not-found, as no row does. + const id = `platform_${table}`; + await seedIn(h, table, id, ORG_B, 'u_other_org'); + for (const op of ['update', 'delete'] as const) { + const row = await write(h, table, op, id, CALLERS.platform); + const none = await write(h, table, op, NOWHERE, CALLERS.platform); + expect([none.status, none.code], `platform ${op} ${table}: ${none.message}`).toEqual([404, 'RECORD_NOT_FOUND']); + expect({ ...row, message: String(row.message).split(id).join('ID') }, `platform ${op} ${table}`) + .toEqual({ ...none, message: String(none.message).split(NOWHERE).join('ID') }); + } + expect(await stored(h, table, { id }), `${table}: refused means untouched`).toHaveLength(1); + } + }); + }); + } + + it("control, 'single': a row of another organization that the caller reads keeps the guidance that names its organization", async () => { + const h = await boot('single'); + for (const table of Object.keys(TABLES) as Table[]) { + const id = `readable_${table}`; + await seedIn(h, table, id, ORG_B, 'u_other_org'); + for (const caller of ORG_ADMINS) { + const context = CALLERS[caller]; + expect(await h.engine.findOne(table, { where: { id }, context } as any), `${caller} reads ${table}`).toBeTruthy(); + const row = await write(h, table, 'delete', id, context); + expect([row.status, row.code], `${caller} ${table}: ${row.message}`).toEqual([403, 'PERMISSION_DENIED']); + expect(row.message).toMatch(/belongs to organization 'org_b'/); + } + expect(await stored(h, table, { id }), `${table}: refused means untouched`).toHaveLength(1); + } + }); +}); From 925f500e431773dc591759de53bd22408817677e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:16:11 +0000 Subject: [PATCH 2/5] test(dogfood): pin the organization administrator's refused assignment write at the HTTP door Behind a real organization wall, an organization administrator's PATCH and DELETE of an assignment row it cannot read answer exactly what an id naming no row answers; a row it reads keeps the boundary guidance. Adds the patch changeset for plugin-security. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/org-admin-removal-caller-read.md | 14 ++ ...ent-removal-unreadable-row.dogfood.test.ts | 160 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 .changeset/org-admin-removal-caller-read.md create mode 100644 packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts diff --git a/.changeset/org-admin-removal-caller-read.md b/.changeset/org-admin-removal-caller-read.md new file mode 100644 index 00000000000..e96b05ae771 --- /dev/null +++ b/.changeset/org-admin-removal-caller-read.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): an organization administrator's refused assignment write answers the same for a row it cannot read as for a missing one + +Clause-②: no +Metadata-protocol: none + +An organization administrator (`organization_admin` or `organization_admin_no_bypass`) removes an assignment row (`sys_user_position` or `sys_user_permission_set`) by id. The delegated-administration gate (ADR-0090 D12) now judges that row as the administrator itself reads it, in its own context. + +- **A row the administrator cannot read** is refused with exactly the answer an id that names no row gets: `403 PERMISSION_DENIED`, "the assignment row to remove could not be read", with the same details. +- **A row the administrator can read** keeps the answer it had, including the guidance that names the organization boundary. +- **Unchanged.** An edit in place is refused as before, every removal that was admitted is still admitted, and a tenant-level administrator is judged as before. diff --git a/packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts b/packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts new file mode 100644 index 00000000000..c9e1f1600ab --- /dev/null +++ b/packages/qa/dogfood/test/org-admin-assignment-removal-unreadable-row.dogfood.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0090 D12; ruling of record 5985885287] An organization administrator's +// refused assignment write answers the same for a row it cannot read as for a +// missing one — measured at the HTTP data door of the showcase behind a real +// organization wall (`multiTenant: 'posture-only'`, the `isolated` posture). +// +// ── what is pinned ───────────────────────────────────────────────────────── +// +// Organization A's owner holds `organization_admin` for A (the grant its owner +// membership earns). Two assignment rows of organization B — B's owner's own +// administrator grant (`sys_user_permission_set`) and a position B holds +// (`sys_user_position`) — sit behind the wall, so A's owner reads neither. +// For each, `PATCH` and `DELETE` by id answer exactly what the same request +// answers for an id that names no row: status, code, sentence and details, +// the one difference being the id the request itself carried. Both rows stay. +// +// Controls, so a green run is not "every removal is answered alike": +// +// - the wall is real (`isolated`, active, not degraded), A's owner is no +// platform administrator, is active in A and reads neither of B's rows; +// - a row of A that A's owner reads — its own administrator grant, which the +// organization boundary keeps outside by name — keeps the boundary's +// guidance, which names the set, and is not answered as no row; it stays. +// +// The engine-level matrix (both walled postures, both administrator variants, +// the tenant-level administrator, the `single` control) is pinned in +// `packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SYS = { isSystem: true } as const; +const OWNER_A = 'oa-unreadable-owner-a@verify.test'; +const OWNER_B = 'oa-unreadable-owner-b@verify.test'; +const MISSING = 'zz_no_such_row_0001'; + +interface Answer { status: number; body: unknown } + +/** The answer with the caller-supplied id written out, so the answers for two ids compare. */ +const withoutId = (a: Answer, id: string): Answer => + JSON.parse(JSON.stringify(a).split(id).join('ID')) as Answer; + +const codeOf = (a: Answer): unknown => (a.body as any)?.code ?? (a.body as any)?.error?.code; +const idOf = (b: any): string => String(b?.id ?? b?.record?.id ?? b?.data?.id ?? ''); + +describe("behind the organization wall, an organization administrator's refused assignment write answers the same for a row it cannot read as for a missing one", () => { + let stack: VerifyStack; + let ql: any; + let ownerATok: string; + let orgA = ''; + let orgB = ''; + let ownerA = ''; + let grantA = ''; + let grantB = ''; + let positionB = ''; + + const call = async (method: string, path: string, body?: unknown): Promise => { + const res = await stack.api(path, { + method, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ownerATok}` }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + let parsed: unknown; + try { parsed = await res.json(); } catch { parsed = undefined; } + return { status: res.status, body: parsed }; + }; + const write = (method: 'PATCH' | 'DELETE', object: string, id: string): Promise => + call(method, `/data/${object}/${id}`, method === 'PATCH' ? { reason: 'edited' } : undefined); + + const stored = (object: string, id: string) => ql.findOne(object, { where: { id }, context: SYS }); + const userId = async (email: string): Promise => + String((await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id ?? ''); + /** The id of the organization-administrator grant `user` holds in `organizationId`. */ + const adminGrantOf = async (user: string, organizationId: string): Promise => { + const rows = await ql.find('sys_user_permission_set', { + where: { user_id: user, permission_set: 'organization_admin', organization_id: organizationId }, + context: SYS, + }); + return String((Array.isArray(rows) ? rows : [])[0]?.id ?? ''); + }; + + /** A new organization, created and made active by its owner. */ + const organization = async (token: string, name: string, slug: string): Promise => { + const created = await stack.apiAs(token, 'POST', '/auth/organization/create', { name, slug }); + expect(created.status, `org create ${slug}: ${await created.clone().text()}`).toBe(200); + const id = String(((await created.json()) as { id: string }).id); + const active = await stack.apiAs(token, 'POST', '/auth/organization/set-active', { organizationId: id }); + expect(active.status, `set-active ${slug}: ${await active.clone().text()}`).toBe(200); + return id; + }; + + beforeAll(async () => { + stack = await bootShowcase({ multiTenant: 'posture-only' }); + ql = await stack.kernel.getServiceAsync('objectql'); + + ownerATok = await stack.signUp(OWNER_A); + orgA = await organization(ownerATok, 'Unreadable Row A', 'oa-unreadable-a'); + const ownerBTok = await stack.signUp(OWNER_B); + orgB = await organization(ownerBTok, 'Unreadable Row B', 'oa-unreadable-b'); + + ownerA = await userId(OWNER_A); + const ownerB = await userId(OWNER_B); + grantA = await adminGrantOf(ownerA, orgA); + grantB = await adminGrantOf(ownerB, orgB); + positionB = idOf(await ql.insert('sys_user_position', + { user_id: ownerB, position: 'contributor', organization_id: orgB }, + { context: { ...SYS, tenantId: orgB } })); + expect(ownerA && grantA && grantB && positionB, 'every row carries an id').toBeTruthy(); + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it("guard the guard: a real wall; A's owner administers A, is no platform administrator, and reads neither of B's rows", async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string; isolationActive: boolean; degraded: boolean }>('tenancy'); + expect([tenancy.posture, tenancy.isolationActive, tenancy.degraded]).toEqual(['isolated', true, false]); + expect(orgA).not.toBe(orgB); + const session = (await (await stack.apiAs(ownerATok, 'GET', '/auth/get-session')).json()) as any; + expect(session.user?.isPlatformAdmin).not.toBe(true); + expect(session.session?.activeOrganizationId).toBe(orgA); + + for (const [object, id, organizationId] of [ + ['sys_user_permission_set', grantB, orgB], + ['sys_user_position', positionB, orgB], + ]) { + const got = await call('GET', `/data/${object}/${id}`); + expect(got.status, `${object} of B — ${JSON.stringify(got.body)}`).toBe(404); + expect((await stored(object, id))?.organization_id, `${object}: the row lives in B`).toBe(organizationId); + } + const own = await call('GET', `/data/sys_user_permission_set/${grantA}`); + expect(own.status, `A's own grant — ${JSON.stringify(own.body)}`).toBe(200); + }); + + it("PATCH and DELETE of a row it cannot read answer exactly what an id naming no row answers, and leave the row", async () => { + for (const [object, id] of [['sys_user_permission_set', grantB], ['sys_user_position', positionB]]) { + const before = await stored(object, id); + for (const method of ['PATCH', 'DELETE'] as const) { + const hit = await write(method, object, id); + const miss = await write(method, object, MISSING); + expect({ status: miss.status, code: codeOf(miss) }, `${method} ${object}, no row — ${JSON.stringify(miss.body)}`) + .toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(withoutId(hit, id), `${method} ${object}: the unreadable row answers as no row`).toEqual(withoutId(miss, MISSING)); + } + expect(await stored(object, id), `${object}: refused writes leave the row as it was`).toEqual(before); + } + }); + + it("control: a row of A that A's owner reads keeps the boundary's guidance, and is not answered as no row", async () => { + const before = await stored('sys_user_permission_set', grantA); + const own = await write('DELETE', 'sys_user_permission_set', grantA); + const miss = await write('DELETE', 'sys_user_permission_set', MISSING); + expect({ status: own.status, code: codeOf(own) }, JSON.stringify(own.body)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(JSON.stringify(own.body)).toMatch(/'organization_admin'.*by name/); + expect(withoutId(own, grantA)).not.toEqual(withoutId(miss, MISSING)); + expect(await stored('sys_user_permission_set', grantA), 'the refused removal leaves the row').toEqual(before); + }); +}); From d6b573a7910da90fd0161d6c113a757894d4e8d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 11:27:13 +0000 Subject: [PATCH 3/5] docs(plugin-security): word the gate's engine-handle note for both read contexts Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../plugins/plugin-security/src/delegated-admin-gate.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index bc35ca8ac82..589c61df2b5 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -220,11 +220,7 @@ function stampWriter(opCtx: any, userId: unknown): void { } export interface DelegatedAdminGateDeps { - /** - * ObjectQL engine handle: system-context reads for lookups and the delegated - * walk's pre-images; the organization branch reads its removal pre-image under - * the caller's own context ({@link DelegatedAdminGate.organizationTargets}). - */ + /** ObjectQL engine handle (pre-images and lookups). */ ql: any; /** Shared permission-set resolution (same path as the CRUD middleware). */ resolveSets: (context: any) => Promise; @@ -843,9 +839,6 @@ export class DelegatedAdminGate { * stays. A read refusal or a store fault propagates as raised: an outage is * neither an answer nor a refusal, and the by-id write door answers a caller * the read door refuses with that refusal. - * - * The organization branch only: the delegated walk reads its pre-image through - * {@link materializeTargets}. */ private async organizationTargets(opCtx: any): Promise> { if (opCtx.operation === 'insert') return rowsOf(opCtx).map((r) => ({ next: r, prev: null })); From 23da8f611392981eaa7f87b8fe8de162bca5e03c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:00:22 +0000 Subject: [PATCH 4/5] test(plugin-security): read as the caller through a typed context, no options erasure The posture rig's caller reads keep the engine query options typed, so the test surface of the query-options erasure ratchet stays at its ceiling. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- ...org-admin-assignment-boundary.posture.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts index 99c18cbd0f8..5ebf0c13180 100644 --- a/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts +++ b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts @@ -304,7 +304,15 @@ async function seedIn(h: Harness, table: Table, id: string, organizationId: stri } const ORG_ADMINS = ['orgAdmin', 'orgAdminNoBypass'] as const; -const write = (h: Harness, table: Table, op: 'update' | 'delete', id: string, context: object) => + +/** `caller`'s own by-id read of `id`: the read door's answer to it. */ +function readAs(h: Harness, table: Table, id: string, caller: Caller) { + const { positions, permissions, ...rest } = CALLERS[caller]; + const context = { ...rest, positions: [...positions], permissions: [...permissions] }; + return h.engine.findOne(table, { where: { id }, context }); +} + +const write =(h: Harness, table: Table, op: 'update' | 'delete', id: string, context: object) => answerOf(() => op === 'update' ? h.engine.update(table, { reason: 'edited' }, { where: { id }, context } as any) : h.engine.delete(table, { where: { id }, context } as any)); @@ -322,7 +330,7 @@ describe("[ADR-0090 D12; ruling of record 5985885287] an organization administra for (const caller of ORG_ADMINS) { const context = CALLERS[caller]; // The precondition, read off the read door: this caller reads no such row. - expect(await h.engine.findOne(table, { where: { id }, context } as any), `${caller} reads ${table} ${id}`).toBeFalsy(); + expect(await readAs(h, table, id, caller), `${caller} reads ${table} ${id}`).toBeFalsy(); for (const op of ['update', 'delete'] as const) { const row = await write(h, table, op, id, context); const none = await write(h, table, op, NOWHERE, context); @@ -340,7 +348,7 @@ describe("[ADR-0090 D12; ruling of record 5985885287] an organization administra await seed(h, table, 'own_out', 'out'); for (const caller of ORG_ADMINS) { const context = CALLERS[caller]; - expect(await h.engine.findOne(table, { where: { id: 'own_out' }, context } as any), `${caller} reads ${table}`).toBeTruthy(); + expect(await readAs(h, table, 'own_out', caller), `${caller} reads ${table}`).toBeTruthy(); const own = await write(h, table, 'delete', 'own_out', context); expect([own.status, own.code], `${caller} ${table}: ${own.message}`).toEqual([403, 'PERMISSION_DENIED']); expect(own.message).toMatch(/'admin_full_access'.*by name/); @@ -373,7 +381,7 @@ describe("[ADR-0090 D12; ruling of record 5985885287] an organization administra await seedIn(h, table, id, ORG_B, 'u_other_org'); for (const caller of ORG_ADMINS) { const context = CALLERS[caller]; - expect(await h.engine.findOne(table, { where: { id }, context } as any), `${caller} reads ${table}`).toBeTruthy(); + expect(await readAs(h, table, id, caller), `${caller} reads ${table}`).toBeTruthy(); const row = await write(h, table, 'delete', id, context); expect([row.status, row.code], `${caller} ${table}: ${row.message}`).toEqual([403, 'PERMISSION_DENIED']); expect(row.message).toMatch(/belongs to organization 'org_b'/); From 51d9481a50435663746886fd79a8bca4aaed02f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 13:12:21 +0000 Subject: [PATCH 5/5] test(plugin-security): copy every array the typed caller context declares Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../src/org-admin-assignment-boundary.posture.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts index 5ebf0c13180..3b9ae0ff28b 100644 --- a/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts +++ b/packages/plugins/plugin-security/src/org-admin-assignment-boundary.posture.test.ts @@ -307,8 +307,8 @@ const ORG_ADMINS = ['orgAdmin', 'orgAdminNoBypass'] as const; /** `caller`'s own by-id read of `id`: the read door's answer to it. */ function readAs(h: Harness, table: Table, id: string, caller: Caller) { - const { positions, permissions, ...rest } = CALLERS[caller]; - const context = { ...rest, positions: [...positions], permissions: [...permissions] }; + const { positions, permissions, accessible_org_ids, ...rest } = CALLERS[caller]; + const context = { ...rest, positions: [...positions], permissions: [...permissions], accessible_org_ids: [...accessible_org_ids] }; return h.engine.findOne(table, { where: { id }, context }); }