Skip to content

Commit 36ca4c4

Browse files
committed
fix(service-storage): forward the caller's execution envelope to the sys_attachment sharing gates (#7145)
`callerContext()` rebuilt a five-field projection of the caller's ExecutionContext before handing it to `ISharingService.canEdit`, whose contract declares the full envelope and forbids callers rebuilding a subset of it (#6523 / the #6206 ruling). Same defect PR #7143 fixed for the sys_comment kit (#7141), one package over. The projection did two jobs and only one was correct: dropping the middleware-private `__`-prefixed keys (preserved, now by PREFIX) vs dropping the principal fields (the defect — `onBehalfOf` disarmed the fail-closed `hasWriteBypass` guard, `principalKind` resolved an ADR-0090 D10 agent principal as a human). Applied to both canEdit call sites and to the read middleware's parent-visibility probe. No parent access DEPTH is synthesised: absent depth leaves the owner-match at `own`, byte-for-byte what the projection produced (the #7144 decision is untouched). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016R9de1FqP7NvwKvqXi92Gh
1 parent aeadbd6 commit 36ca4c4

4 files changed

Lines changed: 489 additions & 11 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/service-storage": patch
3+
---
4+
5+
fix(service-storage): forward the caller's full execution envelope to the `sys_attachment` sharing gates (#7145)
6+
7+
`callerContext()` in `attachment-access-hooks.ts` rebuilt a five-field
8+
projection of the caller's `ExecutionContext` (`userId` / `tenantId` /
9+
`positions` / `permissions` / `isSystem`) before handing it to
10+
`ISharingService.canEdit`, whose contract declares the **full** envelope and
11+
whose doc block tells callers they "MUST NOT rebuild a subset of it" (#6523 /
12+
the #6206 ruling). This is the same defect PR #7143 fixed for the `sys_comment`
13+
kit (#7141), one package over — the attachment kit is what the comment kit was
14+
derived from.
15+
16+
The projection was doing two jobs at once and only one of them was correct:
17+
18+
- **Dropping the middleware-private keys was correct**, and is preserved.
19+
plugin-security's middleware stamps the access DEPTH it resolved for the
20+
object of the operation in flight — `sys_attachment` here — onto the context
21+
in place (`sc.__readScope = …`), while these gates ask the sharing service
22+
about the **parent record's** object. Forwarding that whole would hand one
23+
object's widening to another object's owner-match, the stale-scope leak
24+
`resolveWriteScopeForSharing` was extracted to prevent. The keys are now
25+
dropped by the `__` **prefix** rather than by name, which also covers the
26+
engine's other operation-private markers on that channel (`__expandRead`
27+
waives the object-level CRUD check, `__referentialFieldClear` the
28+
referential-clear write) and cannot go stale when a fifth key is added.
29+
- **Dropping the principal fields was the defect.** Two of them decide the
30+
verdict these gates then trust:
31+
- `onBehalfOf``ISecurityService.hasWriteBypass`, the `modifyAllRecords`
32+
probe `SharingService.canEdit` consults last, is documented to fail CLOSED
33+
on a delegated context and implements that by reading exactly
34+
`context?.onBehalfOf?.userId`. Stripped, the guard could never fire on the
35+
attachment path, and the `/mcp` OAuth agent principal that
36+
`resolve-execution-context` builds *with* the delegation link reached the
37+
bypass probe looking like an ordinary direct call.
38+
- `principalKind``resolvePermissionSetsForContext` keys the ADR-0090 D10
39+
rule "an agent's grants are EXACTLY its scope-derived ceiling" on
40+
`principalKind === 'agent'`. Stripped, the additive human baseline was
41+
appended to an agent's ceiling here, so the sets the bypass probe evaluated
42+
were a superset of what the user consented to.
43+
44+
`systemPermissions`, `accessible_org_ids`, `posture`, `audience` and
45+
`rlsMembership` were dropped by the same projection and are forwarded now for
46+
the same reason.
47+
48+
Both `canEdit` call sites are covered — the `beforeInsert` parent gate and the
49+
`beforeDelete` per-row authorization loop — and the same
50+
envelope-minus-private-keys rule is applied to the read middleware's
51+
parent-visibility probe, which spread the whole operation context into a `find`
52+
on a different object.
53+
54+
No access depth is synthesised for the parent object: absent depth leaves the
55+
sharing owner-match at its narrowest (`own`), which is the safe direction and
56+
byte-for-byte what the projection produced. Resolving the parent's own depth
57+
would WIDEN these gates and is deliberately left to the separate decision
58+
tracked as #7144.
59+
60+
Enforcement effect: a delegated (`onBehalfOf`-carrying) principal is now refused
61+
where the contract says it is refused. No caller gains access.

packages/services/service-storage/src/attachment-access-hooks.test.ts

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)