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