Skip to content

Commit 07586f5

Browse files
feat(spec)!: refuse at save a system hook or script action body that writes an identity-assignment object (#26430)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBRDFqiYQLscu1ybuLZGACQn)_ Fixes #25653 Clause-②: no (narrowing) Metadata-protocol: narrowed ## 维护者速读 - 改了什么:`HookSchema` 与 `ActionSchema` 在保存时拒收「以 system 提权运行、且沙箱 JS 正文写四个身份分配对象(岗位持有、权限集授予、邀请、成员)」的钩子/动作;`Hook.runAs` 说明补上「提权不触及身份分配」;新增一条 ADR-0087 D3 条目。 - 为什么改:运行时早已拒绝这类写入(上两片已合入),但保存门仍然接受,作者只能在运行失败时才发现;裁决 6384657105 字母 A 要求整个家族在保存时同样拒收。 - 风险与回滚:只收窄 spec 的接受集,运行时不变;仓内普查为零命中,不会有已发布的钩子或动作开始被拒;回滚即撤回本 PR。 - 先看:`packages/spec/src/identity/identity-assignment-body-writes.ts`(判定与拒收文案)、`packages/spec/src/data/hook.zod.ts` 的 `refuseElevatedIdentityAssignmentBodyWrite`、`packages/spec/src/ui/action.zod.ts` 的 `refuseIdentityAssignmentBodyWrite`。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/identity/identity-assignment-body-writes.test.ts`,预期 28 passed。 - 需要你做的:无,席位落地。 ## What changes App-authored automation does not use elevation to write `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` (ruling record 6384657105, letter A). The run-time half landed in #25954 and #25982: the data middleware refuses that write with `PERMISSION_DENIED`. This PR is the save-time half that triage routed to `domain:spec` (6431012701). - **New leaf `packages/spec/src/identity/identity-assignment-body-writes.ts`** (import-free, not exported from the barrel). It holds the four-object set, the reading of a sandboxed body's source (a write method `insert`, `update`, `delete`, `updateMany`, `deleteMany` or `upsert` on `ctx.api.object('OBJECT')`, called on the handle or on a name the body bound it to), and the refusal text with its prescription per source. - **`HookSchema`** gains a second object-level check, `refuseElevatedIdentityAssignmentBodyWrite`. It refuses a hook that declares `runAs: 'system'` and carries a `language: 'js'` body whose source writes one of the four objects. There is one `custom` issue per object, at `body.source`. The JSON-stage hook in `stack.zod.ts` keeps the check through `safeExtend`. - **`ActionSchema`** gains `refuseIdentityAssignmentBodyWrite`: the same check for a `type: 'script'` action, whose body always runs as system. - **`Hook.runAs`**: the JSDoc and the `.describe()` now say that elevation does not reach identity assignment, as `FlowSchema.runAs` does. The hook reference page was regenerated (`gen:docs`). - **ADR-0087 D3 entry** `hook-action-body-system-identity-assignment-write-refused` sits beside `18.flow-run-as-system-identity-assignment-write-refused`. It adds no tombstone and no D2 conversion. - **Changeset**: `@objectstack/spec` minor, BREAKING, `adr-0087: registered`. ## Tests `packages/spec/src/identity/identity-assignment-body-writes.test.ts`, 28 cases: - The reader names each of the four objects for every write method and every quote form. It also names a write on a bound handle, and each object once. - `HookSchema`, the registered `hook` type schema, `ActionSchema` and the registered `action` type schema each refuse each object at `body.source`, with the right prescription. - `defineStack` refuses with `422 STACK_SCHEMA_INVALID` at `hooks.N.body.source`. It refuses the action too. An artifact's parse refuses the hook. - The D3 entry is registered at 18 with no conversion and no tombstone. - Controls (what the run-time refusal leaves legal too): reads of the four objects, writes to other objects, a hook under `runAs: 'user'`, an expression body and a code handler. ## Acceptance notes - Census at `origin/main` `e931621a`: no hook or action body in this repository calls `ctx.api.object(...)` on one of the four objects (`git grep`, 0 hits; the control pattern over every `sys_*` object hits 6 files). So no shipped body starts being refused. hotcrm was not re-measured from this session. The run-time slices' census found none there. - The four-object set and the prescription text now live in two places: this spec leaf and plugin-security's `FLOW_SYSTEM_IDENTITY_WRITE_OBJECTS`. The claim's file surface is spec-only, so plugin-security does not import the spec set yet. That would also make the leaf a public export. - `FlowSchema` has no save-time twin of this check. A `runAs: 'system'` flow's write node aimed at one of the four objects is refused only at run time, by the earlier change. This is noted here, not filed. ## Verification All readings below are at head `1234236ab` unless noted. <details><summary>Spec package</summary> - `pnpm --filter @objectstack/spec test` under `os-verify-lock`: VERDICT command-exit 0 · Test Files 728 passed (728) · Tests 22037 passed | 1 todo. The pre-merge head `2a0debc6` read 727 files / 22012 passed. - `pnpm --filter @objectstack/spec typecheck`: exit 0 (`check:test-typecheck` OK). - `eslint --no-inline-config` on the 5 changed `.ts` files: exit 0. - `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up to date after the merge of `origin/main`. The only regenerated artifact is `content/docs/references/data/hook.mdx`. </details> <details><summary>Ablation (one behaviour: the body-source reader)</summary> `node scripts/ablation-replace.mjs` replaced `return [...named];` with `return [];` in the leaf. The anchor went 1 → 0 and the blob changed. With the mutation, the test file read 20 failed | 8 passed. The 20 were exactly the refusal rows. The 8 that stayed green were the 6 controls and the 2 ledger pins. On restore, the blob equalled HEAD and `git diff HEAD` was empty. The spec suite imports from `src/`, so no rebuild was involved. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on head `1234236ab`: 114 families. All 114 exited 0 on the first pass. - `dispatch-gates --ran` with every exit code recorded: 114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN. - On the pre-merge head `2a0debc6` the same derivation (114 families, two different) also ended 114/114 exit 0. Eight of them first answered exit 3 (a prerequisite: an unbuilt workspace or the shallow clone), and they exited 0 after a full build and fetching the pinned fixture commit. - `node scripts/check-dev-prereqs.mjs` (a local-only family): exit 0. </details> <details><summary>Dogfood (full battery, final head)</summary> `pnpm --filter @objectstack/dogfood test` under `os-verify-lock` at `1234236ab`, after a full workspace build (118/118): VERDICT command-exit 1 · Test Files 2 failed | 298 passed | 3 skipped (303) · Tests 10 failed | 2522 passed | 18 skipped (2550). The 10 failures are all in `test/authz-conformance.test.ts` (the #17111 docblock-figure pins: "docblock says 80 rows, the table holds 81" for `packages/rest/src/rest-route-ledger.ts`) and `test/authz-probe-blind-spot.test.ts` (the `this.routeManager.register(` control in `packages/rest/src/rest-server.ts` reads 63, pinned at 62). They are not caused by this diff. It touches no file under `packages/rest` or `packages/qa`: `git diff e677770 1234236 -- packages/rest packages/qa` is empty. The row came from `6a2866af7` (#26361), which is already an ancestor of this branch's base `e931621a` (`git merge-base --is-ancestor` exit 0). So `main`'s own full battery carries the same reds. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint on the changed files locally; the repo-wide run is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzecHS6dL9h6WLdsGCH --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzecHS6dL9h6WLdsGCH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f9e871a commit 07586f5

7 files changed

Lines changed: 526 additions & 6 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a `runAs: 'system'` hook or a `script` action whose sandboxed body writes an identity-assignment object is refused at save
6+
7+
App-authored automation does not use elevation to write `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member`. The data middleware already refuses that write at run time with `PERMISSION_DENIED` (403), from a system flow run, a hook that runs as system and an action body. Until now every authoring door still accepted a hook or action whose body says so in writing, and the author learned otherwise only from a failed write. The parse now refuses it too:
8+
9+
- `HookSchema` refuses a hook that declares `runAs: 'system'` and carries a sandboxed JS body (`language: 'js'`) whose `source` calls `insert`, `update`, `delete`, `updateMany`, `deleteMany` or `upsert` on `ctx.api.object('<object>')` for one of the four objects, on the handle or on a name the body bound it to.
10+
- `ActionSchema` refuses a `type: 'script'` action whose sandboxed JS body does the same. An action body always runs as system.
11+
- One `custom` issue per object, at `body.source`, naming the object and the route: `runAs: 'user'` (a hook) or a `runAs: 'user'` flow (an action) for position holdings and permission-set grants, so the ADR-0090 D12 delegated-administration gate judges the user; the organization endpoints for invitations and memberships. It surfaces at `os validate`, at `defineStack` (`422 STACK_SCHEMA_INVALID`), at an artifact's parse and at the metadata save door (`422 INVALID_METADATA`).
12+
- `Hook.runAs` now says that elevation does not reach identity assignment, as `FlowSchema.runAs` does.
13+
14+
Unchanged: reads of the four objects, writes to every other object, a hook under `runAs: 'user'` or `'inherit'`, an expression (L1) body, a code `handler`, and the run-time refusal, which stays the enforcement for every elevated write.
15+
16+
Clause-②: no (narrowing) — `HookSchema` and `ActionSchema` refuse a body they used to accept. Nothing is widened.
17+
18+
Metadata-protocol: narrowed (a `runAs: 'system'` hook or a `script` action may no longer carry a sandboxed body that writes an identity-assignment object)
19+
20+
Ruling-ref: 6384657105 (letter A); retriage 6431012701
21+
22+
<!-- adr-0087: registered hook-action-body-system-identity-assignment-write-refused -->
23+
24+
**BREAKING** for an author whose system hook or action body writes one of the four objects. Such a write was already refused when the body ran.
25+
26+
- **Before.** The hook or action saved, and its write failed at run time with `PERMISSION_DENIED`.
27+
- **Now.** The hook or action is refused at save, at `body.source`.
28+
- **Fix.** For a position holding or permission-set grant, set the hook's `runAs` to `'user'`, or move an action's grant to a flow whose `runAs` is `'user'` (an action of `type: 'flow'` runs it as the invoking user). For an invitation or membership, call `POST /api/v1/auth/organization/invite-member`, `cancel-invitation`, `update-member-role` or `remove-member`.

‎content/docs/references/data/hook.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const result = HookSchema.parse(data);
4242
| **timeoutMs** | `number` | optional | Maximum execution time in milliseconds before the hook is aborted |
4343
| **timeout** | `never` | optional | [REMOVED] `hook.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description, beside a body-level `timeoutMs` and a `retryPolicy.backoffMs` that spell theirs, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
4444
| **onError** | `Enum<'abort' \| 'log'>` | optional (default: `"abort"`) | Error handling strategy. abort: refuse the operation; a blocking hook's abort rolls the write back inside a unit of work, which the write door opens for an after* hook on an object on the default datasource whose driver declares transactions (elsewhere the caller is refused and the row stays stored). log: log the error and continue |
45-
| **runAs** | `Enum<'system' \| 'user' \| 'inherit'>` | optional (default: `"inherit"`) | Execution identity for the hook's ctx.api data operations: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting), inherit = the context of the write that fired the hook (the pre-runAs behaviour; the default). A hook with no trigger user has no identity to scope to, so under user its ctx.api data operations are REFUSED — declare system to make the elevation explicit. This covers any hook fired by a write that carried no user (an isSystem plugin/service write; a system-elevated flow node). Scope: ctx.api only — condition evaluation, the readonly strip on ctx.input, ctx.session and async are unchanged. |
45+
| **runAs** | `Enum<'system' \| 'user' \| 'inherit'>` | optional (default: `"inherit"`) | Execution identity for the hook's ctx.api data operations: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting), inherit = the context of the write that fired the hook (the pre-runAs behaviour; the default). A hook with no trigger user has no identity to scope to, so under user its ctx.api data operations are REFUSED — declare system to make the elevation explicit. This covers any hook fired by a write that carried no user (an isSystem plugin/service write; a system-elevated flow node). Elevation does not reach identity assignment: a system hook's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED, and a system hook whose body names such a write is refused at save; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the hook acts for), and change invitations and memberships through the organization endpoints. Scope: ctx.api only — condition evaluation, the readonly strip on ctx.input, ctx.session and async are unchanged. |
4646
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
4747
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |
4848
| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). |

‎packages/spec/src/data/hook.zod.ts‎

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
1414
// conversion chain) has no business in this schema's import graph.
1515
import { STORED_METADATA_BODY_PRESCRIPTION, isStoredMetadataBodyObject } from '../kernel/stored-metadata-body-objects';
1616
import { HookBodySchema } from './hook-body.zod';
17+
// [#25653] The save-time half of the identity-assignment refusal — the set, the
18+
// body-source reading and the refusal text, shared with `ActionSchema`. An
19+
// import-free leaf, so this schema's import graph is unchanged.
20+
import {
21+
identityAssignmentBodyWriteRefusal,
22+
identityAssignmentWritesNamedIn,
23+
} from '../identity/identity-assignment-body-writes';
1724
// Type-only, and it must stay that way: `contracts/` already imports `data/`
1825
// (`contracts/data-engine.ts`), so a VALUE import here would close a runtime
1926
// cycle. `import type` is erased, leaving the edge in the type graph only.
@@ -134,6 +141,52 @@ function refuseBodyOnStoredMetadataTarget(
134141
});
135142
}
136143

144+
/*
145+
* ── A hook that runs as system does not write identity assignment ───────────
146+
*
147+
* [#25653, ruling record 6384657105 letter A] App-authored automation does not
148+
* use elevation to write `sys_user_position`, `sys_user_permission_set`,
149+
* `sys_invitation` or `sys_member`. The data middleware refuses that write at
150+
* run time with `PERMISSION_DENIED` (`flow-system-identity-write-refusal.ts`,
151+
* plugin-security), whichever way the hook came to be elevated. The parse
152+
* refuses the hook whose `body` says so in writing, so the author meets the
153+
* refusal at every door that parses a hook — the metadata save door,
154+
* `defineStack` / `os validate`, compile, an artifact's parse (the JSON-stage
155+
* hook in `stack.zod.ts` keeps this check through `safeExtend`) — instead of
156+
* in a failed write:
157+
*
158+
* - a hook that declares `runAs: 'system'`, the one elevation its metadata
159+
* states;
160+
* - carrying a sandboxed JS (L2) `body` — an expression (L1) body performs no
161+
* IO, and a code `handler` is not metadata a parse can read;
162+
* - whose `source` names a write (`insert`, `update`, `delete`, `updateMany`,
163+
* `deleteMany`, `upsert`) on `ctx.api.object('<one of the four>')`, called
164+
* on the handle or on a name the body bound it to. One issue per object, at
165+
* `body.source`.
166+
*
167+
* Reads of the four objects stay legal, as they do at run time. The run-time
168+
* refusal stays the enforcement for every elevated write; this is its
169+
* authoring half (the #21565 precedent above).
170+
*/
171+
172+
/**
173+
* The object-level check that refuses a `runAs: 'system'` hook whose sandboxed
174+
* body names a write to an identity-assignment object. See the block above.
175+
*/
176+
function refuseElevatedIdentityAssignmentBodyWrite(
177+
hook: { runAs?: string; body?: { language?: string; source?: unknown } },
178+
ctx: z.RefinementCtx,
179+
): void {
180+
if (hook.runAs !== 'system' || hook.body?.language !== 'js' || typeof hook.body.source !== 'string') return;
181+
for (const object of identityAssignmentWritesNamedIn(hook.body.source)) {
182+
ctx.addIssue({
183+
code: 'custom',
184+
path: ['body', 'source'],
185+
message: identityAssignmentBodyWriteRefusal(object, 'hook'),
186+
});
187+
}
188+
}
189+
137190
/**
138191
* Hook Lifecycle Events
139192
* Defines the interception points in the ObjectQL execution pipeline.
@@ -462,6 +515,17 @@ export const HookSchema = lazySchema(() => strictObject(
462515
* to `'user'`; the default differs because the situations differ, not the
463516
* word. ⛔ Do not add `'inherit'` to FlowSchema.
464517
*
518+
* Elevation does not reach identity assignment (#25653, ruling record
519+
* 6384657105 letter A — the principle `FlowSchema.runAs` states for a
520+
* flow): under `'system'`, and under an inherited system context, the
521+
* hook's governed writes (insert, update, delete, transfer, restore, purge)
522+
* to `sys_user_position`, `sys_user_permission_set`, `sys_invitation` and
523+
* `sys_member` are refused with `PERMISSION_DENIED`, and a `'system'` hook
524+
* whose sandboxed `body` names such a write is refused at parse. Declare
525+
* `'user'` for position holdings and permission-set grants, so the ADR-0090
526+
* D12 delegated-administration gate judges the user the hook acts for;
527+
* invitations and memberships change through the organization endpoints.
528+
*
465529
* Scope, first cut: `ctx.api` data operations ONLY. `condition` evaluation,
466530
* the `readonly` strip on the hook's own `ctx.input` payload, `ctx.session`
467531
* and the `async` semantics are untouched by this key — the triggering
@@ -484,6 +548,12 @@ export const HookSchema = lazySchema(() => strictObject(
484548
'A hook with no trigger user has no identity to scope to, so under user its ctx.api data operations are REFUSED — ' +
485549
'declare system to make the elevation explicit. This covers any hook fired by a write that carried no user ' +
486550
'(an isSystem plugin/service write; a system-elevated flow node). ' +
551+
"Elevation does not reach identity assignment: a system hook's governed writes (insert, update, delete, " +
552+
'transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member ' +
553+
'are refused with PERMISSION_DENIED, and a system hook whose body names such a write is refused at save; ' +
554+
'declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration ' +
555+
'gate then judges the user the hook acts for), and change invitations and memberships through the ' +
556+
'organization endpoints. ' +
487557
'Scope: ctx.api only — condition evaluation, the readonly strip on ctx.input, ctx.session and async are unchanged.',
488558
),
489559

@@ -494,11 +564,12 @@ export const HookSchema = lazySchema(() => strictObject(
494564
// REJECTED here — the same live 422 that `permission` hit on the ADR-0094
495565
// overlay path before Tier-A declared them (#4001 findings log, entries 2/8).
496566
...MetadataProtectionFields,
497-
// [#21565] The one object-level check: it pairs `object` with `body`, which
498-
// no field-level refine can see together. A schema DERIVED from this one by
499-
// overriding a key must use `.safeExtend()` (zod refuses `.extend()` over a
500-
// refined object), which is what keeps the check on the derived schema too.
501-
}).superRefine(refuseBodyOnStoredMetadataTarget));
567+
// [#21565] The object-level checks: the first pairs `object` with `body`,
568+
// [#25653] the second `runAs` with `body`, pairs no field-level refine can
569+
// see together. A schema DERIVED from this one by overriding a key must use
570+
// `.safeExtend()` (zod refuses `.extend()` over a refined object), which is
571+
// what keeps both checks on the derived schema too.
572+
}).superRefine(refuseBodyOnStoredMetadataTarget).superRefine(refuseElevatedIdentityAssignmentBodyWrite));
502573

503574
/**
504575
* Hook Runtime Context

0 commit comments

Comments
 (0)