From 73927acb9439aa6de72c33e81a082eaf8b666252 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 11 Jul 2026 21:38:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20ADR-0090=20D10=20=E2=80=94=20?= =?UTF-8?q?agent/service=20intersection=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a request's principal acts `onBehalfOf` a user (an AI agent or a service acting for a person), the effective permission is now the INTERSECTION of the principal's own grants and the delegator's grants — never the union. Confused-deputy prevention: an over-privileged agent may never see or touch anything the user it stands in for could not, and vice-versa. Previously `principalKind:'agent'` / `onBehalfOf` was a P1 context shape the evaluator did not read. Applied at every axis, gated on the delegation link so the non-delegated path is byte-identical: - plugin-security: delegator sets reconstructed once (fail-CLOSED on a dangling link — a deleted delegator is denied, not resolved to the additive baseline) and AND-composed into the capability gate, object CRUD, FLS (read mask + write forbid + predicate guard), row-level `using` pre-image, `check` post-image, and RLS read injection. View/Modify-All survives only when BOTH principals hold it. - plugin-sharing: the OWD/record-share owner-match is identity-scoped, so it re-runs the visibility filter and canEdit under the delegator's own identity + depth and AND-s it in — an agent with View-All acting for a plain member sees exactly that member's rows, not everyone's. - explain engine: every layer reports the narrower verdict for a delegated principal; a dangling delegator reads as a fail-closed deny. Delegator resolution + the pure combinators (intersectFieldMasks, narrowerScope) are single-sourced in explain-engine.ts and reused by both the enforcement middleware and the explanation, so they can't drift. Tests: 11 unit (security-plugin.test.ts) exercising the real middleware + a served-engine dogfood (showcase-agent-intersection) proving the intersection strips View-All on a real private-OWD object yet still surfaces the delegator's own rows. Full suites green: plugin-security 297, plugin-sharing 76, permission/OWD/sharing dogfood unchanged. First cut: one delegation hop (a safe lower bound on multi-hop); tenant-scoped substitution bags inherited from the live principal, person-specific membership bags left unresolved (narrows, never widens). The agent grant-ceiling lint (D10 rule 2) is a follow-up — the runtime intersection already caps the agent, and the lint needs an agent-set designation convention that does not yet exist. Co-Authored-By: Claude Opus 4.8 --- .changeset/d10-agent-intersection.md | 14 + ...howcase-agent-intersection.dogfood.test.ts | 118 ++++++++ .../plugin-security/src/explain-engine.ts | 251 +++++++++++++++--- .../src/security-plugin.test.ts | 228 ++++++++++++++++ .../plugin-security/src/security-plugin.ts | 144 +++++++++- .../plugin-sharing/src/sharing-plugin.ts | 30 ++- 6 files changed, 745 insertions(+), 40 deletions(-) create mode 100644 .changeset/d10-agent-intersection.md create mode 100644 packages/dogfood/test/showcase-agent-intersection.dogfood.test.ts diff --git a/.changeset/d10-agent-intersection.md b/.changeset/d10-agent-intersection.md new file mode 100644 index 0000000000..7823d63ba5 --- /dev/null +++ b/.changeset/d10-agent-intersection.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/plugin-sharing': minor +--- + +ADR-0090 D10 — agent/service intersection runtime. When a request's principal acts `onBehalfOf` a user (an AI agent or a service acting for a person), the effective permission is now the INTERSECTION of the principal's own grants and the delegator's grants — never the union. Confused-deputy prevention: an over-privileged agent may never see or touch anything the user it stands in for could not, and vice-versa. Previously `principalKind:'agent'` / `onBehalfOf` was a P1 context shape the evaluator did not read. + +The intersection is applied at EVERY axis, gated on the presence of the delegation link so the ordinary (non-delegated) path is byte-identical: + +- **plugin-security** middleware — the delegator's effective permission sets are reconstructed once (fail-CLOSED if the delegator no longer exists — a dangling link is denied, not resolved to the additive baseline) and AND-composed into: the required-capability gate, object CRUD, field-level security (read mask + write forbid + predicate-oracle guard), the row-level `using` pre-image on by-id writes, the `check` post-image, and the RLS read-filter injection. View/Modify-All only survives when BOTH principals hold it. +- **plugin-sharing** middleware — the OWD/record-sharing owner-match is IDENTITY-scoped, so it re-runs the visibility filter (and `canEdit`) under the delegator's own identity + depth and AND-s it in. An agent with View-All acting on behalf of a plain member therefore sees exactly that member's own rows — not everyone's, and not nothing. +- **explain engine** — every layer reports the narrower verdict when `onBehalfOf` is set, so the D6 access explanation stays truthful for delegated principals; a dangling delegator is reported as a fail-closed deny. + +First-cut scope (documented in code + covered by tests): one delegation hop (the `onBehalfOf` shape carries a single delegator, and any single-hop intersection is a safe lower bound on a true multi-hop chain); tenant-scoped substitution bags (`tenantId`, `org_user_ids`, `email`) are inherited from the live principal, while person-specific membership bags left unresolved narrow rather than widen. The agent grant-ceiling lint (D10 rule 2) is a follow-up — the runtime intersection already caps the agent regardless of what its own sets carry, and a lint needs an agent-set designation convention that does not yet exist. diff --git a/packages/dogfood/test/showcase-agent-intersection.dogfood.test.ts b/packages/dogfood/test/showcase-agent-intersection.dogfood.test.ts new file mode 100644 index 0000000000..cf95c3e244 --- /dev/null +++ b/packages/dogfood/test/showcase-agent-intersection.dogfood.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0090 D10 — agent/service intersection, proven against the REAL served +// engine (real RLS compiler, real SQLite, real private-OWD + VAMA bypass). +// +// The unit suite (`security-plugin.test.ts` → "ADR-0090 D10 agent +// intersection") drives the real middleware with mocked ql/metadata. This +// dogfood closes the last gap the plan flagged as the biggest risk — delegator +// RLS FIDELITY: does the reconstructed delegator context substitute correctly +// into a real compiled `owner_id = current_user.id` policy, and does the +// intersection actually STRIP an agent's View-All when the delegator lacks it? +// +// Scenario: an agent holding `showcase_auditor` (viewAllRecords on the private +// note) acts on behalf of a PLAIN member (baseline member_default, own-only). +// Alone, the agent's View-All lets it read another user's private note. Acting +// on behalf of the plain member, the D10 intersection must hide that row — the +// agent may not see what the user it stands in for cannot. +// +// @proof: showcase-agent-intersection + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { isSystem: true } as const; + +describe('showcase: ADR-0090 D10 agent intersection (served engine)', () => { + let stack: VerifyStack; + let ql: any; + let ownerTok: string; + let agentId: string, delId: string, ownerId: string; + let othersNoteId: string, delsNoteId: string; + + const uid = async (email: string) => + (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + await stack.signIn(); // admin (ensures bootstrap) + ownerTok = await stack.signUp('int-owner@verify.test'); + await stack.signUp('int-agent@verify.test'); + const delTok = await stack.signUp('int-del@verify.test'); + + ql = await stack.kernel.getServiceAsync('objectql'); + agentId = await uid('int-agent@verify.test'); + delId = await uid('int-del@verify.test'); + ownerId = await uid('int-owner@verify.test'); + + // The agent holds the auditor set (viewAllRecords on the private note); the + // delegator holds NOTHING beyond the additive member_default baseline. + const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS }); + expect(auditor?.id, 'showcase_auditor seeded').toBeTruthy(); + await ql.insert('sys_user_permission_set', { user_id: agentId, permission_set_id: auditor.id }, { context: SYS }); + + // A private note owned by int-owner (NOT the delegator) — the VAMA probe. + const other = await stack.apiAs(ownerTok, 'POST', '/data/showcase_private_note', { title: "someone else's note" }); + expect(other.status, 'owner creates a private note').toBeLessThan(300); + othersNoteId = (await other.json())?.id + ?? (await ql.findOne('showcase_private_note', { where: { title: "someone else's note" }, context: SYS }))?.id; + expect(othersNoteId, "other's note id").toBeTruthy(); + + // A private note owned by the delegator — visible to BOTH principals. + const delTokNote = await stack.apiAs(delTok, 'POST', '/data/showcase_private_note', { title: "delegator's own note" }); + delsNoteId = (await delTokNote.json())?.id + ?? (await ql.findOne('showcase_private_note', { where: { title: "delegator's own note" }, context: SYS }))?.id; + expect(delsNoteId, "delegator's note id").toBeTruthy(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + // Agent context as it reaches the engine middleware (auth layer resolved the + // agent's own grants into `permissions`; the delegator's are reconstructed + // from the DB by the D10 path). + const agentAlone = () => ({ userId: agentId, positions: [], permissions: ['showcase_auditor'] }); + const agentOnBehalf = () => ({ + userId: agentId, positions: [], permissions: ['showcase_auditor'], + principalKind: 'agent', onBehalfOf: { userId: delId, principalKind: 'human' }, + }); + + const idsVisible = async (ctx: any): Promise> => { + const rows = await ql.find('showcase_private_note', { where: {}, context: ctx }); + return new Set((Array.isArray(rows) ? rows : []).map((r: any) => r.id)); + }; + + it("baseline: the agent's View-All alone reads another user's private note", async () => { + const seen = await idsVisible(agentAlone()); + expect(seen.has(othersNoteId), 'VAMA bypass surfaces the private row').toBe(true); + }); + + it("D10: acting on behalf of a plain member, the agent can NO LONGER see that note (View-All stripped)", async () => { + const seen = await idsVisible(agentOnBehalf()); + expect(seen.has(othersNoteId), 'intersection hides a row the delegator cannot see').toBe(false); + }); + + it("D10: the agent still sees the DELEGATOR's own note (both principals may read it)", async () => { + const seen = await idsVisible(agentOnBehalf()); + expect(seen.has(delsNoteId), "the delegator's own row survives the intersection").toBe(true); + }); + + it('a dangling on-behalf-of link (deleted delegator) fails CLOSED', async () => { + const ctx = { userId: agentId, positions: [], permissions: ['showcase_auditor'], onBehalfOf: { userId: 'user_does_not_exist' } }; + await expect(ql.find('showcase_private_note', { where: {}, context: ctx })).rejects.toBeTruthy(); + }); + + it('explain() attributes the D10 intersection: alone allowed, on-behalf-of narrows', async () => { + const security: any = stack.kernel.getService('security'); + // Self-explain carries onBehalfOf through unchanged (request.userId omitted). + const decision = await security.explain( + { object: 'showcase_private_note', operation: 'read' }, + agentOnBehalf(), + ); + const principal = (decision?.layers ?? []).find((l: any) => l.layer === 'principal'); + expect(principal?.detail, 'principal layer names the delegator intersection').toMatch(/on behalf of/i); + expect(decision?.principal?.onBehalfOf?.userId).toBe(delId); + }); +}); diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index d7badf1e96..8d170bd64a 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -143,6 +143,115 @@ export async function buildContextForUser(ql: any, userId: string, nowMs: number return { userId, positions, permissions, expiredGrants, delegatedPositions }; } +/** + * [ADR-0090 D10] Result of resolving the delegator behind an on-behalf-of + * principal. `none` = no delegation link; `missing` = the link names a user that + * does not exist (the caller must fail CLOSED — see {@link resolveDelegatorContext}); + * `resolved` = the delegator's reconstructed evaluation context. + */ +export type DelegatorResolution = + | { kind: 'none' } + | { kind: 'missing'; userId: string } + | { kind: 'resolved'; context: any }; + +/** + * [ADR-0090 D10 — agent intersection] Resolve the evaluation context of the USER + * behind an agent/service principal that acts `onBehalfOf` them. The effective + * permission of the delegated principal is the INTERSECTION of its own grants + * and this delegator's grants (confused-deputy prevention) — never the union — + * so every enforcement layer combines the two set-lists with AND. + * + * Semantics this helper pins down (single-sourced for the middleware AND the + * explain engine so enforcement and its explanation can never drift): + * + * - **Fail-closed on a dangling link (edge b).** A `missing` delegator must be + * reported as such by the caller and denied — NOT resolved to empty sets: + * `resolvePermissionSetsForContext` synthesises the additive `member_default` + * baseline for ANY `userId`, so a deleted delegator would otherwise still + * intersect against baseline-level access. The `sys_user` existence check is + * the only correct fail-closed point. + * - **Tenant-scoped bags are inherited from the live principal.** The agent and + * its delegator are, by construction, in the same org, so `tenantId` / + * `org_user_ids` carry over — delegator-side RLS that substitutes them then + * compiles faithfully instead of collapsing to the deny sentinel. + * - **Person-specific membership bags (`rlsMembership`) are left unresolved** + * for the first cut. Absent → the RLS compiler's fail-closed substitution + * NARROWS the delegator's row set, never widens it — safe by construction. + * Full parity (team/territory bags) is a follow-up routing the delegator + * through the shared `resolveAuthzContext`. + * - **One hop only (edge a).** The `onBehalfOf` shape carries a single delegator + * id with no nested link, so a transitive agent→service→user chain is not + * representable in one context. Intersecting against the immediate delegator + * is a safe lower bound on the true multi-hop intersection (each hop only + * narrows), so this never escalates; true chain-walk is a producer-side + * follow-up that collapses the chain to the ultimate human delegator. + * - **Trigger is the LINK, not the label (edge d).** A `service` acting for a + * user is the identical confused-deputy risk as an `agent`; both intersect. + * `principalKind` stays advisory. `human`/`system`/`guest` never carry + * `onBehalfOf` in practice, so they are unaffected. + */ +export async function resolveDelegatorContext( + ql: any, + context: any, + nowMs: number = Date.now(), +): Promise { + const oboId = context?.onBehalfOf?.userId; + if (!oboId) return { kind: 'none' }; + let user: any = null; + try { + user = await ql.findOne('sys_user', { where: { id: oboId }, context: SYSTEM_CTX }); + } catch { + user = null; + } + if (!user) return { kind: 'missing', userId: String(oboId) }; + const dctx = await buildContextForUser(ql, oboId, nowMs); + // Inherit tenant-scoped substitution bags from the live principal (same org). + if (context?.tenantId != null) dctx.tenantId = context.tenantId; + if (context?.org_user_ids != null) dctx.org_user_ids = context.org_user_ids; + if (user.email != null && user.email !== '') dctx.email = user.email; + return { kind: 'resolved', context: dctx }; +} + +const SCOPE_ORDER = ['own', 'own_and_reports', 'unit', 'unit_and_below', 'org'] as const; + +/** + * [ADR-0090 D10] The NARROWER of two access-depth scopes (min rank). Unknown + * values clamp to the narrowest (`own`, fail-closed). Used to intersect the + * agent's and the delegator's effective read/write depth so the stash that + * flows to plugin-sharing carries the tighter of the two. + */ +export function narrowerScope(a: string, b: string): string { + const rank = (s: string): number => { + const i = SCOPE_ORDER.indexOf(s as (typeof SCOPE_ORDER)[number]); + return i < 0 ? 0 : i; + }; + return rank(a) <= rank(b) ? a : b; +} + +/** + * [ADR-0090 D10] Intersect two FLS masks. A field is readable/editable in the + * result only if it is readable/editable on BOTH sides. A field ABSENT from a + * side is unconstrained on that side (the FieldMasker leaves unlisted fields + * fully visible/editable), so an absent side contributes `true` — the AND then + * lets the OTHER side's constraint win. Net effect: the intersection hides or + * write-locks every field that EITHER principal hides or write-locks. + */ +export function intersectFieldMasks( + a: Record, + b: Record, +): Record { + const out: Record = {}; + const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]); + for (const k of keys) { + const ar = k in a ? a[k]?.readable !== false : true; + const ae = k in a ? a[k]?.editable !== false : true; + const br = k in b ? b[k]?.readable !== false : true; + const be = k in b ? b[k]?.editable !== false : true; + out[k] = { readable: ar && br, editable: ae && be }; + } + return out; +} + /** D1-equivalent OWD reading (mirrors plugin-sharing's effectiveSharingModel). */ function describeOwd(schema: any): { model: string; declared: boolean; effect: 'private' | 'read' | 'public' } { const m = schema?.sharingModel ?? schema?.security?.sharingModel; @@ -168,6 +277,27 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput // ── 1. principal ────────────────────────────────────────────────────── const sets = await deps.resolveSets(context).catch(() => [] as PermissionSet[]); const setNames = sets.map((s: any) => String(s.name ?? '?')); + // [ADR-0090 D10] Agent/service intersection. When the principal acts on + // behalf of a user, every layer below reports the INTERSECTION verdict — + // the narrower of the agent's own grants and the delegator's. `delegatorSets` + // stays null on the ordinary path, so each fold is a no-op and the report is + // byte-identical to a non-delegated principal. + let delegatorSets: PermissionSet[] | null = null; + let delegatorContextForRls: any = null; + let delegatorMissing = false; + let delegatorNames: string[] = []; + if (context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(deps.ql, context).catch( + () => ({ kind: 'none' }) as DelegatorResolution, + ); + if (del.kind === 'missing') { + delegatorMissing = true; + } else if (del.kind === 'resolved') { + delegatorContextForRls = del.context; + delegatorSets = await deps.resolveSets(del.context).catch(() => [] as PermissionSet[]); + delegatorNames = delegatorSets.map((s: any) => String(s.name ?? '?')); + } + } const positions: string[] = context?.positions ?? []; const viaOf = (name: string): string => { if (name === deps.fallbackPermissionSet) return 'additive baseline (ADR-0090 D5)'; @@ -188,12 +318,14 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput delegatedPositions.find((d) => d.name === name); layers.push({ layer: 'principal', - verdict: 'neutral', + verdict: delegatorMissing ? 'denies' : 'neutral', detail: `Principal ${context?.userId ?? '(anonymous)'} holds position(s) [${positions.join(', ') || 'none'}] ` + `resolving to permission set(s) [${setNames.join(', ') || 'none'}] (union-merged, most-permissive).` + (context?.onBehalfOf?.userId - ? ` Acting on behalf of ${context.onBehalfOf.userId} — D10 intersection semantics apply at enforcement.` + ? delegatorMissing + ? ` Acting on behalf of ${context.onBehalfOf.userId}, who no longer exists — D10 fails CLOSED (access denied).` + : ` Acting on behalf of ${context.onBehalfOf.userId} — effective access is the D10 INTERSECTION with the delegator's set(s) [${delegatorNames.join(', ') || 'none'}].` : '') + (delegatedPositions.length > 0 ? ` ${delegatedPositions.length} position(s) held via delegation (ADR-0091 D3): [${delegatedPositions @@ -233,13 +365,22 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput if (required.length > 0) { const held = deps.evaluator.getSystemPermissions(sets); const missing = required.filter((c) => !held.has(c)); - capsDeny = missing.length > 0; + // [ADR-0090 D10] Both principals must hold every required capability. + const heldDel = delegatorSets ? deps.evaluator.getSystemPermissions(delegatorSets) : null; + const missingDel = heldDel ? required.filter((c) => !heldDel.has(c)) : []; + capsDeny = missing.length > 0 || missingDel.length > 0; layers.push({ layer: 'required_permissions', verdict: capsDeny ? 'denies' : 'neutral', detail: capsDeny - ? `'${object}' requires capability [${required.join(', ')}] for ${operation} — missing [${missing.join(', ')}] (checked BEFORE the CRUD grant, ADR-0066 ⑤).` - : `Capability prerequisite [${required.join(', ')}] satisfied.`, + ? `'${object}' requires capability [${required.join(', ')}] for ${operation} — missing ` + + `[${[...new Set([...missing, ...missingDel])].join(', ')}]` + + (missingDel.length > 0 && missing.length === 0 + ? ' (the DELEGATOR lacks it — D10 intersection)' + : '') + + ` (checked BEFORE the CRUD grant, ADR-0066 ⑤).` + : `Capability prerequisite [${required.join(', ')}] satisfied` + + (delegatorSets ? ' by BOTH the agent and the delegator (D10)' : '') + '.', contributors: [], }); } else { @@ -252,7 +393,13 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput } // ── 3. object_crud — the core grant, with per-set attribution ───────── - const crudAllowed = deps.evaluator.checkObjectPermission(engineOp, object, sets, { isPrivate: secMeta.isPrivate }); + const agentCrud = deps.evaluator.checkObjectPermission(engineOp, object, sets, { isPrivate: secMeta.isPrivate }); + // [ADR-0090 D10] Both principals must grant the CRUD op; the agent may not + // act beyond the delegator's own reach (and vice-versa). + const delegatorCrud = delegatorSets + ? deps.evaluator.checkObjectPermission(engineOp, object, delegatorSets, { isPrivate: secMeta.isPrivate }) + : true; + const crudAllowed = agentCrud && delegatorCrud && !delegatorMissing; const granting = sets .filter((s) => deps.evaluator.checkObjectPermission(engineOp, object, [s], { isPrivate: secMeta.isPrivate })) .map((s: any) => String(s.name ?? '?')); @@ -260,20 +407,31 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput layer: 'object_crud', verdict: crudAllowed ? 'grants' : 'denies', detail: crudAllowed - ? `${operation} on '${object}' is granted by [${granting.join(', ')}].` - : `No resolved permission set grants ${operation} on '${object}'` + - (secMeta.isPrivate ? " (object is 'private' posture — non-superuser '*' wildcards are excluded, ADR-0066 D2)." : '.'), + ? `${operation} on '${object}' is granted by [${granting.join(', ')}]` + + (delegatorSets ? ' AND by the delegator (D10 intersection).' : '.') + : delegatorMissing + ? `Delegator no longer exists — D10 fails closed (access denied).` + : agentCrud && !delegatorCrud + ? `The agent grants ${operation} on '${object}' but the DELEGATOR does not — D10 intersection denies (an agent may not exceed the user it acts for).` + : `No resolved permission set grants ${operation} on '${object}'` + + (secMeta.isPrivate ? " (object is 'private' posture — non-superuser '*' wildcards are excluded, ADR-0066 D2)." : '.'), contributors: granting.map((n) => ({ kind: 'permission_set' as const, name: n, via: viaOf(n) })), }); // ── 4. fls ───────────────────────────────────────────────────────────── - const mask = deps.getFieldMask(sets, object, secMeta.fieldRequiredPermissions); + const agentMask = deps.getFieldMask(sets, object, secMeta.fieldRequiredPermissions); + // [ADR-0090 D10] Intersect the two masks — a field is readable only if BOTH + // principals can read it. + const mask = delegatorSets + ? intersectFieldMasks(agentMask, deps.getFieldMask(delegatorSets, object, secMeta.fieldRequiredPermissions)) + : agentMask; const hidden = Object.entries(mask).filter(([, p]) => p?.readable === false).map(([f]) => f); layers.push({ layer: 'fls', verdict: hidden.length > 0 ? 'narrows' : 'not_applicable', detail: hidden.length > 0 - ? `${hidden.length} field(s) masked from responses: [${hidden.slice(0, 25).join(', ')}${hidden.length > 25 ? ', …' : ''}].` + ? `${hidden.length} field(s) masked from responses: [${hidden.slice(0, 25).join(', ')}${hidden.length > 25 ? ', …' : ''}]` + + (delegatorSets ? ' (intersection of agent + delegator masks, D10).' : '.') : 'No field-level masking applies.', contributors: [], }); @@ -295,7 +453,11 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput // ── 6. depth ─────────────────────────────────────────────────────────── const opClass = engineOp === 'find' ? 'read' : 'write'; - const scope = deps.evaluator.getEffectiveScope(opClass as 'read' | 'write', object, sets, { isPrivate: secMeta.isPrivate }); + const agentScope = deps.evaluator.getEffectiveScope(opClass as 'read' | 'write', object, sets, { isPrivate: secMeta.isPrivate }); + // [ADR-0090 D10] The delegated principal sees the NARROWER of the two depths. + const scope = delegatorSets + ? narrowerScope(agentScope, deps.evaluator.getEffectiveScope(opClass as 'read' | 'write', object, delegatorSets, { isPrivate: secMeta.isPrivate })) + : agentScope; const depthApplies = owd.effect !== 'public'; layers.push({ layer: 'depth', @@ -303,6 +465,7 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput detail: !depthApplies ? 'Depth axis does not apply (baseline already org-wide).' : `Effective ${opClass} depth: '${scope}' (ADR-0057 D1 — widest across granting sets; ` + + (delegatorSets ? `narrowed to the delegator's depth by D10 intersection; ` : '') + `assignment BU anchors narrow which unit 'unit*' means, ADR-0090 Addendum).`, contributors: [], }); @@ -318,42 +481,70 @@ export async function explainAccess(deps: ExplainEngineDeps, input: ExplainInput }); // ── 8. vama_bypass ───────────────────────────────────────────────────── - const vamaSets = sets - .filter((s: any) => { - const objects = s?.objects ?? {}; - const entry = objects[object] ?? objects['*']; - return entry && (entry.viewAllRecords === true || entry.modifyAllRecords === true); - }) - .map((s: any) => String(s.name ?? '?')); + const vamaOf = (list: PermissionSet[]): string[] => + list + .filter((s: any) => { + const objects = s?.objects ?? {}; + const entry = objects[object] ?? objects['*']; + return entry && (entry.viewAllRecords === true || entry.modifyAllRecords === true); + }) + .map((s: any) => String(s.name ?? '?')); + const agentVama = vamaOf(sets); + const delegatorVama = delegatorSets ? vamaOf(delegatorSets) : null; + // [ADR-0090 D10] The bypass only survives the intersection when BOTH sides + // hold it — an agent's own View-All must never let it see rows its delegator + // cannot (the grant-ceiling makes agent VAMA impossible anyway; this is the + // belt-and-braces at evaluation time). + const vamaEffective = agentVama.length > 0 && (delegatorVama === null || delegatorVama.length > 0); + const vamaSets = agentVama; layers.push({ layer: 'vama_bypass', - verdict: vamaSets.length > 0 ? 'widens' : 'not_applicable', - detail: vamaSets.length > 0 - ? `View/Modify All Data bypass held via [${vamaSets.join(', ')}] — ownership and sharing checks are skipped.` - : 'No View/Modify All Data bypass.', - contributors: vamaSets.map((n) => ({ kind: 'permission_set' as const, name: n, via: viaOf(n) })), + verdict: vamaEffective ? 'widens' : 'not_applicable', + detail: vamaEffective + ? `View/Modify All Data bypass held via [${vamaSets.join(', ')}]` + + (delegatorVama ? ` AND by the delegator [${delegatorVama.join(', ')}]` : '') + + ` — ownership and sharing checks are skipped.` + : agentVama.length > 0 && delegatorVama !== null && delegatorVama.length === 0 + ? `Agent holds View/Modify All Data via [${agentVama.join(', ')}] but the DELEGATOR does not — D10 intersection strips the bypass.` + : 'No View/Modify All Data bypass.', + contributors: vamaEffective ? vamaSets.map((n) => ({ kind: 'permission_set' as const, name: n, via: viaOf(n) })) : [], }); // ── 9. rls — the composed machine artifact ───────────────────────────── - let readFilter: Record | null | undefined; + let agentFilter: Record | null | undefined; try { - readFilter = await deps.computeRlsFilter(sets, object, engineOp, context); + agentFilter = await deps.computeRlsFilter(sets, object, engineOp, context); } catch { - readFilter = { id: '__deny_all__' }; + agentFilter = { id: '__deny_all__' }; + } + // [ADR-0090 D10] AND the delegator's read filter into the composite — the + // delegated principal sees only rows BOTH principals may see. + let delegatorFilter: Record | null | undefined; + if (delegatorSets && delegatorContextForRls) { + try { + delegatorFilter = await deps.computeRlsFilter(delegatorSets, object, engineOp, delegatorContextForRls); + } catch { + delegatorFilter = { id: '__deny_all__' }; + } } - const denyAll = !!readFilter && (readFilter as any).id === '__deny_all__'; + const filterParts = [agentFilter, delegatorFilter].filter(Boolean) as Record[]; + let readFilter: Record | null | undefined = + filterParts.length === 0 ? undefined : filterParts.length === 1 ? filterParts[0] : { $and: filterParts }; + const denyAll = filterParts.some((f) => (f as any).id === '__deny_all__'); + if (denyAll) readFilter = { id: '__deny_all__' }; layers.push({ layer: 'rls', verdict: denyAll ? 'denies' : readFilter ? 'narrows' : 'not_applicable', detail: denyAll ? 'Row-level security composes to DENY ALL for this principal.' : readFilter - ? 'Row-level security narrows the row set (see readFilter for the composed predicate).' + ? 'Row-level security narrows the row set (see readFilter for the composed predicate)' + + (delegatorFilter ? ' — intersection of agent + delegator filters (D10).' : '.') : 'No RLS policy applies.', contributors: [], }); - const allowed = !capsDeny && crudAllowed && !denyAll; + const allowed = !capsDeny && crudAllowed && !denyAll && !delegatorMissing; const decision: ExplainDecision = { allowed, diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 3099c52ef3..04b6abc82a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -2193,3 +2193,231 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { expect(self.principal.userId).toBe('u_east_1'); }); }); + +// --------------------------------------------------------------------------- +// ADR-0090 D10 — agent/service intersection. A principal acting `onBehalfOf` a +// user gets the INTERSECTION of its own grants and the delegator's grants, at +// EVERY axis (capability, object-CRUD, FLS, depth, row-level using/check, +// VAMA). Confused-deputy prevention: an over-privileged agent may never exceed +// the reach of the user it stands in for, and vice-versa. Gated on the +// delegation LINK — the non-delegated path must stay byte-identical. +// --------------------------------------------------------------------------- +describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { + const AGENT = 'user_agent'; + const DELEGATOR = 'user_delegator'; + + // task carries every field the RLS/FLS policies below target, so no policy is + // dropped as a fail-closed "missing field" deny. + const TASK_FIELDS = ['id', 'name', 'owner_id', 'manager_id', 'salary', 'region', 'created_by']; + + /** + * In-memory harness (modelled on the D6/D12 explain harness) that resolves the + * delegator's sets from `sys_user_position` and answers the `sys_user` + * existence check. Business-object (`task`) reads route to `taskFindOne` so a + * write pre-image can be made visible or hidden per test; sys_* reads route to + * the seeded tables. Every set named in `metadata.list()` resolves by name. + */ + const makeD10Harness = (opts: { + sets: PermissionSet[]; + agentPositions: string[]; + delegatorPositions: string[] | null; // null → delegator has NO sys_user row (deleted) + schemaExtra?: Record; + taskFindOne?: (query: any) => any; + }) => { + const tables: Record = { + sys_user: [{ id: AGENT, email: 'agent@x' }], + sys_user_position: [], + sys_user_permission_set: [], + sys_permission_set: [], + sys_position: [], + }; + if (opts.delegatorPositions !== null) { + tables.sys_user.push({ id: DELEGATOR, email: 'delegator@x' }); + for (const p of opts.delegatorPositions) { + tables.sys_user_position.push({ user_id: DELEGATOR, position: p }); + } + } + const matches = (row: any, where: any): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + const fields: Record = {}; + for (const f of TASK_FIELDS) fields[f] = { name: f }; + const baseSchema: any = { name: 'task', fields, ...(opts.schemaExtra ?? {}) }; + const taskFindOne = vi.fn((query: any) => (opts.taskFindOne ? opts.taskFindOne(query) : { id: 'r1' })); + let middleware: any; + const ql: any = { + registerMiddleware: (mw: any) => { if (!middleware) middleware = mw; }, + getSchema: () => baseSchema, + async find(object: string, o: any) { + const rows = (tables[object] ?? []).filter((r) => matches(r, o?.where)); + return typeof o?.limit === 'number' ? rows.slice(0, o.limit) : rows; + }, + async findOne(object: string, o: any) { + if (object.startsWith('sys_')) { + return (tables[object] ?? []).filter((r) => matches(r, o?.where))[0] ?? null; + } + return taskFindOne(o); + }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => baseSchema, list: async () => opts.sets }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + return { + ctx, taskFindOne, + run: async (opCtx: any) => { await middleware(opCtx, async () => {}); return opCtx; }, + }; + }; + + const boot = async (opts: Parameters[0]) => { + // No baseline set — isolate the intersection from the additive member_default. + const plugin = new SecurityPlugin({ fallbackPermissionSet: null }); + const h = makeD10Harness(opts); + await plugin.init(h.ctx); + await plugin.start(h.ctx); + return { plugin, h }; + }; + + const agentCtx = (extra?: Record) => ({ + userId: AGENT, tenantId: 'org-1', positions: ['agent_set'], permissions: [], + onBehalfOf: { userId: DELEGATOR, principalKind: 'human' }, principalKind: 'agent', + ...(extra ?? {}), + }); + + // ── CRUD intersection ─────────────────────────────────────────────────── + const editor = (name: string): PermissionSet => ({ + name, label: name, + objects: { task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + } as any); + const reader = (name: string): PermissionSet => ({ + name, label: name, objects: { task: { allowRead: true } }, + } as any); + + it('agent may EDIT but delegator is read-only → update DENIED (agent cannot exceed the user it acts for)', async () => { + const { h } = await boot({ sets: [editor('agent_set'), reader('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'] }); + const opCtx: any = { object: 'task', operation: 'update', data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: agentCtx() }; + await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + + it('delegator may EDIT but agent is read-only → update DENIED (symmetry)', async () => { + const { h } = await boot({ sets: [reader('agent_set'), editor('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'] }); + const opCtx: any = { object: 'task', operation: 'update', data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: agentCtx() }; + await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + + it('both grant edit → update is ALLOWED', async () => { + const { h } = await boot({ sets: [editor('agent_set'), editor('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], taskFindOne: () => ({ id: 'r1' }) }); + const opCtx: any = { object: 'task', operation: 'update', data: { id: 'r1', name: 'x' }, options: { where: { id: 'r1' } }, context: agentCtx() }; + await expect(h.run(opCtx)).resolves.toBeDefined(); + }); + + // ── capability intersection ───────────────────────────────────────────── + it('delegator lacks a required capability → DENIED even though the agent holds it', async () => { + const capGated = { requiredPermissions: ['export_data'] }; // array form → gates EVERY op + const agentWithCap: PermissionSet = { name: 'agent_set', label: 'a', objects: { task: { allowRead: true } }, systemPermissions: ['export_data'] } as any; + const delNoCap = reader('del_set'); + const { h } = await boot({ sets: [agentWithCap, delNoCap], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], schemaExtra: capGated }); + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: agentCtx() }; + await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + + // ── read RLS composition (AND of both filters) ────────────────────────── + const rlsReader = (name: string, using: string): PermissionSet => ({ + name, label: name, + objects: { task: { allowRead: true } }, + rowLevelSecurity: [{ name: `${name}_rls`, object: 'task', operation: 'all', using }], + } as any); + + it('read filter is the AND (intersection) of the agent and delegator RLS', async () => { + const { h } = await boot({ + sets: [rlsReader('agent_set', 'owner_id = current_user.id'), rlsReader('del_set', 'manager_id = current_user.id')], + agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + }); + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: agentCtx() }; + await h.run(opCtx); + // Composite where AND-s the agent's owner scoping (its id) with the + // delegator's manager scoping (the DELEGATOR's id). + expect(opCtx.ast.where).toEqual({ $and: [{ owner_id: AGENT }, { manager_id: DELEGATOR }] }); + }); + + it('non-delegated principal is byte-identical (only the agent filter, no delegator read)', async () => { + const { h } = await boot({ + sets: [rlsReader('agent_set', 'owner_id = current_user.id'), rlsReader('del_set', 'manager_id = current_user.id')], + agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + }); + // Same context, but WITHOUT onBehalfOf. + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: { userId: AGENT, tenantId: 'org-1', positions: ['agent_set'], permissions: [] } }; + await h.run(opCtx); + expect(opCtx.ast.where).toEqual({ owner_id: AGENT }); + }); + + it("agent's View-All does not widen past the delegator (VAMA survives only if BOTH hold it)", async () => { + const agentViewAll: PermissionSet = { name: 'agent_set', label: 'a', objects: { task: { allowRead: true, viewAllRecords: true } } } as any; + const delScoped = rlsReader('del_set', 'owner_id = current_user.id'); + const { h } = await boot({ + sets: [agentViewAll, delScoped], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], + schemaExtra: { sharingModel: 'private' }, + }); + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: agentCtx() }; + await h.run(opCtx); + // Agent alone (View-All on a private object) would inject NO filter; the + // delegator's owner scoping still applies → the composite is exactly it. + expect(opCtx.ast.where).toEqual({ owner_id: DELEGATOR }); + }); + + // ── FLS intersection ──────────────────────────────────────────────────── + it('masks a field the delegator cannot read even though the agent can', async () => { + const agentFull = reader('agent_set'); + const delMask: PermissionSet = { name: 'del_set', label: 'd', objects: { task: { allowRead: true } }, fields: { 'task.salary': { readable: false, editable: false } } } as any; + const { h } = await boot({ sets: [agentFull, delMask], agentPositions: ['agent_set'], delegatorPositions: ['del_set'] }); + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, result: [{ id: 'r1', name: 'A', salary: 999 }], context: agentCtx() }; + await h.run(opCtx); + expect(opCtx.result[0].name).toBe('A'); + expect(opCtx.result[0].salary).toBeUndefined(); + }); + + // ── depth intersection (per-identity stash for plugin-sharing) ─────────── + it("stashes the agent's own depth AND the delegator's depth SEPARATELY (identity-scoped OWD intersection)", async () => { + const agentViewAll: PermissionSet = { name: 'agent_set', label: 'a', objects: { task: { allowRead: true, viewAllRecords: true } } } as any; + const delOwn: PermissionSet = { name: 'del_set', label: 'd', objects: { task: { allowRead: true, readScope: 'own' } } } as any; + const { h } = await boot({ sets: [agentViewAll, delOwn], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], schemaExtra: { sharingModel: 'private' } }); + const ctx: any = agentCtx(); + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: ctx }; + await h.run(opCtx); + // The agent keeps its own (org-wide) depth so plugin-sharing scopes the + // AGENT identity correctly; the delegator's narrower depth is stashed apart + // so plugin-sharing can re-run the owner-match under the DELEGATOR identity. + expect(ctx.__readScope).toBe('org'); + expect(ctx.__delegatorReadScope).toBe('own'); + }); + + // ── fail-closed on a dangling delegation link ─────────────────────────── + it('delegator no longer exists → fail CLOSED (PermissionDeniedError, not baseline access)', async () => { + const { h } = await boot({ sets: [reader('agent_set')], agentPositions: ['agent_set'], delegatorPositions: null }); + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: agentCtx() }; + await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + + // ── explain attribution (D6 × D10) ────────────────────────────────────── + it('explain reports the D10 intersection deny when the delegator lacks the grant', async () => { + const { plugin } = await boot({ sets: [editor('agent_set'), reader('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'] }); + const caller = { userId: AGENT, positions: ['agent_set'], permissions: [], onBehalfOf: { userId: DELEGATOR } }; + const d = await plugin.explainAccessForCaller({ object: 'task', operation: 'update' }, caller); + expect(d.allowed).toBe(false); + const crud = d.layers.find((l) => l.layer === 'object_crud'); + expect(crud?.verdict).toBe('denies'); + expect(crud?.detail).toMatch(/delegator/i); + expect(d.principal.onBehalfOf?.userId).toBe(DELEGATOR); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 920dfb96e0..d5dd3fdade 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -5,7 +5,12 @@ import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/se import { describeHighPrivilegeBits, describeAnchorForbiddenBits } from '@objectstack/spec/security'; import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; -import { explainAccess, buildContextForUser } from './explain-engine.js'; +import { + explainAccess, + buildContextForUser, + resolveDelegatorContext, + intersectFieldMasks, +} from './explain-engine.js'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; @@ -547,6 +552,37 @@ export class SecurityPlugin implements Plugin { ); } + // [ADR-0090 D10 — agent intersection] When this principal acts ON BEHALF + // OF a user (an AI agent or a service), its effective permission is the + // INTERSECTION of its own grants and the delegator's grants — never the + // union (confused-deputy prevention). Resolve the delegator's effective + // permission sets ONCE here; every gate below AND-composes the two lists + // so the tighter of the two wins at each axis (CRUD, capabilities, FLS, + // depth, row-level using/check, VAMA). The whole thing is gated on the + // presence of the delegation LINK (not the `principalKind` label — a + // service acting for a user is the identical risk): on the ordinary + // non-delegated path `delegatorSets` stays null, every combine reduces to + // today's expression, no extra `ql` read happens, and behaviour is + // byte-identical. A dangling link (delegator deleted) fails CLOSED — see + // resolveDelegatorContext for why "empty sets" would be wrong (the + // additive baseline would resurrect access for a non-existent user). + let delegatorSets: PermissionSet[] | null = null; + let delegatorContext: any = null; + if (permissionSets.length > 0 && opCtx.context?.onBehalfOf?.userId) { + const del = await resolveDelegatorContext(this.ql, opCtx.context); + if (del.kind === 'missing') { + throw new PermissionDeniedError( + `[Security] Access denied: on-behalf-of principal names delegator ` + + `'${del.userId}', who does not exist — refusing to act (ADR-0090 D10 fail-closed)`, + { operation: opCtx.operation, object: opCtx.object }, + ); + } + if (del.kind === 'resolved') { + delegatorContext = del.context; + delegatorSets = await this.resolvePermissionSetsForContext(delegatorContext); + } + } + // [ADR-0066 D2/D3] Resolve the object's security posture (private flag, // platform-global flag, capability contract) once for the checks below. const secMeta = @@ -564,17 +600,22 @@ export class SecurityPlugin implements Plugin { if (required.length > 0) { const held = this.permissionEvaluator.getSystemPermissions(permissionSets); const missing = required.filter((cap) => !held.has(cap)); - if (missing.length > 0) { + // [ADR-0090 D10] Both principals must hold every required capability. + const missingDel = delegatorSets + ? required.filter((cap) => !this.permissionEvaluator.getSystemPermissions(delegatorSets!).has(cap)) + : []; + if (missing.length > 0 || missingDel.length > 0) { + const allMissing = [...new Set([...missing, ...missingDel])]; throw new PermissionDeniedError( `[Security] Access denied: '${opCtx.object}' (operation '${opCtx.operation}') requires capability ` + - `[${required.join(', ')}] — caller is missing [${missing.join(', ')}]`, + `[${required.join(', ')}] — ${missing.length > 0 ? 'caller' : 'the delegator'} is missing [${allMissing.join(', ')}]`, { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets, requiredPermissions: required, - missingPermissions: missing, + missingPermissions: allMissing, }, ); } @@ -597,6 +638,21 @@ export class SecurityPlugin implements Plugin { { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, ); } + + // [ADR-0090 D10] The delegator must independently grant the same op — an + // agent may never act beyond the reach of the user it stands in for. + if (delegatorSets && !this.permissionEvaluator.checkObjectPermission( + opCtx.operation, + opCtx.object, + delegatorSets, + { isPrivate: secMeta.isPrivate }, + )) { + throw new PermissionDeniedError( + `[Security] Access denied: on-behalf-of principal may not '${opCtx.operation}' ` + + `'${opCtx.object}' — the delegator lacks that grant (ADR-0090 D10 intersection)`, + { operation: opCtx.operation, object: opCtx.object, positions, permissionSets: explicitPermissionSets }, + ); + } } // 2.6. [ADR-0057 D1] Stash the grant's access DEPTH for this object so the @@ -605,10 +661,25 @@ export class SecurityPlugin implements Plugin { // (plugin-sharing), so we pass the scope STRING, not the resolved set. if (permissionSets.length > 0) { const sc: any = opCtx.context; + // The AGENT's own depth drives plugin-sharing's owner-match for the + // agent identity (unchanged on the non-delegated path). if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) { sc.__readScope = this.permissionEvaluator.getEffectiveScope('read', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate }); + // [ADR-0090 D10] Stash the DELEGATOR's own read depth SEPARATELY (not a + // min of the two). The OWD/sharing owner-match is identity-scoped: + // plugin-sharing re-runs the owner filter under the delegator's + // identity + THIS depth and AND-s it in, giving a true per-identity + // intersection. Narrowing __readScope alone would wrongly scope the + // AGENT's identity to the delegator's depth (owner_id = agentId), + // hiding the very rows the delegator legitimately owns. + if (delegatorSets) { + sc.__delegatorReadScope = this.permissionEvaluator.getEffectiveScope('read', opCtx.object, delegatorSets, { isPrivate: secMeta.isPrivate }); + } } else if (['update', 'delete', 'transfer', 'restore', 'purge'].includes(opCtx.operation)) { sc.__writeScope = this.permissionEvaluator.getEffectiveScope('write', opCtx.object, permissionSets, { isPrivate: secMeta.isPrivate }); + if (delegatorSets) { + sc.__delegatorWriteScope = this.permissionEvaluator.getEffectiveScope('write', opCtx.object, delegatorSets, { isPrivate: secMeta.isPrivate }); + } } } @@ -655,11 +726,20 @@ export class SecurityPlugin implements Plugin { rlsOperation, opCtx.context, ); - if (writeFilter) { + // [ADR-0090 D10] The target row must satisfy BOTH principals' write + // RLS — a by-id write on behalf of a user may only touch rows that + // user could also touch. Compute the delegator's write filter against + // the delegator's context (its userId/tenant substitutions) and AND + // it into the same pre-image re-read. + const delWriteFilter = delegatorSets + ? await this.computeRlsFilter(delegatorSets, opCtx.object, rlsOperation, delegatorContext) + : null; + const writeParts = [writeFilter, delWriteFilter].filter(Boolean) as Record[]; + if (writeParts.length > 0) { let visible: unknown = null; try { visible = await this.ql.findOne(opCtx.object, { - where: { $and: [{ id: targetId }, writeFilter] }, + where: { $and: [{ id: targetId }, ...writeParts] }, context: opCtx.context, }); } catch { @@ -701,6 +781,17 @@ export class SecurityPlugin implements Plugin { opCtx, opCtx.context, ); + // [ADR-0090 D10] The delegator must ALSO have edit access to the master + // — a detail write on behalf of a user requires that user's master-edit. + if (delegatorSets) { + await this.assertControlledByParentWrite( + delegatorSets, + opCtx.object, + opCtx.operation, + opCtx, + delegatorContext, + ); + } } // 2.5. Field-Level Security write enforcement. @@ -732,6 +823,13 @@ export class SecurityPlugin implements Plugin { ); // [ADR-0066 D3] AND-gate field-level requiredPermissions into the map. fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets); + // [ADR-0090 D10] Intersect with the delegator's field perms — a field + // the agent may edit but the delegator may not becomes forbidden. + if (delegatorSets) { + let delFieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, delegatorSets); + delFieldPerms = this.foldFieldRequiredPermissions(delFieldPerms, secMeta.fieldRequiredPermissions, delegatorSets); + fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); + } if (Object.keys(fieldPerms).length > 0) { const forbidden = this.fieldMasker.detectForbiddenWrites( opCtx.data, @@ -805,7 +903,14 @@ export class SecurityPlugin implements Plugin { opCtx.operation, opCtx.context, ); - if (checkFilter) { + // [ADR-0090 D10] The post-image must satisfy the delegator's CHECK too — + // an on-behalf-of write may not produce a row the delegator itself + // could not have written. + const delCheckFilter = delegatorSets + ? await this.computeWriteCheckFilter(delegatorSets, opCtx.object, opCtx.operation, delegatorContext) + : null; + const checkParts = [checkFilter, delCheckFilter].filter(Boolean) as Record[]; + if (checkParts.length > 0) { // Build the post-image. Insert → the new row. Update by-id → the // pre-image merged with the change set (so a check on an unchanged // field still sees its value). A bulk update (no single id) cannot @@ -830,7 +935,7 @@ export class SecurityPlugin implements Plugin { if (pre && typeof pre === 'object') postImage = { ...(pre as Record), ...(opCtx.data as Record) }; } } - if (postImage && !matchesFilterCondition(postImage, checkFilter as any)) { + if (postImage && !checkParts.every((f) => matchesFilterCondition(postImage as any, f as any))) { this.logger.warn?.( `[Security] RLS check FAILED on ${opCtx.operation} '${opCtx.object}' — write denied (fail-closed)`, ); @@ -854,6 +959,13 @@ export class SecurityPlugin implements Plugin { if (opCtx.ast) { let guardPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); guardPerms = this.foldFieldRequiredPermissions(guardPerms, secMeta.fieldRequiredPermissions, permissionSets); + // [ADR-0090 D10] A field readable only by the agent is not queryable on + // the delegator's behalf — intersect before the oracle guard. + if (delegatorSets) { + let delGuard = this.permissionEvaluator.getFieldPermissions(opCtx.object, delegatorSets); + delGuard = this.foldFieldRequiredPermissions(delGuard, secMeta.fieldRequiredPermissions, delegatorSets); + guardPerms = intersectFieldMasks(guardPerms, delGuard); + } if (Object.keys(guardPerms).length > 0) { assertReadableQueryFields(opCtx.ast as unknown as Record, guardPerms, opCtx.object); } @@ -880,6 +992,16 @@ export class SecurityPlugin implements Plugin { opCtx.context, ); if (cbpFilter) extra.push(cbpFilter); + // [ADR-0090 D10] AND the delegator's read RLS (and CBP) into the same + // where — the delegated principal sees only rows BOTH may see. Computed + // against the delegator's own context so its userId/tenant substitutions + // are faithful. + if (delegatorSets) { + const delRls = await this.computeRlsFilter(delegatorSets, opCtx.object, opCtx.operation, delegatorContext); + if (delRls) extra.push(delRls); + const delCbp = await this.computeControlledByParentFilter(delegatorSets, opCtx.object, delegatorContext); + if (delCbp) extra.push(delCbp); + } if (extra.length) { opCtx.ast.where = opCtx.ast.where ? { $and: [opCtx.ast.where, ...extra] } @@ -901,6 +1023,12 @@ export class SecurityPlugin implements Plugin { let fieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets); // [ADR-0066 D3] AND-gate field-level requiredPermissions into the mask. fieldPerms = this.foldFieldRequiredPermissions(fieldPerms, secMeta.fieldRequiredPermissions, permissionSets); + // [ADR-0090 D10] Mask any field the delegator cannot read, too. + if (delegatorSets) { + let delFieldPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, delegatorSets); + delFieldPerms = this.foldFieldRequiredPermissions(delFieldPerms, secMeta.fieldRequiredPermissions, delegatorSets); + fieldPerms = intersectFieldMasks(fieldPerms, delFieldPerms); + } if (Object.keys(fieldPerms).length > 0) { opCtx.result = this.fieldMasker.maskResults(opCtx.result, fieldPerms, opCtx.object); } diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index d1add7eb18..0d040a454c 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -321,7 +321,23 @@ export function buildSharingMiddleware(service: SharingService): EngineMiddlewar // READS — AND the visibility filter into the AST. if (op === 'find' || op === 'findOne' || op === 'count' || op === 'aggregate') { - const filter = await service.buildReadFilter(ctx.object, exec ?? {}); + let filter = await service.buildReadFilter(ctx.object, exec ?? {}); + // [ADR-0090 D10] Agent/service intersection on the OWD/sharing axis. When + // the principal acts on behalf of a user, the owner-match and record + // shares are IDENTITY-scoped — so we re-run the visibility filter under + // the DELEGATOR's own identity + depth (stashed by plugin-security as + // `__delegatorReadScope`) and AND it in. The delegated principal then + // sees only rows BOTH identities may see (an over-privileged agent can + // never exceed the user it stands in for). Non-delegated path unchanged. + if (exec?.onBehalfOf?.userId) { + const delFilter = await service.buildReadFilter(ctx.object, { + ...exec, + userId: exec.onBehalfOf.userId, + onBehalfOf: undefined, + __readScope: exec.__delegatorReadScope, + }); + filter = composeAnd(filter, delFilter); + } if (filter) { const ast: any = ctx.ast ?? {}; ast.where = composeAnd(ast.where, filter); @@ -337,7 +353,17 @@ export function buildSharingMiddleware(service: SharingService): EngineMiddlewar const options: any = ctx.options; const id = inferTargetId(data, options); if (id != null) { - const ok = await service.canEdit(ctx.object, String(id), exec ?? {}); + let ok = await service.canEdit(ctx.object, String(id), exec ?? {}); + // [ADR-0090 D10] The delegator must ALSO be able to edit the row — an + // on-behalf-of write may only touch rows the delegator could touch. + if (ok && exec?.onBehalfOf?.userId) { + ok = await service.canEdit(ctx.object, String(id), { + ...exec, + userId: exec.onBehalfOf.userId, + onBehalfOf: undefined, + __writeScope: exec.__delegatorWriteScope, + }); + } if (!ok) { const err: any = new Error( `FORBIDDEN: insufficient privileges to ${op} ${ctx.object} ${id}`,