From 6eef23cd6c0b4dab51133ab590fde0c82b987441 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:42:54 +0000 Subject: [PATCH 01/13] wip(plugin-security): explain's record story is gated on the asker's read Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../plugin-security/src/explain-engine.ts | 153 ++++++++++++++++-- .../plugin-security/src/security-plugin.ts | 17 +- 2 files changed, 155 insertions(+), 15 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 9ded7bf31dc..f2bb6a500ed 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -425,6 +425,12 @@ export interface ExplainEngineDeps { * read door also scopes such a record by its master, a leg no other * dependency here answers, so the door's own answer is what the read verdict * takes ({@link applyRecordAttribution}). + * + * [#22792] Asked first of the ASKER, for every record-grained explanation: + * whether the explanation may say the record exists is decided by the read + * access of whoever asked for it, not by the explained principal's + * ({@link explainAccessForAsker}). One question, one notion of "can read": + * the same caller-context by-id read, asked with the asker's context. */ recordAbsentToCaller?: (object: string, recordId: string, context: any) => Promise; } @@ -1854,7 +1860,99 @@ async function applyRecordAttribution( }; } +/** + * Explain `input.context`'s access, asked by that same principal: a + * self-explanation. The asker's read ({@link explainAccessForAsker}) is the + * explained principal's own. + */ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput): Promise { + return explainAccessForAsker(deps, input, undefined); +} + +/** + * [#22792] Explain `input.context`'s access, asked by `askerContext`: the + * principal that requested the explanation, which is not the principal being + * explained when an administrator explains another user (ADR-0090 D6/D12). + * `undefined` means the explained principal asks for itself. + * + * Ruling A of the explain-asker decision: whether an explanation may disclose + * that a record exists is decided by the ASKER's read access, never by the + * explained principal's. An asker who cannot read the record gets, for every + * operation, exactly what an id no row carries gets: the record verdict and + * every layer's per-record attribution. An asker who can read it learns + * nothing new about its existence, and gets the full record story. + * + * Module-internal on purpose: the package publishes {@link explainAccess}, + * whose signature and self-explanation answer this leaves as they are. The + * security service's caller-authorized explain (`explainAccessForCaller`) + * is the one caller that knows a different asker. + */ +export async function explainAccessForAsker( + deps: ExplainEngineDeps, + input: ExplainInput, + askerContext: any, +): Promise { + // One explanation asks the read question of at most two principals (the + // asker and the explained), from up to three places (the asker gate, the + // by-id write guard, the master leg of a read), and the principal's READ + // pass below asks it again: each (principal, record) pair is read once. + return explainOnce({ ...deps, recordAbsentToCaller: askedOnce(deps.recordAbsentToCaller) }, input, askerContext); +} + +/** + * The read question, memoized per (context, object, record) for the length of + * one explanation. The answer to a question is the same however often it is + * asked within one request; a rejection stays the rejection it was. + */ +function askedOnce( + question: ExplainEngineDeps['recordAbsentToCaller'], +): ExplainEngineDeps['recordAbsentToCaller'] { + if (question === undefined) return undefined; + const asked = new Map>>(); + return (object, recordId, context) => { + let byRecord = asked.get(context); + if (!byRecord) { + byRecord = new Map(); + asked.set(context, byRecord); + } + const key = JSON.stringify([object, recordId]); + let answer = byRecord.get(key); + if (!answer) { + answer = question(object, recordId, context); + byRecord.set(key, answer); + } + return answer; + }; +} + +/** + * [#22792] The preface every per-record attribution carries when a by-id write + * is explained to an asker who can read a record the explained principal + * cannot: the write never gets past that read, so the attribution shown is the + * read's, and it says so. + */ +function principalReadPreface(operation: ExplainOperation): string { + return `The principal cannot read this record, so its by-id ${operation} answers 404 RECORD_NOT_FOUND, as an id ` + + 'no row carries does. This attribution is of that read: '; +} + +/** + * [#22792] Carry the explained principal's READ attribution onto a by-id write + * explanation, layer by layer: each row-scoped layer's `record` becomes the + * read's (prefaced), and the tenant layer's verdict, which its record decides, + * follows it. The object-level fields stay the write's own. + */ +function attributeThePrincipalsRead(layers: ExplainLayer[], read: ExplainDecision, operation: ExplainOperation): void { + const readLayers = new Map(read.layers.map((l) => [l.layer, l])); + for (const layer of layers) { + const readLayer = readLayers.get(layer.layer); + if (!layer.record || !readLayer?.record) continue; + layer.record = { ...readLayer.record, detail: principalReadPreface(operation) + (readLayer.record.detail ?? '') }; + if (layer.layer === 'tenant_isolation') layer.verdict = readLayer.verdict; + } +} + +async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, askerContext: any): Promise { const { object, operation, context } = input; const engineOp = EXPLAIN_TO_ENGINE_OP[operation]; // [#3544] The gate operation (`engineOp`) and the data operation may differ — @@ -2289,6 +2387,21 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput let recordVerdict: ExplainDecision['record'] | undefined; let posture: AuthzPosture | undefined; if (input.recordId) { + const recordId = input.recordId; + const readQuestion = deps.recordAbsentToCaller; + // [#22792] Ruling A of the explain-asker decision: the ASKER's read decides + // whether this explanation may say the record exists. An asker the read + // door does not return the record to gets, for every operation, the whole + // answer an id no row carries gets: the record verdict and every layer's + // `record`, all from the one missing-record path below. The question is + // the read door's own (`recordAbsentToCaller`), asked with the asker's + // context, so there is one notion of "can read". A system asker reads + // every row, so it is never asked. + const asker = askerContext ?? context; + const askerCannotRead = + asker?.isSystem !== true && + readQuestion !== undefined && + (await readQuestion(object, recordId, asker)); // [#21771] Ruling A on the write doors: a row the principal cannot READ is // a row that does not exist, so an update or delete of it answers what a // nonexistent id answers, wherever enforcement reaches that question: past @@ -2308,26 +2421,44 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput // them while their grants are retired. // // [#22761] The WHOLE record story answers it, not the record verdict - // alone. The question is asked before the row story is written, and a row - // the caller cannot read is then attributed exactly as an id no row - // carries: the decision's `record` (the missing-record shape, - // `visible: false` with no decider), every row-scoped layer's `record` and - // the tenant layer's verdict all come from that one path, so no part of the - // answer is written from the stored row the door withholds. - const readAbsent = + // alone. The question is asked before the row story is written. + // + // [#22792] Who learns that the row exists is the asker's question, above. + // When the asker is the explained principal, its answer is this one, and + // a row the principal cannot read is attributed exactly as an id no row + // carries. When the asker is someone else who CAN read the row, the + // principal's write still answers what a nonexistent id answers, so the + // verdict stays `visible: false`; but nothing about the row's existence is + // withheld from that asker, so the verdict carries the decider of the + // principal's READ, and each row-scoped layer carries that read's + // attribution: the read is where the write stops, so it is the story of + // the write. A second, read-operation pass yields it: no write-operation + // pass composes the read's row-level security, read filter, read depth or + // view bypass. + const principalCannotRead = + !askerCannotRead && + asker !== context && (rlsOp === 'update' || rlsOp === 'delete') && !capsDeny && crudAllowed && Boolean(context?.userId) && - deps.recordAbsentToCaller !== undefined && - (await deps.recordAbsentToCaller(object, input.recordId, context)); + readQuestion !== undefined && + (await readQuestion(object, recordId, context)); const out = await applyRecordAttribution({ - deps: readAbsent ? { ...deps, fetchRecord: async () => null } : deps, - object, recordId: input.recordId, engineOp: dataOp, rlsOp, context, sets, layers, owd, capsDeny, crudAllowed, + deps: askerCannotRead || principalCannotRead ? { ...deps, fetchRecord: async () => null } : deps, + object, recordId, engineOp: dataOp, rlsOp, context, sets, layers, owd, capsDeny, crudAllowed, vamaEffective, vamaSets, declaredColumns, depthScope: scope, }); recordVerdict = out.record; posture = out.posture; + if (principalCannotRead) { + const read = await explainOnce(deps, { object, operation: 'read', context, recordId }, askerContext); + attributeThePrincipalsRead(layers, read, operation); + // A read story that reaches no refusal names no decider: the door's + // answer stands, `visible: false`, and no layer is credited with it. + const readDecider = read.record && read.record.visible === false ? read.record.decidedBy : undefined; + recordVerdict = { recordId, visible: false, ...(readDecider ? { decidedBy: readDecider } : {}) }; + } } const decision: ExplainDecision = { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 8b322689b0d..5f9b6a2727f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -45,7 +45,7 @@ import { createPositionWriteThrough } from './position-write-through.js'; import { runOneTimePositionEnvironmentBackfill } from './position-environment-backfill.js'; import { reportCatalogReferences } from './catalog-reference-report.js'; import { - explainAccess, + explainAccessForAsker, buildContextForUser, resolveDelegatorContext, intersectFieldMasks, @@ -5206,8 +5206,13 @@ export class SecurityPlugin implements Plugin { * [D12] — hold a delegated `adminScope` whose BU subtree covers the target * user (an access report is itself sensitive data, but an admin who can * already rewire a user's grants may read why they resolve as they do). - * The evaluation delegates to {@link explainAccess} with the SAME internals - * the middleware uses. + * The evaluation delegates to {@link explainAccessForAsker} with the SAME + * internals the middleware uses. + * + * [#22792] ...and with the CALLER as the asker. Whether the explanation may + * say a record exists is decided by the caller's read of it, never by the + * explained user's: a caller the read door does not return the record to is + * answered exactly as for an id no row carries, whoever it explains. */ async explainAccessForCaller( request: { object: string; operation: string; userId?: string; recordId?: string }, @@ -5339,7 +5344,7 @@ export class SecurityPlugin implements Plugin { const readFilterWithReadScope = (o: string, c: any): Promise => this.resolveSharingReadFilter(o, { ...c, __readScope: undefined }); - return explainAccess( + return explainAccessForAsker( { ql: this.ql, resolveSets: (c: any) => this.resolvePermissionSetsForContext(c), @@ -5420,6 +5425,10 @@ export class SecurityPlugin implements Plugin { this.checkControlledByParentWrite(o, rid, c), }, { object, operation, context: targetContext, recordId }, + // The asker: the caller, whose read of the record decides whether this + // explanation discloses that the record exists. On a self-explanation it + // is the explained context itself. + callerContext, ); } From 3f562d94319fe8e72903b822ed01e1e1216aebac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:04:30 +0000 Subject: [PATCH 02/13] wip: asker gate pins, flipped self-explain read pins, spec description text Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../src/explain-asker-gate.test.ts | 409 ++++++++++++++++++ ...explain-controlled-by-parent-write.test.ts | 92 ++-- .../src/explain-cross-class-refusal.test.ts | 10 +- .../plugin-security/src/explain-engine.ts | 8 +- .../src/explain-json-column-refusal.test.ts | 12 +- .../plugin-security/src/security-plugin.ts | 4 +- packages/spec/src/security/explain.zod.ts | 17 +- 7 files changed, 512 insertions(+), 40 deletions(-) create mode 100644 packages/plugins/plugin-security/src/explain-asker-gate.test.ts diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts new file mode 100644 index 00000000000..dec9d377635 --- /dev/null +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -0,0 +1,409 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `security/explain` decides whether an explanation may say a record exists by +// the read access of whoever ASKED for it, never by the explained principal's. +// +// ## The rule +// +// Ruling A of the explain-asker decision. An asker the read door does not +// return the record to gets, for every operation, exactly what an id no row +// carries gets: the record verdict and every layer's per-record attribution. +// An asker who can read the record learns nothing new about its existence, so +// it gets the explained principal's full record story: +// +// - for a read, the principal's own verdict, decider included; +// - for a by-id write (update, delete, transfer) of a row the principal +// cannot read, `visible: false`, the outcome that principal's write gets, +// with the decider of the principal's READ, and each row-scoped layer +// carrying that read's attribution. +// +// "Can read" is the read door's own by-id read asked with the asker's context +// (the question the write doors ask). A system asker reads every row. +// +// ## The rig +// +// The real `SecurityPlugin` over a real `ObjectQL` and SQL driver +// (better-sqlite3, in memory), the platform's grant tables, and one +// `public_read_write` board. The explained member reads only the board named +// `open`, through an app-authored READ row-level policy; nothing narrows the +// member's writes. So the member's update, delete or transfer of the hidden +// board is stopped by the read alone, and the write-operation story by itself +// would admit it: the read's decider is the only right one. +// +// Askers: the member itself; a `manage_users` administrator who reads every +// board; a `manage_users` administrator held to the member's read policy; a +// delegated administrator (ADR-0090 D12) held to it too; and the system. The +// delegated administrator's boundary check is answered by a stub, because the +// boundary is not what is measured: what that asker can READ is. +// +// The last block asks the engine directly over a deps bag, for the facts the +// served rig cannot isolate: which contexts the read question is asked with, +// and how often. + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; +import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objects/identity'; + +import { SysPosition } from './objects/sys-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { PermissionEvaluator } from './permission-evaluator.js'; +import { explainAccess, explainAccessForAsker, type ExplainEngineDeps } from './explain-engine.js'; + +const SYS = { context: { isSystem: true } } as never; +const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; + +const BOARD = 'qa_ag_board'; +const MEMBER = 'usr_ag_member'; + +/** The member's grant: every verb on the board, and a READ policy that admits only the board named `open`. */ +const MEMBER_SET = PermissionSetSchema.parse({ + name: 'qa_ag_member', + objects: { + [BOARD]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowTransfer: true, allowExport: true }, + }, + rowLevelSecurity: [{ name: 'qa_ag_reads_open_only', object: BOARD, operation: 'select', using: "name == 'open'" }], +}); +/** A user administrator who reads every board. */ +const USER_ADMIN_SET = PermissionSetSchema.parse({ + name: 'qa_ag_user_admin', + objects: { [BOARD]: { allowRead: true } }, + systemPermissions: ['manage_users'], +}); +/** `manage_users` alone: no data grant at all (it is not a data-read grant). */ +const USERS_ONLY_SET = PermissionSetSchema.parse({ name: 'qa_ag_users_only', objects: {}, systemPermissions: ['manage_users'] }); + +const SELF = { userId: MEMBER, positions: [] as string[], permissions: [MEMBER_SET.name] }; +const ADMIN = { userId: 'usr_ag_admin', positions: [] as string[], permissions: [USER_ADMIN_SET.name] }; +/** A user administrator held to the member's read policy. */ +const BLIND_ADMIN = { userId: 'usr_ag_blind', positions: [] as string[], permissions: [MEMBER_SET.name, USERS_ONLY_SET.name] }; +/** A delegated administrator (D12, no `manage_users`) held to the member's read policy. */ +const DELEGATE = { userId: 'usr_ag_delegate', positions: [] as string[], permissions: [MEMBER_SET.name] }; +const SYSTEM = { isSystem: true }; + +const HIDDEN = 'b_hidden'; +const OPEN = 'b_open'; +const NONEXISTENT = 'b_nonexistent'; + +/** Every verb the member's grant lets through the object gate, read-class and by-id write alike. */ +const VERBS: ExplainOperation[] = ['read', 'export', 'update', 'delete', 'transfer']; +const WRITE_VERBS: ExplainOperation[] = ['update', 'delete', 'transfer']; + +let engine: ObjectQL; +let plugin: SecurityPlugin; + +beforeAll(async () => { + engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as never, + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.explain-asker-gate', + name: 'Explain: the asker decides what the record story may disclose', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPosition, SysUserPermissionSet, + SysOrganization, SysUser, SysMember, + { + name: BOARD, + label: 'Board', + sharingModel: 'public_read_write', + tenancy: { enabled: false }, + fields: { + id: { name: 'id', type: 'text', primaryKey: true }, + name: { name: 'name', type: 'text' }, + owner_id: { name: 'owner_id', type: 'text' }, + }, + }, + ], + } as never); + await engine.syncSchemas(); + + const e = engine as unknown as { insert: (o: string, row: unknown, opts: never) => Promise }; + for (const id of [MEMBER, ADMIN.userId, BLIND_ADMIN.userId, DELEGATE.userId]) { + await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); + } + // The explained member's grant, as the explainer resolves it: a grant row + // pointing at a catalogue row; what the set confers is declared in metadata. + await e.insert('sys_permission_set', { + id: 'ps_ag_member', name: MEMBER_SET.name, label: MEMBER_SET.name, organization_id: null, managed_by: 'admin', + active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), + }, SYS); + await e.insert('sys_user_permission_set', { + id: 'ups_ag_member', user_id: MEMBER, permission_set_id: 'ps_ag_member', permission_set: MEMBER_SET.name, + }, SYS); + await e.insert(BOARD, [ + { id: HIDDEN, name: 'hidden', owner_id: 'usr_ag_owner' }, + { id: OPEN, name: 'open', owner_id: 'usr_ag_owner' }, + ], SYS); + + const sets = [MEMBER_DEFAULT, MEMBER_SET, USER_ADMIN_SET, USERS_ONLY_SET]; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' ? sets.find((s) => s.name === name) ?? null : engine.getSchema(name) ?? null, + list: async () => sets, + }, + }; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { services[name] = svc; }, + // The lifecycle hooks are collected and never fired, so no bootstrap read + // is left running into the engine teardown. + hook: () => undefined, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + // The D12 boundary: this delegate's adminScope covers the member, and nobody + // else's does. Answered by a stub, as the file header says. + (plugin as unknown as { delegatedAdminGate: unknown }).delegatedAdminGate = { + scopesCoverUser: async (_sets: unknown, target: string, caller: { userId?: string }) => + caller?.userId === DELEGATE.userId && target === MEMBER, + }; +}); + +afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } +}); + +/** `asker` explains the member: a self-explanation for `SELF`, explaining another user for everyone else. */ +const explain = (asker: Record, operation: ExplainOperation, recordId: string): Promise => + plugin.explainAccessForCaller( + { object: BOARD, operation, recordId, ...(asker === SELF ? {} : { userId: MEMBER }) }, + { ...asker }, + ); + +/** The read door: the asker's own by-id read. */ +const reads = async (asker: Record, id: string): Promise => + (await engine.findOne(BOARD, { where: { id }, context: { ...asker } } as never)) != null; + +type Answer = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number }; +const landed = (p: Promise): Promise => + p.then( + () => ({ kind: 'admitted' as const }), + (e: any) => ({ kind: 'refused' as const, code: e?.code == null ? undefined : String(e.code), status: e?.statusCode ?? e?.status }), + ); +/** The member's own by-id write door: a transfer is the update that writes `owner_id`. */ +const memberDoor = (verb: ExplainOperation, id: string): Promise => + verb === 'delete' + ? landed(engine.delete(BOARD, { where: { id }, context: { ...SELF } } as never)) + : landed(engine.update(BOARD, verb === 'transfer' ? { owner_id: 'usr_ag_successor' } : { name: 'edited' }, + { where: { id }, context: { ...SELF } } as never)); + +/** The decision with its record id set aside: the one field two different ids must differ in. */ +const withoutRecordId = (d: ExplainDecision) => ({ ...d, record: d.record ? { ...d.record, recordId: 'ID' } : d.record }); +const layerRecord = (d: ExplainDecision, layer: string) => d.layers.find((l) => l.layer === layer)?.record; +/** Every HTTP status, with its code, that any layer's prose names. */ +const statusesNamed = (d: ExplainDecision): string[] => { + const prose = d.layers.flatMap((l) => [l.detail, l.record?.detail ?? '']).join('\n'); + return [...new Set([...prose.matchAll(/\b([1-5]\d\d) ([A-Z][A-Z_]+)\b/g)].map((m) => `${m[1]} ${m[2]}`))]; +}; + +describe('armed: who reads which board', () => { + it('the hidden board exists; the member, the held administrator and the delegate cannot read it; the administrator can', async () => { + expect(await reads(SYSTEM, HIDDEN), 'a system read of the hidden board').toBe(true); + for (const [who, asker] of [['the member', SELF], ['the held administrator', BLIND_ADMIN], ['the delegate', DELEGATE]] as const) { + expect(await reads(asker, HIDDEN), `${who}'s read of the hidden board`).toBe(false); + expect(await reads(asker, OPEN), `${who}'s read of the open board`).toBe(true); + } + expect(await reads(ADMIN, HIDDEN), "the administrator's read of the hidden board").toBe(true); + expect(await reads(ADMIN, OPEN), "the administrator's read of the open board").toBe(true); + }); + + it('the member\'s write of the hidden board answers what a nonexistent id answers, for every by-id write verb', async () => { + for (const verb of WRITE_VERBS) { + expect(await memberDoor(verb, HIDDEN), `${verb} door, the hidden board`).toEqual({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 }); + expect(await memberDoor(verb, NONEXISTENT), `${verb} door, a nonexistent id`).toEqual({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 }); + } + }); +}); + +describe('an asker who cannot read the row gets, for every operation, the answer an id no row carries gets', () => { + for (const [who, asker] of [ + ['the member explaining itself', SELF], + ['a manage_users administrator held to the member\'s read', BLIND_ADMIN], + ['a delegated administrator (D12) held to the member\'s read', DELEGATE], + ] as const) { + for (const verb of VERBS) { + it(`${who}, ${verb}: the hidden board's whole explanation equals the nonexistent id's, layers included`, async () => { + const hidden = await explain(asker, verb, HIDDEN); + const nonexistent = await explain(asker, verb, NONEXISTENT); + expect(hidden.principal.userId).toBe(MEMBER); + expect(hidden.record).toEqual({ recordId: HIDDEN, visible: false }); + expect(withoutRecordId(hidden)).toEqual(withoutRecordId(nonexistent)); + }); + } + } + + it('the delegate holds no manage_users: it reached the record story through the D12 boundary alone', async () => { + const sets = await (plugin as unknown as { resolvePermissionSetsForContext: (c: unknown) => Promise> }) + .resolvePermissionSetsForContext({ ...DELEGATE }); + expect(sets.some((s) => (s.systemPermissions ?? []).includes('manage_users'))).toBe(false); + }); +}); + +describe('an asker who can read the row gets the explained principal\'s full record story', () => { + it('read: the member\'s own verdict, decider included, as the system asker gets it', async () => { + const byAdmin = await explain(ADMIN, 'read', HIDDEN); + const bySystem = await explain(SYSTEM, 'read', HIDDEN); + expect(byAdmin.record).toEqual({ recordId: HIDDEN, visible: false, decidedBy: 'rls' }); + expect(layerRecord(byAdmin, 'rls')?.outcome).toBe('excluded'); + expect(byAdmin).toEqual(bySystem); + }); + + for (const verb of WRITE_VERBS) { + it(`${verb}: not visible, the member's write's answer, decided by the member's READ, as the system asker gets it`, async () => { + const byAdmin = await explain(ADMIN, verb, HIDDEN); + const bySystem = await explain(SYSTEM, verb, HIDDEN); + expect(byAdmin.record).toEqual({ recordId: HIDDEN, visible: false, decidedBy: 'rls' }); + expect(byAdmin).toEqual(bySystem); + }); + + it(`${verb}: each row-scoped layer carries the member's read attribution, and names only the door's 404`, async () => { + const explained = await explain(ADMIN, verb, HIDDEN); + const rls = layerRecord(explained, 'rls'); + expect(rls?.outcome).toBe('excluded'); + expect(rls?.matchesRecord).toBe(false); + expect(rls?.detail).toContain(`The principal cannot read this record, so its by-id ${verb} answers 404 RECORD_NOT_FOUND`); + expect(rls?.detail).toContain('The record does not satisfy the business row-level predicate.'); + for (const layer of ['tenant_isolation', 'owd_baseline', 'depth', 'sharing', 'vama_bypass', 'rls']) { + expect(layerRecord(explained, layer)?.detail, `${verb}, the ${layer} layer`).toMatch(/^The principal cannot read this record/); + } + expect(statusesNamed(explained)).toEqual(['404 RECORD_NOT_FOUND']); + expect(await memberDoor(verb, HIDDEN)).toEqual({ kind: 'refused', code: 'RECORD_NOT_FOUND', status: 404 }); + }); + } +}); + +describe('controls', () => { + for (const verb of VERBS) { + it(`${verb}: a nonexistent id is the missing-record answer for every asker`, async () => { + for (const asker of [SELF, ADMIN, BLIND_ADMIN, DELEGATE, SYSTEM]) { + expect((await explain(asker, verb, NONEXISTENT)).record).toEqual({ recordId: NONEXISTENT, visible: false }); + } + }); + + it(`${verb}: a row every asker reads gets one answer, whoever asks, and its record story is the stored row's`, async () => { + const bySystem = await explain(SYSTEM, verb, OPEN); + expect(bySystem.record).toMatchObject({ recordId: OPEN, visible: true }); + expect(layerRecord(bySystem, 'rls')?.outcome).toBe(verb === 'read' || verb === 'export' ? 'admitted' : 'not_evaluated'); + for (const asker of [ADMIN, BLIND_ADMIN, DELEGATE]) expect(await explain(asker, verb, OPEN)).toEqual(bySystem); + const self = await explain(SELF, verb, OPEN); + expect(self.record).toEqual(bySystem.record); + expect(self.layers.map((l) => l.record)).toEqual(bySystem.layers.map((l) => l.record)); + }); + } +}); + +describe('the read question, engine level: who it is asked of, and how often', () => { + const OBJECT = 'qa_ag_engine'; + const SET = PermissionSetSchema.parse({ + name: 'qa_ag_engine_writer', + objects: { [OBJECT]: { allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true } }, + }); + const EXPLAINED = { userId: 'usr_ag_explained', positions: [] as string[], permissions: [] as string[] }; + const ASKER = { userId: 'usr_ag_asker', positions: [] as string[], permissions: [] as string[] }; + const STORED = { id: 'r1', owner_id: 'usr_ag_other', name: 'stored' }; + + /** One deps bag; the read question answers per context, from `absentTo`. */ + const deps = (question: ExplainEngineDeps['recordAbsentToCaller']): ExplainEngineDeps => ({ + ql: { getSchema: () => ({ name: OBJECT, sharingModel: 'public_read_write' }) }, + resolveSets: async () => [SET], + evaluator: new PermissionEvaluator(), + getObjectSecurityMeta: async () => ({ + isPrivate: false, + requiredPermissions: { all: [], read: [], create: [], update: [], delete: [] }, + fieldRequiredPermissions: {}, + }), + requiredCaps: () => [], + computeRlsFilter: async () => null, + getFieldMask: () => ({}), + getPartialMaskRules: async () => ({}), + baselinePermissionSets: [], + computeLayeredRlsFilter: async () => ({ layer0: null, layer1: null }), + fetchRecord: async (_o, id) => (id === STORED.id ? { ...STORED } : null), + recordAbsentToCaller: question, + }); + /** The read door: `absent` lists the contexts it does not return the record to. */ + const door = (...absent: unknown[]) => vi.fn(async (_o: string, _id: string, c: unknown) => absent.includes(c)); + + it('a self-explanation asks the explained context, once, whatever the verb', async () => { + for (const verb of VERBS) { + const question = door(); + await explainAccess(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }); + expect(question, verb).toHaveBeenCalledTimes(1); + expect(question.mock.calls[0][2], verb).toBe(EXPLAINED); + } + }); + + it('another asker is asked with its own context, and the explained principal only for a by-id write', async () => { + for (const verb of VERBS) { + const question = door(); + await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER); + const asked = question.mock.calls.map((c) => c[2]); + expect(asked[0], `${verb}: the asker first`).toBe(ASKER); + expect(asked.filter((c) => c === ASKER), `${verb}: the asker, once`).toHaveLength(1); + expect(asked.filter((c) => c === EXPLAINED), `${verb}: the explained principal`).toHaveLength(WRITE_VERBS.includes(verb) ? 1 : 0); + } + }); + + it('a write of a row the explained principal cannot read asks each context once, its read pass included', async () => { + for (const verb of WRITE_VERBS) { + const question = door(EXPLAINED); + const d = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER); + expect(d.record, verb).toMatchObject({ recordId: STORED.id, visible: false }); + expect(question.mock.calls.map((c) => c[2]), verb).toEqual([ASKER, EXPLAINED]); + } + }); + + it('a system asker reads every row: it is never asked, and gets the explained principal\'s story', async () => { + for (const verb of VERBS) { + const question = door(SYSTEM_CONTEXT); + const bySystem = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, SYSTEM_CONTEXT); + expect(question.mock.calls.map((c) => c[2]), verb).not.toContain(SYSTEM_CONTEXT); + expect(bySystem.record, verb).toMatchObject({ recordId: STORED.id, visible: true }); + } + }); + + it('a rejection of the asker\'s question fails the explanation: an outage is neither absence nor a row', async () => { + const fault = Object.assign(new Error('datasource unavailable'), { code: 'ERR_DATASOURCE_UNAVAILABLE', status: 503 }); + for (const verb of VERBS) { + const question = vi.fn(async () => { throw fault; }); + await expect( + explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER), + verb, + ).rejects.toBe(fault); + } + }); + + it('an asker the door does not return the row to gets the stored row\'s explanation as the nonexistent id\'s', async () => { + for (const verb of VERBS) { + const question = door(ASKER); + const stored = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER); + const missing = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: 'r_missing' }, ASKER); + expect(stored.record, verb).toEqual({ recordId: STORED.id, visible: false }); + expect(withoutRecordId(stored), verb).toEqual(withoutRecordId(missing)); + } + }); +}); + +/** A system context, held as one object so a call can be told apart by identity. */ +const SYSTEM_CONTEXT = { isSystem: true }; diff --git a/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts b/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts index eae34392e9a..829f5ef816d 100644 --- a/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts +++ b/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts @@ -41,7 +41,7 @@ import type { ControlledByParentWriteUnresolvedReason, } from '@objectstack/spec/contracts'; import { PermissionEvaluator } from './permission-evaluator.js'; -import { explainAccess, type ExplainEngineDeps } from './explain-engine.js'; +import { explainAccess, explainAccessForAsker, type ExplainEngineDeps } from './explain-engine.js'; const OBJECT = 'cbx_contract'; const CBP_SCHEMA = { name: OBJECT, sharingModel: 'controlled_by_parent' }; @@ -288,28 +288,48 @@ describe('[ADR-0055] which verbs ask the check, and for whom', () => { const READ_DOOR_ASKED = (Object.keys(VERB_CLASS) as ExplainOperation[]).filter((v) => VERB_CLASS[v] === 'read_door_asked'); -/** The read door's answer: `true` withholds the record from the explained principal, `false` returns it. */ -const readDoorAnswering = (absent: boolean): ReadDoor => vi.fn(async () => absent); +/** + * [#22792] The asker the read block explains to: an administrator the read + * door returns the record to. Whether an explanation may say a record exists + * is the ASKER's read, so the principal's read verdict, decider included, is + * what an asker who reads the record is told; a principal explaining itself a + * record the door withholds is told what an id no row carries gets (the + * self-explanation cells below). + */ +const ASKER = { userId: 'u_admin', tenantId: 'org1', positions: [], permissions: [] }; + +/** The read door: `true` withholds the record from the EXPLAINED principal, `false` returns it; the asker always reads it. */ +const readDoorAnswering = (absent: boolean): ReadDoor => + vi.fn(async (_object: string, _recordId: string, context: unknown) => (context === EXPLAINED ? absent : false)); +/** Who the read door was asked about, in order. */ +const askedOf = (readDoor: ReadDoor) => (readDoor as ReturnType).mock.calls.map((c) => c[2]); + +/** `null` asks the object-level question (no `recordId`). */ +const explainToAsker = (d: ExplainEngineDeps, operation: ExplainOperation, recordId: string | null = 'r1') => + explainAccessForAsker(d, { object: OBJECT, operation, context: EXPLAINED, ...(recordId !== null ? { recordId } : {}) }, ASKER); describe('[ADR-0055] a read of a controlled_by_parent record takes the read door\'s answer on the master leg', () => { for (const verb of READ_DOOR_ASKED) { it(`${verb}: a record the read door withholds is not visible, decided by the sharing layer`, async () => { - const d = await explainAs(deps({ readDoor: readDoorAnswering(true) }), verb); + const d = await explainToAsker(deps({ readDoor: readDoorAnswering(true) }), verb); expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' }); expect(sharingRecordOf(d)?.outcome).toBe('excluded'); }); it(`${verb}: a record the read door returns: byte-identical to the report without the question`, async () => { - const without = await explainAs(deps(), verb); - const withDoor = await explainAs(deps({ readDoor: readDoorAnswering(false) }), verb); + const without = await explainToAsker(deps(), verb); + const withDoor = await explainToAsker(deps({ readDoor: readDoorAnswering(false) }), verb); expect(withDoor).toEqual(without); expect(withDoor.record).toMatchObject({ recordId: 'r1', visible: true }); }); it(`${verb}: a rejection fails the find, so it is reported fail-closed with no predicate`, async () => { const fault = Object.assign(new Error('datasource unavailable'), { code: 'ERR_DATASOURCE_UNAVAILABLE', status: 503 }); - const readDoor: ReadDoor = vi.fn(async () => { throw fault; }); - const d = await explainAs(deps({ readDoor }), verb); + const readDoor: ReadDoor = vi.fn(async (_o: string, _r: string, context: unknown) => { + if (context === EXPLAINED) throw fault; + return false; + }); + const d = await explainToAsker(deps({ readDoor }), verb); expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' }); const sharing = sharingRecordOf(d); expect(sharing?.outcome).toBe('not_evaluated'); @@ -317,51 +337,69 @@ describe('[ADR-0055] a read of a controlled_by_parent record takes the read door expect(sharing?.matchesRecord).toBeUndefined(); }); - it(`${verb}: asked once, for that record, with the EXPLAINED context, and the write check is not asked`, async () => { + it(`${verb}: asked once for the asker and once, for that record, with the EXPLAINED context, and the write check is not asked`, async () => { const readDoor = readDoorAnswering(false); const master = answering({ outcome: 'deny', leg: 'object_permission' }); - await explainAs(deps({ readDoor, master }), verb); - expect(readDoor).toHaveBeenCalledTimes(1); + await explainToAsker(deps({ readDoor, master }), verb); + expect(readDoor).toHaveBeenCalledTimes(2); expect(readDoor).toHaveBeenCalledWith(OBJECT, 'r1', EXPLAINED); - expect((readDoor as ReturnType).mock.calls[0][2]).toBe(EXPLAINED); + expect(askedOf(readDoor)).toEqual([ASKER, EXPLAINED]); expect(master).not.toHaveBeenCalled(); }); - it(`${verb}: a leg this report models that withholds the record decides, and the read door is not asked`, async () => { + it(`${verb}: a leg this report models that withholds the record decides, and the read door is not asked of the principal`, async () => { const readDoor = readDoorAnswering(true); - const byRls = await explainAs(deps({ readDoor, layer1: { id: 'not_this_record' } }), verb); + const byRls = await explainToAsker(deps({ readDoor, layer1: { id: 'not_this_record' } }), verb); expect(byRls.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'rls' }); - const byTenant = await explainAs(deps({ readDoor, record: { ...RECORD, organization_id: 'org2' } }), verb); + const byTenant = await explainToAsker(deps({ readDoor, record: { ...RECORD, organization_id: 'org2' } }), verb); expect(byTenant.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'tenant_isolation' }); - expect(readDoor).not.toHaveBeenCalled(); + expect(askedOf(readDoor)).not.toContain(EXPLAINED); }); for (const [label, schema] of [ ['public_read_write', { name: OBJECT, sharingModel: 'public_read_write' }], ['private', { name: OBJECT, sharingModel: 'private' }], ] as const) { - it(`${verb}: not asked on a ${label} object, which derives nothing from a master: the report is unchanged`, async () => { + it(`${verb}: not asked of the principal on a ${label} object, which derives nothing from a master: the report is unchanged`, async () => { const readDoor = readDoorAnswering(true); - const without = await explainAs(deps({ schema }), verb); - const withDoor = await explainAs(deps({ schema, readDoor }), verb); + const without = await explainToAsker(deps({ schema }), verb); + const withDoor = await explainToAsker(deps({ schema, readDoor }), verb); expect(withDoor).toEqual(without); - expect(readDoor).not.toHaveBeenCalled(); + expect(askedOf(readDoor)).toEqual([ASKER]); }); } - it(`${verb}: an object-level request, and a record that does not exist, are not asked about`, async () => { + it(`${verb}: an object-level request is not asked about, and a record that does not exist is not asked of the principal`, async () => { const readDoor = readDoorAnswering(true); - const objectLevel = await explainAs(deps({ readDoor }), verb, null); + const objectLevel = await explainToAsker(deps({ readDoor }), verb, null); expect(objectLevel.record).toBeUndefined(); - const missing = await explainAs(deps({ readDoor, record: null }), verb, 'r_missing'); - expect(missing.record).toEqual({ recordId: 'r_missing', visible: false }); expect(readDoor).not.toHaveBeenCalled(); + const missing = await explainToAsker(deps({ readDoor, record: null }), verb, 'r_missing'); + expect(missing.record).toEqual({ recordId: 'r_missing', visible: false }); + expect(askedOf(readDoor)).not.toContain(EXPLAINED); + }); + + it(`${verb}: explained to itself, a record the read door withholds is answered as the id no row carries, layers included`, async () => { + const readDoor = readDoorAnswering(true); + const withheld = await explainAs(deps({ readDoor }), verb); + const missing = await explainAs(deps({ readDoor, record: null }), verb); + expect(withheld.record).toEqual({ recordId: 'r1', visible: false }); + expect(withheld).toEqual(missing); + expect(sharingRecordOf(withheld)?.outcome).toBe('not_evaluated'); + }); + + it(`${verb}: explained to itself, a record the read door returns is the report without the question, asked once`, async () => { + const readDoor = readDoorAnswering(false); + const without = await explainAs(deps(), verb); + const withDoor = await explainAs(deps({ readDoor }), verb); + expect(withDoor).toEqual(without); + expect(askedOf(readDoor)).toEqual([EXPLAINED]); }); } - it('a create, which addresses no stored record, is not asked about', async () => { + it('a create, which addresses no stored record, is not asked of the principal', async () => { const readDoor = readDoorAnswering(true); - await explainAs(deps({ readDoor }), 'create'); - expect(readDoor).not.toHaveBeenCalled(); + await explainToAsker(deps({ readDoor }), 'create'); + expect(askedOf(readDoor)).not.toContain(EXPLAINED); }); }); diff --git a/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts b/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts index cb6fb20016d..f1341a53456 100644 --- a/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts +++ b/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts @@ -263,9 +263,15 @@ for (const [driverName, makeDriver, available] of DRIVERS) { expect(admitted.record).toEqual({ recordId: 'r1', visible: true, decidedBy: 'rls' }); expect(rlsRecordOf(admitted)).toMatchObject({ outcome: 'admitted', matchesRecord: true }); + // [#22792] r2 is a row the caller cannot read, and the caller is the one + // asking: explain answers it as the id no row carries, layers included, + // as the find answers it (the row is not in `rows` above). const excluded = await w.explain('read', 'r2'); - expect(excluded.record).toEqual({ recordId: 'r2', visible: false, decidedBy: 'rls' }); - expect(rlsRecordOf(excluded)).toMatchObject({ outcome: 'excluded', matchesRecord: false }); + const nonexistent = await w.explain('read', 'r_nonexistent'); + expect(excluded.record).toEqual({ recordId: 'r2', visible: false }); + expect(rlsRecordOf(excluded)).toEqual({ outcome: 'not_evaluated', rules: [], detail: 'Record not found; business RLS not evaluated.' }); + expect({ ...excluded, record: { ...excluded.record, recordId: 'ID' } }) + .toEqual({ ...nonexistent, record: { ...nonexistent.record, recordId: 'ID' } }); expect(await outcome(w.request('update', 'r1'))).toBe('admitted'); expect((await w.explain('update', 'r1')).record).toEqual({ recordId: 'r1', visible: true, decidedBy: 'rls' }); diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index f2bb6a500ed..74175b2cd25 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -428,8 +428,8 @@ export interface ExplainEngineDeps { * * [#22792] Asked first of the ASKER, for every record-grained explanation: * whether the explanation may say the record exists is decided by the read - * access of whoever asked for it, not by the explained principal's - * ({@link explainAccessForAsker}). One question, one notion of "can read": + * access of whoever asked for it, not by the explained principal's (the + * asker gate in the engine's record pass). One question, one notion of "can read": * the same caller-context by-id read, asked with the asker's context. */ recordAbsentToCaller?: (object: string, recordId: string, context: any) => Promise; @@ -1862,8 +1862,8 @@ async function applyRecordAttribution( /** * Explain `input.context`'s access, asked by that same principal: a - * self-explanation. The asker's read ({@link explainAccessForAsker}) is the - * explained principal's own. + * self-explanation. Whether the explanation may say a record exists is decided + * by the asker's read, and here the asker is the explained principal itself. */ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput): Promise { return explainAccessForAsker(deps, input, undefined); diff --git a/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts b/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts index 5279666eb5b..4876471bad6 100644 --- a/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts +++ b/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts @@ -315,9 +315,19 @@ for (const [driverName, makeDriver, available] of DRIVERS) { const object = await w.explain('read'); expect(object.allowed).toBe(true); expect(object.readFilter).toBeTruthy(); + const nonexistent = await w.explain('read', 'r_nonexistent'); for (const { id } of c.rows as Array<{ id: string }>) { const visible = c.visible.includes(id); - expect((await w.explain('read', id)).record, `read ${id}`).toEqual({ recordId: id, visible, decidedBy: 'rls' }); + const read = await w.explain('read', id); + // [#22792] A row the caller cannot read, explained to that caller, + // answers as the id no row carries does, layers included: the asker's + // read decides what the record story may disclose. + expect(read.record, `read ${id}`) + .toEqual(visible ? { recordId: id, visible, decidedBy: 'rls' } : { recordId: id, visible: false }); + if (!visible) { + expect({ ...read, record: { ...read.record, recordId: 'ID' } }, `read ${id}, whole`) + .toEqual({ ...nonexistent, record: { ...nonexistent.record, recordId: 'ID' } }); + } // [#21771] A row the caller cannot read answers, on the write side, // what a nonexistent id answers: the missing-record shape. expect((await w.explain('update', id)).record, `update ${id}`) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 5f9b6a2727f..e539130463d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5206,8 +5206,8 @@ export class SecurityPlugin implements Plugin { * [D12] — hold a delegated `adminScope` whose BU subtree covers the target * user (an access report is itself sensitive data, but an admin who can * already rewire a user's grants may read why they resolve as they do). - * The evaluation delegates to {@link explainAccessForAsker} with the SAME - * internals the middleware uses. + * The evaluation delegates to the explain engine with the SAME internals + * the middleware uses. * * [#22792] ...and with the CALLER as the asker. Whether the explanation may * say a record exists is decided by the caller's read of it, never by the diff --git a/packages/spec/src/security/explain.zod.ts b/packages/spec/src/security/explain.zod.ts index 5d08290b0a0..75d4cdf717b 100644 --- a/packages/spec/src/security/explain.zod.ts +++ b/packages/spec/src/security/explain.zod.ts @@ -277,9 +277,11 @@ export const ExplainRequestSchema = lazySchema(() => z.object({ * refused at validation (HTTP 400), never silently truncated. Empty arrays * are refused too — send at least one id or omit the field. * - **Missing records**: an id that does not resolve to a readable row under - * a system read (filtered, deleted, or never existed) gets - * `{ recordId, visible: false }` with `decidedBy` omitted — the same - * fail-closed answer the singular form gives for that id. + * a system read (filtered, deleted, or never existed), and an id the ASKER + * cannot read (the caller of explain, whose own read does not return the + * row, whichever principal it explains), gets `{ recordId, visible: false }` + * with `decidedBy` omitted — the same fail-closed answer the singular form + * gives for that id. * - **Mutually exclusive** with `recordId`: a request carrying both is * refused loudly rather than silently preferring either spelling. * - Batch responses carry the per-layer trace at OBJECT level only (no @@ -390,9 +392,16 @@ export const ExplainDecisionSchema = lazySchema(() => z.object({ * (the layer whose `record.outcome` excluded it, or the last one to admit it). * The per-layer `record` attributions above carry the full trace; this is the * summary a UI pins next to the record. + * + * An id the ASKER cannot read — the caller of explain, whose own read does + * not return the row, whichever principal it explains — answers as a missing + * record, alongside a filtered, deleted or never-existed id: `visible: false` + * with `decidedBy` omitted, and every layer's `record` attribution the one an + * id no row carries gets. Whether a row exists is never disclosed to an asker + * who cannot read it. */ record: ExplainRecordVerdictSchema.optional() - .describe('Row-level verdict for the specific record; set only for singular record-grained requests.'), + .describe('Row-level verdict for the specific record; set only for singular record-grained requests. An id the asker (the caller of explain, not the principal explained) cannot read answers as missing, like a filtered, deleted or never-existed id: visible false, decidedBy omitted.'), /** * [#8326] Batch record-grained verdicts — present only when the request * carried `recordIds`. `records[i]` answers `recordIds[i]` (same order, same From 9bae302d8d5cc3c5614bdcb369c11432f3d6c4e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:09:06 +0000 Subject: [PATCH 03/13] wip: discriminating pins for the write arm's read decider Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../src/explain-asker-gate.test.ts | 46 ++++++++++++++----- ...explain-controlled-by-parent-write.test.ts | 33 +++++++++++++ 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index dec9d377635..4c3f087e252 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -25,10 +25,9 @@ // The real `SecurityPlugin` over a real `ObjectQL` and SQL driver // (better-sqlite3, in memory), the platform's grant tables, and one // `public_read_write` board. The explained member reads only the board named -// `open`, through an app-authored READ row-level policy; nothing narrows the -// member's writes. So the member's update, delete or transfer of the hidden -// board is stopped by the read alone, and the write-operation story by itself -// would admit it: the read's decider is the only right one. +// `open`, through an app-authored READ row-level policy. So the member's +// update, delete or transfer of the hidden board stops at the read: the door +// answers what a nonexistent id answers before any write gate runs. // // Askers: the member itself; a `manage_users` administrator who reads every // board; a `manage_users` administrator held to the member's read policy; a @@ -38,7 +37,8 @@ // // The last block asks the engine directly over a deps bag, for the facts the // served rig cannot isolate: which contexts the read question is asked with, -// and how often. +// how often, and that a write's decider is its principal's READ even where +// the write's own composition would admit the row. import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; @@ -173,11 +173,13 @@ beforeAll(async () => { await plugin.init(ctx as never); await plugin.start(ctx as never); // The D12 boundary: this delegate's adminScope covers the member, and nobody - // else's does. Answered by a stub, as the file header says. - (plugin as unknown as { delegatedAdminGate: unknown }).delegatedAdminGate = { - scopesCoverUser: async (_sets: unknown, target: string, caller: { userId?: string }) => - caller?.userId === DELEGATE.userId && target === MEMBER, - }; + // else's does. Answered by a stub on the real gate's boundary question, as + // the file header says; the gate's write-path checks stay its own. + const gate = (plugin as unknown as { delegatedAdminGate: { scopesCoverUser: (...a: any[]) => Promise } }) + .delegatedAdminGate; + vi.spyOn(gate, 'scopesCoverUser').mockImplementation( + async (_sets: unknown, target: string, caller: { userId?: string }) => caller?.userId === DELEGATE.userId && target === MEMBER, + ); }); afterAll(async () => { @@ -304,7 +306,7 @@ describe('controls', () => { it(`${verb}: a row every asker reads gets one answer, whoever asks, and its record story is the stored row's`, async () => { const bySystem = await explain(SYSTEM, verb, OPEN); expect(bySystem.record).toMatchObject({ recordId: OPEN, visible: true }); - expect(layerRecord(bySystem, 'rls')?.outcome).toBe(verb === 'read' || verb === 'export' ? 'admitted' : 'not_evaluated'); + expect(layerRecord(bySystem, 'rls')).toMatchObject({ outcome: 'admitted', matchesRecord: true }); for (const asker of [ADMIN, BLIND_ADMIN, DELEGATE]) expect(await explain(asker, verb, OPEN)).toEqual(bySystem); const self = await explain(SELF, verb, OPEN); expect(self.record).toEqual(bySystem.record); @@ -317,7 +319,7 @@ describe('the read question, engine level: who it is asked of, and how often', ( const OBJECT = 'qa_ag_engine'; const SET = PermissionSetSchema.parse({ name: 'qa_ag_engine_writer', - objects: { [OBJECT]: { allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true } }, + objects: { [OBJECT]: { allowRead: true, allowEdit: true, allowDelete: true, allowTransfer: true, allowExport: true } }, }); const EXPLAINED = { userId: 'usr_ag_explained', positions: [] as string[], permissions: [] as string[] }; const ASKER = { userId: 'usr_ag_asker', positions: [] as string[], permissions: [] as string[] }; @@ -374,6 +376,26 @@ describe('the read question, engine level: who it is asked of, and how often', ( } }); + it('a write the principal cannot read is decided by the principal\'s READ, where the write\'s own composition admits the row', async () => { + // Business RLS that narrows the read and not the write: a row the write + // composition alone admits, and the read excludes. + const readOnlyRls = (question: ExplainEngineDeps['recordAbsentToCaller']): ExplainEngineDeps => ({ + ...deps(question), + computeLayeredRlsFilter: async (_s, _o, op) => ({ layer0: null, layer1: op === 'find' ? { name: 'open' } : null }), + }); + for (const verb of WRITE_VERBS) { + const input = { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }; + const writeAlone = await explainAccessForAsker(readOnlyRls(door()), input, ASKER); + expect(writeAlone.record, `${verb}, a principal who reads the row`).toMatchObject({ visible: true }); + const unreadable = await explainAccessForAsker(readOnlyRls(door(EXPLAINED)), input, ASKER); + expect(unreadable.record, `${verb}, a principal who cannot read the row`).toEqual({ recordId: STORED.id, visible: false, decidedBy: 'rls' }); + const rls = layerRecord(unreadable, 'rls'); + expect(rls?.outcome, verb).toBe('excluded'); + expect(rls?.rowFilter, verb).toEqual({ name: 'open' }); + expect(rls?.detail, verb).toMatch(/^The principal cannot read this record, so its by-id \w+ answers 404 RECORD_NOT_FOUND/); + } + }); + it('a system asker reads every row: it is never asked, and gets the explained principal\'s story', async () => { for (const verb of VERBS) { const question = door(SYSTEM_CONTEXT); diff --git a/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts b/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts index 829f5ef816d..122b04319db 100644 --- a/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts +++ b/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts @@ -403,3 +403,36 @@ describe('[ADR-0055] a read of a controlled_by_parent record takes the read door expect(askedOf(readDoor)).not.toContain(EXPLAINED); }); }); + +describe('[#22792] a by-id write of a record the principal cannot read, explained to an asker who reads it', () => { + /** Every HTTP status, with its code, that any layer's prose names. */ + const statusesNamed = (d: ExplainDecision): string[] => { + const prose = d.layers.flatMap((l) => [l.detail, l.record?.detail ?? '']).join('\n'); + return [...new Set([...prose.matchAll(/\b([1-5]\d\d) ([A-Z][A-Z_]+)\b/g)].map((m) => `${m[1]} ${m[2]}`))]; + }; + + for (const verb of MASTER_CHECKED) { + it(`${verb}: decided by the principal's READ on the master leg; the write check is never reached, and no layer names its 403`, async () => { + const master = answering({ outcome: 'deny', leg: 'record_sharing' }); + const readDoor = readDoorAnswering(true); + const d = await explainToAsker(deps({ master, readDoor }), verb); + expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' }); + const sharing = sharingRecordOf(d); + expect(sharing?.outcome).toBe('excluded'); + expect(sharing?.detail).toContain(`The principal cannot read this record, so its by-id ${verb} answers 404 RECORD_NOT_FOUND`); + expect(sharing?.detail).toContain('the read door, asked by this principal, does not return the record'); + expect(statusesNamed(d)).toEqual(['404 RECORD_NOT_FOUND']); + // The door asks the read question (step 2.7) before the master check (step 2.8). + expect(master).not.toHaveBeenCalled(); + }); + + it(`${verb}: control, a record the principal reads keeps the write check's refusal and its 403`, async () => { + const master = answering({ outcome: 'deny', leg: 'record_sharing' }); + const d = await explainToAsker(deps({ master, readDoor: readDoorAnswering(false) }), verb); + expect(d.record).toEqual({ recordId: 'r1', visible: false, decidedBy: 'sharing' }); + expect(sharingRecordOf(d)?.detail).toContain(`refuses this ${verb} on its 'record_sharing' leg`); + expect(statusesNamed(d)).toEqual(['403 PERMISSION_DENIED']); + expect(master).toHaveBeenCalledTimes(1); + }); + } +}); From 4ae3a27637e73ad5bc84bc0296dea42e13b6e55e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:10:36 +0000 Subject: [PATCH 04/13] wip: the rig's write composition admits every board Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../src/explain-asker-gate.test.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index 4c3f087e252..c4078a9ea73 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -25,9 +25,11 @@ // The real `SecurityPlugin` over a real `ObjectQL` and SQL driver // (better-sqlite3, in memory), the platform's grant tables, and one // `public_read_write` board. The explained member reads only the board named -// `open`, through an app-authored READ row-level policy. So the member's -// update, delete or transfer of the hidden board stops at the read: the door -// answers what a nonexistent id answers before any write gate runs. +// `open`, through an app-authored READ row-level policy, and its write-class +// policies admit every board. So the member's update, delete or transfer of +// the hidden board stops at the read alone (the door answers what a +// nonexistent id answers), and the write composition by itself would admit +// it: the read's decider is the only right one. // // Askers: the member itself; a `manage_users` administrator who reads every // board; a `manage_users` administrator held to the member's read policy; a @@ -63,13 +65,21 @@ const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_defa const BOARD = 'qa_ag_board'; const MEMBER = 'usr_ag_member'; -/** The member's grant: every verb on the board, and a READ policy that admits only the board named `open`. */ +/** + * The member's grant: every verb on the board; a READ policy that admits only + * the board named `open`; and write-class policies that admit every board, so + * the write composition alone narrows nothing the read does. + */ const MEMBER_SET = PermissionSetSchema.parse({ name: 'qa_ag_member', objects: { [BOARD]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowTransfer: true, allowExport: true }, }, - rowLevelSecurity: [{ name: 'qa_ag_reads_open_only', object: BOARD, operation: 'select', using: "name == 'open'" }], + rowLevelSecurity: [ + { name: 'qa_ag_reads_open_only', object: BOARD, operation: 'select', using: "name == 'open'" }, + { name: 'qa_ag_updates_any', object: BOARD, operation: 'update', using: "name != 'nothing'" }, + { name: 'qa_ag_deletes_any', object: BOARD, operation: 'delete', using: "name != 'nothing'" }, + ], }); /** A user administrator who reads every board. */ const USER_ADMIN_SET = PermissionSetSchema.parse({ @@ -284,6 +294,8 @@ describe('an asker who can read the row gets the explained principal\'s full rec const rls = layerRecord(explained, 'rls'); expect(rls?.outcome).toBe('excluded'); expect(rls?.matchesRecord).toBe(false); + // The READ policy's predicate, never the write composition's (which admits every board). + expect(rls?.rowFilter).toEqual({ name: 'open' }); expect(rls?.detail).toContain(`The principal cannot read this record, so its by-id ${verb} answers 404 RECORD_NOT_FOUND`); expect(rls?.detail).toContain('The record does not satisfy the business row-level predicate.'); for (const layer of ['tenant_isolation', 'owd_baseline', 'depth', 'sharing', 'vama_bypass', 'rls']) { From c92fbf10071e803f5b075467b009c595bb8c28f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:12:23 +0000 Subject: [PATCH 05/13] wip: changesets; dogfood read row asks who is asking; batch case Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .changeset/22792-explain-asker-gate.md | 14 +++++ .changeset/22792-explain-read-master-leg.md | 2 +- .../cbp-explain-master-write.dogfood.test.ts | 58 ++++++++++++++++--- 3 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 .changeset/22792-explain-asker-gate.md diff --git a/.changeset/22792-explain-asker-gate.md b/.changeset/22792-explain-asker-gate.md new file mode 100644 index 00000000000..75087644fc6 --- /dev/null +++ b/.changeset/22792-explain-asker-gate.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-security': patch +'@objectstack/spec': patch +--- + +`security/explain` decides whether a record-grained explanation may say a record exists by the read access of whoever asked for it, not by the explained principal's. + +Clause-②: no + +- **An asker who cannot read the record** (its own by-id read does not return the row) gets, for every operation, exactly the answer an id no row carries gets: `record` is `{ recordId, visible: false }` with no `decidedBy`, and every layer's per-record attribution is the missing record's. This holds whoever the asker explains: itself, or another user it may explain (`manage_users`, or a delegated administrator's scope). Before, a principal explaining itself a `read` of a row it cannot read was told the row's verdict and why; the by-id write verbs already answered it as missing. +- **An asker who can read the record** gets the explained principal's full record story. For a `read`, that is the principal's own verdict, decider included, so the `controlled_by_parent` read verdict on the master leg (not visible, decided by the `sharing` layer) is what such an asker is told. For a by-id `update`, `delete` or `transfer` of a row the principal cannot read, the verdict stays `visible: false`, the answer that principal's write gets, and now names the layer that decided the principal's read; each row-scoped layer's `record` carries that read's attribution, prefaced with the write's `404 RECORD_NOT_FOUND`. Before, such an asker was told the missing record's answer. +- A system asker reads every record. The batch form (`recordIds`) answers each id as the singular form does. +- `@objectstack/spec`: the `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say that an id the asker cannot read answers as missing. Description text only; no schema shape changes. +- Nothing you author changes, and no exported type or signature moves. Object-level explanations (no `recordId`) are unchanged. diff --git a/.changeset/22792-explain-read-master-leg.md b/.changeset/22792-explain-read-master-leg.md index bd24f936118..3c34145aa89 100644 --- a/.changeset/22792-explain-read-master-leg.md +++ b/.changeset/22792-explain-read-master-leg.md @@ -4,5 +4,5 @@ `security/explain`'s record-grained `read` verdict for a `controlled_by_parent` record now equals the read door's answer on the master leg (ADR-0055). -- **What changed.** The read door scopes such a record by its master as well as by its own row-level security. explain's read branch modelled the record's own row-level security only. It now asks the read door's own by-id read, with the context it is explaining, wherever every other leg of the read's row story admits the record. A record the door does not return is reported not visible, decided by the `sharing` layer. A record the door returns is reported exactly as before. A rejection of that read is reported fail-closed, as a dependency that throws already is. An `export` explanation, which streams the same find, answers the same way. +- **What changed.** The read door scopes such a record by its master as well as by its own row-level security. explain's read branch modelled the record's own row-level security only. It now asks the read door's own by-id read, with the context it is explaining, wherever every other leg of the read's row story admits the record. A record the door does not return is reported not visible, decided by the `sharing` layer, to an asker who can read that record; a principal explaining itself a record it cannot read is told what an id no row carries is told. A record the door returns is reported exactly as before. A rejection of that read is reported fail-closed, as a dependency that throws already is. An `export` explanation, which streams the same find, answers the same way. - **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. Every write verb. The answer for an id no row carries. The `ExplainEngineDeps` type: the `recordAbsentToCaller` dependency keeps its signature and is now asked for such a read too. diff --git a/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts b/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts index 035b387ac3a..872811e30d9 100644 --- a/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts +++ b/packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts @@ -45,6 +45,11 @@ // (ADR-0055), so the read row sits on `cpg_vault_item`, whose master is // private: explain's read verdict is pinned beside that door for a principal // who cannot read the master, for one who can, and for an id no row carries. +// Whether explain may say such a record exists is decided by the ASKER's own +// read (ruling A of the explain-asker decision): the principal explaining +// itself a record it cannot read is told what an id no row carries is told, +// and an administrator who reads it is told the principal's verdict, decider +// included. The batch form answers each id as the singular form does. // // ## A transfer is judged by the update policies its door is judged by // @@ -235,16 +240,24 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do return { record: decision.record, sharing: sharing?.record as { outcome?: string; detail?: string } | undefined }; }; - /** explain for any verb, as the REST route answers it; the sharing layer's record attribution beside the record verdict. */ - const explainAs = async (token: string, operation: ExplainOperation, object: string, recordId: string) => { - const res = await stack.apiAs(token, 'POST', '/security/explain', { object, operation, recordId }); + /** The whole decision, as the REST route answers it; `userId` explains another user, as an administrator does. */ + const explainDecision = async (token: string, body: Record) => { + const res = await stack.apiAs(token, 'POST', '/security/explain', body); const text = await res.text(); - expect(res.status, `explain ${operation} ${object}/${recordId}: ${text}`).toBe(200); - const decision = JSON.parse(text) as any; + expect(res.status, `explain ${JSON.stringify(body)}: ${text}`).toBe(200); + return JSON.parse(text) as any; + }; + + /** explain for any verb, as the REST route answers it; the sharing layer's record attribution beside the record verdict. */ + const explainAs = async (token: string, operation: ExplainOperation, object: string, recordId: string, userId?: string) => { + const decision = await explainDecision(token, { object, operation, recordId, ...(userId ? { userId } : {}) }); const sharing = (decision.layers ?? []).find((l: any) => l.layer === 'sharing'); return { record: decision.record, sharing: sharing?.record as { outcome?: string; detail?: string } | undefined }; }; + /** The decision with its record id set aside: the one field two different ids must differ in. */ + const withoutRecordId = (d: any) => ({ ...d, record: d.record ? { ...d.record, recordId: 'ID' } : d.record }); + const patch = async (token: string, object: string, id: string, name: string) => answer(await stack.apiAs(token, 'PATCH', `/data/${object}/${id}`, { name })); @@ -397,18 +410,47 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do } for (const verb of verbsOf('by_id_read_door')) { - it(`${verb}: a record under a master the principal cannot read is explained not visible, as its door answers`, async () => { + it(`${verb}: a record under a master the principal cannot read, explained to that principal, is the id no row carries`, async () => { const { object, door } = readDoorOf(verb); const id = underUnreadable[verb]!; - const explained = await explainAs(stewardTok, verb, object, id); + const explained = await explainDecision(stewardTok, { object, operation: verb, recordId: id }); + const noRow = await explainDecision(stewardTok, { object, operation: verb, recordId: `${verb}-no-such-record` }); const answered = await answer(await door(stewardTok, id)); expect(answered.status, `${verb} door, the master's non-reader: ${answered.body}`).toBe(404); expect(answered.code).toBe('RECORD_NOT_FOUND'); - expect(explained.record, `explain ${verb}`).toEqual({ recordId: id, visible: false, decidedBy: 'sharing' }); + // The asker cannot read it: not visible, no decider, every layer the missing record's. + expect(explained.record, `explain ${verb}`).toEqual({ recordId: id, visible: false }); + expect(explained.layers.find((l: any) => l.layer === 'sharing')?.record?.outcome).toBe('not_evaluated'); + expect(withoutRecordId(explained), `explain ${verb}, whole`).toEqual(withoutRecordId(noRow)); + }); + + it(`${verb}: the same record, explained by an administrator who reads it, is the principal's verdict, decider included`, async () => { + const { object, door } = readDoorOf(verb); + const id = underUnreadable[verb]!; + const adminRead = await answer(await door(adminTok, id)); + expect(adminRead.status, `${verb} door, the administrator: ${adminRead.body}`).toBe(200); + + const explained = await explainAs(adminTok, verb, object, id, stewardId); + expect(explained.record, `explain ${verb} for the steward`).toEqual({ recordId: id, visible: false, decidedBy: 'sharing' }); expect(explained.sharing?.outcome).toBe('excluded'); }); + it(`${verb}: the batch form answers each id as the singular form does, whoever asks`, async () => { + const { object } = readDoorOf(verb); + const ids = [underUnreadable[verb]!, underReadable[verb]!, `${verb}-no-such-batch-record`]; + for (const [who, token, userId] of [['the steward', stewardTok, undefined], ['an administrator', adminTok, stewardId]] as const) { + const batch = await explainDecision(token, { object, operation: verb, recordIds: ids, ...(userId ? { userId } : {}) }); + const singular: unknown[] = []; + for (const id of ids) singular.push((await explainAs(token, verb, object, id, userId)).record); + expect(batch.records, `${who}, ${verb} batch`).toEqual(singular); + } + const toSteward = await explainDecision(stewardTok, { object, operation: verb, recordIds: ids }); + expect(toSteward.records[0]).toEqual({ recordId: ids[0], visible: false }); + const toAdmin = await explainDecision(adminTok, { object, operation: verb, recordIds: ids, userId: stewardId }); + expect(toAdmin.records[0]).toEqual({ recordId: ids[0], visible: false, decidedBy: 'sharing' }); + }); + it(`${verb}: control, a record under a master the principal reads is explained visible and its door answers 200`, async () => { const { object, door } = readDoorOf(verb); const id = underReadable[verb]!; From 46dcaa54a49c66d1d8f374e0518d9615baab0ed3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:32:03 +0000 Subject: [PATCH 06/13] wip: regenerate the explain reference page (gen:docs via check:generated --fix) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- content/docs/references/security/explain.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx index f88bd525934..6813de1a7de 100644 --- a/content/docs/references/security/explain.mdx +++ b/content/docs/references/security/explain.mdx @@ -108,7 +108,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object | **principal** | `{ userId: string \| null; positions?: string[]; permissionSets?: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` | ✅ | | | **layers** | `{ layer: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …>; kernelTier?: Enum<'layer_0_tenant' \| 'layer_1_business'>; verdict: Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>; detail: string; … }[]` | ✅ | | | **readFilter** | `any` | optional | The composed row filter the caller would be served with — the machine artifact behind the prose (null = unrestricted). Two shapes mean zero rows — `{ id: "__deny_all__" }` and the fail-closed RLS denial (`__rls_deny__` plus a colon and a UUID-shaped suffix, which can also ride inside an $and composite) — so a consumer pattern-matching this payload alone must match both; the decision itself is allowed plus the rls layer verdict. | -| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }` | optional | Row-level verdict for the specific record; set only for singular record-grained requests. | +| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }` | optional | Row-level verdict for the specific record; set only for singular record-grained requests. An id the asker (the caller of explain, not the principal explained) cannot read answers as missing, like a filtered, deleted or never-existed id: visible false, decidedBy omitted. | | **records** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }[]` | optional | Per-record verdicts for a batch request — records[i] answers recordIds[i]; set only when the request carried recordIds. | ### Nested Shape: `ExplainDecision.principal` From 4335f949f2e0d6f18734058ec5a5a80df42a2906 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:14:38 +0000 Subject: [PATCH 07/13] wip: leave item 1's pending release note as it is; this PR's note carries the qualification Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .changeset/22792-explain-read-master-leg.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22792-explain-read-master-leg.md b/.changeset/22792-explain-read-master-leg.md index 3c34145aa89..bd24f936118 100644 --- a/.changeset/22792-explain-read-master-leg.md +++ b/.changeset/22792-explain-read-master-leg.md @@ -4,5 +4,5 @@ `security/explain`'s record-grained `read` verdict for a `controlled_by_parent` record now equals the read door's answer on the master leg (ADR-0055). -- **What changed.** The read door scopes such a record by its master as well as by its own row-level security. explain's read branch modelled the record's own row-level security only. It now asks the read door's own by-id read, with the context it is explaining, wherever every other leg of the read's row story admits the record. A record the door does not return is reported not visible, decided by the `sharing` layer, to an asker who can read that record; a principal explaining itself a record it cannot read is told what an id no row carries is told. A record the door returns is reported exactly as before. A rejection of that read is reported fail-closed, as a dependency that throws already is. An `export` explanation, which streams the same find, answers the same way. +- **What changed.** The read door scopes such a record by its master as well as by its own row-level security. explain's read branch modelled the record's own row-level security only. It now asks the read door's own by-id read, with the context it is explaining, wherever every other leg of the read's row story admits the record. A record the door does not return is reported not visible, decided by the `sharing` layer. A record the door returns is reported exactly as before. A rejection of that read is reported fail-closed, as a dependency that throws already is. An `export` explanation, which streams the same find, answers the same way. - **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. Every write verb. The answer for an id no row carries. The `ExplainEngineDeps` type: the `recordAbsentToCaller` dependency keeps its signature and is now asked for such a read too. From 842f1ca0372106f47939c113d473c06655c378e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 13:17:32 +0000 Subject: [PATCH 08/13] fix(plugin-security): no system exemption beside the read door's own answer for the asker Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../plugin-security/src/explain-asker-gate.test.ts | 9 +++++---- packages/plugins/plugin-security/src/explain-engine.ts | 5 ++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index c4078a9ea73..b8f338d28e6 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -18,7 +18,8 @@ // carrying that read's attribution. // // "Can read" is the read door's own by-id read asked with the asker's context -// (the question the write doors ask). A system asker reads every row. +// (the question the write doors ask). A system asker is asked too, and the +// read door returns it every row (the system asker's cells below). // // ## The rig // @@ -408,11 +409,11 @@ describe('the read question, engine level: who it is asked of, and how often', ( } }); - it('a system asker reads every row: it is never asked, and gets the explained principal\'s story', async () => { + it('a system asker is asked like any other asker: there is no exemption beside the read door\'s own answer', async () => { for (const verb of VERBS) { - const question = door(SYSTEM_CONTEXT); + const question = door(); const bySystem = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, SYSTEM_CONTEXT); - expect(question.mock.calls.map((c) => c[2]), verb).not.toContain(SYSTEM_CONTEXT); + expect(question.mock.calls[0][2], verb).toBe(SYSTEM_CONTEXT); expect(bySystem.record, verb).toMatchObject({ recordId: STORED.id, visible: true }); } }); diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 74175b2cd25..ae16d15610f 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -2395,11 +2395,10 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, askerCo // answer an id no row carries gets: the record verdict and every layer's // `record`, all from the one missing-record path below. The question is // the read door's own (`recordAbsentToCaller`), asked with the asker's - // context, so there is one notion of "can read". A system asker reads - // every row, so it is never asked. + // context, so there is one notion of "can read" and no exemption beside + // it: a system asker is asked too, and the door returns it every row. const asker = askerContext ?? context; const askerCannotRead = - asker?.isSystem !== true && readQuestion !== undefined && (await readQuestion(object, recordId, asker)); // [#21771] Ruling A on the write doors: a row the principal cannot READ is From 349d8fcf09c7a8b0d7a9dd64fa3bdeb931308e94 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:05:34 +0000 Subject: [PATCH 09/13] wip(plugin-security): a third-party asker's refused read counts as cannot read (ruling C) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../src/explain-asker-gate.test.ts | 28 +++-- ...explain-controlled-by-parent-write.test.ts | 6 +- .../plugin-security/src/explain-engine.ts | 114 ++++++++++++------ .../plugin-security/src/security-plugin.ts | 31 ++++- 4 files changed, 132 insertions(+), 47 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index b8f338d28e6..8642f495a71 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -58,7 +58,7 @@ import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.j import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { PermissionEvaluator } from './permission-evaluator.js'; -import { explainAccess, explainAccessForAsker, type ExplainEngineDeps } from './explain-engine.js'; +import { explainAccess, explainAccessForAsker, type ExplainEngineDeps, type ExplainInput } from './explain-engine.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -359,6 +359,16 @@ describe('the read question, engine level: who it is asked of, and how often', ( }); /** The read door: `absent` lists the contexts it does not return the record to. */ const door = (...absent: unknown[]) => vi.fn(async (_o: string, _id: string, c: unknown) => absent.includes(c)); + /** + * The internal entry, asked by `askerContext`, whose own read is the deps + * bag's door asked with its context: a door that never refuses, so the + * third-party classification (ruling C) answers what the door answers. + */ + const explainFor = (d: ExplainEngineDeps, input: ExplainInput, askerContext: unknown) => + explainAccessForAsker(d, input, { + context: askerContext, + cannotRead: (o, id) => (d.recordAbsentToCaller ? d.recordAbsentToCaller(o, id, askerContext) : Promise.resolve(false)), + }); it('a self-explanation asks the explained context, once, whatever the verb', async () => { for (const verb of VERBS) { @@ -372,7 +382,7 @@ describe('the read question, engine level: who it is asked of, and how often', ( it('another asker is asked with its own context, and the explained principal only for a by-id write', async () => { for (const verb of VERBS) { const question = door(); - await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER); + await explainFor(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER); const asked = question.mock.calls.map((c) => c[2]); expect(asked[0], `${verb}: the asker first`).toBe(ASKER); expect(asked.filter((c) => c === ASKER), `${verb}: the asker, once`).toHaveLength(1); @@ -383,7 +393,7 @@ describe('the read question, engine level: who it is asked of, and how often', ( it('a write of a row the explained principal cannot read asks each context once, its read pass included', async () => { for (const verb of WRITE_VERBS) { const question = door(EXPLAINED); - const d = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER); + const d = await explainFor(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER); expect(d.record, verb).toMatchObject({ recordId: STORED.id, visible: false }); expect(question.mock.calls.map((c) => c[2]), verb).toEqual([ASKER, EXPLAINED]); } @@ -398,9 +408,9 @@ describe('the read question, engine level: who it is asked of, and how often', ( }); for (const verb of WRITE_VERBS) { const input = { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }; - const writeAlone = await explainAccessForAsker(readOnlyRls(door()), input, ASKER); + const writeAlone = await explainFor(readOnlyRls(door()), input, ASKER); expect(writeAlone.record, `${verb}, a principal who reads the row`).toMatchObject({ visible: true }); - const unreadable = await explainAccessForAsker(readOnlyRls(door(EXPLAINED)), input, ASKER); + const unreadable = await explainFor(readOnlyRls(door(EXPLAINED)), input, ASKER); expect(unreadable.record, `${verb}, a principal who cannot read the row`).toEqual({ recordId: STORED.id, visible: false, decidedBy: 'rls' }); const rls = layerRecord(unreadable, 'rls'); expect(rls?.outcome, verb).toBe('excluded'); @@ -412,7 +422,7 @@ describe('the read question, engine level: who it is asked of, and how often', ( it('a system asker is asked like any other asker: there is no exemption beside the read door\'s own answer', async () => { for (const verb of VERBS) { const question = door(); - const bySystem = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, SYSTEM_CONTEXT); + const bySystem = await explainFor(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, SYSTEM_CONTEXT); expect(question.mock.calls[0][2], verb).toBe(SYSTEM_CONTEXT); expect(bySystem.record, verb).toMatchObject({ recordId: STORED.id, visible: true }); } @@ -423,7 +433,7 @@ describe('the read question, engine level: who it is asked of, and how often', ( for (const verb of VERBS) { const question = vi.fn(async () => { throw fault; }); await expect( - explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER), + explainFor(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER), verb, ).rejects.toBe(fault); } @@ -432,8 +442,8 @@ describe('the read question, engine level: who it is asked of, and how often', ( it('an asker the door does not return the row to gets the stored row\'s explanation as the nonexistent id\'s', async () => { for (const verb of VERBS) { const question = door(ASKER); - const stored = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER); - const missing = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: 'r_missing' }, ASKER); + const stored = await explainFor(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, ASKER); + const missing = await explainFor(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: 'r_missing' }, ASKER); expect(stored.record, verb).toEqual({ recordId: STORED.id, visible: false }); expect(withoutRecordId(stored), verb).toEqual(withoutRecordId(missing)); } diff --git a/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts b/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts index 122b04319db..2b8c5e1512d 100644 --- a/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts +++ b/packages/plugins/plugin-security/src/explain-controlled-by-parent-write.test.ts @@ -306,7 +306,11 @@ const askedOf = (readDoor: ReadDoor) => (readDoor as ReturnType).m /** `null` asks the object-level question (no `recordId`). */ const explainToAsker = (d: ExplainEngineDeps, operation: ExplainOperation, recordId: string | null = 'r1') => - explainAccessForAsker(d, { object: OBJECT, operation, context: EXPLAINED, ...(recordId !== null ? { recordId } : {}) }, ASKER); + explainAccessForAsker(d, { object: OBJECT, operation, context: EXPLAINED, ...(recordId !== null ? { recordId } : {}) }, { + context: ASKER, + // The asker's own read is the same door, asked with its context; the door never refuses here. + cannotRead: (o, id) => (d.recordAbsentToCaller ? d.recordAbsentToCaller(o, id, ASKER) : Promise.resolve(false)), + }); describe('[ADR-0055] a read of a controlled_by_parent record takes the read door\'s answer on the master leg', () => { for (const verb of READ_DOOR_ASKED) { diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index ae16d15610f..c22a124bc6d 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -426,11 +426,14 @@ export interface ExplainEngineDeps { * dependency here answers, so the door's own answer is what the read verdict * takes ({@link applyRecordAttribution}). * - * [#22792] Asked first of the ASKER, for every record-grained explanation: - * whether the explanation may say the record exists is decided by the read - * access of whoever asked for it, not by the explained principal's (the - * asker gate in the engine's record pass). One question, one notion of "can read": - * the same caller-context by-id read, asked with the asker's context. + * [#22792] Asked first of the ASKER, for every record-grained + * self-explanation: whether the explanation may say the record exists is + * decided by the read access of whoever asked for it, not by the explained + * principal's (the asker gate in the engine's record pass). [#22851] An + * asker explaining ANOTHER principal is asked the same caller-context by-id + * read through the security service's own binding, where a declared refusal + * counts as "cannot read" too (ruling C); this dependency keeps the write + * path's notion, which a principal explaining itself is asked. */ recordAbsentToCaller?: (object: string, recordId: string, context: any) => Promise; } @@ -1870,10 +1873,29 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput } /** - * [#22792] Explain `input.context`'s access, asked by `askerContext`: the - * principal that requested the explanation, which is not the principal being - * explained when an administrator explains another user (ADR-0090 D6/D12). - * `undefined` means the explained principal asks for itself. + * [#22792] The principal that asked for an explanation. Module-internal, as + * {@link explainAccessForAsker} is. + */ +export interface ExplainAsker { + /** The asking principal's context; `undefined` or the explained context itself is a self-explanation. */ + context: any; + /** + * [#22851, ruling C] The asker's own caller-context by-id read, classified + * for an asker who is NOT the explained principal: `true` when the read + * leaves the asker without the row, by returning none OR by refusing (a + * declared 4xx); a store fault rejects. Asked only for such an asker. A + * principal explaining itself is asked the write path's question + * (`recordAbsentToCaller`: absent means no row) instead, so a + * self-explanation shows what that principal's own doors do. + */ + cannotRead: (object: string, recordId: string) => Promise; +} + +/** + * [#22792] Explain `input.context`'s access, asked by `asker`: the principal + * that requested the explanation, which is not the principal being explained + * when an administrator explains another user (ADR-0090 D6/D12). `undefined` + * means the explained principal asks for itself. * * Ruling A of the explain-asker decision: whether an explanation may disclose * that a record exists is decided by the ASKER's read access, never by the @@ -1882,6 +1904,11 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput * every layer's per-record attribution. An asker who can read it learns * nothing new about its existence, and gets the full record story. * + * [#22851] Ruling C settles what "cannot read" means for each asker, on the + * one caller-context by-id read: for an asker explaining ANOTHER principal, + * no row or a declared refusal (`asker.cannotRead`); for a principal + * explaining itself, no row (`recordAbsentToCaller`, the write path's notion). + * * Module-internal on purpose: the package publishes {@link explainAccess}, * whose signature and self-explanation answer this leaves as they are. The * security service's caller-authorized explain (`explainAccessForCaller`) @@ -1890,13 +1917,30 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput export async function explainAccessForAsker( deps: ExplainEngineDeps, input: ExplainInput, - askerContext: any, + asker: ExplainAsker | undefined, ): Promise { // One explanation asks the read question of at most two principals (the // asker and the explained), from up to three places (the asker gate, the // by-id write guard, the master leg of a read), and the principal's READ // pass below asks it again: each (principal, record) pair is read once. - return explainOnce({ ...deps, recordAbsentToCaller: askedOnce(deps.recordAbsentToCaller) }, input, askerContext); + const askerOnce: ExplainAsker | undefined = asker && { context: asker.context, cannotRead: oncePerRecord(asker.cannotRead) }; + return explainOnce({ ...deps, recordAbsentToCaller: askedOnce(deps.recordAbsentToCaller) }, input, askerOnce); +} + +/** One answer per (object, record) for the length of one explanation; a rejection stays the rejection it was. */ +function oncePerRecord( + question: (object: string, recordId: string) => Promise, +): (object: string, recordId: string) => Promise { + const asked = new Map>(); + return (object, recordId) => { + const key = JSON.stringify([object, recordId]); + let answer = asked.get(key); + if (!answer) { + answer = question(object, recordId); + asked.set(key, answer); + } + return answer; + }; } /** @@ -1908,20 +1952,14 @@ function askedOnce( question: ExplainEngineDeps['recordAbsentToCaller'], ): ExplainEngineDeps['recordAbsentToCaller'] { if (question === undefined) return undefined; - const asked = new Map>>(); + const byContext = new Map Promise>(); return (object, recordId, context) => { - let byRecord = asked.get(context); - if (!byRecord) { - byRecord = new Map(); - asked.set(context, byRecord); - } - const key = JSON.stringify([object, recordId]); - let answer = byRecord.get(key); - if (!answer) { - answer = question(object, recordId, context); - byRecord.set(key, answer); + let ask = byContext.get(context); + if (!ask) { + ask = oncePerRecord((o, r) => question(o, r, context)); + byContext.set(context, ask); } - return answer; + return ask(object, recordId); }; } @@ -1952,7 +1990,7 @@ function attributeThePrincipalsRead(layers: ExplainLayer[], read: ExplainDecisio } } -async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, askerContext: any): Promise { +async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: ExplainAsker | undefined): Promise { const { object, operation, context } = input; const engineOp = EXPLAIN_TO_ENGINE_OP[operation]; // [#3544] The gate operation (`engineOp`) and the data operation may differ — @@ -2393,14 +2431,22 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, askerCo // whether this explanation may say the record exists. An asker the read // door does not return the record to gets, for every operation, the whole // answer an id no row carries gets: the record verdict and every layer's - // `record`, all from the one missing-record path below. The question is - // the read door's own (`recordAbsentToCaller`), asked with the asker's - // context, so there is one notion of "can read" and no exemption beside - // it: a system asker is asked too, and the door returns it every row. - const asker = askerContext ?? context; - const askerCannotRead = - readQuestion !== undefined && - (await readQuestion(object, recordId, asker)); + // `record`, all from the one missing-record path below. The read is the + // read door's own caller-context by-id read, asked with the asker's + // context, and no exemption sits beside it: a system asker is asked too, + // and the door returns it every row. + // + // [#22851] Ruling C: what "cannot read" means differs by asker, on that one + // read. A principal explaining itself is asked the write path's question + // (`recordAbsentToCaller`: absent means no row), so its explanation shows + // what its own doors do; an asker explaining ANOTHER principal is asked + // `asker.cannotRead`, where a declared refusal of its read counts as + // "cannot read" too. A store fault fails the explanation either way. + const askerContext = asker?.context ?? context; + const askerIsPrincipal = askerContext === context; + const askerCannotRead = askerIsPrincipal + ? readQuestion !== undefined && (await readQuestion(object, recordId, context)) + : await asker!.cannotRead(object, recordId); // [#21771] Ruling A on the write doors: a row the principal cannot READ is // a row that does not exist, so an update or delete of it answers what a // nonexistent id answers, wherever enforcement reaches that question: past @@ -2436,7 +2482,7 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, askerCo // view bypass. const principalCannotRead = !askerCannotRead && - asker !== context && + !askerIsPrincipal && (rlsOp === 'update' || rlsOp === 'delete') && !capsDeny && crudAllowed && @@ -2451,7 +2497,7 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, askerCo recordVerdict = out.record; posture = out.posture; if (principalCannotRead) { - const read = await explainOnce(deps, { object, operation: 'read', context, recordId }, askerContext); + const read = await explainOnce(deps, { object, operation: 'read', context, recordId }, asker); attributeThePrincipalsRead(layers, read, operation); // A read story that reaches no refusal names no decider: the door's // answer stands, `visible: false`, and no layer is credited with it. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 49537b53b44..481bbf13473 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -692,6 +692,23 @@ async function absentUnderCallerRead(read: () => Promise): Promise Promise): Promise { + const answer = await readUnlessRefused(read); + return answer.refused || answer.value == null; +} + /** * [#21771 / #21829] The one classification of a caller-context read that the * write doors ask the read door's question with — the read's answer, or @@ -5105,7 +5122,10 @@ export class SecurityPlugin implements Plugin { * [#22792] ...and with the CALLER as the asker. Whether the explanation may * say a record exists is decided by the caller's read of it, never by the * explained user's: a caller the read door does not return the record to is - * answered exactly as for an id no row carries, whoever it explains. + * answered exactly as for an id no row carries. [#22851, ruling C] A caller + * explaining ANOTHER user whose read of the record is refused (a declared + * 4xx) is answered so too; a caller explaining itself keeps the write path's + * notion (no row), so its explanation shows what its own doors do. */ async explainAccessForCaller( request: { object: string; operation: string; userId?: string; recordId?: string }, @@ -5320,8 +5340,13 @@ export class SecurityPlugin implements Plugin { { object, operation, context: targetContext, recordId }, // The asker: the caller, whose read of the record decides whether this // explanation discloses that the record exists. On a self-explanation it - // is the explained context itself. - callerContext, + // is the explained context itself, and the engine asks it the write + // path's question; explaining another user, the engine asks `cannotRead`, + // the same read with a refusal counted as "cannot read" (ruling C). + { + context: callerContext, + cannotRead: (o: string, rid: string) => unreadableUnderCallerRead(() => this.readRowById(o, rid, callerContext)), + }, ); } From 6cc56c243af53054f107e31053af68a96fc75b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:09:10 +0000 Subject: [PATCH 10/13] wip: ruling C pins (refused third-party askers; refused self-explanation control) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../src/explain-asker-gate.test.ts | 165 +++++++++++++++++- 1 file changed, 159 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index 8642f495a71..58292fae7aa 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -21,6 +21,13 @@ // (the question the write doors ask). A system asker is asked too, and the // read door returns it every row (the system asker's cells below). // +// Ruling C (#22851) settles how a REFUSED read (a declared 4xx, such as no +// read grant on the object) is classified, on that same read: for an asker +// explaining another user it is "cannot read", the nonexistent id's answer; +// for a principal explaining itself it is not, because that principal's own +// by-id write doors do not treat it as absence, so its explanation shows what +// those doors do (the edit-only principal's cells). +// // ## The rig // // The real `SecurityPlugin` over a real `ObjectQL` and SQL driver @@ -98,6 +105,23 @@ const BLIND_ADMIN = { userId: 'usr_ag_blind', positions: [] as string[], permiss /** A delegated administrator (D12, no `manage_users`) held to the member's read policy. */ const DELEGATE = { userId: 'usr_ag_delegate', positions: [] as string[], permissions: [MEMBER_SET.name] }; const SYSTEM = { isSystem: true }; +/** [#22851] A user administrator with no read grant on the object: its own read of every row is REFUSED. */ +const REFUSED_ADMIN = { userId: 'usr_ag_refused_admin', positions: [] as string[], permissions: [USERS_ONLY_SET.name] }; +/** [#22851] A delegated administrator (D12) with no read grant on the object: its own read is refused too. */ +const REFUSED_DELEGATE = { userId: 'usr_ag_refused_delegate', positions: [] as string[], permissions: [] as string[] }; +/** + * [#22851] Edits, deletes and transfers boards, and may not read them: the + * principal whose self-explanation keeps the write path's notion under ruling + * C, because its by-id write doors do not treat its refused read as absence. + */ +const EDIT_ONLY_SET = PermissionSetSchema.parse({ + name: 'qa_ag_edit_only', + objects: { [BOARD]: { allowEdit: true, allowDelete: true, allowTransfer: true } }, +}); +const EDIT_ONLY_USER = 'usr_ag_edit_only'; +const EDIT_ONLY = { userId: EDIT_ONLY_USER, positions: [] as string[], permissions: [EDIT_ONLY_SET.name] }; +/** Per write verb, a board the edit-only principal's own door writes, so a door that lands changes no other case's row. */ +const editOnlyRowOf = (verb: ExplainOperation) => `b_edit_only_${verb}`; const HIDDEN = 'b_hidden'; const OPEN = 'b_open'; @@ -142,7 +166,7 @@ beforeAll(async () => { await engine.syncSchemas(); const e = engine as unknown as { insert: (o: string, row: unknown, opts: never) => Promise }; - for (const id of [MEMBER, ADMIN.userId, BLIND_ADMIN.userId, DELEGATE.userId]) { + for (const id of [MEMBER, ADMIN.userId, BLIND_ADMIN.userId, DELEGATE.userId, REFUSED_ADMIN.userId, REFUSED_DELEGATE.userId, EDIT_ONLY_USER]) { await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); } // The explained member's grant, as the explainer resolves it: a grant row @@ -154,12 +178,20 @@ beforeAll(async () => { await e.insert('sys_user_permission_set', { id: 'ups_ag_member', user_id: MEMBER, permission_set_id: 'ps_ag_member', permission_set: MEMBER_SET.name, }, SYS); + await e.insert('sys_permission_set', { + id: 'ps_ag_edit_only', name: EDIT_ONLY_SET.name, label: EDIT_ONLY_SET.name, organization_id: null, managed_by: 'admin', + active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), + }, SYS); + await e.insert('sys_user_permission_set', { + id: 'ups_ag_edit_only', user_id: EDIT_ONLY_USER, permission_set_id: 'ps_ag_edit_only', permission_set: EDIT_ONLY_SET.name, + }, SYS); await e.insert(BOARD, [ { id: HIDDEN, name: 'hidden', owner_id: 'usr_ag_owner' }, { id: OPEN, name: 'open', owner_id: 'usr_ag_owner' }, + ...WRITE_VERBS.map((verb) => ({ id: editOnlyRowOf(verb), name: 'hidden', owner_id: 'usr_ag_owner' })), ], SYS); - const sets = [MEMBER_DEFAULT, MEMBER_SET, USER_ADMIN_SET, USERS_ONLY_SET]; + const sets = [MEMBER_DEFAULT, MEMBER_SET, USER_ADMIN_SET, USERS_ONLY_SET, EDIT_ONLY_SET]; const services: Record = { manifest: { register: vi.fn() }, objectql: engine, @@ -183,13 +215,14 @@ beforeAll(async () => { plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); await plugin.init(ctx as never); await plugin.start(ctx as never); - // The D12 boundary: this delegate's adminScope covers the member, and nobody - // else's does. Answered by a stub on the real gate's boundary question, as - // the file header says; the gate's write-path checks stay its own. + // The D12 boundary: these delegates' adminScope covers the member, and + // nobody else's does. Answered by a stub on the real gate's boundary + // question, as the file header says; the gate's write-path checks stay its own. const gate = (plugin as unknown as { delegatedAdminGate: { scopesCoverUser: (...a: any[]) => Promise } }) .delegatedAdminGate; vi.spyOn(gate, 'scopesCoverUser').mockImplementation( - async (_sets: unknown, target: string, caller: { userId?: string }) => caller?.userId === DELEGATE.userId && target === MEMBER, + async (_sets: unknown, target: string, caller: { userId?: string }) => + (caller?.userId === DELEGATE.userId || caller?.userId === REFUSED_DELEGATE.userId) && target === MEMBER, ); }); @@ -208,6 +241,19 @@ const explain = (asker: Record, operation: ExplainOperation, re const reads = async (asker: Record, id: string): Promise => (await engine.findOne(BOARD, { where: { id }, context: { ...asker } } as never)) != null; +/** The read door's whole answer: a row, no row, or a refusal with its status and code. */ +const readAnswer = (asker: Record, id: string): Promise => + (engine.findOne(BOARD, { where: { id }, context: { ...asker } } as never) as Promise).then( + (row) => (row != null ? 'row' : 'no row'), + (e: any) => `refused ${e?.statusCode ?? e?.status} ${e?.code}`, + ); + +/** `asker` explains `userId` (another user), or itself when `userId` is omitted. */ +const explainAs = ( + asker: Record, operation: ExplainOperation, recordId: string, userId?: string, +): Promise => + plugin.explainAccessForCaller({ object: BOARD, operation, recordId, ...(userId ? { userId } : {}) }, { ...asker }); + type Answer = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number }; const landed = (p: Promise): Promise => p.then( @@ -328,6 +374,75 @@ describe('controls', () => { } }); +/** `ctx`'s own by-id write door: a transfer is the update that writes `owner_id`. */ +const doorAs = (ctx: Record, verb: ExplainOperation, id: string): Promise => + verb === 'delete' + ? landed(engine.delete(BOARD, { where: { id }, context: { ...ctx } } as never)) + : landed(engine.update(BOARD, verb === 'transfer' ? { owner_id: 'usr_ag_successor' } : { name: 'edited' }, + { where: { id }, context: { ...ctx } } as never)); + +describe('[#22851] ruling C: a refused read is "cannot read" only for an asker explaining another user', () => { + it('armed: the refused askers\' and the edit-only principal\'s own reads are REFUSED, for a stored row and a nonexistent id alike', async () => { + for (const [who, asker] of [ + ['the refused administrator', REFUSED_ADMIN], + ['the refused delegate', REFUSED_DELEGATE], + ['the edit-only principal', EDIT_ONLY], + ] as const) { + for (const id of [HIDDEN, OPEN, NONEXISTENT]) { + expect(await readAnswer(asker, id), `${who}'s read of ${id}`).toBe('refused 403 PERMISSION_DENIED'); + } + } + }); + + for (const [who, asker] of [ + ['a manage_users administrator with no read grant on the object', REFUSED_ADMIN], + ['a delegated administrator (D12) with no read grant on the object', REFUSED_DELEGATE], + ] as const) { + for (const verb of VERBS) { + it(`${who}, ${verb}: a stored row's whole explanation equals the nonexistent id's, layers included`, async () => { + const nonexistent = await explainAs(asker, verb, NONEXISTENT, MEMBER); + expect(nonexistent.record).toEqual({ recordId: NONEXISTENT, visible: false }); + for (const id of [HIDDEN, OPEN]) { + const stored = await explainAs(asker, verb, id, MEMBER); + expect(stored.principal.userId).toBe(MEMBER); + expect(stored.record, `${verb} ${id}`).toEqual({ recordId: id, visible: false }); + expect(withoutRecordId(stored), `${verb} ${id}, whole`).toEqual(withoutRecordId(nonexistent)); + } + }); + } + } + + for (const verb of VERBS) { + it(`control, ${verb}: a principal explaining itself keeps the write path's notion, so a refused read is not the missing record`, async () => { + const id = WRITE_VERBS.includes(verb) ? editOnlyRowOf(verb) : HIDDEN; + const self = await explainAs(EDIT_ONLY, verb, id); + const nonexistent = await explainAs(EDIT_ONLY, verb, NONEXISTENT); + // Today's answer: the stored row's own story, the one a system asker is told about this principal. + const bySystem = await explainAs(SYSTEM, verb, id, EDIT_ONLY_USER); + expect(self.record, verb).toEqual(bySystem.record); + expect(self.layers.map((l) => l.record), verb).toEqual(bySystem.layers.map((l) => l.record)); + expect(withoutRecordId(self), verb).not.toEqual(withoutRecordId(nonexistent)); + expect(layerRecord(self, 'owd_baseline')?.outcome, verb).toBe('admitted'); + expect(layerRecord(nonexistent, 'owd_baseline')?.outcome, verb).toBe('not_evaluated'); + if (WRITE_VERBS.includes(verb)) { + // ...which is what that principal's own door does with the row. + expect(self.record, verb).toMatchObject({ visible: true }); + expect(await doorAs(EDIT_ONLY, verb, id), `${verb} door`).toEqual({ kind: 'admitted' }); + } else { + expect(self.record, verb).toEqual({ recordId: id, visible: false, decidedBy: 'object_crud' }); + } + }); + } + + it('control: a reading administrator and a nonexistent id are unchanged by the refused-asker rule', async () => { + expect((await explainAs(ADMIN, 'read', HIDDEN, MEMBER)).record).toEqual({ recordId: HIDDEN, visible: false, decidedBy: 'rls' }); + expect((await explainAs(ADMIN, 'update', HIDDEN, MEMBER)).record).toEqual({ recordId: HIDDEN, visible: false, decidedBy: 'rls' }); + for (const asker of [REFUSED_ADMIN, REFUSED_DELEGATE, ADMIN, SYSTEM]) { + expect((await explainAs(asker, 'read', NONEXISTENT, MEMBER)).record).toEqual({ recordId: NONEXISTENT, visible: false }); + } + }); +}); + describe('the read question, engine level: who it is asked of, and how often', () => { const OBJECT = 'qa_ag_engine'; const SET = PermissionSetSchema.parse({ @@ -419,6 +534,44 @@ describe('the read question, engine level: who it is asked of, and how often', ( } }); + it('[#22851] an asker explaining another principal is answered by its own read\'s classification: a refusal is "cannot read"', async () => { + for (const verb of VERBS) { + // The door never answers absent, for anyone: only the asker's classified read can say "cannot read". + const question = door(); + const refused = { context: ASKER, cannotRead: vi.fn(async () => true) }; + const stored = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, refused); + const missing = await explainAccessForAsker(deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: 'r_missing' }, refused); + expect(stored.record, verb).toEqual({ recordId: STORED.id, visible: false }); + expect(withoutRecordId(stored), verb).toEqual(withoutRecordId(missing)); + expect(question.mock.calls.map((c) => c[2]), verb).not.toContain(ASKER); + } + }); + + it('[#22851] a principal explaining itself is asked the write path\'s question, never the third-party classification', async () => { + for (const verb of VERBS) { + const question = door(); + const classified = vi.fn(async () => true); + const d = await explainAccessForAsker( + deps(question), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, { context: EXPLAINED, cannotRead: classified }, + ); + expect(classified, verb).not.toHaveBeenCalled(); + expect(question.mock.calls.map((c) => c[2]), verb).toEqual([EXPLAINED]); + expect(d.record, verb).toMatchObject({ recordId: STORED.id, visible: true }); + } + }); + + it('[#22851] a store fault in a third-party asker\'s read fails the explanation', async () => { + const fault = Object.assign(new Error('datasource unavailable'), { code: 'ERR_DATASOURCE_UNAVAILABLE', status: 503 }); + for (const verb of VERBS) { + await expect( + explainAccessForAsker(deps(door()), { object: OBJECT, operation: verb, context: EXPLAINED, recordId: STORED.id }, { + context: ASKER, cannotRead: async () => { throw fault; }, + }), + verb, + ).rejects.toBe(fault); + } + }); + it('a system asker is asked like any other asker: there is no exemption beside the read door\'s own answer', async () => { for (const verb of VERBS) { const question = door(); From 781573f3cb0091124627e1af0c5dd12e809e7a8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:51:42 +0000 Subject: [PATCH 11/13] test(plugin-security): the parity rig's explaining administrator reads what it explains (ruling C) Under ruling C an administrator whose own read of a record is refused is answered as for an id no row carries, so the rig's asker now holds the reader set and the record cells keep measuring the explained user's story. The single-posture record row for the other organization's row joins the invariant: the asker's own read is natively scoped the same way. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .../src/explain-enforce-parity.test.ts | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index f9820cd3d23..f8082a3416b 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -63,7 +63,9 @@ * * - `NATIVE_SCOPING_UNDER_SINGLE` (explain's side): under `single` the engine * still scopes a tenant object's read to the context's organization, which - * explain's tenant layer does not report. + * explain's tenant layer does not report. Its `readFilter` row measures it; + * its record row joined the invariant once the record story was gated on the + * asker's own read (#22792), which that scoping answers. * - `CLAIM_KEPT_UNDER_A_WALL` (enforcement's side): with `org-scoping` and no * `tenancy` service, admission reads no posture and never drops a removed * member's organization claim, while this plugin walls Layer 0 at @@ -335,7 +337,15 @@ const SAME_CLASS = 'record.status != record.title'; const ALPHA = 'org_alpha'; const BETA = 'org_beta'; -/** An `org_alpha` member holding `manage_users` there: the caller who explains. */ +/** + * An `org_alpha` member holding `manage_users` there: the caller who explains. + * It also reads what the explained users read ({@link READER_SET}): whether an + * explanation may disclose a record is decided by the ASKER's read, and an + * administrator explaining another user whose own read of the record is + * refused is told what an id no row carries (ruling C of #22851). Holding the + * reader set, this asker is told the explained user's own record story, which + * is what every record cell below holds against enforcement. + */ const USER_ADMIN = 'usr_alpha_admin'; /** A current `org_alpha` member. */ const USER_MEMBER = 'usr_alpha_member'; @@ -424,6 +434,7 @@ async function bootPrincipal(source: PostureSource) { id: `ups_${user}_${set}_${n}`, user_id: user, permission_set_id: set, permission_set: name, organization_id: ALPHA, }, SYS); await grant(USER_ADMIN, `ps_user_admin_${n}`, 'qa_user_admin'); + await grant(USER_ADMIN, `ps_reader_${n}`, READER_SET); for (const u of [USER_MEMBER, USER_REMOVED]) { await grant(u, `ps_reader_${n}`, READER_SET); await grant(u, `ps_alpha_notes_${n}`, ALPHA_ONLY_SET); @@ -810,8 +821,11 @@ function principalRows( * Under `single` there is no tenant wall, yet the engine still scopes a tenant * object's read to the context's organization (driver-native tenant scoping, * any posture). Explain's tenant layer contributes nothing under `single`, so - * it reports the other organization's row readable. Explain's side; a finding - * of this card, not fixed here. + * its `readFilter` reports the other organization's rows as served. Explain's + * side; a finding of this card, not fixed here. [#22792] The record verdict no + * longer carries it: whether an explanation may say a record exists is the + * asker's own by-id read, which that same native scoping answers, so the other + * organization's row is answered as an id no row carries. */ const NATIVE_SCOPING_UNDER_SINGLE = 'under `single`, the engine scopes the read to the context organization; explain reports the other organization\'s row'; @@ -897,8 +911,11 @@ const TABLE: Row[] = [ removed: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, member: { LEDGER: 'rows', PROBE: 'rows', NOTES: REFUSED_DENIED }, }, { - removed: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE, 'record:l_beta': NATIVE_SCOPING_UNDER_SINGLE }, - member: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE, 'record:l_beta': NATIVE_SCOPING_UNDER_SINGLE }, + // [#22792] `record:l_beta` joined the invariant: the asker's own read is + // scoped to the context organization the same way, so the record verdict + // is the missing record's, as the explained user's read answers. + removed: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE }, + member: { 'readFilter:LEDGER': NATIVE_SCOPING_UNDER_SINGLE }, }), // The posture-source asymmetry: the member's rows hold; the removed member's are enforcement's finding. ...principalRows('#20604 A4', { orgScopingOnly: true }, '`org-scoping` with no `tenancy` service', { From 308bbb0b8b0f3860b08a6129363c480926fe9dcf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:51:42 +0000 Subject: [PATCH 12/13] wip(spec): explain's record and Missing records text say which ids each asker cannot read (ruling C) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .changeset/22792-explain-asker-gate.md | 7 ++++-- packages/spec/src/security/explain.zod.ts | 26 ++++++++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.changeset/22792-explain-asker-gate.md b/.changeset/22792-explain-asker-gate.md index 75087644fc6..8f28203823b 100644 --- a/.changeset/22792-explain-asker-gate.md +++ b/.changeset/22792-explain-asker-gate.md @@ -7,8 +7,11 @@ Clause-②: no -- **An asker who cannot read the record** (its own by-id read does not return the row) gets, for every operation, exactly the answer an id no row carries gets: `record` is `{ recordId, visible: false }` with no `decidedBy`, and every layer's per-record attribution is the missing record's. This holds whoever the asker explains: itself, or another user it may explain (`manage_users`, or a delegated administrator's scope). Before, a principal explaining itself a `read` of a row it cannot read was told the row's verdict and why; the by-id write verbs already answered it as missing. +- **An asker who cannot read the record** gets, for every operation, exactly the answer an id no row carries gets: `record` is `{ recordId, visible: false }` with no `decidedBy`, and every layer's per-record attribution is the missing record's. + - An asker explaining another user (`manage_users`, or a delegated administrator's scope) cannot read a record its own by-id read finds no row for, or refuses (a declared 4xx, such as no read grant on the object). + - A principal explaining itself cannot read a record its own by-id read finds no row for. A refused read does not count, so the explanation shows what that principal's own doors answer: a principal that may edit a record it may not read is told what its edit does. + - Before, a principal explaining itself a `read` of a row its read finds no row for was told the row's verdict and why, and an administrator with no read grant on the object was told any row's story; the by-id write verbs already answered a self-explanation of such a row as missing. - **An asker who can read the record** gets the explained principal's full record story. For a `read`, that is the principal's own verdict, decider included, so the `controlled_by_parent` read verdict on the master leg (not visible, decided by the `sharing` layer) is what such an asker is told. For a by-id `update`, `delete` or `transfer` of a row the principal cannot read, the verdict stays `visible: false`, the answer that principal's write gets, and now names the layer that decided the principal's read; each row-scoped layer's `record` carries that read's attribution, prefaced with the write's `404 RECORD_NOT_FOUND`. Before, such an asker was told the missing record's answer. - A system asker reads every record. The batch form (`recordIds`) answers each id as the singular form does. -- `@objectstack/spec`: the `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say that an id the asker cannot read answers as missing. Description text only; no schema shape changes. +- `@objectstack/spec`: the `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say which ids an asker cannot read, by asker, and that those answer as missing. Description text only; no schema shape changes. - Nothing you author changes, and no exported type or signature moves. Object-level explanations (no `recordId`) are unchanged. diff --git a/packages/spec/src/security/explain.zod.ts b/packages/spec/src/security/explain.zod.ts index 75d4cdf717b..14b732373fe 100644 --- a/packages/spec/src/security/explain.zod.ts +++ b/packages/spec/src/security/explain.zod.ts @@ -278,10 +278,13 @@ export const ExplainRequestSchema = lazySchema(() => z.object({ * are refused too — send at least one id or omit the field. * - **Missing records**: an id that does not resolve to a readable row under * a system read (filtered, deleted, or never existed), and an id the ASKER - * cannot read (the caller of explain, whose own read does not return the - * row, whichever principal it explains), gets `{ recordId, visible: false }` + * (the caller of explain) cannot read, gets `{ recordId, visible: false }` * with `decidedBy` omitted — the same fail-closed answer the singular form - * gives for that id. + * gives for that id. An asker explaining another principal cannot read an + * id its own by-id read finds no row for or refuses (a declared 4xx); a + * principal explaining itself cannot read an id its own by-id read finds no + * row for, and a refused read leaves its explanation as its own doors + * answer. * - **Mutually exclusive** with `recordId`: a request carrying both is * refused loudly rather than silently preferring either spelling. * - Batch responses carry the per-layer trace at OBJECT level only (no @@ -393,15 +396,18 @@ export const ExplainDecisionSchema = lazySchema(() => z.object({ * The per-layer `record` attributions above carry the full trace; this is the * summary a UI pins next to the record. * - * An id the ASKER cannot read — the caller of explain, whose own read does - * not return the row, whichever principal it explains — answers as a missing - * record, alongside a filtered, deleted or never-existed id: `visible: false` - * with `decidedBy` omitted, and every layer's `record` attribution the one an - * id no row carries gets. Whether a row exists is never disclosed to an asker - * who cannot read it. + * An id the ASKER cannot read answers as a missing record, alongside a + * filtered, deleted or never-existed id: `visible: false` with `decidedBy` + * omitted, and every layer's `record` attribution the one an id no row + * carries gets. The asker is the caller of explain, not the principal + * explained. An asker explaining another principal cannot read an id its own + * by-id read finds no row for or refuses (a declared 4xx, such as no read + * grant on the object). A principal explaining itself cannot read an id its + * own by-id read finds no row for; a refused read does not count, so its + * explanation shows what its own doors answer. */ record: ExplainRecordVerdictSchema.optional() - .describe('Row-level verdict for the specific record; set only for singular record-grained requests. An id the asker (the caller of explain, not the principal explained) cannot read answers as missing, like a filtered, deleted or never-existed id: visible false, decidedBy omitted.'), + .describe('Row-level verdict for the specific record; set only for singular record-grained requests. An id the asker (the caller of explain) cannot read answers as missing, like a filtered, deleted or never-existed id: visible false, decidedBy omitted. Explaining another principal, the asker cannot read an id its own by-id read finds no row for or refuses; a principal explaining itself cannot read an id its read finds no row for.'), /** * [#8326] Batch record-grained verdicts — present only when the request * carried `recordIds`. `records[i]` answers `recordIds[i]` (same order, same From 2de8d8a6e0eed417c40b59e735b952af1f8d4c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:00:10 +0000 Subject: [PATCH 13/13] wip: regenerate the explain reference page (gen:docs) Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- content/docs/references/security/explain.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx index 6813de1a7de..79d0dcecb78 100644 --- a/content/docs/references/security/explain.mdx +++ b/content/docs/references/security/explain.mdx @@ -108,7 +108,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object | **principal** | `{ userId: string \| null; positions?: string[]; permissionSets?: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` | ✅ | | | **layers** | `{ layer: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …>; kernelTier?: Enum<'layer_0_tenant' \| 'layer_1_business'>; verdict: Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>; detail: string; … }[]` | ✅ | | | **readFilter** | `any` | optional | The composed row filter the caller would be served with — the machine artifact behind the prose (null = unrestricted). Two shapes mean zero rows — `{ id: "__deny_all__" }` and the fail-closed RLS denial (`__rls_deny__` plus a colon and a UUID-shaped suffix, which can also ride inside an $and composite) — so a consumer pattern-matching this payload alone must match both; the decision itself is allowed plus the rls layer verdict. | -| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }` | optional | Row-level verdict for the specific record; set only for singular record-grained requests. An id the asker (the caller of explain, not the principal explained) cannot read answers as missing, like a filtered, deleted or never-existed id: visible false, decidedBy omitted. | +| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }` | optional | Row-level verdict for the specific record; set only for singular record-grained requests. An id the asker (the caller of explain) cannot read answers as missing, like a filtered, deleted or never-existed id: visible false, decidedBy omitted. Explaining another principal, the asker cannot read an id its own by-id read finds no row for or refuses; a principal explaining itself cannot read an id its read finds no row for. | | **records** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }[]` | optional | Per-record verdicts for a batch request — records[i] answers recordIds[i]; set only when the request carried recordIds. | ### Nested Shape: `ExplainDecision.principal`