Skip to content

Commit d79ca07

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0090 D10 — activate agent principal (OAuth → scope ceiling) (#2843)
Wires the PRODUCER side of the D10 intersection that shipped in #2838, so it stops being dormant. An MCP request authenticated with an OAuth access token is now resolved as an AI agent acting ON BEHALF OF the human `sub`, and its effective permission is the intersection of a scope-derived capability ceiling AND the user's own grants. - resolve-execution-context (producer): a verified MCP OAuth token that names an authorized client (`azp`) → principalKind:'agent', onBehalfOf:{userId}, and the agent's OWN grants become the scope-derived ceiling (data:read→read-only, data:write→CRUD, neither→no data). userId stays the human (owner/RLS scope); user systemPermissions cleared so a cap-gated action can't ride the user's capabilities. No client → human. - plugin-security: three built-in ceiling sets (mcp_agent_data_read / _write / _restricted) — pure CRUD bits, NO row-level security (all row/owner/tenant narrowing comes from the delegating user on the other side of the intersection). An agent skips the additive human baseline (member_default) and its fallback is the restricted (no-object-access) set, so a mis-resolved agent fails CLOSED, never open. - spec: MCP_AGENT_PERMISSION_SET_* + scopesToAgentPermissionSets(), single-sourced beside the OAuth scope constants; api-surface regenerated. Behaviour change (a security tightening): an MCP OAuth request previously executed with the FULL authority of the logged-in user (scopes narrowed only the tool surface). Now the scope is also a real data-layer ceiling — a data:read token can never write ANY record, even via a crafted call, regardless of what the user could do. Strictly consistent with "a scope can never grant more than the user could do" (the intersection only narrows), closing the confused/compromised-agent gap. Tests: producer mapping unit-tested in runtime (resolve-execution-context, 491 green incl. mcp-oauth); enforcement dogfooded against the served engine (showcase-agent-scope-ceiling — data:read reads but cannot write/create; data:write for the same user can). plugin-security 298, plugin-sharing 76, D10/OWD/permission dogfood unchanged. api-surface check + tsc clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b043f24 commit d79ca07

9 files changed

Lines changed: 355 additions & 18 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
ADR-0090 D10 — activate the agent principal (OAuth → `principalKind:'agent'` + scope-derived ceiling). This wires the *producer* side of the D10 intersection that shipped in #2838, so it stops being dormant: an MCP request authenticated with an OAuth access token is now resolved as an AI **agent acting on behalf of** the human `sub`, and its effective permission is the intersection of a scope-derived capability ceiling AND the user's own grants.
8+
9+
- **`resolve-execution-context` (producer)**: when a verified MCP OAuth token names an authorized client (`azp`), the request resolves to `principalKind:'agent'` with `onBehalfOf:{ userId }` (the human), and the agent's OWN grants are replaced by the scope-derived ceiling — `data:read` → read-only, `data:write` → full CRUD, neither → no data access. `userId` stays the human so owner-stamping and `current_user.*` RLS resolve to them; the user-derived `systemPermissions` are cleared so a cap-gated action can't ride the user's capabilities. A token without a client stays a `human` principal.
10+
- **`plugin-security`**: three built-in ceiling sets (`mcp_agent_data_read` / `mcp_agent_data_write` / `mcp_agent_restricted`) — pure CRUD bits, no row-level security (all row/owner/tenant narrowing comes from the delegating user on the other side of the intersection). An `agent` principal skips the additive human baseline (`member_default`) — its grants are exactly its ceiling — and its fallback is the restricted (no-object-access) set, so a mis-resolved agent fails CLOSED, never open.
11+
- **`spec`**: `MCP_AGENT_PERMISSION_SET_*` names + `scopesToAgentPermissionSets()`, single-sourced next to the OAuth scope constants.
12+
13+
**Behaviour change (a security tightening).** Previously an MCP OAuth request executed with the FULL authority of the logged-in user, and scopes narrowed only the tool surface. Now the scope is also a real data-layer ceiling: a `data:read` token can never write ANY record, even via a crafted call, no matter what the user could do. This is strictly consistent with the existing contract that "a scope can never grant more than the user could do" — the intersection only ever narrows — and closes the gap where a compromised or confused agent could act with the user's full reach.
14+
15+
Verified end-to-end: a `data:read` agent acting for a member who owns a record can read it but cannot edit or create; a `data:write` agent for the same user can. Producer mapping unit-tested in `@objectstack/runtime`; enforcement dogfooded against the served engine (`showcase-agent-scope-ceiling`).
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0090 D10 — the OAuth-scope-derived AGENT CEILING, enforced end-to-end.
4+
//
5+
// The producer (`resolve-execution-context`) turns an MCP OAuth token into an
6+
// `principalKind:'agent'` principal acting `onBehalfOf` the human, whose OWN
7+
// grants are the scope-derived ceiling set (`data:read`→mcp_agent_data_read,
8+
// `data:write`→mcp_agent_data_write). That mapping is unit-tested in
9+
// `@objectstack/runtime`. THIS dogfood proves the other half: that the ceiling
10+
// sets resolve from the real bootstrap and the D10 intersection enforces them
11+
// against the real engine (SQLite, RLS, private-OWD) — so a `data:read` agent
12+
// acting for a user who CAN write is nonetheless blocked from writing at the
13+
// data layer, while a `data:write` agent for the same user is allowed.
14+
//
15+
// This promotes an OAuth scope from a tool-surface hint to a real, enforced
16+
// data-layer boundary, strictly consistent with "a scope can never grant more
17+
// than the user could do" (the intersection only narrows).
18+
//
19+
// @proof: showcase-agent-scope-ceiling
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+
const idOf = (r: any): string => r?.id ?? r?.record?.id;
27+
28+
describe('showcase: ADR-0090 D10 agent scope ceiling (served engine)', () => {
29+
let stack: VerifyStack;
30+
let ql: any;
31+
let aliceTok: string;
32+
let aliceId: string;
33+
let noteId: string;
34+
35+
const uid = async (email: string) =>
36+
(await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id;
37+
38+
beforeAll(async () => {
39+
stack = await bootStack(showcaseStack);
40+
await stack.signIn(); // admin bootstrap
41+
aliceTok = await stack.signUp('scope-alice@verify.test');
42+
ql = await stack.kernel.getServiceAsync('objectql');
43+
aliceId = await uid('scope-alice@verify.test');
44+
45+
// Alice (a plain member) owns a private note — she can read AND edit it.
46+
const created = await stack.apiAs(aliceTok, 'POST', '/data/showcase_private_note', { title: 'Alice note' });
47+
expect(created.status, 'member creates her own private note').toBeLessThan(300);
48+
noteId = idOf(await created.json())
49+
?? (await ql.findOne('showcase_private_note', { where: { title: 'Alice note' }, context: SYS }))?.id;
50+
expect(noteId, 'note id resolved').toBeTruthy();
51+
}, 120_000);
52+
53+
afterAll(async () => {
54+
await stack?.stop();
55+
});
56+
57+
// The agent context exactly as the producer emits it: acting on behalf of
58+
// Alice, whose OWN grants are the scope-derived ceiling (no member baseline).
59+
const agentCtx = (ceiling: string) => ({
60+
userId: aliceId,
61+
principalKind: 'agent' as const,
62+
positions: [] as string[],
63+
permissions: [ceiling],
64+
onBehalfOf: { userId: aliceId, principalKind: 'human' as const },
65+
});
66+
// A plain human context for Alice (member baseline applies) — the control.
67+
const aliceCtx = () => ({ userId: aliceId, positions: [] as string[], permissions: [] as string[] });
68+
69+
it('control: Alice (human) CAN read and edit her own note', async () => {
70+
const rows = await ql.find('showcase_private_note', { where: {}, context: aliceCtx() });
71+
expect((rows ?? []).map(idOf)).toContain(noteId);
72+
await expect(
73+
ql.update('showcase_private_note', { id: noteId, title: 'Alice edit' }, { context: aliceCtx() }),
74+
).resolves.toBeTruthy();
75+
});
76+
77+
it("a data:read agent CAN read Alice's note (read ceiling ∩ Alice = read Alice's rows)", async () => {
78+
const rows = await ql.find('showcase_private_note', { where: {}, context: agentCtx('mcp_agent_data_read') });
79+
expect((rows ?? []).map(idOf)).toContain(noteId);
80+
});
81+
82+
it('a data:read agent CANNOT edit that note — even though the user could (scope ceiling enforced at the data layer)', async () => {
83+
await expect(
84+
ql.update('showcase_private_note', { id: noteId, title: 'agent tried to edit' }, { context: agentCtx('mcp_agent_data_read') }),
85+
).rejects.toBeTruthy();
86+
});
87+
88+
it('a data:read agent CANNOT create either (read-only ceiling)', async () => {
89+
await expect(
90+
ql.insert('showcase_private_note', { title: 'agent created' }, { context: agentCtx('mcp_agent_data_read') }),
91+
).rejects.toBeTruthy();
92+
});
93+
94+
it('a data:write agent for the SAME user CAN edit the note (write ceiling ∩ Alice = write)', async () => {
95+
await expect(
96+
ql.update('showcase_private_note', { id: noteId, title: 'write agent edit' }, { context: agentCtx('mcp_agent_data_write') }),
97+
).resolves.toBeTruthy();
98+
const after = await ql.findOne('showcase_private_note', { where: { id: noteId }, context: SYS });
99+
expect(after?.title).toBe('write agent edit');
100+
});
101+
});

packages/plugins/plugin-security/src/objects/default-permission-sets.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
4+
import {
5+
MCP_AGENT_PERMISSION_SET_READ,
6+
MCP_AGENT_PERMISSION_SET_WRITE,
7+
MCP_AGENT_PERMISSION_SET_RESTRICTED,
8+
} from '@objectstack/spec/ai';
49

510
/**
611
* Identity tables managed by the better-auth plugin (see
@@ -544,4 +549,47 @@ export const defaultPermissionSets: PermissionSet[] = [
544549
},
545550
],
546551
}),
552+
553+
// ── [ADR-0090 D10] MCP agent ceiling sets ────────────────────────────────
554+
// The capability ceiling an OAuth-authenticated MCP agent runs under,
555+
// derived from the token's consented scopes (see
556+
// `scopesToAgentPermissionSets`). These are ONE SIDE of the D10 intersection:
557+
// the delegating user's own sets provide all row/owner/tenant narrowing, so
558+
// these carry pure CRUD bits and NO row-level security. They are never bound
559+
// to a position or an audience anchor — the producer
560+
// (`resolve-execution-context`) injects them onto the agent principal's
561+
// context directly — so the anchor high-privilege gate does not apply.
562+
PermissionSetSchema.parse({
563+
name: MCP_AGENT_PERMISSION_SET_READ,
564+
label: 'MCP Agent — Read Only',
565+
description:
566+
'Read-only ceiling for an AI agent acting on behalf of a user (OAuth `data:read`). ' +
567+
'Bounded by the delegating user via the ADR-0090 D10 intersection.',
568+
objects: {
569+
'*': { allowRead: true },
570+
},
571+
}),
572+
PermissionSetSchema.parse({
573+
name: MCP_AGENT_PERMISSION_SET_WRITE,
574+
label: 'MCP Agent — Read & Write',
575+
description:
576+
'Read+write ceiling for an AI agent acting on behalf of a user (OAuth `data:write`). ' +
577+
'Full CRUD, still bounded by the delegating user via the ADR-0090 D10 intersection. ' +
578+
'Identity tables stay read-only (better-auth managed).',
579+
objects: {
580+
'*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
581+
// Even a write-scoped agent must not mutate better-auth identity tables
582+
// directly — a belt to the intersection's braces (the user's baseline
583+
// already denies these, but an admin delegator would not).
584+
...denyWritesOnManagedObjects(),
585+
},
586+
}),
587+
PermissionSetSchema.parse({
588+
name: MCP_AGENT_PERMISSION_SET_RESTRICTED,
589+
label: 'MCP Agent — No Data Access',
590+
description:
591+
'No-object-access floor for an agent with no data scope (e.g. `actions:execute` only). ' +
592+
'Keeps the resolved set list non-empty so enforcement fails CLOSED, never open.',
593+
objects: {},
594+
}),
547595
];

packages/plugins/plugin-security/src/objects/rbac-objects.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,42 @@ import { SysPosition, SysPermissionSet, SysCapability, defaultPermissionSets } f
99
* data owns its tests.
1010
*/
1111
describe('default permission sets', () => {
12-
it('exposes the four canonical platform permission sets', () => {
12+
it('exposes the canonical platform permission sets + the ADR-0090 D10 agent ceilings', () => {
1313
const names = defaultPermissionSets.map((p) => p.name).sort();
1414
expect(names).toEqual([
1515
'admin_full_access',
16+
// [ADR-0090 D10] MCP agent capability ceilings (scope-derived; one side
17+
// of the agent∩user intersection). Never bound to a position/anchor.
18+
'mcp_agent_data_read',
19+
'mcp_agent_data_write',
20+
'mcp_agent_restricted',
1621
'member_default',
1722
'organization_admin',
1823
'viewer_readonly',
1924
]);
2025
});
2126

27+
it('the MCP agent ceiling sets carry pure CRUD bits and NO row-level security', () => {
28+
const read = defaultPermissionSets.find((p) => p.name === 'mcp_agent_data_read')!;
29+
const write = defaultPermissionSets.find((p) => p.name === 'mcp_agent_data_write')!;
30+
const restricted = defaultPermissionSets.find((p) => p.name === 'mcp_agent_restricted')!;
31+
expect(read.rowLevelSecurity ?? []).toEqual([]);
32+
expect(write.rowLevelSecurity ?? []).toEqual([]);
33+
// Read-only: read yes, write no.
34+
expect(read.objects?.['*']?.allowRead).toBe(true);
35+
expect(read.objects?.['*']?.allowEdit ?? false).toBe(false);
36+
expect(read.objects?.['*']?.allowCreate ?? false).toBe(false);
37+
// Write ceiling: full CRUD on the wildcard.
38+
expect(write.objects?.['*']?.allowEdit).toBe(true);
39+
expect(write.objects?.['*']?.allowDelete).toBe(true);
40+
// Restricted floor: no wildcard object grant at all (fail-closed).
41+
expect(restricted.objects?.['*']).toBeUndefined();
42+
// None of the agent ceilings carry high-privilege system permissions.
43+
for (const s of [read, write, restricted]) {
44+
expect(s.systemPermissions ?? []).toEqual([]);
45+
}
46+
});
47+
2248
it('organization_admin has setup.access but not studio.access / manage_metadata / manage_platform_settings', () => {
2349
const orgAdmin = defaultPermissionSets.find((p) => p.name === 'organization_admin')!;
2450
const sys = orgAdmin.systemPermissions ?? [];

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Plugin, PluginContext } from '@objectstack/core';
44
import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security';
55
import { describeHighPrivilegeBits, describeAnchorForbiddenBits } from '@objectstack/spec/security';
6+
import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai';
67
import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js';
78
import { DelegatedAdminGate } from './delegated-admin-gate.js';
89
import {
@@ -1427,15 +1428,24 @@ export class SecurityPlugin implements Plugin {
14271428
const positions = context?.positions ?? [];
14281429
const explicitPermissionSets = context?.permissions ?? [];
14291430
const requested = [...positions, ...explicitPermissionSets];
1430-
// [ADR-0090 D5] Baseline is ADDITIVE, always: the configured baseline set
1431-
// (default `member_default`) applies to every authenticated request IN
1432-
// ADDITION to whatever else resolved. The former "only when the user has
1433-
// nothing else" conditional was the fallback CLIFF — receiving your first
1434-
// explicit grant silently cost you the entire baseline. The `everyone`
1435-
// audience anchor carries the same semantics for admin-authored defaults;
1436-
// this keeps the CLI-configured baseline coherent with it.
1437-
if (context?.userId && this.fallbackPermissionSet && !requested.includes(this.fallbackPermissionSet)) {
1438-
requested.push(this.fallbackPermissionSet);
1431+
// [ADR-0090 D10] An AGENT principal's grants are EXACTLY its scope-derived
1432+
// ceiling set(s) — the additive human baseline (member_default) must NOT
1433+
// apply, or its write bit would silently widen a read-only agent past the
1434+
// scope the user consented to. The agent's floor is instead the restricted
1435+
// (no-object-access) set, so an agent whose sets fail to resolve fails
1436+
// CLOSED (every object op denied) rather than falling open. The delegating
1437+
// user's own baseline still applies on the OTHER side of the intersection
1438+
// (resolved from `onBehalfOf` via a context without this flag).
1439+
const isAgent = context?.principalKind === 'agent';
1440+
const baseline = isAgent ? MCP_AGENT_PERMISSION_SET_RESTRICTED : this.fallbackPermissionSet;
1441+
// [ADR-0090 D5] Baseline is ADDITIVE, always (for humans): the configured
1442+
// baseline set applies to every authenticated request IN ADDITION to
1443+
// whatever else resolved. The former "only when the user has nothing else"
1444+
// conditional was the fallback CLIFF — receiving your first explicit grant
1445+
// silently cost you the entire baseline. Agents skip this additive step
1446+
// (their ceiling is closed, not floored) — see above.
1447+
if (!isAgent && context?.userId && baseline && !requested.includes(baseline)) {
1448+
requested.push(baseline);
14391449
}
14401450
let permissionSets = await this.permissionEvaluator.resolvePermissionSets(
14411451
requested,
@@ -1445,15 +1455,17 @@ export class SecurityPlugin implements Plugin {
14451455
{ logger: this.logger },
14461456
);
14471457
// Post-resolution fallback — closes the fail-open hole where a populated
1448-
// `positions` array maps to no permission set yet (no sys_position binding), which
1449-
// would otherwise skip RLS entirely and expose every tenant's data.
1458+
// `positions` array maps to no permission set yet (no sys_position binding),
1459+
// which would otherwise skip RLS entirely and expose every tenant's data.
1460+
// For an agent, the fallback is the restricted set (deny all objects), NOT
1461+
// the human baseline — a mis-resolved agent must never inherit member access.
14501462
if (
14511463
permissionSets.length === 0 &&
14521464
context?.userId &&
1453-
this.fallbackPermissionSet
1465+
baseline
14541466
) {
14551467
permissionSets = await this.permissionEvaluator.resolvePermissionSets(
1456-
[this.fallbackPermissionSet],
1468+
[baseline],
14571469
this.metadata,
14581470
this.bootstrapPermissionSets,
14591471
this.dbLoader,

packages/runtime/src/security/resolve-execution-context.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,54 @@ describe('principal taxonomy at the HTTP entry (ADR-0090 D9/D10)', () => {
353353
});
354354
});
355355

356+
describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mcp)', () => {
357+
// A verified MCP OAuth token: `sub` = the human, `azp` = the agent client.
358+
const agentOpts = (verified: any) => ({
359+
acceptOAuthAccessToken: true,
360+
getService: async (name: string) =>
361+
name === 'auth' ? { verifyMcpAccessToken: async () => verified } : undefined,
362+
getQl: async () => makeQl([]),
363+
request: { headers: { authorization: 'Bearer a.b.c' } },
364+
});
365+
366+
it("data:read token with a client → principalKind 'agent', onBehalfOf the user, read-only ceiling", async () => {
367+
const ctx = await resolveExecutionContext(
368+
agentOpts({ userId: 'u1', scopes: ['data:read'], clientId: 'agent-app-1' }),
369+
);
370+
expect(ctx.principalKind).toBe('agent');
371+
// userId stays the human so owner-stamping + current_user.* RLS resolve to them.
372+
expect(ctx.userId).toBe('u1');
373+
expect(ctx.onBehalfOf).toEqual({ userId: 'u1', principalKind: 'human' });
374+
// Agent's OWN grants are the scope-derived ceiling, NOT the user's.
375+
expect(ctx.permissions).toEqual(['mcp_agent_data_read']);
376+
expect(ctx.positions).toEqual([]);
377+
expect(ctx.systemPermissions).toEqual([]);
378+
// Tool-surface scope gate still applies.
379+
expect(ctx.oauthScopes).toEqual(['data:read']);
380+
});
381+
382+
it('data:write token → read+write ceiling set', async () => {
383+
const ctx = await resolveExecutionContext(
384+
agentOpts({ userId: 'u1', scopes: ['data:write'], clientId: 'c1' }),
385+
);
386+
expect(ctx.principalKind).toBe('agent');
387+
expect(ctx.permissions).toEqual(['mcp_agent_data_write']);
388+
});
389+
390+
it('actions-only token → restricted (no-object-access) ceiling, still an agent', async () => {
391+
const ctx = await resolveExecutionContext(
392+
agentOpts({ userId: 'u1', scopes: ['actions:execute'], clientId: 'c1' }),
393+
);
394+
expect(ctx.principalKind).toBe('agent');
395+
expect(ctx.permissions).toEqual(['mcp_agent_restricted']);
396+
});
397+
398+
it('an OAuth token WITHOUT a client (no azp) stays a human principal — not every bearer is an agent', async () => {
399+
const ctx = await resolveExecutionContext(
400+
agentOpts({ userId: 'u1', scopes: ['data:read'] }),
401+
);
402+
expect(ctx.principalKind).toBe('human');
403+
expect(ctx.onBehalfOf).toBeUndefined();
404+
});
405+
});
406+

0 commit comments

Comments
 (0)