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
23 changes: 23 additions & 0 deletions .changeset/action-ctx-user-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@objectstack/runtime": patch
---

fix(runtime): action body `ctx.user` now reflects the session operator, not `system`

The `POST /actions/:object/:action` route called `handleActions` directly,
bypassing `dispatcher.dispatch()` — so `resolveExecutionContext` never ran and
the action handler's `ctx.user` was hard-coded to `{ id: 'system' }`. Handlers
could not branch on the operator's identity or business roles, nor enforce
server-side ownership. (#2701)

- The action routes now dispatch through `dispatch()` like the automation/AI
routes, so the per-request pipeline resolves the session identity (and swaps
to the per-project kernel) before the action body runs.
- `handleActions` builds `ctx.user` from the resolved `ExecutionContext`,
exposing `id`, `email`, `roles`/`positions` (ADR-0090 business roles),
`permissions`, and `tenantId` — matching the MCP `runAction` and
record-change trigger paths. It falls back to a `system` principal only for a
genuinely anonymous / self-invoked call.

No authoring change is required: action handlers that previously always saw
`ctx.user.id === 'system'` will now see the real caller.
15 changes: 9 additions & 6 deletions packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,22 +983,25 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
// ── Actions (server-registered handlers, e.g. CRM convertLead) ───
// Bridges UI `script` / `modal` actions to ObjectQL handlers
// registered via `engine.registerAction(object, action, fn)`.
// Dispatched through `dispatcher.dispatch()` (like automation/AI) so
// the per-request pipeline resolves the session into `executionContext`
// and swaps to the per-project kernel BEFORE the action body sandbox
// runs — otherwise the handler's `ctx.user` sees no session and falls
// back to `system` (#2701). The scoped-URL `:environmentId` rides on
// `req.params` and is picked up by `prepareResolverHints`, exactly as
// the automation routes handle it.
const registerActionRoutes = (base: string) => {
server!.post(`${base}/actions/:object/:action`, async (req: any, res: any) => {
try {
const ctx: any = { request: req };
if (req.params?.environmentId) ctx.environmentId = req.params.environmentId;
const result = await dispatcher.handleActions(`/${req.params.object}/${req.params.action}`, 'POST', req.body, ctx);
const result = await dispatcher.dispatch('POST', `/actions/${req.params.object}/${req.params.action}`, req.body, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});
server!.post(`${base}/actions/:object/:action/:recordId`, async (req: any, res: any) => {
try {
const ctx: any = { request: req };
if (req.params?.environmentId) ctx.environmentId = req.params.environmentId;
const result = await dispatcher.handleActions(`/${req.params.object}/${req.params.action}/${req.params.recordId}`, 'POST', req.body, ctx);
const result = await dispatcher.dispatch('POST', `/actions/${req.params.object}/${req.params.action}/${req.params.recordId}`, req.body, req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
Expand Down
107 changes: 107 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2134,6 +2134,113 @@ describe('HttpDispatcher — ADR-0066 D4 action requiredPermissions gate', () =>
});
});

describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
// The action body sandbox must see the SESSION operator (id + business
// roles), resolved from the request's ExecutionContext — the same envelope
// `dispatch()` populates and that the MCP / record-change paths already read.
// Pre-#2701 the fallback chain read `_context.user` / `_context.userId`
// (fields HttpProtocolContext never carries) and hard-fell to `system`, so
// every action ran blind to who invoked it.
const captureCtx = (execCtx: any) => {
const executeAction = vi.fn(async () => ({ ok: true }));
const schemaOf = (name: string) => ({
name,
actions: [{ name: 'convert', label: 'Convert', type: 'script', execute: 'true' }],
});
const ql: any = {
executeAction,
getSchema: schemaOf,
registry: { getObject: schemaOf },
find: vi.fn().mockResolvedValue([]),
insert: vi.fn(), update: vi.fn(), delete: vi.fn(),
};
const kernel: any = { context: { getService: (n: string) => (n === 'objectql' ? ql : null) } };
const dispatcher = new HttpDispatcher(kernel);
const ctx: any = { request: {}, environmentId: 'platform', executionContext: execCtx };
return { dispatcher, executeAction, ctx };
};

const actionUser = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.user;

it('forwards the session user id + business roles to the action body (not `system`)', async () => {
const { dispatcher, executeAction, ctx } = captureCtx({
userId: 'user_42',
positions: ['sales_rep', 'org_member'],
permissions: ['convert_lead'],
email: 'rep@acme.test',
tenantId: 'org_acme',
});
await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx);
const user = actionUser(executeAction);
expect(user.id).toBe('user_42');
expect(user.roles).toEqual(['sales_rep', 'org_member']);
expect(user.positions).toEqual(['sales_rep', 'org_member']);
expect(user.permissions).toEqual(['convert_lead']);
expect(user.email).toBe('rep@acme.test');
expect(user.tenantId).toBe('org_acme');
});

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);
expect(user.id).toBe('system');
expect(user.roles).toEqual([]);
expect(user.positions).toEqual([]);
});

it('sources identity from executionContext, ignoring a stray `_context.user` (regression guard)', async () => {
// HttpProtocolContext carries no `user`/`userId`; a caller must not be able
// to spoof identity by stuffing one on. The resolved session is the one source.
const { dispatcher, executeAction, ctx } = captureCtx({ userId: 'ec_user', positions: ['viewer'] });
(ctx as any).user = { id: 'spoofed' };
(ctx as any).userId = 'spoofed_2';
await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx);
expect(actionUser(executeAction).id).toBe('ec_user');
});

it('resolves the session end-to-end: dispatch(/actions/…) threads the authenticated principal into ctx.user', async () => {
// Full pipeline: an api-key request → dispatch() → resolveExecutionContext →
// handleActions. This is the path registerActionRoutes now takes (it calls
// `dispatch('POST', '/actions/…')`) — the identity resolution that was
// bypassed pre-#2701, when the action route called handleActions directly.
const rows: any[] = [];
const executeAction = vi.fn(async () => ({ ok: true }));
const schemaOf = (name: string) => ({ name, actions: [{ name: 'convert', label: 'C', type: 'script', execute: 'true' }] });
const ql: any = {
executeAction,
getSchema: schemaOf,
registry: { getObject: schemaOf },
insert: async (_o: string, data: any) => { const id = `key_${rows.length + 1}`; rows.push({ id, ...data }); return { id }; },
find: async (obj: string, opts: any) => {
const where = opts?.where ?? {};
if (obj !== 'sys_api_key') return [];
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
},
update: async () => ({}), delete: async () => ({}),
};
const kernel: any = {
getService: (n: string) => (n === 'objectql' ? ql : undefined),
getServiceAsync: async (n: string) => (n === 'objectql' ? ql : undefined),
context: { getService: (n: string) => (n === 'objectql' ? ql : undefined) },
};
const dispatcher = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false });

// Mint an api key bound to `user_9`, then invoke an action authenticated by it.
const mint = await dispatcher.handleKeys('POST', { name: 'agent' }, {
request: { headers: {} }, executionContext: { userId: 'user_9', positions: [], permissions: [] },
} as any);
const raw = mint.response.body.data.key;

await dispatcher.dispatch('POST', '/actions/lead/convert', {}, {}, {
request: { headers: { 'x-api-key': raw } },
} as any);

const user = executeAction.mock.calls[0]?.[2]?.user;
expect(user?.id).toBe('user_9'); // was `system` before the fix — the route bypassed dispatch()
});
});

describe('HttpDispatcher — MCP action bridge (list_actions / run_action)', () => {
// A `todo_task` object with declarative actions, mirroring examples/app-todo:
// - complete_task: script bound to the `completeTask` handler (row-context)
Expand Down
35 changes: 27 additions & 8 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3322,12 +3322,14 @@ export class HttpDispatcher {
if (def?.environmentId) _context.environmentId = def.environmentId;
}

// Replicate the kernel swap that `dispatcher.handle()` does for
// data/meta/automation routes. Action routes are registered on the
// raw HTTP server and skip the `handle()` chain, so without this
// swap `getObjectQLService` would resolve the control-plane kernel
// (where the CRM bundle's actions are NOT registered). Routed via the
// host's KernelResolver (ADR-0006 Phase 5) — same seam as handle().
// Kernel-resolution fallback for the per-project kernel. HTTP action
// routes now flow through `dispatcher.dispatch()` (like data/meta/
// automation), which already swapped to the project kernel and resolved
// `executionContext` before reaching here — so on that path this block
// re-resolves idempotently (a no-op in single-kernel mode). Kept for
// DIRECT `handleActions` callers (unit tests / internal dispatch) so the
// call still lands on the kernel where the bundle's actions are
// registered, not the control-plane kernel (ADR-0006 Phase 5).
let projectQl: any = null;
if (this.kernelResolver && _context.environmentId && _context.environmentId !== 'platform') {
try {
Expand Down Expand Up @@ -3417,8 +3419,25 @@ export class HttpDispatcher {
},
};

const userIdFromAuth = (_context as any)?.user?.id ?? (_context as any)?.userId ?? 'system';
const userFromAuth = (_context as any)?.user ?? { id: userIdFromAuth, name: userIdFromAuth };
// Resolve the caller identity from the request's ExecutionContext — the
// single source `dispatch()` populates via `resolveExecutionContext`,
// the same envelope the MCP `runAction` and record-change trigger paths
// read. The action body sandbox receives the operator's id and business
// roles (ADR-0090 `positions`, formerly `roles`) so a handler can branch
// on identity and enforce ownership. Falls back to a `system` principal
// only for a genuinely anonymous / self-invoked call (#2701).
const ec: any = _context?.executionContext;
const userFromAuth = ec?.userId
? {
id: ec.userId,
name: ec.userId,
email: ec.email,
roles: Array.isArray(ec.positions) ? ec.positions : [],
positions: Array.isArray(ec.positions) ? ec.positions : [],
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
tenantId: ec.tenantId,
}
: { id: 'system', name: 'system', roles: [], positions: [], permissions: [] };

const actionContext: any = {
record,
Expand Down