From 8bfe4a1c3f677ac9bcb218f1d8aa723b662d428a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:49:39 +0000 Subject: [PATCH 1/2] fix(runtime): an API-key caller of an api action that forwards to an identity route is refused by name before the forward Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d1MkJvadehWmrLUUT1 --- .../api-action-door-forward.dogfood.test.ts | 65 +++++++++++-- packages/runtime/src/action-execution.ts | 54 ++++++++++- .../src/domains/api-action-forward.test.ts | 91 +++++++++++++++++++ 3 files changed, 203 insertions(+), 7 deletions(-) diff --git a/packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts b/packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts index b628554cdeb..ec7ef8bfb85 100644 --- a/packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts +++ b/packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts @@ -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 @@ -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', @@ -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); @@ -87,7 +102,7 @@ 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, @@ -95,6 +110,7 @@ describe('[#27506] a same-origin api action is dispatched through the action doo const headers: Record = { '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; @@ -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); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 8591efd8e2e..4cdbcc691e0 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -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)) { @@ -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; @@ -1532,6 +1566,24 @@ export async function forwardApiAction( req: ApiActionRequest, mark: { objectName: string; actionName: string }, ): Promise { + // 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 = requestContext?.request?.headers && typeof requestContext.request.headers === 'object' ? requestContext.request.headers diff --git a/packages/runtime/src/domains/api-action-forward.test.ts b/packages/runtime/src/domains/api-action-forward.test.ts index 608818f4223..7b28098dd4e 100644 --- a/packages/runtime/src/domains/api-action-forward.test.ts +++ b/packages/runtime/src/domains/api-action-forward.test.ts @@ -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'; @@ -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, 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'); + }); +}); From cf29292bf3ffa037981a8b10886843e113f72734 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:00:51 +0000 Subject: [PATCH 2/2] docs(runtime): say where the API-key identity-route refusal lives, and changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4d1MkJvadehWmrLUUT1 --- .../28217-api-key-identity-route-forward-refusal.md | 13 +++++++++++++ content/docs/ai/actions-as-tools.mdx | 2 +- packages/runtime/src/action-execution.ts | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .changeset/28217-api-key-identity-route-forward-refusal.md diff --git a/.changeset/28217-api-key-identity-route-forward-refusal.md b/.changeset/28217-api-key-identity-route-forward-refusal.md new file mode 100644 index 00000000000..5c82cf49c0b --- /dev/null +++ b/.changeset/28217-api-key-identity-route-forward-refusal.md @@ -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. diff --git a/content/docs/ai/actions-as-tools.mdx b/content/docs/ai/actions-as-tools.mdx index c625069e3f9..04f6eac2d9b 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 | 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. diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 4cdbcc691e0..2dab20c473a 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -1550,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` /