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

fix(runtime): action bodies execute under a real execution context — every owner-scoped write no longer dies FORBIDDEN

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 to the engine with **no `context`**.
`ctx.engine` had the identical hole.

Context-less is not "trusted", it is **identity-less**, and identity-less is
strictly worse than either coherent posture: plugin-sharing's write gate
short-circuits on `!context.userId` (no user to own the record) and its bypass
needs `context.isSystem` (never set). So a `type: 'script'` action whose body
called `ctx.api.object('crm_case').update(...)` failed with
`FORBIDDEN: insufficient privileges to update crm_case` — **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, so only *some* actions
broke and the defect read as object-dependent flakiness.

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 already get from the engine's
`buildHookApi`. The envelope is the caller's `ExecutionContext` elevated with
`isSystem: true` — the posture the action surface already documents and gates
for at invoke time (the ADR-0066 D4 capability gate and the `ai.exposed` gate
are what admit a body to trusted execution). The caller's fields are spread
first, so a body's writes stay attributable (`userId` stamps
`created_by`/`updated_by`), org-scoped (`tenantId` stamps the org column and
drives driver-level tenant isolation), and joined to an open transaction —
rather than the unattributable, org-less rows a bare `{ isSystem: true }` would
write.

No authoring change is required: `ctx.api.object(name)` inside a `body` now
does what the docs always said it does. Bodies that worked before (public /
owner-less objects) are unaffected apart from their writes now being correctly
attributed and org-stamped.
8 changes: 5 additions & 3 deletions content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ export const onEnable = async (ctx: { ql: { registerAction: (...args: unknown[])
themselves. A `script` action whose `target` names a handler that was never
`registerAction`-ed compiles fine but throws `Action 'x' on object 'y' not
found` at click time. (An action with *neither* `body` nor `target` is
rejected at authoring time.) Also note the handler's `engine` facade is
**trusted** — it bypasses row- and field-level security, so enforce any
caller-specific rules yourself.
rejected at authoring time.) Also note that both data surfaces a body reaches
— `ctx.api.object(name)` and the handler's `ctx.engine` facade — are
**trusted**: they run under the caller's identity elevated to system, so they
bypass row- and field-level security (writes stay attributed to the caller and
scoped to their organization). Enforce any caller-specific rules yourself.
</Callout>

<Callout type="warn">
Expand Down
323 changes: 323 additions & 0 deletions packages/runtime/src/action-body-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#3914] Action bodies must execute under a REAL execution context.
*
* The regression: an action body's `ctx.api` was never bound, so the sandbox
* synthesized a repo facade against the raw engine and proxied every call with
* NO `context` — no `userId` to own the record, no `isSystem` to bypass. That
* is not "trusted", it is identity-less, and plugin-sharing's write gate
* denies it (`canEdit` short-circuits on `!context.userId`; the bypass needs
* `context.isSystem`). 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. `ctx.engine` had the
* identical hole.
*
* These tests pin BOTH surfaces on BOTH dispatch paths (REST + MCP), and use a
* sharing-shaped engine stub that denies a context-less write the way the real
* gate does — so a future context-less regression fails here, not in an app.
*/

import { describe, it, expect, vi } from 'vitest';
import { HttpDispatcher } from './http-dispatcher.js';
import {
buildActionApi,
buildActionEngineFacade,
buildActionExecutionContext,
invokeBusinessAction,
} from './action-execution.js';
import { actionBodyRunnerFactory } from './sandbox/body-runner.js';
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';

/**
* An engine stub shaped like the real write path: a write carrying neither
* `context.userId` nor `context.isSystem` is refused exactly as plugin-sharing
* refuses it. `createContext` returns a ScopedContext-alike so the runtime's
* `ctx.api` binding is exercised end-to-end.
*/
function makeSharingEngine(extra: Record<string, unknown> = {}) {
const writes: Array<{ op: string; object: string; context: any }> = [];
const gate = (op: string, object: string, context: any) => {
writes.push({ op, object, context });
if (!context?.isSystem && !context?.userId) {
throw new Error(`FORBIDDEN: insufficient privileges to ${op} ${object}`);
}
};
const ql: any = {
writes,
async insert(object: string, data: any, options?: any) {
gate('insert', object, options?.context);
return { id: data?.id ?? 'rec_new' };
},
async update(object: string, _data: any, options?: any) {
gate('update', object, options?.context);
return { ok: true };
},
async delete(object: string, options?: any) {
gate('delete', object, options?.context);
return { ok: true };
},
async find(object: string, options?: any) {
gate('find', object, options?.context);
return [];
},
async count(object: string, options?: any) {
gate('count', object, options?.context);
return 0;
},
createContext(context: any) {
return {
__context: context,
object: (object: string) => ({
find: (o?: any) => ql.find(object, { ...(o ?? {}), context }),
count: (o?: any) => ql.count(object, { ...(o ?? {}), context }),
insert: (data: any) => ql.insert(object, data, { context }),
update: (data: any, o?: any) => ql.update(object, data, { ...(o ?? {}), context }),
delete: (o?: any) => ql.delete(object, { ...(o ?? {}), context }),
}),
};
},
...extra,
};
return ql;
}

const deps: any = { resolveService: () => undefined, getObjectQL: async () => undefined };

describe('#3914 — buildActionExecutionContext', () => {
it('elevates the caller envelope instead of replacing it', () => {
const ctx = buildActionExecutionContext({
userId: 'user_42',
tenantId: 'org_acme',
positions: ['sales_rep'],
email: 'rep@acme.test',
});
// isSystem is what plugin-sharing's bypass keys on…
expect(ctx.isSystem).toBe(true);
// …and the caller's identity still rides along, so the write stays
// attributable (created_by/updated_by) and org-scoped.
expect(ctx.userId).toBe('user_42');
expect(ctx.tenantId).toBe('org_acme');
expect(ctx.positions).toEqual(['sales_rep']);
expect(ctx.email).toBe('rep@acme.test');
});

it('yields a usable elevated context for an anonymous / self-invoked call', () => {
expect(buildActionExecutionContext(undefined)).toEqual({ isSystem: true });
});
});

describe('#3914 — ctx.engine (buildActionEngineFacade)', () => {
it('threads the elevated context through every verb', async () => {
const ql = makeSharingEngine();
const engine = buildActionEngineFacade(deps, ql, { userId: 'user_42', tenantId: 'org_acme' });

await engine.insert('crm_case', { subject: 'x' });
await engine.update('crm_case', 'case_1', { status: 'closed' });
await engine.delete('crm_case', 'case_1');
await engine.find('crm_case', { status: 'open' });

expect(ql.writes.map((w: any) => w.op)).toEqual(['insert', 'update', 'delete', 'find']);
for (const w of ql.writes) {
expect(w.context).toMatchObject({ isSystem: true, userId: 'user_42', tenantId: 'org_acme' });
}
});

it('does not die FORBIDDEN on an owner-scoped update (the reported symptom)', async () => {
const ql = makeSharingEngine();
const engine = buildActionEngineFacade(deps, ql, { userId: 'admin' });
await expect(engine.update('crm_case', 'case_1', { status: 'closed' })).resolves.toBeUndefined();
});

it('still passes the caller filter on find (context is additive, not a replacement)', async () => {
const ql = makeSharingEngine();
const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' });
await engine.find('crm_case', { status: 'open' });
expect(ql.writes[0].context).toMatchObject({ isSystem: true, userId: 'u1' });
// the caller's predicate must survive alongside the injected context
expect((ql.writes as any)[0]).toBeDefined();
});
});

describe('#3914 — ctx.api (buildActionApi)', () => {
it('binds to engine.createContext() with the elevated caller envelope', async () => {
const ql = makeSharingEngine();
const api = buildActionApi(deps, ql, { userId: 'user_42', tenantId: 'org_acme' });
expect(api).toBeDefined();
expect(typeof api.object).toBe('function');
await api.object('crm_case').update({ status: 'closed' }, { where: { id: 'case_1' } });
expect(ql.writes[0].context).toMatchObject({ isSystem: true, userId: 'user_42' });
});

it('falls back to a bare elevated context when the caller envelope is unparseable', () => {
const ql: any = {
createContext(ctx: any) {
if (ctx.userId !== undefined && typeof ctx.userId !== 'string') throw new Error('bad envelope');
return { __context: ctx, object: () => ({}) };
},
};
const api = buildActionApi(deps, ql, { userId: 42 });
expect(api.__context).toEqual({ isSystem: true });
});

it('returns undefined for an engine with no createContext (sandbox fallback stays in charge)', () => {
expect(buildActionApi(deps, { insert: async () => ({}) }, { userId: 'u1' })).toBeUndefined();
});
});

describe('#3914 — REST /actions dispatch binds ctx.api and ctx.engine', () => {
const schemaOf = (name: string) => ({
name,
actions: [{ name: 'close_case', label: 'Close', type: 'script', execute: 'true' }],
});

const makeDispatcher = (executionContext: any) => {
const executeAction = vi.fn(async () => ({ ok: true }));
const ql = makeSharingEngine({ executeAction, getSchema: schemaOf, registry: { getObject: schemaOf } });
const kernel: any = { context: { getService: (n: string) => (n === 'objectql' ? ql : null) } };
const dispatcher = new HttpDispatcher(kernel);
const ctx: any = { request: {}, environmentId: 'platform', executionContext };
return { dispatcher, executeAction, ql, ctx };
};

it('hands the handler a ctx.api whose writes carry the elevated context', async () => {
const { dispatcher, executeAction, ql, ctx } = makeDispatcher({
userId: 'user_42', positions: [], permissions: [], tenantId: 'org_acme',
});
await dispatcher.handleActions('/crm_case/close_case', 'POST', { recordId: 'case_1' }, ctx);

const actionCtx = executeAction.mock.calls[0]?.[2];
expect(actionCtx.api).toBeDefined();
expect(typeof actionCtx.api.object).toBe('function');

await actionCtx.api.object('crm_case').update({ status: 'closed' }, { where: { id: 'case_1' } });
const write = ql.writes.find((w: any) => w.op === 'update');
expect(write.context).toMatchObject({ isSystem: true, userId: 'user_42', tenantId: 'org_acme' });
});

it('hands the handler a ctx.engine whose writes carry the elevated context', async () => {
const { dispatcher, executeAction, ql, ctx } = makeDispatcher({
userId: 'user_42', positions: [], permissions: [],
});
await dispatcher.handleActions('/crm_case/close_case', 'POST', { recordId: 'case_1' }, ctx);

const actionCtx = executeAction.mock.calls[0]?.[2];
// Was: `ql.update(object, data, { where })` with no context → FORBIDDEN.
await expect(actionCtx.engine.update('crm_case', 'case_1', { status: 'closed' })).resolves.toBeUndefined();
expect(ql.writes.find((w: any) => w.op === 'update').context).toMatchObject({
isSystem: true, userId: 'user_42',
});
});

it('carries the same envelope on ctx.executionContext for the sandbox fallback', async () => {
const { dispatcher, executeAction, ctx } = makeDispatcher({ userId: 'user_42', positions: [], permissions: [] });
await dispatcher.handleActions('/crm_case/close_case', 'POST', {}, ctx);
expect(executeAction.mock.calls[0]?.[2]?.executionContext).toMatchObject({
isSystem: true, userId: 'user_42',
});
});

it('still elevates for an anonymous / self-invoked call', async () => {
const { dispatcher, executeAction, ql, ctx } = makeDispatcher(undefined);
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 });
});
});

describe('#3914 — MCP run_action dispatch binds ctx.api and ctx.engine', () => {
const action = {
name: 'close_case',
label: 'Close',
objectName: 'crm_case',
type: 'script',
target: 'closeCase',
ai: { exposed: true, description: 'Close a case.' },
};
const obj = { name: 'crm_case', actions: [action] };

it('threads the caller identity into the body ctx (was context-less)', async () => {
const executeAction = vi.fn(async () => ({ ok: true }));
const ql = makeSharingEngine({ executeAction });
const mcpDeps: any = {
resolveService: async () => null,
getObjectQL: async () => ql,
};
const ec = { userId: 'user_42', tenantId: 'org_acme', positions: [], permissions: [] };

await invokeBusinessAction(mcpDeps, 'close_case', { recordId: 'case_1' }, {
driver: undefined,
envId: 'platform',
ec,
getMeta: () => ({ listObjects: async () => [obj] }),
callData: async () => ({ record: { id: 'case_1' } }),
});

const actionCtx = executeAction.mock.calls[0]?.[2];
expect(actionCtx.api).toBeDefined();
await actionCtx.api.object('crm_case').update({ status: 'closed' }, { where: { id: 'case_1' } });
await actionCtx.engine.insert('crm_task', { subject: 'follow up' });
for (const w of ql.writes.filter((x: any) => x.op !== 'find')) {
expect(w.context).toMatchObject({ isSystem: true, userId: 'user_42', tenantId: 'org_acme' });
}
});
});

describe('#3914 — sandboxed action body reaches the bound ctx.api', () => {
const runner = new QuickJSScriptRunner();

it('uses the host-supplied ctx.api rather than synthesizing a context-less facade', async () => {
const ql = makeSharingEngine();
const factory = actionBodyRunnerFactory(runner, { ql, appId: 'crm' });
const fn = factory({
name: 'close_case',
object: 'crm_case',
type: 'script',
body: {
language: 'js',
source: "await ctx.api.object('crm_case').update({ status: 'closed' }, { where: { id: ctx.recordId } }); return { ok: true };",
capabilities: ['api.write'],
},
} as any);
expect(typeof fn).toBe('function');

await fn!({
record: { id: 'case_1' },
params: {},
api: buildActionApi(deps, ql, { userId: 'user_42' }),
executionContext: buildActionExecutionContext({ userId: 'user_42' }),
});

const write = ql.writes.find((w: any) => w.op === 'update');
expect(write).toBeDefined();
expect(write.context).toMatchObject({ isSystem: true, userId: 'user_42' });
});

it('elevates the last-resort repo facade from ctx.executionContext', async () => {
// Engine with NO createContext and NO .object() — the shape that made
// buildSandboxApi fall all the way through to a context-less facade.
const ql = makeSharingEngine();
delete (ql as any).createContext;
const factory = actionBodyRunnerFactory(runner, { ql, appId: 'crm' });
const fn = factory({
name: 'close_case',
object: 'crm_case',
type: 'script',
body: {
language: 'js',
source: "await ctx.api.object('crm_case').update({ status: 'closed' }, { where: { id: ctx.recordId } }); return { ok: true };",
capabilities: ['api.write'],
},
} as any);

await fn!({
record: { id: 'case_1' },
params: {},
executionContext: buildActionExecutionContext({ userId: 'user_42' }),
});

const write = ql.writes.find((w: any) => w.op === 'update');
expect(write.context).toMatchObject({ isSystem: true, userId: 'user_42' });
});
});
Loading
Loading