Skip to content

Commit 3e8e669

Browse files
os-zhuangos-zhuangclaude
authored
fix(plugin-audit): forward the caller's execution envelope to the sys_comment sharing gates (#7141) (#7143)
* fix(plugin-audit): forward the caller's execution envelope to the sys_comment sharing gates (#7141) `callerContext()` rebuilt a five-field projection of the caller's `ExecutionContext` before handing it to `ISharingService.canEdit`, whose contract declares the full envelope and whose doc block forbids callers from rebuilding a subset of it (#6523 / the #6206 ruling). #7136 (PR #7140) widened the return annotation; this is the deferred body change. The projection was doing two jobs. Stripping the middleware-private keys is correct and is preserved — plugin-security stamps the access DEPTH resolved for `sys_comment` onto the context in place, while these gates ask the sharing service about the PARENT record's object — but it is now done by the `__` PREFIX, which also covers the engine's other operation-private markers (`__expandRead` waives object-level CRUD) and cannot go stale. Stripping the principal fields was the defect: `onBehalfOf` (which `ISecurityService.hasWriteBypass` reads to fail CLOSED on a delegated context) and `principalKind` (which `resolvePermissionSetsForContext` reads to cap an ADR-0090 D10 agent at its consented ceiling instead of appending the additive human baseline), plus `systemPermissions`, `accessible_org_ids`, `posture`, `audience` and `rlsMembership`. No access depth is synthesised for the parent object: absent depth leaves the owner-match at its narrowest (`own`), byte-for-byte what the projection produced. The read side's parent probe gets the same envelope-minus-private-keys treatment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ * test(plugin-audit): type the canEdit spy so tsc can read its call tuple (#7141) `vi.fn(async () => true)` gives the mock a zero-length parameter tuple, so `calls[0]![2]` is a TS2493 under `tsc --noEmit` even though vitest records the argument at runtime. Declare the three parameters the contract passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BM1tNf5U3nEbHKR4fo5qVQ --------- Co-authored-by: os-zhuang <steve@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3415a61 commit 3e8e669

4 files changed

Lines changed: 376 additions & 22 deletions

File tree

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

packages/plugins/plugin-audit/src/comment-access-hooks.test.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,3 +301,198 @@ describe('comment access — beforeDelete (author or parent editor)', () => {
301301
).resolves.toBeUndefined();
302302
});
303303
});
304+
305+
306+
// ─────────────────────────────────────────────────────────────────────────
307+
// #7141 — what the gate FORWARDS to the sharing service
308+
// ─────────────────────────────────────────────────────────────────────────
309+
310+
/**
311+
* The caller's execution envelope as a real transport builds it — an OAuth MCP
312+
* agent principal acting on behalf of a human (`resolve-execution-context.ts`
313+
* is the live producer of `principalKind: 'agent'` + `onBehalfOf`) — with the
314+
* middleware-private keys plugin-security stamps for the object of the
315+
* operation in flight (`sys_comment`) riding along, because that is exactly
316+
* what `sc.__readScope = …` leaves on the context these hooks receive.
317+
*/
318+
const DELEGATED_ENVELOPE = {
319+
userId: 'human_1',
320+
tenantId: 'org_1',
321+
email: 'human@example.com',
322+
positions: [],
323+
permissions: ['mcp_agent_data_write'],
324+
systemPermissions: [],
325+
principalKind: 'agent',
326+
onBehalfOf: { userId: 'human_1', principalKind: 'human' },
327+
audience: 'internal',
328+
posture: 'authenticated',
329+
accessible_org_ids: ['org_1'],
330+
rlsMembership: { team: ['t1'] },
331+
isSystem: false,
332+
// Middleware-private, resolved for `sys_comment` — NOT for the parent.
333+
__readScope: 'org',
334+
__writeScope: 'org',
335+
__delegatorReadScope: 'org',
336+
__delegatorWriteScope: 'org',
337+
__expandRead: true,
338+
} as const;
339+
340+
/** The same context, shaped the way the write hooks receive it. */
341+
const envelopeWriteCtx = (
342+
event: 'beforeUpdate' | 'beforeDelete',
343+
input: any,
344+
exec: Record<string, unknown>,
345+
) => ({
346+
object: 'sys_comment',
347+
event,
348+
input: { ...input, options: { ...(input.options ?? {}), context: exec } },
349+
session: { userId: exec.userId as string },
350+
api: apiFor([]),
351+
});
352+
353+
/** The deployment's `fallbackPermissionSet` (ADR-0056 D7: an app's `isDefault`
354+
* profile, else the built-in `member_default`). */
355+
const DEPLOYMENT_BASELINE_SET = 'app_default_profile';
356+
357+
/**
358+
* `ISecurityService.hasWriteBypass` as plugin-security implements it
359+
* (`security-plugin.ts`) — the three guard lines, then the `modifyAllRecords`
360+
* set probe. A DOUBLE, not a copy of production logic: plugin-audit does not
361+
* depend on plugin-security (dependency-free posture), so the only way to pin
362+
* the OUTCOME on this side of the seam is to model the contract the gate is
363+
* documented to be talking to. `setsWithBypass` names which permission sets
364+
* carry the bit in the modelled deployment.
365+
*/
366+
function hasWriteBypassDouble(context: any, setsWithBypass: string[]): boolean {
367+
if (context?.isSystem) return true;
368+
if (!context?.userId) return false;
369+
if (context?.onBehalfOf?.userId) return false; // documented fail-CLOSED on delegation
370+
// `resolvePermissionSetsForContext`: positions + explicit sets, plus the
371+
// ADDITIVE human baseline — which an ADR-0090 D10 agent principal must NOT
372+
// receive (its grants are exactly its scope-derived ceiling).
373+
const requested = [...(context?.positions ?? []), ...(context?.permissions ?? [])];
374+
const resolved =
375+
context?.principalKind === 'agent' ? requested : [...requested, DEPLOYMENT_BASELINE_SET];
376+
return resolved.some((name: string) => setsWithBypass.includes(name));
377+
}
378+
379+
/**
380+
* `SharingService.checkEdit`'s positive bases, in order: ownership widened by
381+
* the middleware-stamped write DEPTH (`matchesOwnerScope` — `__writeScope ===
382+
* 'org'` short-circuits to true), then the `modifyAllRecords` bypass. The share
383+
* branch is omitted (no grants in these fixtures).
384+
*/
385+
function sharingCanEditDouble(opts: { ownerId: string; setsWithBypass?: string[] }) {
386+
return vi.fn(async (_object: string, _recordId: string, callerCtx: any) => {
387+
if (callerCtx?.isSystem) return true;
388+
if (!callerCtx?.userId) return false;
389+
if ((callerCtx as any).__writeScope === 'org') return true; // depth fast-exit
390+
if (String(callerCtx.userId) === opts.ownerId) return true;
391+
return hasWriteBypassDouble(callerCtx, opts.setsWithBypass ?? []);
392+
});
393+
}
394+
395+
describe('#7141 — caller envelope forwarded to the sharing gate', () => {
396+
const row = { id: 'c1', thread_id: 'crm_opportunity:opp1', author_id: 'someone_else', body: 'hi' };
397+
398+
it('forwards the whole envelope MINUS the operation-private keys', async () => {
399+
const canEdit = vi.fn(async (_object: string, _recordId: string, _callerCtx: any) => true);
400+
const { beforeDelete } = install({ comments: [row], sharing: { canEdit } });
401+
await beforeDelete(envelopeWriteCtx('beforeDelete', { id: 'c1' }, { ...DELEGATED_ENVELOPE }));
402+
403+
const forwarded = canEdit.mock.calls[0]![2] as unknown as Record<string, unknown>;
404+
// Every principal field survives — the #6523 contract's unit is the envelope
405+
// and #6206 forbids rebuilding a subset of it.
406+
expect(forwarded).toEqual({
407+
userId: 'human_1',
408+
tenantId: 'org_1',
409+
email: 'human@example.com',
410+
positions: [],
411+
permissions: ['mcp_agent_data_write'],
412+
systemPermissions: [],
413+
principalKind: 'agent',
414+
onBehalfOf: { userId: 'human_1', principalKind: 'human' },
415+
audience: 'internal',
416+
posture: 'authenticated',
417+
accessible_org_ids: ['org_1'],
418+
rlsMembership: { team: ['t1'] },
419+
isSystem: false,
420+
});
421+
// …and every middleware-private key resolved for `sys_comment` is gone.
422+
for (const key of ['__readScope', '__writeScope', '__delegatorReadScope', '__delegatorWriteScope', '__expandRead']) {
423+
expect(forwarded).not.toHaveProperty(key);
424+
}
425+
});
426+
427+
it('hands the service a COPY, so a callee stamping its own depth cannot write back', async () => {
428+
const exec: Record<string, unknown> = { ...DELEGATED_ENVELOPE };
429+
const canEdit = vi.fn(async (_o: string, _r: string, callerCtx: any) => {
430+
// What plugin-security does right before it calls the sharing service.
431+
callerCtx.__writeScope = 'unit';
432+
return true;
433+
});
434+
const { beforeDelete } = install({ comments: [row], sharing: { canEdit } });
435+
await beforeDelete(envelopeWriteCtx('beforeDelete', { id: 'c1' }, exec));
436+
437+
expect(canEdit.mock.calls[0]![2]).not.toBe(exec);
438+
expect(exec.__writeScope).toBe('org'); // untouched: still sys_comment's own
439+
});
440+
441+
it('REFUSES a delegated principal whose sets carry modifyAllRecords (fail-closed, #7141)', async () => {
442+
// The exploit shape the card names: an OAuth agent on the `/mcp` surface
443+
// presenting sets that carry the super-user write bypass. `hasWriteBypass`
444+
// is documented to fail CLOSED on `onBehalfOf` — it can only do that if the
445+
// field reaches it.
446+
const canEdit = sharingCanEditDouble({ ownerId: 'other_owner', setsWithBypass: ['admin_full_access'] });
447+
const { beforeDelete } = install({ comments: [row], sharing: { canEdit } });
448+
await expect(
449+
beforeDelete(
450+
envelopeWriteCtx('beforeDelete', { id: 'c1' }, {
451+
...DELEGATED_ENVELOPE,
452+
permissions: ['admin_full_access'],
453+
}),
454+
),
455+
).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 });
456+
expect(canEdit).toHaveBeenCalledTimes(1);
457+
});
458+
459+
it('keeps an AGENT principal capped at its ceiling — no additive human baseline (ADR-0090 D10)', async () => {
460+
// `resolvePermissionSetsForContext` keys that rule on `principalKind`, which
461+
// the old projection dropped: the agent was resolved as a human and the
462+
// deployment's default profile was appended to its consented ceiling.
463+
const canEdit = sharingCanEditDouble({
464+
ownerId: 'other_owner',
465+
setsWithBypass: [DEPLOYMENT_BASELINE_SET],
466+
});
467+
const { beforeDelete } = install({ comments: [row], sharing: { canEdit } });
468+
await expect(
469+
beforeDelete(
470+
envelopeWriteCtx('beforeDelete', { id: 'c1' }, {
471+
...DELEGATED_ENVELOPE,
472+
onBehalfOf: undefined, // isolate the ceiling rule from the delegation guard
473+
}),
474+
),
475+
).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 });
476+
});
477+
478+
it('does NOT carry sys_comment\'s access DEPTH into the parent\'s owner-match', async () => {
479+
// The half of the old projection that was CORRECT and must survive: the
480+
// context carries `__writeScope: 'org'` resolved for `sys_comment`, and the
481+
// gate asks about `crm_opportunity`. Forwarding it whole would widen one
482+
// object's question with another object's answer.
483+
const canEdit = sharingCanEditDouble({ ownerId: 'other_owner' });
484+
const { beforeDelete } = install({ comments: [row], sharing: { canEdit } });
485+
await expect(
486+
beforeDelete(
487+
envelopeWriteCtx('beforeDelete', { id: 'c1' }, {
488+
...DELEGATED_ENVELOPE,
489+
principalKind: 'human',
490+
onBehalfOf: undefined,
491+
userId: 'plain_member',
492+
permissions: [],
493+
}),
494+
),
495+
).rejects.toMatchObject({ code: 'RECORD_NOT_ACCESSIBLE', status: 403 });
496+
expect((canEdit.mock.calls[0]![2] as any).__writeScope).toBeUndefined();
497+
});
498+
});

packages/plugins/plugin-audit/src/comment-access-hooks.ts

Lines changed: 87 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -180,35 +180,90 @@ function asIdList(id: unknown): Array<string | number> | null {
180180
return null;
181181
}
182182

183+
/**
184+
* Keys plugin-security's middleware STAMPS onto the operation context, resolved
185+
* for the object of the CURRENT operation — `sys_comment` here.
186+
*
187+
* They are middleware-private vocabulary, not fields of `ExecutionContext`, and
188+
* they are all read as WIDENING inputs by whoever consumes them: the ADR-0057
189+
* D1 access DEPTH the sharing owner-match expands to (`__readScope` /
190+
* `__writeScope`, plus the ADR-0090 D10 delegator halves
191+
* `__delegatorReadScope` / `__delegatorWriteScope`, `security-plugin.ts` — `sc.__readScope = …`),
192+
* and the engine's internal privilege markers on the same channel
193+
* (`__expandRead` waives the object-level CRUD check for a lookup expansion,
194+
* `__referentialFieldClear` the referential-clear write).
195+
*
196+
* Every gate in this module asks about the PARENT record's object, never about
197+
* `sys_comment`, so carrying any of these across is one object's widening
198+
* applied to another object's question — the exact stale-scope leak
199+
* `resolveWriteScopeForSharing` was extracted to prevent ("a stale value can
200+
* never leak in through a spread", `security-plugin.ts`). They are therefore
201+
* dropped by PREFIX rather than by a name list: the `__` convention is what
202+
* marks a key as belonging to the operation in flight, and a list would go
203+
* stale the day the middleware stamps a fifth one.
204+
*/
205+
const OPERATION_PRIVATE_KEY_PREFIX = '__';
206+
207+
/**
208+
* The caller's execution envelope, minus the operation-private keys above.
209+
*
210+
* [#7141] A FRESH object every time, so a callee that stamps its own
211+
* `__writeScope` onto what it receives (which is exactly what plugin-security
212+
* does before it calls the sharing service) can never write back into the
213+
* operation context this hook was handed.
214+
*/
215+
function withoutOperationPrivateKeys(exec: Record<string, unknown>): ExecutionContext {
216+
const out: Record<string, unknown> = {};
217+
for (const [key, value] of Object.entries(exec)) {
218+
if (key.startsWith(OPERATION_PRIVATE_KEY_PREFIX)) continue;
219+
out[key] = value;
220+
}
221+
return out as ExecutionContext;
222+
}
223+
183224
/** The caller's ExecutionContext rides on the operation options — the session
184225
* snapshot lacks `permissions`, which sharing bypasses need.
185226
*
186227
* [#7136] Typed as the full envelope, which is what `ISharingService` declares
187228
* for every parameter this value is handed to (#6523 / the #6206 ruling).
188229
*
189-
* ⚠️ The BODY still projects a five-field subset, which the same ruling tells
190-
* callers not to do — and that half is deliberately NOT changed here, because
191-
* it is not the inert half. Widening the annotation is type-side; forwarding
192-
* `exec` whole is a RUNTIME change. plugin-security's middleware MUTATES the
193-
* operation context in place (`sc.__readScope = …`, `security-plugin.ts`), so
194-
* the context this hook receives carries the depth resolved for `sys_comment` —
195-
* the object of the operation — while these gates ask the sharing service about
196-
* the PARENT record's object. Forwarding it would hand one object's access
197-
* depth to another object's owner-match, the exact stale-scope leak
198-
* `resolveWriteScopeForSharing` was extracted to prevent ("a stale value can
199-
* never leak in through a spread"). This projection is currently what stops
200-
* that, so replacing it needs its own card and its own evidence — filed rather
201-
* than folded in. */
230+
* [#7141] And FORWARDED as the full envelope, which is the other half of that
231+
* ruling: a caller "MUST NOT rebuild a subset of it". The five-field projection
232+
* this replaced (`userId` / `tenantId` / `positions` / `permissions` /
233+
* `isSystem`) was doing two jobs at once, and only one of them was correct:
234+
*
235+
* - dropping the middleware-private keys — CORRECT, and preserved above by
236+
* {@link withoutOperationPrivateKeys}: `return exec;` would hand
237+
* `sys_comment`'s access depth to the parent object's owner-match;
238+
* - dropping the PRINCIPAL fields — the defect. Two of them decide the
239+
* verdict the gate then trusts:
240+
* * `onBehalfOf` — `ISecurityService.hasWriteBypass`, the `modifyAllRecords`
241+
* probe `SharingService.canEdit` consults last, is documented to fail
242+
* CLOSED on a delegated context and implements that by reading exactly
243+
* `context?.onBehalfOf?.userId` (`security-plugin.ts`). Stripped, that
244+
* guard could never fire here, and a `/mcp` OAuth agent principal (which
245+
* `resolve-execution-context.ts` builds WITH the delegation link) reached
246+
* the bypass probe looking like an ordinary direct call.
247+
* * `principalKind` — `resolvePermissionSetsForContext` keys the ADR-0090
248+
* D10 rule "an agent's grants are EXACTLY its scope-derived ceiling" on
249+
* `principalKind === 'agent'`; stripped, the additive human baseline
250+
* (`member_default`) was appended to an agent's ceiling on this path, so
251+
* the sets the bypass probe evaluated were a SUPERSET of what the user
252+
* consented to.
253+
*
254+
* `systemPermissions`, `accessible_org_ids`, `posture`, `audience` and
255+
* `rlsMembership` were dropped by the same projection; they are forwarded
256+
* now for the same reason — the envelope is the contract's unit.
257+
*
258+
* Note what deliberately did NOT change: no access DEPTH is synthesised for the
259+
* parent object. Absent depth leaves the sharing owner-match at its narrowest
260+
* (`own`) — the safe direction, and byte-for-byte the behaviour the projection
261+
* produced. Resolving the parent's own depth (the other candidate shape) would
262+
* WIDEN this gate and is a separate decision; see #7141's PR discussion. */
202263
function callerContext(ctx: any): ExecutionContext {
203264
const exec = ctx?.input?.options?.context;
204265
if (exec && typeof exec === 'object') {
205-
return {
206-
userId: exec.userId,
207-
tenantId: exec.tenantId,
208-
positions: exec.positions,
209-
permissions: exec.permissions,
210-
isSystem: exec.isSystem,
211-
};
266+
return withoutOperationPrivateKeys(exec as Record<string, unknown>);
212267
}
213268
const s = ctx?.session ?? {};
214269
return { userId: s.userId, tenantId: s.tenantId ?? s.organizationId, positions: s.positions };
@@ -506,6 +561,17 @@ async function computeThreadVisibilityFilter(
506561

507562
// 2. Per parent object, the visible id subset via the CALLER's context —
508563
// the parent object's own RLS/OWD/sharing applies.
564+
//
565+
// [#7141] The caller's envelope, minus the operation-private keys: this
566+
// probe reads a DIFFERENT object than the one the middleware resolved its
567+
// depth for, and `__readScope` / `__expandRead` are widening inputs that
568+
// would arrive attached to the wrong question (the security middleware
569+
// re-stamps the depth for THIS object when it resolves any set, so the
570+
// only thing dropping them can do is leave the owner-match at its
571+
// narrowest — the safe direction). Same rule as `callerContext` above.
572+
const callerEnvelope = withoutOperationPrivateKeys(
573+
(ctx.context ?? {}) as Record<string, unknown>,
574+
);
509575
const visibleByObject = new Map<string, Set<string>>();
510576
for (const [parentObject, idSet] of byObject) {
511577
const ids = [...idSet];
@@ -515,7 +581,7 @@ async function computeThreadVisibilityFilter(
515581
where: { id: { $in: ids } },
516582
fields: ['id'],
517583
limit: ids.length,
518-
context: { ...ctx.context },
584+
context: { ...callerEnvelope },
519585
});
520586
visible = rows.map((r) => String(r.id)).filter(Boolean);
521587
} catch {

0 commit comments

Comments
 (0)