Skip to content

Commit bc35e00

Browse files
authored
fix(runtime): bind action-body ctx.api/ctx.engine to a real execution context (#3914) (#3922)
An action body's `ctx.api` was never bound. The sandbox's `buildSandboxApi` walked its whole fallback chain — no `actionCtx.api`, and the raw ObjectQL engine has no `.object()` (that lives on `ScopedContext`, reachable only via `engine.createContext()`, which the action path never called) — and landed on a repo facade that proxied every call with NO `context`. `ctx.engine` had the identical hole. Context-less is not "trusted", it is identity-less: plugin-sharing's write gate short-circuits on `!context.userId` and its bypass needs `context.isSystem`, so every owner-scoped write from an action body died FORBIDDEN — as the built-in admin — while the `[action-audit]` line on the same request announced RLS-bypassing TRUSTED execution. Objects with a public sharing model, no owner field, or a bypass listing passed the gate early, which is why only some actions broke. Both dispatch paths (REST `/actions/:object/:action` and MCP `run_action`) now bind `ctx.api` to `engine.createContext(...)` and thread the same envelope through `ctx.engine`, matching what hook bodies get from `buildHookApi`. The envelope is the caller's ExecutionContext elevated with `isSystem: true` — the posture #2849 documents and gates for at invoke time — with the caller's fields spread first so writes stay attributable, org-scoped, and joined to an open transaction. </commit_message> </invoke>
1 parent 98e7cc7 commit bc35e00

6 files changed

Lines changed: 520 additions & 65 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): action bodies execute under a real execution context — every owner-scoped write no longer dies FORBIDDEN
6+
7+
An action body's `ctx.api` was never bound. The sandbox's `buildSandboxApi`
8+
walked its whole fallback chain — no `actionCtx.api`, and the raw `ObjectQL`
9+
engine has no `.object()` (that lives on `ScopedContext`, reachable only via
10+
`engine.createContext()`, which the action path never called) — and landed on a
11+
repo facade that proxied every call to the engine with **no `context`**.
12+
`ctx.engine` had the identical hole.
13+
14+
Context-less is not "trusted", it is **identity-less**, and identity-less is
15+
strictly worse than either coherent posture: plugin-sharing's write gate
16+
short-circuits on `!context.userId` (no user to own the record) and its bypass
17+
needs `context.isSystem` (never set). So a `type: 'script'` action whose body
18+
called `ctx.api.object('crm_case').update(...)` failed with
19+
`FORBIDDEN: insufficient privileges to update crm_case`**as the built-in
20+
admin** — while the `[action-audit]` line on the same request announced
21+
RLS-bypassing TRUSTED execution. Objects with a `public` sharing model, no
22+
owner field, or a bypass listing passed the gate early, so only *some* actions
23+
broke and the defect read as object-dependent flakiness.
24+
25+
Both dispatch paths (REST `/actions/:object/:action` and MCP `run_action`) now
26+
bind `ctx.api` to `engine.createContext(...)` and thread the same envelope
27+
through `ctx.engine`, matching what hook bodies already get from the engine's
28+
`buildHookApi`. The envelope is the caller's `ExecutionContext` elevated with
29+
`isSystem: true` — the posture the action surface already documents and gates
30+
for at invoke time (the ADR-0066 D4 capability gate and the `ai.exposed` gate
31+
are what admit a body to trusted execution). The caller's fields are spread
32+
first, so a body's writes stay attributable (`userId` stamps
33+
`created_by`/`updated_by`), org-scoped (`tenantId` stamps the org column and
34+
drives driver-level tenant isolation), and joined to an open transaction —
35+
rather than the unattributable, org-less rows a bare `{ isSystem: true }` would
36+
write.
37+
38+
No authoring change is required: `ctx.api.object(name)` inside a `body` now
39+
does what the docs always said it does. Bodies that worked before (public /
40+
owner-less objects) are unaffected apart from their writes now being correctly
41+
attributed and org-stamped.

content/docs/ui/actions.mdx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,11 @@ export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[])
125125
themselves. A `script` action whose `target` names a handler that was never
126126
`registerAction`-ed compiles fine but throws `Action 'x' on object 'y' not
127127
found` at click time. (An action with *neither* `body` nor `target` is
128-
rejected at authoring time.) Also note the handler's `engine` facade is
129-
**trusted** — it bypasses row- and field-level security, so enforce any
130-
caller-specific rules yourself.
128+
rejected at authoring time.) Also note that both data surfaces a body reaches
129+
`ctx.api.object(name)` and the handler's `ctx.engine` facade — are
130+
**trusted**: they run under the caller's identity elevated to system, so they
131+
bypass row- and field-level security (writes stay attributed to the caller and
132+
scoped to their organization). Enforce any caller-specific rules yourself.
131133
</Callout>
132134

133135
<Callout type="warn">
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#3914] Action bodies must execute under a REAL execution context.
5+
*
6+
* The regression: an action body's `ctx.api` was never bound, so the sandbox
7+
* synthesized a repo facade against the raw engine and proxied every call with
8+
* NO `context` — no `userId` to own the record, no `isSystem` to bypass. That
9+
* is not "trusted", it is identity-less, and plugin-sharing's write gate
10+
* denies it (`canEdit` short-circuits on `!context.userId`; the bypass needs
11+
* `context.isSystem`). Every owner-scoped write from an action body died
12+
* FORBIDDEN — as the built-in admin — while the `[action-audit]` line on the
13+
* same request announced RLS-bypassing TRUSTED execution. `ctx.engine` had the
14+
* identical hole.
15+
*
16+
* These tests pin BOTH surfaces on BOTH dispatch paths (REST + MCP), and use a
17+
* sharing-shaped engine stub that denies a context-less write the way the real
18+
* gate does — so a future context-less regression fails here, not in an app.
19+
*/
20+
21+
import { describe, it, expect, vi } from 'vitest';
22+
import { HttpDispatcher } from './http-dispatcher.js';
23+
import {
24+
buildActionApi,
25+
buildActionEngineFacade,
26+
buildActionExecutionContext,
27+
invokeBusinessAction,
28+
} from './action-execution.js';
29+
import { actionBodyRunnerFactory } from './sandbox/body-runner.js';
30+
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
31+
32+
/**
33+
* An engine stub shaped like the real write path: a write carrying neither
34+
* `context.userId` nor `context.isSystem` is refused exactly as plugin-sharing
35+
* refuses it. `createContext` returns a ScopedContext-alike so the runtime's
36+
* `ctx.api` binding is exercised end-to-end.
37+
*/
38+
function makeSharingEngine(extra: Record<string, unknown> = {}) {
39+
const writes: Array<{ op: string; object: string; context: any }> = [];
40+
const gate = (op: string, object: string, context: any) => {
41+
writes.push({ op, object, context });
42+
if (!context?.isSystem && !context?.userId) {
43+
throw new Error(`FORBIDDEN: insufficient privileges to ${op} ${object}`);
44+
}
45+
};
46+
const ql: any = {
47+
writes,
48+
async insert(object: string, data: any, options?: any) {
49+
gate('insert', object, options?.context);
50+
return { id: data?.id ?? 'rec_new' };
51+
},
52+
async update(object: string, _data: any, options?: any) {
53+
gate('update', object, options?.context);
54+
return { ok: true };
55+
},
56+
async delete(object: string, options?: any) {
57+
gate('delete', object, options?.context);
58+
return { ok: true };
59+
},
60+
async find(object: string, options?: any) {
61+
gate('find', object, options?.context);
62+
return [];
63+
},
64+
async count(object: string, options?: any) {
65+
gate('count', object, options?.context);
66+
return 0;
67+
},
68+
createContext(context: any) {
69+
return {
70+
__context: context,
71+
object: (object: string) => ({
72+
find: (o?: any) => ql.find(object, { ...(o ?? {}), context }),
73+
count: (o?: any) => ql.count(object, { ...(o ?? {}), context }),
74+
insert: (data: any) => ql.insert(object, data, { context }),
75+
update: (data: any, o?: any) => ql.update(object, data, { ...(o ?? {}), context }),
76+
delete: (o?: any) => ql.delete(object, { ...(o ?? {}), context }),
77+
}),
78+
};
79+
},
80+
...extra,
81+
};
82+
return ql;
83+
}
84+
85+
const deps: any = { resolveService: () => undefined, getObjectQL: async () => undefined };
86+
87+
describe('#3914 — buildActionExecutionContext', () => {
88+
it('elevates the caller envelope instead of replacing it', () => {
89+
const ctx = buildActionExecutionContext({
90+
userId: 'user_42',
91+
tenantId: 'org_acme',
92+
positions: ['sales_rep'],
93+
email: 'rep@acme.test',
94+
});
95+
// isSystem is what plugin-sharing's bypass keys on…
96+
expect(ctx.isSystem).toBe(true);
97+
// …and the caller's identity still rides along, so the write stays
98+
// attributable (created_by/updated_by) and org-scoped.
99+
expect(ctx.userId).toBe('user_42');
100+
expect(ctx.tenantId).toBe('org_acme');
101+
expect(ctx.positions).toEqual(['sales_rep']);
102+
expect(ctx.email).toBe('rep@acme.test');
103+
});
104+
105+
it('yields a usable elevated context for an anonymous / self-invoked call', () => {
106+
expect(buildActionExecutionContext(undefined)).toEqual({ isSystem: true });
107+
});
108+
});
109+
110+
describe('#3914 — ctx.engine (buildActionEngineFacade)', () => {
111+
it('threads the elevated context through every verb', async () => {
112+
const ql = makeSharingEngine();
113+
const engine = buildActionEngineFacade(deps, ql, { userId: 'user_42', tenantId: 'org_acme' });
114+
115+
await engine.insert('crm_case', { subject: 'x' });
116+
await engine.update('crm_case', 'case_1', { status: 'closed' });
117+
await engine.delete('crm_case', 'case_1');
118+
await engine.find('crm_case', { status: 'open' });
119+
120+
expect(ql.writes.map((w: any) => w.op)).toEqual(['insert', 'update', 'delete', 'find']);
121+
for (const w of ql.writes) {
122+
expect(w.context).toMatchObject({ isSystem: true, userId: 'user_42', tenantId: 'org_acme' });
123+
}
124+
});
125+
126+
it('does not die FORBIDDEN on an owner-scoped update (the reported symptom)', async () => {
127+
const ql = makeSharingEngine();
128+
const engine = buildActionEngineFacade(deps, ql, { userId: 'admin' });
129+
await expect(engine.update('crm_case', 'case_1', { status: 'closed' })).resolves.toBeUndefined();
130+
});
131+
132+
it('still passes the caller filter on find (context is additive, not a replacement)', async () => {
133+
const ql = makeSharingEngine();
134+
const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' });
135+
await engine.find('crm_case', { status: 'open' });
136+
expect(ql.writes[0].context).toMatchObject({ isSystem: true, userId: 'u1' });
137+
// the caller's predicate must survive alongside the injected context
138+
expect((ql.writes as any)[0]).toBeDefined();
139+
});
140+
});
141+
142+
describe('#3914 — ctx.api (buildActionApi)', () => {
143+
it('binds to engine.createContext() with the elevated caller envelope', async () => {
144+
const ql = makeSharingEngine();
145+
const api = buildActionApi(deps, ql, { userId: 'user_42', tenantId: 'org_acme' });
146+
expect(api).toBeDefined();
147+
expect(typeof api.object).toBe('function');
148+
await api.object('crm_case').update({ status: 'closed' }, { where: { id: 'case_1' } });
149+
expect(ql.writes[0].context).toMatchObject({ isSystem: true, userId: 'user_42' });
150+
});
151+
152+
it('falls back to a bare elevated context when the caller envelope is unparseable', () => {
153+
const ql: any = {
154+
createContext(ctx: any) {
155+
if (ctx.userId !== undefined && typeof ctx.userId !== 'string') throw new Error('bad envelope');
156+
return { __context: ctx, object: () => ({}) };
157+
},
158+
};
159+
const api = buildActionApi(deps, ql, { userId: 42 });
160+
expect(api.__context).toEqual({ isSystem: true });
161+
});
162+
163+
it('returns undefined for an engine with no createContext (sandbox fallback stays in charge)', () => {
164+
expect(buildActionApi(deps, { insert: async () => ({}) }, { userId: 'u1' })).toBeUndefined();
165+
});
166+
});
167+
168+
describe('#3914 — REST /actions dispatch binds ctx.api and ctx.engine', () => {
169+
const schemaOf = (name: string) => ({
170+
name,
171+
actions: [{ name: 'close_case', label: 'Close', type: 'script', execute: 'true' }],
172+
});
173+
174+
const makeDispatcher = (executionContext: any) => {
175+
const executeAction = vi.fn(async () => ({ ok: true }));
176+
const ql = makeSharingEngine({ executeAction, getSchema: schemaOf, registry: { getObject: schemaOf } });
177+
const kernel: any = { context: { getService: (n: string) => (n === 'objectql' ? ql : null) } };
178+
const dispatcher = new HttpDispatcher(kernel);
179+
const ctx: any = { request: {}, environmentId: 'platform', executionContext };
180+
return { dispatcher, executeAction, ql, ctx };
181+
};
182+
183+
it('hands the handler a ctx.api whose writes carry the elevated context', async () => {
184+
const { dispatcher, executeAction, ql, ctx } = makeDispatcher({
185+
userId: 'user_42', positions: [], permissions: [], tenantId: 'org_acme',
186+
});
187+
await dispatcher.handleActions('/crm_case/close_case', 'POST', { recordId: 'case_1' }, ctx);
188+
189+
const actionCtx = executeAction.mock.calls[0]?.[2];
190+
expect(actionCtx.api).toBeDefined();
191+
expect(typeof actionCtx.api.object).toBe('function');
192+
193+
await actionCtx.api.object('crm_case').update({ status: 'closed' }, { where: { id: 'case_1' } });
194+
const write = ql.writes.find((w: any) => w.op === 'update');
195+
expect(write.context).toMatchObject({ isSystem: true, userId: 'user_42', tenantId: 'org_acme' });
196+
});
197+
198+
it('hands the handler a ctx.engine whose writes carry the elevated context', async () => {
199+
const { dispatcher, executeAction, ql, ctx } = makeDispatcher({
200+
userId: 'user_42', positions: [], permissions: [],
201+
});
202+
await dispatcher.handleActions('/crm_case/close_case', 'POST', { recordId: 'case_1' }, ctx);
203+
204+
const actionCtx = executeAction.mock.calls[0]?.[2];
205+
// Was: `ql.update(object, data, { where })` with no context → FORBIDDEN.
206+
await expect(actionCtx.engine.update('crm_case', 'case_1', { status: 'closed' })).resolves.toBeUndefined();
207+
expect(ql.writes.find((w: any) => w.op === 'update').context).toMatchObject({
208+
isSystem: true, userId: 'user_42',
209+
});
210+
});
211+
212+
it('carries the same envelope on ctx.executionContext for the sandbox fallback', async () => {
213+
const { dispatcher, executeAction, ctx } = makeDispatcher({ userId: 'user_42', positions: [], permissions: [] });
214+
await dispatcher.handleActions('/crm_case/close_case', 'POST', {}, ctx);
215+
expect(executeAction.mock.calls[0]?.[2]?.executionContext).toMatchObject({
216+
isSystem: true, userId: 'user_42',
217+
});
218+
});
219+
220+
it('still elevates for an anonymous / self-invoked call', async () => {
221+
const { dispatcher, executeAction, ql, ctx } = makeDispatcher(undefined);
222+
await dispatcher.handleActions('/crm_case/close_case', 'POST', {}, ctx);
223+
const actionCtx = executeAction.mock.calls[0]?.[2];
224+
await actionCtx.engine.update('crm_case', 'case_1', { status: 'closed' });
225+
expect(ql.writes.find((w: any) => w.op === 'update').context).toMatchObject({ isSystem: true });
226+
});
227+
});
228+
229+
describe('#3914 — MCP run_action dispatch binds ctx.api and ctx.engine', () => {
230+
const action = {
231+
name: 'close_case',
232+
label: 'Close',
233+
objectName: 'crm_case',
234+
type: 'script',
235+
target: 'closeCase',
236+
ai: { exposed: true, description: 'Close a case.' },
237+
};
238+
const obj = { name: 'crm_case', actions: [action] };
239+
240+
it('threads the caller identity into the body ctx (was context-less)', async () => {
241+
const executeAction = vi.fn(async () => ({ ok: true }));
242+
const ql = makeSharingEngine({ executeAction });
243+
const mcpDeps: any = {
244+
resolveService: async () => null,
245+
getObjectQL: async () => ql,
246+
};
247+
const ec = { userId: 'user_42', tenantId: 'org_acme', positions: [], permissions: [] };
248+
249+
await invokeBusinessAction(mcpDeps, 'close_case', { recordId: 'case_1' }, {
250+
driver: undefined,
251+
envId: 'platform',
252+
ec,
253+
getMeta: () => ({ listObjects: async () => [obj] }),
254+
callData: async () => ({ record: { id: 'case_1' } }),
255+
});
256+
257+
const actionCtx = executeAction.mock.calls[0]?.[2];
258+
expect(actionCtx.api).toBeDefined();
259+
await actionCtx.api.object('crm_case').update({ status: 'closed' }, { where: { id: 'case_1' } });
260+
await actionCtx.engine.insert('crm_task', { subject: 'follow up' });
261+
for (const w of ql.writes.filter((x: any) => x.op !== 'find')) {
262+
expect(w.context).toMatchObject({ isSystem: true, userId: 'user_42', tenantId: 'org_acme' });
263+
}
264+
});
265+
});
266+
267+
describe('#3914 — sandboxed action body reaches the bound ctx.api', () => {
268+
const runner = new QuickJSScriptRunner();
269+
270+
it('uses the host-supplied ctx.api rather than synthesizing a context-less facade', async () => {
271+
const ql = makeSharingEngine();
272+
const factory = actionBodyRunnerFactory(runner, { ql, appId: 'crm' });
273+
const fn = factory({
274+
name: 'close_case',
275+
object: 'crm_case',
276+
type: 'script',
277+
body: {
278+
language: 'js',
279+
source: "await ctx.api.object('crm_case').update({ status: 'closed' }, { where: { id: ctx.recordId } }); return { ok: true };",
280+
capabilities: ['api.write'],
281+
},
282+
} as any);
283+
expect(typeof fn).toBe('function');
284+
285+
await fn!({
286+
record: { id: 'case_1' },
287+
params: {},
288+
api: buildActionApi(deps, ql, { userId: 'user_42' }),
289+
executionContext: buildActionExecutionContext({ userId: 'user_42' }),
290+
});
291+
292+
const write = ql.writes.find((w: any) => w.op === 'update');
293+
expect(write).toBeDefined();
294+
expect(write.context).toMatchObject({ isSystem: true, userId: 'user_42' });
295+
});
296+
297+
it('elevates the last-resort repo facade from ctx.executionContext', async () => {
298+
// Engine with NO createContext and NO .object() — the shape that made
299+
// buildSandboxApi fall all the way through to a context-less facade.
300+
const ql = makeSharingEngine();
301+
delete (ql as any).createContext;
302+
const factory = actionBodyRunnerFactory(runner, { ql, appId: 'crm' });
303+
const fn = factory({
304+
name: 'close_case',
305+
object: 'crm_case',
306+
type: 'script',
307+
body: {
308+
language: 'js',
309+
source: "await ctx.api.object('crm_case').update({ status: 'closed' }, { where: { id: ctx.recordId } }); return { ok: true };",
310+
capabilities: ['api.write'],
311+
},
312+
} as any);
313+
314+
await fn!({
315+
record: { id: 'case_1' },
316+
params: {},
317+
executionContext: buildActionExecutionContext({ userId: 'user_42' }),
318+
});
319+
320+
const write = ql.writes.find((w: any) => w.op === 'update');
321+
expect(write.context).toMatchObject({ isSystem: true, userId: 'user_42' });
322+
});
323+
});

0 commit comments

Comments
 (0)