Skip to content

Commit f70eb2c

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0090 D10 — agent/service intersection runtime (#2838)
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 <noreply@anthropic.com>
1 parent 6da03ee commit f70eb2c

6 files changed

Lines changed: 745 additions & 40 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/plugin-sharing': minor
4+
---
5+
6+
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.
7+
8+
The intersection is applied at EVERY axis, gated on the presence of the delegation link so the ordinary (non-delegated) path is byte-identical:
9+
10+
- **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.
11+
- **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.
12+
- **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.
13+
14+
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.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0090 D10 — agent/service intersection, proven against the REAL served
4+
// engine (real RLS compiler, real SQLite, real private-OWD + VAMA bypass).
5+
//
6+
// The unit suite (`security-plugin.test.ts` → "ADR-0090 D10 agent
7+
// intersection") drives the real middleware with mocked ql/metadata. This
8+
// dogfood closes the last gap the plan flagged as the biggest risk — delegator
9+
// RLS FIDELITY: does the reconstructed delegator context substitute correctly
10+
// into a real compiled `owner_id = current_user.id` policy, and does the
11+
// intersection actually STRIP an agent's View-All when the delegator lacks it?
12+
//
13+
// Scenario: an agent holding `showcase_auditor` (viewAllRecords on the private
14+
// note) acts on behalf of a PLAIN member (baseline member_default, own-only).
15+
// Alone, the agent's View-All lets it read another user's private note. Acting
16+
// on behalf of the plain member, the D10 intersection must hide that row — the
17+
// agent may not see what the user it stands in for cannot.
18+
//
19+
// @proof: showcase-agent-intersection
20+
21+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22+
import showcaseStack from '@objectstack/example-showcase';
23+
import { bootStack, type VerifyStack } from '@objectstack/verify';
24+
25+
const SYS = { isSystem: true } as const;
26+
27+
describe('showcase: ADR-0090 D10 agent intersection (served engine)', () => {
28+
let stack: VerifyStack;
29+
let ql: any;
30+
let ownerTok: string;
31+
let agentId: string, delId: string, ownerId: string;
32+
let othersNoteId: string, delsNoteId: string;
33+
34+
const uid = async (email: string) =>
35+
(await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id;
36+
37+
beforeAll(async () => {
38+
stack = await bootStack(showcaseStack);
39+
await stack.signIn(); // admin (ensures bootstrap)
40+
ownerTok = await stack.signUp('int-owner@verify.test');
41+
await stack.signUp('int-agent@verify.test');
42+
const delTok = await stack.signUp('int-del@verify.test');
43+
44+
ql = await stack.kernel.getServiceAsync('objectql');
45+
agentId = await uid('int-agent@verify.test');
46+
delId = await uid('int-del@verify.test');
47+
ownerId = await uid('int-owner@verify.test');
48+
49+
// The agent holds the auditor set (viewAllRecords on the private note); the
50+
// delegator holds NOTHING beyond the additive member_default baseline.
51+
const auditor = await ql.findOne('sys_permission_set', { where: { name: 'showcase_auditor' }, context: SYS });
52+
expect(auditor?.id, 'showcase_auditor seeded').toBeTruthy();
53+
await ql.insert('sys_user_permission_set', { user_id: agentId, permission_set_id: auditor.id }, { context: SYS });
54+
55+
// A private note owned by int-owner (NOT the delegator) — the VAMA probe.
56+
const other = await stack.apiAs(ownerTok, 'POST', '/data/showcase_private_note', { title: "someone else's note" });
57+
expect(other.status, 'owner creates a private note').toBeLessThan(300);
58+
othersNoteId = (await other.json())?.id
59+
?? (await ql.findOne('showcase_private_note', { where: { title: "someone else's note" }, context: SYS }))?.id;
60+
expect(othersNoteId, "other's note id").toBeTruthy();
61+
62+
// A private note owned by the delegator — visible to BOTH principals.
63+
const delTokNote = await stack.apiAs(delTok, 'POST', '/data/showcase_private_note', { title: "delegator's own note" });
64+
delsNoteId = (await delTokNote.json())?.id
65+
?? (await ql.findOne('showcase_private_note', { where: { title: "delegator's own note" }, context: SYS }))?.id;
66+
expect(delsNoteId, "delegator's note id").toBeTruthy();
67+
}, 120_000);
68+
69+
afterAll(async () => {
70+
await stack?.stop();
71+
});
72+
73+
// Agent context as it reaches the engine middleware (auth layer resolved the
74+
// agent's own grants into `permissions`; the delegator's are reconstructed
75+
// from the DB by the D10 path).
76+
const agentAlone = () => ({ userId: agentId, positions: [], permissions: ['showcase_auditor'] });
77+
const agentOnBehalf = () => ({
78+
userId: agentId, positions: [], permissions: ['showcase_auditor'],
79+
principalKind: 'agent', onBehalfOf: { userId: delId, principalKind: 'human' },
80+
});
81+
82+
const idsVisible = async (ctx: any): Promise<Set<string>> => {
83+
const rows = await ql.find('showcase_private_note', { where: {}, context: ctx });
84+
return new Set((Array.isArray(rows) ? rows : []).map((r: any) => r.id));
85+
};
86+
87+
it("baseline: the agent's View-All alone reads another user's private note", async () => {
88+
const seen = await idsVisible(agentAlone());
89+
expect(seen.has(othersNoteId), 'VAMA bypass surfaces the private row').toBe(true);
90+
});
91+
92+
it("D10: acting on behalf of a plain member, the agent can NO LONGER see that note (View-All stripped)", async () => {
93+
const seen = await idsVisible(agentOnBehalf());
94+
expect(seen.has(othersNoteId), 'intersection hides a row the delegator cannot see').toBe(false);
95+
});
96+
97+
it("D10: the agent still sees the DELEGATOR's own note (both principals may read it)", async () => {
98+
const seen = await idsVisible(agentOnBehalf());
99+
expect(seen.has(delsNoteId), "the delegator's own row survives the intersection").toBe(true);
100+
});
101+
102+
it('a dangling on-behalf-of link (deleted delegator) fails CLOSED', async () => {
103+
const ctx = { userId: agentId, positions: [], permissions: ['showcase_auditor'], onBehalfOf: { userId: 'user_does_not_exist' } };
104+
await expect(ql.find('showcase_private_note', { where: {}, context: ctx })).rejects.toBeTruthy();
105+
});
106+
107+
it('explain() attributes the D10 intersection: alone allowed, on-behalf-of narrows', async () => {
108+
const security: any = stack.kernel.getService('security');
109+
// Self-explain carries onBehalfOf through unchanged (request.userId omitted).
110+
const decision = await security.explain(
111+
{ object: 'showcase_private_note', operation: 'read' },
112+
agentOnBehalf(),
113+
);
114+
const principal = (decision?.layers ?? []).find((l: any) => l.layer === 'principal');
115+
expect(principal?.detail, 'principal layer names the delegator intersection').toMatch(/on behalf of/i);
116+
expect(decision?.principal?.onBehalfOf?.userId).toBe(delId);
117+
});
118+
});

0 commit comments

Comments
 (0)