Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/30771-platform-admin-action-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/platform-objects': patch
'@objectstack/runtime': patch
'@objectstack/mcp': patch
---

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

Clause-②: yes (narrowing)
Metadata-protocol: none

<!-- 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 -->

**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.

What changes, before and now:

- **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.
- **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`.
- **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.

**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.

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.
43 changes: 43 additions & 0 deletions packages/mcp/src/mcp-action-system-object-exposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,46 @@ describe('[#27681] run_action: a sys_* action runs only where list_actions lists
expect(bridge.runs).toEqual(['complete_task']);
});
});

describe('[#30771] run_action: a sys_* action left out for the caller\'s capability answers the gate, not the system-object sentence', () => {
const GATE = "Action 'ban_user' on 'sys_user' requires capability [manage_users] — caller is missing [manage_users]";
/** The bridge's answer for an exposed action whose declared gate refuses this caller. */
const withCapabilityRefusal = (listing: McpActionSummary[]) => {
const bridge = makeBridge(listing);
const asked: Array<[string, string]> = [];
return Object.assign(bridge, {
asked,
async capabilityRefusal(name: string, objectName: string) {
asked.push([name, objectName]);
return name === 'ban_user' ? { code: 'PERMISSION_DENIED', status: 403, message: GATE } : undefined;
},
});
};

it('answers the bridge\'s capability refusal (403, PERMISSION_DENIED, the gate\'s sentence) and runs nothing', async () => {
const bridge = withCapabilityRefusal([APP]);
const json = await call(bridge, toolsCall(7, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' }));
expect(json.result.isError).toBe(true);
const body = JSON.parse(json.result.content[0].text);
expect(body.error).toMatchObject({ code: 'PERMISSION_DENIED', status: 403, message: GATE });
expect(String(json.result.content[0].text)).not.toMatch(/is not exposed via MCP/);
expect(bridge.asked).toEqual([['ban_user', SYS]]);
expect(bridge.runs).toEqual([]);
});

it('CONTROL — an action the bridge answers no capability refusal for keeps the system-object sentence', async () => {
const bridge = withCapabilityRefusal([APP]);
const json = await call(bridge, toolsCall(8, 'run_action', { actionName: 'unban_user', objectName: SYS, recordId: 'u1' }));
expect(json.result.isError).toBe(true);
expect(String(json.result.content[0].text)).toMatch(/system object "sys_user" is not exposed via MCP/);
expect(bridge.runs).toEqual([]);
});

it('CONTROL — a listed action never asks: the listing admits it as before', async () => {
const bridge = withCapabilityRefusal([EXPOSED]);
const json = await call(bridge, toolsCall(9, 'run_action', { actionName: 'ban_user', objectName: SYS, recordId: 'u1' }));
expect(json.result.isError, JSON.stringify(json.result)).toBeFalsy();
expect(bridge.asked).toEqual([]);
expect(bridge.runs).toEqual(['ban_user']);
});
});
22 changes: 21 additions & 1 deletion packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,18 @@ export interface McpActionBridge {
name: string,
input: { objectName?: string; recordId?: string; params?: Record<string, unknown> } & AIActionConfirmation,
): Promise<unknown>;
/**
* [#30771] The capability refusal `runAction` would throw for an action on
* `objectName` that {@link listActions} leaves out because the caller lacks
* its declared `requiredPermissions` — `{ code, status, message }`, the same
* values the by-name call throws. `undefined` for anything else (not
* declared, not exposed to AI), so `run_action`'s scoped `sys_*` pre-screen
* keeps its system-object refusal there. Runs nothing.
*
* OPTIONAL: a bridge without it keeps the system-object refusal for every
* `sys_*` action its listing leaves out, as before.
*/
capabilityRefusal?(name: string, objectName: string): Promise<ThrownRefusalEnvelope | undefined>;
/**
* [#15705] Continue a run that `runAction` answered with `status: 'paused'`
* and a `screen`, submitting that screen's field values as `values`.
Expand Down Expand Up @@ -1783,7 +1795,15 @@ export function registerActionTools(
const admitted = listed.some(
(a) => a.name === actionName && a.objectName === objectName && isListableAction(a),
);
if (!admitted) return errorResult(systemObjectActionRefusal(actionName, objectName));
if (!admitted) {
// [#30771] An exposed action the caller's declared gate refuses is
// left out of the listing too: answer that gate's refusal, the one
// the by-name call gets, not the system-object sentence. Nothing
// runs either way.
const refusal = await bridge.capabilityRefusal?.(actionName, objectName);
if (refusal) return errorResultWithCode(Object.assign(new Error(refusal.message), refusal));
return errorResult(systemObjectActionRefusal(actionName, objectName));
}
}
const result = await bridge.runAction(actionName, {
objectName,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The declared gate of a first-party action whose door runs the ADR-0068
* platform-admin gate (#30771, ruling A on #30126 row 16).
*
* ## Why the action declares it
*
* Such a door answers every caller below the ADR-0095 D3 `PLATFORM_ADMIN` rung
* 403. Its action used to carry that fact only in `visible`, which is display
* only: no door reads it. So MCP `list_actions` listed `ban_user` and
* `set_user_manager` to a plain member, and the platform action door
* (`POST /actions/:object/:action`, MCP `run_action`) forwarded the call for the
* target to refuse. Declaring the gate in `requiredPermissions` (ADR-0066 D4,
* an existing key) puts it where both doors already look: the runtime's ONE
* reader, `actionPermissionError`, refuses the call with 403 at the door and
* leaves the action out of `list_actions`, from this one declaration.
*
* ## Why a capability stands for the rung
*
* The rung is not a key an action can name, but the rung implies these
* capabilities by construction: both anchors that confer it (the stored
* `admin_full_access` grant and the deployment's `OS_PLATFORM_OWNER_EMAIL`
* list) hand the holder `ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions`,
* and both names below are catalog `scope: 'platform'` capabilities that
* `organization_admin` withholds. So the declaration never hides the action from
* a caller its door admits on the rung, and the door still judges the rung
* itself: a holder of the capability without the rung is listed and refused by
* the target, as before.
*
* Pinned, for every action on such a door, by
* `platform-admin-affordance-standing.test.ts` (both halves: the `visible`
* standing and this declaration).
*/

/** Doors that administer accounts and memberships across the platform. */
export const PLATFORM_ADMIN_USERS_GATE: readonly string[] = Object.freeze(['manage_users']);

/** Doors that administer platform-wide settings: OAuth applications, SSO providers. */
export const PLATFORM_ADMIN_SETTINGS_GATE: readonly string[] = Object.freeze(['manage_platform_settings']);
3 changes: 3 additions & 0 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { ObjectSchema, Field } from '@objectstack/spec/data';
import { PLATFORM_ADMIN_USERS_GATE } from './platform-admin-action-gate.js';
import { F } from '@objectstack/spec/shared';
import {
BUILTIN_MEMBERSHIP_ROLE_OPTIONS,
Expand Down Expand Up @@ -177,6 +178,8 @@ export const SysMember = ObjectSchema.create({
locations: ['list_toolbar'],
type: 'api',
target: '/api/v1/auth/organization/add-member',
// The door's platform-admin gate, declared where both action doors read it (#30771).
requiredPermissions: [...PLATFORM_ADMIN_USERS_GATE],
// Offered only to the one standing the door admits (ADR-0068 D4):
// `current_user.isPlatformAdmin` is the ADR-0095 D3 PLATFORM_ADMIN
// posture rung — what the session payload emits and what the mount's
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

/**
* sys_oauth_application — Registered OAuth/OIDC client application
Expand Down Expand Up @@ -98,6 +99,8 @@ export const SysOauthApplication = ObjectSchema.create({
type: 'api',
method: 'POST',
target: '/api/v1/auth/admin/oauth2/toggle-disabled',
// The door's platform-admin gate, declared where both action doors read it (#30771).
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
requiresFeature: 'oidcProvider',
// The confirm question rides `description`, not `confirmText`: this action
// collects params, and the console action runner chains confirmation THEN
Expand Down Expand Up @@ -131,6 +134,8 @@ export const SysOauthApplication = ObjectSchema.create({
type: 'api',
method: 'POST',
target: '/api/v1/auth/admin/oauth2/toggle-disabled',
// The door's platform-admin gate, declared where both action doors read it (#30771).
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
requiresFeature: 'oidcProvider',
// Confirm question on `description` — one dialog, not two (#7278/#7309).
description: 'Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

/**
* sys_sso_provider — Registered external SSO identity provider (OIDC / SAML)
Expand Down Expand Up @@ -101,6 +102,8 @@ export const SysSsoProvider = ObjectSchema.create({
// /sso/register would drop clientId/clientSecret (top-level → Zod-stripped)
// and persist an unusable `oidc_config = null` provider.
target: '/api/v1/auth/admin/sso/register',
// The door's platform-admin gate, declared where both action doors read it (#30771).
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
visible: 'current_user.isPlatformAdmin == true',
refreshAfter: true,
Expand Down Expand Up @@ -143,6 +146,8 @@ export const SysSsoProvider = ObjectSchema.create({
// re-dispatches to /sso/register (admin gate runs). The response returns
// the SP ACS + metadata URLs to configure on the IdP.
target: '/api/v1/auth/admin/sso/register-saml',
// The door's platform-admin gate, declared where both action doors read it (#30771).
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
visible: 'current_user.isPlatformAdmin == true',
refreshAfter: true,
Expand Down Expand Up @@ -175,6 +180,8 @@ export const SysSsoProvider = ObjectSchema.create({
// feature is OFF the bridge returns a clear "not enabled for this
// environment" error instead of a bare 404.
target: '/api/v1/auth/admin/sso/request-domain-verification',
// The door's platform-admin gate, declared where both action doors read it (#30771).
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
visible: 'current_user.isPlatformAdmin == true',
params: [
Expand Down Expand Up @@ -211,6 +218,8 @@ export const SysSsoProvider = ObjectSchema.create({
// on success. Routed through the env bridge, which maps @better-auth/sso's
// empty 204 / 502 into a clear success/error toast.
target: '/api/v1/auth/admin/sso/verify-domain',
// The door's platform-admin gate, declared where both action doors read it (#30771).
requiredPermissions: [...PLATFORM_ADMIN_SETTINGS_GATE],
// Platform-admin standing (ADR-0068 D4) — see the actions header above.
visible: 'current_user.isPlatformAdmin == true',
successMessage: 'Domain ownership verified',
Expand Down
Loading
Loading