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
39 changes: 39 additions & 0 deletions .changeset/thick-pumas-judge.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 16 additions & 2 deletions packages/runtime/src/action-body-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand Down
20 changes: 18 additions & 2 deletions packages/runtime/src/action-ctx-user-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IHttpServer>('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<void>((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);
});
18 changes: 14 additions & 4 deletions packages/runtime/src/domain-handler-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
Loading
Loading