diff --git a/.changeset/repo-execute-elevated-context.md b/.changeset/repo-execute-elevated-context.md new file mode 100644 index 0000000000..67128a2df2 --- /dev/null +++ b/.changeset/repo-execute-elevated-context.md @@ -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. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0a598b835c..35b3822482 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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 | diff --git a/packages/objectql/src/engine-repo-execute-elevation.test.ts b/packages/objectql/src/engine-repo-execute-elevation.test.ts new file mode 100644 index 0000000000..5ca1330185 --- /dev/null +++ b/packages/objectql/src/engine-repo-execute-elevation.test.ts @@ -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>(); + 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) { + 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) { + 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[]) { + 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'); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index bf75d36620..1a476fb09c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -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 { 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`);