1515 *
1616 * Refused by name, `INTERNAL_ERROR` / 500, before any driver is asked: a write
1717 * verb, `having`, an aggregation's filter, any position but a `$in`
18- * comparand, a composed leaf the engine did not compose, and D5's three
19- * classes (a delegated caller, a middleware-gated parent, a federated parent).
18+ * comparand, a composed leaf the engine did not compose, and D5's two
19+ * remaining classes (a middleware-gated parent, a federated parent). A
20+ * delegated (on-behalf-of) caller composes like any other (D5.1 as amended):
21+ * both halves are asked with the delegated context as it stands, and what the
22+ * security service answers for that context — under ADR-0090 D10 the
23+ * intersection of the two legs — is the scope.
2024 *
2125 * The recording driver is this package's witness shape (the in-memory
2226 * driver's test consumers are a closed census); evaluating the composed leaf
@@ -87,18 +91,27 @@ const leafOf = (object = 'rid_parent') => ReadableIds.of(object);
8791const branch = ( object = 'rid_parent' ) => ( { object_name : object , record_id : { $in : leafOf ( object ) } } ) ;
8892const COUNT_AGG = [ { function : 'count' , alias : 'n' } ] ;
8993
90- /** A security service double that records which half it was asked, per object. */
94+ /** A security service double that records which half it was asked, per object, and the context each half received . */
9195function makeSecurity ( opts : {
92- canRead ?: ( object : string ) => boolean | Promise < boolean > ;
93- scope ?: ( object : string ) => unknown ;
96+ canRead ?: ( object : string , context ?: unknown ) => boolean | Promise < boolean > ;
97+ scope ?: ( object : string , context ?: unknown ) => unknown ;
9498} = { } ) {
9599 const asked : string [ ] = [ ] ;
100+ const contexts : unknown [ ] = [ ] ;
96101 const svc : any = {
97- async canReadObject ( object : string ) { asked . push ( `canReadObject:${ object } ` ) ; return opts . canRead ? opts . canRead ( object ) : true ; } ,
98- async getReadFilter ( object : string ) { asked . push ( `getReadFilter:${ object } ` ) ; return opts . scope ? opts . scope ( object ) : undefined ; } ,
102+ async canReadObject ( object : string , context ?: unknown ) {
103+ asked . push ( `canReadObject:${ object } ` ) ;
104+ contexts . push ( context ) ;
105+ return opts . canRead ? opts . canRead ( object , context ) : true ;
106+ } ,
107+ async getReadFilter ( object : string , context ?: unknown ) {
108+ asked . push ( `getReadFilter:${ object } ` ) ;
109+ contexts . push ( context ) ;
110+ return opts . scope ? opts . scope ( object , context ) : undefined ;
111+ } ,
99112 async explain ( ) { asked . push ( 'explain' ) ; return { allowed : false } ; } ,
100113 } ;
101- return { svc, asked } ;
114+ return { svc, asked, contexts } ;
102115}
103116
104117/** The `$in` comparand the driver received at `where.record_id`, under any `$and` the chain wrapped around it. */
@@ -344,6 +357,133 @@ describe('[ADR-0139 D2] the executor boundary composes the ReadableIds leaf', ()
344357 } ) ;
345358} ) ;
346359
360+ describe ( '[ADR-0139 D5.1, as amended] a delegated (on-behalf-of) caller composes — both halves asked with the delegated context as it stands' , ( ) => {
361+ let engine : ObjectQL ;
362+ let calls : SeenCall [ ] ;
363+ /** The shape the OAuth door assembles: `userId` stays the human, the link names them as the delegator. */
364+ const DELEGATED = { userId : 'usr_owner' , principalKind : 'agent' , onBehalfOf : { userId : 'usr_owner' , principalKind : 'human' } } ;
365+ const delegated = ( ) => ( { ...DELEGATED , onBehalfOf : { ...DELEGATED . onBehalfOf } } ) as any ;
366+
367+ beforeEach ( async ( ) => {
368+ const rec = makeRecordingDriver ( ) ;
369+ const farRec = makeRecordingDriver ( 'far' ) ;
370+ calls = rec . calls ;
371+ engine = new ObjectQL ( ) ;
372+ engine . registerDriver ( rec . driver , true ) ;
373+ engine . registerDriver ( farRec . driver ) ;
374+ await engine . init ( ) ;
375+ for ( const def of [ CHILD , PARENT , OTHER , GATED , FAR ] ) engine . registry . registerObject ( def as any , 'test' ) ;
376+ engine . registerMiddleware ( async ( _ctx , next ) => next ( ) , { object : 'rid_gated' } ) ;
377+ calls . length = 0 ;
378+ } ) ;
379+
380+ const delegatedReads = ( where : Record < string , unknown > ) => [
381+ [ 'find' , ( ) => engine . find ( 'rid_child' , { where, context : delegated ( ) } ) ] ,
382+ [ 'findOne' , ( ) => engine . findOne ( 'rid_child' , { where, context : delegated ( ) } ) ] ,
383+ [ 'count' , ( ) => engine . count ( 'rid_child' , { where, context : delegated ( ) } ) ] ,
384+ [ 'aggregate' , ( ) => engine . aggregate ( 'rid_child' , { where, aggregations : COUNT_AGG as any , context : delegated ( ) } ) ] ,
385+ ] as const ;
386+
387+ it ( 'each read verb asks both halves with the delegation link intact and hands its driver ONE call carrying the composed leaf — scope by reference, marked policy' , async ( ) => {
388+ // What the service answers for the delegated context stands for the
389+ // ADR-0090 D10 intersection; the engine composes it, it derives nothing.
390+ const scope = { $and : [ { owner : 'usr_owner' } , { title : { $ne : 'secret' } } ] } ;
391+ const { svc, asked, contexts } = makeSecurity ( { scope : ( ) => scope } ) ;
392+ wire ( engine , ( ) => svc ) ;
393+ for ( const [ verb , run ] of delegatedReads ( branch ( ) ) ) {
394+ calls . length = 0 ;
395+ asked . length = 0 ;
396+ contexts . length = 0 ;
397+ await run ( ) ;
398+ expect ( calls . map ( ( c ) => c . verb ) , verb ) . toEqual ( [ verb ] ) ;
399+ expect ( asked , verb ) . toEqual ( [ 'canReadObject:rid_parent' , 'getReadFilter:rid_parent' ] ) ;
400+ // Both halves received the caller's context as it stands: the link the
401+ // service intersects on is there, and the principal is the delegated one
402+ // — never a context the engine rebuilt for the delegator.
403+ for ( const ctx of contexts as Array < Record < string , unknown > > ) {
404+ expect ( ctx . onBehalfOf , verb ) . toEqual ( DELEGATED . onBehalfOf ) ;
405+ expect ( ctx . userId , verb ) . toBe ( DELEGATED . userId ) ;
406+ expect ( ctx . principalKind , verb ) . toBe ( 'agent' ) ;
407+ }
408+ const composed = inComparandOf ( calls [ 0 ] . ast . where ) as any ;
409+ expect ( isComposedReadableIds ( composed ) , verb ) . toBe ( true ) ;
410+ expect ( composed . object , verb ) . toBe ( 'rid_parent' ) ;
411+ expect ( composed . scope , verb ) . toBe ( scope ) ;
412+ }
413+ expect ( filterSubtreeProvenanceOf ( scope ) ) . toBe ( 'policy' ) ;
414+ } ) ;
415+
416+ it ( 'a leaf written by an object-scoped middleware on the child is composed for a delegated caller too' , async ( ) => {
417+ const scope = { owner : 'usr_owner' } ;
418+ const { svc, contexts } = makeSecurity ( { scope : ( ) => scope } ) ;
419+ wire ( engine , ( ) => svc ) ;
420+ engine . registerMiddleware ( async ( ctx , next ) => {
421+ if ( ctx . ast && [ 'find' , 'findOne' , 'count' , 'aggregate' ] . includes ( ctx . operation ) ) {
422+ ctx . ast . where = ctx . ast . where ? { $and : [ ctx . ast . where , branch ( ) ] } : branch ( ) ;
423+ }
424+ await next ( ) ;
425+ } , { object : 'rid_child' } ) ;
426+ for ( const [ verb , run ] of delegatedReads ( { note : 'x' } ) ) {
427+ calls . length = 0 ;
428+ contexts . length = 0 ;
429+ await run ( ) ;
430+ const composed = inComparandOf ( calls [ 0 ] . ast . where ) as any ;
431+ expect ( isComposedReadableIds ( composed ) , verb ) . toBe ( true ) ;
432+ expect ( composed . scope , verb ) . toBe ( scope ) ;
433+ expect ( contexts . map ( ( c ) => ( c as any ) ?. onBehalfOf ?. userId ) , verb ) . toEqual ( [ 'usr_owner' , 'usr_owner' ] ) ;
434+ }
435+ } ) ;
436+
437+ it ( 'the closed doors stay closed under a delegated context: canReadObject false, the deny sentinel, a throw on either half ⇒ $in: []' , async ( ) => {
438+ const doors : ReadonlyArray < readonly [ string , ReturnType < typeof makeSecurity > ] > = [
439+ [ 'canReadObject false' , makeSecurity ( { canRead : ( ) => false , scope : ( ) => ( { owner : 'usr_owner' } ) } ) ] ,
440+ [ 'the deny sentinel, by identity' , makeSecurity ( { scope : ( ) => RLS_DENY_FILTER } ) ] ,
441+ [ 'canReadObject throws' , ( ( ) => {
442+ const s = makeSecurity ( { scope : ( ) => ( { owner : 'usr_owner' } ) } ) ;
443+ s . svc . canReadObject = async ( ) => { throw new Error ( 'canReadObject is down' ) ; } ;
444+ return s ;
445+ } ) ( ) ] ,
446+ [ 'getReadFilter throws' , ( ( ) => {
447+ const s = makeSecurity ( ) ;
448+ s . svc . getReadFilter = async ( ) => { throw new Error ( 'getReadFilter is down' ) ; } ;
449+ return s ;
450+ } ) ( ) ] ,
451+ ] ;
452+ for ( const [ label , { svc } ] of doors ) {
453+ wire ( engine , ( ) => svc ) ;
454+ for ( const [ verb , run ] of delegatedReads ( branch ( ) ) ) {
455+ calls . length = 0 ;
456+ await run ( ) ;
457+ expect ( calls . map ( ( c ) => c . verb ) , `${ label } : ${ verb } ` ) . toEqual ( [ verb ] ) ;
458+ expect ( inComparandOf ( calls [ 0 ] . ast . where ) , `${ label } : ${ verb } ` ) . toEqual ( [ ] ) ;
459+ }
460+ }
461+ } ) ;
462+
463+ it ( 'D5.2 and D5.3 keep their refusal under a delegated context: code and status, no driver call, the service unasked' , async ( ) => {
464+ const { svc, asked } = makeSecurity ( ) ;
465+ wire ( engine , ( ) => svc ) ;
466+ const gated = await refusalOf ( engine . count ( 'rid_child' , { where : branch ( 'rid_gated' ) , context : delegated ( ) } ) ) ;
467+ expect ( envelopeOf ( gated ) ) . toEqual ( REFUSED ) ;
468+ expect ( gated ! . message ) . toContain ( "'rid_gated', whose rows an object-scoped read middleware narrows" ) ;
469+ const far = await refusalOf ( engine . aggregate ( 'rid_child' , { where : branch ( 'rid_far' ) , aggregations : COUNT_AGG as any , context : delegated ( ) } ) ) ;
470+ expect ( envelopeOf ( far ) ) . toEqual ( REFUSED ) ;
471+ expect ( far ! . message ) . toContain ( "'rid_far', which is bound to datasource 'far'" ) ;
472+ expect ( calls ) . toHaveLength ( 0 ) ;
473+ expect ( asked ) . toEqual ( [ ] ) ;
474+ } ) ;
475+
476+ it ( 'CONTROL — the same read without the delegation link composes the same way, so the link is not what the engine keys on' , async ( ) => {
477+ const scope = { owner : 'usr_owner' } ;
478+ const { svc, asked, contexts } = makeSecurity ( { scope : ( ) => scope } ) ;
479+ wire ( engine , ( ) => svc ) ;
480+ await engine . find ( 'rid_child' , { where : branch ( ) , context : { userId : 'usr_owner' , principalKind : 'human' } as any } ) ;
481+ expect ( asked ) . toEqual ( [ 'canReadObject:rid_parent' , 'getReadFilter:rid_parent' ] ) ;
482+ expect ( contexts . map ( ( c ) => ( c as any ) ?. onBehalfOf ) ) . toEqual ( [ undefined , undefined ] ) ;
483+ expect ( ( inComparandOf ( calls [ 0 ] . ast . where ) as any ) . scope ) . toBe ( scope ) ;
484+ } ) ;
485+ } ) ;
486+
347487describe ( '[ADR-0139 D1, D5] what the engine never composes is refused by name — no driver call' , ( ) => {
348488 let engine : ObjectQL ;
349489 let calls : SeenCall [ ] ;
@@ -365,17 +505,6 @@ describe('[ADR-0139 D1, D5] what the engine never composes is refused by name
365505 calls . length = 0 ;
366506 } ) ;
367507
368- it ( 'D5 — a delegated (on-behalf-of) caller' , async ( ) => {
369- const err = await refusalOf ( engine . find ( 'rid_child' , {
370- where : branch ( ) ,
371- context : { userId : 'usr_agent' , onBehalfOf : { userId : 'usr_owner' } } as any ,
372- } ) ) ;
373- expect ( envelopeOf ( err ) ) . toEqual ( REFUSED ) ;
374- expect ( err ! . message ) . toContain ( 'on-behalf-of' ) ;
375- expect ( calls ) . toHaveLength ( 0 ) ;
376- expect ( asked , 'D5 is decided before the security service is asked' ) . toEqual ( [ ] ) ;
377- } ) ;
378-
379508 it ( 'D5 — a parent an object-scoped read middleware narrows' , async ( ) => {
380509 const err = await refusalOf ( engine . count ( 'rid_child' , { where : branch ( 'rid_gated' ) } ) ) ;
381510 expect ( envelopeOf ( err ) ) . toEqual ( REFUSED ) ;
0 commit comments