Repository navigation
Commit 7569fb5
fix(platform-objects)!: platform-admin actions declare their gate, read by MCP list_actions and both action doors (#30849)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DNYpoC5fqFrwXeg25HEaoL1)_
domain:server#4 · os-elon-musk · session_016WV4czSnPb3bu4Q5weHEpr
Fixes #30771
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — `skills/**` teaches none of these 15 actions, and
`content/docs/ai/connect-mcp.mdx` already lists "the caller fails its
`requiredPermissions`" as a reason an action is missing from
`list_actions`, which is what now happens.
## 维护者速读
- **改了什么**:15 个只有平台管理员能用的内置动作(封禁用户、设置上级、建用户、重置密码、OAuth 应用启停、SSO
注册等)在声明里写上门槛 `requiredPermissions`,用户类为 `manage_users`,平台设置类为
`manage_platform_settings`;MCP `run_action` 被门槛拒绝时统一答 403
`PERMISSION_DENIED`,带不带 `objectName` 都一样。
- **为什么改**:裁决 A(#30126 第 16 行):以前普通成员在 MCP `list_actions` 里能看到
`ban_user` 等 36 个动作,试了才被目标拒绝;现在门槛写在一处,列表与两个执行入口都读它。
- **风险与回滚**:收窄(BREAKING)。普通成员看不到这些动作,调用在入口就被 403;只靠旧 `role === 'admin'`
标量、没有平台管理员权限集的账号,经动作入口也被拒(直连 `/api/v1/auth/admin/*` 不变)。回滚即 revert。
- **先看**:①
`packages/platform-objects/src/identity/platform-admin-action-gate.ts`(两个门槛常量及为何能代表平台管理员身份);②
`packages/mcp/src/mcp-http-tools.ts` 的 `run_action` 预检与新的可选成员
`capabilityRefusal`;③ `sys-user.object.ts` 的 8 处 `requiredPermissions`。
- **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run
src/platform-admin-action-gate.test.ts`,预期 10/10 绿:普通成员列表里没有
`ban_user`/`set_user_manager`,管理员有;成员调用被 403 且目标从未被调用。
- **需要你做的**:收窄改动,落地前需要你对这个 PR 点头。
## What changes
Ruling A on #30126 row 16 (record 6674432613): an action declares its
gate once, and REST `/actions` and MCP `list_actions` read that one
declaration.
**Measured on `origin/main` (c17719d) before building:**
1. **An existing key carries the gate, so no spec change.**
`requiredPermissions` (ADR-0066 D4) is already on the action schema, and
its one reader is `actionPermissionError`
(`packages/runtime/src/action-execution.ts`), which compares it with
`ExecutionContext.systemPermissions`. The `PLATFORM_ADMIN` rung that the
platform-admin doors judge is conferred by two anchors (the stored
`admin_full_access` grant and `OS_PLATFORM_OWNER_EMAIL`), and both put
`ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions` on the holder
(`resolve-authz-context.ts`, step 6b). `manage_users` and
`manage_platform_settings` are in that list and are catalog `scope:
'platform'` capabilities that `organization_admin` withholds. So the
rung implies the capability, and the declaration never hides an action
from a caller its door admits on the rung.
2. **The shared reader already exists.** REST `/actions` has no listing
route; it is `POST` invocation only. `POST /actions/:object/:action`,
MCP `list_actions`, `run_action` and `resume_run` all call
`actionPermissionError` on the same declaration. The gap was that these
15 actions declared their gate only in `visible`, which is display only.
The one code change (the contract review's S1, below) is so the MCP
tools name that gate when it refuses.
3. **`invite_user` listed four times is correct.** It is declared on
four objects (`sys_user`, `sys_member`, `sys_invitation` as a
list-toolbar action, `sys_organization` as a record-scoped action with
`organizationId`), all targeting
`/api/v1/auth/organization/invite-member`. Each is a separate
`(objectName, name)` address, and `run_action` refuses a bare name that
is ambiguous across objects. Collapsing them would mean turning
`ai.exposed` off on mirrors, which narrows `run_action`. Left as is.
4. **Clause-②: yes (narrowing)**, accepted by the seat. See the
changeset for the three before/now pairs.
**The change.** A new module `platform-admin-action-gate.ts` exports
`PLATFORM_ADMIN_USERS_GATE` (`['manage_users']`) and
`PLATFORM_ADMIN_SETTINGS_GATE` (`['manage_platform_settings']`), with
the reasoning above. The 15 actions whose target is a
platform-admin-gated door (the population
`platform-admin-affordance-standing.test.ts` already derives from the
`/api/v1/auth/admin/` namespace plus `/organization/add-member`) spread
one of them into `requiredPermissions`:
| Object | Actions | Gate |
|:--|:--|:--|
| `sys_user` | `ban_user`, `unban_user`, `unlock_user`, `create_user`,
`set_user_password`, `impersonate_user`, `set_user_manager`,
`list_platform_admins` | `manage_users` |
| `sys_member` | `add_member` | `manage_users` |
| `sys_oauth_application` | `disable_oauth_application`,
`enable_oauth_application` | `manage_platform_settings` |
| `sys_sso_provider` | `register_sso_provider`,
`register_saml_provider`, `request_domain_verification`, `verify_domain`
| `manage_platform_settings` |
**The MCP refusal (contract review S1, 6679092098).** With
`allowSystemObjects` off, the MCP tools admit a `sys_*` action called
with `objectName` only where the bridge's `list_actions` lists it, and
answered every unlisted one with the system-object sentence (`… is not
exposed via MCP: … author exposed it to AI (ai.exposed) …`). Once the
listing leaves out an action whose gate refuses the caller, that
sentence names the wrong cause: all 15 are `ai.exposed: true`. So:
- `@objectstack/mcp`: `McpActionBridge` gains an OPTIONAL member,
`capabilityRefusal(name, objectName)`. For an unlisted `sys_*` action,
the scoped pre-screen asks it. A refusal is answered as that envelope;
`undefined` (not declared there, or not exposed to AI) keeps the
system-object sentence. A bridge without the member behaves as before,
and nothing runs in either case.
- `@objectstack/runtime`: the bridge implements it from the declarations
`listActions` reads (`domains/mcp.ts`). The answer is
`actionPermissionRefusal` (`action-execution.ts`, new): `{ code:
'PERMISSION_DENIED', status: 403, message }`, the message being
`actionPermissionError`'s sentence. `invokeBusinessAction` now throws
its capability refusal with that same `code` and `status`, so a scoped
call and a by-name call answer identically. This applies to EVERY action
with `requiredPermissions`, not only these 15: MCP `run_action`'s
capability refusal used to be a plain message, and it now carries the
REST door's `403` and its standard code.
**Before → now (for reviewers; the changeset states the same as
before/now):**
- MCP `list_actions`: FROM every caller listed all 15 → TO a caller
without the capability is not listed them.
- `POST /actions/:object/:action` and MCP `run_action` (by `actionName`
alone or with `objectName`): FROM forwarded, target answered 403
`PERMISSION_DENIED` "Admin role required" → TO the door answers 403
`PERMISSION_DENIED` with the capability sentence, and nothing is
forwarded.
- MCP `run_action`'s capability refusal for any action with
`requiredPermissions`: FROM a plain message (no `code`, no `status`) →
TO `code: 'PERMISSION_DENIED'`, `status: 403`, same message.
- MCP `run_action` with `objectName` on a `sys_*` action that is NOT
exposed to AI: unchanged, the system-object sentence.
- A caller whose only standing is the legacy `role === 'admin'` scalar:
FROM admitted through the action doors → TO refused 403 there; the
`/api/v1/auth/admin/*` endpoints still admit it.
## Tests
-
`packages/platform-objects/src/platform-admin-affordance-standing.test.ts`:
a new case, for every member of the derived family, that
`requiredPermissions` is non-empty and each name is both in
`ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions` and a `scope:
'platform'` catalog capability, with a positive control on the two
names.
- `packages/runtime/src/platform-admin-action-gate.test.ts` (new): the
real `sys_user` declaration through the production MCP bridge and the
REST door. A plain member is not listed `ban_user`/`set_user_manager`, a
platform admin is; the member is refused 403 at REST and at `run_action`
and the forward target is never called; the admin is forwarded; and for
every `sys_user` action and both callers, the listing omits it exactly
when the shared gate refuses it.
-
`packages/runtime/src/domains/api-action-create-user-email-param.test.ts`:
its caller is documented as a platform administrator but held no
capabilities; it now holds the rung's.
- `packages/qa/dogfood/test/api-action-door-forward.dogfood.test.ts`:
the plain-member case now asserts the door's capability sentence (status
403 as before).
-
`packages/qa/dogfood/test/mcp-system-object-action-exposure.dogfood.test.ts`:
the "plain member's agent passes the door and is refused by the identity
route" case is replaced. The member's agent is not listed `ban_user`;
scoped and by-name `run_action` both answer 403 `PERMISSION_DENIED` with
the identical capability sentence and never "not exposed"; nobody is
banned. A new CONTROL: `impersonate_user` (gated, no `ai` block), called
scoped by the same agent, keeps the system-object sentence.
- `packages/mcp/src/mcp-action-system-object-exposure.test.ts`: three
new cases over a double bridge. A `capabilityRefusal` answer is rendered
as its 403 envelope and nothing runs. CONTROL: an action the bridge
answers nothing for keeps the system-object sentence. CONTROL: a listed
action never asks.
- `packages/runtime/src/platform-admin-action-gate.test.ts` also pins
that by-name `run_action` throws `{ code: 'PERMISSION_DENIED', status:
403 }`, that `capabilityRefusal` answers the by-name message exactly,
and that it answers `undefined` for the admin, for `impersonate_user`
(CONTROL) and for an undeclared name.
<details><summary>Ablation (two legs, rebuilt dist, on-disk
proof)</summary>
- Leg 1, `PLATFORM_ADMIN_USERS_GATE` → `['ablation_30771_unheld']` via
`scripts/ablation-replace.mjs` (anchor 1 → 0, blob 0dd5273 → a3e22e53),
`pnpm --filter @objectstack/platform-objects build`,
`ablation-dist-preflight` marker present in 4 dist files. Standing pin:
1 failed / 5. Runtime: 6 failed / 8 (the admin is no longer listed or
forwarded).
- Leg 2, the same constant → `[]` (blob 0dd5273 → 2e06f41b), rebuilt,
`manage_users` count in `dist/identity/index.mjs` 0. Runtime: 6 failed /
8, including "a plain member is not listed ban_user or
set_user_manager".
- Restore: `ablation-replace --restore` (blob == HEAD, `git diff HEAD`
empty), rebuild, `ablation-dist-preflight --absent` marker absent from
66 files and tree clean; both files green again (6/6, 8/8).
</details>
## Acceptance notes
- The target endpoints are unchanged and still judge the rung
themselves, so a holder of `manage_users` without the rung (a custom
permission set) is listed and then refused by the target, as every
caller was before.
- The two dogfood files were added to the claim surface (6678953801);
their old assertions are what this change makes false.
- `McpActionBridge.capabilityRefusal` is a new optional member of a
published interface (widening). The `run_action` capability refusal
gaining `code` and `status` is additive for a client that reads only the
message.
- The changeset carries `adr-0087: not-required
(no-migration-prescription)`: no authored metadata is retired or
renamed. Its behaviour list is written as before/now, because a `FROM →
TO` label is read by `check-adr-0087-registration` as a migration
prescription and refuses that disposition; the FROM → TO form is in this
body instead.
## Verification
<details><summary>Local runs (final head 1764b50)</summary>
- On 1764b50: `pnpm --filter @objectstack/mcp typecheck` exit 0, `pnpm
--filter @objectstack/mcp test` Test Files 56 passed, Tests 683 passed;
`pnpm --filter @objectstack/runtime typecheck` exit 0, `pnpm --filter
@objectstack/runtime test` Test Files 548 passed, Tests 8770 passed, 19
skipped; dogfood `mcp-system-object-action-exposure`,
`api-action-door-forward`, `parent-read-gate-silent-skip`: 28/28.
- `pnpm --filter @objectstack/platform-objects typecheck` exit 0.
- `pnpm --filter @objectstack/platform-objects test`: Test Files 116
passed, Tests 1584 passed.
- Earlier, on 42264b4: `pnpm --filter @objectstack/runtime test` had 1
file / 4 tests failed (`api-action-create-user-email-param.test.ts`, the
fixture above); fixed in ba157b0.
- Full dogfood (`pnpm --filter @objectstack/dogfood test`, under the
verify lock) on 64f9d94: Test Files 423 passed, 3 skipped (426); Tests
3533 passed, 41 skipped (3574); VERDICT command-exit 0. Not re-run in
full on 1764b50; the three dogfood files the S1 change reaches are the
28/28 above.
- `node scripts/pm/dispatch-gates.mjs --commands` on 1764b50: 66
families derived; all 66 run, exit 0. `--ran` reconciliation: 66
derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. (On 8b33698: 63/63;
`check-adr-0087-registration` went green after the disposition marker,
and `check:dual-build-cjs-loads` after building 7 packages that had no
dist.)
- CI on 8b33698: 49 check-runs, 0 failures.
- CI runs the rest (`pnpm lint`, `test:repo`, the type-check lanes); not
run locally.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_016WV4czSnPb3bu4Q5weHEpr
<!-- signed: domain:server#4 · os-elon-musk ·
session_016WV4czvZykUqhYxYXSSDt4 -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent ac5d0ce commit 7569fb5
15 files changed
Lines changed: 500 additions & 14 deletions
File tree
- .changeset
- packages
- mcp/src
- platform-objects/src
- identity
- qa/dogfood/test
- runtime/src
- domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
131 | 131 | | |
132 | 132 | | |
133 | 133 | | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
315 | 315 | | |
316 | 316 | | |
317 | 317 | | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
318 | 330 | | |
319 | 331 | | |
320 | 332 | | |
| |||
1783 | 1795 | | |
1784 | 1796 | | |
1785 | 1797 | | |
1786 | | - | |
| 1798 | + | |
| 1799 | + | |
| 1800 | + | |
| 1801 | + | |
| 1802 | + | |
| 1803 | + | |
| 1804 | + | |
| 1805 | + | |
| 1806 | + | |
1787 | 1807 | | |
1788 | 1808 | | |
1789 | 1809 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| |||
177 | 178 | | |
178 | 179 | | |
179 | 180 | | |
| 181 | + | |
| 182 | + | |
180 | 183 | | |
181 | 184 | | |
182 | 185 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| |||
98 | 99 | | |
99 | 100 | | |
100 | 101 | | |
| 102 | + | |
| 103 | + | |
101 | 104 | | |
102 | 105 | | |
103 | 106 | | |
| |||
131 | 134 | | |
132 | 135 | | |
133 | 136 | | |
| 137 | + | |
| 138 | + | |
134 | 139 | | |
135 | 140 | | |
136 | 141 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
4 | 5 | | |
5 | 6 | | |
6 | 7 | | |
| |||
101 | 102 | | |
102 | 103 | | |
103 | 104 | | |
| 105 | + | |
| 106 | + | |
104 | 107 | | |
105 | 108 | | |
106 | 109 | | |
| |||
143 | 146 | | |
144 | 147 | | |
145 | 148 | | |
| 149 | + | |
| 150 | + | |
146 | 151 | | |
147 | 152 | | |
148 | 153 | | |
| |||
175 | 180 | | |
176 | 181 | | |
177 | 182 | | |
| 183 | + | |
| 184 | + | |
178 | 185 | | |
179 | 186 | | |
180 | 187 | | |
| |||
211 | 218 | | |
212 | 219 | | |
213 | 220 | | |
| 221 | + | |
| 222 | + | |
214 | 223 | | |
215 | 224 | | |
216 | 225 | | |
| |||
0 commit comments