Skip to content

Commit 4e644f0

Browse files
docs: a system-elevated hook or action body is refused identity-assignment writes (#26501)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9D7NoqduWp2tw8aB42huhVy)_ Fixes #26466 Clause-②: no Metadata-protocol: none Skills-docs: follow-up owed — skills/objectstack-automation/SKILL.md (the `runAs: 'system'` guidance at L164-191 never says these four writes are refused; it also owes an eval prompt per the package's convention; `domain:skills`, Tier H, triage 6434140653) ## 维护者速读 - 改了什么:两页作者文档各补一句——`hooks.mdx` 的 `runAs: 'system'` 行和 `actions.mdx` 的「动作内授权」段,写明系统提权的钩子/动作对岗位持有、权限集授予、邀请、成员四个对象的写入会被拒,并给出拒绝信息里点名的补救办法,链到 `system-context.mdx` 的 1b 行;删掉「full-access」这种过宽的说法。 - 为什么改:运行时(#25653 系列)和保存时(#26430,已合入)都已经拒绝这类写入,但这两页仍告诉作者「提权就是全权」,作者照做后只会在运行或保存时碰壁,且看不到该怎么改。 - 风险与回滚:纯文档,不动任何代码与发布包;回滚即撤回本 PR。 - 先看:`content/docs/automation/hooks.mdx`(`'system'` 表格行)、`content/docs/ui/actions.mdx`(Authorization inside an action 段首)。 - 两分钟验证:`pnpm check:doc-anchors`,预期 exit 0(两条新链接 `#1-authorization-and-scoping` 都能解析)。 - 需要你做的:无,席位落地。 ## What changes - `content/docs/automation/hooks.mdx`, the `runAs` table's `'system'` row: drops "full-access" and adds one sentence. Elevation stops at identity assignment. An insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from an app-authored hook's system context (`runAs: 'system'`, an inherited system context, or `ctx.api.sudo()`) is refused with `PERMISSION_DENIED`. A `runAs: 'system'` hook whose `language: 'js'` body writes one of them by name is refused at save. The sentence gives the remedies the refusal names: `runAs: 'user'` for positions and permission sets, so the delegated-administration gate judges the user; the organization endpoints for invitations and memberships. It links `system-context.mdx` row 1b. - `content/docs/ui/actions.mdx`, "Authorization inside an action": the same sentence for an action body and a registered handler. A `type: 'script'` action whose body names such a write is refused at save. The assignment remedy is the one the action refusal names: a flow whose `runAs` is `'user'`, run by an action of `type: 'flow'`. The next sentence now reads "The other consequence is…" so the caller-specific-rule guidance still follows. The pages describe the refusal and its remedy only. They name no way around it. ## Why Both pages told authors that system elevation bypasses every check. Since #25653 the data middleware refuses these four writes from app-authored system automation. Since #26430 (merged 2026-10-27T04:43Z) `HookSchema` and `ActionSchema` also refuse a body that names one at save. `system-context.mdx` row 1b already states the rule. These two pages contradicted it and left out the remedy. ## Tests Docs only: no behaviour changed, so no test and no ablation. ## Acceptance notes Wording was read against `origin/main` `00a4593a`: - Run-time refusal and remedy text: `packages/plugins/plugin-security/src/flow-system-identity-write-refusal.ts` (`SUBJECT`, `ASSIGNMENT_PRESCRIPTION`, `MEMBERSHIP_PRESCRIPTION`). - Who carries the mark: `packages/runtime/src/action-execution.ts#buildActionExecutionContext` serves every handler an action door dispatches, a host code handler included. That is why the actions sentence says "either". - Save-time half: `packages/spec/src/identity/identity-assignment-body-writes.ts`, `hook.zod.ts#refuseElevatedIdentityAssignmentBodyWrite` (only `runAs: 'system'` + `language: 'js'`) and `action.zod.ts#refuseIdentityAssignmentBodyWrite` (only `type: 'script'` + `language: 'js'`). The pages claim save-time refusal only for those shapes. Not done here, by the claim: - `skills/objectstack-automation/SKILL.md`: see the `Skills-docs:` line. - The card's two "Also carried" items: `FlowSchema` has no save-time twin of the body check, and the four-object set and prescription text are duplicated between the spec leaf and plugin-security. Both are reported to the seat as out-of-scope findings and are not built here. ## Verification Readings are at head `a5d2534c`. <details><summary>Derived gate families</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 43 commands derived for the 2 changed paths (16 changed lines). - On the first pass, 42 exited 0. `check:skill-examples` exited 3 (PREREQUISITE NOT MET: the client-react dist was missing). After `pnpm --filter '@objectstack/client-react...' build` under `os-verify-lock` (VERDICT command-exit 0), it exited 0: "265 prose examples type-check across 3 surface(s)". - `dispatch-gates --ran` with every exit code recorded: "43 derived, 43 run, 0 NOT-MEASURED, 0 UNRUN" (a DERIVED zero). </details> <details><summary>Card acceptance</summary> - `pnpm check:doc-authoring`: exit 0 (in the derived set). - docs-audit: `pnpm check:docs-audit-scope` and `pnpm check:docs-transcript-drift`: exit 0 (in the derived set). - Doc links: `pnpm check:doc-anchors`: exit 0. Negative control: after the `actions.mdx` link fragment was changed to `#1-authorization-and-scopingX`, it exited 1 and named `content/docs/ui/actions.mdx:455` ("renders no heading with id"). The file was restored with `git checkout HEAD --`, and `git diff HEAD` is empty. The lychee lane (`check-links.yml`) is CI's run. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No schema touched.) - [x] 2. Every name I added uses the casing for its kind. (No names added.) - [x] 3. No formatter ran; `pnpm lint` passes. (No code touched; 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. (Docs only, nothing publishes: `skip-changeset`.) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [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_01ADHkmQoXzgoMtkwN9diP2g --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQoXzgoMtkwN9diP2g)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2e5746d commit 4e644f0

2 files changed

Lines changed: 13 additions & 3 deletions

File tree

‎content/docs/automation/hooks.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ A hook's `ctx.api` runs with the context of the write that **fired** it. That is
154154
| `runAs` | The hook's `ctx.api` runs as |
155155
|:---|:---|
156156
| `'inherit'` *(default)* | the context of the triggering write — the behaviour every hook had before this key existed. |
157-
| `'system'` | elevated: a full-access, RLS-bypassing system principal. Row and field permissions do not apply, and the static `readonly` strip is skipped. |
157+
| `'system'` | elevated: an RLS-bypassing system principal. Row and field permissions do not apply, and the static `readonly` strip is skipped. Elevation stops at identity assignment: an insert, update, delete, transfer, restore or purge of `sys_user_position`, `sys_user_permission_set`, `sys_invitation` or `sys_member` from an app-authored hook's system context (`runAs: 'system'`, a system context it inherits, or `ctx.api.sudo()`) is refused with `PERMISSION_DENIED`, and a `runAs: 'system'` hook whose `language: 'js'` body writes one of them by name through `ctx.api.object('…')` is refused when it is saved — grant positions and permission sets from a hook with `runAs: 'user'`, so the delegated-administration gate judges the user it acts for, and change invitations and memberships through the organization endpoints the refusal names ([row 1b](/docs/permissions/system-context#1-authorization-and-scoping)). |
158158
| `'user'` | the triggering **user**, so the hook can never exceed that user's grants. A hook fired by a write that carried no user has no identity to scope to, so its `ctx.api` data operations are **refused** (`HOOK_UNSCOPED_DATA_ACCESS`) rather than run unscoped. |
159159

160160
```ts

‎content/docs/ui/actions.mdx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,8 +442,18 @@ is a spec proposal for a properly named key, not a values map under this one.
442442

443443
An action body and a registered handler both run **elevated**: `ctx.api` and
444444
`ctx.engine` carry `isSystem`, so they bypass row- and field-level security by
445-
design (that is what lets an action do work the caller cannot do directly). The
446-
consequence is that any caller-specific rule has to be re-established **inside**
445+
design (that is what lets an action do work the caller cannot do directly).
446+
Elevation stops at identity assignment: an insert, update, delete, transfer,
447+
restore or purge of `sys_user_position`, `sys_user_permission_set`,
448+
`sys_invitation` or `sys_member` from either is refused with
449+
`PERMISSION_DENIED`, and a `type: 'script'` action whose body writes one of them
450+
by name through `ctx.api.object('…')` is refused when it is saved — make a
451+
position or permission-set grant from a flow whose `runAs` is `'user'`, which an
452+
action of `type: 'flow'` runs as the invoking user so the
453+
delegated-administration gate judges that user, and change invitations and
454+
memberships through the organization endpoints the refusal names
455+
([row 1b](/docs/permissions/system-context#1-authorization-and-scoping)). The
456+
other consequence is that any caller-specific rule has to be re-established **inside**
447457
your handler — and the predicate most people reach for first does not work:
448458

449459
```js

0 commit comments

Comments
 (0)