You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 6a6ebff
Browse filesBrowse the repository at this point in the historyBrowse files
fix(security)!: an app-authored hook's inherited elevation, its sudo() and an action it reaches by execute() write no identity assignment (#25982)
<!-- 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: an app-authored hook's inherited or sudo()
elevation, and an action reached through ObjectRepository.execute(), are
refused the four identity objects at run time, as a runAs: 'system' hook
already is)
Metadata-protocol: none
Slice 2 of the card's run-time half. It closes the inherited-context
path that slice 1's report (#25954) names among its findings: an
app-authored hook that declares no `runAs` and runs under a system
context it inherits. It also closes the two neighbouring elevations the
order asked to be measured, the in-process `ctx.api.sudo()` and
`ObjectRepository.execute()`.
## What changed
Slice 1 refuses writes to the four identity objects from a context with
`isSystem` and either a `flowRunId` or the app-automation mark. The mark
was set only on a hook's `runAs: 'system'` api and on an action body's
context. Three other app-authored elevations carried `isSystem` with
neither, so the refusal did not see them.
| Context | Governed write to `sys_user_position`,
`sys_user_permission_set`, `sys_invitation`, `sys_member` | Writes to
other objects |
|---|---|---|
| app-authored hook, no `runAs`, system context inherited (body or
handler) | **refused, PERMISSION_DENIED** (was: admitted) | admitted |
| app-authored hook's in-process `ctx.api.sudo()`, under any `runAs`,
also inside `ctx.api.transaction` | **refused** (was: admitted) |
admitted |
| action reached through `ctx.api.object(x).execute(y)` | **refused**
(was: admitted) | admitted |
| app-authored hook, no `runAs`, user's context inherited | not judged
here; the ordinary gates judge the user | per the user |
| hook registered in code (`engine.registerHook`, no metadata binding),
its api and its `sudo()` | admitted, unchanged | admitted |
| platform `sudo()`, bare `{ isSystem: true }` writers | admitted,
unchanged | admitted |
**The predicate that tells app-authored hooks from platform hooks is the
hook binder.** A hook is app-authored when it is bound through
`bindHooksToEngine`. That is the one door metadata hooks come through:
stack and package `hooks:`, runtime-authored hooks, and app artifacts.
It is also the set the engine already treats as automations: the binder
registers each hook with `meta`, and `skipAutomations` skips exactly
those. A platform hook is registered in code with `engine.registerHook`
and no `meta`. The audit builtins, security, and every plugin's own
hooks are registered this way, and their path does not change.
- `objectql` `hook-binder.ts`: the binder passes `appAuthored: true` to
`wrapDeclarativeHook`.
- `objectql` `hook-wrappers.ts`: when `appAuthored` is set, the wrapper
derives the hook's `ctx.api` through a new key before it applies
`runAs`, so every `runAs` keeps the provenance. The step is synchronous,
so an `'inherit'` hook gains no extra `await`. An async
(fire-and-forget) hook runs on the detached copy whenever an api was
installed. A platform wrap (no flag) still hands the api through by
reference.
- `objectql` `hook-run-as.ts`: `APP_AUTHORED_HOOK_API` and
`deriveAppAuthoredHookApi`. The key is a symbol and is not exported from
the package entry, the same posture as `SEED_USER_GRANTS`.
- `objectql` `ScopedContext`:
- `[APP_AUTHORED_HOOK_API]()` marks an inherited `isSystem` context
`'hook'` when it has no `flowRunId` and no mark.
- A private `appAuthored` flag rides every derived child (`transaction`,
`beginTransaction`, `withRunAs`, the grant seeding) through one
`derive()` helper.
- `sudo()` marks `'hook'` only on an app-authored api. Platform `sudo()`
is byte-identical.
- `objectql` `ObjectRepository.execute()` marks its elevation
`'action'`, as `buildActionExecutionContext` already does for the REST
and MCP action doors. The census found no caller of `execute()` in
objectstack, `examples/`, `apps/` or HotCRM.
- `plugin-security`: no predicate change. The hook's subject and
prescription now name the three ways a hook is elevated, and the module
doc covers them.
- `content/docs/permissions/system-context.mdx` row 1b names the new
producers and anchors the new `isSystem` read
(`engine.ts#inheritsElevation`); `check-system-context-census` exits 0.
- Changeset: `objectql` and `plugin-security` `patch`,
`Metadata-protocol: none`, BREAKING banner, ADR-0087 `not-required
(no-migration-prescription)`.
## Measured before any code (the order's question)
On `main` `59d9181e`, with a real `ObjectQL`, the SQL driver,
`SecurityPlugin` and QuickJS:
- **Does app-authored code reach `ctx.api.sudo()`?** Yes, from an
in-process handler. Its writes carried `isSystem` and no mark. A
sandboxed body does not reach it, because the sandbox api exposes
neither `sudo` nor `execute`.
- **Does it reach `ObjectRepository.execute()`?** Yes, from an
in-process handler, through `ctx.api.object(x).execute(y)`. The action
it reached ran with an elevated, unmarked `api`.
- An action host handler's own `sudo()` was already refused, because the
action context's mark rides the spread.
Both reaches were measured as reachable, so both are fixed here.
Census: no hook, action body or host handler in objectstack
(`packages/`, `examples/`, `apps/`) or HotCRM writes the four objects,
calls `sudo()` from app code, or calls `execute()`. Nothing shipped
starts being refused.
## Tests
-
`runtime/src/app-automation-identity-write-refusal.integration.test.ts`:
50 new cases beside slice 1's 50. They use the same real stack and read
the store back past the middleware.
- A hook with no `runAs` (body and handler) under a platform system
write, × four objects × insert/update/delete, is refused with code,
status, subject and prescription. The store does not move and the
triggering write aborts. 24 cases.
- A hook's in-process `sudo()` under a user's write, × four × three
verbs, is refused. One more case covers it inside `ctx.api.transaction`
and under `runAs: 'user'`. 13 cases.
- An action reached through `execute()` is refused on each of the four
objects. 4 cases.
- Controls, 9 cases:
- the inheriting hook still writes another object;
- under a user's write, the ordinary gates refuse the plain member, and
this refusal does not;
- a hook registered in code, its `sudo()`, and the declarative wrapper
with no binding all write each of the four objects.
- **Red first** (commit `cb6bb395`, before the fix): 41 refusal cases
failed and 59 passed (slice 1's 50 plus the 9 controls).
- `objectql/src/hook-run-as.test.ts`: 4 new cases.
- An inherited system context is marked, but a run's context and a
user's are not.
- `sudo()` is marked under every `runAs`.
- A platform wrap keeps the api by reference and marks nothing.
- The binder binds hooks as app-authored.
- The published-face pin now counts two unexported symbol-keyed members.
- `plugin-security` refusal test: the hook row's message pin follows the
new wording.
Pins describe behaviour only.
## Verification
Head `d0489d5e`. Its tree is identical to `7d29c9ab`, where every
reading below was taken: the commit messages were reworded before the
first push and the files did not change. All readings ran under the
verify lock.
- `pnpm test`, each with VERDICT `command-exit 0`:
- objectql: 469 files, 8734 passed;
- plugin-security: 223 files, 4683 passed, 45 skipped;
- runtime: 447 files, 7232 passed, 19 skipped;
- core: 119 files, 2763 passed.
- Runtime and core ran at the fix commit `4a7683e2`. Only objectql and
plugin-security test files, the docs page and the changeset changed
after it. Runtime reads both of those packages from `dist/`, and `dist/`
was built from the fix commit.
- `pnpm typecheck` in objectql, plugin-security and runtime exits 0
each, including `check:test-typecheck`.
- `eslint --no-inline-config` on the 8 changed TypeScript files exits 0.
- `check-system-context-census` exits 0.
- Derived gates (`dispatch-gates --commands` on the real diff, 106
families): all 106 exit 0 on the first pass. Reconciled with `--ran`:
106 derived, 106 run, 0 NOT-MEASURED, 0 UNRUN.
- Ablation (`scripts/ablation-replace.mjs`): one behaviour per run, each
marker proven in objectql `dist/` by `ablation-dist-preflight` before
the readings.
- **A, the discriminator** (the binder's `appAuthored: true` forced to
`false`). Runtime pin: 37 failed, 63 passed. The 37 are exactly the
inherited-context and `sudo()` cases; the `execute()` cases, slice 1's
cases and every control stay green. objectql pin: 1 failed (the binder
case), 28 passed.
- **B, `execute()`'s mark** (`'action'` forced to none). Runtime pin: 4
failed, 96 passed. The 4 are exactly the `execute()` cases. objectql
pin: 29 passed.
- Restore: blob equals HEAD for both files, `git diff HEAD` is empty and
`git status --porcelain` is clean. After the rebuild, both markers are
absent from `dist/` (preflight `--absent` exits 0) and the runtime pin
is 100/100 again.
- Full dogfood battery (owed for the `(narrowing)` arm): it was running
at this tree when this PR was opened. Its counts go in the
`os-dev-report` on #25653.
## Acceptance notes
- Claimed surface: the hook wrapper, binder and `ScopedContext` in
objectql, plus `ObjectRepository.execute()` as the order's measurement
asked. The refusal module changed its wording only. No
`packages/spec/**`, no new error code, and the predicate is unchanged.
- The mark is the slice 1 symbol. A `flowRunId` context is still
answered as the flow's, and a context that already carries a mark keeps
it.
- 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>
fix(security): an app-authored hook that inherits a system context, its `ctx.api.sudo()`, and an action it reaches through `execute()` are refused writes to position holdings, permission-set grants, invitations and memberships
7
+
8
+
Clause-②: no (narrowing: an app-authored hook's inherited or sudo() elevation, and an action reached through ObjectRepository.execute(), are refused the four identity objects at run time, as a runAs: 'system' hook already is)
9
+
Metadata-protocol: none
10
+
11
+
<!-- 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 an app-authored hook's elevated write to four platform objects. -->
12
+
13
+
**BREAKING** (a served-set narrowing) for an app-authored hook that writes `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` while elevated. A census of the hooks in this repository and in HotCRM found none that does.
14
+
15
+
Since the previous release, a hook that declares `runAs: 'system'` and an action body are refused these writes. Three other ways app-authored code ran elevated were not:
16
+
17
+
- a hook that declares no `runAs` and fires on a write made under a system context, which it inherits;
18
+
- a hook's in-process `ctx.api.sudo()`;
19
+
- an action reached through `ctx.api.object(name).execute(action)`.
20
+
21
+
Each is now refused the same way: `403 PERMISSION_DENIED`, nothing written, and under the default `onError: 'abort'` the triggering write aborts. The message names the hook and the fix: set its `runAs` to `'user'` and leave `ctx.api.sudo()` out, or use the organization endpoints for invitations and memberships.
22
+
23
+
A hook is app-authored when it is bound from metadata through the hook binder (the same hooks the `skipAutomations` opt-out skips). A hook a plugin registers in code with `engine.registerHook`, and platform code's own `sudo()`, keep writing these objects. Reads of the four objects, and writes to every other object, are unchanged.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -97,7 +97,7 @@ that silently does not happen.
97
97
| # | Behaviour when `isSystem`| Package | What you get / what you lose | Anchor |
98
98
|:--|:---|:---|:---|:---|
99
99
| 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 | **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` |
100
+
| 1b | **App-authored automation's system context is REFUSED governed writes to the identity-assignment objects** — a flow run's, an app-authored hook's (`runAs: 'system'`, a system context it inherits, its `sudo()`) 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, by the action body's execution-context builder and `ObjectRepository.execute`, and on the api of a hook bound through the hook binder, whose inherited system context and `sudo()` it marks), 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, platform code's `sudo()`, which never sets the mark, and a hook registered in code) 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`, `packages/objectql/src/engine.ts#inheritsElevation` |
101
101
| 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`|
Copy file name to clipboardExpand all lines: packages/objectql/src/engine.ts
+72-38Lines changed: 72 additions & 38 deletions
Original file line number
Diff line number
Diff line change
@@ -153,9 +153,12 @@ import {
153
153
resolveUserAuthzGrants,
154
154
// [#25653] The app-automation elevation mark: set by a hook's
155
155
// `runAs: 'system'` derivation, carried across `createContext`'s parse —
156
-
// see `ScopedContext.withRunAs`.
156
+
// see `ScopedContext.withRunAs` — and by an app-authored hook's inherited
157
+
// elevation and `sudo()`, and `ObjectRepository.execute` — see
158
+
// `ScopedContext[APP_AUTHORED_HOOK_API]`.
157
159
markAppAutomationElevation,
158
160
carryAppAutomationElevation,
161
+
appAutomationElevationOf,
159
162
} from '@objectstack/core';
160
163
import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js';
161
164
import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js';
@@ -312,7 +315,7 @@ import {
312
315
divergingHookPayloadKeys,
313
316
MultiUpdateHookKeyDivergenceError,
314
317
} from './multi-update-hook-key-divergence.js';
315
-
import { UnscopedHookApi, SEED_USER_GRANTS, type HookRunAs, type HookRunAsRef, type RunAsDerivableApi } from './hook-run-as.js';
318
+
import { UnscopedHookApi, SEED_USER_GRANTS, APP_AUTHORED_HOOK_API, type HookRunAs, type HookRunAsRef, type RunAsDerivableApi } from './hook-run-as.js';
316
319
import type { HookWriteRecording } from './hook-write-provenance.js';
317
320
import { resolveMasterDetailRelation } from './master-detail.js';
318
321
// [#19911] The settlement's question "does judging the FK's own lock need the
@@ -22957,7 +22960,14 @@ export class ObjectRepository implements IScopedObjectRepository {
0 commit comments