diff --git a/.changeset/28367-agent-forward-credential.md b/.changeset/28367-agent-forward-credential.md new file mode 100644 index 00000000000..29bde84d99b --- /dev/null +++ b/.changeset/28367-agent-forward-credential.md @@ -0,0 +1,27 @@ +--- +'@objectstack/rest': patch +'@objectstack/runtime': patch +'@objectstack/plugin-auth': patch +--- + +feat(runtime, rest, plugin-auth): an AI agent connected over OAuth can run `api` actions that forward to identity routes and other same-origin routes, as the agent + +Clause-②: yes (widening, at the two named cells only) +Metadata-protocol: none + +An `api` action runs by forwarding a request to its `target` inside the server. Until now an agent connected over OAuth was refused by name before that forward unless the target was the MCP endpoint, because its access token is honoured there alone. So the identity actions ADR-0011 exposes to agents (update my profile, the confirmed admin actions) never ran for one. + +Now, per ADR-0090 D10 rule 5, the forward carries a credential the server mints for that one forward instead of the agent's token. It is bound to the forward's method and route and to the action run, spent by its first use, revoked when the target answers, and redeemable only inside the server's own in-process call. At the target it resolves to the same agent: its scope ceiling, the user it acts for and its client. At the identity routes, the auth library is shown the delegating user for that one request only; no session is created, and the user's own permissions there still decide. Writes the agent makes are recorded with both the agent and its user. + +What changes, at exactly two cells of the forward table: + +- an agent at an identity route: refused by name before, now forwarded as the agent; +- an agent at any other same-origin route: refused by name before, now forwarded as the agent when the connection holds `actions:execute` and `data:write`. + +What does not change: + +- An API key is still refused by name at an identity route. +- A state-changing request to the metadata endpoint is never forwarded for an agent; it is refused `403` by name. +- An agent without `data:write` is refused `403` by name, and `list_actions` marks those actions the same way. +- Nothing the target mints, such as a rotated session token or cookie, comes back to an agent. +- An OAuth access token presented directly to the data API or to an identity route is still refused. diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx index ad37ee3cdab..4c3e50d0869 100644 --- a/content/docs/ai/actions-as-tools.mdx +++ b/content/docs/ai/actions-as-tools.mdx @@ -113,7 +113,7 @@ through the metadata API. |:---|:---|:---| | `script` | `IDataEngine.executeAction(object, target, ctx)` — the same call Studio makes | open (MCP) + ObjectOS | | `flow` | automation flow runner — `execute(target, automationContext)` (caller identity forwarded so `runAs` engages) | open (MCP) + ObjectOS; needs the `automation` service registered | -| `api` | same-origin `target` (a path starting with `/`): forwarded in-process through the action door to that target, after the door's gates, with the caller's own credentials; a credential rotation the target emits comes back as the bearer-lane headers only (`set-auth-token` / `set-admin-session-token`), never a `Set-Cookie` line. The forward carries only a credential the target's door accepts: an identity route (`/api/v1/auth/…`) reads a signed-in session only, every other target a session or an API key, and an OAuth access token is honoured on the MCP endpoint only. Any other caller is refused `401` `UNAUTHENTICATED` before the forward, with a sentence naming the action, its target and what would let it run: an API key on an identity route, and an agent connected over OAuth on any target outside the MCP endpoint | open (MCP) + ObjectOS; an `api` action whose `target` is on another origin stays browser-only and is not listed | +| `api` | same-origin `target` (a path starting with `/`): forwarded in-process through the action door to that target, after the door's gates, with the caller's own credentials; a credential rotation the target emits comes back as the bearer-lane headers only (`set-auth-token` / `set-admin-session-token`), never a `Set-Cookie` line. The forward carries only a credential the target's door accepts: an identity route (`/api/v1/auth/…`) reads a signed-in session only, every other target a session or an API key, and an OAuth access token is honoured on the MCP endpoint only. An API key on an identity route is refused `401` `UNAUTHENTICATED` before the forward, with a sentence naming the action, its target and what would let it run. An agent connected over OAuth is forwarded with a credential the server mints for that one forward instead of its token, and the target reads the same agent (its scope ceiling, the user it acts for, its client). Outside the MCP endpoint that needs the `actions:execute` and `data:write` scopes, since every `api` action's method changes state, and a state-changing request to the metadata endpoint (`/api/v1/meta/…`) is never forwarded for an agent: those are refused `403` `PERMISSION_DENIED` by name, before the forward. Nothing the target mints, such as a rotated session, comes back to an agent | open (MCP) + ObjectOS; an `api` action whose `target` is on another origin stays browser-only and is not listed | Console-only types (`url`, `modal`, `form`) are always skipped. diff --git a/docs/adr/0090-permission-model-v2-concept-convergence.md b/docs/adr/0090-permission-model-v2-concept-convergence.md index 6a14033fa90..d8e28004bb1 100644 --- a/docs/adr/0090-permission-model-v2-concept-convergence.md +++ b/docs/adr/0090-permission-model-v2-concept-convergence.md @@ -418,8 +418,10 @@ depends on: agent may *initiate*, not what it may *complete alone*. 4. **Dual attribution**: every write **by an agent principal** records `performed_by` (agent) + `on_behalf_of` (user) + run id; explain (D6) reports both sides of the intersection. An - agent principal is a caller a door resolves to `principalKind: 'agent'` — today the OAuth / - MCP client door alone. A caller of any other kind records the principal it authenticates as, + agent principal is a caller a door resolves to `principalKind: 'agent'`: the OAuth / MCP + client door, and (since #28367) a door serving an `api` action's forward that carries the + forward credential of rule 5, which is minted only for an agent that first door admitted. + A caller of any other kind records the principal it authenticates as, and the missing `performed_by` is itself the record that the principal acted for itself; for the API-key case see the 2026-09-16 note at the end of this decision. 5. **A forward carries the agent, never the user** (amended 2026-10-31): when an agent runs an @@ -454,10 +456,11 @@ alias tax on every API. > on **every** door that runs > `packages/core/src/security/resolve-authz-context.ts#resolveAuthzContext` — REST, the runtime / > MCP **HTTP** dispatcher, and the MCP **stdio** transport — and none of the three turns one into -> an agent, by two different mechanisms. REST and stdio hand the assembler -> `oauth: undefined` **by construction** (`packages/rest/src/rest-server.ts`, -> `packages/mcp/src/plugin.ts#resolveStdioExecutionContext`), so agent provenance is not -> representable on either. On the HTTP dispatcher — the one door that does mint agent principals — +> an agent, by two different mechanisms. stdio hands the assembler `oauth: undefined` **by +> construction** (`packages/mcp/src/plugin.ts#resolveStdioExecutionContext`), so agent provenance +> is not representable there; REST did the same until #28367, and since then represents it for +> one caller only, a request carrying a rule 5 forward credential +> (`packages/rest/src/rest-server.ts`), never for a key. On the HTTP dispatcher — the one door that does mint agent principals — > it is a **guard** rather than a structural impossibility: > `packages/runtime/src/security/resolve-execution-context.ts#extractJwtBearer` refuses an > `osk_`-prefixed bearer and anything that is not a three-segment JWS, so no OAuth provenance is diff --git a/packages/plugins/plugin-auth/src/agent-forward-identity-view.test.ts b/packages/plugins/plugin-auth/src/agent-forward-identity-view.test.ts new file mode 100644 index 00000000000..3589b9a82af --- /dev/null +++ b/packages/plugins/plugin-auth/src/agent-forward-identity-view.test.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [ADR-0090 D10 rule 5, #28367] The identity mount's request-scoped view of +// the delegating user. Pinned here at the seam (the mount middleware and the +// session-lookup plugin); end to end through better-auth in +// `packages/qa/dogfood/test/mcp-oauth-api-action-forward.dogfood.test.ts`. + +import { describe, it, expect, vi } from 'vitest'; +import { FORWARD_CREDENTIAL_HEADER, withForwardCredential } from '@objectstack/rest'; +import { + agentForwardIdentityViewPlugin, + agentForwardPerformedBy, + serveAgentForwardAtIdentityMount, +} from './agent-forward-identity-view.js'; +import { authSystemWriteContext } from './auth-actor-attribution.js'; + +const AGENT = { userId: 'u_mia', clientId: 'agent-client-1', scopes: ['actions:execute', 'data:write'], organizationId: 'org_alpha' }; +const URL_ = 'http://localhost/api/v1/auth/update-user'; +const bind = { method: 'POST', path: '/api/v1/auth/update-user', run: { objectName: 'sys_user', actionName: 'update_my_profile' }, agent: AGENT }; + +/** A Hono-shaped context: `c.req.raw` and a writable `c.res`. */ +const honoCtx = (headers: Record) => ({ + req: { raw: new Request(URL_, { method: 'POST', headers, body: JSON.stringify({ name: 'x' }) }) }, + res: undefined as Response | undefined, +}); + +/** The plugin's wrapped adapter over an inner one that knows one user and no session. */ +const wrappedAdapter = (user: Record | null = { id: 'u_mia', email: 'mia@example.com', banned: false }) => { + const inner = { + findOne: vi.fn(async (args: any) => (args.model === 'user' ? user : null)), + create: vi.fn(), + }; + const out: any = agentForwardIdentityViewPlugin().init({ adapter: inner, sessionConfig: { expiresIn: 604800 } }); + return { inner, adapter: out.context.adapter }; +}; + +describe('[ADR-0090 D10 rule 5] the identity mount: a forward credential becomes a request-scoped view of the delegating user', () => { + it('a redeemed credential: better-auth reads one opaque bearer and none of the caller\'s identity headers; its lookup answers the delegating user in the consent\'s organization', async () => { + const { adapter, inner } = wrappedAdapter(); + let seen: { authorization: string | null; cookie: string | null; forward: string | null; apiKey: string | null } | undefined; + let session: any; + let performedBy: unknown; + let writeCtx: unknown; + + await withForwardCredential(bind, async (handle) => { + const c = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: handle, cookie: 'better-auth.session_token=ambient', 'x-api-key': 'osk_x' }); + await serveAgentForwardAtIdentityMount(c, async () => { + const h = c.req.raw.headers; + seen = { authorization: h.get('authorization'), cookie: h.get('cookie'), forward: h.get(FORWARD_CREDENTIAL_HEADER), apiKey: h.get('x-api-key') }; + const token = String(seen.authorization).replace(/^Bearer /, ''); + session = await adapter.findOne({ model: 'session', where: [{ field: 'token', value: token }], join: { user: true } }); + performedBy = agentForwardPerformedBy(); + writeCtx = await authSystemWriteContext(); + c.res = new Response('{}', { headers: { 'set-cookie': 'better-auth.session_token=minted', 'set-auth-token': 'minted', 'content-type': 'application/json' } }); + }); + // Nothing that carries a credential leaves the mount for an agent caller. + expect(c.res!.headers.get('set-cookie')).toBeNull(); + expect(c.res!.headers.get('set-auth-token')).toBeNull(); + expect(c.res!.headers.get('content-type')).toBe('application/json'); + }); + + expect(seen!.authorization).toMatch(/^Bearer [A-Za-z0-9_-]{32}$/); + expect(seen!.cookie).toBeNull(); + expect(seen!.apiKey).toBeNull(); + expect(seen!.forward).toBeNull(); + expect(session).toMatchObject({ userId: 'u_mia', activeOrganizationId: 'org_alpha', user: { id: 'u_mia' } }); + expect(session.expiresAt.getTime()).toBeGreaterThan(Date.now()); + // No session of record: the view never wrote one. + expect(inner.create).not.toHaveBeenCalled(); + expect(performedBy).toEqual({ clientId: 'agent-client-1' }); + expect(writeCtx).toMatchObject({ isSystem: true, performedBy: { clientId: 'agent-client-1' } }); + }); + + it('the view\'s bearer is no session anywhere else: outside the request\'s own chain the lookup passes through and finds nothing', async () => { + const { adapter, inner } = wrappedAdapter(); + let token = ''; + await withForwardCredential(bind, async (handle) => { + const c = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: handle }); + await serveAgentForwardAtIdentityMount(c, async () => { + token = String(c.req.raw.headers.get('authorization')).replace(/^Bearer /, ''); + }); + }); + expect(await adapter.findOne({ model: 'session', where: [{ field: 'token', value: token }], join: { user: true } })).toBeNull(); + expect(inner.findOne).toHaveBeenCalledWith(expect.objectContaining({ model: 'session' })); + expect(agentForwardPerformedBy()).toBeUndefined(); + }); + + it('a banned or missing delegating user has no view', async () => { + for (const user of [{ id: 'u_mia', banned: true, banExpires: null }, null]) { + const { adapter } = wrappedAdapter(user); + await withForwardCredential(bind, async (handle) => { + const c = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: handle }); + await serveAgentForwardAtIdentityMount(c, async () => { + const token = String(c.req.raw.headers.get('authorization')).replace(/^Bearer /, ''); + expect(await adapter.findOne({ model: 'session', where: [{ field: 'token', value: token }], join: { user: true } })).toBeNull(); + }); + }); + } + }); + + it('a credential that does not redeem (from outside, or for another route) is dropped and the request resolves on its other headers alone', async () => { + const outside = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: 'A'.repeat(43), cookie: 'c=1' }); + await serveAgentForwardAtIdentityMount(outside, async () => { + expect(outside.req.raw.headers.get(FORWARD_CREDENTIAL_HEADER)).toBeNull(); + expect(outside.req.raw.headers.get('authorization')).toBeNull(); + expect(outside.req.raw.headers.get('cookie')).toBe('c=1'); + expect(agentForwardPerformedBy()).toBeUndefined(); + }); + + await withForwardCredential({ ...bind, path: '/api/v1/auth/admin/ban-user' }, async (handle) => { + const c = honoCtx({ [FORWARD_CREDENTIAL_HEADER]: handle }); + await serveAgentForwardAtIdentityMount(c, async () => { + expect(c.req.raw.headers.get('authorization')).toBeNull(); + expect(agentForwardPerformedBy()).toBeUndefined(); + }); + }); + }); + + it('no credential presented: the request passes through untouched', async () => { + const c = honoCtx({ authorization: 'Bearer person_session' }); + const before = c.req.raw; + await serveAgentForwardAtIdentityMount(c, async () => undefined); + expect(c.req.raw).toBe(before); + }); +}); diff --git a/packages/plugins/plugin-auth/src/agent-forward-identity-view.ts b/packages/plugins/plugin-auth/src/agent-forward-identity-view.ts new file mode 100644 index 00000000000..ecc9496439d --- /dev/null +++ b/packages/plugins/plugin-auth/src/agent-forward-identity-view.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D10 rule 5, #28367] The identity mount's half of the agent forward + * credential: an AI agent's `api` action forwarded to an identity route + * (`/api/v1/auth/…`) reaches better-auth with the delegating user as its + * caller, for that one request only, and with no session of record. + * + * ## Why a view, and not a session + * + * better-auth reads a session and nothing else. Minting one for the forward + * (option C of #28178) stays refused: a session is valid at every identity + * route, for its whole lifetime, wherever it is presented, so it would hand the + * agent the user's whole reach. So the forward credential is resolved BEFORE + * better-auth runs ({@link serveAgentForwardAtIdentityMount}), and better-auth + * is shown a request-scoped view of the delegating user: + * + * - the request better-auth reads carries a random, opaque bearer and none of + * the caller's identity headers (the action door already dropped them; they + * are dropped again here, so this door reads one credential only); + * - better-auth's session lookup for that bearer is answered from memory by + * {@link agentForwardIdentityViewPlugin}, ONLY inside this request's own + * async chain: no `sys_session` row, no stored token, no cookie, nothing + * that outlives the request. Presented anywhere else the bearer names no + * session at all; + * - nothing a target answers that could carry a credential (a session cookie, + * a rotated bearer) leaves this door for an agent caller. + * + * Two gates then stand, and the agent passes only if both do: the action + * door's (the agent's intersected authority, the exposure and the confirmation, + * checked before the mint) and better-auth's own (the delegating user's, here). + * + * ## Attribution + * + * A write this request makes records the agent and its user: the user through + * the session the view answers (the existing actor seam, + * `auth-actor-attribution.ts`), the agent's client through + * {@link agentForwardPerformedBy}, which the identity write contexts carry as + * `performedBy`, so the audit row stamps `performed_by` and `on_behalf_of`. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomBytes } from 'node:crypto'; +import { + FORWARD_CREDENTIAL_HEADER, + redeemForwardCredential, + type ForwardAgentProvenance, +} from '@objectstack/rest'; + +/** One forwarded agent request's view, alive for that request's async chain only. */ +interface AgentIdentityView { + /** The opaque bearer better-auth reads; meaningful only inside this chain. */ + readonly token: string; + readonly sessionId: string; + readonly agent: ForwardAgentProvenance; + readonly createdAt: Date; +} + +/** Kept on a registered global symbol so a dual-loaded module copy shares it. */ +const VIEW_STORE_KEY = Symbol.for('@objectstack/plugin-auth.agent-forward-identity-view'); +const viewStore: AsyncLocalStorage = + ((globalThis as Record)[VIEW_STORE_KEY] as AsyncLocalStorage | undefined) ?? + ((globalThis as Record)[VIEW_STORE_KEY] = new AsyncLocalStorage()) as AsyncLocalStorage; + +/** + * The caller identity headers this door never lets a forwarded agent request + * carry into better-auth: the forward itself carries none, so a copy here is a + * request the action door did not build, and the view's bearer is the one + * credential better-auth reads. + */ +const IDENTITY_REQUEST_HEADERS = ['authorization', 'cookie', 'x-admin-session-token', 'x-api-key'] as const; + +/** + * The response headers that can carry a credential (`bearer()`'s rotated + * token, the admin recovery lane's, a session cookie). None reaches an agent + * caller: whatever a target mints stays with the target. + */ +const CREDENTIAL_RESPONSE_HEADERS = ['set-cookie', 'set-auth-token', 'set-admin-session-token'] as const; + +/** The agent's client, when the current request is a forwarded agent's; recorded as `performedBy`. */ +export function agentForwardPerformedBy(): { clientId: string } | undefined { + const view = viewStore.getStore(); + return view ? { clientId: view.agent.clientId } : undefined; +} + +/** + * The identity mount's middleware, run ahead of every route under the auth + * base path (the vendor catch-all and the platform's raw mounts alike). + * + * - No forward credential presented: the request passes untouched. + * - One presented that does not redeem (from the network, replayed, for + * another method or route, expired): the header is dropped and the request + * resolves as if it had carried nothing more than its other headers. + * - One that redeems: the request is rebuilt with no caller identity header and + * the view's bearer, and runs inside the view; credential headers are + * stripped from the answer. + */ +export async function serveAgentForwardAtIdentityMount(c: any, next: () => Promise): Promise { + const raw: Request | undefined = c?.req?.raw; + const presented = raw?.headers?.get(FORWARD_CREDENTIAL_HEADER); + if (!raw || !presented) return next(); + const agent = redeemForwardCredential(presented, raw.method, raw.url); + const headers = new Headers(raw.headers); + headers.delete(FORWARD_CREDENTIAL_HEADER); + if (!agent) { + c.req.raw = new Request(raw, { headers }); + return next(); + } + for (const name of IDENTITY_REQUEST_HEADERS) headers.delete(name); + // `bearer()` signs an undotted token itself and reads it as the session + // cookie: so the view's token is 192 random bits, base64url (no dot). + const view: AgentIdentityView = { + token: randomBytes(24).toString('base64url'), + sessionId: `agent_forward_${randomBytes(12).toString('base64url')}`, + agent, + createdAt: new Date(), + }; + headers.set('authorization', `Bearer ${view.token}`); + c.req.raw = new Request(raw, { headers }); + await viewStore.run(view, () => next()); + const res: Response | undefined = c.res; + if (res && CREDENTIAL_RESPONSE_HEADERS.some((h) => res.headers.has(h))) { + const out = new Headers(res.headers); + for (const name of CREDENTIAL_RESPONSE_HEADERS) out.delete(name); + const exposed = out.get('access-control-expose-headers'); + if (exposed) { + const kept = exposed.split(',').map((s) => s.trim()).filter((s) => s && !CREDENTIAL_RESPONSE_HEADERS.includes(s.toLowerCase() as never)); + if (kept.length) out.set('access-control-expose-headers', kept.join(', ')); + else out.delete('access-control-expose-headers'); + } + c.res = new Response(res.body, { status: res.status, statusText: res.statusText, headers: out }); + } +} + +/** A better-auth adapter `where` naming exactly one session token. */ +function soleTokenOf(where: unknown): string | undefined { + if (!Array.isArray(where) || where.length !== 1) return undefined; + const w = where[0] as { field?: unknown; value?: unknown; operator?: unknown }; + if (w?.field !== 'token' || (w.operator !== undefined && w.operator !== 'eq')) return undefined; + return typeof w.value === 'string' ? w.value : undefined; +} + +/** A user better-auth would refuse a session to: banned, and the ban not lapsed. */ +function isBanned(user: any): boolean { + if (!user?.banned) return false; + const until = user.banExpires ? new Date(user.banExpires).getTime() : undefined; + return until === undefined || Number.isNaN(until) || until > Date.now(); +} + +/** + * The better-auth plugin that answers the view's session lookup from memory. + * It wraps the database adapter better-auth builds its session store on + * (`init` runs before that store is built), and changes exactly one read: a + * `session` lookup by the token of the view open in THIS async chain. Every + * other read and every write passes through untouched, and outside a view this + * plugin does nothing. + * + * The view's session belongs to the delegating user, in the one organization + * the consent bound, and is created now, so better-auth neither refreshes it + * (no write) nor re-issues it. A user who is gone, or banned, has no view. + */ +export function agentForwardIdentityViewPlugin() { + return { + id: 'objectstack-agent-forward-identity-view', + init(ctx: any) { + const inner = ctx?.adapter; + if (!inner || typeof inner.findOne !== 'function') return undefined; + const expiresInSec = Number(ctx?.sessionConfig?.expiresIn) > 0 ? Number(ctx.sessionConfig.expiresIn) : 60 * 60 * 24 * 7; + const findOne = async (args: any) => { + const view = viewStore.getStore(); + if (!view || args?.model !== 'session' || soleTokenOf(args?.where) !== view.token) { + return inner.findOne(args); + } + const user = await inner.findOne({ model: 'user', where: [{ field: 'id', value: view.agent.userId }] }); + if (!user || isBanned(user)) return null; + const session = { + id: view.sessionId, + token: view.token, + userId: view.agent.userId, + createdAt: view.createdAt, + updatedAt: view.createdAt, + expiresAt: new Date(view.createdAt.getTime() + expiresInSec * 1000), + ipAddress: null, + userAgent: null, + ...(view.agent.organizationId ? { activeOrganizationId: view.agent.organizationId } : {}), + }; + return args?.join?.user ? { ...session, user } : session; + }; + const adapter = new Proxy(inner, { + get(target, prop, receiver) { + if (prop === 'findOne') return findOne; + return Reflect.get(target, prop, receiver); + }, + }); + return { context: { adapter } }; + }, + }; +} diff --git a/packages/plugins/plugin-auth/src/auth-actor-attribution.ts b/packages/plugins/plugin-auth/src/auth-actor-attribution.ts index 5742bf66f16..63839875b93 100644 --- a/packages/plugins/plugin-auth/src/auth-actor-attribution.ts +++ b/packages/plugins/plugin-auth/src/auth-actor-attribution.ts @@ -60,6 +60,7 @@ */ import { AsyncLocalStorage } from 'node:async_hooks'; +import { agentForwardPerformedBy } from './agent-forward-identity-view.js'; /** * Resolves the acting user id for the current auth request, or `null` when no @@ -167,7 +168,16 @@ export async function resolveAttributedUserId(): Promise { export async function authSystemWriteContext(): Promise<{ isSystem: true; attributedUserId?: string; + performedBy?: { clientId: string }; }> { const attributedUserId = await resolveAttributedUserId(); - return attributedUserId ? { isSystem: true, attributedUserId } : { isSystem: true }; + // [ADR-0090 D10 rule 5, #28367] A forwarded AI agent's identity write is + // attributed to both: the user (above) and the agent's client, which the + // audit row records as `performed_by`. Attribution only, like the user half. + const performedBy = agentForwardPerformedBy(); + return { + isSystem: true, + ...(attributedUserId ? { attributedUserId } : {}), + ...(performedBy ? { performedBy } : {}), + }; } diff --git a/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts index 244d3ff9d5b..a48a439316a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts @@ -55,6 +55,7 @@ * Nothing re-exports this module, so the published surface does not move. */ +import { agentForwardIdentityViewPlugin } from './agent-forward-identity-view.js'; import type { AuthPluginConfig } from '@objectstack/spec/system'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { resolveUserAuthzGrants } from '@objectstack/core'; @@ -217,6 +218,12 @@ export async function buildPluginList(this: PluginListHost): Promise { const { bearer } = await import('better-auth/plugins/bearer'); plugins.push(bearer()); + // [ADR-0090 D10 rule 5, #28367] ALWAYS enabled, inert outside a forwarded + // agent request: answers the identity mount's request-scoped view of the + // delegating user from memory, so an agent's identity-route forward is + // served with no session of record. See `agent-forward-identity-view.ts`. + plugins.push(agentForwardIdentityViewPlugin() as any); + // Opt-in Cloudflare Turnstile on email sign-up: mounted only when BOTH keys // are configured, through the same resolver `getPublicConfig()` advertises // `features.turnstileSiteKey` from. Not an optional plugin: a guard that diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index b956a5702e1..3a797dbbd45 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -429,7 +429,8 @@ describe('AuthManager', () => { await manager.getAuthInstance(); warnSpy.mockRestore(); - expect(capturedConfig.plugins.map((p: any) => p.id)).toEqual(['bearer', 'organization']); + // [#28367] The agent forward view rides right after bearer(), on every instance. + expect(capturedConfig.plugins.map((p: any) => p.id)).toEqual(['bearer', 'objectstack-agent-forward-identity-view', 'organization']); }); it('should register organization plugin with schema mapping when enabled', async () => { @@ -1626,9 +1627,9 @@ describe('AuthManager', () => { await manager.getAuthInstance(); warnSpy.mockRestore(); - expect(capturedConfig.plugins).toHaveLength(4); + expect(capturedConfig.plugins).toHaveLength(5); expect(capturedConfig.plugins.map((p: any) => p.id).sort()).toEqual( - ['bearer', 'magic-link', 'organization', 'two-factor'], + ['bearer', 'magic-link', 'objectstack-agent-forward-identity-view', 'organization', 'two-factor'], ); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 9a115cec870..c7363118bd8 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. // [#25083] `isCloudManaged`: the ONE accessor the cloud-managed doors below ask. +import { serveAgentForwardAtIdentityMount } from './agent-forward-identity-view.js'; import { Plugin, PluginContext, IHttpServer, isCloudManaged, resolveRequestExecutionContext, type RequestAuthService } from '@objectstack/core'; import type { BetterAuthOptions } from 'better-auth'; import { @@ -3083,6 +3084,24 @@ export class AuthPlugin implements Plugin { return next(); }); + // ── ADR-0090 D10 rule 5 (#28367) — the agent forward credential ─────── + // An AI agent's `api` action forwarded to an identity route reaches this + // mount carrying a credential the action door minted for that one forward, + // never the agent's token. Resolved HERE, before any route below runs — + // the vendor catch-all (`handleRequest`) and the platform's raw mounts + // (`/admin/ban-user`, …) alike, which is why the seam is this middleware + // and not `handleRequest` alone — into a request-scoped view of the + // delegating user: no session of record, nothing that outlives the + // request, no credential header back to the agent. Registered after the + // D5 allow-list, so a forward is judged by the same network gate. A + // credential that does not redeem resolves as nobody. See + // `agent-forward-identity-view.ts`. + // Inlined so the wildcard fall-through guard (#4116) can read that it + // yields: every branch of the seam hands the request on to `next`. + if (typeof rawApp.use === 'function') { + rawApp.use(`${basePath}/*`, (c: any, next: () => Promise) => serveAgentForwardAtIdentityMount(c, next)); + } + // Register /config before the wildcard so it takes precedence. // better-auth has no /config endpoint, so without this explicit route // the wildcard below forwards the request and better-auth returns 404. diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 18a81e34a1c..e090fb74ed7 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -5,6 +5,7 @@ import { createAdapterFactory } from 'better-auth/adapters'; import type { CleanedWhere, WhereOperator } from 'better-auth/adapters'; import { SystemObjectName } from '@objectstack/spec/system'; import { resolveAttributedUserId } from './auth-actor-attribution.js'; +import { agentForwardPerformedBy } from './agent-forward-identity-view.js'; import { inScimRequestScope } from './scim-connection-service.js'; import { adoptExistingMembership } from './adopt-membership.js'; import { refuseIssuerRepointWithLiveBindings } from './account-identity-preflight.js'; @@ -752,8 +753,19 @@ export function withSystemContext(engine: IDataEngine): IDataEngine { // still wins on every key — the same precedence `isSystem` already had. const asAttributedSystem = async (q: any) => { const attributedUserId = await resolveAttributedUserId(); - if (!attributedUserId) return asSystem(q); - return { ...(q ?? {}), context: { attributedUserId, isSystem: true, ...(q?.context ?? {}) } }; + // [ADR-0090 D10 rule 5, #28367] A forwarded AI agent's write also names the + // agent's client (`performedBy`), so the audit row records both sides. + const performedBy = agentForwardPerformedBy(); + if (!attributedUserId && !performedBy) return asSystem(q); + return { + ...(q ?? {}), + context: { + ...(attributedUserId ? { attributedUserId } : {}), + ...(performedBy ? { performedBy } : {}), + isSystem: true, + ...(q?.context ?? {}), + }, + }; }; return { insert: async (m: string, d: any, o?: any) => e.insert(m, d, await asAttributedSystem(o)), diff --git a/packages/qa/dogfood/test/mcp-oauth-api-action-forward.dogfood.test.ts b/packages/qa/dogfood/test/mcp-oauth-api-action-forward.dogfood.test.ts index 3a95a902cde..91cc421df87 100644 --- a/packages/qa/dogfood/test/mcp-oauth-api-action-forward.dogfood.test.ts +++ b/packages/qa/dogfood/test/mcp-oauth-api-action-forward.dogfood.test.ts @@ -1,23 +1,47 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#28178] An AI agent connected to MCP over OAuth runs a same-origin -// `type: 'api'` action. The action doors forward it with the caller's OWN -// credentials, and the platform honours an OAuth access token on the MCP -// endpoint only, so the target read the agent as nobody and answered its -// anonymous 401. Ruled B (fable 总监决裁 5, Elon 2026-10-31): refuse by name -// before the forward, from one table of credential kind × target door that -// `run_action` and `list_actions` share, and MARK such an action in the -// listing instead of hiding it. A server-built forward credential that would -// let the agent through is a separate decision (#28367), so the last pin here -// is that the token is still refused when presented to a target directly. +// [#28178 → #28367] An AI agent connected to MCP over OAuth runs a same-origin +// `type: 'api'` action. The action doors forward it in-process, and the +// platform honours an OAuth access token on the MCP endpoint only. // -// Booted showcase, a REAL OAuth access token (DCR → authorize with the MCP -// resource → consent → token), a session and an API key for the unchanged lanes. +// #28178 (ruled B) made the gap loud: one table of credential kind × target +// door, shared by `run_action` and `list_actions`, refuses such a forward by +// name before the target is called. #28367 (ADR-0090 D10 rule 5, ruled A, +// records 6545950368 and 6548923676) is the other branch: at exactly two cells +// of that table, `oauth` × `identity` and `oauth` × `platform`, the forward +// carries a credential the server mints for that one forward instead of the +// agent's token, and the target resolves it to the SAME agent principal. The +// platform cell is bounded by the scope-family rule. Every other cell keeps +// its answer, `api_key` × `identity` included. +// +// What this file pins, against a booted showcase and REAL OAuth access tokens +// (DCR → authorize with the MCP resource → consent → token): +// +// - each flipped cell served for a real agent, and the audit row of each +// write naming both the agent (`performed_by`) and its user (`on_behalf_of`); +// - the kept refusals, by name: `api_key` × `identity`, a state-changing +// metadata-door forward, and the scope-family refusals of a read-only +// agent at an identity target and at a platform target; +// - no session: a forward leaves no `sys_session` row and no credential +// header in the tool result, and what the identity route was shown cannot +// be presented from outside; +// - the credential's outside face: a request from the network presenting the +// header is nobody; +// - the boundary, unmoved: the same OAuth token presented to the data door +// or an identity route directly is still refused. +// +// The credential's per-forward properties (replay, method or route mismatch, +// expiry) are pinned on the module that mints it +// (`packages/rest/src/forward-credential.test.ts`); the intersection at the +// target (a plain member's agent refused by the identity route itself) beside +// the system-object actions (`mcp-system-object-action-exposure.dogfood.test.ts`). + import { createHash, randomBytes } from 'node:crypto'; -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { ObjectSchema, Field } from '@objectstack/spec/data'; import { type VerifyStack } from '@objectstack/verify'; import { MCPServerPlugin } from '@objectstack/mcp'; +import { AuditPlugin } from '@objectstack/plugin-audit'; import showcaseStack from '@objectstack/example-showcase'; import { bootShowcase } from './showcase-boot.js'; @@ -25,7 +49,11 @@ const SYS = { isSystem: true } as const; const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); const REDIRECT_URI = 'http://127.0.0.1:33418/callback'; const ai = (d: string) => ({ exposed: true, description: d }); -let banTarget = ''; +const FULL_SCOPE = 'openid profile email offline_access data:read data:write actions:execute'; +const READ_SCOPE = 'openid profile email offline_access data:read actions:execute'; +const ADMIN = { email: 'admin@objectos.ai', password: 'admin123' }; +/** `@objectstack/rest` `FORWARD_CREDENTIAL_HEADER`, spelled here as a caller from outside would send it. */ +const FORWARD_CREDENTIAL_HEADER = 'x-objectstack-forward-credential'; const Probe = ObjectSchema.create({ name: 'probe_fwd_thing', @@ -33,19 +61,25 @@ const Probe = ObjectSchema.create({ sharingModel: 'public_read_write', fields: { name: Field.text({ label: 'Name' }) }, actions: [ - { name: 'probe_get_session', label: 'Get Session', type: 'api', method: 'POST', target: '/api/v1/auth/get-session', locations: ['record_header'], ai: ai('Read the session the forward target sees for this caller.') }, + { name: 'probe_update_me', label: 'Update Me', type: 'api', method: 'POST', target: '/api/v1/auth/update-user', bodyExtra: { name: 'Renamed By Agent' }, locations: ['record_header'], ai: ai('Rename the signed-in user through the identity route as the caller.') }, { name: 'probe_data_patch', label: 'Data Patch', type: 'api', method: 'PATCH', target: '/api/v1/data/probe_fwd_thing/${ctx.recordId}', bodyExtra: { name: 'patched' }, locations: ['record_header'], ai: ai('Patch the probe record through the data door as the caller.') }, { name: 'probe_ban', label: 'Ban', type: 'api', method: 'POST', target: '/api/v1/auth/admin/ban-user', params: [{ name: 'userId', label: 'User', type: 'text', required: true }], locations: ['record_header'], ai: ai('Ban the probe target user through the better-auth admin endpoint.') }, + { name: 'probe_meta_put', label: 'Meta Put', type: 'api', method: 'PUT', target: '/api/v1/meta/object/probe_fwd_thing', locations: ['record_header'], ai: ai('Rewrite the probe object definition through the metadata door.') }, ], } as any); -describe('[#28178] an OAuth-connected agent and api actions: refused by name, marked in list_actions, boundary unmoved', () => { +describe('[#28367] an OAuth-connected agent and api actions: the two flipped cells served as the agent, every kept refusal by name, boundary unmoved', () => { let stack: VerifyStack; let ql: any; let session: string; let apiKey: string; let oauth: string; + let oauthRead: string; + let clientId: string; + let adminId: string; let recordId: string; + let banTarget = ''; + let memberBanTarget = ''; let prior: string | undefined; let priorOidc: string | undefined; @@ -58,8 +92,9 @@ describe('[#28178] an OAuth-connected agent and api actions: refused by name, ma const text = await res.text(); const parsed: any = JSON.parse(text); const content = parsed?.result?.content?.[0]?.text; - return { http: res.status, isError: parsed?.result?.isError === true, body: content ? JSON.parse(content) : parsed }; + return { http: res.status, text, isError: parsed?.result?.isError === true, body: content ? JSON.parse(content) : parsed }; }; + const bearer = (t: string) => ({ Authorization: `Bearer ${t}` }); const runAction = (headers: Record, actionName: string, args: Record = {}) => rpc(headers, 'run_action', { actionName, objectName: 'probe_fwd_thing', recordId, ...args }); const listProbeActions = async (headers: Record) => { @@ -69,41 +104,40 @@ describe('[#28178] an OAuth-connected agent and api actions: refused by name, ma }; const rowName = async () => rowsOf(await ql.find('probe_fwd_thing', { where: { id: recordId }, limit: 1 }, { context: SYS }))[0]?.name; - const isBanned = async () => - rowsOf(await ql.find('sys_user', { where: { id: banTarget }, limit: 1 }, { context: SYS }))[0]?.banned === true; + const setRowName = (name: string) => ql.update('probe_fwd_thing', { id: recordId, name }, { context: SYS }); + const isBanned = async (id: string) => + rowsOf(await ql.find('sys_user', { where: { id }, limit: 1 }, { context: SYS }))[0]?.banned === true; + const idOf = async (email: string) => + String(rowsOf(await ql.find('sys_user', { where: { email }, limit: 1 }, { context: SYS }))[0]?.id); + const sessionCount = async () => rowsOf(await ql.find('sys_session', { context: SYS })).length; + /** + * The dual attribution on the WRITE's audit row for `record` on `object` — + * not the action-invocation row the door also records (that one carries a + * `surface`, and is written whatever the target does). + */ + const agentWriteAttribution = async (object: string, record: string) => { + let found: Record | undefined; + await vi.waitFor(async () => { + const rows = rowsOf(await ql.find('sys_audit_log', { where: { object_name: object, record_id: record }, context: SYS })); + const metas = rows + .map((r) => { try { return JSON.parse(String(r.metadata)); } catch { return undefined; } }) + .filter((m) => m && typeof m === 'object' && 'performed_by' in m && !('surface' in m)); + found = metas[metas.length - 1]; + expect(found, `a write audit row naming the agent for ${object}/${record}`).toBeDefined(); + }, { timeout: 10_000 }); + return found; + }; - beforeAll(async () => { - prior = process.env.OS_SCIM_ENABLED; - priorOidc = process.env.OS_OIDC_PROVIDER_ENABLED; - process.env.OS_SCIM_ENABLED = 'true'; - process.env.OS_OIDC_PROVIDER_ENABLED = 'true'; - const declared = showcaseStack as unknown as { objects?: unknown[] }; - stack = await bootShowcase( - { extraPlugins: [new MCPServerPlugin()] }, - { ...(showcaseStack as unknown as Record), objects: [...(declared.objects ?? []), Probe] }, - ); - session = await stack.signIn(); - ql = await stack.kernel.getServiceAsync('objectql'); - const created = await stack.apiAs(session, 'POST', '/data/probe_fwd_thing', { name: 'probe' }); - const cj: any = await created.json(); - recordId = String(cj?.data?.id ?? cj?.id); - await stack.signUp('fwd.target@example.com', 'Target-Pass-123'); - banTarget = String(rowsOf(await ql.find('sys_user', { where: { email: 'fwd.target@example.com' }, limit: 1 }, { context: SYS }))[0]?.id); - // API key - const k = await stack.apiAs(session, 'POST', '/keys', { name: 'fwd-probe' }); - const kj: any = await k.json(); - apiKey = String(kj?.data?.key); - // OAuth: DCR → authorize → consent → token + const mintAgentToken = async (who: { email: string; password: string }, scope: string): Promise<{ token: string; clientId: string }> => { const authSvc: any = await stack.kernel.getServiceAsync('auth'); const resource = authSvc.getMcpResourceUrl(); - const scope = 'openid profile email offline_access data:read data:write actions:execute'; const reg = await stack.api('/auth/oauth2/register', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ client_name: 'fwd probe', redirect_uris: [REDIRECT_URI], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', application_type: 'native', scope }), }); const regBody: any = await reg.json(); expect(reg.status, JSON.stringify(regBody)).toBe(201); - const signin = await stack.api('/auth/sign-in/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }) }); + const signin = await stack.api('/auth/sign-in/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(who) }); const cookie = ((signin.headers as any).getSetCookie() as string[]).map((c) => c.split(';')[0]).join('; '); const verifier = randomBytes(32).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); @@ -122,7 +156,34 @@ describe('[#28178] an OAuth-connected agent and api actions: refused by name, ma }); const tj: any = await tok.json(); expect(tok.status, JSON.stringify(tj)).toBe(200); - oauth = tj.access_token; + return { token: tj.access_token, clientId: regBody.client_id }; + }; + + beforeAll(async () => { + prior = process.env.OS_SCIM_ENABLED; + priorOidc = process.env.OS_OIDC_PROVIDER_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + process.env.OS_OIDC_PROVIDER_ENABLED = 'true'; + const declared = showcaseStack as unknown as { objects?: unknown[] }; + stack = await bootShowcase( + { extraPlugins: [new MCPServerPlugin(), new AuditPlugin()] }, + { ...(showcaseStack as unknown as Record), objects: [...(declared.objects ?? []), Probe] }, + ); + session = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + adminId = await idOf(ADMIN.email); + const created = await stack.apiAs(session, 'POST', '/data/probe_fwd_thing', { name: 'probe' }); + const cj: any = await created.json(); + recordId = String(cj?.data?.id ?? cj?.id); + await stack.signUp('fwd.target@example.com', 'Target-Pass-123'); + banTarget = await idOf('fwd.target@example.com'); + await stack.signUp('fwd.member.target@example.com', 'Target-Pass-123'); + memberBanTarget = await idOf('fwd.member.target@example.com'); + const k = await stack.apiAs(session, 'POST', '/keys', { name: 'fwd-probe' }); + const kj: any = await k.json(); + apiKey = String(kj?.data?.key); + ({ token: oauth, clientId } = await mintAgentToken(ADMIN, FULL_SCOPE)); + ({ token: oauthRead } = await mintAgentToken(ADMIN, READ_SCOPE)); }, 300_000); afterAll(async () => { @@ -131,56 +192,128 @@ describe('[#28178] an OAuth-connected agent and api actions: refused by name, ma if (priorOidc === undefined) delete process.env.OS_OIDC_PROVIDER_ENABLED; else process.env.OS_OIDC_PROVIDER_ENABLED = priorOidc; }); - it('list_actions LISTS the api actions for the OAuth agent and marks each with the refusal, code UNAUTHENTICATED', async () => { - const listed = await listProbeActions({ Authorization: `Bearer ${oauth}` }); + it('anti-vacuity: the admin, the agent\'s client and the probe row all exist', () => { + for (const v of [adminId, clientId, recordId, banTarget, memberBanTarget]) { + expect(v).toMatch(/\S/); + expect(v).not.toBe('undefined'); + } + }); + + it('list_actions: the full agent sees the flipped cells unmarked, and only the metadata-door write marked by name', async () => { + const listed = await listProbeActions(bearer(oauth)); - expect(Object.keys(listed).sort()).toEqual(['probe_ban', 'probe_data_patch', 'probe_get_session']); - expect(listed.probe_data_patch.unavailable?.code).toBe('UNAUTHENTICATED'); - expect(listed.probe_data_patch.unavailable?.reason).toMatch( - /^Action 'probe_data_patch' forwards to an endpoint \('\/api\/v1\/data\/probe_fwd_thing\/.*'\) that accepts a signed-in session or an API key, and this request was authenticated with an OAuth access token, which is honoured on the MCP endpoint only\. The action was not run\. Connect with an API key/, - ); - expect(listed.probe_ban.unavailable?.code).toBe('UNAUTHENTICATED'); - expect(listed.probe_ban.unavailable?.reason).toMatch(/identity endpoint \('\/api\/v1\/auth\/admin\/ban-user'\) that accepts a signed-in session only/); + expect(Object.keys(listed).sort()).toEqual(['probe_ban', 'probe_data_patch', 'probe_meta_put', 'probe_update_me']); + for (const name of ['probe_ban', 'probe_data_patch', 'probe_update_me']) { + expect(listed[name].unavailable, name).toBeUndefined(); + } + expect(listed.probe_meta_put.unavailable?.code).toBe('PERMISSION_DENIED'); + expect(listed.probe_meta_put.unavailable?.reason).toMatch(/^Action 'probe_meta_put' sends a PUT request to the metadata endpoint .* does not change metadata through an action\. The action was not run\./); }); - it('run_action: the OAuth agent gets the named refusal the listing carried, not the target\'s anonymous 401, and the row is untouched', async () => { - const before = await rowName(); - const listed = await listProbeActions({ Authorization: `Bearer ${oauth}` }); + it('oauth × platform: the agent\'s data-door write is served as the agent, and its audit row names both the agent and its user', async () => { + await setRowName('probe'); - const r = await runAction({ Authorization: `Bearer ${oauth}` }, 'probe_data_patch'); + const r = await runAction(bearer(oauth), 'probe_data_patch'); - expect(r.isError).toBe(true); - expect(r.body.error).toMatchObject({ code: 'UNAUTHENTICATED', status: 401, message: listed.probe_data_patch.unavailable.reason }); - expect(r.body.error.message).not.toBe('Authentication is required to access this endpoint.'); - expect(await rowName()).toBe(before); + expect(r.isError, JSON.stringify(r.body)).toBe(false); + expect(await rowName()).toBe('patched'); + expect(await agentWriteAttribution('probe_fwd_thing', recordId)).toEqual({ performed_by: clientId, on_behalf_of: adminId }); }); - it('run_action: the OAuth agent running the identity-route Ban action is refused by name and nobody is banned', async () => { - const r = await runAction({ Authorization: `Bearer ${oauth}` }, 'probe_ban', { params: { userId: banTarget } }); + it('oauth × identity: the agent\'s identity write is served for the delegating user, attributed to both, from a view that leaves no session of record', async () => { + const before = await sessionCount(); + + const r = await runAction(bearer(oauth), 'probe_update_me'); + + expect(r.isError, JSON.stringify(r.body)).toBe(false); + const me = rowsOf(await ql.find('sys_user', { where: { id: adminId }, limit: 1 }, { context: SYS }))[0]; + expect(me?.name).toBe('Renamed By Agent'); + expect(await sessionCount()).toBe(before); + expect(await agentWriteAttribution('sys_user', adminId)).toEqual({ performed_by: clientId, on_behalf_of: adminId }); + // No credential header rides back in the tool result. + expect(r.text).not.toMatch(/set-auth-token|set-cookie|set-admin-session-token/i); + }); + + it('oauth × identity: the admin\'s agent bans through the admin route, attributed to the agent and the admin, with no session left behind', async () => { + const before = await sessionCount(); + + const r = await runAction(bearer(oauth), 'probe_ban', { params: { userId: banTarget } }); + + expect(r.isError, JSON.stringify(r.body)).toBe(false); + expect(await isBanned(banTarget)).toBe(true); + expect(await sessionCount()).toBeLessThanOrEqual(before); + expect(await agentWriteAttribution('sys_user', banTarget)).toEqual({ performed_by: clientId, on_behalf_of: adminId }); + }); + + // An `api` action's method is POST, PATCH, PUT or DELETE (the action schema + // admits no other), so every forward an action can make changes state: a + // read-consented agent runs none of them outside MCP. The rule's safe-method + // clause is pinned on the predicate itself (`api-action-forward.test.ts`). + it('the scope-family rule: a read-only agent is refused by name at a state-changing platform and identity target, before anything is sent', async () => { + const listed = await listProbeActions(bearer(oauthRead)); + for (const name of ['probe_data_patch', 'probe_ban', 'probe_update_me']) { + expect(listed[name].unavailable?.code, name).toBe('PERMISSION_DENIED'); + expect(listed[name].unavailable?.reason, name).toMatch(/which an AI agent may run only with the 'data:write' scope, and this connection was not granted it\. The action was not run\./); + } + + await setRowName('untouched'); + const patch = await runAction(bearer(oauthRead), 'probe_data_patch'); + expect(patch.isError).toBe(true); + expect(patch.body.error).toMatchObject({ code: 'PERMISSION_DENIED', status: 403, message: listed.probe_data_patch.unavailable.reason }); + expect(await rowName()).toBe('untouched'); + + const ban = await runAction(bearer(oauthRead), 'probe_ban', { params: { userId: memberBanTarget } }); + expect(ban.isError).toBe(true); + expect(ban.body.error).toMatchObject({ code: 'PERMISSION_DENIED', status: 403, message: listed.probe_ban.unavailable.reason }); + expect(await isBanned(memberBanTarget)).toBe(false); + }); + + it('kept refusal: a state-changing metadata-door forward stays outside the flip and is refused by name, whatever the scopes', async () => { + const r = await runAction(bearer(oauth), 'probe_meta_put'); expect(r.isError).toBe(true); - expect(r.body.error.code).toBe('UNAUTHENTICATED'); - expect(r.body.error.message).toMatch(/^Action 'probe_ban' forwards to an identity endpoint .* OAuth access token, which is honoured on the MCP endpoint only\. The action was not run\./); - expect(await isBanned()).toBe(false); + expect(r.body.error.code).toBe('PERMISSION_DENIED'); + expect(r.body.error.message).toMatch(/^Action 'probe_meta_put' sends a PUT request to the metadata endpoint/); }); - it('unchanged lanes: a session lists the actions unmarked; an API key is marked on the identity route only and its data-door action is served', async () => { - const asSession = await listProbeActions({ Authorization: `Bearer ${session}` }); - expect(Object.values(asSession).filter((a: any) => a.unavailable)).toEqual([]); + it('kept refusal: an API key is still refused by name at the identity route, and its data-door action is still served', async () => { + const asSession = await listProbeActions(bearer(session)); + expect(Object.entries(asSession).filter(([, a]: [string, any]) => a.unavailable).map(([n]) => n)).toEqual([]); const asKey = await listProbeActions({ 'x-api-key': apiKey }); expect(asKey.probe_ban.unavailable?.code).toBe('UNAUTHENTICATED'); + expect(asKey.probe_ban.unavailable?.reason).toMatch(/identity endpoint/); expect(asKey.probe_data_patch.unavailable).toBeUndefined(); + const ban = await runAction({ 'x-api-key': apiKey }, 'probe_ban', { params: { userId: memberBanTarget } }); + expect(ban.isError).toBe(true); + expect(ban.body.error.code).toBe('UNAUTHENTICATED'); + expect(await isBanned(memberBanTarget)).toBe(false); + await setRowName('probe'); const r = await runAction({ 'x-api-key': apiKey }, 'probe_data_patch'); expect(r.isError, JSON.stringify(r.body)).toBe(false); expect(await rowName()).toBe('patched'); }); + it('the credential from outside the process is nobody: the header presented over the network resolves as no caller', async () => { + const forged = randomBytes(32).toString('base64url'); + const data = await stack.api(`/data/probe_fwd_thing/${recordId}`, { headers: { [FORWARD_CREDENTIAL_HEADER]: forged } }); + expect(data.status).toBe(401); + const identity = await stack.api('/auth/get-session', { headers: { [FORWARD_CREDENTIAL_HEADER]: forged } }); + const body: any = await identity.json().catch(() => null); + expect(body?.user ?? null).toBeNull(); + const ban = await stack.api('/auth/admin/ban-user', { + method: 'POST', headers: { 'content-type': 'application/json', [FORWARD_CREDENTIAL_HEADER]: forged }, + body: JSON.stringify({ userId: memberBanTarget }), + }); + expect(ban.status).toBeGreaterThanOrEqual(400); + expect(await isBanned(memberBanTarget)).toBe(false); + }); + it('the boundary is unmoved: the same OAuth token presented to the data door or an identity route directly is still refused', async () => { - const data = await stack.api(`/data/probe_fwd_thing/${recordId}`, { headers: { Authorization: `Bearer ${oauth}` } }); + const data = await stack.api(`/data/probe_fwd_thing/${recordId}`, { headers: bearer(oauth) }); expect(data.status).toBe(401); - const identity = await stack.api('/auth/get-session', { headers: { Authorization: `Bearer ${oauth}` } }); + const identity = await stack.api('/auth/get-session', { headers: bearer(oauth) }); expect(identity.status).toBe(401); }); }); diff --git a/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts b/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts index 42d248166b9..2224c4a2026 100644 --- a/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts +++ b/packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts @@ -33,19 +33,17 @@ // authorization server the way an MCP client connects: dynamic client // registration, authorize with the MCP resource and PKCE, consent, token. // -// ## ⚠️ The E run does not succeed for the OAuth agent today — and says so +// ## The agent's runs are served as the agent (ADR-0090 D10 rule 5, #28367) // // Every one of these actions is `type: 'api'`: the door forwards the call to -// its identity route as the caller, and an identity route accepts a signed-in -// session only. So the agent's forward is answered 401 at the target and -// nothing changes, while the administrator's session making the same call is -// served (pinned beside it as the contrast — it is not the ruled case, and it -// does not stand in for it). The run case pins TODAY's answer; the ruled -// outcome (the agent's E call served) waits on -// the open decision on #28178, which governs which credentials a forward -// target accepts. ⛔ Do not make it pass by switching the caller to a session -// bearer, by minting a session for the agent, or by widening what an identity -// route or the REST face accepts — when #28178 lands, flip the expectation. +// its identity route. An identity route accepts a signed-in session only, so +// until #28367 the agent's forward was refused by name (#28178). Now the +// forward carries a credential the server mints for that one forward, and the +// identity mount shows better-auth a request-scoped view of the delegating +// user: the agent's E call and its confirmed C call are SERVED, and a plain +// member's agent is refused by the identity route itself (the intersection at +// the target). ⛔ No session is minted for the agent, and its token never +// reaches the target. // // `OS_SCIM_ENABLED=true` precedes the boot because `/admin/*` 501s without // better-auth's admin plugin (see `api-action-door-forward.dogfood.test.ts`), @@ -82,6 +80,7 @@ describe('[#27681, #27900] sys_user identity actions over MCP: what each bucket\ let admin: string; let member: string; let agent: string; + let memberAgent: string; let adminUserId: string; let targetUserId: string; let agentTargetUserId: string; @@ -129,7 +128,7 @@ describe('[#27681, #27900] sys_user identity actions over MCP: what each bucket\ * server for the administrator, the way an MCP client connects (a public * client, PKCE S256, `resource` = the MCP URL). */ - const mintAgentToken = async (): Promise => { + const mintAgentToken = async (email = ADMIN_EMAIL, password = ADMIN_PASSWORD): Promise => { const authSvc: any = await stack.kernel.getServiceAsync('auth'); const resource = authSvc.getMcpResourceUrl(); const reg = await stack.api('/auth/oauth2/register', { @@ -150,7 +149,7 @@ describe('[#27681, #27900] sys_user identity actions over MCP: what each bucket\ const signIn = await stack.api('/auth/sign-in/email', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + body: JSON.stringify({ email, password }), }); const cookie = ((signIn.headers as any).getSetCookie() as string[]).map((c) => c.split(';')[0]).join('; '); const verifier = randomBytes(32).toString('base64url'); @@ -218,6 +217,7 @@ describe('[#27681, #27900] sys_user identity actions over MCP: what each bucket\ targetUserId = await idOf(TARGET_EMAIL); agentTargetUserId = await idOf(AGENT_TARGET_EMAIL); agent = await mintAgentToken(); + memberAgent = await mintAgentToken(MEMBER_EMAIL, 'Member-Pass-123'); }, 300_000); afterAll(async () => { @@ -297,17 +297,16 @@ describe('[#27681, #27900] sys_user identity actions over MCP: what each bucket\ }); }, 120_000); - it('E, today (waits on #28178): the agent\'s update_my_profile run is answered 401 at its identity route, and the name is unchanged', async () => { - const before = (await userRow(adminUserId))?.name; + it('E, served as the agent (#28367): the agent\'s update_my_profile run renames its delegating user through the identity route', async () => { const ran = await tool(agent, 'run_action', { actionName: 'update_my_profile', objectName: 'sys_user', recordId: adminUserId, params: { name: 'Renamed By Agent' }, }); - expect(ran.isError, ran.text).toBe(true); - expect(ran.payload?.error).toMatchObject({ status: 401 }); - expect((await userRow(adminUserId))?.name).toBe(before); + expect(ran.isError, ran.text).toBe(false); + expect(ran.payload).toMatchObject({ ok: true, action: 'update_my_profile', objectName: 'sys_user', recordId: adminUserId }); + expect((await userRow(adminUserId))?.name).toBe('Renamed By Agent'); }, 120_000); - it('E, the contrast: the same update_my_profile run from the administrator\'s session is served — the declaration is live, and the agent\'s 401 is the identity route\'s credential rule', async () => { + it('E, the session lane unchanged: the same update_my_profile run from the administrator\'s session is served', async () => { const ran = await tool(admin, 'run_action', { actionName: 'update_my_profile', objectName: 'sys_user', recordId: adminUserId, params: { name: 'Renamed By Session' }, }); @@ -316,7 +315,17 @@ describe('[#27681, #27900] sys_user identity actions over MCP: what each bucket\ expect((await userRow(adminUserId))?.name).toBe('Renamed By Session'); }, 120_000); - it('C: the agent\'s unconfirmed ban_user is refused 428 before any forward; confirmed, today it is answered 401 at its identity route (waits on #28178); nobody is banned', async () => { + it('the intersection at the target: a plain member\'s agent, confirmed, passes the action door and is refused by the identity route itself; nobody is banned', async () => { + const refused = await tool(memberAgent, 'run_action', { + actionName: 'ban_user', objectName: 'sys_user', recordId: agentTargetUserId, params: { banReason: 'member agent' }, confirm: true, + }); + expect(refused.isError, refused.text).toBe(true); + expect(refused.payload?.error?.status, refused.text).toBe(403); + expect(messageOf(refused)).not.toMatch(/OAuth access token|scope/); + expect(await isBanned(agentTargetUserId)).toBe(false); + }, 120_000); + + it('C: the agent\'s unconfirmed ban_user is refused 428 before any forward; confirmed, it is served as the agent (#28367) and bans the user', async () => { const unconfirmed = await tool(agent, 'run_action', { actionName: 'ban_user', objectName: 'sys_user', recordId: agentTargetUserId, params: { banReason: 'agent' }, }); @@ -327,9 +336,9 @@ describe('[#27681, #27900] sys_user identity actions over MCP: what each bucket\ const confirmed = await tool(agent, 'run_action', { actionName: 'ban_user', objectName: 'sys_user', recordId: agentTargetUserId, params: { banReason: 'agent' }, confirm: true, }); - expect(confirmed.isError, confirmed.text).toBe(true); - expect(confirmed.payload?.error).toMatchObject({ status: 401 }); - expect(await isBanned(agentTargetUserId)).toBe(false); + expect(confirmed.isError, confirmed.text).toBe(false); + expect(confirmed.payload).toMatchObject({ ok: true, action: 'ban_user', objectName: 'sys_user', recordId: agentTargetUserId }); + expect(await isBanned(agentTargetUserId)).toBe(true); }, 120_000); it('H: impersonate_user is absent from the agent\'s listing and refused by name', async () => { diff --git a/packages/rest/src/forward-credential.test.ts b/packages/rest/src/forward-credential.test.ts new file mode 100644 index 00000000000..781931b2300 --- /dev/null +++ b/packages/rest/src/forward-credential.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [ADR-0090 D10 rule 5, #28367] The agent forward credential's four +// properties, pinned on the one module that mints and redeems it: server-minted +// (the handle is random and names nothing), bound to one forward (method and +// route), single-use and short-lived (spent by the first presentation, revoked +// when the forward answers, expired after the bound), and unreachable out of +// process (a presentation outside the forward's own async chain redeems to +// nothing). + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + FORWARD_CREDENTIAL_HEADER, + FORWARD_CREDENTIAL_TTL_MS, + withForwardCredential, + redeemForwardCredential, + presentedForwardCredential, + type ForwardCredentialBinding, +} from './forward-credential.js'; + +const agent = { userId: 'u_delegator', clientId: 'c_agent', scopes: ['actions:execute', 'data:write'], organizationId: 'org_1' }; +const binding = (over: Partial = {}): ForwardCredentialBinding => ({ + method: 'POST', + path: '/api/v1/auth/update-user', + run: { objectName: 'sys_user', actionName: 'update_my_profile', recordId: 'u_delegator' }, + agent, + ...over, +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('[ADR-0090 D10 rule 5] the agent forward credential', () => { + it('redeems, inside the forward, to the agent it was minted with — a copy, never the binding itself', async () => { + const got = await withForwardCredential(binding(), async (handle) => + redeemForwardCredential(handle, 'post', 'http://localhost/api/v1/auth/update-user')); + expect(got).toEqual(agent); + expect(got).not.toBe(agent); + }); + + it('the handle is 256 random bits that name nothing: no user, client, scope or route in it', async () => { + const handles: string[] = []; + await withForwardCredential(binding(), async (h) => { handles.push(h); }); + await withForwardCredential(binding(), async (h) => { handles.push(h); }); + expect(handles[0]).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(handles[0]).not.toBe(handles[1]); + for (const fragment of ['u_delegator', 'c_agent', 'actions', 'update-user', 'org_1']) { + expect(Buffer.from(handles[0]!, 'base64url').toString('latin1')).not.toContain(fragment); + } + }); + + it('a second presentation is refused, whether the first was accepted or refused', async () => { + const accepted = await withForwardCredential(binding(), async (h) => [ + redeemForwardCredential(h, 'POST', '/api/v1/auth/update-user'), + redeemForwardCredential(h, 'POST', '/api/v1/auth/update-user'), + ]); + expect(accepted[0]).toEqual(agent); + expect(accepted[1]).toBeUndefined(); + + const refusedFirst = await withForwardCredential(binding(), async (h) => [ + redeemForwardCredential(h, 'GET', '/api/v1/auth/update-user'), + redeemForwardCredential(h, 'POST', '/api/v1/auth/update-user'), + ]); + expect(refusedFirst).toEqual([undefined, undefined]); + }); + + it('another method, or another route, is refused; the query is not part of the route', async () => { + const run = (method: string, url: string) => + withForwardCredential(binding({ path: '/api/v1/data/thing/r1' }), async (h) => redeemForwardCredential(h, method, url)); + expect(await run('PATCH', '/api/v1/data/thing/r1')).toBeUndefined(); + expect(await run('POST', '/api/v1/data/thing/r2')).toBeUndefined(); + expect(await run('POST', '/api/v1/data/thing/r1/extra')).toBeUndefined(); + expect(await run('POST', '/api/v1/data/thing')).toBeUndefined(); + expect(await run('POST', 'http://localhost/api/v1/data/thing/r1?x=1')).toEqual(agent); + }); + + it('expires after the fixed bound even while its forward is still running', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + const got = await withForwardCredential(binding(), async (h) => { + vi.setSystemTime(Date.now() + FORWARD_CREDENTIAL_TTL_MS + 1); + return redeemForwardCredential(h, 'POST', '/api/v1/auth/update-user'); + }); + expect(got).toBeUndefined(); + expect(FORWARD_CREDENTIAL_TTL_MS).toBe(30_000); + }); + + it('is revoked when its forward answers: work the forward left running, still in its async chain, cannot present it afterwards', async () => { + let kept = ''; + let lingering!: Promise; + await withForwardCredential(binding(), async (h) => { + kept = h; + // A task the target scheduled and did not await: it keeps the + // forward's async context, and runs after the forward has answered. + lingering = new Promise((resolve) => { + setTimeout(() => resolve(redeemForwardCredential(h, 'POST', '/api/v1/auth/update-user')), 5); + }); + }); + expect(await lingering).toBeUndefined(); + expect(redeemForwardCredential(kept, 'POST', '/api/v1/auth/update-user')).toBeUndefined(); + }); + + it('is unreachable out of process: a presentation from another async chain redeems to nothing, even with the live handle', async () => { + // A request that arrives over the network is served in an async chain + // that did not start inside the forward. This one is registered BEFORE + // the forward exists, then handed the live handle while it runs. + let deliver!: (handle: string) => void; + const networkSide = new Promise((resolve) => { deliver = resolve; }) + .then((handle) => redeemForwardCredential(handle, 'POST', '/api/v1/auth/update-user')); + const insideAfterwards = await withForwardCredential(binding(), async (h) => { + deliver(h); + const fromOutside = await networkSide; + expect(fromOutside).toBeUndefined(); + // The outside presentation found no slot, so it spent nothing: the + // forward's own presentation still redeems. + return redeemForwardCredential(h, 'POST', '/api/v1/auth/update-user'); + }); + expect(insideAfterwards).toEqual(agent); + // And no chain outside a forward carries a credential at all. + expect(redeemForwardCredential('A'.repeat(43), 'POST', '/api/v1/auth/update-user')).toBeUndefined(); + }); + + it('a guessed or malformed handle inside a live forward redeems to nothing and does not spend the real one', async () => { + const got = await withForwardCredential(binding(), async (h) => [ + redeemForwardCredential('A'.repeat(43), 'POST', '/api/v1/auth/update-user'), + redeemForwardCredential('', 'POST', '/api/v1/auth/update-user'), + redeemForwardCredential(42, 'POST', '/api/v1/auth/update-user'), + redeemForwardCredential(h, 'POST', '/api/v1/auth/update-user'), + ]); + expect(got.slice(0, 3)).toEqual([undefined, undefined, undefined]); + expect(got[3]).toEqual(agent); + }); + + it('reads the presented handle off a Headers or a plain record, in either case', () => { + expect(presentedForwardCredential(new Headers({ [FORWARD_CREDENTIAL_HEADER]: 'h1' }))).toBe('h1'); + expect(presentedForwardCredential({ 'X-ObjectStack-Forward-Credential': 'h2' })).toBe('h2'); + expect(presentedForwardCredential({ [FORWARD_CREDENTIAL_HEADER]: ['h3'] })).toBe('h3'); + expect(presentedForwardCredential({ authorization: 'Bearer x' })).toBeUndefined(); + expect(presentedForwardCredential(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/rest/src/forward-credential.ts b/packages/rest/src/forward-credential.ts new file mode 100644 index 00000000000..b7176e783c1 --- /dev/null +++ b/packages/rest/src/forward-credential.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D10 rule 5] The agent forward credential: what an `api` action's + * in-process forward carries when the caller is an AI agent principal. + * + * ## The problem it closes + * + * A same-origin `type: 'api'` action runs by forwarding a request to its + * `target` inside the process (`@objectstack/runtime` `forwardApiAction`). For + * a person the forward carries their own session, and the target authorizes + * the same principal the action door admitted. An agent is admitted on an + * OAuth access token, which is honoured on the MCP endpoint alone, so the same + * forward reached its target as nobody. This module is the credential the + * server mints instead, so the target reads the agent with its ceiling intact. + * + * ## The four properties (ADR-0090 D10 rule 5), and where each is enforced + * + * 1. **Server-minted.** {@link withForwardCredential} is the only producer, and + * the action door calls it only after its own gates passed. The handle is + * 256 random bits; it names nothing and encodes nothing. + * 2. **Bound to one forward.** A credential carries the method and the route + * the forward requests; {@link redeemForwardCredential} refuses any other. + * 3. **Single-use, short-lived, non-replayable.** The first presentation spends + * it, whether it is accepted or refused. It is revoked when the forward + * answers ({@link withForwardCredential}'s `finally`) and expires after + * {@link FORWARD_CREDENTIAL_TTL_MS} in any case. + * 4. **Unreachable out of process.** The credential lives in an + * `AsyncLocalStorage` slot that only the forward's own async chain can see. + * The header carries a handle into that slot, never the credential itself: + * a request that arrives over the network runs in another async chain, so + * its slot is empty and a handle it presents redeems to nothing, whatever + * it guessed. Nothing here is stored, logged or returned. + * + * ## What a door does with it + * + * A door that serves forward targets (the runtime HTTP dispatcher, the REST + * face, the identity mount) reads {@link FORWARD_CREDENTIAL_HEADER} and asks + * {@link redeemForwardCredential}. A redeemed credential answers the agent's + * provenance, which the door hands to the shared assembler exactly as the MCP + * door hands a verified OAuth token's, so the target builds the SAME agent + * principal: scope-derived ceiling, `onBehalfOf`, `performedBy`. An + * unredeemable one is ignored: the request resolves as if the header were not + * there. ⛔ No door accepts this as a human: it never establishes anything but + * an agent principal. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; + +/** The request header a forward carries the credential's handle in. */ +export const FORWARD_CREDENTIAL_HEADER = 'x-objectstack-forward-credential'; + +/** + * How long a credential lives if its forward never answers. A forward is one + * in-process request, so this is generous for any real target and short + * against any replay: the credential is revoked the moment its forward + * answers, and this bound only covers a forward that hangs. + */ +export const FORWARD_CREDENTIAL_TTL_MS = 30_000; + +/** + * The agent the forward carries: the same provenance the MCP door verified + * from the agent's OAuth access token, handed over by the action door, never + * re-derived from a token. + */ +export interface ForwardAgentProvenance { + /** The delegating human (`onBehalfOf`), the token's `sub`. */ + userId: string; + /** The authorized client (`azp`), recorded as `performedBy`. */ + clientId: string; + /** The scopes the user consented to; the target derives the ceiling from them. */ + scopes: string[]; + /** The one organization the consent bound (absent when it bound none). */ + organizationId?: string; +} + +/** What one forward is bound to (ADR-0090 D10 rule 5, property 2). */ +export interface ForwardCredentialBinding { + /** The forward's HTTP method, upper-case. */ + method: string; + /** The path the forward requests; its route (the pathname) is what binds. */ + path: string; + /** The action run this forward belongs to. */ + run: { objectName: string; actionName: string; recordId?: string }; + agent: ForwardAgentProvenance; +} + +interface ForwardCredentialSlot extends ForwardCredentialBinding { + handle: Buffer; + expiresAt: number; + spent: boolean; +} + +/** + * ONE store per process, kept on a registered global symbol: a workspace + * package can be loaded twice (its ESM and CJS builds), and two module copies + * holding two stores would make a credential minted through one copy + * unredeemable through the other. That direction fails closed, but it fails. + * The symbol is process-local; nothing outside the process can reach it. + */ +const STORE_KEY = Symbol.for('@objectstack/rest.forward-credential.store'); +const store: AsyncLocalStorage = + ((globalThis as Record)[STORE_KEY] as AsyncLocalStorage | undefined) ?? + ((globalThis as Record)[STORE_KEY] = new AsyncLocalStorage()) as AsyncLocalStorage; + +/** + * Mint a credential bound to one forward and run `send` with its handle. The + * handle is valid only inside `send`'s own async chain and only until `send` + * settles: it is revoked in `finally`, whatever the target answered. + */ +export async function withForwardCredential( + binding: ForwardCredentialBinding, + send: (handle: string) => Promise, +): Promise { + const handle = randomBytes(32); + const slot: ForwardCredentialSlot = { + method: binding.method.toUpperCase(), + path: binding.path, + run: { ...binding.run }, + agent: { ...binding.agent, scopes: [...binding.agent.scopes] }, + handle, + expiresAt: Date.now() + FORWARD_CREDENTIAL_TTL_MS, + spent: false, + }; + try { + return await store.run(slot, () => send(handle.toString('base64url'))); + } finally { + slot.spent = true; + } +} + +/** + * The route of a URL or path: its pathname, percent-decoded, without the + * query. The binding is to a route, and two doors spell one route the same way + * only once both are decoded. + */ +function routeOf(url: string): string | undefined { + try { + return decodeURI(new URL(url, 'http://localhost').pathname); + } catch { + return undefined; + } +} + +/** + * Redeem the handle a request presented. Answers the agent's provenance when + * the handle names the credential of the forward this request IS — same + * async chain, not spent, not expired, same method, same path — and + * `undefined` otherwise. Any presentation in the forward's chain spends the + * credential, accepted or not, so a second one is always refused. + * + * `url` is the request URL the door saw (absolute or a path); only its route + * (the decoded pathname) is compared. + */ +export function redeemForwardCredential( + presented: unknown, + method: string, + url: string, +): ForwardAgentProvenance | undefined { + if (typeof presented !== 'string' || presented.length === 0) return undefined; + const slot = store.getStore(); + if (!slot || slot.spent) return undefined; + let given: Buffer; + try { + given = Buffer.from(presented, 'base64url'); + } catch { + return undefined; + } + if (given.length !== slot.handle.length || !timingSafeEqual(given, slot.handle)) return undefined; + // From here the handle is this forward's own, so this presentation spends it. + slot.spent = true; + if (Date.now() > slot.expiresAt) return undefined; + if (String(method).toUpperCase() !== slot.method) return undefined; + const route = routeOf(url); + if (route === undefined || route !== routeOf(slot.path)) return undefined; + return { ...slot.agent, scopes: [...slot.agent.scopes] }; +} + +/** + * Read the presented handle off a request's headers (a `Headers`, or a plain + * record of either case). `undefined` when absent. + */ +export function presentedForwardCredential(headers: unknown): string | undefined { + if (!headers || typeof headers !== 'object') return undefined; + if (typeof (headers as Headers).get === 'function') { + const v = (headers as Headers).get(FORWARD_CREDENTIAL_HEADER); + return v ?? undefined; + } + for (const [k, v] of Object.entries(headers as Record)) { + if (k.toLowerCase() !== FORWARD_CREDENTIAL_HEADER) continue; + if (typeof v === 'string') return v; + if (Array.isArray(v) && typeof v[0] === 'string') return v[0]; + } + return undefined; +} diff --git a/packages/rest/src/index.ts b/packages/rest/src/index.ts index 66543947946..d0d1a5d2523 100644 --- a/packages/rest/src/index.ts +++ b/packages/rest/src/index.ts @@ -230,3 +230,16 @@ export { metaItemPackageBinding } from './rest-server.js'; // MCP federation tools (`list_remote_tables` / `import_object`) refuse a caller // exactly where `GET …/external/tables` and `POST …/tables/:remote/import` do. export { FEDERATION_READ_CAPABILITY, FEDERATION_WRITE_CAPABILITY } from './external-datasource-routes.js'; + +// [ADR-0090 D10 rule 5, #28367] The agent forward credential: the action door +// mints it for an AI agent's in-process `api` forward, and every door that +// serves forward targets (the runtime dispatcher, this REST face, the identity +// mount) redeems it to the same agent principal. Process-local by design. +export { + FORWARD_CREDENTIAL_HEADER, + FORWARD_CREDENTIAL_TTL_MS, + withForwardCredential, + redeemForwardCredential, + presentedForwardCredential, +} from './forward-credential.js'; +export type { ForwardAgentProvenance, ForwardCredentialBinding } from './forward-credential.js'; diff --git a/packages/rest/src/rest-forward-credential-agent.test.ts b/packages/rest/src/rest-forward-credential-agent.test.ts new file mode 100644 index 00000000000..0088d9d691f --- /dev/null +++ b/packages/rest/src/rest-forward-credential-agent.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// [ADR-0090 D10 rule 5, #28367] The REST face's half of the agent forward +// credential. This face hands the shared assembler `oauth: undefined` for every +// caller but one: a request the action door itself injected in-process for an +// AI agent's `api` forward, carrying the credential minted for that forward. +// That request resolves to the SAME agent principal the MCP door built +// (`principalKind: 'agent'`, `onBehalfOf`, `performedBy`, `oauthScopes`), and it +// reports no admission, so the key-management rule never reads it as a +// signed-in session. ⛔ An OAuth access token presented from outside stays +// unaccepted here, and the credential's header presented outside a forward is +// nobody. +// +// The fixture below is the real data route over the stub protocol of +// `rest-exec-ctx-principal-kind.test.ts`, copied rather than shared because +// that file's fixtures are module-private. + +import { describe, it, expect, vi } from 'vitest'; +import { hashApiKey } from '@objectstack/core'; +import { RestServer } from './rest-server'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; +import { FORWARD_CREDENTIAL_HEADER, withForwardCredential } from './forward-credential.js'; + +const TASK = { + name: 'task', + label: '任务', + fields: { id: { type: 'text', label: 'ID' }, title: { type: 'text', label: '标题' } }, +}; + +const makeServer = () => ({ + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn(), close: vi.fn(), +}); + +const FUTURE = '2999-01-01T00:00:00Z'; +/** The raw API key `member1` presents; `sys_api_key` stores only its hash. */ +const RAW_API_KEY = 'osk_6071_rest_face'; + +/** + * A fake data engine holding what a real deployment holds: one `sys_api_key` + * row for `member1`, a `member_default` grant for `member1` and an + * `admin_full_access` grant for `admin1` owned by the Default Organization (the + * grant that anchors platform standing → PLATFORM_ADMIN). Everything else + * resolves empty. + */ +const makeQl = () => bindTestSecurityCatalog({ + find: async (object: string, opts: any) => { + const rows = rowsFor(object, opts); + return typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows; + }, +}, { permissions: [{ name: 'admin_full_access' }, { name: 'member_default' }] }); + +/** The rows the fake engine holds for `object`, filtered by `opts.where`. */ +function rowsFor(object: string, opts: any): any[] { + { + const where = opts?.where ?? {}; + if (object === 'sys_api_key') { + const row = { id: 'k1', key: hashApiKey(RAW_API_KEY), revoked: false, user_id: 'member1', expires_at: FUTURE }; + return Object.entries(where).every(([k, v]) => (row as any)[k] === v) ? [row] : []; + } + if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; + // The Default Organization, by its slug. + if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; + if (object === 'sys_user_permission_set') { + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: 'org_default' }]; + return []; + } + if (object === 'sys_permission_set') { + // By id (a position binding) or by name (a user grant, ADR-0131 D4). + const ids: string[] = where.id?.$in ?? []; + const names: string[] = where.name?.$in ?? []; + return [ + ids.includes('ps_admin') || names.includes('admin_full_access') ? { id: 'ps_admin', name: 'admin_full_access' } : null, + ids.includes('ps_member') || names.includes('member_default') ? { id: 'ps_member', name: 'member_default' } : null, + ].filter(Boolean); + } + return []; + } +} + +/** A fake auth service whose session is keyed off the request's `cookie` header. */ +const makeAuth = () => ({ + // [#7280] The ADR-0069 gate feature is ON, so `computeExecCtx` takes its + // gate branch for every authenticated request. A session with no + // `user.authGate` still yields no gate — which is what keeps the golden key + // set below unchanged, and makes the gated cases genuinely opt-in. + isAuthGateActive: () => true, + api: { + getSession: async ({ headers }: { headers: any }) => { + const cookie = headers?.get?.('cookie'); + if (cookie === 'admin') return { user: { id: 'admin1' } }; + if (cookie === 'member') return { user: { id: 'member1' } }; + // [commit f586f1a89] A session that DOES carry a bearer token, so the + // `accessToken` pin below exercises a live branch of + // `resolveAuthzContext` rather than an absent value. + if (cookie === 'member-tok') { + return { user: { id: 'member1' }, session: { token: 'sess_tok_rest' } }; + } + // [#7280] A session blocked by an authentication policy… + if (cookie === 'member-gated') { + return { + user: { + id: 'member1', + authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }, + }, + }; + } + // …and one whose gate names a code but no usable message, the shape + // `normalizeAuthGate` exists to complete. + if (cookie === 'member-gated-bare') { + return { user: { id: 'member1', authGate: { code: 'MFA_REQUIRED' } } }; + } + return undefined; + }, + }, +}); + +function makeRes() { + let status = 200; + const res: any = { + write: () => true, + end: () => {}, + header: () => res, + status: (code: number) => { status = code; return res; }, + json: (body: any) => { res._json = body; return res; }, + }; + return { res, getStatus: () => status, getJson: () => res._json }; +} + +/** + * Boot the REAL data route over a stub protocol. `findData` records the + * ExecutionContext the transport handed it — the instrumentation point of the + * issue, moved into a test. + */ +function boot(auth: unknown = makeAuth()) { + const seen: any[] = []; + const protocol: any = { + getMetaItems: vi.fn().mockResolvedValue({ items: [TASK] }), + findData: vi.fn(async (args: any) => { seen.push(args.context); return { object: 'task', records: [] }; }), + }; + const rest = new RestServer( + makeServer() as any, + protocol as any, + {} as any, + undefined, // kernelManager + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + async () => auth, // authServiceProvider + async () => makeQl(), // objectQLProvider + ); + rest.registerRoutes(); + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object'); + expect(route).toBeDefined(); + return { rest, route, seen, protocol }; +} + +/** Drive `GET /api/v1/data/task` with the given request headers. */ +async function request(headers: Record, auth?: unknown) { + const { route, seen, protocol } = boot(auth); + const out = makeRes(); + await route!.handler({ + method: 'GET', + // [#7280] A real adapter always sets `path`, and `enforceAuth`'s + // ADR-0069 branch reads it. [#7898] `isAuthGateAllowlisted(undefined)` + // used to return TRUE, so a request without one read as allow-listed and + // the gate never fired; the predicate is fail-closed now, which reverses + // the consequence of omitting `path`. Either way, spelling it here keeps + // the wire fixture honest about what a real adapter sends. + path: '/api/v1/data/task', + params: { object: 'task' }, query: {}, headers, + } as any, out.res); + return { ctx: seen[0], out, protocol }; +} + +const AGENT = { userId: 'member1', clientId: 'client_agent', scopes: ['actions:execute', 'data:read', 'data:write'] }; +const bindForward = (method = 'GET', path = '/api/v1/data/task') => + ({ method, path, run: { objectName: 'task', actionName: 'probe' }, agent: AGENT }); + +describe('[ADR-0090 D10 rule 5] the REST face resolves a forward credential to the agent, and nothing else to one', () => { + it('inside the forward, the presented credential resolves to the SAME agent principal the MCP door builds', async () => { + const { ctx, out } = await withForwardCredential(bindForward(), (handle) => + request({ host: 'localhost', [FORWARD_CREDENTIAL_HEADER]: handle })); + + expect(out.getStatus()).toBe(200); + expect(ctx).toMatchObject({ + userId: 'member1', + principalKind: 'agent', + onBehalfOf: { userId: 'member1', principalKind: 'human' }, + performedBy: { clientId: 'client_agent' }, + oauthScopes: AGENT.scopes, + }); + // No admission is reported for it: the key-management rule reads no + // credential, never a signed-in session. + expect(ctx.__admittedBy).toBeUndefined(); + }); + + it('a credential bound to another method or route is nobody here: 401, no context', async () => { + for (const bound of [bindForward('POST'), bindForward('GET', '/api/v1/data/other')]) { + const { ctx, out } = await withForwardCredential(bound, (handle) => + request({ host: 'localhost', [FORWARD_CREDENTIAL_HEADER]: handle })); + expect(ctx).toBeUndefined(); + expect(out.getStatus()).toBe(401); + } + }); + + it('a second request presenting the same handle inside the forward is nobody: the credential was spent', async () => { + const second = await withForwardCredential(bindForward(), async (handle) => { + const first = await request({ host: 'localhost', [FORWARD_CREDENTIAL_HEADER]: handle }); + expect(first.ctx?.principalKind).toBe('agent'); + return request({ host: 'localhost', [FORWARD_CREDENTIAL_HEADER]: handle }); + }); + expect(second.ctx).toBeUndefined(); + expect(second.out.getStatus()).toBe(401); + }); + + it('the header presented outside any forward is nobody, and a cookie beside it is read as before, unchanged', async () => { + const forged = await request({ [FORWARD_CREDENTIAL_HEADER]: 'A'.repeat(43) }); + expect(forged.ctx).toBeUndefined(); + expect(forged.out.getStatus()).toBe(401); + + const withCookie = await request({ cookie: 'member', [FORWARD_CREDENTIAL_HEADER]: 'A'.repeat(43) }); + expect(withCookie.ctx).toMatchObject({ userId: 'member1', principalKind: 'human' }); + expect(withCookie.ctx.oauthScopes).toBeUndefined(); + }); + + it('the boundary is unmoved: an OAuth access token presented from outside is not accepted on this face', async () => { + const jwtShaped = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJtZW1iZXIxIn0.c2lnbmF0dXJl'; + const { ctx, out } = await request({ authorization: `Bearer ${jwtShaped}` }); + expect(ctx).toBeUndefined(); + expect(out.getStatus()).toBe(401); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 979a9341e53..c66a6538425 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -99,6 +99,8 @@ import { // them to `meta-item-read-routes.ts` (#23050 slice 13b). } from '@objectstack/metadata-core'; import { RouteManager, type RouteEntry } from './route-manager.js'; +import { presentedForwardCredential, redeemForwardCredential } from './forward-credential.js'; +import { scopesToAgentPermissionSets, MCP_OAUTH_SCOPE_ACTIONS } from '@objectstack/spec/ai'; // [#6877] Query-parameter multiplicity. `IHttpRequest.query` declares // `string | string[]`; the array arm is real (`NodeHttpServer` produces it, // measured over a socket on #6878) and this file used it as a string at ~50 @@ -3385,6 +3387,19 @@ export class RestServer { // are the composition's; each parameter below is this door's answer // from before the composition existed (its docblock tables them). let admittedBy: 'api_key' | 'session' | undefined; + // [ADR-0090 D10 rule 5, #28367] The ONE agent provenance this face + // accepts: a forward credential the action door minted for an AI + // agent's in-process `api` forward, redeemed here (once: the + // resolution is memoized per request by `resolveExecCtx`). It is + // only ever minted from an agent the MCP OAuth door already + // admitted, and resolves to the SAME agent principal. ⛔ An OAuth + // access token presented from outside stays unaccepted on this + // face: nothing below reads one, and a forward credential is + // redeemable only inside the forward's own in-process call. + const presentedForward = presentedForwardCredential(headers); + const forwardAgent = presentedForward + ? redeemForwardCredential(presentedForward, String(req?.method ?? ''), String(req?.path ?? req?.url ?? '')) + : undefined; const base = await resolveRequestExecutionContext({ headers, ql, @@ -3419,7 +3434,18 @@ export class RestServer { // rule: this request answers with its OWN response, so a request // carrying a session cookie reads without renewal (only // `/get-session`, which re-issues the cookie, renews it). - session: 'request', + // A redeemed forward credential establishes the session for the + // agent's delegating user, in the one organization the consent + // bound, exactly as the dispatcher's `/mcp` face does for a + // verified token; nothing is read from the request for it. + session: forwardAgent + ? { + established: { + user: { id: forwardAgent.userId }, + session: { userId: forwardAgent.userId, activeOrganizationId: forwardAgent.organizationId }, + }, + } + : 'request', // The reference localization from this door's settings provider; // a provider that throws or rejects reads as no service. localization: { @@ -3438,13 +3464,28 @@ export class RestServer { accessToken: 'withhold', // OAuth access tokens are honoured on the `/mcp` door alone // (`acceptOAuthAccessToken`), precisely so coarse tool-family - // scopes cannot ride onto REST — so `principalKind: 'agent'`, - // `onBehalfOf` and `oauthScopes` are not representable here. - oauth: undefined, + // scopes cannot ride onto REST. [ADR-0090 D10 rule 5] The agent + // principal (`principalKind: 'agent'`, `onBehalfOf`, + // `oauthScopes`) is represented here for one caller only: an + // agent a forward credential carried. Its ceiling is derived + // from the consented scopes exactly as the MCP door derives it. + oauth: forwardAgent + ? { + userId: forwardAgent.userId, + clientId: forwardAgent.clientId, + scopes: forwardAgent.scopes, + ...(forwardAgent.organizationId ? { organizationId: forwardAgent.organizationId } : {}), + scopePermissions: scopesToAgentPermissionSets(forwardAgent.scopes), + delegatesActions: forwardAgent.scopes.includes(MCP_OAUTH_SCOPE_ACTIONS), + } + : undefined, // Which credential admitted the caller, kept on THIS request for // `enforceApiAccess`'s API-key management rule. The ADR-0069 gate // is still enforced off the envelope, at `enforceAuth`. - onAdmission: (admission) => { admittedBy = admission.credential; }, + // [ADR-0090 D10 rule 5] Not for a forward-credential agent: no + // credential of its own admitted it here, and the key-management + // rule must not read it as a signed-in session. + onAdmission: forwardAgent ? undefined : (admission) => { admittedBy = admission.credential; }, }); if (!base) return undefined; diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 9d17d9857f7..ad1870a54d3 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -39,6 +39,11 @@ import { checkApiExposure, type ApiExposureDecision, type ObjectApiDef } from '. // door's and the MCP stdio bridge's, imported rather than re-walked so this door // cannot disagree with them about which conditions a filter holds. import { relationConditionSites } from '@objectstack/core'; +// [ADR-0090 D10 rule 5] The agent forward credential: minted here, redeemed by +// the doors that serve forward targets (the dispatcher, the REST face, the +// identity mount). One module in `@objectstack/rest`, which all three reach. +import { FORWARD_CREDENTIAL_HEADER, withForwardCredential, type ForwardAgentProvenance } from '@objectstack/rest'; +import { MCP_OAUTH_SCOPE_ACTIONS, MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_METADATA_WRITE } from '@objectstack/spec/ai'; // [#25653] The app-automation elevation mark an action body's context carries // — see `buildActionExecutionContext`. import { markAppAutomationElevation } from '@objectstack/core'; @@ -1181,6 +1186,16 @@ const API_FORWARD_HEADER_ALLOWLIST: ReadonlySet = new Set([ 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-real-ip', 'x-request-id', ]); +/** + * [ADR-0090 D10 rule 5] The caller's identity headers, of + * {@link API_FORWARD_HEADER_ALLOWLIST}: dropped from a forward that carries an + * agent forward credential, so the agent's token never reaches the target and + * the target reads exactly one credential, the one the server minted. + */ +const API_FORWARD_IDENTITY_HEADERS: ReadonlySet = new Set([ + 'authorization', 'cookie', 'x-admin-session-token', 'x-api-key', +]); + /** * Marks a forwarded request so a `target` that is itself an action door * cannot loop: the door refuses a request already carrying it. @@ -1450,10 +1465,10 @@ export type ApiForwardTargetDoor = 'identity' | 'mcp' | 'platform'; * Read by `run_action` and the REST action door (the refusal, before any * forward) and by `list_actions` (the same refusal, carried on the listing as * the action's `unavailable` mark), so the listing and the run door cannot - * disagree. ⛔ This table states what each door accepts today; it changes no - * door. Letting an OAuth-connected agent reach a door that does not accept its - * token is a separate decision (a server-built forward credential), never a - * new cell here. + * disagree. ⛔ This table states what each door accepts of the CALLER'S OWN + * credential; it changes no door. Where an agent reaches a door that does not + * accept its token, the forward carries a credential the server mints instead, + * and only at the cells {@link API_FORWARD_AGENT_CREDENTIAL_DOORS} names. */ const API_FORWARD_DOOR_ACCEPTS: Readonly>> = { identity: new Set(['session']), @@ -1461,6 +1476,22 @@ const API_FORWARD_DOOR_ACCEPTS: Readonly(['session', 'api_key']), }; +/** + * [ADR-0090 D10 rule 5] The cells of {@link API_FORWARD_DOOR_ACCEPTS} that flip + * from "refuse by name" to "accept with a forward credential": an OAuth-connected + * agent at an identity route and at a platform route. The forward then carries + * a credential the server mints for that one forward + * (`@objectstack/rest` `withForwardCredential`), never the agent's token, and + * the target resolves it to the same agent principal. The platform cell is + * bounded by the scope-family rule ({@link apiForwardScopeFamilyRefusal}). + * + * ⛔ Exactly these two. `api_key` × `identity` keeps refusing by name: an API + * key is its owner's credential, not an agent principal (ADR-0090 D10, the + * 2026-09-16 note), so there is no agent for a forward credential to carry. + * `oauth` × `mcp` needs none: that door honours the caller's own token. + */ +const API_FORWARD_AGENT_CREDENTIAL_DOORS: ReadonlySet = new Set(['identity', 'platform']); + /** The door a forward `path` (or an action's declared `target`) is served by. */ function apiForwardTargetDoor(path: string): ApiForwardTargetDoor { if (isIdentityRouteTarget(path)) return 'identity'; @@ -1470,14 +1501,50 @@ function apiForwardTargetDoor(path: string): ApiForwardTargetDoor { return 'platform'; } +/** + * The metadata door (`/api/v1/meta/…`, plain or environment-scoped). A + * state-changing forward to it stays outside the forward credential (ADR-0090 + * D10 rule 5, the scope-family rule's third clause): an agent authors metadata + * through the MCP authoring tools under `metadata:write`, which save drafts a + * person publishes, never through an action forward. + */ +function isMetadataDoorTarget(path: string): boolean { + return /^\/api\/v1(?:\/environments\/[^/]+)?\/meta(?:[/?]|$)/.test(path); +} + +/** + * The agent a forward credential would carry for this request: the agent + * principal the MCP door resolved from a verified OAuth access token. `undefined` + * for every other caller, and for an OAuth caller the door did not resolve to an + * agent (a token naming no client), whom the forward has no agent to carry for. + */ +export function apiForwardAgentOf(requestContext: HttpProtocolContext | undefined): ForwardAgentProvenance | undefined { + const ec: any = requestContext?.executionContext; + if (!ec || ec.principalKind !== 'agent' || !Array.isArray(ec.oauthScopes)) return undefined; + const userId = typeof ec.onBehalfOf?.userId === 'string' ? ec.onBehalfOf.userId : ec.userId; + const clientId = ec.performedBy?.clientId; + if (typeof userId !== 'string' || !userId || typeof clientId !== 'string' || !clientId) return undefined; + return { + userId, + clientId, + scopes: (ec.oauthScopes as unknown[]).filter((s): s is string => typeof s === 'string'), + ...(typeof ec.tenantId === 'string' && ec.tenantId ? { organizationId: ec.tenantId } : {}), + }; +} + /** The kind of credential that admitted this request's caller (see {@link ApiForwardCredentialKind}). */ export function apiForwardCredentialKind(requestContext: HttpProtocolContext | undefined): ApiForwardCredentialKind | undefined { if (Array.isArray(requestContext?.executionContext?.oauthScopes)) return 'oauth'; return requestContext?.admission?.credential; } -/** A forward refusal: the status and standard code the target would have answered, and the sentence that says why. */ -export type ApiForwardCredentialRefusal = { status: 401; code: string; message: string }; +/** + * A forward refusal: the status and standard code, and the sentence that says + * why. `401` when the target's door cannot read the caller's credential (the + * status the target would have answered); `403` when the scope-family rule + * refuses an agent the consent did not grant the forward's family. + */ +export type ApiForwardCredentialRefusal = { status: 401 | 403; code: string; message: string }; /** * The refusal for running `action` by forward with this caller's credential — @@ -1504,12 +1571,75 @@ export function apiForwardCredentialRefusal( const door = apiForwardTargetDoor(target); if (API_FORWARD_DOOR_ACCEPTS[door].has(kind)) return null; const path = (typeof action?.target === 'string' ? action.target : target).split('?')[0]!; + // [ADR-0090 D10 rule 5] The two flipped cells: an agent principal is carried + // by a forward credential, bounded by the scope-family rule. + if (kind === 'oauth' && API_FORWARD_AGENT_CREDENTIAL_DOORS.has(door)) { + const agent = apiForwardAgentOf(requestContext); + if (agent) return apiForwardScopeFamilyRefusal(agent.scopes, action, target, path); + } const message = kind === 'api_key' ? apiActionIdentityRouteKeyRefusal(action, path) : apiActionOAuthForwardRefusal(action, path, door); return { status: 401, code: standardErrorCodeForHttpStatus(401), message }; } +/** + * [ADR-0090 D10 rule 5, the scope-family rule] Whether an agent's consented + * scopes cover a forward outside MCP — `null` when they do, else the refusal, + * given by name before anything is minted or sent. + * + * Outside MCP there are no tool families, and a target that gates on system + * permissions only would see the agent's `systemPermissions`, which under + * `actions:execute` are the delegating user's whole list. So the forward's + * method names a data family the agent must ALSO hold: + * + * 1. every forward credential needs `actions:execute` (the action doors serve + * an agent only under it, so this is the floor, restated where it is read); + * 2. a safe method (`GET`, `HEAD`) needs `data:read` or `data:write`, any + * other method `data:write` — a read-consented agent can read through an + * action and can never change state through one, at any target; + * 3. a state-changing method on the metadata door is outside the flip and + * keeps refusing by name ({@link isMetadataDoorTarget}). + * + * `method` is the forward's (`action.method`, default `POST`, as + * {@link buildApiActionRequest} reads it), so the listing and the run door + * judge the same request. + */ +function apiForwardScopeFamilyRefusal( + scopes: readonly string[], + action: any, + target: string, + path: string, +): ApiForwardCredentialRefusal | null { + const method = String(action?.method || 'POST').toUpperCase(); + const safe = method === 'GET' || method === 'HEAD'; + const name = action?.name ?? 'unknown'; + if (!safe && isMetadataDoorTarget(target)) { + return { + status: 403, + code: standardErrorCodeForHttpStatus(403), + message: + `Action '${name}' sends a ${method} request to the metadata endpoint ('${path}'), and an AI agent ` + + `does not change metadata through an action. The action was not run. An agent connected with the '` + + `${MCP_OAUTH_SCOPE_METADATA_WRITE}' scope builds metadata as drafts through its authoring tools, and a person publishes them.`, + }; + } + const needs = !scopes.includes(MCP_OAUTH_SCOPE_ACTIONS) + ? [MCP_OAUTH_SCOPE_ACTIONS] + : safe + ? (scopes.includes(MCP_OAUTH_SCOPE_DATA_READ) || scopes.includes(MCP_OAUTH_SCOPE_DATA_WRITE) ? [] : [MCP_OAUTH_SCOPE_DATA_READ]) + : (scopes.includes(MCP_OAUTH_SCOPE_DATA_WRITE) ? [] : [MCP_OAUTH_SCOPE_DATA_WRITE]); + if (needs.length === 0) return null; + return { + status: 403, + code: standardErrorCodeForHttpStatus(403), + message: + `Action '${name}' sends a ${method} request to '${path}', which an AI agent may run only with the ` + + `'${needs[0]}' scope, and this connection was not granted it. The action was not run. Reconnect the ` + + `agent and grant '${needs[0]}', or ask a person signed in to the app to run it.`, + }; +} + /** * The sentence for an API-key caller of an identity-route action (the table's * `api_key` × `identity` cell). Which credentials the identity route accepts @@ -1658,6 +1788,16 @@ export function apiActionForwardLoopError(action: any, mark: string): string { * A caller whose credential the target's door cannot read is never * forwarded ({@link apiForwardCredentialRefusal}). * + * [ADR-0090 D10 rule 5] An AI agent principal is the one caller whose own + * credential is never carried. At the two cells the table flips + * ({@link API_FORWARD_AGENT_CREDENTIAL_DOORS}) the forward drops every caller + * identity header and carries a forward credential instead: minted here, after + * every gate of the action door has passed, bound to this one method, path and + * action run, spent by its first presentation and revoked when the target + * answers (`@objectstack/rest` `withForwardCredential`). The target resolves it + * to the same agent principal. And no credential-rotation header comes back to + * an agent: whatever the target mints stays with the target. + * * Relayed back: the status, the JSON body, and the credential-rotation * headers a target like `/admin/impersonate-user` emits — `set-auth-token` / * `set-admin-session-token` (the bearer lane) and `Set-Cookie` (the cookie @@ -1671,14 +1811,14 @@ export async function forwardApiAction( inject: (url: string, init: RequestInit) => Promise, requestContext: HttpProtocolContext, req: ApiActionRequest, - mark: { objectName: string; actionName: string; target?: string }, + mark: { objectName: string; actionName: string; target?: string; recordId?: string }, ): Promise { // A caller whose credential the target's door cannot read (the table, // {@link API_FORWARD_DOOR_ACCEPTS}) is refused here, before the target is // called, with the status the target would have answered and the sentence // that says why. Both doors relay it as they relay a target's refusal. const refusal = apiForwardCredentialRefusal( - requestContext, { type: 'api', name: mark.actionName, target: mark.target }, req.path, + requestContext, { type: 'api', name: mark.actionName, target: mark.target, method: req.method }, req.path, ); if (refusal) { return { @@ -1691,10 +1831,18 @@ export async function forwardApiAction( requestContext?.request?.headers && typeof requestContext.request.headers === 'object' ? requestContext.request.headers : {}; + // [ADR-0090 D10 rule 5] The agent this forward carries, when the caller is + // one and the target's door is a flipped cell; the refusal above has + // already answered every other agent forward. + const agent = apiForwardCredentialKind(requestContext) === 'oauth' + && API_FORWARD_AGENT_CREDENTIAL_DOORS.has(apiForwardTargetDoor(req.path)) + ? apiForwardAgentOf(requestContext) + : undefined; const headers: Record = {}; for (const [k, v] of Object.entries(incoming)) { const key = k.toLowerCase(); if (!API_FORWARD_HEADER_ALLOWLIST.has(key) || v == null) continue; + if (agent && API_FORWARD_IDENTITY_HEADERS.has(key)) continue; headers[key] = Array.isArray(v) ? v.join(', ') : String(v); } headers.accept = 'application/json'; @@ -1705,7 +1853,21 @@ export async function forwardApiAction( init.body = JSON.stringify(req.body); } const host = typeof incoming.host === 'string' && incoming.host ? incoming.host : 'localhost'; - const res = await inject(`http://${host}${req.path}`, init); + const url = `http://${host}${req.path}`; + const res = agent + ? await withForwardCredential( + { + method: req.method, + path: req.path, + run: { objectName: mark.objectName, actionName: mark.actionName, ...(mark.recordId ? { recordId: mark.recordId } : {}) }, + agent, + }, + (handle) => { + headers[FORWARD_CREDENTIAL_HEADER] = handle; + return inject(url, init); + }, + ) + : await inject(url, init); const text = await res.text(); let body: any; @@ -1734,6 +1896,8 @@ export async function forwardApiAction( }; } const relayed: Record = {}; + // [ADR-0090 D10 rule 5] Nothing a target mints reaches an agent caller. + if (agent) return { status: res.status, body, headers: relayed }; for (const name of API_FORWARD_RELAYED_RESPONSE_HEADERS) { const value = res.headers.get(name); if (value) relayed[name] = value; @@ -3729,7 +3893,7 @@ async function dispatchAdmittedBusinessAction(deps: ActionExecutionDeps, if ('message' in built) { throw Object.assign(new Error(built.message), { code: standardErrorCodeForHttpStatus(built.status), status: built.status }); } - const answer = await forwardApiAction(inject, requestContext, built, { objectName, actionName: action.name, target: action.target }); + const answer = await forwardApiAction(inject, requestContext, built, { objectName, actionName: action.name, target: action.target, ...(recordId ? { recordId: String(recordId) } : {}) }); if (answer.status < 200 || answer.status >= 300) throw apiActionForwardRefusal(action, answer); return { ok: true, action: action.name, objectName, ...(recordId ? { recordId } : {}), diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index a02454f2afc..23767580554 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -907,7 +907,7 @@ async function dispatchAdmittedRestAction( if ('message' in built) { return { handled: true, response: deps.error(built.message, built.status) }; } - const answer = await actionExec.forwardApiAction(inject, _context, built, { objectName, actionName, target: actionDef?.target }); + const answer = await actionExec.forwardApiAction(inject, _context, built, { objectName, actionName, target: actionDef?.target, ...(recordId ? { recordId: String(recordId) } : {}) }); const relayed = Object.keys(answer.headers).length > 0 ? { headers: answer.headers } : {}; if (answer.status >= 200 && answer.status < 300) { return { handled: true, response: { ...deps.success(actionExec.apiActionResultOf(answer)), ...relayed } }; diff --git a/packages/runtime/src/domains/api-action-forward.test.ts b/packages/runtime/src/domains/api-action-forward.test.ts index 8b48d3dc91e..9e30954c114 100644 --- a/packages/runtime/src/domains/api-action-forward.test.ts +++ b/packages/runtime/src/domains/api-action-forward.test.ts @@ -40,7 +40,9 @@ import { apiActionOAuthForwardRefusal, apiForwardCredentialRefusal, API_FORWARD_MARK_HEADER, + apiForwardAgentOf, } from '../action-execution.js'; +import { FORWARD_CREDENTIAL_HEADER, redeemForwardCredential } from '@objectstack/rest'; import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; import { ActionActivationProjection } from '@objectstack/objectql'; @@ -824,8 +826,14 @@ describe('[#28217] an API-key caller is refused by name before a forward to an i // read — and `list_actions` MARKS such an action instead of hiding it. The // table states what each door accepts; it widens none (a server-built forward // credential is #28367, a separate decision). -describe('[#28178] the credential × target-door table: an OAuth-connected agent is refused by name, and the listing marks it', () => { - /** An agent the MCP door admitted by OAuth access token: the envelope carries its scopes. */ +describe('[#28178] the credential × target-door table: an OAuth caller with no agent to carry is refused by name, and the listing marks it', () => { + /** + * An OAuth caller whose envelope carries scopes but that the MCP door did + * not resolve to an agent principal (no `principalKind: 'agent'`, no + * client). [ADR-0090 D10 rule 5, #28367] There is no agent for a forward + * credential to carry, so the #28178 refusal still answers it; the agent + * principal's flipped cells are pinned in the describe below. + */ const OAUTH_CTX = (): HttpProtocolContext => { const ctx: any = CTX({ authorization: 'Bearer oat_agent_token' }); delete ctx.request.headers.cookie; @@ -967,3 +975,190 @@ describe('[#28178] the credential × target-door table: an OAuth-connected agent expect(keyed[RECALC_LEAD.name].unavailable).toBeUndefined(); }); }); + +describe('[ADR-0090 D10 rule 5, #28367] the two flipped cells: an OAuth agent is forwarded with a forward credential, under the scope-family rule', () => { + /** The agent principal the MCP door assembles from a verified OAuth access token. */ + const AGENT_CTX = (scopes: string[] = ['actions:execute', 'data:read', 'data:write'], over: Record = {}): HttpProtocolContext => { + const ctx: any = CTX({ authorization: 'Bearer oat_agent_token', cookie: 'better-auth.session_token=ambient' }); + ctx.admission = { credential: 'session', authGate: undefined }; + ctx.executionContext = { + ...ctx.executionContext, + principalKind: 'agent', + onBehalfOf: { userId: 'u_member', principalKind: 'human' }, + performedBy: { clientId: 'client_agent' }, + oauthScopes: scopes, + ...over, + }; + return ctx as HttpProtocolContext; + }; + const KEY_CTX = (): HttpProtocolContext => { + const ctx: any = CTX({ 'x-api-key': 'osk_member_key' }); + delete ctx.request.headers.authorization; + delete ctx.request.headers.cookie; + ctx.admission = { credential: 'api_key', authGate: undefined }; + return ctx as HttpProtocolContext; + }; + const SESSION_CTX = (): HttpProtocolContext => + ({ ...CTX(), admission: { credential: 'session', authGate: undefined } } as unknown as HttpProtocolContext); + const deps = (h: ReturnType, request = h.request): any => ({ + resolveService: async (_ctx: unknown, service: string) => + service === 'metadata' ? h.metadata + : service === 'automation' ? null + : service === 'http.server' ? { getRawApp: () => ({ request }) } + : h.ql, + getObjectQL: async () => h.ql, + }); + const runViaMcp = async (h: ReturnType, ctx: HttpProtocolContext, name: string, input: any = {}, request = h.request) => + invokeBusinessAction(deps(h, request), ctx as any, name, input, { + driver: h.ql, + ec: (ctx as any).executionContext, + getMeta: () => h.metadata, + callData: async (_a: string, p: any) => ({ object: p?.object, id: p?.id, record: { id: p?.id } }), + }); + const listFor = async (h: ReturnType, ctx: HttpProtocolContext): Promise => + (h.dispatcher as any).buildMcpBridge(ctx).listActions(); + const api = (target: string, method?: string) => ({ name: 'probe', type: 'api', target, ...(method ? { method } : {}) }); + const doors = { identity: '/api/v1/auth/admin/ban-user', mcp: '/api/v1/mcp', platform: '/api/v1/data/crm_lead/{id}' }; + + it('the table, cell by cell: exactly oauth × identity and oauth × platform flip; api_key × identity keeps refusing by name', () => { + const cell = (ctx: HttpProtocolContext, target: string) => apiForwardCredentialRefusal(ctx, api(target)) === null ? 'ok' : 'refused'; + const table = Object.fromEntries( + (Object.entries({ session: SESSION_CTX, api_key: KEY_CTX, oauth: () => AGENT_CTX() }) as Array<[string, () => HttpProtocolContext]>) + .map(([kind, ctx]) => [kind, Object.fromEntries(Object.entries(doors).map(([door, t]) => [door, cell(ctx(), t)]))]), + ); + expect(table).toEqual({ + session: { identity: 'ok', mcp: 'ok', platform: 'ok' }, + api_key: { identity: 'refused', mcp: 'ok', platform: 'ok' }, + oauth: { identity: 'ok', mcp: 'ok', platform: 'ok' }, + }); + expect(apiForwardCredentialRefusal(KEY_CTX(), api(doors.identity))).toMatchObject({ + status: 401, message: apiActionIdentityRouteKeyRefusal(api(doors.identity), doors.identity), + }); + }); + + it('an OAuth caller the door did not resolve to an agent (no client named) has no agent to carry: still refused by name', () => { + const noClient = AGENT_CTX(undefined, { performedBy: undefined }); + expect(apiForwardAgentOf(noClient)).toBeUndefined(); + expect(apiForwardCredentialRefusal(noClient, api(doors.identity))).toMatchObject({ + status: 401, message: apiActionOAuthForwardRefusal(api(doors.identity), doors.identity, 'identity'), + }); + const notAgent = AGENT_CTX(undefined, { principalKind: 'human' }); + expect(apiForwardCredentialRefusal(notAgent, api(doors.platform))?.status).toBe(401); + }); + + it('the scope-family rule: actions:execute always; a safe method needs a data scope; any other method needs data:write', () => { + const readOnly = AGENT_CTX(['actions:execute', 'data:read']); + // A read-consented agent reads through an action and never changes state through one, at either door. + expect(apiForwardCredentialRefusal(readOnly, api(doors.platform, 'GET'))).toBeNull(); + expect(apiForwardCredentialRefusal(readOnly, api('/api/v1/auth/get-session', 'GET'))).toBeNull(); + for (const [target, method] of [[doors.identity, 'POST'], [doors.platform, 'PATCH'], [doors.platform, 'DELETE'], ['/api/v1/auth/update-user', undefined]] as const) { + const refusal = apiForwardCredentialRefusal(readOnly, api(target, method)); + expect(refusal, `${method ?? 'POST'} ${target}`).toMatchObject({ status: 403, code: standardErrorCodeForHttpStatus(403) }); + expect(refusal!.message).toMatch(/may run only with the 'data:write' scope, and this connection was not granted it\. The action was not run\./); + } + // No data scope at all: not even a read. + expect(apiForwardCredentialRefusal(AGENT_CTX(['actions:execute']), api(doors.platform, 'GET'))?.message) + .toMatch(/only with the 'data:read' scope/); + // No actions:execute: nothing is minted for any method. + expect(apiForwardCredentialRefusal(AGENT_CTX(['data:read', 'data:write']), api(doors.platform, 'GET'))?.message) + .toMatch(/only with the 'actions:execute' scope/); + // The MCP door is not a flipped cell: the rule does not reach it. + expect(apiForwardCredentialRefusal(readOnly, api(doors.mcp, 'POST'))).toBeNull(); + }); + + it('a state-changing method on the metadata door stays outside the flip, whatever the scopes; a read of it is served', () => { + const full = AGENT_CTX(['actions:execute', 'data:read', 'data:write', 'metadata:write']); + for (const target of ['/api/v1/meta/object/crm_lead', '/api/v1/environments/env_1/meta/view/x']) { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + const refusal = apiForwardCredentialRefusal(full, api(target, method)); + expect(refusal, `${method} ${target}`).toMatchObject({ status: 403 }); + expect(refusal!.message).toMatch(/metadata endpoint .* does not change metadata through an action\. The action was not run\./); + } + expect(apiForwardCredentialRefusal(full, api(target, 'GET'))).toBeNull(); + } + // A data route whose name merely starts with "meta" is not the metadata door. + expect(apiForwardCredentialRefusal(full, api('/api/v1/metadata-like/x', 'POST'))).toBeNull(); + }); + + it('MCP `run_action`: the agent\'s identity-route action is forwarded with a forward credential and none of the caller\'s identity headers', async () => { + const h = boot(); + let redeemed: unknown; + let handle = ''; + const request = vi.fn(async (url: string, init: RequestInit) => { + h.injected.push({ url, init }); + handle = headersOf(init)[FORWARD_CREDENTIAL_HEADER]!; + // What the target's door does: redeem the presented handle for this request. + redeemed = redeemForwardCredential(handle, String(init.method), url); + return new Response(JSON.stringify({ success: true, data: { banned: true } }), { + status: 200, + headers: [['content-type', 'application/json'], ['set-auth-token', 'rotated'], ['set-cookie', 'better-auth.session_token=new; HttpOnly']], + }); + }); + + const result: any = await runViaMcp(h, AGENT_CTX(), BAN_LEAD.name, { recordId: 'u_target' }, request); + + expect(result.ok).toBe(true); + expect(request).toHaveBeenCalledTimes(1); + const sent = headersOf(h.injected[0]!.init); + expect(sent.authorization).toBeUndefined(); + expect(sent.cookie).toBeUndefined(); + expect(sent['x-api-key']).toBeUndefined(); + expect(handle).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(handle).not.toContain('oat_agent_token'); + expect(redeemed).toEqual({ userId: 'u_member', clientId: 'client_agent', scopes: ['actions:execute', 'data:read', 'data:write'], organizationId: 'org_1' }); + // Revoked once the forward answered; and nothing the target minted, nor the handle, comes back. + expect(redeemForwardCredential(handle, 'POST', h.injected[0]!.url)).toBeUndefined(); + const answer = JSON.stringify(result); + expect(answer).not.toContain(handle); + expect(answer).not.toContain('rotated'); + expect(answer).not.toContain('session_token=new'); + }); + + it('REST door: the agent\'s platform action is forwarded with a forward credential, and the answer relays no credential header', async () => { + const h = boot({ answer: { body: { success: true, data: { recalculated: 1 } }, headers: [['set-auth-token', 'rotated'], ['set-cookie', 'a=b']] } }); + + const res: any = await h.dispatcher.handleActions(`/${OBJECT}/${RECALC_LEAD.name}`, 'POST', { recordId: 'u_target' }, AGENT_CTX()); + + expect(statusOf(res)).toBe(200); + expect(h.injected).toHaveLength(1); + const sent = headersOf(h.injected[0]!.init); + expect(sent[FORWARD_CREDENTIAL_HEADER]).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(sent.authorization).toBeUndefined(); + expect(sent.cookie).toBeUndefined(); + const relayed = JSON.stringify(res?.response?.headers ?? {}); + expect(relayed).not.toMatch(/set-auth-token|set-cookie|rotated/i); + }); + + it('a refused agent run mints nothing: the read-only agent\'s state-changing action is refused 403 by name and the target is never called', async () => { + const h = boot(); + + const thrown = await runViaMcp(h, AGENT_CTX(['actions:execute', 'data:read']), BAN_LEAD.name, { recordId: 'u_target' }).catch((e) => e); + + expect((thrown as any).status).toBe(403); + expect((thrown as any).code).toBe(standardErrorCodeForHttpStatus(403)); + expect(String(thrown.message)).toMatch(/^Action 'ban_lead' sends a POST request to '\/api\/v1\/auth\/admin\/ban-user', which an AI agent may run only with the 'data:write' scope/); + expect(h.request).not.toHaveBeenCalled(); + }); + + it('`list_actions` marks from the same predicate: the read-only agent sees its state-changing actions marked 403, a full agent sees none marked', async () => { + const h = boot(); + + const readOnly = Object.fromEntries((await listFor(h, AGENT_CTX(['actions:execute', 'data:read']))).map((a) => [a.name, a])); + expect(readOnly[BAN_LEAD.name]?.unavailable).toMatchObject({ code: standardErrorCodeForHttpStatus(403) }); + expect(readOnly[BAN_LEAD.name].unavailable.reason).toMatch(/only with the 'data:write' scope/); + const thrown = await runViaMcp(h, AGENT_CTX(['actions:execute', 'data:read']), BAN_LEAD.name, { recordId: 'u_target' }).catch((e) => e); + expect(String(thrown.message)).toBe(readOnly[BAN_LEAD.name].unavailable.reason); + + const full = await listFor(h, AGENT_CTX()); + expect(full.filter((a) => a.unavailable && [BAN_LEAD.name, RECALC_LEAD.name].includes(a.name))).toEqual([]); + }); + + it('a session caller is forwarded exactly as before: its own credentials, no forward credential', async () => { + const h = boot(); + const result: any = await runViaMcp(h, SESSION_CTX(), BAN_LEAD.name, { recordId: 'u_target' }); + expect(result.ok).toBe(true); + const sent = headersOf(h.injected[0]!.init); + expect(sent.authorization).toBe('Bearer tok_member'); + expect(sent[FORWARD_CREDENTIAL_HEADER]).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.forward-credential.test.ts b/packages/runtime/src/security/resolve-execution-context.forward-credential.test.ts new file mode 100644 index 00000000000..8dd054d82aa --- /dev/null +++ b/packages/runtime/src/security/resolve-execution-context.forward-credential.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0090 D10 rule 5, #28367] The runtime dispatcher's half of the agent + * forward credential. + * + * A request the action door injected in-process for an AI agent's `api` + * forward carries a credential minted for that one forward, never the agent's + * token. `resolveExecutionContext` redeems it and hands the shared assembler + * the agent provenance it was minted with, so the target builds the SAME agent + * principal the MCP door built: `principalKind: 'agent'`, `onBehalfOf`, + * `performedBy`, `oauthScopes`, and the consent's organization, vetted again. + * It does so on every route this door serves, not only `/mcp`, and it reports + * no admission (the `/keys` mint door refuses anything but a signed-in + * session). Presented outside its forward, the header is nobody. + * + * The rig is the REAL resolver over a table double (the shape of + * `resolve-execution-context.oauth-organization.test.ts`). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { FORWARD_CREDENTIAL_HEADER, withForwardCredential, type ForwardCredentialBinding } from '@objectstack/rest'; + +import { resolveExecutionContext } from './resolve-execution-context.js'; +import { bindCatalogFromTables } from './security-catalog.testkit.js'; + +const ALPHA = 'org_alpha'; + +function tables(): Record { + return { + sys_api_key: [], + sys_user: [{ id: 'u_mia', email: 'u_mia@example.com' }], + sys_member: [{ user_id: 'u_mia', organization_id: ALPHA, role: 'member' }], + sys_user_position: [], + sys_user_permission_set: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_permission_set: [], + sys_position: [], + sys_position_permission_set: [], + }; +} + +/** Equality, `$in` and a NULL column; every other shape refused loudly. */ +function matchesWhere(row: any, where: any): boolean { + return Object.entries(where ?? {}).every(([field, cond]) => { + if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + if (cond !== null && typeof cond === 'object') { + if (!Array.isArray((cond as any).$in)) throw new Error(`fixture where-matcher: unsupported operator on '${field}'`); + return (cond as any).$in.includes(row[field]); + } + if (cond === null) return (row[field] ?? null) === null; + return row[field] === cond; + }); +} + +function rig() { + const t = tables(); + const ql: any = bindCatalogFromTables({ + registerMiddleware: () => undefined, + getSchema: () => undefined, + async find(object: string, q: any = {}) { + const rows = (t[object] ?? []).filter((r) => matchesWhere(r, q?.where)); + return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + async findOne(object: string, q: any = {}) { + assertEngineFindOnePredicate(object, q); + return (t[object] ?? []).filter((r) => matchesWhere(r, q?.where))[0] ?? null; + }, + }, t); + const getSession = vi.fn(async () => null); + const services: Record = { + objectql: ql, + auth: { api: { getSession }, verifyMcpAccessToken: vi.fn(async () => null) }, + tenancy: { posture: 'isolated' }, + }; + const resolve = (request: any, extra: { acceptOAuthAccessToken?: boolean } = {}) => { + const onAdmission = vi.fn(); + return resolveExecutionContext({ + getService: async (name: string) => services[name], + getQl: async () => ql, + request, + onAdmission, + ...extra, + }).then((ec) => ({ ec: ec as any, onAdmission })); + }; + return { resolve, getSession, services }; +} + +const binding = (over: Partial = {}): ForwardCredentialBinding => ({ + method: 'PATCH', + path: '/api/v1/data/crm_lead/r1', + run: { objectName: 'crm_lead', actionName: 'probe', recordId: 'r1' }, + agent: { userId: 'u_mia', clientId: 'agent-client-1', scopes: ['actions:execute', 'data:write'], organizationId: ALPHA }, + ...over, +}); +const forwarded = (handle: string, over: Record = {}) => ({ + method: 'PATCH', + path: '/api/v1/data/crm_lead/r1', + headers: { host: 'localhost', [FORWARD_CREDENTIAL_HEADER]: handle }, + ...over, +}); + +describe('[ADR-0090 D10 rule 5] the dispatcher resolves a forward credential to the same agent principal', () => { + it('on a route that is not `/mcp`, the redeemed credential is the agent: scopes, delegator, client, the consent\'s organization; no admission reported', async () => { + const { resolve, getSession } = rig(); + + const { ec, onAdmission } = await withForwardCredential(binding(), (h) => resolve(forwarded(h))); + + expect(ec).toMatchObject({ + userId: 'u_mia', + principalKind: 'agent', + onBehalfOf: { userId: 'u_mia', principalKind: 'human' }, + performedBy: { clientId: 'agent-client-1' }, + oauthScopes: ['actions:execute', 'data:write'], + tenantId: ALPHA, + }); + expect(onAdmission).not.toHaveBeenCalled(); + // Nothing was read from a session for it: the forward carries none. + expect(getSession).not.toHaveBeenCalled(); + }); + + it('the same request object resolved twice reads the same agent (the credential is spent once per request, not per read)', async () => { + const { resolve } = rig(); + const [a, b] = await withForwardCredential(binding(), async (h) => { + const req = forwarded(h); + return [await resolve(req), await resolve(req)]; + }); + expect(a.ec.principalKind).toBe('agent'); + expect(b.ec.principalKind).toBe('agent'); + }); + + it('a second request presenting the spent handle, or a request for another method or route, is nobody: a guest', async () => { + const { resolve } = rig(); + const replay = await withForwardCredential(binding(), async (h) => { + await resolve(forwarded(h)); + return resolve(forwarded(h)); + }); + expect(replay.ec.principalKind).toBe('guest'); + + for (const over of [{ method: 'DELETE' }, { path: '/api/v1/data/crm_lead/r2' }]) { + const { ec } = await withForwardCredential(binding(), (h) => resolve(forwarded(h, over))); + expect(ec.principalKind, JSON.stringify(over)).toBe('guest'); + expect(ec.oauthScopes).toBeUndefined(); + } + }); + + it('the header presented outside any forward is nobody, on `/mcp` and off it', async () => { + const { resolve } = rig(); + for (const accept of [false, true]) { + const { ec } = await resolve(forwarded('A'.repeat(43)), { acceptOAuthAccessToken: accept }); + expect(ec.principalKind).toBe('guest'); + expect(ec.oauthScopes).toBeUndefined(); + } + }); + + it('a consent organization the delegator no longer belongs to is dropped, exactly as for a token at the MCP door', async () => { + const { resolve } = rig(); + const { ec } = await withForwardCredential( + binding({ agent: { userId: 'u_mia', clientId: 'agent-client-1', scopes: ['actions:execute'], organizationId: 'org_gone' } }), + (h) => resolve(forwarded(h)), + ); + expect(ec.principalKind).toBe('agent'); + expect(ec.tenantId).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index c1e8c5ba34c..9ca6af118e5 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -28,6 +28,7 @@ import { scopesToAgentPermissionSets, MCP_OAUTH_SCOPE_ACTIONS } from '@objectsta import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import { resolveRequestExecutionContext, type RequestAdmission } from '@objectstack/core'; +import { presentedForwardCredential, redeemForwardCredential, type ForwardAgentProvenance } from '@objectstack/rest'; /** * [#4127 batch 4] The lookup facade, typed by slot like the ones on @@ -118,6 +119,32 @@ function toHeaders(input: any): any { return h; } +/** + * [ADR-0090 D10 rule 5, #28367] The agent a forward credential carried to this + * request, redeemed ONCE per request object: the credential is spent by its + * first presentation, and a door that resolves the same request again must read + * the same agent rather than a spent handle. Keyed weakly, so nothing outlives + * the request. + */ +const forwardAgents = new WeakMap(); + +/** + * The agent provenance this request's forward credential redeems to, or + * `undefined`. Only a request the action door itself injected in-process can + * carry a redeemable one (`@objectstack/rest` `redeemForwardCredential`); a + * request from the network presenting the header resolves as if it carried + * nothing. + */ +function forwardAgentOf(request: any, headers: Headers): ForwardAgentProvenance | undefined { + const presented = presentedForwardCredential(headers); + if (!presented) return undefined; + const key = request && typeof request === 'object' ? request : undefined; + if (key && forwardAgents.has(key)) return forwardAgents.get(key) ?? undefined; + const agent = redeemForwardCredential(presented, String(request?.method ?? ''), String(request?.path ?? request?.url ?? '')); + if (key) forwardAgents.set(key, agent ?? null); + return agent; +} + export async function resolveExecutionContext(opts: ResolveOptions): Promise { const headers = toHeaders(opts.request?.headers); const ql = await opts.getQl(); @@ -136,7 +163,22 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise