Skip to content

Commit 073f08b

Browse files
fix(security)!: a hook that runs as system and an action body write no identity assignment (#25954)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQXftB7pSBetnK4NoZD2gaR)_ Part of #25653 Clause-②: no (narrowing: app-authored hook and action-script writes to the four identity objects are refused at run time, as #25251 did for flows; the seat runs the contract review before the queue) Metadata-protocol: none The run-time half of the card. The save-time half (refusing at registration a hook or action body that names one of the four objects under system elevation, with its ADR-0087 D3 semantic entry) is `domain:spec` work, pointed to on #6017; the card stays open for it. ## What changed The #25251 refusal reads `isSystem` AND `flowRunId`. Two other app-authored elevations carry `isSystem` and no run, so it did not see them: a hook's `runAs: 'system'` api, and an action body's `ctx.api` / `ctx.engine`. Each is now derived with a provenance mark, and the one predicate reads it. | Context | Governed write to `sys_user_position`, `sys_user_permission_set`, `sys_invitation`, `sys_member` | Reads of the four | Writes to other objects | |---|---|---|---| | `runAs: 'system'` hook (sandboxed body or in-process handler) | **refused, PERMISSION_DENIED** (was: admitted past every gate) | admitted | admitted | | action body, `ctx.api` or `ctx.engine` | **refused, PERMISSION_DENIED** (was: admitted past every gate) | admitted | admitted | | `runAs: 'system'` flow | refused, word for word as before | admitted | admitted | | `runAs: 'user'` hook | not judged here; the ordinary gates judge the user it acts for | per the user | per the user | | platform `sudo()`, bare `{ isSystem: true }` writers | admitted, unchanged (no mark) | admitted | admitted | - `@objectstack/core`, new `security/app-automation-elevation.ts`: the mark is a registry symbol (`Symbol.for`, so the ESM and CJS builds agree) with `markAppAutomationElevation`, `appAutomationElevationOf` and `carryAppAutomationElevation`. A symbol cannot be spelled by a request body or a stored row, so no caller can claim or shed it. It lives in core because it is the one package objectql, runtime and plugin-security all depend on (the reasoning `operation-private-keys.ts` gives). - `objectql` `ScopedContext.withRunAs('system')` builds the `sudo()`-shaped context plus the `'hook'` mark. `sudo()` itself is untouched. - `runtime` `buildActionExecutionContext` adds the `'action'` mark; `buildActionApi`'s malformed-envelope fallback now goes through the same builder. - `objectql` `createContext` carries the mark across `ExecutionContextSchema.parse`. The parse keeps declared keys only, and an action body's `ctx.api` is built through it, so without the carry the body's `ctx.api` writes reached the refusal unmarked (measured: `ctx.engine` was refused and `ctx.api` was not). - `plugin-security` `flow-system-identity-write-refusal.ts`: the predicate is now `isSystem` AND (`flowRunId` OR the mark), in a new `elevationSource` helper. A context carrying both a run and a mark is answered as the flow, so a system flow's refusal is byte-identical. Each source names itself and its fix: a hook sets `runAs: 'user'`; an action body has no `runAs`, so its grant moves to a `runAs: 'user'` flow, which an action of type `flow` runs as the invoking user; invitations and memberships use the organization endpoints. No second module, no new error code. - `content/docs/permissions/system-context.mdx` row 1b covers the family; its anchor moves to `#elevationSource`, where the `isSystem` read now sits (`check-system-context-census` exit 0). - Comment only: the middleware's call-site comment in `security-plugin-authz-middleware.ts`. - Changeset: the four packages `patch` (`Metadata-protocol: none`), BREAKING banner, ADR-0087 `not-required (no-migration-prescription)`. ## Measured before any code (card condition 1) On `main` `cb0a2fcf`, real `ObjectQL` + SQL driver + `SecurityPlugin` + QuickJS, a plain member as the trigger or caller: - a `runAs: 'system'` hook, as an L2 body and as an in-process handler, wrote each of the four objects (positions and grants with `granted_by` null); - an L2 action body wrote each of the four objects; - an **L1 expression** action body cannot reach `ctx` at all, so it writes nothing. Per condition 1 that sub-path is reported as a fork, not fixed; - control: the same member's direct writes are refused `PERMISSION_DENIED`. Census: no hook, action body or host action handler in objectstack (`packages/`, `examples/`, `apps/`) or HotCRM writes the four objects; the writers on `main` are platform code with their own system contexts. Nothing shipped starts being refused. ## Tests - New `runtime/src/app-automation-identity-write-refusal.integration.test.ts` (50 cases), real engine, driver, security middleware and QuickJS, store read back past the middleware: - `runAs: 'system'` hook, body and handler × four objects × insert/update/delete: refused with `code`, `status`, source and prescription; the store does not move and the triggering write aborts; - action body `ctx.api` × four × three verbs, and `ctx.engine` per object: the same; - controls: the same hook writes another object; the same body reads the four and writes another object; the hook under `runAs: 'user'` is refused by the ordinary gates, not by this refusal; a `sudo()` derived from a user context and a platform system context insert, update and delete all four. - **Red first** (commit `7bb83a96`, before the fix): 40 refusal cases failed, 10 controls passed. - `plugin-security` refusal test: the predicate for both marks over every `GOVERNED_OPERATIONS` verb on each object, run-plus-mark answered as the flow, the mark without `isSystem` not judged, an unknown mark value read as none. PR1's cases unchanged and green. - `core` `app-automation-elevation.test.ts` (6): symbol identity, fresh copy, spread survival, JSON cannot spell it, dropped by the parse and carried across it. - `runtime/action-body-identity.test.ts`: two `toEqual({ isSystem: true })` pins now include the mark, plus one case that the builder carries it. Pins describe behaviour only. ## Verification All at head `a8b13e57`, under the verify lock: - `pnpm test` per package, each VERDICT `command-exit 0`: core 119 files / 2763 passed; plugin-security 223 files / 4683 passed, 45 skipped; objectql 467 files / 8718 passed; runtime 445 files / 7151 passed, 19 skipped. - `pnpm typecheck` in the same four packages: exit 0 each, including `check:test-typecheck` where the package has it. - `eslint --no-inline-config` on the 10 changed TypeScript files: exit 0. - Derived gates (`dispatch-gates --commands`, 108 families): 108 exit 0, reconciled with `--ran` (0 NOT-MEASURED, 0 UNRUN). Four answered a prerequisite on the first pass and exit 0 after it was met: `check-engine-split-ratio` (shallow clone, deepened with `--shallow-since`), and `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n` (full `turbo run build`). - Ablation (`scripts/ablation-replace.mjs`, one behaviour: the predicate's read of the mark, `elevationSource`), with the marker proven in `dist/` by `ablation-dist-preflight` before the readings, because the runtime suite reads plugin-security from `dist/`: - runtime pin: 40 failed, 10 passed. The 40 are exactly the hook and action refusal cases; every control stays green. - plugin-security pin: 48 failed, 47 passed. The 48 are the two marks × four objects × six verbs; PR1's flow cases and the controls stay green. - Restore: blob equals HEAD, `git diff HEAD` empty, rebuilt, marker absent from `dist/`, both pins green again (50/50, 95/95). - A first attempt was a no-op: its marker was a comment, which the build strips, so the preflight refused it before any reading. The second used a marker that survives the build. - Full dogfood battery (owed for the `(narrowing)` arm): running at head `a8b13e57` when this PR was opened; its counts go in the `os-dev-report` on #25653. ## Acceptance notes - Claimed surface, declared departures: `packages/core/src/security/` (new module, barrel export, test), `objectql` `createContext` (the carry, beside the claimed `withRunAs`), the middleware call-site comment, and the two pin updates in `action-body-identity.test.ts`. No `packages/spec/**`, and `sudo()` is unchanged. - `buildActionExecutionContext` serves every handler an action door dispatches, host code handlers included, so a host handler's writes to the four objects are refused too. The census found none. - `ObjectRepository.execute()` (the third `executeAction` caller) elevates with `{ ...context, isSystem: true }` and is not one of the two ruled derivation sites, so an action reached that way from an unmarked context runs unmarked. The sandbox does not expose `execute`, so only host code reaches it. Noted, not changed. - The mark is an undeclared, in-process context key rather than an `ExecutionContext` field, because this PR may not touch `packages/spec`. Declaring it there is an option for the spec half; see the report on #25653. - Branch is the harness-assigned `claude/project-thread-hg746g`; the claim comment is the identity record. --- _Generated by [Claude Code](https://claude.ai/code)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VtHeMxWXt2A79EvHgkRQ8k --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9ca36b1 commit 073f08b

12 files changed

Lines changed: 738 additions & 24 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/runtime': patch
5+
'@objectstack/core': patch
6+
---
7+
8+
fix(security): a hook that runs as system and an action body are refused writes to position holdings, permission-set grants, invitations and memberships, as a system flow already is
9+
10+
Clause-②: no (narrowing: app-authored hook and action-script writes to the four identity objects are refused at run time, as #25251 did for flows; the seat runs the contract review before the queue)
11+
Metadata-protocol: none
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What changes is the run-time answer to a hook or action body's elevated write to four platform objects; the save-time refusal and its semantic entry are the spec half of the same card. -->
14+
15+
**BREAKING** (a served-set narrowing) for app-authored automation that writes `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` under system elevation. A census of the hooks and action bodies in this repository and in HotCRM found none that does.
16+
17+
A `runAs: 'system'` flow has been refused these writes since 18.1. Two other kinds of app-authored automation run with the same elevation and were not: a hook that declares `runAs: 'system'`, and an action body (`type: 'script'`), which always runs elevated. Their writes skipped the delegated-administration gate (so a grant carried no `granted_by`) and the invitation door's placement and role-cap checks. Both are now refused the same way:
18+
19+
- An insert, update, delete, transfer, restore or purge of one of the four objects, from a `runAs: 'system'` hook (a sandboxed body or an in-process handler) or from an action body (through `ctx.api` or `ctx.engine`), answers `403 PERMISSION_DENIED`. Nothing is written. A hook's refusal aborts the triggering write under the default `onError: 'abort'`.
20+
- The message names the source and the fix. For position holdings and permission-set grants: set the hook's `runAs` to `'user'`, so the delegated-administration gate judges the user the hook acts for and records them as `granted_by`. An action body has no `runAs`, so its grant moves to a `runAs: 'user'` flow, which an action of type `flow` runs as the invoking user. For invitations and memberships: use the organization endpoints.
21+
22+
Unchanged: reads of the four objects, and writes to every other object, from the same hook or body. A hook under `runAs: 'user'`, which the ordinary gates judge as its user. The platform's own writers (better-auth and its reconciliation, invitation placement, the seeders, any `sudo()`), which carry no app-automation mark. A system flow's refusal, word for word.
23+
24+
**What changes for you.** A hook or action body that grants a position or a permission set under elevation now fails. Declare `runAs: 'user'` on the hook, or move an action body's grant into a `runAs: 'user'` flow. Invitations and memberships change through `POST /api/v1/auth/organization/invite-member`, `cancel-invitation`, `update-member-role` and `remove-member`.
25+
26+
`@objectstack/core` exports the mark: `APP_AUTOMATION_ELEVATION`, `markAppAutomationElevation`, `appAutomationElevationOf` and `carryAppAutomationElevation`.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ that silently does not happen.
9797
| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
9898
|:--|:---|:---|:---|:---|
9999
| 1 | **The whole security middleware short-circuits** before any gate runs | plugin-security | Get: every CRUD/FLS/tenant/owner gate below skipped in one branch. Lose: every gate under it in this lane at once, down to and including the write-bypass row that widens the effective write scope to `org` (row 7) — this is the single largest behaviour on the page | `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` |
100-
| 1b | **A flow run's system context is REFUSED governed writes to the identity-assignment objects** — the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and a `flowRunId` (a `runAs: 'system'` flow's data nodes, at any depth of their writes) is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context without a `flowRunId` (the seeders, better-auth and its reconciliation, invitation placement) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` run carries no `isSystem`, so the D12 gate judges it as the user it acts for | `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#assertFlowSystemIdentityWriteAllowed` |
100+
| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, a `runAs: 'system'` hook's and an action body's; the one check ahead of the middleware's short-circuit | plugin-security | Get: nothing — an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from a context carrying `isSystem` and either a `flowRunId` (a `runAs: 'system'` flow's data nodes) or the app-automation elevation mark (`@objectstack/core`; set only by a hook's `runAs: 'system'` derivation and by the action body's execution-context builder), at any depth of their writes, is refused `PERMISSION_DENIED` before the short-circuit skips the ADR-0090 D12 gate and the identity write guard. Lose: nothing a platform writer held — a system context with neither (the seeders, better-auth and its reconciliation, invitation placement, any `sudo()`, which never sets the mark) passes on to the short-circuit unchanged, reads are not judged, and a `runAs: 'user'` flow or hook carries no `isSystem`, so the D12 gate judges it as the user it acts for | `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts#elevationSource` |
101101
| 2 | **`owner_id` is not auto-stamped on INSERT** (the step 3.5 anchor guard is inside the block row 1 skips) | plugin-security | Lose: the row lands `owner_id = NULL`, so the default `owner_only_writes` policy hides it **from its own creator**. Get: nothing — this is a gap, not a capability | the step 3.5 guard block and the short-circuit that skips it are both inside `packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts#registerAuthzMiddleware` |
102102
| 3 | Row-level read filter resolves to "no filter" | plugin-security | Get: unscoped reads. Lose: row-level scoping entirely | `packages/plugins/plugin-security/src/security-plugin-read-filter.ts#getReadFilter` |
103103
| 4 | Field-level security returns **all** fields | plugin-security | Get: every column readable (`getReadableFields`) and writable (`getWritableFields`). Lose: field masking | `packages/plugins/plugin-security/src/security-plugin-field-readers.ts#resolveProjectionFieldMask` |
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25653] The app-automation elevation mark at its home: how it is set, read
5+
* and carried. Its two producers and its one reader are driven end to end in
6+
* runtime (`app-automation-identity-write-refusal.integration.test.ts`).
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { ExecutionContextSchema } from '@objectstack/spec/kernel';
11+
12+
import {
13+
APP_AUTOMATION_ELEVATION,
14+
appAutomationElevationOf,
15+
carryAppAutomationElevation,
16+
markAppAutomationElevation,
17+
} from './app-automation-elevation.js';
18+
19+
describe('the app-automation elevation mark', () => {
20+
it('is the registry symbol, so an ESM and a CJS copy of this package agree on it', () => {
21+
expect(APP_AUTOMATION_ELEVATION).toBe(Symbol.for('objectstack.core.appAutomationElevation'));
22+
});
23+
24+
it('marks a fresh copy and leaves the given context unchanged', () => {
25+
const context = { isSystem: true, userId: 'u1' };
26+
const marked = markAppAutomationElevation(context, 'hook');
27+
expect(marked).not.toBe(context);
28+
expect(appAutomationElevationOf(marked)).toBe('hook');
29+
expect(appAutomationElevationOf(context)).toBe(undefined);
30+
expect(marked).toMatchObject({ isSystem: true, userId: 'u1' });
31+
});
32+
33+
it('rides a spread, as every sudo()-shaped derivation spreads its caller', () => {
34+
const marked = markAppAutomationElevation({ isSystem: true }, 'action');
35+
expect(appAutomationElevationOf({ ...marked, transaction: {} })).toBe('action');
36+
});
37+
38+
it('reads nothing from a context that carries none, a value outside the two sources, or a non-object', () => {
39+
expect(appAutomationElevationOf({ isSystem: true })).toBe(undefined);
40+
expect(appAutomationElevationOf({ [APP_AUTOMATION_ELEVATION]: 'flow' })).toBe(undefined);
41+
expect(appAutomationElevationOf(null)).toBe(undefined);
42+
expect(appAutomationElevationOf('hook')).toBe(undefined);
43+
});
44+
45+
it('cannot be spelled by a JSON payload', () => {
46+
const parsed = JSON.parse(JSON.stringify({ isSystem: true, 'objectstack.core.appAutomationElevation': 'hook' }));
47+
expect(appAutomationElevationOf(parsed)).toBe(undefined);
48+
});
49+
50+
it('is dropped by the schema parse, and carried across it', () => {
51+
const marked = markAppAutomationElevation({ isSystem: true, userId: 'u1' }, 'action');
52+
const parsed = ExecutionContextSchema.parse(marked);
53+
expect(appAutomationElevationOf(parsed)).toBe(undefined);
54+
55+
const carried = carryAppAutomationElevation(marked, parsed);
56+
expect(appAutomationElevationOf(carried)).toBe('action');
57+
expect(carried).toMatchObject({ isSystem: true, userId: 'u1' });
58+
59+
const unmarked = ExecutionContextSchema.parse({ isSystem: true });
60+
expect(carryAppAutomationElevation({ isSystem: true }, unmarked)).toBe(unmarked);
61+
});
62+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25653, the #25251 principle for the whole family] The provenance mark an
5+
* app-authored automation's system elevation carries.
6+
*
7+
* ## Why a mark
8+
*
9+
* App-authored automation elevates by declaration: a flow by `runAs: 'system'`,
10+
* a hook by `runAs: 'system'`, and an action body always (it is trusted code,
11+
* #2849 / #3914). Each derives `{ ...caller's context, isSystem: true }`, and so
12+
* does platform code — the better-auth sync, the approval mirrors, the seeders
13+
* — through the same `sudo()`-shaped spread. The security middleware's
14+
* `isSystem` short-circuit cannot tell them apart, and the refusal that stops
15+
* automation from writing the identity-assignment objects
16+
* (`flow-system-identity-write-refusal.ts`, plugin-security) must. A flow run
17+
* is told apart by its `flowRunId`; a hook and an action body carry no run, so
18+
* their elevation carries this mark instead.
19+
*
20+
* ## Where it is set — and where it is never set
21+
*
22+
* Only where app-authored metadata's elevation is derived:
23+
*
24+
* - `'hook'` — a hook's `runAs: 'system'` derivation (`ScopedContext.withRunAs`,
25+
* objectql);
26+
* - `'action'` — an action body's execution-context builder
27+
* (`buildActionExecutionContext`, runtime).
28+
*
29+
* ⛔ Never on `sudo()` itself, nor on any other platform system context: the
30+
* platform's own writes to those objects stay unchanged. Once set, the mark
31+
* rides every spread of the context exactly as `flowRunId` does, so a write the
32+
* hook's or the body's own write sets off under the same context (a nested
33+
* hook, a cascade leg, a `sudo()` taken inside the body) is judged as theirs.
34+
*
35+
* ## Why a symbol, and why it is not an `ExecutionContext` field
36+
*
37+
* It is server-constructed by construction: no request body, query string or
38+
* stored row can spell a symbol, so no caller can claim or shed it. It is an
39+
* in-process provenance fact, never a principal and never an authorization
40+
* grant — its only reader narrows (it refuses; it admits nothing). `Symbol.for`
41+
* rather than a module-local `Symbol()`, because this package ships an ESM and
42+
* a CJS build and a process may load both: the mark must be the same key in
43+
* each.
44+
*
45+
* Being undeclared, the mark does not survive `ExecutionContextSchema.parse`,
46+
* which keeps declared keys only. A producer that parses a marked context
47+
* (`ObjectQL.createContext`) carries it across with
48+
* {@link carryAppAutomationElevation}.
49+
*/
50+
51+
/** What derived the elevation: a hook's `runAs: 'system'`, or an action body. */
52+
export type AppAutomationElevation = 'hook' | 'action';
53+
54+
/** The context key of the mark. See the module header. */
55+
export const APP_AUTOMATION_ELEVATION: unique symbol = Symbol.for('objectstack.core.appAutomationElevation');
56+
57+
/**
58+
* `context` with the mark added — a fresh object; `context` is not changed.
59+
* Called only at the two derivation sites the module header names.
60+
*/
61+
export function markAppAutomationElevation<T extends object>(
62+
context: T,
63+
source: AppAutomationElevation,
64+
): T & { [APP_AUTOMATION_ELEVATION]: AppAutomationElevation } {
65+
return { ...context, [APP_AUTOMATION_ELEVATION]: source };
66+
}
67+
68+
/** The mark `context` carries, or `undefined` for every other context. */
69+
export function appAutomationElevationOf(context: unknown): AppAutomationElevation | undefined {
70+
if (context === null || typeof context !== 'object') return undefined;
71+
const mark = (context as { [APP_AUTOMATION_ELEVATION]?: unknown })[APP_AUTOMATION_ELEVATION];
72+
return mark === 'hook' || mark === 'action' ? mark : undefined;
73+
}
74+
75+
/**
76+
* `to` carrying the mark `from` carries: `to` itself when `from` has none, a
77+
* fresh copy of `to` with the mark otherwise. For a producer that rebuilds a
78+
* context (a schema parse) from one that may be marked.
79+
*/
80+
export function carryAppAutomationElevation<T extends object>(from: unknown, to: T): T {
81+
const mark = appAutomationElevationOf(from);
82+
return mark ? markAppAutomationElevation(to, mark) : to;
83+
}

‎packages/core/src/security/index.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,17 @@ export {
453453
withoutOperationPrivateKeys,
454454
} from './operation-private-keys.js';
455455

456+
// #25653 — the provenance mark a hook's `runAs: 'system'` and an action body's
457+
// elevation carry, so the identity-assignment refusal tells them from platform
458+
// `sudo()` writes. Set by objectql and runtime, read by plugin-security.
459+
export {
460+
APP_AUTOMATION_ELEVATION,
461+
markAppAutomationElevation,
462+
appAutomationElevationOf,
463+
carryAppAutomationElevation,
464+
type AppAutomationElevation,
465+
} from './app-automation-elevation.js';
466+
456467
// Ruling of record 6355206139 — the EXTERNAL rung's OWD seam: plugin-security
457468
// stamps whether a held grant's RLS speaks, plugin-sharing floors the OWD.
458469
export {

‎packages/objectql/src/engine.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ import {
159159
// [#24497] The ONE grants resolver, the one the request path seeds a
160160
// context's grants, peer list and email (#24639) through — see `ScopedContext[SEED_USER_GRANTS]`.
161161
resolveUserAuthzGrants,
162+
// [#25653] The app-automation elevation mark: set by a hook's
163+
// `runAs: 'system'` derivation, carried across `createContext`'s parse —
164+
// see `ScopedContext.withRunAs`.
165+
markAppAutomationElevation,
166+
carryAppAutomationElevation,
162167
} from '@objectstack/core';
163168
import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js';
164169
import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js';
@@ -23095,8 +23100,12 @@ export class ObjectQL implements IObjectQLEngine {
2309523100
* await users.find({ where: { status: 'active' } });
2309623101
*/
2309723102
createContext(ctx: Partial<ExecutionContext>): ScopedContext {
23103+
// [#25653] The parse keeps declared keys only, so the app-automation
23104+
// elevation mark an action body's envelope carries is carried across it:
23105+
// dropped here, the body's `ctx.api` writes would reach the
23106+
// identity-assignment refusal unmarked.
2309823107
return new ScopedContext(
23099-
ExecutionContextSchema.parse(ctx),
23108+
carryAppAutomationElevation(ctx, ExecutionContextSchema.parse(ctx)),
2310023109
this
2310123110
);
2310223111
}
@@ -23390,12 +23399,18 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2339023399
* `wrapDeclarativeHook`, for the duration of the handler call. `'inherit'`
2339123400
* never reaches here (the wrapper hands the original through by reference).
2339223401
*
23393-
* - `'system'` → {@link sudo}: `{ ...triggering context, isSystem: true }`.
23402+
* - `'system'` → {@link sudo}-shaped: `{ ...triggering context, isSystem: true }`.
2339423403
* The same envelope the in-process `ctx.api.sudo()` idiom produced, so a
2339523404
* hook that used to elevate by hand elevates identically by declaration
2339623405
* — and now on the sandboxed surface too. `userId` / `tenantId` /
2339723406
* `transaction` ride along: elevation is authorization, not anonymity
2339823407
* (#5494), and the hook's writes stay inside the triggering transaction.
23408+
* [#25653] It also carries the app-automation elevation mark
23409+
* (`'hook'`, `@objectstack/core`), so the identity-assignment refusal
23410+
* (plugin-security) judges the hook's writes to the four identity
23411+
* objects as it judges a system flow's. Derived here, ⛔ never in
23412+
* {@link sudo}: platform code elevates through `sudo()` and keeps writing
23413+
* those objects.
2339923414
* - `'user'` → `{ ...triggering context, isSystem: false }` when the
2340023415
* trigger resolved a user — the pin de-elevates a hook fired by a
2340123416
* `runAs:'system'` flow node or an `isSystem` service write that still
@@ -23405,7 +23420,12 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2340523420
* fall-open, never a silent re-badge as system).
2340623421
*/
2340723422
withRunAs(runAs: Exclude<HookRunAs, 'inherit'>, ref: HookRunAsRef): IScopedContext {
23408-
if (runAs === 'system') return this.sudo();
23423+
if (runAs === 'system') {
23424+
return new ScopedContext(
23425+
markAppAutomationElevation({ ...this.executionContext, isSystem: true }, 'hook'),
23426+
this.engine
23427+
);
23428+
}
2340923429
if (!this.executionContext.userId) return new UnscopedHookApi(ref);
2341023430
return new ScopedContext(
2341123431
{ ...this.executionContext, isSystem: false },

0 commit comments

Comments
 (0)