From d9b38ce13cf9b1f6f73562c368c79236bafa24cb Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 11 Jul 2026 22:19:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20ADR-0090=20D10=20=E2=80=94=20?= =?UTF-8?q?activate=20agent=20principal=20(OAuth=20=E2=86=92=20scope=20cei?= =?UTF-8?q?ling)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/d10-producer-agent-principal.md | 15 +++ ...owcase-agent-scope-ceiling.dogfood.test.ts | 101 ++++++++++++++++++ .../src/objects/default-permission-sets.ts | 48 +++++++++ .../src/objects/rbac-objects.test.ts | 28 ++++- .../plugin-security/src/security-plugin.ts | 38 ++++--- .../resolve-execution-context.test.ts | 51 +++++++++ .../src/security/resolve-execution-context.ts | 26 ++++- packages/spec/api-surface.json | 5 + packages/spec/src/ai/mcp.zod.ts | 61 ++++++++++- 9 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 .changeset/d10-producer-agent-principal.md create mode 100644 packages/dogfood/test/showcase-agent-scope-ceiling.dogfood.test.ts diff --git a/.changeset/d10-producer-agent-principal.md b/.changeset/d10-producer-agent-principal.md new file mode 100644 index 0000000000..c311cd1953 --- /dev/null +++ b/.changeset/d10-producer-agent-principal.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-security': minor +'@objectstack/runtime': minor +--- + +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. + +- **`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. +- **`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. +- **`spec`**: `MCP_AGENT_PERMISSION_SET_*` names + `scopesToAgentPermissionSets()`, single-sourced next to the OAuth scope constants. + +**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. + +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`). diff --git a/packages/dogfood/test/showcase-agent-scope-ceiling.dogfood.test.ts b/packages/dogfood/test/showcase-agent-scope-ceiling.dogfood.test.ts new file mode 100644 index 0000000000..8147e969c5 --- /dev/null +++ b/packages/dogfood/test/showcase-agent-scope-ceiling.dogfood.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0090 D10 — the OAuth-scope-derived AGENT CEILING, enforced end-to-end. +// +// The producer (`resolve-execution-context`) turns an MCP OAuth token into an +// `principalKind:'agent'` principal acting `onBehalfOf` the human, whose OWN +// grants are the scope-derived ceiling set (`data:read`→mcp_agent_data_read, +// `data:write`→mcp_agent_data_write). That mapping is unit-tested in +// `@objectstack/runtime`. THIS dogfood proves the other half: that the ceiling +// sets resolve from the real bootstrap and the D10 intersection enforces them +// against the real engine (SQLite, RLS, private-OWD) — so a `data:read` agent +// acting for a user who CAN write is nonetheless blocked from writing at the +// data layer, while a `data:write` agent for the same user is allowed. +// +// This promotes an OAuth scope from a tool-surface hint to a real, enforced +// data-layer boundary, strictly consistent with "a scope can never grant more +// than the user could do" (the intersection only narrows). +// +// @proof: showcase-agent-scope-ceiling + +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; +const idOf = (r: any): string => r?.id ?? r?.record?.id; + +describe('showcase: ADR-0090 D10 agent scope ceiling (served engine)', () => { + let stack: VerifyStack; + let ql: any; + let aliceTok: string; + let aliceId: string; + let noteId: 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 bootstrap + aliceTok = await stack.signUp('scope-alice@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); + aliceId = await uid('scope-alice@verify.test'); + + // Alice (a plain member) owns a private note — she can read AND edit it. + const created = await stack.apiAs(aliceTok, 'POST', '/data/showcase_private_note', { title: 'Alice note' }); + expect(created.status, 'member creates her own private note').toBeLessThan(300); + noteId = idOf(await created.json()) + ?? (await ql.findOne('showcase_private_note', { where: { title: 'Alice note' }, context: SYS }))?.id; + expect(noteId, 'note id resolved').toBeTruthy(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + // The agent context exactly as the producer emits it: acting on behalf of + // Alice, whose OWN grants are the scope-derived ceiling (no member baseline). + const agentCtx = (ceiling: string) => ({ + userId: aliceId, + principalKind: 'agent' as const, + positions: [] as string[], + permissions: [ceiling], + onBehalfOf: { userId: aliceId, principalKind: 'human' as const }, + }); + // A plain human context for Alice (member baseline applies) — the control. + const aliceCtx = () => ({ userId: aliceId, positions: [] as string[], permissions: [] as string[] }); + + it('control: Alice (human) CAN read and edit her own note', async () => { + const rows = await ql.find('showcase_private_note', { where: {}, context: aliceCtx() }); + expect((rows ?? []).map(idOf)).toContain(noteId); + await expect( + ql.update('showcase_private_note', { id: noteId, title: 'Alice edit' }, { context: aliceCtx() }), + ).resolves.toBeTruthy(); + }); + + it("a data:read agent CAN read Alice's note (read ceiling ∩ Alice = read Alice's rows)", async () => { + const rows = await ql.find('showcase_private_note', { where: {}, context: agentCtx('mcp_agent_data_read') }); + expect((rows ?? []).map(idOf)).toContain(noteId); + }); + + it('a data:read agent CANNOT edit that note — even though the user could (scope ceiling enforced at the data layer)', async () => { + await expect( + ql.update('showcase_private_note', { id: noteId, title: 'agent tried to edit' }, { context: agentCtx('mcp_agent_data_read') }), + ).rejects.toBeTruthy(); + }); + + it('a data:read agent CANNOT create either (read-only ceiling)', async () => { + await expect( + ql.insert('showcase_private_note', { title: 'agent created' }, { context: agentCtx('mcp_agent_data_read') }), + ).rejects.toBeTruthy(); + }); + + it('a data:write agent for the SAME user CAN edit the note (write ceiling ∩ Alice = write)', async () => { + await expect( + ql.update('showcase_private_note', { id: noteId, title: 'write agent edit' }, { context: agentCtx('mcp_agent_data_write') }), + ).resolves.toBeTruthy(); + const after = await ql.findOne('showcase_private_note', { where: { id: noteId }, context: SYS }); + expect(after?.title).toBe('write agent edit'); + }); +}); diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 9aa9c8de08..fa9af50452 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -1,6 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { + MCP_AGENT_PERMISSION_SET_READ, + MCP_AGENT_PERMISSION_SET_WRITE, + MCP_AGENT_PERMISSION_SET_RESTRICTED, +} from '@objectstack/spec/ai'; /** * Identity tables managed by the better-auth plugin (see @@ -544,4 +549,47 @@ export const defaultPermissionSets: PermissionSet[] = [ }, ], }), + + // ── [ADR-0090 D10] MCP agent ceiling sets ──────────────────────────────── + // The capability ceiling an OAuth-authenticated MCP agent runs under, + // derived from the token's consented scopes (see + // `scopesToAgentPermissionSets`). These are ONE SIDE of the D10 intersection: + // the delegating user's own sets provide all row/owner/tenant narrowing, so + // these carry pure CRUD bits and NO row-level security. They are never bound + // to a position or an audience anchor — the producer + // (`resolve-execution-context`) injects them onto the agent principal's + // context directly — so the anchor high-privilege gate does not apply. + PermissionSetSchema.parse({ + name: MCP_AGENT_PERMISSION_SET_READ, + label: 'MCP Agent — Read Only', + description: + 'Read-only ceiling for an AI agent acting on behalf of a user (OAuth `data:read`). ' + + 'Bounded by the delegating user via the ADR-0090 D10 intersection.', + objects: { + '*': { allowRead: true }, + }, + }), + PermissionSetSchema.parse({ + name: MCP_AGENT_PERMISSION_SET_WRITE, + label: 'MCP Agent — Read & Write', + description: + 'Read+write ceiling for an AI agent acting on behalf of a user (OAuth `data:write`). ' + + 'Full CRUD, still bounded by the delegating user via the ADR-0090 D10 intersection. ' + + 'Identity tables stay read-only (better-auth managed).', + objects: { + '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, + // Even a write-scoped agent must not mutate better-auth identity tables + // directly — a belt to the intersection's braces (the user's baseline + // already denies these, but an admin delegator would not). + ...denyWritesOnManagedObjects(), + }, + }), + PermissionSetSchema.parse({ + name: MCP_AGENT_PERMISSION_SET_RESTRICTED, + label: 'MCP Agent — No Data Access', + description: + 'No-object-access floor for an agent with no data scope (e.g. `actions:execute` only). ' + + 'Keeps the resolved set list non-empty so enforcement fails CLOSED, never open.', + objects: {}, + }), ]; diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index f2b1fc3d49..07bce5102b 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -9,16 +9,42 @@ import { SysPosition, SysPermissionSet, SysCapability, defaultPermissionSets } f * data owns its tests. */ describe('default permission sets', () => { - it('exposes the four canonical platform permission sets', () => { + it('exposes the canonical platform permission sets + the ADR-0090 D10 agent ceilings', () => { const names = defaultPermissionSets.map((p) => p.name).sort(); expect(names).toEqual([ 'admin_full_access', + // [ADR-0090 D10] MCP agent capability ceilings (scope-derived; one side + // of the agent∩user intersection). Never bound to a position/anchor. + 'mcp_agent_data_read', + 'mcp_agent_data_write', + 'mcp_agent_restricted', 'member_default', 'organization_admin', 'viewer_readonly', ]); }); + it('the MCP agent ceiling sets carry pure CRUD bits and NO row-level security', () => { + const read = defaultPermissionSets.find((p) => p.name === 'mcp_agent_data_read')!; + const write = defaultPermissionSets.find((p) => p.name === 'mcp_agent_data_write')!; + const restricted = defaultPermissionSets.find((p) => p.name === 'mcp_agent_restricted')!; + expect(read.rowLevelSecurity ?? []).toEqual([]); + expect(write.rowLevelSecurity ?? []).toEqual([]); + // Read-only: read yes, write no. + expect(read.objects?.['*']?.allowRead).toBe(true); + expect(read.objects?.['*']?.allowEdit ?? false).toBe(false); + expect(read.objects?.['*']?.allowCreate ?? false).toBe(false); + // Write ceiling: full CRUD on the wildcard. + expect(write.objects?.['*']?.allowEdit).toBe(true); + expect(write.objects?.['*']?.allowDelete).toBe(true); + // Restricted floor: no wildcard object grant at all (fail-closed). + expect(restricted.objects?.['*']).toBeUndefined(); + // None of the agent ceilings carry high-privilege system permissions. + for (const s of [read, write, restricted]) { + expect(s.systemPermissions ?? []).toEqual([]); + } + }); + it('organization_admin has setup.access but not studio.access / manage_metadata / manage_platform_settings', () => { const orgAdmin = defaultPermissionSets.find((p) => p.name === 'organization_admin')!; const sys = orgAdmin.systemPermissions ?? []; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index d5dd3fdade..051bae6380 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -3,6 +3,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; import type { PermissionSet, RowLevelSecurityPolicy } from '@objectstack/spec/security'; import { describeHighPrivilegeBits, describeAnchorForbiddenBits } from '@objectstack/spec/security'; +import { MCP_AGENT_PERMISSION_SET_RESTRICTED } from '@objectstack/spec/ai'; import { PermissionEvaluator, crudBucketForOperation } from './permission-evaluator.js'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; import { @@ -1427,15 +1428,24 @@ export class SecurityPlugin implements Plugin { const positions = context?.positions ?? []; const explicitPermissionSets = context?.permissions ?? []; const requested = [...positions, ...explicitPermissionSets]; - // [ADR-0090 D5] Baseline is ADDITIVE, always: the configured baseline set - // (default `member_default`) applies to every authenticated request IN - // ADDITION to whatever else resolved. The former "only when the user has - // nothing else" conditional was the fallback CLIFF — receiving your first - // explicit grant silently cost you the entire baseline. The `everyone` - // audience anchor carries the same semantics for admin-authored defaults; - // this keeps the CLI-configured baseline coherent with it. - if (context?.userId && this.fallbackPermissionSet && !requested.includes(this.fallbackPermissionSet)) { - requested.push(this.fallbackPermissionSet); + // [ADR-0090 D10] An AGENT principal's grants are EXACTLY its scope-derived + // ceiling set(s) — the additive human baseline (member_default) must NOT + // apply, or its write bit would silently widen a read-only agent past the + // scope the user consented to. The agent's floor is instead the restricted + // (no-object-access) set, so an agent whose sets fail to resolve fails + // CLOSED (every object op denied) rather than falling open. The delegating + // user's own baseline still applies on the OTHER side of the intersection + // (resolved from `onBehalfOf` via a context without this flag). + const isAgent = context?.principalKind === 'agent'; + const baseline = isAgent ? MCP_AGENT_PERMISSION_SET_RESTRICTED : this.fallbackPermissionSet; + // [ADR-0090 D5] Baseline is ADDITIVE, always (for humans): the configured + // baseline set applies to every authenticated request IN ADDITION to + // whatever else resolved. The former "only when the user has nothing else" + // conditional was the fallback CLIFF — receiving your first explicit grant + // silently cost you the entire baseline. Agents skip this additive step + // (their ceiling is closed, not floored) — see above. + if (!isAgent && context?.userId && baseline && !requested.includes(baseline)) { + requested.push(baseline); } let permissionSets = await this.permissionEvaluator.resolvePermissionSets( requested, @@ -1445,15 +1455,17 @@ export class SecurityPlugin implements Plugin { { logger: this.logger }, ); // Post-resolution fallback — closes the fail-open hole where a populated - // `positions` array maps to no permission set yet (no sys_position binding), which - // would otherwise skip RLS entirely and expose every tenant's data. + // `positions` array maps to no permission set yet (no sys_position binding), + // which would otherwise skip RLS entirely and expose every tenant's data. + // For an agent, the fallback is the restricted set (deny all objects), NOT + // the human baseline — a mis-resolved agent must never inherit member access. if ( permissionSets.length === 0 && context?.userId && - this.fallbackPermissionSet + baseline ) { permissionSets = await this.permissionEvaluator.resolvePermissionSets( - [this.fallbackPermissionSet], + [baseline], this.metadata, this.bootstrapPermissionSets, this.dbLoader, diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 3acb249dfc..fad92beadc 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -353,3 +353,54 @@ describe('principal taxonomy at the HTTP entry (ADR-0090 D9/D10)', () => { }); }); +describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mcp)', () => { + // A verified MCP OAuth token: `sub` = the human, `azp` = the agent client. + const agentOpts = (verified: any) => ({ + acceptOAuthAccessToken: true, + getService: async (name: string) => + name === 'auth' ? { verifyMcpAccessToken: async () => verified } : undefined, + getQl: async () => makeQl([]), + request: { headers: { authorization: 'Bearer a.b.c' } }, + }); + + it("data:read token with a client → principalKind 'agent', onBehalfOf the user, read-only ceiling", async () => { + const ctx = await resolveExecutionContext( + agentOpts({ userId: 'u1', scopes: ['data:read'], clientId: 'agent-app-1' }), + ); + expect(ctx.principalKind).toBe('agent'); + // userId stays the human so owner-stamping + current_user.* RLS resolve to them. + expect(ctx.userId).toBe('u1'); + expect(ctx.onBehalfOf).toEqual({ userId: 'u1', principalKind: 'human' }); + // Agent's OWN grants are the scope-derived ceiling, NOT the user's. + expect(ctx.permissions).toEqual(['mcp_agent_data_read']); + expect(ctx.positions).toEqual([]); + expect(ctx.systemPermissions).toEqual([]); + // Tool-surface scope gate still applies. + expect(ctx.oauthScopes).toEqual(['data:read']); + }); + + it('data:write token → read+write ceiling set', async () => { + const ctx = await resolveExecutionContext( + agentOpts({ userId: 'u1', scopes: ['data:write'], clientId: 'c1' }), + ); + expect(ctx.principalKind).toBe('agent'); + expect(ctx.permissions).toEqual(['mcp_agent_data_write']); + }); + + it('actions-only token → restricted (no-object-access) ceiling, still an agent', async () => { + const ctx = await resolveExecutionContext( + agentOpts({ userId: 'u1', scopes: ['actions:execute'], clientId: 'c1' }), + ); + expect(ctx.principalKind).toBe('agent'); + expect(ctx.permissions).toEqual(['mcp_agent_restricted']); + }); + + it('an OAuth token WITHOUT a client (no azp) stays a human principal — not every bearer is an agent', async () => { + const ctx = await resolveExecutionContext( + agentOpts({ userId: 'u1', scopes: ['data:read'] }), + ); + expect(ctx.principalKind).toBe('human'); + expect(ctx.onBehalfOf).toBeUndefined(); + }); +}); + diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 74d9b3c13a..e5aa4d4f1e 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -20,6 +20,7 @@ */ import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { scopesToAgentPermissionSets } from '@objectstack/spec/ai'; import { resolveAuthzContext, @@ -152,7 +153,30 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise; * These are the coarse, tool-family-level grants an OAuth access token can * carry when a human-connected MCP client (claude.ai, Claude Desktop, * Claude Code, …) authorizes against a deployment's embedded authorization - * server. Scopes bound the *tool surface* only — every call still executes - * under the resolved principal's permissions and row-level security, so a - * scope can never grant more than the logged-in user could do anyway. + * server. Scopes narrow the *tool surface* (which tools are exposed) AND, via + * `scopesToAgentPermissionSets` below, derive the agent's DATA-LAYER capability + * ceiling: an OAuth-authenticated request is a `principalKind:'agent'` acting + * on behalf of the human, and the ADR-0090 D10 intersection bounds it by BOTH + * that scope-derived ceiling AND the user's own grants. So a scope can never + * grant more than the logged-in user could do (the intersection only narrows), + * and it now enforces that at the data layer, not just the tool surface. * * Deliberately minimal (see #2698): finer grades can be added later without * breaking these. Constants live in the spec so the authorization server @@ -131,3 +135,54 @@ export const MCP_OAUTH_SCOPES = [ ] as const; export type McpOauthScope = (typeof MCP_OAUTH_SCOPES)[number]; + +/** + * [ADR-0090 D10] Built-in permission sets that express an MCP agent's + * capability CEILING, derived from the OAuth scopes the user consented to. + * + * When an MCP request authenticates via an OAuth token (an AI client acting on + * behalf of a human), the engine treats it as a `principalKind:'agent'` + * principal `onBehalfOf` that human. The agent's OWN grants are one of these + * sets, and the D10 intersection bounds the effective permission by BOTH the + * agent's ceiling AND the delegating user's grants — so a `data:read` token can + * never write ANY record at the data layer, even via a crafted call, no matter + * what the user could do. This promotes the OAuth scope from a tool-surface + * hint (bypassable at the API) to a real, enforced data-layer boundary while + * staying strictly consistent with "a scope can never grant more than the user + * could do anyway" (the intersection only ever narrows). + * + * These sets are pure capability ceilings — object CRUD bits only, NO + * row-level security. All row/owner/tenant narrowing comes from the delegating + * user's own sets on the other side of the intersection. + */ +/** Read-only ceiling (maps to `data:read`): read every object, write none. */ +export const MCP_AGENT_PERMISSION_SET_READ = 'mcp_agent_data_read'; +/** Read+write ceiling (maps to `data:write`): full CRUD, still bounded by the user. */ +export const MCP_AGENT_PERMISSION_SET_WRITE = 'mcp_agent_data_write'; +/** + * No-object-access floor (actions-only / no data scope). Keeps the resolved set + * list non-empty so the enforcement middleware fails CLOSED (every object op + * denied) rather than falling open on an empty principal. + */ +export const MCP_AGENT_PERMISSION_SET_RESTRICTED = 'mcp_agent_restricted'; + +/** All built-in MCP agent ceiling sets. */ +export const MCP_AGENT_PERMISSION_SETS = [ + MCP_AGENT_PERMISSION_SET_READ, + MCP_AGENT_PERMISSION_SET_WRITE, + MCP_AGENT_PERMISSION_SET_RESTRICTED, +] as const; + +/** + * [ADR-0090 D10] Map the OAuth scopes on an MCP token to the agent's ceiling + * permission set(s). `data:write` (a superset — its set also grants read) wins + * over `data:read`; a token with neither data scope (e.g. `actions:execute` + * only) gets the no-object-access floor. Always returns at least one set so the + * agent principal is never empty (which would fail OPEN in the middleware). + */ +export function scopesToAgentPermissionSets(scopes: readonly string[] | undefined): string[] { + const s = new Set(scopes ?? []); + if (s.has(MCP_OAUTH_SCOPE_DATA_WRITE)) return [MCP_AGENT_PERMISSION_SET_WRITE]; + if (s.has(MCP_OAUTH_SCOPE_DATA_READ)) return [MCP_AGENT_PERMISSION_SET_READ]; + return [MCP_AGENT_PERMISSION_SET_RESTRICTED]; +}