Skip to content

Commit 7569fb5

Browse files
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
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/mcp': patch
5+
---
6+
7+
fix(platform-objects)!: the platform-admin actions declare their gate, so MCP `list_actions` no longer lists them to a caller who cannot run them, and both action doors refuse that caller themselves
8+
9+
Clause-②: yes (narrowing)
10+
Metadata-protocol: none
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) nothing an author wrote is retired or renamed: the change adds `requiredPermissions` to fifteen first-party action declarations, so stored metadata needs no rewrite, and the one thing an upgrader may act on (a legacy role-only admin needing the admin_full_access set or OS_PLATFORM_OWNER_EMAIL) is a grant, not a metadata edit -->
13+
14+
**BREAKING.** The 15 first-party actions whose door runs the platform-admin gate (on `sys_user`: `ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user`, `set_user_manager`, `list_platform_admins`; on `sys_member`: `add_member`; on `sys_oauth_application`: `disable_oauth_application`, `enable_oauth_application`; on `sys_sso_provider`: `register_sso_provider`, `register_saml_provider`, `request_domain_verification`, `verify_domain`) now declare `requiredPermissions`: `['manage_users']` for the account and membership doors, `['manage_platform_settings']` for the OAuth application and SSO doors. It is the existing ADR-0066 D4 key, read by one runtime check for MCP `list_actions`, MCP `run_action` and `POST /api/v1/actions/:object/:action`. A platform administrator holds both capabilities on either way of becoming one (the `admin_full_access` grant or `OS_PLATFORM_OWNER_EMAIL`), so nothing changes for them.
15+
16+
What changes, before and now:
17+
18+
- **MCP `list_actions`.** Before: every caller was listed all of these actions (a plain member saw `ban_user` and `set_user_manager`). Now: a caller without the capability is not listed them.
19+
- **The refusal at `POST /actions/:object/:action` and MCP `run_action`.** Before: the door forwarded the call and the target answered 403 `PERMISSION_DENIED` "Admin role required". Now: nothing reaches the target. `POST /actions/...` answers 403 itself, with the capability sentence (`Action 'ban_user' on 'sys_user' requires capability [manage_users] — caller is missing [manage_users]`), and MCP `run_action` answers the same sentence as `PERMISSION_DENIED` with status 403, whether it is called by `actionName` alone or with `objectName`.
20+
- **A caller whose only admin standing is the legacy better-auth `role === 'admin'` scalar** (no `admin_full_access` grant, not in `OS_PLATFORM_OWNER_EMAIL`). Before: the action doors forwarded the call and the target admitted it. Now: the action doors refuse it 403, because that caller holds no `manage_users` / `manage_platform_settings`. The target endpoints themselves (`/api/v1/auth/admin/*`) are unchanged and still admit it; give the account the `admin_full_access` permission set, or list its email in `OS_PLATFORM_OWNER_EMAIL`, to keep it on the action doors.
21+
22+
**MCP `run_action` refusal codes (`@objectstack/runtime`, `@objectstack/mcp`).** The capability gate's refusal at MCP `run_action` used to be a plain message; it now carries `code: 'PERMISSION_DENIED'` and `status: 403`, the REST door's answer, for every action with `requiredPermissions`. With `objectName` on a `sys_*` object, the MCP tools admit an action only where `list_actions` lists it, and answered every action missing from the listing with the system-object sentence (`… is not exposed via MCP …`). An action that IS exposed but whose gate refuses the caller now gets the gate's refusal there too, through a new optional `McpActionBridge.capabilityRefusal(name, objectName)` the runtime bridge implements; an action not exposed to AI keeps the system-object sentence, and a bridge without the member behaves as before.
23+
24+
What does not change: the target endpoints judge the platform-admin standing as before, the console shows and hides these buttons from their `visible` standing as before, and an action on another object is unaffected.

‎packages/mcp/src/mcp-action-system-object-exposure.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,46 @@ describe('[#27681] run_action: a sys_* action runs only where list_actions lists
131131
expect(bridge.runs).toEqual(['complete_task']);
132132
});
133133
});
134+
135+
describe('[#30771] run_action: a sys_* action left out for the caller\'s capability answers the gate, not the system-object sentence', () => {
136+
const GATE = "Action 'ban_user' on 'sys_user' requires capability [manage_users] — caller is missing [manage_users]";
137+
/** The bridge's answer for an exposed action whose declared gate refuses this caller. */
138+
const withCapabilityRefusal = (listing: McpActionSummary[]) => {
139+
const bridge = makeBridge(listing);
140+
const asked: Array<[string, string]> = [];
141+
return Object.assign(bridge, {
142+
asked,
143+
async capabilityRefusal(name: string, objectName: string) {
144+
asked.push([name, objectName]);
145+
return name === 'ban_user' ? { code: 'PERMISSION_DENIED', status: 403, message: GATE } : undefined;
146+
},
147+
});
148+
};
149+
150+
it('answers the bridge\'s capability refusal (403, PERMISSION_DENIED, the gate\'s sentence) and runs nothing', async () => {
151+
const bridge = withCapabilityRefusal([APP]);
152+
const json = await call(bridge, toolsCall(7, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' }));
153+
expect(json.result.isError).toBe(true);
154+
const body = JSON.parse(json.result.content[0].text);
155+
expect(body.error).toMatchObject({ code: 'PERMISSION_DENIED', status: 403, message: GATE });
156+
expect(String(json.result.content[0].text)).not.toMatch(/is not exposed via MCP/);
157+
expect(bridge.asked).toEqual([['ban_user', SYS]]);
158+
expect(bridge.runs).toEqual([]);
159+
});
160+
161+
it('CONTROL — an action the bridge answers no capability refusal for keeps the system-object sentence', async () => {
162+
const bridge = withCapabilityRefusal([APP]);
163+
const json = await call(bridge, toolsCall(8, 'run_action', { actionName: 'unban_user', objectName: SYS, recordId: 'u1' }));
164+
expect(json.result.isError).toBe(true);
165+
expect(String(json.result.content[0].text)).toMatch(/system object "sys_user" is not exposed via MCP/);
166+
expect(bridge.runs).toEqual([]);
167+
});
168+
169+
it('CONTROL — a listed action never asks: the listing admits it as before', async () => {
170+
const bridge = withCapabilityRefusal([EXPOSED]);
171+
const json = await call(bridge, toolsCall(9, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' }));
172+
expect(json.result.isError, JSON.stringify(json.result)).toBeFalsy();
173+
expect(bridge.asked).toEqual([]);
174+
expect(bridge.runs).toEqual(['ban_user']);
175+
});
176+
});

‎packages/mcp/src/mcp-http-tools.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,18 @@ export interface McpActionBridge {
315315
name: string,
316316
input: { objectName?: string; recordId?: string; params?: Record<string, unknown> } & AIActionConfirmation,
317317
): Promise<unknown>;
318+
/**
319+
* [#30771] The capability refusal `runAction` would throw for an action on
320+
* `objectName` that {@link listActions} leaves out because the caller lacks
321+
* its declared `requiredPermissions` — `{ code, status, message }`, the same
322+
* values the by-name call throws. `undefined` for anything else (not
323+
* declared, not exposed to AI), so `run_action`'s scoped `sys_*` pre-screen
324+
* keeps its system-object refusal there. Runs nothing.
325+
*
326+
* OPTIONAL: a bridge without it keeps the system-object refusal for every
327+
* `sys_*` action its listing leaves out, as before.
328+
*/
329+
capabilityRefusal?(name: string, objectName: string): Promise<ThrownRefusalEnvelope | undefined>;
318330
/**
319331
* [#15705] Continue a run that `runAction` answered with `status: 'paused'`
320332
* and a `screen`, submitting that screen's field values as `values`.
@@ -1783,7 +1795,15 @@ export function registerActionTools(
17831795
const admitted = listed.some(
17841796
(a) => a.name === actionName && a.objectName === objectName && isListableAction(a),
17851797
);
1786-
if (!admitted) return errorResult(systemObjectActionRefusal(actionName, objectName));
1798+
if (!admitted) {
1799+
// [#30771] An exposed action the caller's declared gate refuses is
1800+
// left out of the listing too: answer that gate's refusal, the one
1801+
// the by-name call gets, not the system-object sentence. Nothing
1802+
// runs either way.
1803+
const refusal = await bridge.capabilityRefusal?.(actionName, objectName);
1804+
if (refusal) return errorResultWithCode(Object.assign(new Error(refusal.message), refusal));
1805+
return errorResult(systemObjectActionRefusal(actionName, objectName));
1806+
}
17871807
}
17881808
const result = await bridge.runAction(actionName, {
17891809
objectName,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The declared gate of a first-party action whose door runs the ADR-0068
5+
* platform-admin gate (#30771, ruling A on #30126 row 16).
6+
*
7+
* ## Why the action declares it
8+
*
9+
* Such a door answers every caller below the ADR-0095 D3 `PLATFORM_ADMIN` rung
10+
* 403. Its action used to carry that fact only in `visible`, which is display
11+
* only: no door reads it. So MCP `list_actions` listed `ban_user` and
12+
* `set_user_manager` to a plain member, and the platform action door
13+
* (`POST /actions/:object/:action`, MCP `run_action`) forwarded the call for the
14+
* target to refuse. Declaring the gate in `requiredPermissions` (ADR-0066 D4,
15+
* an existing key) puts it where both doors already look: the runtime's ONE
16+
* reader, `actionPermissionError`, refuses the call with 403 at the door and
17+
* leaves the action out of `list_actions`, from this one declaration.
18+
*
19+
* ## Why a capability stands for the rung
20+
*
21+
* The rung is not a key an action can name, but the rung implies these
22+
* capabilities by construction: both anchors that confer it (the stored
23+
* `admin_full_access` grant and the deployment's `OS_PLATFORM_OWNER_EMAIL`
24+
* list) hand the holder `ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions`,
25+
* and both names below are catalog `scope: 'platform'` capabilities that
26+
* `organization_admin` withholds. So the declaration never hides the action from
27+
* a caller its door admits on the rung, and the door still judges the rung
28+
* itself: a holder of the capability without the rung is listed and refused by
29+
* the target, as before.
30+
*
31+
* Pinned, for every action on such a door, by
32+
* `platform-admin-affordance-standing.test.ts` (both halves: the `visible`
33+
* standing and this declaration).
34+
*/
35+
36+
/** Doors that administer accounts and memberships across the platform. */
37+
export const PLATFORM_ADMIN_USERS_GATE: readonly string[] = Object.freeze(['manage_users']);
38+
39+
/** Doors that administer platform-wide settings: OAuth applications, SSO providers. */
40+
export const PLATFORM_ADMIN_SETTINGS_GATE: readonly string[] = Object.freeze(['manage_platform_settings']);

‎packages/platform-objects/src/identity/sys-member.object.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
import { PLATFORM_ADMIN_USERS_GATE } from './platform-admin-action-gate.js';
45
import { F } from '@objectstack/spec/shared';
56
import {
67
BUILTIN_MEMBERSHIP_ROLE_OPTIONS,
@@ -177,6 +178,8 @@ export const SysMember = ObjectSchema.create({
177178
locations: ['list_toolbar'],
178179
type: 'api',
179180
target: '/api/v1/auth/organization/add-member',
181+
// The door's platform-admin gate, declared where both action doors read it (#30771).
182+
requiredPermissions: [...PLATFORM_ADMIN_USERS_GATE],
180183
// Offered only to the one standing the door admits (ADR-0068 D4):
181184
// `current_user.isPlatformAdmin` is the ADR-0095 D3 PLATFORM_ADMIN
182185
// posture rung — what the session payload emits and what the mount's

‎packages/platform-objects/src/identity/sys-oauth-application.object.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
import { PLATFORM_ADMIN_SETTINGS_GATE } from './platform-admin-action-gate.js';
45

56
/**
67
* sys_oauth_application — Registered OAuth/OIDC client application
@@ -98,6 +99,8 @@ export const SysOauthApplication = ObjectSchema.create({
9899
type: 'api',
99100
method: 'POST',
100101
target: '/api/v1/auth/admin/oauth2/toggle-disabled',
102+
// The door's platform-admin gate, declared where both action doors read it (#30771).
103+
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
101104
requiresFeature: 'oidcProvider',
102105
// The confirm question rides `description`, not `confirmText`: this action
103106
// collects params, and the console action runner chains confirmation THEN
@@ -131,6 +134,8 @@ export const SysOauthApplication = ObjectSchema.create({
131134
type: 'api',
132135
method: 'POST',
133136
target: '/api/v1/auth/admin/oauth2/toggle-disabled',
137+
// The door's platform-admin gate, declared where both action doors read it (#30771).
138+
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
134139
requiresFeature: 'oidcProvider',
135140
// Confirm question on `description` — one dialog, not two (#7278/#7309).
136141
description: 'Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.',

‎packages/platform-objects/src/identity/sys-sso-provider.object.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
import { PLATFORM_ADMIN_SETTINGS_GATE } from './platform-admin-action-gate.js';
45

56
/**
67
* sys_sso_provider — Registered external SSO identity provider (OIDC / SAML)
@@ -101,6 +102,8 @@ export const SysSsoProvider = ObjectSchema.create({
101102
// /sso/register would drop clientId/clientSecret (top-level → Zod-stripped)
102103
// and persist an unusable `oidc_config = null` provider.
103104
target: '/api/v1/auth/admin/sso/register',
105+
// The door's platform-admin gate, declared where both action doors read it (#30771).
106+
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
104107
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
105108
visible: 'current_user.isPlatformAdmin == true',
106109
refreshAfter: true,
@@ -143,6 +146,8 @@ export const SysSsoProvider = ObjectSchema.create({
143146
// re-dispatches to /sso/register (admin gate runs). The response returns
144147
// the SP ACS + metadata URLs to configure on the IdP.
145148
target: '/api/v1/auth/admin/sso/register-saml',
149+
// The door's platform-admin gate, declared where both action doors read it (#30771).
150+
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
146151
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
147152
visible: 'current_user.isPlatformAdmin == true',
148153
refreshAfter: true,
@@ -175,6 +180,8 @@ export const SysSsoProvider = ObjectSchema.create({
175180
// feature is OFF the bridge returns a clear "not enabled for this
176181
// environment" error instead of a bare 404.
177182
target: '/api/v1/auth/admin/sso/request-domain-verification',
183+
// The door's platform-admin gate, declared where both action doors read it (#30771).
184+
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
178185
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
179186
visible: 'current_user.isPlatformAdmin == true',
180187
params: [
@@ -211,6 +218,8 @@ export const SysSsoProvider = ObjectSchema.create({
211218
// on success. Routed through the env bridge, which maps @better-auth/sso's
212219
// empty 204 / 502 into a clear success/error toast.
213220
target: '/api/v1/auth/admin/sso/verify-domain',
221+
// The door's platform-admin gate, declared where both action doors read it (#30771).
222+
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
214223
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
215224
visible: 'current_user.isPlatformAdmin == true',
216225
successMessage: 'Domain ownership verified',

0 commit comments

Comments
 (0)