diff --git a/.changeset/thick-pumas-judge.md b/.changeset/thick-pumas-judge.md new file mode 100644 index 0000000000..d3ac05ad01 --- /dev/null +++ b/.changeset/thick-pumas-judge.md @@ -0,0 +1,39 @@ +--- +"@objectstack/runtime": patch +--- + +Deny anonymous callers on the `/actions` and `/automation` dispatch routes (#5519) + +`@objectstack/rest`'s `/data` and the dispatcher's `/meta`, `/ai` and `/security` +have answered an unauthenticated caller 401 `UNAUTHENTICATED` since #3963 made +"anonymous access is always denied" a platform promise (the `api.requireAuth` +opt-out is a tombstone). The dispatcher's own `/actions/*` and `/automation/*` +routes — mounted by `dispatcher-plugin.ts` onto the host server, a different +registration path from the REST one — carried no anonymity check at all. + +`/actions` was the expensive half: a `script` action's body executes with +`isSystem: true` forced on (`buildActionExecutionContext`), so an +unauthenticated POST bought an RLS/FLS-bypassing SYSTEM write. The only gate +ahead of it was ADR-0066 D4's `requiredPermissions`, which allows every action +that declares none — i.e. most authored actions. On `/automation`, anonymous +callers could trigger a flow run, list every flow, register one, and +unregister one. + +Both domains now call the shared `shouldDenyAnonymous` decision before anything +dispatches, returning the same 401 envelope every other seam returns. Finer +authorization is unchanged and still runs for callers who clear the floor — +`requiredPermissions` (ADR-0066 D4), `ai.exposed`, the ADR-0104 param contract. + +**What passes unchanged:** any authenticated caller (session, API key or OAuth +principal), and internal `isSystem` contexts. CORS preflight (`OPTIONS`) is +exempt as always. Internal dispatch paths never enter these HTTP handlers and +are untouched — the MCP `run_action` bridge, the declarative endpoint executor +(a `type: 'flow'` endpoint keeps its own `authRequired` gate, so an explicit +`authRequired: false` endpoint stays public), and engine-internal record-change +and schedule triggers. + +**Behaviour change to expect:** an unauthenticated call that previously got 200 +(or 403 on a `requiredPermissions` action, or 405/501) now gets 401. If a +deployment relied on unauthenticated action or flow invocation, the supported +replacement is a declared endpoint with `authRequired: false`, a public-form +grant, or a share-link token — never an anonymous `/actions` POST. diff --git a/packages/runtime/src/action-body-identity.test.ts b/packages/runtime/src/action-body-identity.test.ts index a34daa00d0..f489280233 100644 --- a/packages/runtime/src/action-body-identity.test.ts +++ b/packages/runtime/src/action-body-identity.test.ts @@ -217,12 +217,26 @@ describe('#3914 — REST /actions dispatch binds ctx.api and ctx.engine', () => }); }); - it('still elevates for an anonymous / self-invoked call', async () => { - const { dispatcher, executeAction, ql, ctx } = makeDispatcher(undefined); + it('still elevates for a SELF-INVOKED call — and the anonymous half is 401 now (#5519)', async () => { + // REPLACED, not re-spelled. Driven with NO execution context this used + // to be the "anonymous" case; #5519 puts the platform anonymous-deny + // baseline in front of `/actions`, so an anonymous POST never reaches + // the body and `executeAction.mock.calls[0]` would be `undefined` — + // the assertions below would have been reading nothing. + // + // The elevation claim survives intact for the caller that can still + // get here without a `userId`: a self-invoked `isSystem` context. + const { dispatcher, executeAction, ql, ctx } = makeDispatcher({ isSystem: true }); await dispatcher.handleActions('/crm_case/close_case', 'POST', {}, ctx); const actionCtx = executeAction.mock.calls[0]?.[2]; await actionCtx.engine.update('crm_case', 'case_1', { status: 'closed' }); expect(ql.writes.find((w: any) => w.op === 'update').context).toMatchObject({ isSystem: true }); + + // The anonymous door is shut — stated, not implied. + const anon = makeDispatcher(undefined); + const denied: any = await anon.dispatcher.handleActions('/crm_case/close_case', 'POST', {}, anon.ctx); + expect(denied.response.status).toBe(401); + expect(anon.executeAction).not.toHaveBeenCalled(); }); }); diff --git a/packages/runtime/src/action-ctx-user-shape.test.ts b/packages/runtime/src/action-ctx-user-shape.test.ts index f0b4996093..31de0fb915 100644 --- a/packages/runtime/src/action-ctx-user-shape.test.ts +++ b/packages/runtime/src/action-ctx-user-shape.test.ts @@ -207,8 +207,16 @@ describe('#5372 — the VALUE, other direction: name === id iff there is no disp expect(actionCtx.user.name).toBe('usr_admin'); }); - it('an anonymous / self-invoked dispatch is the `system` principal, unchanged (#2701)', async () => { - const { actionCtx } = await dispatchRest(undefined, makeQl(DEV_ADMIN)); + it('a SELF-INVOKED dispatch is the `system` principal, unchanged (#2701); anonymous is 401 (#5519)', async () => { + // REPLACED, not re-spelled: driven with `undefined` this was the + // ANONYMOUS shape, and #5519 denies that at the door — `executeAction` + // is never called, so `actionCtx` would be `undefined` and every + // assertion below would read off nothing. + // + // The `system`-principal shape #2701 pinned is still real for the + // caller that reaches the body without a `userId`: the self-invoked + // `isSystem` context. + const { actionCtx } = await dispatchRest({ isSystem: true }, makeQl(DEV_ADMIN)); expect(actionCtx.user.id).toBe('system'); expect(actionCtx.user.name).toBe('system'); @@ -217,6 +225,14 @@ describe('#5372 — the VALUE, other direction: name === id iff there is no disp expect(actionCtx.user.permissions).toEqual([]); expect(actionCtx.user.systemPermissions).toEqual([]); }); + + it('a genuinely ANONYMOUS dispatch never reaches the body at all (#5519)', async () => { + const ql = makeQl(DEV_ADMIN); + const { response } = await dispatchRest(undefined, ql); + + expect(response.status).toBe(401); + expect(ql.executeAction).not.toHaveBeenCalled(); + }); }); describe('#5372 — the FAILURE MODE: an unresolvable name is quiet', () => { diff --git a/packages/runtime/src/dispatcher-plugin.anonymous-gate.integration.test.ts b/packages/runtime/src/dispatcher-plugin.anonymous-gate.integration.test.ts new file mode 100644 index 0000000000..37d8e82aac --- /dev/null +++ b/packages/runtime/src/dispatcher-plugin.anonymous-gate.integration.test.ts @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5519 — the `/actions` and `/automation` anonymous baseline, through a REAL + * boot and over a REAL socket. + * + * `domains/anonymous-gate-actions-automation.test.ts` pins the DECISION (which + * caller the handler denies, and that nothing dispatches behind it). This file + * pins the WIRING, and the distinction is the whole reason it exists: the gate + * lives in the domain handler, but the routes are mounted by + * `dispatcher-plugin.ts` straight onto the host `IHttpServer` — a separate + * registration path from the one `@objectstack/rest` uses for `/data`, and + * precisely the seam whose divergence #5519 is about. A unit test that calls + * `handleActions()` directly cannot tell you that the MOUNTED route reaches the + * gated handler; only a socket can. AGENTS.md states the rule flatly: "who + * serves this path" is a question about the composed, provisioned runtime — + * boot it or do not claim an answer. #3913 is the standing proof, where + * `POST /actions//:action` was correct in the domain and unreachable on the + * wire for exactly this reason. + * + * The pre-fix behaviour these replace, measured on a real showcase boot: + * POST /actions/showcase_task/showcase_mark_done/:id → 200 {ok:true} + * POST /automation/showcase_reassign_wizard/trigger → 200 {runId: run_…} + * GET /automation → 200 (full inventory) + * DELETE /automation/showcase_inquiry_janitor → 200 {deleted:true} + * …all with no credential of any kind, while `/data` on the same process + * answered 401. + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { LiteKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +const SESSION_HEADER = 'x-test-session'; + +const executeAction = vi.fn(async () => ({ ok: true, wrote: 'system-elevated' })); +const automationExecute = vi.fn(async () => ({ success: true, status: 'paused', runId: 'run_1' })); +const unregisterFlow = vi.fn(); +const listFlows = vi.fn(async () => ['crm_escalation_flow']); + +/** One `script` action, declared on the object and carrying NO `requiredPermissions`. */ +const scriptAction = { + name: 'mark_primary', + objectName: 'crm_contact', + type: 'script', + body: { language: 'js', source: 'return { ok: true };', capabilities: ['api.write'] }, +}; +const objectDef = { name: 'crm_contact', actions: [scriptAction] }; + +/** + * `auth` slot in the shape `resolveExecutionContext` actually reads + * (`authService.api.getSession({ headers })`). It answers a session only when + * the request carries `x-test-session`, so ONE boot serves both the anonymous + * and the authenticated case and the difference on the wire is a header — + * which is exactly the difference the gate is supposed to key off. + */ +function servicesPlugin(): Plugin { + return { + name: 'com.objectstack.test.services-5519', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('objectql', { + executeAction, + getSchema: (n: string) => (n === objectDef.name ? objectDef : undefined), + registry: { getObject: (n: string) => (n === objectDef.name ? objectDef : undefined), getItem: () => undefined }, + find: async () => [], + insert: async () => ({}), update: async () => ({}), delete: async () => ({}), + }); + ctx.registerService('automation', { + execute: automationExecute, + unregisterFlow, + listFlows, + registerFlow: () => { /* unused */ }, + handlerReady: true, + }); + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: any) => + (headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_socket' } } : undefined), + }, + }); + }, + }; +} + +async function boot() { + const kernel = new LiteKernel(); + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(servicesPlugin()); + kernel.use(createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false })); + await kernel.bootstrap(); + const httpServer = kernel.getService('http.server'); + return { kernel, baseUrl: `http://127.0.0.1:${httpServer.getPort!()}` }; +} + +describe('#5519 — the mounted /actions and /automation routes deny anonymous callers on the wire', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { ({ kernel, baseUrl } = await boot()); }, 60_000); + afterAll(async () => { + await Promise.race([kernel?.shutdown(), new Promise((r) => setTimeout(r, 10_000))]); + }, 30_000); + + const post = (path: string, body: unknown, session = false) => + fetch(`${baseUrl}/api/v1${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...(session ? { [SESSION_HEADER]: '1' } : {}) }, + body: JSON.stringify(body), + }); + + // ── /actions ──────────────────────────────────────────────────────────── + + it('anonymous POST /api/v1/actions/:object/:action/:recordId → 401, body never runs', async () => { + executeAction.mockClear(); + const res = await post('/actions/crm_contact/mark_primary/c1', { params: {} }); + + expect(res.status).toBe(401); + const body: any = await res.json(); + expect(body?.error?.code ?? body?.error?.details?.code).toBe('UNAUTHENTICATED'); + expect(body?.error?.message).toBe('Authentication is required to access this endpoint.'); + // The script body would have run system-elevated. It did not. + expect(executeAction).not.toHaveBeenCalled(); + }, 60_000); + + it('anonymous POST /api/v1/actions/:object/:action (no recordId) → 401', async () => { + executeAction.mockClear(); + const res = await post('/actions/crm_contact/mark_primary', {}); + + expect(res.status).toBe(401); + expect(executeAction).not.toHaveBeenCalled(); + }, 60_000); + + it('the SAME request with a session is served — the deny targets anonymity, not the route', async () => { + executeAction.mockClear(); + const res = await post('/actions/crm_contact/mark_primary/c1', { params: {} }, true); + + expect(res.status).toBe(200); + expect((await res.json())?.data).toMatchObject({ ok: true }); + expect(executeAction).toHaveBeenCalledTimes(1); + }, 60_000); + + // ── /automation ───────────────────────────────────────────────────────── + + it('anonymous POST /api/v1/automation/:name/trigger → 401, no run started', async () => { + automationExecute.mockClear(); + const res = await post('/automation/crm_escalation_flow/trigger', { recordId: 'c1' }); + + expect(res.status).toBe(401); + expect(automationExecute).not.toHaveBeenCalled(); + }, 60_000); + + it('anonymous POST /api/v1/automation/trigger/:name (the legacy SDK shape) → 401', async () => { + automationExecute.mockClear(); + const res = await post('/automation/trigger/crm_escalation_flow', { recordId: 'c1' }); + + expect(res.status).toBe(401); + expect(automationExecute).not.toHaveBeenCalled(); + }, 60_000); + + it('anonymous GET /api/v1/automation → 401, the flow inventory stays private', async () => { + listFlows.mockClear(); + const res = await fetch(`${baseUrl}/api/v1/automation`); + + expect(res.status).toBe(401); + expect(listFlows).not.toHaveBeenCalled(); + }, 60_000); + + it('anonymous DELETE /api/v1/automation/:name → 401 — the destructive one', async () => { + unregisterFlow.mockClear(); + const res = await fetch(`${baseUrl}/api/v1/automation/crm_escalation_flow`, { method: 'DELETE' }); + + expect(res.status).toBe(401); + expect(unregisterFlow).not.toHaveBeenCalled(); + }, 60_000); + + it('the same trigger with a session is served', async () => { + automationExecute.mockClear(); + const res = await post('/automation/crm_escalation_flow/trigger', { recordId: 'c1' }, true); + + expect(res.status).toBe(200); + expect(automationExecute).toHaveBeenCalledTimes(1); + // Identity forwarding survives the gate — a `runAs: 'user'` flow still + // learns who triggered it (#4127). + expect(automationExecute.mock.calls[0]?.[1]).toMatchObject({ userId: 'u_socket' }); + }, 60_000); + + // ── one answer, one shape ─────────────────────────────────────────────── + + it('both newly-gated domains answer in the IDENTICAL envelope, byte for byte', async () => { + // The cross-surface contrast that made this a p0 — `/data` answering + // 401 while `/actions` answered 200 in the SAME process — is not + // provable on this boot: `@objectstack/rest` owns `/data` and `/meta` + // and the dispatcher plugin mounts neither, so there is no second + // surface here to compare against. It was measured instead on a real + // showcase boot (recorded in the PR body), and asserting a lookalike + // here would be a weaker claim wearing the stronger one's clothes. + // + // What THIS boot can prove is the half that would actually regress + // unnoticed: the two domains gated by this change share one envelope + // and cannot drift into two dialects of "unauthenticated". + const fromActions = await (await post('/actions/crm_contact/mark_primary/c1', {})).json(); + const fromAutomation = await (await post('/automation/crm_escalation_flow/trigger', {})).json(); + + expect(fromActions).toEqual(fromAutomation); + expect((fromActions as any)?.error?.code ?? (fromActions as any)?.error?.details?.code).toBe('UNAUTHENTICATED'); + }, 60_000); +}); diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index c8b996744b..ae65c94527 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -540,9 +540,19 @@ describe('HttpDispatcher extracted domains (PR-5: packages)', () => { // --------------------------------------------------------------------------- describe('HttpDispatcher extracted domains (PR-6: automation)', () => { + /** + * [#5519] `/automation` stands on the platform anonymous-deny baseline now. + * The cases below go through the REAL `dispatch()`, which re-resolves + * identity off the mock kernel, so an `auth` slot that answers with a + * session is what keeps each of them testing ROUTING (which service method + * a path reaches) instead of quietly re-testing the auth floor. Anonymity + * itself is pinned in `domains/anonymous-gate-actions-automation.test.ts`. + */ + const auth = { api: { getSession: async () => ({ user: { id: 'u_test' } }) } }; + it('GET /automation lists flows via the automation service', async () => { const automation = { listFlows: vi.fn().mockResolvedValue(['flow-a', 'flow-b']) }; - const result = await makeDispatcher({ automation }).dispatch('GET', '/automation', undefined, {}, {} as any); + const result = await makeDispatcher({ automation, auth }).dispatch('GET', '/automation', undefined, {}, {} as any); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.total).toBe(2); }); @@ -556,7 +566,7 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { { name: 'a2', source: 'plugin', paradigms: ['workflow'] }, ]), }; - const result = await makeDispatcher({ automation }).dispatch('GET', '/automation/actions', undefined, { source: 'plugin' }, {} as any); + const result = await makeDispatcher({ automation, auth }).dispatch('GET', '/automation/actions', undefined, { source: 'plugin' }, {} as any); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.actions).toHaveLength(1); // The /:name→getFlow catch-all must NOT have shadowed the guard route. @@ -631,7 +641,7 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { const execute = vi.fn().mockResolvedValue({ success: true }); const automation = { trigger, execute, listFlows: vi.fn(), getFlow: vi.fn() }; - const result = await makeDispatcher({ automation }) + const result = await makeDispatcher({ automation, auth }) .dispatch('POST', '/automation/trigger/nurture', {}, {}, {} as any); expect(result.response?.status).toBe(200); @@ -653,7 +663,7 @@ describe('HttpDispatcher extracted domains (PR-6: automation)', () => { { name: 'nurture', enabled: true, bound: false, status: 'active', triggerType: 'on_create', object: 'sales_lead' }, ]), }; - const result = await makeDispatcher({ automation }).dispatch('GET', '/automation/_status', undefined, {}, {} as any); + const result = await makeDispatcher({ automation, auth }).dispatch('GET', '/automation/_status', undefined, {}, {} as any); expect(result.response?.status).toBe(200); expect(result.response?.body?.data?.flows?.[0]).toEqual({ name: 'nurture', enabled: true, bound: false, diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index a81605a113..26aac07e0d 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -48,6 +48,9 @@ * did it return? 200, `data` = handler return value */ +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; import * as actionExec from '../action-execution.js'; import { actorUserFromExecutionContext, resolveActorDisplayName } from '../security/actor-user.js'; import { validationFailureDetails } from '../validation-failure.js'; @@ -96,6 +99,40 @@ export function createActionsDomain(deps: DomainHandlerDeps): DomainRoute { * above is a script-BODY property only. */ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, _context: HttpProtocolContext): Promise { + // [#5519] ANONYMOUS BASELINE — first, before anything dispatches. + // + // ADR-0056 D2 / #3963 made "anonymous access is always denied" a platform + // promise, and `/data`, `/meta`, `/ai` and `/security` each honour it with + // this one shared decision. `/actions` did not, and it is the surface where + // the omission costs most: a `script` action's body runs with + // `buildActionExecutionContext` forcing `isSystem: true`, so an + // unauthenticated POST bought an RLS/FLS-bypassing SYSTEM write. The only + // gate ahead of it was ADR-0066 D4's `actionPermissionError`, which returns + // `null` — allow — for every action that declares no `requiredPermissions`, + // i.e. for the overwhelming majority of authored actions. + // + // Deliberately the FIRST statement, ahead of the 405: an anonymous caller + // learns the auth baseline and nothing about the route's shape, exactly as + // `/data` answers. The finer-grained gates below are unchanged and still + // run for everyone who clears this one — this adds a floor, it does not + // replace `requiredPermissions`. + // + // Who still passes: any resolved `userId` (a session, an API key, an OAuth + // principal — `resolveExecutionContext` writes them all), and any internal + // `isSystem` context. `isSystem` is never settable from the wire; internal + // callers (flow `call action` nodes, the MCP `run_action` bridge, the + // declarative endpoint executor) do not route through this HTTP handler at + // all — they reach `action-execution.ts` / the automation service directly, + // so this gate cannot see them. + { + const gateEc: any = _context?.executionContext; + if (shouldDenyAnonymous({ userId: gateEc?.userId, isSystem: gateEc?.isSystem, method })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + } if (method.toUpperCase() !== 'POST') { return { handled: true, response: deps.error('Method not allowed', 405) }; } diff --git a/packages/runtime/src/domains/anonymous-gate-actions-automation.test.ts b/packages/runtime/src/domains/anonymous-gate-actions-automation.test.ts new file mode 100644 index 0000000000..9036509a9c --- /dev/null +++ b/packages/runtime/src/domains/anonymous-gate-actions-automation.test.ts @@ -0,0 +1,411 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5519 — the `/actions` and `/automation` dispatch domains join the platform's + * anonymous-deny baseline (ADR-0056 D2 → #3963). + * + * The gap this pins closed: in ONE process, `@objectstack/rest`'s `/data` and + * the dispatcher's `/meta`, `/ai`, `/security` all answered an anonymous caller + * 401 `UNAUTHENTICATED`, while the dispatcher's own `/actions/*` and + * `/automation/*` had no anonymity check at all. `/actions` was the expensive + * one: `buildActionExecutionContext` forces `isSystem: true` on a `script` + * action's body, so an unauthenticated POST bought an RLS/FLS-bypassing SYSTEM + * write. The only gate ahead of it was ADR-0066 D4's `actionPermissionError`, + * which returns "allow" for every action that declares no + * `requiredPermissions` — i.e. for most authored actions. + * + * Verified on a real showcase boot before the fix: anonymous + * `POST /actions/showcase_task/showcase_mark_done/:id` answered 200 + * `{ok: true}` (the body's `ctx.api…update()` ran), anonymous + * `POST /automation/showcase_reassign_wizard/trigger` answered 200 with a live + * `runId`, anonymous `GET /automation` returned the full flow inventory, and + * anonymous `DELETE /automation/showcase_inquiry_janitor` answered 200 + * `{deleted: true}` — an unauthenticated caller unregistering a flow, which the + * issue had not even claimed. + * + * ## What is NOT gated here, and why that is correct + * + * The gate sits on the HTTP DOMAIN HANDLERS only. Every internal dispatch path + * reaches the action/flow machinery WITHOUT passing through them, so none of + * them can be broken by this change — a mapping this file pins from the other + * side rather than asserting in prose: + * + * - the MCP `run_action` bridge → `action-execution.invokeBusinessAction` + * (`domains/mcp.ts` wires it directly); + * - the declarative endpoint executor (#5040 E5) → `buildAutomationContext` + + * `IAutomationService.execute`, mounted in the transport's fallback seam + * that `dispatch()` deliberately never sees, with its OWN `authRequired` + * policy gate (`endpoint-policy.ts` ②) that an `authRequired: false` + * declaration legitimately opens; + * - internal engine triggers (record-change, schedule) never speak HTTP. + * + * And on the seam itself, a SYSTEM context (`isSystem: true`, never settable + * from the wire) passes untouched — so a host that dispatches internally + * through the domain handler is unaffected too. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import * as actionExec from '../action-execution.js'; +import { buildAutomationContext } from './automation.js'; +import { applyEndpointPolicies, createEndpointRateLimiterRegistry } from '../endpoint-policy.js'; +import { ApiEndpointSchema } from '@objectstack/spec/api'; + +// ── contexts ──────────────────────────────────────────────────────────────── +// `resolveExecutionContext` leaves `executionContext` UNDEFINED when identity +// resolution throws and writes `{ isSystem: false, positions: [], … }` with no +// `userId` for a resolved-but-sessionless caller. Both are anonymous; both must +// be denied, so both shapes are exercised. +const anonUnresolved = () => ({ request: {}, environmentId: 'platform' }) as any; +const anonResolved = () => + ({ request: {}, environmentId: 'platform', executionContext: { isSystem: false, positions: [], permissions: [], systemPermissions: [] } }) as any; +const authed = (systemPermissions: string[] = []) => + ({ request: {}, environmentId: 'platform', executionContext: { userId: 'u1', isSystem: false, positions: [], permissions: [], systemPermissions } }) as any; +const system = () => + ({ request: {}, environmentId: 'platform', executionContext: { isSystem: true } }) as any; + +// ── fixtures ──────────────────────────────────────────────────────────────── + +const scriptAction = { + name: 'mark_primary', + objectName: 'crm_contact', + type: 'script', + body: { language: 'js', source: 'return { ok: true };', capabilities: ['api.write'] }, +}; + +/** Same declaration, but ADR-0066 D4 gated — pins that the finer gate survives. */ +const gatedScriptAction = { ...scriptAction, name: 'purge_contact', requiredPermissions: ['manage_contacts'] }; + +const flowAction = { + name: 'escalate_case', + objectName: 'crm_contact', + type: 'flow', + target: 'crm_escalation_flow', +}; + +/** + * A genuinely OBJECT-LESS (`global`) declaration — a standalone `defineAction` + * artifact carrying no `objectName`, which is what `POST /actions//:action` + * routes at the `'global'` key (#3913). + * + * It has to be object-less for the test over it to mean anything. An earlier + * draft reused `mark_primary` (declared on `crm_contact`) for that URL shape: + * with the gate removed, the request answered 404 — ADR-0110 D3's "no + * declaration" exit — rather than dispatching, so the case's + * `executeAction not called` assertion was passing because NOTHING would have + * run, not because the gate stopped it. Replaced rather than re-spelled. + */ +const globalScriptAction = { + name: 'import_contacts', + type: 'script', + body: { language: 'js', source: 'return { ok: true };', capabilities: ['api.write'] }, +}; + +function makeDispatcher() { + const objectDef = { name: 'crm_contact', actions: [scriptAction, gatedScriptAction, flowAction] }; + const executeAction = vi.fn(async () => ({ ok: true, wrote: 'system-elevated' })); + const execute = vi.fn(async () => ({ success: true, status: 'paused', runId: 'run_1' })); + const registerFlow = vi.fn(); + const unregisterFlow = vi.fn(); + const listFlows = vi.fn(async () => ['crm_escalation_flow']); + + const ql: any = { + executeAction, + getSchema: (n: string) => (n === objectDef.name ? objectDef : undefined), + registry: { + getObject: (n: string) => (n === objectDef.name ? objectDef : undefined), + getItem: (type: string, name: string) => + (type === 'action' && name === globalScriptAction.name ? globalScriptAction : undefined), + }, + find: vi.fn(async () => [{ id: 'c1' }]), + insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + }; + const metadata: any = { + load: vi.fn(async () => null), + listObjects: vi.fn(async () => [objectDef]), + getObject: vi.fn(async () => objectDef), + }; + const automation: any = { execute, registerFlow, unregisterFlow, listFlows, handlerReady: true }; + const kernel: any = { + context: { + getService: (n: string) => + n === 'objectql' || n === 'data' ? ql + : n === 'metadata' ? metadata + : n === 'automation' ? automation + : null, + }, + }; + return { dispatcher: new HttpDispatcher(kernel), executeAction, execute, registerFlow, unregisterFlow, listFlows }; +} + +const DENY_MESSAGE = 'Authentication is required to access this endpoint.'; + +/** The envelope every anonymous-denied seam returns — asserted, not assumed. */ +function expectAnonymousDenial(response: any) { + expect(response.status).toBe(401); + expect(response.body?.error?.code ?? response.body?.error?.details?.code).toBe('UNAUTHENTICATED'); + expect(response.body?.error?.message).toBe(DENY_MESSAGE); +} + +// ════════════════════════════════════════════════════════════════════════════ +// /actions +// ════════════════════════════════════════════════════════════════════════════ + +describe('/actions — anonymous baseline (#5519)', () => { + it('401s an anonymous script-action POST and dispatches NOTHING', async () => { + const { dispatcher, executeAction } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/mark_primary/c1', 'POST', {}, anonUnresolved()); + + expectAnonymousDenial(r.response); + // The load-bearing half: the body never ran. Before #5519 this call + // reached `executeAction` with `isSystem: true` forced on. + expect(executeAction).not.toHaveBeenCalled(); + }, 60_000); + + it('401s when identity RESOLVED but carries no user (the sessionless shape)', async () => { + const { dispatcher, executeAction } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/mark_primary/c1', 'POST', {}, anonResolved()); + + expectAnonymousDenial(r.response); + expect(executeAction).not.toHaveBeenCalled(); + }, 60_000); + + it('401s an anonymous FLOW-action POST and starts no run', async () => { + const { dispatcher, execute } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/escalate_case/c1', 'POST', {}, anonUnresolved()); + + expectAnonymousDenial(r.response); + expect(execute).not.toHaveBeenCalled(); + }, 60_000); + + it('401s the object-less (`global`) action shape too', async () => { + const { dispatcher, executeAction } = makeDispatcher(); + const r: any = await dispatcher.handleActions('//import_contacts', 'POST', {}, anonUnresolved()); + + expectAnonymousDenial(r.response); + expect(executeAction).not.toHaveBeenCalled(); + }, 60_000); + + it('dispatches the object-less shape for an AUTHENTICATED caller (the case above has teeth)', async () => { + // The counterpart that proves the 401 above prevented a REAL dispatch: + // the same URL with a session reaches `executeAction`. + const { dispatcher, executeAction } = makeDispatcher(); + const r: any = await dispatcher.handleActions('//import_contacts', 'POST', {}, authed()); + + expect(r.response.status).toBe(200); + expect(executeAction).toHaveBeenCalled(); + }, 60_000); + + it('lets an AUTHENTICATED caller through — the deny targets anonymity, not the route', async () => { + const { dispatcher, executeAction } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/mark_primary/c1', 'POST', {}, authed()); + + expect(r.response.status).toBe(200); + expect(r.response.body?.data).toMatchObject({ ok: true }); + expect(executeAction).toHaveBeenCalled(); + }, 60_000); + + it('lets an internal SYSTEM context through (`isSystem` is never settable from the wire)', async () => { + const { dispatcher, executeAction } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/mark_primary/c1', 'POST', {}, system()); + + expect(r.response.status).toBe(200); + expect(executeAction).toHaveBeenCalled(); + }, 60_000); + + it('dispatches an authenticated FLOW action exactly as before', async () => { + const { dispatcher, execute } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/escalate_case/c1', 'POST', {}, authed()); + + expect(r.response.status).toBe(200); + expect(r.response.body?.data).toMatchObject({ runId: 'run_1' }); + expect(execute).toHaveBeenCalled(); + }, 60_000); + + it('answers the anonymous floor BEFORE the 405, so a non-POST leaks no route shape', async () => { + // Deliberate: the gate is the first statement in the handler. `/data` + // answers an anonymous GET 401 rather than describing its verbs, and + // this surface now matches. + const { dispatcher } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/mark_primary/c1', 'GET', {}, anonUnresolved()); + + expectAnonymousDenial(r.response); + }, 60_000); + + it('does not turn a CORS preflight into a 401 (OPTIONS passes the gate, then 405s)', async () => { + // `shouldDenyAnonymous` exempts OPTIONS by contract; passing `method` + // through is what keeps that true here. A preflight answered 401 would + // break every browser client of this route. + const { dispatcher } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/mark_primary/c1', 'OPTIONS', {}, anonUnresolved()); + + expect(r.response.status).toBe(405); + }, 60_000); +}); + +describe('/actions — `requiredPermissions` semantics are UNCHANGED below the floor (ADR-0066 D4)', () => { + it('an authenticated caller missing the capability still gets 403, not 401', async () => { + const { dispatcher, executeAction } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/purge_contact/c1', 'POST', {}, authed([])); + + expect(r.response.status).toBe(403); + expect(r.response.body?.error?.message).toContain('manage_contacts'); + expect(executeAction).not.toHaveBeenCalled(); + }, 60_000); + + it('an authenticated caller HOLDING the capability is dispatched', async () => { + const { dispatcher, executeAction } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/purge_contact/c1', 'POST', {}, authed(['manage_contacts'])); + + expect(r.response.status).toBe(200); + expect(executeAction).toHaveBeenCalled(); + }, 60_000); + + it('an ANONYMOUS caller on a gated action is denied by the floor (401), not by the capability gate (403)', async () => { + // The one place the two gates visibly reorder. Before #5519 an + // anonymous caller on a `requiredPermissions` action got 403 — + // "you lack a capability" — which described the wrong problem and + // implied a session existed. The floor answers first now. + const { dispatcher } = makeDispatcher(); + const r: any = await dispatcher.handleActions('/crm_contact/purge_contact/c1', 'POST', {}, anonUnresolved()); + + expectAnonymousDenial(r.response); + }, 60_000); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// /automation +// ════════════════════════════════════════════════════════════════════════════ + +describe('/automation — anonymous baseline covers the WHOLE domain (#5519)', () => { + it('401s an anonymous `POST /:name/trigger` and starts no run', async () => { + const { dispatcher, execute } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/crm_escalation_flow/trigger', 'POST', { recordId: 'c1' }, anonUnresolved()); + + expectAnonymousDenial(r.response); + expect(execute).not.toHaveBeenCalled(); + }, 60_000); + + it('401s the LEGACY `POST /trigger/:name` shape too (the SDK route)', async () => { + const { dispatcher, execute } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/trigger/crm_escalation_flow', 'POST', { recordId: 'c1' }, anonUnresolved()); + + expectAnonymousDenial(r.response); + expect(execute).not.toHaveBeenCalled(); + }, 60_000); + + it('401s an anonymous `GET /` — the flow inventory is not public', async () => { + const { dispatcher, listFlows } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/', 'GET', undefined, anonUnresolved()); + + expectAnonymousDenial(r.response); + expect(listFlows).not.toHaveBeenCalled(); + }, 60_000); + + it('401s an anonymous `DELETE /:name` — proven reachable on a real boot', async () => { + const { dispatcher, unregisterFlow } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/crm_escalation_flow', 'DELETE', undefined, anonUnresolved()); + + expectAnonymousDenial(r.response); + expect(unregisterFlow).not.toHaveBeenCalled(); + }, 60_000); + + it('401s an anonymous `POST /` (flow registration)', async () => { + const { dispatcher, registerFlow } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/', 'POST', { name: 'injected_flow' }, anonUnresolved()); + + expectAnonymousDenial(r.response); + expect(registerFlow).not.toHaveBeenCalled(); + }, 60_000); + + it('denies BEFORE the service-availability probe — a 501 would leak the deployment shape', async () => { + // No `automation` slot at all: an anonymous caller must still see 401, + // not `capabilityUnavailable`'s 501. + const kernel: any = { context: { getService: () => null } }; + const d = new HttpDispatcher(kernel); + const r: any = await d.handleAutomation('/crm_escalation_flow/trigger', 'POST', {}, anonUnresolved()); + + expectAnonymousDenial(r.response); + }, 60_000); + + it('lets an AUTHENTICATED caller trigger, with identity forwarding intact', async () => { + const { dispatcher, execute } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/crm_escalation_flow/trigger', 'POST', { recordId: 'c1' }, authed()); + + expect(r.response.status).toBe(200); + expect(execute).toHaveBeenCalledWith('crm_escalation_flow', expect.objectContaining({ userId: 'u1' })); + }, 60_000); + + it('lets an internal SYSTEM context through', async () => { + const { dispatcher, execute } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/crm_escalation_flow/trigger', 'POST', {}, system()); + + expect(r.response.status).toBe(200); + expect(execute).toHaveBeenCalled(); + }, 60_000); + + it('lets an authenticated caller list flows', async () => { + const { dispatcher, listFlows } = makeDispatcher(); + const r: any = await dispatcher.handleAutomation('/', 'GET', undefined, authed()); + + expect(r.response.status).toBe(200); + expect(listFlows).toHaveBeenCalled(); + }, 60_000); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// The internal dispatch paths the gate must NOT touch +// ════════════════════════════════════════════════════════════════════════════ + +describe('#5519 — internal dispatch paths are untouched (the gate is HTTP-seam only)', () => { + it('the MCP bridge (`invokeBusinessAction`) still runs its OWN gates, not the HTTP floor', async () => { + // `domains/mcp.ts` wires `runAction` straight to this function; it never + // enters `handleActionsRequest`. Its boundary is `ai.exposed` + + // `requiredPermissions`, which is why an action that is NOT AI-exposed + // is refused here for that reason — an unchanged message, and proof the + // 401 envelope did not leak into the shared execution layer. + const objectDef = { name: 'crm_contact', actions: [scriptAction] }; + const deps: any = { + resolveService: async () => null, + getObjectQL: async () => undefined, + }; + await expect( + actionExec.invokeBusinessAction(deps, anonUnresolved(), 'mark_primary', {}, { + driver: undefined, + ec: undefined, + getMeta: () => ({ listObjects: async () => [objectDef], getObject: async () => objectDef }), + callData: async () => ({}), + }), + ).rejects.toThrow(/not exposed to AI/); + }, 60_000); + + it('`buildAutomationContext` — the seam the declarative endpoint executor shares — is not gated', async () => { + // #5040 E5 exports this so a `type: 'flow'` endpoint sends the SAME + // context the trigger route sends. It runs in the transport fallback + // seam, outside `dispatch()`, so it must stay callable with any context. + const ctx = buildAutomationContext({ recordId: 'c1', objectName: 'crm_contact' }, anonUnresolved()); + expect(ctx).toMatchObject({ object: 'crm_contact', event: 'manual' }); + expect((ctx.params as any).recordId).toBe('c1'); + expect(ctx.userId).toBeUndefined(); + }, 60_000); + + it('a declared `authRequired: false` endpoint still passes anonymously (its own gate, ②)', async () => { + // The declarative endpoint executor owns the ONLY sanctioned way a + // flow runs for a session-less caller: an explicit `authRequired: + // false` in the endpoint declaration. Gating `/automation` must not + // (and does not) close it — different seam entirely. + const endpoint = ApiEndpointSchema.parse({ + name: 'public_intake', path: '/api/v1/apps/demo/intake', method: 'POST', + type: 'flow', target: 'demo_intake_flow', authRequired: false, + }); + const verdict = await applyEndpointPolicies({ + endpoint, + method: 'POST', + limiters: createEndpointRateLimiterRegistry({ resolveCache: async () => undefined }), + resolvePrincipalId: async () => undefined, // anonymous + }); + + expect(verdict.verdict).toBe('pass'); + }, 60_000); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 74451b49f7..933ee76110 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -9,6 +9,9 @@ * "actions"/"connectors" would shadow them. */ +import { + shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, +} from '@objectstack/core'; import { CoreServiceName } from '@objectstack/spec/system'; import type { IAutomationService } from '@objectstack/spec/contracts'; import { isServiceServeable } from '../service-serveable.js'; @@ -122,6 +125,36 @@ export function createAutomationDomain(deps: DomainHandlerDeps): DomainRoute { * GET /:name/runs/:runId/screen → the screen a paused run awaits */ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise { + // [#5519] ANONYMOUS BASELINE — the same floor `/data`, `/meta`, `/ai` and + // `/security` stand on (ADR-0056 D2 → #3963: "anonymous access is now + // always denied"). `/automation` had none, and the whole domain is a write + // surface: `POST /:name/trigger` starts a flow run, `POST /` and `PUT + // /:name` register a flow definition, `DELETE /:name` unregisters one, and + // `GET /` enumerates every flow the deployment has. All four were reachable + // unauthenticated — verified against a real showcase boot, where an + // anonymous `DELETE /automation/showcase_inquiry_janitor` answered 200 + // `{deleted: true}` and an anonymous trigger returned a live `runId`. + // + // Gated for the WHOLE domain rather than per-route, and ahead of the + // service-availability probe below: one floor cannot drift route by route, + // and an anonymous caller should not learn from a 501-vs-401 whether this + // deployment mounts automation at all. + // + // ⚠️ This is the HTTP seam only. `buildAutomationContext` above is exported + // and also used by the declarative endpoint executor (#5040 E5), which runs + // in the transport's fallback seam and never enters this handler — a + // metadata-declared `type: 'flow'` endpoint keeps its own policy chain and + // is untouched here. Internal engine triggers (record-change, schedule) + // never speak HTTP at all. + { + const ec: any = (context as any)?.executionContext; + if (shouldDenyAnonymous({ userId: ec?.userId, isSystem: ec?.isSystem, method })) { + return { + handled: true, + response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), + }; + } + } const automationService = await deps.getService(context, CoreServiceName.enum.automation); // [#4058] Empty slot — or a slot filled by a self-declared non-handler // (`handlerReady: false`, ADR-0076 D12), which is the same amount of diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts index bc60ea0825..bd729708e5 100644 --- a/packages/runtime/src/error-envelope.conformance.test.ts +++ b/packages/runtime/src/error-envelope.conformance.test.ts @@ -65,9 +65,17 @@ function expectConformantError(response: { status: number; body: any } | undefin return body.error; } +/** + * [#5519] `/actions` stands on the platform anonymous-deny baseline now, and it + * answers ahead of the 400/405 branches below. These cases are about the ERROR + * ENVELOPE those branches produce, not about who may call them, so they carry a + * session — otherwise they would silently become a third copy of the 401 test. + */ +const AUTHED: any = { request: {}, executionContext: { userId: 'u_test', systemPermissions: [] } }; + describe('#3842 — every dispatcher error exit answers in the declared envelope', () => { it('derives a catalogued code when the branch has none of its own (400)', async () => { - const result = await makeDispatcher().handleActions('', 'POST', {}, { request: {} }); + const result = await makeDispatcher().handleActions('', 'POST', {}, AUTHED); const error = expectConformantError(result.response); expect(error.code).toBe('VALIDATION_ERROR'); @@ -78,7 +86,7 @@ describe('#3842 — every dispatcher error exit answers in the declared envelope // The two statuses `StandardErrorCode` had no member for until #3842 — // without them a 405 would have derived the generic 4xx bucket and read // as a validation failure. - const notAllowed = await makeDispatcher().handleActions('/task/close', 'GET', {}, { request: {} }); + const notAllowed = await makeDispatcher().handleActions('/task/close', 'GET', {}, AUTHED); expect(expectConformantError(notAllowed.response).code).toBe('METHOD_NOT_ALLOWED'); const notImplemented = await makeDispatcher().handleI18n('/labels/account', 'GET', {}, { request: {} }); diff --git a/packages/runtime/src/http-dispatcher.actions-global-key.test.ts b/packages/runtime/src/http-dispatcher.actions-global-key.test.ts index fc8b889851..74aca0e73b 100644 --- a/packages/runtime/src/http-dispatcher.actions-global-key.test.ts +++ b/packages/runtime/src/http-dispatcher.actions-global-key.test.ts @@ -84,10 +84,18 @@ function makeDispatcher(opts: { listObjects: vi.fn(async () => (objectDef ? [objectDef] : [])), getObject: vi.fn(async () => objectDef), }; + // [#5519] An `auth` slot that resolves a session for any request. The one + // case below that goes through the REAL `dispatch()` pipeline has its + // identity RE-RESOLVED off this kernel (a seeded `executionContext` on the + // passed context is overwritten), and `/actions` now denies an anonymous + // caller 401 before addressing anything — so without a session that case + // would stop testing addressing and start re-testing the auth floor. + // Every other case calls `handleActions` directly and carries `ctx()`. + const auth: any = { api: { getSession: async () => ({ user: { id: 'u1' } }) } }; const kernel: any = { context: { getService: (n: string) => - n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : null, + n === 'objectql' || n === 'data' ? ql : n === 'metadata' ? metadata : n === 'auth' ? auth : null, }, }; return { dispatcher: new HttpDispatcher(kernel) as any, executeAction }; diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 3b82a0f5f2..e00afd65b2 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -20,6 +20,17 @@ import type { IAuthService, IAutomationService } from '@objectstack/spec/contrac */ type ContractMock = Partial>; +/** + * [#5519] The dispatch domains below (`/actions`, `/automation`) now stand on + * the platform anonymous-deny baseline, so a route-behaviour test needs a + * caller. These cases are about ROUTING — which service method a path reaches, + * which status a miss returns — and were only ever anonymous incidentally + * (`{ request: {} }` is the smallest context that compiles). Anonymity itself + * is pinned in `domains/anonymous-gate-actions-automation.test.ts`; giving + * these a session keeps each file testing the thing it is named after. + */ +const AUTHED_CALLER = () => ({ request: {}, executionContext: { userId: 'u_test', isSystem: false, positions: [], permissions: [], systemPermissions: [] } }) as any; + describe('HttpDispatcher', () => { let kernel: ObjectKernel; let dispatcher: HttpDispatcher; @@ -234,13 +245,13 @@ describe('HttpDispatcher', () => { }); it('should list flows via GET /', async () => { - const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.flows).toEqual(['flow_a', 'flow_b']); }); it('should return per-flow runtime enable/bound state via GET /_status', async () => { - const result = await dispatcher.handleAutomation('_status', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('_status', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.flows).toEqual([ { name: 'flow_a', enabled: true, bound: true }, @@ -251,41 +262,41 @@ describe('HttpDispatcher', () => { }); it('should get a flow via GET /:name', async () => { - const result = await dispatcher.handleAutomation('flow_a', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.name).toBe('flow_a'); }); it('should return 404 for non-existent flow via GET /:name', async () => { mockAutomationService.getFlow.mockResolvedValue(null); - const result = await dispatcher.handleAutomation('missing', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('missing', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(404); }); it('should create a flow via POST /', async () => { const body = { name: 'new_flow', label: 'New Flow' }; - const result = await dispatcher.handleAutomation('', 'POST', body, { request: {} }); + const result = await dispatcher.handleAutomation('', 'POST', body, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(mockAutomationService.registerFlow).toHaveBeenCalledWith('new_flow', body); }); it('should update a flow via PUT /:name', async () => { const body = { definition: { label: 'Updated' } }; - const result = await dispatcher.handleAutomation('flow_a', 'PUT', body, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a', 'PUT', body, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(mockAutomationService.registerFlow).toHaveBeenCalledWith('flow_a', { label: 'Updated' }); }); it('should delete a flow via DELETE /:name', async () => { - const result = await dispatcher.handleAutomation('flow_a', 'DELETE', {}, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a', 'DELETE', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(mockAutomationService.unregisterFlow).toHaveBeenCalledWith('flow_a'); expect(result.response?.body?.data?.deleted).toBe(true); }); it('should trigger a flow via POST /:name/trigger', async () => { - const result = await dispatcher.handleAutomation('flow_a/trigger', 'POST', { key: 'val' }, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a/trigger', 'POST', { key: 'val' }, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(mockAutomationService.execute).toHaveBeenCalledWith('flow_a', expect.objectContaining({ params: expect.objectContaining({ key: 'val' }), @@ -294,26 +305,26 @@ describe('HttpDispatcher', () => { }); it('should toggle a flow via POST /:name/toggle', async () => { - const result = await dispatcher.handleAutomation('flow_a/toggle', 'POST', { enabled: false }, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a/toggle', 'POST', { enabled: false }, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(mockAutomationService.toggleFlow).toHaveBeenCalledWith('flow_a', false); }); it('should list runs via GET /:name/runs', async () => { - const result = await dispatcher.handleAutomation('flow_a/runs', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a/runs', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.runs).toHaveLength(1); }); it('should get a run via GET /:name/runs/:runId', async () => { - const result = await dispatcher.handleAutomation('flow_a/runs/run_1', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a/runs/run_1', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.id).toBe('run_1'); }); it('should return 404 for non-existent run', async () => { mockAutomationService.getRun.mockResolvedValue(null); - const result = await dispatcher.handleAutomation('flow_a/runs/missing', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a/runs/missing', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(404); }); @@ -321,7 +332,7 @@ describe('HttpDispatcher', () => { // ── screen-flow runtime (ADR-0019 durable pause, #3528) ────────── it('should resume a paused run via POST /:name/runs/:runId/resume', async () => { const result = await dispatcher.handleAutomation( - 'flow_a/runs/run_1/resume', 'POST', { inputs: { new_assignee: 'ada' } }, { request: {} }, + 'flow_a/runs/run_1/resume', 'POST', { inputs: { new_assignee: 'ada' } }, AUTHED_CALLER(), ); expect(result.handled).toBe(true); expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', { @@ -332,7 +343,7 @@ describe('HttpDispatcher', () => { it('should accept `variables` as an alias for `inputs` on resume', async () => { await dispatcher.handleAutomation( - 'flow_a/runs/run_1/resume', 'POST', { variables: { note: 'hi' } }, { request: {} }, + 'flow_a/runs/run_1/resume', 'POST', { variables: { note: 'hi' } }, AUTHED_CALLER(), ); expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', { variables: { note: 'hi' }, @@ -342,7 +353,7 @@ describe('HttpDispatcher', () => { it('should forward approval-style output + branchLabel on resume', async () => { await dispatcher.handleAutomation( 'flow_a/runs/run_1/resume', 'POST', - { output: { comment: 'ok' }, branchLabel: 'approve' }, { request: {} }, + { output: { comment: 'ok' }, branchLabel: 'approve' }, AUTHED_CALLER(), ); expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', { output: { comment: 'ok' }, @@ -351,7 +362,7 @@ describe('HttpDispatcher', () => { }); it('should resume with an empty signal when the body carries no input', async () => { - await dispatcher.handleAutomation('flow_a/runs/run_1/resume', 'POST', undefined, { request: {} }); + await dispatcher.handleAutomation('flow_a/runs/run_1/resume', 'POST', undefined, AUTHED_CALLER()); expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', {}); }); @@ -361,7 +372,7 @@ describe('HttpDispatcher', () => { screen: { nodeId: 'step2', title: 'Confirm', fields: [] }, }); const result = await dispatcher.handleAutomation( - 'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, { request: {} }, + 'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, AUTHED_CALLER(), ); expect(result.response?.body?.data?.status).toBe('paused'); expect(result.response?.body?.data?.screen?.nodeId).toBe('step2'); @@ -378,7 +389,7 @@ describe('HttpDispatcher', () => { error: "Run 'run_1' is paused at an 'approval' node, which only its owning service may resume", }); const result = await dispatcher.handleAutomation( - 'flow_a/runs/run_1/resume', 'POST', { branchLabel: 'approve' }, { request: {} }, + 'flow_a/runs/run_1/resume', 'POST', { branchLabel: 'approve' }, AUTHED_CALLER(), ); expect(result.handled).toBe(true); expect(result.response?.status).toBe(403); @@ -391,7 +402,7 @@ describe('HttpDispatcher', () => { it('should not 403 an ordinary failed resume', async () => { mockAutomationService.resume.mockResolvedValue({ success: false, error: 'node blew up' }); const result = await dispatcher.handleAutomation( - 'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, { request: {} }, + 'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, AUTHED_CALLER(), ); expect(result.response?.status).not.toBe(403); expect(result.response?.body?.data?.success).toBe(false); @@ -409,7 +420,7 @@ describe('HttpDispatcher', () => { }); const result = await dispatcher.handleAutomation( 'flow_a/runs/run_1/resume', 'POST', - { output: { $mapItemDone: true } }, { request: {} }, + { output: { $mapItemDone: true } }, AUTHED_CALLER(), ); expect(result.response?.status).toBe(400); expect(result.response?.body?.error?.message).toMatch(/reserved by the flow engine/); @@ -420,7 +431,7 @@ describe('HttpDispatcher', () => { await dispatcher.handleAutomation( 'flow_a/runs/run_1/resume', 'POST', { inputs: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3 }, output: { decision: 'ok' } }, - { request: {} }, + AUTHED_CALLER(), ); expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', { variables: { new_assignee: 'ada', 'collect.note': 'hi', price$: 3 }, @@ -431,14 +442,14 @@ describe('HttpDispatcher', () => { it('should return 501 when the automation service cannot resume', async () => { delete mockAutomationService.resume; const result = await dispatcher.handleAutomation( - 'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, { request: {} }, + 'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, AUTHED_CALLER(), ); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); }); it('should get the pending screen via GET /:name/runs/:runId/screen', async () => { - const result = await dispatcher.handleAutomation('flow_a/runs/run_1/screen', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a/runs/run_1/screen', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(mockAutomationService.getSuspendedScreen).toHaveBeenCalledWith('run_1'); expect(result.response?.body?.data?.screen?.nodeId).toBe('collect'); @@ -448,7 +459,7 @@ describe('HttpDispatcher', () => { it('should return 404 when the run is not awaiting a screen', async () => { mockAutomationService.getSuspendedScreen.mockResolvedValue(null); - const result = await dispatcher.handleAutomation('flow_a/runs/run_1/screen', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('flow_a/runs/run_1/screen', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(404); }); @@ -468,7 +479,7 @@ describe('HttpDispatcher', () => { * executionContext without dispatch() overwriting it. */ it('routes the legacy POST /trigger/:name through execute, never a non-contract trigger()', async () => { - const result = await dispatcher.handleAutomation('trigger/flow_a', 'POST', { data: 1 }, { request: {} }); + const result = await dispatcher.handleAutomation('trigger/flow_a', 'POST', { data: 1 }, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(mockAutomationService.trigger).not.toHaveBeenCalled(); expect(mockAutomationService.execute).toHaveBeenCalledTimes(1); @@ -482,7 +493,7 @@ describe('HttpDispatcher', () => { // ── GET /actions — action descriptor registry (ADR-0018) ────────── it('should list action descriptors via GET /actions', async () => { - const result = await dispatcher.handleAutomation('actions', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('actions', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(mockAutomationService.getActionDescriptors).toHaveBeenCalled(); expect(result.response?.body?.data?.total).toBe(3); @@ -492,7 +503,7 @@ describe('HttpDispatcher', () => { }); it('must NOT let GET /actions be shadowed by the /:name flow lookup', async () => { - const result = await dispatcher.handleAutomation('actions', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('actions', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); // The actions registry is returned, NOT a getFlow('actions') result. expect(mockAutomationService.getFlow).not.toHaveBeenCalled(); @@ -500,14 +511,14 @@ describe('HttpDispatcher', () => { }); it('should filter GET /actions by ?source', async () => { - const result = await dispatcher.handleAutomation('actions', 'GET', {}, { request: {} }, { source: 'plugin' }); + const result = await dispatcher.handleAutomation('actions', 'GET', {}, AUTHED_CALLER(), { source: 'plugin' }); expect(result.handled).toBe(true); expect(result.response?.body?.data?.total).toBe(1); expect(result.response?.body?.data?.actions[0].type).toBe('send_sms'); }); it('should filter GET /actions by ?paradigm', async () => { - const result = await dispatcher.handleAutomation('actions', 'GET', {}, { request: {} }, { paradigm: 'approval' }); + const result = await dispatcher.handleAutomation('actions', 'GET', {}, AUTHED_CALLER(), { paradigm: 'approval' }); expect(result.handled).toBe(true); expect(result.response?.body?.data?.total).toBe(1); expect(result.response?.body?.data?.actions[0].type).toBe('http_request'); @@ -515,7 +526,7 @@ describe('HttpDispatcher', () => { it('should return an empty registry when the service lacks getActionDescriptors', async () => { delete mockAutomationService.getActionDescriptors; - const result = await dispatcher.handleAutomation('actions', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('actions', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.actions).toEqual([]); expect(result.response?.body?.data?.total).toBe(0); @@ -523,7 +534,7 @@ describe('HttpDispatcher', () => { // ── GET /connectors — connector descriptor registry (ADR-0022) ──── it('should list connector descriptors via GET /connectors', async () => { - const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('connectors', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(mockAutomationService.getConnectorDescriptors).toHaveBeenCalled(); expect(result.response?.body?.data?.total).toBe(3); @@ -533,7 +544,7 @@ describe('HttpDispatcher', () => { }); it('must NOT let GET /connectors be shadowed by the /:name flow lookup', async () => { - const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('connectors', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); // The connector registry is returned, NOT a getFlow('connectors') result. expect(mockAutomationService.getFlow).not.toHaveBeenCalled(); @@ -541,7 +552,7 @@ describe('HttpDispatcher', () => { }); it('should filter GET /connectors by ?type', async () => { - const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} }, { type: 'database' }); + const result = await dispatcher.handleAutomation('connectors', 'GET', {}, AUTHED_CALLER(), { type: 'database' }); expect(result.handled).toBe(true); expect(result.response?.body?.data?.total).toBe(1); expect(result.response?.body?.data?.connectors[0].name).toBe('pg'); @@ -553,7 +564,7 @@ describe('HttpDispatcher', () => { // degraded one (ADR-0097 §4, #3017) — while the contract did not // declare the method, nothing pinned that they survive the hop. it('should preserve origin / state / degradedReason on GET /connectors', async () => { - const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('connectors', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); const byName = Object.fromEntries( result.response?.body?.data?.connectors.map((c: ConnectorDescriptor) => [c.name, c]), @@ -568,7 +579,7 @@ describe('HttpDispatcher', () => { it('should return an empty registry when the service lacks getConnectorDescriptors', async () => { delete mockAutomationService.getConnectorDescriptors; - const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('connectors', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.connectors).toEqual([]); expect(result.response?.body?.data?.total).toBe(0); @@ -1105,7 +1116,7 @@ describe('HttpDispatcher', () => { return null; }); - const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.flows).toEqual(['f1']); }); @@ -1117,7 +1128,7 @@ describe('HttpDispatcher', () => { (kernel as any).getService = vi.fn().mockResolvedValue(null); (kernel as any).services = new Map(); - const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.status).toBe(501); expect(result.response?.body?.error?.message ?? '').toContain('service-automation'); @@ -1176,7 +1187,7 @@ describe('HttpDispatcher', () => { }; (kernel as any).getService = vi.fn().mockReturnValue(syncAuto); - const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.flows).toEqual(['flow_x']); }); @@ -1229,7 +1240,7 @@ describe('HttpDispatcher', () => { }; (kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncAuto); - const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.flows).toEqual(['flow_async']); expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('automation'); @@ -2916,7 +2927,7 @@ describe('HttpDispatcher', () => { // the stub is NEVER CALLED, so nothing can read "flow executed" // off a flow that never ran. for (const [path, method] of [['', 'GET'], ['', 'POST'], ['trigger/x', 'POST'], ['x/trigger', 'POST']] as const) { - const result = await dispatcher.handleAutomation(path, method, { name: 'x' }, { request: {} }); + const result = await dispatcher.handleAutomation(path, method, { name: 'x' }, AUTHED_CALLER()); expect(result.response?.status, `${method} /automation/${path}`).toBe(501); } expect(stub.execute).not.toHaveBeenCalled(); @@ -2929,7 +2940,7 @@ describe('HttpDispatcher', () => { const svc = degraded({ listFlows: vi.fn().mockResolvedValue(['flow_a']) }); serveOnly('automation', svc); - const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} }); + const result = await dispatcher.handleAutomation('', 'GET', {}, AUTHED_CALLER()); expect(result.handled).toBe(true); expect(result.response?.body?.data?.flows).toEqual(['flow_a']); }); @@ -3549,10 +3560,21 @@ describe('HttpDispatcher — ADR-0066 D4 action requiredPermissions gate', () => expect(executeAction).toHaveBeenCalledTimes(1); }); - it('denies an unauthenticated caller for a gated action', async () => { + it('denies an unauthenticated caller for a gated action — now at the 401 FLOOR, not the 403 gate (#5519)', async () => { + // The one place #5519 visibly REORDERS two denials. This case used to + // reach `actionPermissionError` and answer 403 "you are missing + // [manage_platform_settings]" — a message that describes the wrong + // problem and implies a session exists. `/actions` now stands on the + // platform anonymous-deny baseline, which answers first and answers 401. + // + // The verdict is unchanged where it counts (denied, nothing dispatched); + // only the reason improved. The capability gate itself is untouched — the + // three cases above still exercise it for authenticated callers, which is + // the population it was ever able to judge. const { dispatcher, executeAction, ctx } = make(gated, undefined); const res = await dispatcher.handleActions('/sys_license/issue_and_sign', 'POST', {}, ctx); - expect(res.response.status).toBe(403); + expect(res.response.status).toBe(401); + expect(res.response.body?.error?.code ?? res.response.body?.error?.details?.code).toBe('UNAUTHENTICATED'); expect(executeAction).not.toHaveBeenCalled(); }); }); @@ -3621,15 +3643,33 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => { expect(session.tenantId).toBeUndefined(); }); - it('falls back to a `system` principal only when the request is anonymous', async () => { - const { dispatcher, executeAction, ctx } = captureCtx(undefined); - await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx); - const user = actionUser(executeAction); + it('falls back to a `system` principal for a SELF-INVOKED call — the anonymous door is 401 now (#5519)', async () => { + // REPLACED, not re-spelled. This was driven with NO execution context — + // the shape an anonymous HTTP request has — and asserted over the action + // context the body received. Since #5519 an anonymous `/actions` POST is + // denied 401 before anything dispatches, so `executeAction` is never + // called and the old assertions would have been reading `undefined`: + // green for the empty reason, which is worse than red. + // + // What #2701's fallback actually still describes is the SELF-INVOKED + // caller — a context with `isSystem: true` and no `userId`, which is the + // one identity-less shape that legitimately reaches the body (and cannot + // be forged from the wire). That half is pinned here; the anonymous half + // is pinned as the denial it now is. + const selfInvoked = captureCtx({ isSystem: true }); + await selfInvoked.dispatcher.handleActions('/lead/convert', 'POST', {}, selfInvoked.ctx); + const user = actionUser(selfInvoked.executeAction); expect(user.id).toBe('system'); expect(user.roles).toEqual([]); expect(user.positions).toEqual([]); // No resolved caller → no session (parity with the hook surface). - expect(actionSession(executeAction)).toBeUndefined(); + expect(actionSession(selfInvoked.executeAction)).toBeUndefined(); + + // …and the anonymous door, asserted rather than left implied. + const anon = captureCtx(undefined); + const denied: any = await anon.dispatcher.handleActions('/lead/convert', 'POST', {}, anon.ctx); + expect(denied.response.status).toBe(401); + expect(anon.executeAction).not.toHaveBeenCalled(); }); it('sources identity from executionContext, ignoring a stray `_context.user` (regression guard)', async () => {