@@ -268,3 +268,295 @@ describe('attachment access — beforeDelete (uploader or parent editor)', () =>
268268 } ) ;
269269 } ) ;
270270} ) ;
271+
272+ // ─────────────────────────────────────────────────────────────────────────
273+ // #7145 — what the gate FORWARDS to the sharing service
274+ //
275+ // The mirror of #7141 / PR #7143 for the attachment kit: `callerContext()`
276+ // rebuilt a five-field projection of the caller's execution envelope before
277+ // handing it to `ISharingService.canEdit`, whose contract declares the FULL
278+ // envelope and whose doc block tells callers they "MUST NOT rebuild a subset
279+ // of it" (#6523 / the #6206 ruling).
280+ // ─────────────────────────────────────────────────────────────────────────
281+
282+ /**
283+ * The caller's execution envelope as a real transport builds it — an OAuth MCP
284+ * agent principal acting on behalf of a human (`resolve-execution-context.ts`
285+ * is the live producer of `principalKind: 'agent'` + `onBehalfOf`) — with the
286+ * middleware-private keys plugin-security stamps for the object of the
287+ * operation in flight (`sys_attachment`) riding along, because that is exactly
288+ * what `sc.__readScope = …` leaves on the context these hooks receive.
289+ */
290+ const DELEGATED_ENVELOPE = {
291+ userId : 'human_1' ,
292+ tenantId : 'org_1' ,
293+ email : 'human@example.com' ,
294+ positions : [ ] ,
295+ permissions : [ 'mcp_agent_data_write' ] ,
296+ systemPermissions : [ ] ,
297+ principalKind : 'agent' ,
298+ onBehalfOf : { userId : 'human_1' , principalKind : 'human' } ,
299+ audience : 'internal' ,
300+ posture : 'authenticated' ,
301+ accessible_org_ids : [ 'org_1' ] ,
302+ rlsMembership : { team : [ 't1' ] } ,
303+ isSystem : false ,
304+ // Middleware-private, resolved for `sys_attachment` — NOT for the parent.
305+ __readScope : 'org' ,
306+ __writeScope : 'org' ,
307+ __delegatorReadScope : 'org' ,
308+ __delegatorWriteScope : 'org' ,
309+ __expandRead : true ,
310+ } as const ;
311+
312+ /** The principal half of {@link DELEGATED_ENVELOPE} — everything that must
313+ * survive the forward, and nothing that must not. */
314+ const DELEGATED_PRINCIPAL_FIELDS = {
315+ userId : 'human_1' ,
316+ tenantId : 'org_1' ,
317+ email : 'human@example.com' ,
318+ positions : [ ] ,
319+ permissions : [ 'mcp_agent_data_write' ] ,
320+ systemPermissions : [ ] ,
321+ principalKind : 'agent' ,
322+ onBehalfOf : { userId : 'human_1' , principalKind : 'human' } ,
323+ audience : 'internal' ,
324+ posture : 'authenticated' ,
325+ accessible_org_ids : [ 'org_1' ] ,
326+ rlsMembership : { team : [ 't1' ] } ,
327+ isSystem : false ,
328+ } ;
329+
330+ const OPERATION_PRIVATE_KEYS = [
331+ '__readScope' ,
332+ '__writeScope' ,
333+ '__delegatorReadScope' ,
334+ '__delegatorWriteScope' ,
335+ '__expandRead' ,
336+ ] ;
337+
338+ /** An insert ctx carrying an explicit execution envelope. */
339+ const envelopeInsertCtx = ( data : any , exec : Record < string , unknown > ) => ( {
340+ object : 'sys_attachment' ,
341+ event : 'beforeInsert' ,
342+ input : { data, options : { context : exec } } ,
343+ session : { userId : exec . userId as string } ,
344+ api : apiFor ( [ ] ) ,
345+ } ) ;
346+
347+ /** A delete ctx carrying an explicit execution envelope. */
348+ const envelopeDeleteCtx = ( input : any , exec : Record < string , unknown > ) => ( {
349+ object : 'sys_attachment' ,
350+ event : 'beforeDelete' ,
351+ input : { ...input , options : { ...( input . options ?? { } ) , context : exec } } ,
352+ session : { userId : exec . userId as string } ,
353+ api : apiFor ( [ ] ) ,
354+ } ) ;
355+
356+ /** The deployment's `fallbackPermissionSet` (ADR-0056 D7: an app's `isDefault`
357+ * profile, else the built-in `member_default`). */
358+ const DEPLOYMENT_BASELINE_SET = 'app_default_profile' ;
359+
360+ /**
361+ * `ISecurityService.hasWriteBypass` as plugin-security implements it
362+ * (`security-plugin.ts`) — the three guard lines, then the `modifyAllRecords`
363+ * set probe. A DOUBLE, not a copy of production logic: service-storage does not
364+ * depend on plugin-security (it consults a duck-typed `AttachmentSharingLike`),
365+ * so the only way to pin the OUTCOME on this side of the seam is to model the
366+ * contract the gate is documented to be talking to. `setsWithBypass` names
367+ * which permission sets carry the bit in the modelled deployment.
368+ */
369+ function hasWriteBypassDouble ( context : any , setsWithBypass : string [ ] ) : boolean {
370+ if ( context ?. isSystem ) return true ;
371+ if ( ! context ?. userId ) return false ;
372+ if ( context ?. onBehalfOf ?. userId ) return false ; // documented fail-CLOSED on delegation
373+ // `resolvePermissionSetsForContext`: positions + explicit sets, plus the
374+ // ADDITIVE human baseline — which an ADR-0090 D10 agent principal must NOT
375+ // receive (its grants are exactly its scope-derived ceiling).
376+ const requested = [ ...( context ?. positions ?? [ ] ) , ...( context ?. permissions ?? [ ] ) ] ;
377+ const resolved =
378+ context ?. principalKind === 'agent' ? requested : [ ...requested , DEPLOYMENT_BASELINE_SET ] ;
379+ return resolved . some ( ( name : string ) => setsWithBypass . includes ( name ) ) ;
380+ }
381+
382+ /**
383+ * `SharingService.checkEdit`'s positive bases, in order: ownership widened by
384+ * the middleware-stamped write DEPTH (`matchesOwnerScope` — `__writeScope ===
385+ * 'org'` short-circuits to true), then the `modifyAllRecords` bypass. The share
386+ * branch is omitted (no grants in these fixtures).
387+ */
388+ function sharingCanEditDouble ( opts : { ownerId : string ; setsWithBypass ?: string [ ] } ) {
389+ return vi . fn ( async ( _object : string , _recordId : string , callerCtx : any ) => {
390+ if ( callerCtx ?. isSystem ) return true ;
391+ if ( ! callerCtx ?. userId ) return false ;
392+ if ( ( callerCtx as any ) . __writeScope === 'org' ) return true ; // depth fast-exit
393+ if ( String ( callerCtx . userId ) === opts . ownerId ) return true ;
394+ return hasWriteBypassDouble ( callerCtx , opts . setsWithBypass ?? [ ] ) ;
395+ } ) ;
396+ }
397+
398+ describe ( '#7145 — caller envelope forwarded to the sharing gate' , ( ) => {
399+ const attRow = {
400+ id : 'a1' ,
401+ file_id : 'f1' ,
402+ parent_object : 'att_secret' ,
403+ parent_id : 'r1' ,
404+ uploaded_by : 'someone_else' ,
405+ } ;
406+
407+ // ── The forward itself, on BOTH call sites ────────────────────────────
408+ it ( 'beforeInsert forwards the whole envelope MINUS the operation-private keys' , async ( ) => {
409+ const canEdit = vi . fn ( async ( _o : string , _r : string , _c : any ) => true ) ;
410+ const { beforeInsert } = install ( { sharing : { canEdit } } ) ;
411+ await beforeInsert (
412+ envelopeInsertCtx ( { parent_object : 'att_case' , parent_id : 'r1' , file_id : 'f1' } , {
413+ ...DELEGATED_ENVELOPE ,
414+ } ) ,
415+ ) ;
416+
417+ const forwarded = canEdit . mock . calls [ 0 ] ! [ 2 ] as unknown as Record < string , unknown > ;
418+ // Every principal field survives — the #6523 contract's unit is the
419+ // envelope, and #6206 forbids rebuilding a subset of it. `uploaded_by`
420+ // stamping does not touch the context.
421+ expect ( forwarded ) . toEqual ( DELEGATED_PRINCIPAL_FIELDS ) ;
422+ // …and every middleware-private key resolved for `sys_attachment` is gone.
423+ for ( const key of OPERATION_PRIVATE_KEYS ) expect ( forwarded ) . not . toHaveProperty ( key ) ;
424+ } ) ;
425+
426+ it ( 'beforeDelete forwards the whole envelope MINUS the operation-private keys' , async ( ) => {
427+ const canEdit = vi . fn ( async ( _o : string , _r : string , _c : any ) => true ) ;
428+ const { beforeDelete } = install ( { attachments : [ attRow ] , sharing : { canEdit } } ) ;
429+ await beforeDelete ( envelopeDeleteCtx ( { id : 'a1' } , { ...DELEGATED_ENVELOPE } ) ) ;
430+
431+ const forwarded = canEdit . mock . calls [ 0 ] ! [ 2 ] as unknown as Record < string , unknown > ;
432+ expect ( forwarded ) . toEqual ( DELEGATED_PRINCIPAL_FIELDS ) ;
433+ for ( const key of OPERATION_PRIVATE_KEYS ) expect ( forwarded ) . not . toHaveProperty ( key ) ;
434+ } ) ;
435+
436+ it ( 'hands the service a COPY, so a callee stamping its own depth cannot write back' , async ( ) => {
437+ const exec : Record < string , unknown > = { ...DELEGATED_ENVELOPE } ;
438+ const canEdit = vi . fn ( async ( _o : string , _r : string , callerCtx : any ) => {
439+ // What plugin-security does right before it calls the sharing service.
440+ callerCtx . __writeScope = 'unit' ;
441+ return true ;
442+ } ) ;
443+ const { beforeDelete } = install ( { attachments : [ attRow ] , sharing : { canEdit } } ) ;
444+ await beforeDelete ( envelopeDeleteCtx ( { id : 'a1' } , exec ) ) ;
445+
446+ expect ( canEdit . mock . calls [ 0 ] ! [ 2 ] ) . not . toBe ( exec ) ;
447+ expect ( exec . __writeScope ) . toBe ( 'org' ) ; // untouched: still sys_attachment's own
448+ } ) ;
449+
450+ // ── What the restored fields BUY: the two verdict-deciding ones ───────
451+ it ( 'REFUSES a delegated principal whose sets carry modifyAllRecords (fail-closed, #7145)' , async ( ) => {
452+ // The exploit shape the card names: an OAuth agent on the `/mcp` surface
453+ // presenting sets that carry the super-user write bypass. `hasWriteBypass`
454+ // is documented to fail CLOSED on `onBehalfOf` — it can only do that if the
455+ // field reaches it.
456+ const canEdit = sharingCanEditDouble ( {
457+ ownerId : 'other_owner' ,
458+ setsWithBypass : [ 'admin_full_access' ] ,
459+ } ) ;
460+ const { beforeDelete } = install ( { attachments : [ attRow ] , sharing : { canEdit } } ) ;
461+ await expect (
462+ beforeDelete (
463+ envelopeDeleteCtx ( { id : 'a1' } , {
464+ ...DELEGATED_ENVELOPE ,
465+ permissions : [ 'admin_full_access' ] ,
466+ } ) ,
467+ ) ,
468+ ) . rejects . toMatchObject ( { code : 'ATTACHMENT_DELETE_DENIED' , status : 403 } ) ;
469+ expect ( canEdit ) . toHaveBeenCalledTimes ( 1 ) ;
470+ } ) ;
471+
472+ it ( 'REFUSES the same delegated principal on the beforeInsert parent gate too' , async ( ) => {
473+ // Both `canEdit` call sites read the same `callerContext()`, so the guard
474+ // has to arrive on the attach path as well as the detach one.
475+ const canEdit = sharingCanEditDouble ( {
476+ ownerId : 'other_owner' ,
477+ setsWithBypass : [ 'admin_full_access' ] ,
478+ } ) ;
479+ const { beforeInsert } = install ( { sharing : { canEdit } } ) ;
480+ await expect (
481+ beforeInsert (
482+ envelopeInsertCtx ( { parent_object : 'att_secret' , parent_id : 'r1' , file_id : 'f1' } , {
483+ ...DELEGATED_ENVELOPE ,
484+ permissions : [ 'admin_full_access' ] ,
485+ } ) ,
486+ ) ,
487+ ) . rejects . toMatchObject ( { code : 'ATTACHMENT_PARENT_ACCESS' , status : 403 } ) ;
488+ expect ( canEdit ) . toHaveBeenCalledTimes ( 1 ) ;
489+ } ) ;
490+
491+ it ( 'keeps an AGENT principal capped at its ceiling — no additive human baseline (ADR-0090 D10)' , async ( ) => {
492+ // `resolvePermissionSetsForContext` keys that rule on `principalKind`, which
493+ // the old projection dropped: the agent was resolved as a human and the
494+ // deployment's default profile was appended to its consented ceiling.
495+ const canEdit = sharingCanEditDouble ( {
496+ ownerId : 'other_owner' ,
497+ setsWithBypass : [ DEPLOYMENT_BASELINE_SET ] ,
498+ } ) ;
499+ const { beforeDelete } = install ( { attachments : [ attRow ] , sharing : { canEdit } } ) ;
500+ await expect (
501+ beforeDelete (
502+ envelopeDeleteCtx ( { id : 'a1' } , {
503+ ...DELEGATED_ENVELOPE ,
504+ onBehalfOf : undefined , // isolate the ceiling rule from the delegation guard
505+ } ) ,
506+ ) ,
507+ ) . rejects . toMatchObject ( { code : 'ATTACHMENT_DELETE_DENIED' , status : 403 } ) ;
508+ } ) ;
509+
510+ // ── The half of the old projection that was CORRECT ───────────────────
511+ it ( "does NOT carry sys_attachment's access DEPTH into the parent's owner-match" , async ( ) => {
512+ // The context carries `__writeScope: 'org'` resolved for `sys_attachment`,
513+ // and the gate asks about `att_secret`. Forwarding it whole would widen one
514+ // object's question with another object's answer — a plain member would
515+ // detach any file on any record in the org.
516+ const canEdit = sharingCanEditDouble ( { ownerId : 'other_owner' } ) ;
517+ const { beforeDelete } = install ( { attachments : [ attRow ] , sharing : { canEdit } } ) ;
518+ await expect (
519+ beforeDelete (
520+ envelopeDeleteCtx ( { id : 'a1' } , {
521+ ...DELEGATED_ENVELOPE ,
522+ principalKind : 'human' ,
523+ onBehalfOf : undefined ,
524+ userId : 'plain_member' ,
525+ permissions : [ ] ,
526+ } ) ,
527+ ) ,
528+ ) . rejects . toMatchObject ( { code : 'ATTACHMENT_DELETE_DENIED' , status : 403 } ) ;
529+ expect ( ( canEdit . mock . calls [ 0 ] ! [ 2 ] as any ) . __writeScope ) . toBeUndefined ( ) ;
530+ } ) ;
531+
532+ it ( 'does not carry the depth into the beforeInsert parent gate either' , async ( ) => {
533+ const canEdit = sharingCanEditDouble ( { ownerId : 'other_owner' } ) ;
534+ const { beforeInsert } = install ( { sharing : { canEdit } } ) ;
535+ await expect (
536+ beforeInsert (
537+ envelopeInsertCtx ( { parent_object : 'att_secret' , parent_id : 'r1' , file_id : 'f1' } , {
538+ ...DELEGATED_ENVELOPE ,
539+ principalKind : 'human' ,
540+ onBehalfOf : undefined ,
541+ userId : 'plain_member' ,
542+ permissions : [ ] ,
543+ } ) ,
544+ ) ,
545+ ) . rejects . toMatchObject ( { code : 'ATTACHMENT_PARENT_ACCESS' , status : 403 } ) ;
546+ expect ( ( canEdit . mock . calls [ 0 ] ! [ 2 ] as any ) . __writeScope ) . toBeUndefined ( ) ;
547+ } ) ;
548+
549+ // ── The session fallback is unchanged ─────────────────────────────────
550+ it ( 'still falls back to the session snapshot when no execution context rides along' , async ( ) => {
551+ const canEdit = vi . fn ( async ( _o : string , _r : string , _c : any ) => true ) ;
552+ const { beforeDelete } = install ( { attachments : [ attRow ] , sharing : { canEdit } } ) ;
553+ await beforeDelete ( {
554+ object : 'sys_attachment' ,
555+ event : 'beforeDelete' ,
556+ input : { id : 'a1' } ,
557+ session : { userId : 'u1' , tenantId : 'org_1' , positions : [ 'p1' ] } ,
558+ api : apiFor ( [ ] ) ,
559+ } ) ;
560+ expect ( canEdit . mock . calls [ 0 ] ! [ 2 ] ) . toEqual ( { userId : 'u1' , tenantId : 'org_1' , positions : [ 'p1' ] } ) ;
561+ } ) ;
562+ } ) ;
0 commit comments