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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/28367-agent-forward-credential.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/ai/actions-as-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 9 additions & 6 deletions docs/adr/0090-permission-model-v2-concept-convergence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions packages/plugins/plugin-auth/src/agent-forward-identity-view.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) => ({
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<string, unknown> | 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);
});
});
Loading
Loading