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
13 changes: 13 additions & 0 deletions .changeset/28217-api-key-identity-route-forward-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/runtime': patch
---

fix(runtime): an API-key caller of an api action whose target is an identity route is refused by name, before the forward

Clause-②: no (a call that was already refused now says why and is refused before its target is called; no credential is newly accepted anywhere, and no error code, status name, answer member or package export is added)
Metadata-protocol: none

A same-origin `type: 'api'` action is forwarded by the action doors (`POST /actions/:object/:action` and MCP `run_action`) with the caller's own credentials, so its target authorizes the same principal the door admitted. An identity route (under `/api/v1/auth/`, for example the `sys_user` Ban User action's target) reads its caller only from its own signed-in session and admits no API key. So a caller the door admitted by API key reached it as nobody, and got the identity route's anonymous `401` ("Sign in first") although it had authenticated.

- **Now.** Both doors refuse that call before the target is called, with `401` and the standard `UNAUTHENTICATED` code, and a sentence naming the action, its identity endpoint, and that the endpoint accepts a signed-in session only, not an API key. Nothing is run.
- **Unchanged.** Which credentials an identity route accepts. A signed-in session (session token or cookie) is forwarded to it through the door exactly as before, and an API-key caller is still forwarded to every other target (the data door, a declared endpoint) and served.
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 | 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. A caller authenticated with an API key is refused `401` before the forward when the `target` is an identity route (`/api/v1/auth/…`), which reads a signed-in session only | 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
65 changes: 59 additions & 6 deletions packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@
// - the off-origin case: the activation write door refuses to flip it, and
// the action door refuses to run it;
// - MCP: the type gate is open — `run_action` on a same-origin api action
// now reaches the exposure gate instead of "cannot be invoked via MCP".
// now reaches the exposure gate instead of "cannot be invoked via MCP";
// - the API-key lane (#28217): an identity route reads only its own
// signed-in session, so a key caller of `ban_user` is refused BY NAME at
// the door (the target's anonymous "Sign in first" was the defect) and
// nobody is banned, while a key caller of an api action whose target is
// the data door is still forwarded and served.
//
// `OS_SCIM_ENABLED=true` precedes the boot because `/admin/*` 501s without
// better-auth's admin plugin, and that env knob is the one that reaches it
Expand All @@ -56,10 +61,11 @@ const TARGET_PASS = 'Target-Pass-123';
const MEMBER_EMAIL = 'door.forward.member@example.com';

/**
* The one off-origin api action this file needs and no shipped app declares:
* a probe object added to the showcase for this boot only. Its `target` names
* a host the platform never sees, which is exactly the case the ruling keeps
* browser-only.
* The two api actions this file needs and no shipped app declares, on a probe
* object added to the showcase for this boot only. `ping_remote`'s `target`
* names a host the platform never sees, which is exactly the case the ruling
* keeps browser-only. `rename_probe` targets the data door, which admits an
* API key: the API-key lane's control against the identity-route refusal.
*/
const RemoteProbe = ObjectSchema.create({
name: 'probe_remote_thing',
Expand All @@ -74,6 +80,15 @@ const RemoteProbe = ObjectSchema.create({
target: 'https://example.invalid/ping',
locations: ['record_header'],
},
{
name: 'rename_probe',
label: 'Rename Probe',
type: 'api',
method: 'PATCH',
target: '/api/v1/data/probe_remote_thing/${ctx.recordId}',
bodyExtra: { name: 'renamed-through-the-door' },
locations: ['record_header'],
},
],
} as any);

Expand All @@ -87,14 +102,15 @@ describe('[#27506] a same-origin api action is dispatched through the action doo
let priorScim: string | undefined;

const call = async (
auth: { token?: string; cookie?: string } | null,
auth: { token?: string; cookie?: string; key?: string } | null,
method: string,
path: string,
body?: unknown,
): Promise<Answer> => {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (auth?.token) headers.Authorization = `Bearer ${auth.token}`;
if (auth?.cookie) headers.Cookie = auth.cookie;
if (auth?.key) headers['x-api-key'] = auth.key;
const res = await stack.api(path, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
const text = await res.text();
let json: any;
Expand Down Expand Up @@ -222,6 +238,43 @@ describe('[#27506] a same-origin api action is dispatched through the action doo
expect(anonymous.status, anonymous.text).toBe(401);
}, 120_000);

it('API-key lane: ban_user is refused by name at the door, before its identity route is called, and nobody is banned', async () => {
// `ban_user` was switched back on above, and the admin's session lane is
// served (that case). The key is the SAME admin's.
const minted = await call({ token: admin }, 'POST', '/keys', { name: 'door-forward-key' });
expect(minted.status, minted.text).toBe(201);
const key = String(minted.json?.data?.key);

const refused = await call({ key }, 'POST', '/actions/sys_user/ban_user', {
recordId: targetUserId, params: { banReason: 'dogfood-key' },
});

expect(refused.status, refused.text).toBe(401);
expect(refused.json?.error?.code).toBe('UNAUTHENTICATED');
const message = String(refused.json?.error?.message);
// The door's sentence, not the identity route's anonymous "Sign in first".
expect(message).toContain("Action 'ban_user' forwards to an identity endpoint ('/api/v1/auth/admin/ban-user')");
expect(message).toContain('accepts a signed-in session only');
expect(message).toContain('authenticated with an API key');
expect(message).not.toContain('Sign in first');
expect(await isBanned()).toBe(false);
}, 120_000);

it('API-key lane: an api action whose target is the data door is still forwarded with the key and served', async () => {
const minted = await call({ token: admin }, 'POST', '/keys', { name: 'door-forward-data-key' });
expect(minted.status, minted.text).toBe(201);
const key = String(minted.json?.data?.key);
const created = await call({ token: admin }, 'POST', '/data/probe_remote_thing', { name: 'before' });
expect(created.status, created.text).toBe(201);
const id = String(created.json?.data?.id ?? created.json?.id);

const res = await call({ key }, 'POST', '/actions/probe_remote_thing/rename_probe', { recordId: id });

expect(res.status, res.text).toBe(200);
const [row] = rowsOf(await ql.find('probe_remote_thing', { where: { id }, limit: 1 }, { context: SYS }));
expect(row?.name).toBe('renamed-through-the-door');
}, 120_000);

it('off-origin: the write door refuses to switch `ping_remote`, and the action door refuses to run it', async () => {
const flipAttempt = await call({ token: admin }, 'POST', activation('probe_remote_thing', 'ping_remote'), { enabled: false });
expect(flipAttempt.status, flipAttempt.text).toBe(409);
Expand Down
56 changes: 55 additions & 1 deletion packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1396,7 +1396,7 @@ export function buildApiActionRequest(
body[recordIdParam] = value;
}
}
if (/\/api\/v1\/auth\//.test(path) && body.organizationId === undefined && organizationId) {
if (isIdentityRouteTarget(path) && body.organizationId === undefined && organizationId) {
body.organizationId = organizationId;
}
if (action?.bodyExtra && typeof action.bodyExtra === 'object' && !Array.isArray(action.bodyExtra)) {
Expand All @@ -1408,6 +1408,40 @@ export function buildApiActionRequest(
return method === 'GET' || method === 'DELETE' ? { method, path } : { method, path, body };
}

/**
* Is this forward `path` an identity route — one under the auth mount
* (`/api/v1/auth/…`)? That mount reads its caller from its OWN signed-in
* session (a session bearer or cookie) and admits no API key, so a principal
* the door admitted by key reaches it as nobody. Read once, here, for the two
* rules that depend on it: the `organizationId` stamp in
* {@link buildApiActionRequest} and the key refusal in {@link forwardApiAction}.
*/
function isIdentityRouteTarget(path: string): boolean {
return /\/api\/v1\/auth\//.test(path);
}

/**
* The refusal a door answers, before any forward, when an API-key caller runs
* an action whose `target` is an identity route ({@link isIdentityRouteTarget}).
*
* The forward carries the caller's own credentials so the target authorizes
* the SAME principal the door admitted. An identity route cannot read a key,
* so it would authorize no one: the request would leave the door and come
* back as the target's anonymous 401 ("Sign in first"), telling a caller who
* did authenticate to sign in. This says what actually happened. Which
* credentials the identity route accepts is unchanged: a signed-in session
* still reaches it through the door exactly as before, and a key still
* reaches every other target.
*/
export function apiActionIdentityRouteKeyRefusal(action: any, target: string): string {
return (
`Action '${action?.name ?? 'unknown'}' forwards to an identity endpoint ('${target}') that accepts a ` +
`signed-in session only, and this request was authenticated with an API key, which that endpoint ` +
`cannot read. The action was not run. Run it from a signed-in session (a session token or the ` +
`Console's sign-in cookie) instead of an API key.`
);
}

/** What the in-process forward answered — status, parsed JSON body, relayed headers. */
export type ApiActionForwardAnswer = {
status: number;
Expand Down Expand Up @@ -1516,6 +1550,8 @@ export function apiActionForwardLoopError(action: any, mark: string): string {
* ({@link API_FORWARD_HEADER_ALLOWLIST}); `Content-Length` / `Host` are the
* injector's to set. The forward is marked ({@link API_FORWARD_MARK_HEADER})
* so a door reached by it refuses instead of looping.
* One caller is never forwarded: an API-key caller of an identity route,
* which cannot read a key ({@link apiActionIdentityRouteKeyRefusal}).
*
* Relayed back: the status, the JSON body, and the credential-rotation
* headers a target like `/admin/impersonate-user` emits — `set-auth-token` /
Expand All @@ -1532,6 +1568,24 @@ export async function forwardApiAction(
req: ApiActionRequest,
mark: { objectName: string; actionName: string },
): Promise<ApiActionForwardAnswer> {
// An API-key caller cannot be carried to an identity route (see
// {@link apiActionIdentityRouteKeyRefusal}): 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.
if (requestContext?.admission?.credential === 'api_key' && isIdentityRouteTarget(req.path)) {
return {
status: 401,
body: {
success: false,
error: {
code: standardErrorCodeForHttpStatus(401),
message: apiActionIdentityRouteKeyRefusal({ name: mark.actionName }, req.path.split('?')[0]!),
},
},
headers: {},
};
}
const incoming: Record<string, unknown> =
requestContext?.request?.headers && typeof requestContext.request.headers === 'object'
? requestContext.request.headers
Expand Down
91 changes: 91 additions & 0 deletions packages/runtime/src/domains/api-action-forward.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ import {
isHeadlessInvokableAction,
headlessActionTypeError,
offOriginApiActivationRefusal,
apiActionIdentityRouteKeyRefusal,
API_FORWARD_MARK_HEADER,
} from '../action-execution.js';
import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
import { ActionActivationProjection } from '@objectstack/objectql';

const OBJECT = 'crm_lead';
Expand Down Expand Up @@ -720,3 +722,92 @@ describe('buildApiActionRequest — the declaration\'s request, by the console\'
expect((explicit as any).body).toEqual({ organizationId: 'org_other' });
});
});

// [#28217] An identity route (the auth mount) reads its caller from its own
// signed-in session and admits no API key, so an API-key caller forwarded to it
// arrived as nobody and came back as the target's anonymous 401. The doors now
// refuse that one combination by name, before the target is called; which
// credentials the identity route accepts is unchanged, so the session lane is
// forwarded exactly as before and a key still reaches every other target.
describe('[#28217] an API-key caller is refused by name before a forward to an identity route', () => {
/** A caller the door admitted by API key: the key header, no session bearer or cookie. */
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 runViaMcp = async (h: ReturnType<typeof boot>, ctx: HttpProtocolContext, name: string, input: any = {}) => {
const deps: any = {
resolveService: async (_ctx: unknown, service: string) =>
service === 'metadata' ? h.metadata
: service === 'automation' ? null
: service === 'http.server' ? { getRawApp: () => ({ request: h.request }) }
: h.ql,
getObjectQL: async () => h.ql,
};
return invokeBusinessAction(deps, ctx as any, name, input, {
driver: h.ql,
ec: { userId: 'u_member', tenantId: 'org_1', systemPermissions: [] },
getMeta: () => h.metadata,
callData: async (_a: string, p: any) => ({ object: p?.object, id: p?.id, record: { id: p?.id } }),
});
};

it('REST: a key-admitted caller of an identity-route action gets the named 401 and the target is never called', async () => {
const { dispatcher, request } = boot();

const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, KEY_CTX());

expect(statusOf(res)).toBe(401);
expect(codeOf(res)).toBe(standardErrorCodeForHttpStatus(401));
expect(messageOf(res)).toBe(apiActionIdentityRouteKeyRefusal(BAN_LEAD, BAN_LEAD.target));
expect(request).not.toHaveBeenCalled();
});

it('REST: the same action for a session-admitted caller is forwarded exactly as before', async () => {
const { dispatcher, injected } = boot({ answer: { body: { success: true, data: { banned: true } } } });

const res = await dispatcher.handleActions(`/${OBJECT}/${BAN_LEAD.name}`, 'POST', { recordId: 'u_target' }, SESSION_CTX());

expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200);
expect(injected).toHaveLength(1);
expect(headersOf(injected[0].init).authorization).toBe('Bearer tok_member');
});

it('REST: a key-admitted caller of a NON-identity target is still forwarded, the key carried across', async () => {
const { dispatcher, injected } = boot();

const res = await dispatcher.handleActions(`/${OBJECT}/${DELETE_LEAD.name}`, 'POST', { recordId: 'lead 7' }, KEY_CTX());

expect(statusOf(res), JSON.stringify(res?.response?.body)).toBe(200);
expect(injected).toHaveLength(1);
expect(injected[0].url).toBe('http://app.example.test/api/v1/data/crm_lead/lead%207');
expect(headersOf(injected[0].init)['x-api-key']).toBe('osk_member_key');
});

it('MCP `run_action`: the key-admitted caller gets the same refusal on the throw, and nothing is forwarded', async () => {
const h = boot();

const thrown = await runViaMcp(h, KEY_CTX(), BAN_LEAD.name, { recordId: 'u_target' }).catch((e) => e);

expect(thrown).toBeInstanceOf(Error);
expect((thrown as any).status).toBe(401);
expect((thrown as any).code).toBe(standardErrorCodeForHttpStatus(401));
expect(String(thrown.message)).toBe(apiActionIdentityRouteKeyRefusal(BAN_LEAD, BAN_LEAD.target));
expect(h.request).not.toHaveBeenCalled();
});

it('MCP `run_action`: a key-admitted caller of a non-identity target is forwarded', async () => {
const h = boot({ answer: { body: { success: true, data: { recalculated: 1 } } } });

const result: any = await runViaMcp(h, KEY_CTX(), RECALC_LEAD.name, { recordId: 'u_target' });

expect(result.ok).toBe(true);
expect(result.result).toEqual({ recalculated: 1 });
expect(h.injected[0].url).toBe('http://app.example.test/api/v1/apps/showcase/recalc');
});
});
Loading