From e989cacdea8b4e9d255653770b45be95d8de12ee Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 10 Jul 2026 10:30:14 +0800 Subject: [PATCH 1/2] chore: bump objectui to 7a68d78f2a0c chore: release packages (#2304) objectui@7a68d78f2a0c3c1f99dafd39b75b4f117a24917b --- .objectui-sha | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.objectui-sha b/.objectui-sha index e18aa26bfb..37dadd1210 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -b0aa25129542504b81589ee95bb81a5d6003f998 +7a68d78f2a0c3c1f99dafd39b75b4f117a24917b From f334cc7aaaa5e92f7af917bc89adf885462c6973 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Fri, 10 Jul 2026 11:15:47 +0800 Subject: [PATCH 2/2] fix(runtime): resolve session identity for action body ctx.user (#2701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POST /actions/:object/:action route called handleActions directly, bypassing dispatch() → resolveExecutionContext. The action body sandbox's ctx.user was therefore hard-coded to { id: 'system' }, so handlers could not branch on the operator's identity/roles or enforce server-side ownership. - dispatcher-plugin: action routes now dispatch through dispatch() (like the automation/AI routes), resolving the session identity + per-project kernel per request before the action body runs. - http-dispatcher: handleActions builds ctx.user from the request's ExecutionContext (id, email, positions/roles, permissions, tenantId), matching the MCP runAction and record-change trigger paths; falls back to a system principal only for a genuinely anonymous / self-invoked call. - Tests: 4 regression cases incl. an end-to-end api-key → dispatch → action pipeline asserting the principal reaches ctx.user (verified red without the fix). Co-Authored-By: Claude Opus 4.8 --- .changeset/action-ctx-user-identity.md | 23 ++++ packages/runtime/src/dispatcher-plugin.ts | 15 +-- packages/runtime/src/http-dispatcher.test.ts | 107 +++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 35 ++++-- 4 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 .changeset/action-ctx-user-identity.md diff --git a/.changeset/action-ctx-user-identity.md b/.changeset/action-ctx-user-identity.md new file mode 100644 index 0000000000..3caf722c43 --- /dev/null +++ b/.changeset/action-ctx-user-identity.md @@ -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. diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index ce9a80306f..43c7a1dc1b 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -971,12 +971,17 @@ 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); @@ -984,9 +989,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu }); 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); diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index db37b534ff..29b9dcd4ec 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -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) diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index b74c39b628..e0aeff722a 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -3244,12 +3244,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 { @@ -3339,8 +3341,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,