diff --git a/.changeset/22792-explain-asker-gate.md b/.changeset/22792-explain-asker-gate.md new file mode 100644 index 00000000000..8f28203823b --- /dev/null +++ b/.changeset/22792-explain-asker-gate.md @@ -0,0 +1,17 @@ +--- +'@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** 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 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/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx index f88bd525934..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. | +| **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` 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..58292fae7aa --- /dev/null +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -0,0 +1,607 @@ +// 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 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 +// (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, 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 +// 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, +// 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'; +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, type ExplainInput } 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; 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'" }, + { 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({ + 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 }; +/** [#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'; +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, 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 + // 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('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, EDIT_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: 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 || caller?.userId === REFUSED_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; + +/** 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( + () => ({ 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); + // 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']) { + 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')).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); + expect(self.layers.map((l) => l.record)).toEqual(bySystem.layers.map((l) => l.record)); + }); + } +}); + +/** `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({ + name: 'qa_ag_engine_writer', + 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[] }; + 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)); + /** + * 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) { + 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 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); + 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 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]); + } + }); + + 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 explainFor(readOnlyRls(door()), input, ASKER); + expect(writeAlone.record, `${verb}, a principal who reads the row`).toMatchObject({ visible: true }); + 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'); + 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('[#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(); + 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 }); + } + }); + + 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( + explainFor(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 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)); + } + }); +}); + +/** 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..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 @@ -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,52 @@ 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 } : {}) }, { + 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) { 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 +341,102 @@ 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); }); }); + +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); + }); + } +}); 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-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', { diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index 9ded7bf31dc..c22a124bc6d 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -425,6 +425,15 @@ 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 + * 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; } @@ -1854,7 +1863,134 @@ async function applyRecordAttribution( }; } +/** + * Explain `input.context`'s access, asked by that same principal: a + * 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); +} + +/** + * [#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 + * 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. + * + * [#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`) + * is the one caller that knows a different asker. + */ +export async function explainAccessForAsker( + deps: ExplainEngineDeps, + input: ExplainInput, + 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. + 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; + }; +} + +/** + * 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 byContext = new Map Promise>(); + return (object, recordId, context) => { + let ask = byContext.get(context); + if (!ask) { + ask = oncePerRecord((o, r) => question(o, r, context)); + byContext.set(context, ask); + } + return ask(object, recordId); + }; +} + +/** + * [#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, 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 — @@ -2289,6 +2425,28 @@ 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 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 @@ -2308,26 +2466,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 && + !askerIsPrincipal && (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 }, 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. + 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/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 12449d326a2..c3401ae9403 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, @@ -693,6 +693,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 @@ -5128,8 +5145,16 @@ 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 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 + * explained user's: a caller the read door does not return the record to is + * 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 }, @@ -5261,7 +5286,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), @@ -5342,6 +5367,15 @@ 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, 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)), + }, ); } 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 aa8beceffe8..c50172d9a8c 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]!; diff --git a/packages/spec/src/security/explain.zod.ts b/packages/spec/src/security/explain.zod.ts index 5d08290b0a0..14b732373fe 100644 --- a/packages/spec/src/security/explain.zod.ts +++ b/packages/spec/src/security/explain.zod.ts @@ -277,9 +277,14 @@ 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 + * (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. 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 @@ -390,9 +395,19 @@ 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 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.'), + .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