Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/d10-producer-agent-principal.md
Original file line number Diff line number Diff line change
@@ -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`).
101 changes: 101 additions & 0 deletions packages/dogfood/test/showcase-agent-scope-ceiling.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: {},
}),
];
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [];
Expand Down
38 changes: 25 additions & 13 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions packages/runtime/src/security/resolve-execution-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Loading