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
11 changes: 11 additions & 0 deletions .changeset/repo-execute-elevated-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/objectql': patch
---

`ObjectRepository.execute()` (the `repo.execute(actionName, params)` face a hook or action body reaches as `ctx.api.object(name).execute(...)`) now dispatches the action handler under the same elevated `ScopedContext` REST `/actions` and MCP `run_action` already give an action body — closing the third of three `executeAction` callers that #3914 argues must never run identity-less.

Before this change, the handler's `ctx` carried `params`, `userId`, `tenantId` and `roles` but neither `api` nor `executionContext`: a handler composing a sibling write via `ctx.api.object(x).update(y)` got `ctx.api === undefined`, and the sandbox's own last-resort fallback ran that write as a non-system caller — so the engine's static `readonly` strip (`!opCtx.context?.isSystem`) applied to a write made through this path and not to the identical write made through REST `/actions` or MCP `run_action`.

`ctx.api` is now a real `ScopedContext` bound to `{ ...callerContext, isSystem: true }` — the caller's own envelope, elevated — the same `sudo()`-shaped formula `buildActionExecutionContext` and `recomputeSummaries`'s `systemCtx` already use, so `userId`/`tenantId` still stamp the write and an open transaction still joins rather than escapes. `ctx.executionContext` carries the same elevated envelope, matching the REST/MCP shape exactly.

**What widens**: a `readonly: true` field a handler writes through `ctx.api.object(x).update(y)` when reached via `repo.execute()` now lands instead of being silently stripped, matching REST `/actions` and MCP `run_action`. A repo-wide census (production + test, `examples/` and `apps/` included) found no existing caller of `ObjectRepository.execute()` — every hit in the tree was prose describing the shape, never an invocation — so no shipped write changes behaviour.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ a reader tracing where elevation travels needs them.
| # | Site | Package | What it does |
|:--|:---|:---|:---|
| 62 | `objectql/src/engine.ts:3543` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
| 63 | `objectql/src/engine.ts:14463` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 63 | `objectql/src/engine.ts:14496` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report |
| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |

Expand Down
221 changes: 221 additions & 0 deletions packages/objectql/src/engine-repo-execute-elevation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13866 — Director ruling 决裁批 #24 (2026-09-01), clause 2: give
// `ObjectRepository.execute()` the same elevated `ScopedContext` REST
// `/actions` and MCP `run_action` already supply an action body (#13832),
// so all three `executeAction` dispatch paths behave identically under the
// platform's documented trusted posture and #3914's identity-less shape is
// gone from the third one.
//
// Before this fix, `ObjectRepository.execute()` handed the handler
// `{ ...params, userId, tenantId, roles }` — no `api`, no `executionContext`.
// A handler reaching a sibling write via `ctx.api.object(x).update(y)` (the
// in-process action-composition shape `action-execution.ts` and
// `body-runner.ts` document as this method's own reason to exist) got
// `ctx.api === undefined` and threw, or — for the sandbox's own last-resort
// facade — a context-less repo whose writes ran as a non-system caller, so
// the engine's static `readonly` strip (`!opCtx.context?.isSystem`,
// `validation/rule-validator.ts`) applied to it and NOT to the same write
// made through REST `/actions` or MCP `run_action`. This suite pins the
// fixed shape: `ctx.api` is a real `ScopedContext` bound to
// `{ ...callerContext, isSystem: true }` — the same `sudo()`-shaped
// elevation `buildActionExecutionContext` uses — so a `readonly: true`
// column a handler writes through `ctx.api` now LANDS on this path exactly
// as it already does on the other two.
//
// The census this ruling required (repo + `examples/` + `apps/`, production
// and test) found ZERO existing callers of `ObjectRepository.execute()` —
// every hit was prose describing the shape (`action-execution.ts`,
// `body-runner.ts`, `validate-readonly-action-writes.ts`, this method's own
// call site), never an invocation — so nothing shipped today depends on the
// old, context-less behaviour this suite retires.

import { describe, it, expect } from 'vitest';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { ObjectQL, ScopedContext } from './engine.js';

function makeDriver() {
const stores = new Map<string, Map<string, any>>();
const storeFor = (o: string) => {
let s = stores.get(o);
if (!s) { s = new Map(); stores.set(o, s); }
return s;
};
const matches = (row: any, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
return Object.entries(where).every(([k, v]: [string, any]) => {
// [check:where-matcher] REFUSE a combinator this fixture does not
// implement, rather than silently reading it as a field name — the
// exact fake-driver idiom `engine-readonly-strip-caller-values.test.ts`
// already carries.
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return row?.[k] === v;
});
};
let n = 0;
const driver: any = {
name: 'memory', version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
async find(object: string, ast: any) {
const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
// [check:objectql-double-limit] Apply the caller's bound AFTER the
// filter, by PRESENCE — this fixture is not under test for pagination,
// but a `find` double that silently ignores `limit` is exactly the
// shape that gate exists to catch.
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
},
async findOne(object: string, ast: any) {
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
return null;
},
async create(object: string, data: Record<string, unknown>) {
n += 1;
const id = (data.id as string) ?? `r_${n}`;
const row = { ...data, id };
storeFor(object).set(id, row);
return row;
},
async update(object: string, id: string, data: Record<string, unknown>) {
const s = storeFor(object);
const row = { ...s.get(id), ...data, id };
s.set(id, row);
return row;
},
async updateMany() { return 0; },
async delete(object: string, id: string) { return storeFor(object).delete(id); },
async count() { return 0; },
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
return Promise.all(rows.map((r) => this.create(object, r, undefined)));
},
async bulkUpdate() { return []; }, async bulkDelete() {},
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
async commit() {}, async rollback() {},
};
return { driver, storeFor };
}

function makeRig() {
const engine = new ObjectQL({});
const d = makeDriver();
engine.registerDriver(d.driver, true);
engine.registry.registerObject({
name: 'os_repo_execute_probe',
fields: {
title: { type: 'text' },
// Author-declared lock — the exact gate `!opCtx.context?.isSystem`
// guards (`validation/rule-validator.ts`'s `stripReadonlyFields`).
stamped_by: { type: 'text', readonly: true },
},
} as any);
return { engine, storeFor: d.storeFor };
}

describe('ObjectRepository.execute() elevation (#13866, 决裁批 #24)', () => {
it('THE FIX: a readonly-field write through ctx.api LANDS, matching REST/MCP', async () => {
const { engine, storeFor } = makeRig();
await engine.init();
storeFor('os_repo_execute_probe').set('p_1', { id: 'p_1', title: 'A', stamped_by: null });

engine.registerAction('os_repo_execute_probe', 'stamp', async (ctx: any) => {
// The in-process composition shape this method exists for: a handler
// reaching a sibling write via `ctx.api.object(x).update(y)`.
await ctx.api.object('os_repo_execute_probe').update({ id: ctx.id, stamped_by: 'action-body' });
return { ok: true };
});

const callerCtx: ExecutionContext = { userId: 'u_1', tenantId: 't_1' } as any;
const repo = new ScopedContext(callerCtx, engine as any).object('os_repo_execute_probe');
const result = await repo.execute('stamp', { id: 'p_1' });

expect(result).toEqual({ ok: true });
// THE REGRESSION, stated as the value it must NOT be: before the fix
// `ctx.api` was `undefined` (throwing) or a context-less facade whose
// write the static strip silently discarded, leaving `stamped_by: null`.
expect(storeFor('os_repo_execute_probe').get('p_1').stamped_by).toBe('action-body');
});

it('ctx.executionContext carries isSystem: true — the same envelope buildActionExecutionContext builds', async () => {
const { engine } = makeRig();
await engine.init();
let seenExecutionContext: any;
let seenApi: any;
engine.registerAction('os_repo_execute_probe', 'inspect', async (ctx: any) => {
seenExecutionContext = ctx.executionContext;
seenApi = ctx.api;
return { ok: true };
});

const callerCtx: ExecutionContext = { userId: 'u_2', tenantId: 't_2' } as any;
await new ScopedContext(callerCtx, engine as any).object('os_repo_execute_probe').execute('inspect', {});

expect(seenExecutionContext).toMatchObject({ userId: 'u_2', tenantId: 't_2', isSystem: true });
expect(seenApi).toBeInstanceOf(ScopedContext);
});

it('the elevation is ATTRIBUTABLE, not anonymous: userId/tenantId still ride the elevated context', async () => {
// The reason `buildActionExecutionContext` spreads the caller's envelope
// FIRST rather than handing over a bare `{ isSystem: true }` — pinned
// here on the third path exactly as `recomputeSummaries`' `systemCtx`
// pins it on the second.
const { engine } = makeRig();
await engine.init();
let capturedUserId: unknown;
let capturedTenantId: unknown;
engine.registerAction('os_repo_execute_probe', 'capture_identity', async (ctx: any) => {
capturedUserId = ctx.executionContext.userId;
capturedTenantId = ctx.executionContext.tenantId;
return { ok: true };
});

const callerCtx: ExecutionContext = { userId: 'u_3', tenantId: 't_3' } as any;
await new ScopedContext(callerCtx, engine as any).object('os_repo_execute_probe').execute('capture_identity', {});

expect(capturedUserId).toBe('u_3');
expect(capturedTenantId).toBe('t_3');
});

it('an already-elevated caller composing through repo.execute() stays elevated (no regression)', async () => {
const { engine, storeFor } = makeRig();
await engine.init();
storeFor('os_repo_execute_probe').set('p_2', { id: 'p_2', title: 'B', stamped_by: null });

engine.registerAction('os_repo_execute_probe', 'stamp2', async (ctx: any) => {
await ctx.api.object('os_repo_execute_probe').update({ id: ctx.id, stamped_by: 'still-elevated' });
return { ok: true };
});

const systemCtx: ExecutionContext = { isSystem: true } as any;
await new ScopedContext(systemCtx, engine as any).object('os_repo_execute_probe').execute('stamp2', { id: 'p_2' });

expect(storeFor('os_repo_execute_probe').get('p_2').stamped_by).toBe('still-elevated');
});

it('PARITY: the same landed value a non-system caller updating directly with { context: { isSystem: true } } produces', async () => {
// Not a REST/MCP integration test (those live in `packages/runtime`,
// which cannot be imported from here without a circular dependency) —
// this asserts the same OUTCOME that posture produces on the write path
// both those doors and this one now share: a readonly write elevated by
// `isSystem: true` lands, non-elevated does not.
const { engine, storeFor } = makeRig();
await engine.init();
storeFor('os_repo_execute_probe').set('p_3', { id: 'p_3', title: 'C', stamped_by: null });
storeFor('os_repo_execute_probe').set('p_4', { id: 'p_4', title: 'D', stamped_by: null });

// The REST/MCP-equivalent direct write.
await engine.update(
'os_repo_execute_probe',
{ id: 'p_3', stamped_by: 'direct-elevated' },
{ context: { isSystem: true } } as any,
);

// The repo.execute()-mediated write, now under the same posture.
engine.registerAction('os_repo_execute_probe', 'stamp3', async (ctx: any) => {
await ctx.api.object('os_repo_execute_probe').update({ id: ctx.id, stamped_by: 'via-repo-execute' });
});
const callerCtx: ExecutionContext = { userId: 'u_4' } as any;
await new ScopedContext(callerCtx, engine as any).object('os_repo_execute_probe').execute('stamp3', { id: 'p_4' });

expect(storeFor('os_repo_execute_probe').get('p_3').stamped_by).toBe('direct-elevated');
expect(storeFor('os_repo_execute_probe').get('p_4').stamped_by).toBe('via-repo-execute');
});
});
35 changes: 34 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14072,14 +14072,47 @@ export class ObjectRepository implements IScopedObjectRepository {
});
}

/** Execute a named action registered on this object */
/**
* Execute a named action registered on this object.
*
* [#13866, Director ruling 决裁批 #24 2026-09-01] Elevated to the SAME
* trusted posture REST `/actions` (`domains/actions.ts`) and MCP
* `run_action` (`action-execution.ts`) already give an action body (#13832,
* #2849). This is the third `executeAction` caller those two files' own
* comments name `ScopedRepo.execute()` — until now it handed the body
* neither `api` nor `executionContext`, the context-less facade #3914
* argues an action body must never get. A caller reaching another action
* via `ctx.api.object(x).execute(y)` (an in-process handler composing a
* sibling handler, `action-execution.ts`'s own description of this path)
* now dispatches under the same identity its own body runs under.
*
* `{ ...this.context, isSystem: true }` is the `sudo()`-shaped elevation
* `buildActionExecutionContext` and `recomputeSummaries`'s `systemCtx` both
* use, not a bare `{ isSystem: true }`: spreading the caller's envelope
* FIRST keeps the resulting write attributable and correctly scoped —
* `userId` stamps `created_by`/`updated_by`, `tenantId` stamps the org
* column and drives driver-level tenant isolation, an open `transaction`
* joins rather than escapes — instead of the unattributable, org-less rows
* a bare `{ isSystem: true }` would produce.
*
* The census behind this change (repo + `examples/` + `apps/`, production
* and test) found ZERO existing callers of this method anywhere — every
* `ObjectRepository.execute()` / `ScopedRepo.execute()` hit in the tree was
* prose describing the shape, never an invocation — so this widens what a
* FUTURE caller's write is accepted to do (the static `readonly` strip now
* skips this path exactly as it already skips REST `/actions` and MCP
* `run_action`) without changing any write anyone ships today.
*/
async execute(actionName: string, params?: any): Promise<any> {
if (this.engine.executeAction) {
const executionContext: ExecutionContext = { ...this.context, isSystem: true };
return this.engine.executeAction(this.objectName, actionName, {
...params,
userId: this.context.userId,
tenantId: this.context.tenantId,
roles: this.context.positions,
executionContext,
api: new ScopedContext(executionContext, this.engine),
});
}
throw new Error(`Actions not supported by engine`);
Expand Down
Loading