From 37b2be3bff22cf9c6445894a6ef83a9450badbe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:24:54 +0000 Subject: [PATCH 1/9] fix(plugin-security): an organization administrator's assignment removal is judged on the stored row, and its refusal names only values the caller can read The organization administrator's branch of the ADR-0090 D12 gate now takes a removal's verdict from the stored row once the caller's own read returns the row, as the delegated branch does (ruling of record 5985885287, sub-item B), and words its refusal through mayName. A row the caller cannot read, and an id naming no row, keep the read door's not-found. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../src/delegated-admin-gate.ts | 81 +++++++++++++------ 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index cb514e84ecc..fbfc61a0288 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -74,7 +74,7 @@ import { rowOrganizationId, seedCtx as organizationScopedCtx, } from './organization-scope.js'; -import { grantSetNameOf } from './grant-permission-set-name.js'; +import { GRANT_SET_NAME_FIELD, grantSetNameOf } from './grant-permission-set-name.js'; import { isReservedIdentityName } from './objects/reserved-identity-names.js'; const SYSTEM_CTX = { isSystem: true } as const; @@ -424,10 +424,11 @@ export function isUnreadableAddressedRowRefusal(refusal: unknown): boolean { } /** - * [ADR-0090 D12] A row a delegated write targets, with what the caller may be - * told about it: `prev` the stored row a by-id write addresses, `next` the row - * an insert or an update writes, and the two column sets a refusal's guidance - * reads ({@link mayName}). + * [ADR-0090 D12] A row an administrator's write targets — the delegated walk's + * and the organization branch's alike — with what the caller may be told about + * it: `prev` the stored row a by-id write addresses, `next` the row an insert + * or an update writes, and the two column sets a refusal's guidance reads + * ({@link mayName}). */ interface DelegatedTarget { next: any | null; @@ -847,13 +848,18 @@ export class DelegatedAdminGate { * ({@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 + * Whether a removal has a row to judge is asked of the CALLER's own read * ({@link organizationTargets}), so a row the caller cannot read is answered - * exactly as an id naming no row is (ruling of record 5985885287). A by-id - * edit, which the right never admits, is asked the same question first: an - * edit of a row the caller cannot read, or of an id naming none, is refused - * as no row, not as an edit — `unreadable`, which the security middleware - * answers with the read door's not-found. + * exactly as an id naming no row is (ruling of record 5985885287). A row the + * caller reads is judged on the STORED row, as the delegated branch judges + * it (sub-item B of that ruling): the caller's read is served through its + * field rules and masking, and a verdict taken on what a rule hid would be + * taken on less than the row. A refusal names a value only when the caller + * reads it at its stored value or wrote it ({@link mayName}). A by-id edit, + * which the right never admits, is asked the same question first: an edit + * of a row the caller cannot read, or of an id naming none, is refused as no + * row, not as an edit — `unreadable`, which the security middleware answers + * with the read door's not-found. */ private async organizationAdminFailure( opCtx: any, @@ -883,13 +889,20 @@ export class DelegatedAdminGate { } for (const t of await this.organizationTargets(opCtx)) { + const side = t.next ? 'next' : 'prev'; 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: {}, unreadable: op === 'delete' }; + // [ruling of record 5985885287, sub-item B] The verdict is the stored + // row's; each value below is named only when the caller wrote or reads + // it ({@link mayName}). const rowOrg = rowOrganizationId(row); if (t.next ? rowOrg !== null && rowOrg !== organizationId : rowOrg !== organizationId) { + if (!mayName(t, side, 'organization_id')) { + return { reason: 'the row does not belong to the organization you administer', meta: {} }; + } return { reason: rowOrg ? `the row belongs to organization '${rowOrg}', not the one you administer` @@ -908,13 +921,22 @@ export class DelegatedAdminGate { } const positionName = String(row.position ?? ''); const failure = await this.positionBoundaryFailure(positionName); - if (failure) return { reason: failure.reason, meta: { position: positionName, permissionSet: failure.permissionSet } }; + if (failure) { + // A set is named only beside the position it is read from. + return mayName(t, side, 'position') + ? { reason: failure.reason, meta: { position: positionName, permissionSet: failure.permissionSet } } + : { reason: "the assignment's position is not inside the organization boundary", meta: {} }; + } } else { const setName = grantSetNameOf(row) ?? ''; const reason = setName ? await this.organizationBoundaryFailure(setName) : 'the grant names no permission set'; - if (reason) return { reason, meta: { permissionSet: setName } }; + if (reason) { + return mayName(t, side, GRANT_SET_NAME_FIELD) + ? { reason, meta: { permissionSet: setName } } + : { reason: "the grant's permission set is not inside the organization boundary", meta: {} }; + } } } @@ -929,21 +951,30 @@ export class DelegatedAdminGate { /** * [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. + * administrator's insert or removal targets, with what the caller may be + * told of each ({@link DelegatedTarget}): an insert's payload rows, every + * value the caller's own; or the one row a removal addresses. * - * 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. + * Whether a removal has a row is asked of the caller's own by-id read + * ({@link callerRead}) — the read door's answer to this caller, every read + * visibility and wall included: a row the caller cannot read answers what an + * id naming no row answers, and no system read is made for it. A row it + * reads is then judged on the stored row ({@link storedRow}), never on the + * caller's read of it — sub-item B: that read is served through the caller's + * field rules and masking — and `reads` holds the columns that read returned + * at their stored value ({@link columnsReadAsStored}). A read refusal or a + * store fault, in either read, 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. */ - private async organizationTargets(opCtx: any): Promise> { - if (opCtx.operation === 'insert') return rowsOf(opCtx).map((r) => ({ next: r, prev: null })); + private async organizationTargets(opCtx: any): Promise { + if (opCtx.operation === 'insert') { + return rowsOf(opCtx).map((r) => ({ next: r, prev: null, wrote: null, reads: new Set() })); + } const id = extractSingleId(opCtx); - return [{ next: null, prev: id != null ? await this.callerRead(opCtx, id) : null }]; + const seen = id != null ? await this.callerRead(opCtx, id) : null; + const prev = seen && id != null ? await this.storedRow(opCtx.object, id) : null; + return [{ next: null, prev, wrote: new Set(), reads: columnsReadAsStored(seen, prev) }]; } /** From b1aebacd60b125a05a1929426005cc162147c4e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:34:29 +0000 Subject: [PATCH 2/9] test(plugin-security): an organization administrator's removal is judged on the stored row, and its refusal names only values the caller can read The posture rig pins it on a real engine under 'group' and 'isolated' for a position or permission set hidden by a field rule or partially masked, the row's organization (the gate's own verdict), the read door's not-found for these callers, and a 'single' control. The field-rule test's organization block is re-pinned to the stored-row verdict. The gate's unit file pins the same rule, and that no stored read is made for a row the caller cannot read. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../src/delegated-admin-gate.test.ts | 94 +++++++- ...-admin-assignment-boundary.posture.test.ts | 201 ++++++++++++++++-- 2 files changed, 280 insertions(+), 15 deletions(-) diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 6f44b857a3c..f718d683e94 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { mapDataError } from '@objectstack/types'; -import { DelegatedAdminGate } from './delegated-admin-gate'; +import { DelegatedAdminGate, isUnreadableAddressedRowRefusal } from './delegated-admin-gate'; import { isPermissionDeniedError } from './errors.js'; import { bindCatalogFromTables, bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; @@ -1389,6 +1389,98 @@ describe('DelegatedAdminGate — organization administrators (ADR-0090 D12 as am }); }); +/** + * The organization harness's caller reads served through a field rule on + * `column`: absent from the row (`hidden`) or replaced by a partial mask + * (`masked`), as the read door serves them. System reads see the stored row. + * Returns the objects the gate read by the system, in order. + */ +function serveCallerReadsThrough(o: ReturnType, column: string, how: 'hidden' | 'masked'): string[] { + const storedFindOne = o.ql.findOne.bind(o.ql); + const systemReads: string[] = []; + o.ql.findOne = async (object: string, opts: any) => { + const row = await storedFindOne(object, opts); + if (opts?.context?.isSystem) { + systemReads.push(object); + return row; + } + if (!row || !Object.prototype.hasOwnProperty.call(row, column)) return row; + const seen = { ...row }; + if (how === 'hidden') delete seen[column]; + else seen[column] = `${String(row[column]).slice(0, 2)}****`; + return seen; + }; + return systemReads; +} + +describe("[ADR-0090 D12; ruling of record 5985885287, sub-item B] an organization administrator's removal is judged on the stored row, and its refusal names only values the caller reads", () => { + let o: ReturnType; + beforeEach(() => { o = makeOrganizationHarness(); }); + + const remove = (principal: string, object: string, id: string) => + o.gate.assert({ object, operation: 'delete', options: { where: { id } }, context: o.ctxOf(principal) }); + + /** One refusal, serialized whole: its sentence and every detail it carries. */ + const whole = (err: any) => JSON.stringify({ message: err.message, details: err.details }); + + /** + * Per column a removal is judged on: the in-boundary rows the stored-row + * verdict admits, the rows it refuses, the stored values each refusal + * leaves out, and the sentence it says instead. + */ + const CASES = [ + { column: 'position', object: 'sys_user_position', admitted: ['up_a'], + refused: [{ id: 'up_a_mixed', unnamed: ['mixed_pos', 'wild_set', 'in_set'], says: /the assignment's position is not inside the organization boundary/ }] }, + { column: 'permission_set', object: 'sys_user_permission_set', admitted: ['ups_a'], + refused: [{ id: 'ups_a_wild', unnamed: ['wild_set'], says: /the grant's permission set is not inside the organization boundary/ }] }, + { column: 'organization_id', object: 'sys_user_position', admitted: ['up_a'], + refused: [ + { id: 'up_b', unnamed: ['org_b'], says: /the row does not belong to the organization you administer/ }, + { id: 'up_orgless', unnamed: ['carries no organization'], says: /the row does not belong to the organization you administer/ }, + ] }, + { column: 'organization_id', object: 'sys_user_permission_set', admitted: ['ups_a'], refused: [] }, + ] as const; + + for (const principal of ['org_admin', 'org_admin_nb']) { + for (const how of ['hidden', 'masked'] as const) { + for (const c of CASES) { + it(`${principal}: with ${c.object}.${c.column} ${how} from its reads, an in-boundary row is removed, and a row outside is refused without the stored value`, async () => { + serveCallerReadsThrough(o, c.column, how); + for (const id of c.admitted) { + await expect(remove(principal, c.object, id), id).resolves.toBeUndefined(); + } + for (const r of c.refused) { + const err = await deniedOf(() => remove(principal, c.object, r.id)); + expect(err.message, r.id).toMatch(/organization boundary/); + expect(err.message, r.id).toMatch(r.says); + for (const value of r.unnamed) expect(whole(err), `${r.id}: '${value}'`).not.toContain(value); + expect(whole(err), `${r.id}: not the masked value either`).not.toContain('****'); + } + }); + } + } + + it(`${principal}: a column it does read keeps the stored value in the guidance — the position and its set, beside an organization it does not read`, async () => { + serveCallerReadsThrough(o, 'organization_id', 'hidden'); + const err = await deniedOf(() => remove(principal, 'sys_user_position', 'up_a_mixed')); + expect(err.message).toMatch(/position 'mixed_pos' carries permission set 'wild_set'.*condition \(a\)/); + expect(err.details).toMatchObject({ position: 'mixed_pos', permissionSet: 'wild_set' }); + }); + + it(`${principal}: a row its own read does not return, and an id naming no row, are refused as no row — with no read of the stored row`, async () => { + const systemReads = serveCallerReadsThrough(o, 'position', 'hidden'); + const servedFindOne = o.ql.findOne; + o.ql.findOne = async (object: string, opts: any) => + (!opts?.context?.isSystem && opts?.where?.id === 'up_a') ? null : servedFindOne(object, opts); + for (const id of ['up_a', 'zz_no_such_row']) { + const err = await deniedOf(() => remove(principal, 'sys_user_position', id)); + expect(isUnreadableAddressedRowRefusal(err), id).toBe(true); + } + expect(systemReads.filter((object) => object === 'sys_user_position'), 'no system read of the addressed row').toEqual([]); + }); + } +}); + // --------------------------------------------------------------------------- // [ADR-0090 D12, amended 2026-10-17; #24757] The delegated walk judges the // listed sets, never a set that only shares the position's name 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 90c50fdbcef..b60214d3bd2 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 @@ -95,7 +95,14 @@ const ORG_ADMIN_FIELDS_HIDDEN = { objects: {}, fields: { 'sys_user_position.position': HIDDEN, 'sys_user_permission_set.permission_set': HIDDEN }, }; -const PERMISSIONS = [...defaultPermissionSets, QA_IN_SET, ...DELEGATE_SETS, ORG_ADMIN_FIELDS_HIDDEN]; +/** The third column a removal is judged on — the row's organization — hidden the same way, on both tables. */ +const ORG_ADMIN_ORGANIZATION_HIDDEN = { + name: 'qa_org_admin_organization_hidden', + label: 'QA organization administrator, organization hidden', + objects: {}, + fields: { 'sys_user_position.organization_id': HIDDEN, 'sys_user_permission_set.organization_id': HIDDEN }, +}; +const PERMISSIONS = [...defaultPermissionSets, QA_IN_SET, ...DELEGATE_SETS, ORG_ADMIN_FIELDS_HIDDEN, ORG_ADMIN_ORGANIZATION_HIDDEN]; type Posture = 'single' | 'group' | 'isolated'; const POSTURES: Posture[] = ['single', 'group', 'isolated']; @@ -127,11 +134,20 @@ afterEach(async () => { }); /** - * `boot`'s one variation: a partial masking rule declared on - * `sys_user_position.position` (a rule, not a field rule's `readable: false`), - * which serves every non-system reader the masked value in place of the stored one. + * `boot`'s one variation: partial masking rules declared on columns of the two + * assignment tables (a rule, not a field rule's `readable: false`), which serve + * every non-system reader the masked value in place of the stored one. */ -type BootOptions = { positionMaskingRule?: { keepHead: number; keepTail: number } }; +type MaskingRule = { keepHead: number; keepTail: number }; +type BootOptions = { maskingRules?: Partial>> }; + +/** `object` with a partial masking rule declared on each column `rules` names. */ +function withMaskingRules(object: any, rules: Record | undefined) { + if (!rules) return object; + const fields = { ...object.fields }; + for (const [column, maskingRule] of Object.entries(rules)) fields[column] = { ...fields[column], maskingRule }; + return { ...object, fields }; +} async function boot(posture: Posture, options: BootOptions = {}) { const engine = new ObjectQL(); @@ -147,10 +163,9 @@ async function boot(posture: Posture, options: BootOptions = {}) { type: 'plugin', scope: 'system', objects: [ - options.positionMaskingRule - ? { ...SysUserPosition, fields: { ...SysUserPosition.fields, position: { ...SysUserPosition.fields.position, maskingRule: options.positionMaskingRule } } } - : SysUserPosition, - SysUserPermissionSet, SysMember, SysBusinessUnit, SysBusinessUnitMember, + withMaskingRules(SysUserPosition, options.maskingRules?.sys_user_position), + withMaskingRules(SysUserPermissionSet, options.maskingRules?.sys_user_permission_set), + SysMember, SysBusinessUnit, SysBusinessUnitMember, ], } as any); await engine.syncSchemas(); @@ -726,9 +741,13 @@ describe('[ADR-0090 D12; ruling of record 5985885287] an administrator\'s refuse expect([rewrote.status, rewrote.code], String(rewrote.message)).toEqual([403, 'DELEGATION_SCOPE_VIOLATION']); expect(rewrote.message).toContain("'bu_a_other'"); - // The organization branch holds the same rule: an organization - // administrator whose field rules hide the column a removal is judged - // on is refused without its value; one who reads it is told it. + // The organization branch holds the same rule, and is judged on the + // stored row too: an organization administrator whose field rules hide + // the column a removal is judged on is refused a row outside the + // boundary by the boundary itself, without the value, and removes a row + // inside it; one who reads the column is told the value. (Before the + // stored-row verdict the organization branch judged its read of the row, + // and refused both rows as naming no position or set.) const orgAdminHiding = { userId: 'u_org_admin_hiding', positions: [] as string[], permissions: ['organization_admin', ORG_ADMIN_FIELDS_HIDDEN.name], tenantId: ORG_A, accessible_org_ids: [ORG_A] }; await h.engine.insert('sys_member', { id: 'm_u_org_admin_hiding', user_id: orgAdminHiding.userId, organization_id: ORG_A, role: 'admin' }, { context: { isSystem: true, tenantId: ORG_A } } as any); @@ -738,18 +757,26 @@ describe('[ADR-0090 D12; ruling of record 5985885287] an administrator\'s refuse { context: { isSystem: true, tenantId: ORG_A } } as any); const hiddenTo = await write(h, table, 'delete', id, orgAdminHiding); expect([hiddenTo.status, hiddenTo.code], `${table}: ${hiddenTo.message}`).toEqual([403, 'PERMISSION_DENIED']); - expect(JSON.stringify(hiddenTo), `${table}: no hidden value in the refusal`).not.toMatch(/qa_out_pos|admin_full_access/); + expect(hiddenTo.message, `${table}: the boundary refuses it, on the stored row`).toMatch(/is not inside the organization boundary/); + expect(JSON.stringify(hiddenTo), `${table}: the refusal names no value the caller does not read`).not.toMatch(/qa_out_pos|admin_full_access/); const readTo = await write(h, table, 'delete', id, CALLERS.orgAdmin); expect([readTo.status, readTo.code], `${table}, read: ${readTo.message}`).toEqual([403, 'PERMISSION_DENIED']); expect(readTo.message, `${table}, read`).toContain("'admin_full_access'"); expect(await stored(h, table, { id }), `${table}: refused means untouched`).toHaveLength(1); + + const inId = `org_in_${table}`; + await h.engine.insert(table, { id: inId, user_id: 'u_target', ...TABLES[table].in, organization_id: ORG_A }, + { context: { isSystem: true, tenantId: ORG_A } } as any); + const admitted = await write(h, table, 'delete', inId, orgAdminHiding); + expect(admitted, `${table}, inside the boundary: ${admitted.message}`).toEqual({ status: 200 }); + expect(await stored(h, table, { id: inId }), `${table}: the removal landed`).toHaveLength(0); } }); } it("a partially masked column is never named either — neither its masked nor its stored value — and the delegate is still judged on the stored value", async () => { const rule = { keepHead: 3, keepTail: 0 }; - const h = await boot('isolated', { positionMaskingRule: rule }); + const h = await boot('isolated', { maskingRules: { sys_user_position: { position: rule } } }); await seedDelegation(h); const inA = { context: { isSystem: true, tenantId: ORG_A } } as any; // Outside the scope only by its position, whose stored value distributes a set off the allowlist. @@ -782,3 +809,149 @@ describe('[ADR-0090 D12; ruling of record 5985885287] an administrator\'s refuse expect(await stored(h, 'sys_user_position', { id: 'masked_in' }), 'the delete landed').toHaveLength(0); }); }); + +// ── The organization branch: judged on the stored row ────────────────────── + +describe("[ADR-0090 D12; ruling of record 5985885287, sub-item B] an organization administrator's removal is judged on the stored row, and its refusal names only values the caller can read", () => { + const inA = { context: { isSystem: true, tenantId: ORG_A } } as any; + const MASK: MaskingRule = { keepHead: 3, keepTail: 0 }; + + /** An administrator of organization A holding `organization_admin` beside `fieldRules`, enrolled as an admin member. */ + async function orgAdminWith(h: Harness, userId: string, fieldRules: string[]) { + await h.engine.insert('sys_member', { id: `m_${userId}`, user_id: userId, organization_id: ORG_A, role: 'admin' }, inA); + return { userId, positions: [] as string[], permissions: ['organization_admin', ...fieldRules], tenantId: ORG_A, accessible_org_ids: [ORG_A] }; + } + + /** The column of `table` that names the set its row distributes. */ + const DISTRIBUTES: Record = { sys_user_position: 'position', sys_user_permission_set: 'permission_set' }; + + /** An in-boundary and an out-of-boundary row of organization A in `table`. */ + async function seedPair(h: Harness, table: Table, tag: string) { + await h.engine.insert(table, { id: `in_${tag}`, user_id: 'u_target', ...TABLES[table].in, organization_id: ORG_A }, inA); + await h.engine.insert(table, { id: `out_${tag}`, user_id: 'u_seeded', ...TABLES[table].out, organization_id: ORG_A }, inA); + } + + /** What the security plugin's own delegated-administration gate answers a by-id removal, asked as the write door asks it. */ + const gateAnswer = (h: Harness, table: Table, id: string, context: object) => + answerOf(() => (h.plugin as any).delegatedAdminGate.assert({ + object: table, operation: 'delete', options: { where: { id } }, context: structuredClone(context), + })); + + /** Every value the stored out-of-boundary row of `table` distributes — the stored name and the sets behind it. */ + const OUT_VALUES: Record = { + sys_user_position: ['qa_out_pos', 'admin_full_access', 'qa_in_set'], + sys_user_permission_set: ['admin_full_access'], + }; + + for (const posture of ['group', 'isolated'] as const) { + describe(`posture '${posture}'`, () => { + for (const how of ['hidden by a field rule', 'partially masked'] as const) { + it(`a position or a permission set ${how}: the removal of an in-boundary row is admitted, and of an out-of-boundary row refused by the boundary, naming neither the stored nor the masked value`, async () => { + const masked = how === 'partially masked'; + const h = masked + ? await boot(posture, { maskingRules: { sys_user_position: { position: MASK }, sys_user_permission_set: { permission_set: MASK } } }) + : await boot(posture); + const caller = masked ? CALLERS.orgAdmin : await orgAdminWith(h, 'u_org_admin_columns_hidden', [ORG_ADMIN_FIELDS_HIDDEN.name]); + for (const table of Object.keys(TABLES) as Table[]) { + const column = DISTRIBUTES[table]; + await seedPair(h, table, table); + // The precondition, read off the read door: the caller reads the + // row, and not the stored value of the column it is judged on. + const seen = await h.engine.findOne(table, { where: { id: `out_${table}` }, context: structuredClone(caller) }); + expect(seen, `${table}: the caller reads the row`).toBeTruthy(); + const maskedValue = String(maskFieldValue((TABLES[table].out as Record)[column], MASK)); + if (masked) expect(seen?.[column], `${table}: read masked`).toBe(maskedValue); + else expect(seen, `${table}: read without the column`).not.toHaveProperty(column); + + const out = await write(h, table, 'delete', `out_${table}`, caller); + expect([out.status, out.code], `${table}, outside: ${out.message}`).toEqual([403, 'PERMISSION_DENIED']); + expect(out.message, `${table}, outside`).toMatch(/is not inside the organization boundary/); + for (const value of [...OUT_VALUES[table], maskedValue]) { + expect(JSON.stringify(out), `${table}, outside: '${value}' is not in the refusal`).not.toContain(value); + } + expect(await stored(h, table, { id: `out_${table}` }), `${table}: refused means untouched`).toHaveLength(1); + + const inside = await write(h, table, 'delete', `in_${table}`, caller); + expect(inside, `${table}, inside: ${inside.message}`).toEqual({ status: 200 }); + expect(await stored(h, table, { id: `in_${table}` }), `${table}: the removal landed`).toHaveLength(0); + } + }); + + it(`the row's organization ${how}: the gate admits the removal of an in-boundary row on its stored organization, and refuses an out-of-boundary row naming only the values the caller reads`, async () => { + const masked = how === 'partially masked'; + const h = masked + ? await boot(posture, { maskingRules: { sys_user_position: { organization_id: MASK }, sys_user_permission_set: { organization_id: MASK } } }) + : await boot(posture); + const caller = masked ? CALLERS.orgAdmin : await orgAdminWith(h, 'u_org_admin_organization_hidden', [ORG_ADMIN_ORGANIZATION_HIDDEN.name]); + const maskedOrganization = String(maskFieldValue(ORG_A, MASK)); + for (const table of Object.keys(TABLES) as Table[]) { + await seedPair(h, table, table); + const seen = await h.engine.findOne(table, { where: { id: `in_${table}` }, context: structuredClone(caller) }); + expect(seen, `${table}: the caller reads the row`).toBeTruthy(); + if (masked) expect(seen?.organization_id, `${table}: read masked`).toBe(maskedOrganization); + else expect(seen, `${table}: read without the column`).not.toHaveProperty('organization_id'); + + // The gate's own verdict, asked as the write door asks it. (The + // write door asks its other row-level questions of the same write + // separately; they are not this gate's.) + expect(await gateAnswer(h, table, `in_${table}`, caller), `${table}, inside`).toEqual({ status: 200 }); + + // Outside the boundary by the set it distributes, which the caller + // reads: the refusal names that, and nothing of the organization. + for (const out of [await gateAnswer(h, table, `out_${table}`, caller), await write(h, table, 'delete', `out_${table}`, caller)]) { + expect([out.status, out.code], `${table}, outside: ${out.message}`).toEqual([403, 'PERMISSION_DENIED']); + expect(out.message, `${table}, outside`).toMatch(/'admin_full_access'.*by name/); + expect(JSON.stringify(out), `${table}, outside: the organization is not in the refusal`).not.toContain(maskedOrganization); + } + expect(await stored(h, table, { id: `out_${table}` }), `${table}: refused means untouched`).toHaveLength(1); + } + }); + } + + it("control: a caller whose field rules hide a judged column is still answered the read door's not-found, whole, for a row it cannot read and for an id naming no row", async () => { + const h = await boot(posture); + const callers = [ + await orgAdminWith(h, 'u_org_admin_columns_hidden', [ORG_ADMIN_FIELDS_HIDDEN.name]), + await orgAdminWith(h, 'u_org_admin_organization_hidden', [ORG_ADMIN_ORGANIZATION_HIDDEN.name]), + ]; + for (const table of Object.keys(TABLES) as Table[]) { + const id = `unreadable_${table}`; + await seedIn(h, table, id, ORG_B, 'u_other_org'); + for (const target of [id, NOWHERE]) { + const notFound = await readDoorNotFound(table, target); + for (const caller of callers) { + expect(await h.engine.findOne(table, { where: { id: target }, context: structuredClone(caller) }), `${caller.userId} reads ${table} ${target}`).toBeFalsy(); + for (const op of ['update', 'delete'] as const) { + expect(await wholeWrite(h, table, op, target, caller), `${caller.userId} ${op} ${table} ${target}`).toEqual(notFound); + } + } + } + expect(await stored(h, table, { id }), `${table}: refused means untouched`).toHaveLength(1); + } + }); + }); + } + + it("control, 'single': an organization the caller's field rules hide is judged on the stored row too — its own organization's in-boundary row is removed, another organization's row is refused without naming that organization", async () => { + const h = await boot('single'); + const caller = await orgAdminWith(h, 'u_org_admin_organization_hidden', [ORG_ADMIN_ORGANIZATION_HIDDEN.name]); + for (const table of Object.keys(TABLES) as Table[]) { + await seedPair(h, table, table); + const foreign = `foreign_${table}`; + await seedIn(h, table, foreign, ORG_B, 'u_other_org'); + const seen = await h.engine.findOne(table, { where: { id: foreign }, context: structuredClone(caller) }); + expect(seen, `${table}: the caller reads the other organization's row`).toBeTruthy(); + expect(seen, `${table}: without its organization`).not.toHaveProperty('organization_id'); + + const refused = await write(h, table, 'delete', foreign, caller); + expect([refused.status, refused.code], `${table}: ${refused.message}`).toEqual([403, 'PERMISSION_DENIED']); + expect(refused.message, table).toMatch(/does not belong to the organization you administer/); + expect(JSON.stringify(refused), `${table}: the organization is not in the refusal`).not.toContain(ORG_B); + expect(await stored(h, table, { id: foreign }), `${table}: refused means untouched`).toHaveLength(1); + + const inside = await write(h, table, 'delete', `in_${table}`, caller); + expect(inside, `${table}, inside: ${inside.message}`).toEqual({ status: 200 }); + expect(await stored(h, table, { id: `in_${table}` }), `${table}: the removal landed`).toHaveLength(0); + } + }); +}); From a6e40467d3cc9dd070681d3b4ac0af9ae1d2fc08 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:47:24 +0000 Subject: [PATCH 3/9] chore(changeset): an organization administrator's assignment removal is judged on the stored row Adds the plugin-security patch changeset, and corrects one sentence of the pending note .changeset/org-admin-removal-caller-read.md that this change makes false: the administrator's own read now answers whether there is a row to remove, not the verdict on it. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/org-admin-removal-caller-read.md | 2 +- .changeset/org-admin-removal-stored-row-verdict.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .changeset/org-admin-removal-stored-row-verdict.md diff --git a/.changeset/org-admin-removal-caller-read.md b/.changeset/org-admin-removal-caller-read.md index c9ebaa1b760..a0db1415868 100644 --- a/.changeset/org-admin-removal-caller-read.md +++ b/.changeset/org-admin-removal-caller-read.md @@ -7,7 +7,7 @@ fix(plugin-security): an organization administrator's refused assignment write a 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. +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 asks the administrator's own read, in its own context, whether that row is there to remove. - **A row the administrator cannot read** is answered exactly as an id that names no row is: the read door's `404 RECORD_NOT_FOUND` for that object and id. - **A row the administrator can read** keeps the answer it had, including the guidance that names the organization boundary. diff --git a/.changeset/org-admin-removal-stored-row-verdict.md b/.changeset/org-admin-removal-stored-row-verdict.md new file mode 100644 index 00000000000..782c96cd2ab --- /dev/null +++ b/.changeset/org-admin-removal-stored-row-verdict.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): an organization administrator's assignment removal is judged on the stored row, and its refusal names only values the caller can read + +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. Once its own read returns the row, the delegated-administration gate (ADR-0090 D12) judges the removal on the stored row, as it judges a delegated administrator's write. + +- **The caller's field rules and masking no longer decide the verdict.** When the caller's field rules hide the row's `position` or `permission_set`, or a masking rule partially masks it, the removal of a row inside the organization boundary is admitted and the removal of a row outside it is refused. Before, the gate judged the caller's read of the row, so it refused both (for example "the assignment names no position"). +- **A refusal names a value only when the caller can read it.** The row's organization, its position and the sets that position carries, or its permission set are named only when the caller's own read returned that column at its stored value. Otherwise the sentence leaves the value out, for example "the assignment's position is not inside the organization boundary", and `details` leaves it out too. +- **Unchanged.** A row the caller cannot read, and an id that names no row, still answer the read door's `404 RECORD_NOT_FOUND`. An edit in place is refused as before. Inserts, tenant-level administrators and delegated administrators are judged as before. From ff7fa368729436cb129ca096a206b7bf906ab0c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:58:34 +0000 Subject: [PATCH 4/9] test(plugin-security): the stored-row describe's not-found control pins removals only An edit in place is no part of the organization administrator's right, and where its refusal falls is not this describe's subject, so its control asks the removal alone. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../src/org-admin-assignment-boundary.posture.test.ts | 8 ++++---- 1 file changed, 4 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 b60214d3bd2..139763e5bda 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 @@ -908,7 +908,7 @@ describe("[ADR-0090 D12; ruling of record 5985885287, sub-item B] an organizatio }); } - it("control: a caller whose field rules hide a judged column is still answered the read door's not-found, whole, for a row it cannot read and for an id naming no row", async () => { + it("control: a caller whose field rules hide a judged column is still answered the read door's not-found, whole, when it removes a row it cannot read or an id naming no row", async () => { const h = await boot(posture); const callers = [ await orgAdminWith(h, 'u_org_admin_columns_hidden', [ORG_ADMIN_FIELDS_HIDDEN.name]), @@ -921,9 +921,9 @@ describe("[ADR-0090 D12; ruling of record 5985885287, sub-item B] an organizatio const notFound = await readDoorNotFound(table, target); for (const caller of callers) { expect(await h.engine.findOne(table, { where: { id: target }, context: structuredClone(caller) }), `${caller.userId} reads ${table} ${target}`).toBeFalsy(); - for (const op of ['update', 'delete'] as const) { - expect(await wholeWrite(h, table, op, target, caller), `${caller.userId} ${op} ${table} ${target}`).toEqual(notFound); - } + // A removal only: an edit in place is no part of the right, and + // where its refusal falls is not what this describe pins. + expect(await wholeWrite(h, table, 'delete', target, caller), `${caller.userId} delete ${table} ${target}`).toEqual(notFound); } } expect(await stored(h, table, { id }), `${table}: refused means untouched`).toHaveLength(1); From 5fd31958457b3e6009278eb19cbb37f7d0b71928 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:10:14 +0000 Subject: [PATCH 5/9] test(plugin-security): the stored-row describe's masked cases use an enrolled organization administrator with no field rules Keeps the caller's context type mutable, as the engine's context expects. 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 139763e5bda..f8a559decb9 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 @@ -851,7 +851,7 @@ describe("[ADR-0090 D12; ruling of record 5985885287, sub-item B] an organizatio const h = masked ? await boot(posture, { maskingRules: { sys_user_position: { position: MASK }, sys_user_permission_set: { permission_set: MASK } } }) : await boot(posture); - const caller = masked ? CALLERS.orgAdmin : await orgAdminWith(h, 'u_org_admin_columns_hidden', [ORG_ADMIN_FIELDS_HIDDEN.name]); + const caller = await orgAdminWith(h, masked ? 'u_org_admin_reads_masked' : 'u_org_admin_columns_hidden', masked ? [] : [ORG_ADMIN_FIELDS_HIDDEN.name]); for (const table of Object.keys(TABLES) as Table[]) { const column = DISTRIBUTES[table]; await seedPair(h, table, table); @@ -882,7 +882,7 @@ describe("[ADR-0090 D12; ruling of record 5985885287, sub-item B] an organizatio const h = masked ? await boot(posture, { maskingRules: { sys_user_position: { organization_id: MASK }, sys_user_permission_set: { organization_id: MASK } } }) : await boot(posture); - const caller = masked ? CALLERS.orgAdmin : await orgAdminWith(h, 'u_org_admin_organization_hidden', [ORG_ADMIN_ORGANIZATION_HIDDEN.name]); + const caller = await orgAdminWith(h, masked ? 'u_org_admin_reads_masked' : 'u_org_admin_organization_hidden', masked ? [] : [ORG_ADMIN_ORGANIZATION_HIDDEN.name]); const maskedOrganization = String(maskFieldValue(ORG_A, MASK)); for (const table of Object.keys(TABLES) as Table[]) { await seedPair(h, table, table); From a491a3c06cd274042d77a1db63eb54da11b1cb4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:31:14 +0000 Subject: [PATCH 6/9] docs(plugin-security): the stored-row changeset and the re-pinned test state the rule only Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/org-admin-removal-stored-row-verdict.md | 2 +- .../src/org-admin-assignment-boundary.posture.test.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.changeset/org-admin-removal-stored-row-verdict.md b/.changeset/org-admin-removal-stored-row-verdict.md index 782c96cd2ab..548fc671768 100644 --- a/.changeset/org-admin-removal-stored-row-verdict.md +++ b/.changeset/org-admin-removal-stored-row-verdict.md @@ -9,6 +9,6 @@ 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. Once its own read returns the row, the delegated-administration gate (ADR-0090 D12) judges the removal on the stored row, as it judges a delegated administrator's write. -- **The caller's field rules and masking no longer decide the verdict.** When the caller's field rules hide the row's `position` or `permission_set`, or a masking rule partially masks it, the removal of a row inside the organization boundary is admitted and the removal of a row outside it is refused. Before, the gate judged the caller's read of the row, so it refused both (for example "the assignment names no position"). +- **The verdict is the stored row's.** What the caller's field rules and masking rules serve it of the row does not change the verdict: a removal of a row inside the organization boundary is admitted, and a removal of a row outside it is refused. - **A refusal names a value only when the caller can read it.** The row's organization, its position and the sets that position carries, or its permission set are named only when the caller's own read returned that column at its stored value. Otherwise the sentence leaves the value out, for example "the assignment's position is not inside the organization boundary", and `details` leaves it out too. - **Unchanged.** A row the caller cannot read, and an id that names no row, still answer the read door's `404 RECORD_NOT_FOUND`. An edit in place is refused as before. Inserts, tenant-level administrators and delegated administrators are judged as before. 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 f8a559decb9..2e5252315f4 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 @@ -745,9 +745,7 @@ describe('[ADR-0090 D12; ruling of record 5985885287] an administrator\'s refuse // stored row too: an organization administrator whose field rules hide // the column a removal is judged on is refused a row outside the // boundary by the boundary itself, without the value, and removes a row - // inside it; one who reads the column is told the value. (Before the - // stored-row verdict the organization branch judged its read of the row, - // and refused both rows as naming no position or set.) + // inside it; one who reads the column is told the value. const orgAdminHiding = { userId: 'u_org_admin_hiding', positions: [] as string[], permissions: ['organization_admin', ORG_ADMIN_FIELDS_HIDDEN.name], tenantId: ORG_A, accessible_org_ids: [ORG_A] }; await h.engine.insert('sys_member', { id: 'm_u_org_admin_hiding', user_id: orgAdminHiding.userId, organization_id: ORG_A, role: 'admin' }, { context: { isSystem: true, tenantId: ORG_A } } as any); From a3cdc6fa3536ac2bab9c624c31c57b306ab2c59d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 02:35:28 +0000 Subject: [PATCH 7/9] docs(changeset): the stored-row note claims the admission only for the columns it holds for end to end Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/org-admin-removal-stored-row-verdict.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/org-admin-removal-stored-row-verdict.md b/.changeset/org-admin-removal-stored-row-verdict.md index 548fc671768..032dd8a53c1 100644 --- a/.changeset/org-admin-removal-stored-row-verdict.md +++ b/.changeset/org-admin-removal-stored-row-verdict.md @@ -9,6 +9,6 @@ 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. Once its own read returns the row, the delegated-administration gate (ADR-0090 D12) judges the removal on the stored row, as it judges a delegated administrator's write. -- **The verdict is the stored row's.** What the caller's field rules and masking rules serve it of the row does not change the verdict: a removal of a row inside the organization boundary is admitted, and a removal of a row outside it is refused. +- **The verdict is the stored row's.** A field rule or a masking rule on the row's `position` or `permission_set` does not change it: the removal of a row inside the organization boundary is admitted, and the removal of a row outside it is refused. - **A refusal names a value only when the caller can read it.** The row's organization, its position and the sets that position carries, or its permission set are named only when the caller's own read returned that column at its stored value. Otherwise the sentence leaves the value out, for example "the assignment's position is not inside the organization boundary", and `details` leaves it out too. - **Unchanged.** A row the caller cannot read, and an id that names no row, still answer the read door's `404 RECORD_NOT_FOUND`. An edit in place is refused as before. Inserts, tenant-level administrators and delegated administrators are judged as before. From 0cc6f17e954fe5e39aa825a49963cc1396692522 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:45:13 +0000 Subject: [PATCH 8/9] =?UTF-8?q?fix(plugin-security):=20contract-review=20f?= =?UTF-8?q?indings=20=E2=80=94=20the=20stored-row=20verdict=20cites=20its?= =?UTF-8?q?=20governing=20ruling=20record,=20and=20the=20corrected=20pendi?= =?UTF-8?q?ng=20note=20is=20true=20of=20the=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every stored-row-verdict citation this branch added now names ruling of record 6649064874 (the ADR-0090 D12 assignment gate judges on the stored row, and its refusal names only what the caller can read): the gate's docblocks and comment, and the two new describe names. The citation of 5985885287 for the read door's not-found answer is kept. - .changeset/org-admin-removal-caller-read.md (the declared deliberate correction): a row the administrator reads is judged on the stored row and its refusal names a value only when the administrator's own read returns it as stored; the not-found bullet is scoped to a removal. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/org-admin-removal-caller-read.md | 4 ++-- .../src/delegated-admin-gate.test.ts | 2 +- .../src/delegated-admin-gate.ts | 20 +++++++++---------- ...-admin-assignment-boundary.posture.test.ts | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.changeset/org-admin-removal-caller-read.md b/.changeset/org-admin-removal-caller-read.md index a0db1415868..c57f14e8cd7 100644 --- a/.changeset/org-admin-removal-caller-read.md +++ b/.changeset/org-admin-removal-caller-read.md @@ -9,6 +9,6 @@ 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 asks the administrator's own read, in its own context, whether that row is there to remove. -- **A row the administrator cannot read** is answered exactly as an id that names no row is: the read door's `404 RECORD_NOT_FOUND` for that object and id. -- **A row the administrator can read** keeps the answer it had, including the guidance that names the organization boundary. +- **The removal of a row the administrator cannot read** is answered exactly as the removal of an id that names no row is: the read door's `404 RECORD_NOT_FOUND` for that object and id. +- **A row the administrator can read** is judged on the stored row. A refusal names the organization boundary, and names the row's organization, position or permission set only when the administrator's own read returns that value as stored. - **Unchanged.** An edit in place of a row it reads 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/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index f718d683e94..cc4a13b4874 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -1413,7 +1413,7 @@ function serveCallerReadsThrough(o: ReturnType, return systemReads; } -describe("[ADR-0090 D12; ruling of record 5985885287, sub-item B] an organization administrator's removal is judged on the stored row, and its refusal names only values the caller reads", () => { +describe("[ADR-0090 D12; ruling of record 6649064874] an organization administrator's removal is judged on the stored row, and its refusal names only values the caller reads", () => { let o: ReturnType; beforeEach(() => { o = makeOrganizationHarness(); }); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 9603be9030e..14580467166 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -852,7 +852,7 @@ export class DelegatedAdminGate { * ({@link organizationTargets}), so a row the caller cannot read is answered * exactly as an id naming no row is (ruling of record 5985885287). A row the * caller reads is judged on the STORED row, as the delegated branch judges - * it (sub-item B of that ruling): the caller's read is served through its + * it (ruling of record 6649064874): the caller's read is served through its * field rules and masking, and a verdict taken on what a rule hid would be * taken on less than the row. A refusal names a value only when the caller * reads it at its stored value or wrote it ({@link mayName}). An edit in @@ -889,9 +889,9 @@ export class DelegatedAdminGate { // 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: {}, unreadable: op === 'delete' }; - // [ruling of record 5985885287, sub-item B] The verdict is the stored - // row's; each value below is named only when the caller wrote or reads - // it ({@link mayName}). + // [ruling of record 6649064874] The verdict is the stored row's; each + // value below is named only when the caller wrote or reads it + // ({@link mayName}). const rowOrg = rowOrganizationId(row); if (t.next ? rowOrg !== null && rowOrg !== organizationId : rowOrg !== organizationId) { if (!mayName(t, side, 'organization_id')) { @@ -954,12 +954,12 @@ export class DelegatedAdminGate { * visibility and wall included: a row the caller cannot read answers what an * id naming no row answers, and no system read is made for it. A row it * reads is then judged on the stored row ({@link storedRow}), never on the - * caller's read of it — sub-item B: that read is served through the caller's - * field rules and masking — and `reads` holds the columns that read returned - * at their stored value ({@link columnsReadAsStored}). A read refusal or a - * store fault, in either read, 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. + * caller's read of it, which is served through the caller's field rules and + * masking (ruling of record 6649064874); `reads` holds the columns that read + * returned at their stored value ({@link columnsReadAsStored}). A read + * refusal or a store fault, in either read, 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. */ private async organizationTargets(opCtx: any): Promise { if (opCtx.operation === 'insert') { 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 0b050b43f70..39b9351f9d2 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 @@ -822,7 +822,7 @@ describe('[ADR-0090 D12; ruling of record 5985885287] an administrator\'s refuse // ── The organization branch: judged on the stored row ────────────────────── -describe("[ADR-0090 D12; ruling of record 5985885287, sub-item B] an organization administrator's removal is judged on the stored row, and its refusal names only values the caller can read", () => { +describe("[ADR-0090 D12; ruling of record 6649064874] an organization administrator's removal is judged on the stored row, and its refusal names only values the caller can read", () => { const inA = { context: { isSystem: true, tenantId: ORG_A } } as any; const MASK: MaskingRule = { keepHead: 3, keepTail: 0 }; From 0b0d0e216f41f563de1d94a86e9e985d7f673c3f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:52:38 +0000 Subject: [PATCH 9/9] docs(plugin-security): every stored-row-verdict and naming-rule citation in the package names ruling of record 6649064874 Comments and one describe name only. The citations of the stored-row verdict and of the rule that a refusal names only what the caller can read now name ruling of record 6649064874: the columnsReadAsStored and mayName docblocks, the two delegated-walk comments, the delegatedTargets docblock (whose guidance sentence now says what mayName does), and the posture rig's field-rule describe. The citations of 5985885287 for the read door's not-found answer are kept. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../plugin-security/src/delegated-admin-gate.ts | 17 +++++++++-------- ...rg-admin-assignment-boundary.posture.test.ts | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 14580467166..8174d6484be 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -446,7 +446,7 @@ interface DelegatedTarget { } /** - * [ruling of record 5985885287, sub-item B] The columns of the stored row + * [ruling of record 6649064874] The columns of the stored row * `prev` that the caller's own read `seen` returned at their stored value. * * The read is the read door's answer to this caller, field rules and masking @@ -464,7 +464,7 @@ function columnsReadAsStored(seen: any | null, prev: any | null): Set { } /** - * [ruling of record 5985885287, sub-item B] May a refusal of `t` name the + * [ruling of record 6649064874] May a refusal of `t` name the * value of `column` on its `side`? * * Only a value the caller already has: one it wrote (an insert's row, a @@ -1315,7 +1315,7 @@ export class DelegatedAdminGate { const positionSide = t.next && (t.next.position != null || !t.prev) ? 'next' : 'prev'; const positionName = String(t.next?.position ?? t.prev?.position ?? ''); const boundSets = positionName ? await this.setsBoundToPosition(positionName) : []; - // [ruling of record 5985885287, sub-item B] Each value below is named + // [ruling of record 6649064874] Each value below is named // only when the caller wrote or reads it ({@link mayName}); the verdict // is the stored row's either way. const positionNamed = mayName(t, positionSide, 'position'); @@ -1381,7 +1381,7 @@ export class DelegatedAdminGate { const { setRow, setName, unresolved, setNamed } = await this.directGrantSet(t, row); const targetUserId = row.user_id ? String(row.user_id) : null; const userBUs = targetUserId ? await this.businessUnitsOfUser(targetUserId) : new Set(); - // [ruling of record 5985885287, sub-item B] Named only when the caller + // [ruling of record 6649064874] Named only when the caller // wrote or reads it ({@link mayName}); the verdict is the stored row's. const userNamed = mayName(t, side, 'user_id'); @@ -1644,10 +1644,11 @@ export class DelegatedAdminGate { * ({@link callerRead}): a row the caller cannot read answers exactly what an * id naming no row answers — one refusal, here at the gate, never a * fall-through to the engine. A row the caller reads is judged on the stored - * row, as before, never on the caller's read of it: that read is served - * through the caller's field-level security, and a judgement made on what a - * field rule hid would be made on less than the row. So a row the caller - * reads keeps its verdict and its guidance. + * row, never on the caller's read of it (ruling of record 6649064874): that + * read is served through the caller's field-level security, and a judgement + * made on what a field rule hid would be made on less than the row. So a row + * the caller reads keeps its verdict, and its guidance names only what the + * caller may be told ({@link mayName}). * * A read the read door REFUSES (a declared 4xx — the object's read grant * withheld) hides no row: the stored row is judged as before, and a refusal 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 39b9351f9d2..83abe90249a 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 @@ -707,7 +707,7 @@ describe("[ADR-0090 D12; ruling of record 5985885287] a delegated administrator' }); }); -describe('[ADR-0090 D12; ruling of record 5985885287] an administrator\'s refused assignment write names a value only when the caller reads it, or wrote it', () => { +describe('[ADR-0090 D12; ruling of record 6649064874] an administrator\'s refused assignment write names a value only when the caller reads it, or wrote it', () => { /** * One row of organization A per hidden column, each outside the `a_ops` * scope on the axis that column decides — the hidden values it carries, and