Skip to content

Commit 5ad8ac3

Browse files
committed
test(dogfood): the explain-versus-door enumeration pin covers the read verb beside the by-id read door
The family's per-verb table classifies read against its by-id GET door on a controlled_by_parent detail of a private master: a record under a master the principal cannot read, a record under one it reads, and an id no row carries, each beside that door's answer. Adds the patch changeset. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
1 parent 45576b8 commit 5ad8ac3

2 files changed

Lines changed: 85 additions & 4 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
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).
6+
7+
- **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.
8+
- **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.

‎packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts‎

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
// classification is total, so a verb added to the explain vocabulary without a
4141
// door parity row fails it.
4242
//
43+
// A read is in that table too. Its door is the by-id `GET`, which scopes a
44+
// `controlled_by_parent` record by the master ids the principal can read
45+
// (ADR-0055), so the read row sits on `cpg_vault_item`, whose master is
46+
// private: explain's read verdict is pinned beside that door for a principal
47+
// who cannot read the master, for one who can, and for an id no row carries.
48+
//
4349
// ## A transfer is judged by the update policies its door is judged by
4450
//
4551
// The third block is not about the master. A transfer's door is the `PATCH`
@@ -153,6 +159,10 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do
153159
const underAdmin: Partial<Record<ExplainOperation, string>> = {};
154160
/** Per verb with a by-id door: a child of the steward's own master, which the steward did not create. */
155161
const underSteward: Partial<Record<ExplainOperation, string>> = {};
162+
/** Per verb with a by-id read door: a child of the admin's private master, which the steward cannot read. */
163+
const underUnreadable: Partial<Record<ExplainOperation, string>> = {};
164+
/** Per verb with a by-id read door: a child of the steward's own private master, which the steward reads. */
165+
const underReadable: Partial<Record<ExplainOperation, string>> = {};
156166

157167
/**
158168
* Every operation explain answers, classified against the data door. Total
@@ -164,14 +174,20 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do
164174
* floor over to it (step 2.7). `transfer`'s door is the `PATCH` that
165175
* writes `owner_id`: the engine dispatches no `transfer` operation, and
166176
* that write is where the transfer grant is enforced (step 3.5).
177+
* - `by_id_read_door`: the by-id `GET`, which scopes a `controlled_by_parent`
178+
* record by the master ids the principal can READ (ADR-0055). Asked of
179+
* `cpg_vault_item`, whose master is private, so a principal who cannot
180+
* read the master cannot read the record either.
167181
* - `object_gate_refused`: step 2.8 lists the verb, but the object gate
168182
* refuses it to every principal (its grant is retired until the lifecycle
169183
* batch returns it) and no route dispatches it.
170-
* - `no_by_id_write`: not a write the master check judges by id — a read,
171-
* or an insert, whose master step 2.8 reads off the request body.
184+
* - `no_by_id_write`: neither — an insert, whose master step 2.8 reads off
185+
* the request body, or an export, which has no by-id door (it streams the
186+
* read's find, and explain answers it as it answers the read).
172187
*/
173188
type DoorRow =
174189
| { kind: 'by_id_door'; door: (token: string, id: string) => Promise<Response> }
190+
| { kind: 'by_id_read_door'; object: string; door: (token: string, id: string) => Promise<Response> }
175191
| { kind: 'object_gate_refused' }
176192
| { kind: 'no_by_id_write' };
177193
const DOORS: Record<ExplainOperation, DoorRow> = {
@@ -184,7 +200,11 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do
184200
restore: { kind: 'object_gate_refused' },
185201
purge: { kind: 'object_gate_refused' },
186202
create: { kind: 'no_by_id_write' },
187-
read: { kind: 'no_by_id_write' },
203+
read: {
204+
kind: 'by_id_read_door',
205+
object: 'cpg_vault_item',
206+
door: (t, id) => stack.apiAs(t, 'GET', `/data/cpg_vault_item/${id}`),
207+
},
188208
export: { kind: 'no_by_id_write' },
189209
};
190210
const verbsOf = (kind: DoorRow['kind']) => (Object.keys(DOORS) as ExplainOperation[]).filter((v) => DOORS[v].kind === kind);
@@ -193,6 +213,11 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do
193213
if (row.kind !== 'by_id_door') throw new Error(`${verb} has no by-id door`);
194214
return row.door;
195215
};
216+
const readDoorOf = (verb: ExplainOperation) => {
217+
const row = DOORS[verb];
218+
if (row.kind !== 'by_id_read_door') throw new Error(`${verb} has no by-id read door`);
219+
return row;
220+
};
196221

197222
const uid = async (email: string) => (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id;
198223

@@ -270,6 +295,18 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do
270295
const stewardAccountId = (
271296
await ql.insert('cpg_account', { name: 'steward account', owner_id: stewardId }, { context: { ...SYS } })
272297
).id;
298+
const adminVaultId = (await ql.insert('cpg_vault', { name: 'admin vault', owner_id: adminId }, { context: { ...SYS } })).id;
299+
const stewardVaultId = (
300+
await ql.insert('cpg_vault', { name: 'steward vault', owner_id: stewardId }, { context: { ...SYS } })
301+
).id;
302+
for (const verb of verbsOf('by_id_read_door')) {
303+
underUnreadable[verb] = (
304+
await ql.insert('cpg_vault_item', { name: `${verb}: admin's vault`, vault: adminVaultId }, { context: { ...SYS } })
305+
).id;
306+
underReadable[verb] = (
307+
await ql.insert('cpg_vault_item', { name: `${verb}: steward's vault`, vault: stewardVaultId }, { context: { ...SYS } })
308+
).id;
309+
}
273310
for (const verb of verbsOf('by_id_door')) {
274311
underAdmin[verb] = (
275312
await ql.insert('cpg_contract', { name: `${verb}: admin's master`, account: adminAccountId }, { context: { ...SYS } })
@@ -328,7 +365,7 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do
328365
const admitted = await patch(memberTok, 'cpg_board', boardId, 'open board');
329366
expect(admitted.status, `member PATCH of the board: ${admitted.body}`).toBe(200);
330367
});
331-
describe('every write verb explain answers: its verdict is the door\'s, for the master\'s editor and for a non-editor', () => {
368+
describe('every verb explain answers by id: its verdict is the door\'s, for the master\'s editor or reader and for a principal who is not', () => {
332369
it('the classification is total: every operation explain answers has a door row', () => {
333370
expect([...ExplainOperationSchema.options].sort()).toEqual(Object.keys(DOORS).sort());
334371
});
@@ -359,6 +396,42 @@ describe('security/explain answers a controlled_by_parent update as its PATCH do
359396
});
360397
}
361398

399+
for (const verb of verbsOf('by_id_read_door')) {
400+
it(`${verb}: a record under a master the principal cannot read is explained not visible, as its door answers`, async () => {
401+
const { object, door } = readDoorOf(verb);
402+
const id = underUnreadable[verb]!;
403+
const explained = await explainAs(stewardTok, verb, object, id);
404+
const answered = await answer(await door(stewardTok, id));
405+
expect(answered.status, `${verb} door, the master's non-reader: ${answered.body}`).toBe(404);
406+
expect(answered.code).toBe('RECORD_NOT_FOUND');
407+
408+
expect(explained.record, `explain ${verb}`).toEqual({ recordId: id, visible: false, decidedBy: 'sharing' });
409+
expect(explained.sharing?.outcome).toBe('excluded');
410+
});
411+
412+
it(`${verb}: control, a record under a master the principal reads is explained visible and its door answers 200`, async () => {
413+
const { object, door } = readDoorOf(verb);
414+
const id = underReadable[verb]!;
415+
const explained = await explainAs(stewardTok, verb, object, id);
416+
const answered = await answer(await door(stewardTok, id));
417+
expect(answered.status, `${verb} door, the master's reader: ${answered.body}`).toBe(200);
418+
419+
expect(explained.record, `explain ${verb}`).toMatchObject({ recordId: id, visible: true });
420+
expect(explained.sharing?.outcome).not.toBe('excluded');
421+
});
422+
423+
it(`${verb}: control, an id no row carries is explained as it was, beside its door's 404`, async () => {
424+
const { object, door } = readDoorOf(verb);
425+
const id = `${verb}-no-such-record`;
426+
const explained = await explainAs(stewardTok, verb, object, id);
427+
const answered = await answer(await door(stewardTok, id));
428+
expect(answered.status, `${verb} door, an id no row carries: ${answered.body}`).toBe(404);
429+
expect(answered.code).toBe('RECORD_NOT_FOUND');
430+
431+
expect(explained.record, `explain ${verb}`).toEqual({ recordId: id, visible: false });
432+
});
433+
}
434+
362435
for (const verb of verbsOf('object_gate_refused')) {
363436
it(`${verb}: refused at the object gate, for the master's editor and for a non-editor alike`, async () => {
364437
for (const id of [underAdmin.update!, underSteward.update!]) {

0 commit comments

Comments
 (0)