From 5a969f0e7b80870799333ce84015b95cbcbde148 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 01:48:40 +0000 Subject: [PATCH] fix(platform-objects): retire the set_user_role action from sys_user set_user_role's only effect was internalAdapter.updateUser(userId, { role }) - a gated, UI-driven writer for the legacy sys_user.role scalar ADR-0068 D2 stopped synthesizing. Platform-admin membership is granted through sys_user_permission_set / admin_full_access; a working "Set Platform Role" button was a supported, one-user-at-a-time channel for resurrecting the dual identity representation the 2026-08-18 ruling permanently vetoed (Option 3). Removal, not a narrowed re-implementation (maintainer ruling, 2026-08-20, reaffirmed 2026-08-22, Option B). The vendor's POST /admin/set-role route itself stays mounted and vendor-gated, unchanged - only the sys_user console action pointing at it is gone. packages/spec/src/kernel/public-auth-features.ts drops the corresponding 'sys_user.actions.set_user_role' entry from PUBLIC_AUTH_FEATURES.admin's gatedInputs - the bidirectional feature-gate-guard completeness check in platform-objects reads this registry and forces the edit. Pinned in one test with its counter-direction so a retirement that removed the wrong entry (or several) cannot pass: the retired name is asserted gone BY NAME, and all 15 sibling sys_user actions are asserted present BY NAME. Translations regenerated via `node scripts/check-i18n-bundles.mjs --write` (never hand-edited). #9968 half 1. Half 2 (impersonate_user) is already fixed and merged on main via PR #10352 - no changes needed here; see the PR body for the measurement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 --- .changeset/retire-set-user-role-action.md | 14 +++++++ .../apps/translations/en.objects.generated.ts | 9 ----- .../translations/es-ES.objects.generated.ts | 9 ----- .../translations/ja-JP.objects.generated.ts | 9 ----- .../translations/zh-CN.objects.generated.ts | 9 ----- .../src/feature-gate-guard.test.ts | 8 ++-- .../src/identity/sys-user.object.ts | 26 +++++-------- .../src/pages/sys-user.page.ts | 4 +- .../src/platform-objects.test.ts | 37 ++++++++++++++++++- .../spec/src/kernel/public-auth-features.ts | 3 +- 10 files changed, 68 insertions(+), 60 deletions(-) create mode 100644 .changeset/retire-set-user-role-action.md diff --git a/.changeset/retire-set-user-role-action.md b/.changeset/retire-set-user-role-action.md new file mode 100644 index 0000000000..4f3a312055 --- /dev/null +++ b/.changeset/retire-set-user-role-action.md @@ -0,0 +1,14 @@ +--- +"@objectstack/platform-objects": patch +"@objectstack/spec": patch +--- + +**Fix:** `sys_user`'s **`set_user_role`** action ("Set Platform Role") is retired — removed from the object's declared actions, not re-implemented (#9968). + +The action's only effect was `POST /api/v1/auth/admin/set-role`, which better-auth's `admin` plugin lowers to `internalAdapter.updateUser(userId, { role })` — a gated, UI-driven writer for the legacy `sys_user.role` scalar that ADR-0068 D2 stopped synthesizing. Platform-admin membership is granted through `sys_user_permission_set` / `admin_full_access`; a working "Set Platform Role" button was a supported, one-user-at-a-time channel for resurrecting the dual identity representation the 2026-08-18 ruling permanently vetoed (Option 3). + +**What an operator will now observe.** The "Set Platform Role" button is gone from the Users list row menu and the user detail header. It was already dead for every platform admin before this change — better-auth's vendor `adminMiddleware` gates on the same retired scalar, so the button 403'd with `YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE` for platform admins and plain members alike. Removing it removes a byte-identical-refusal dead affordance, not a working capability. + +**Unchanged.** The vendor's `POST /api/v1/auth/admin/set-role` route itself stays mounted and vendor-gated exactly as before — this change touches only the `sys_user` console action pointing at it. Every other `sys_user` admin action (`ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user`) is unaffected. + +`@objectstack/spec`'s `PUBLIC_AUTH_FEATURES.admin.gatedInputs` registry drops the corresponding `sys_user.actions.set_user_role` entry in the same change (`packages/spec/src/kernel/public-auth-features.ts`) — internal completeness-guard bookkeeping only, no public export shape change. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 927ee20fdc..95b59b929a 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -209,15 +209,6 @@ export const enObjects: NonNullable = { } } }, - set_user_role: { - label: "Set Platform Role", - successMessage: "Role updated", - params: { - role: { - label: "Platform Role" - } - } - }, impersonate_user: { label: "Impersonate User", confirmText: "Start an impersonation session for this user? Use only for legitimate support cases — actions will be logged.", diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index e67b5982fa..321c6fd757 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -209,15 +209,6 @@ export const esESObjects: NonNullable = { } } }, - set_user_role: { - label: "Establecer rol de plataforma", - successMessage: "Rol actualizado", - params: { - role: { - label: "Rol de plataforma" - } - } - }, impersonate_user: { label: "Suplantar usuario", confirmText: "¿Iniciar una sesión de suplantación para este usuario? Úsela solo para casos legítimos de soporte; las acciones se registrarán.", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 5e34cb9b39..1b05a26b99 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -209,15 +209,6 @@ export const jaJPObjects: NonNullable = { } } }, - set_user_role: { - label: "プラットフォームロールを設定", - successMessage: "ロールを更新しました", - params: { - role: { - label: "プラットフォームロール" - } - } - }, impersonate_user: { label: "代理ログイン", confirmText: "このユーザーとして代理ログインを開始しますか?正当なサポート対応時のみ使用してください。操作は監査ログに記録されます。", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index e9d93c4408..07b21e4ba3 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -209,15 +209,6 @@ export const zhCNObjects: NonNullable = { } } }, - set_user_role: { - label: "设置平台角色", - successMessage: "角色已更新", - params: { - role: { - label: "平台角色" - } - } - }, impersonate_user: { label: "模拟用户", confirmText: "要为该用户启动模拟会话吗?仅限合法支持场景使用——所有操作都会被记录。", diff --git a/packages/platform-objects/src/feature-gate-guard.test.ts b/packages/platform-objects/src/feature-gate-guard.test.ts index 9d011e3635..dba49b9634 100644 --- a/packages/platform-objects/src/feature-gate-guard.test.ts +++ b/packages/platform-objects/src/feature-gate-guard.test.ts @@ -113,9 +113,11 @@ describe('feature-gate completeness guard (#2874)', () => { } it('finds the gated surface (guards the walker itself)', () => { - // 38 booked inputs exist today; if the walker ever goes blind and finds - // none, the it.each below would vacuously pass — pin a floor instead. - expect(referencedInputs.length).toBeGreaterThanOrEqual(38); + // 37 booked inputs exist today (38 before #9968 retired + // sys_user.actions.set_user_role); if the walker ever goes blind and + // finds none, the it.each below would vacuously pass — pin a floor + // instead. + expect(referencedInputs.length).toBeGreaterThanOrEqual(37); }); it.each(referencedInputs)('%s references registered flag %s and is booked', (path, flag) => { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index f54463fca0..32f06af212 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -249,22 +249,16 @@ export const SysUser = ObjectSchema.create({ ], }, }, - { - name: 'set_user_role', - label: 'Set Platform Role', - icon: 'shield-check', - variant: 'secondary', - locations: ['list_item', 'record_header'], - type: 'api', - target: '/api/v1/auth/admin/set-role', - requiresFeature: 'admin', - recordIdParam: 'userId', - successMessage: 'Role updated', - refreshAfter: true, - params: [ - { name: 'role', label: 'Platform Role', type: 'text', required: true }, - ], - }, + // #9968 — `set_user_role` (target: /api/v1/auth/admin/set-role) retired + // from here. Its only effect was `internalAdapter.updateUser(userId, { + // role })` — a gated, UI-driven writer for the legacy `sys_user.role` + // scalar ADR-0068 D2 stopped synthesizing. Platform-admin membership is + // granted through `sys_user_permission_set` / `admin_full_access`; a + // working "Set Platform Role" button was a supported, one-user-at-a-time + // resurrection channel for the dual identity representation the + // 2026-08-18 ruling permanently vetoed (Option 3). Removal, not a + // narrowed re-implementation — the vendor route itself stays mounted + // and vendor-gated, unchanged; only this action/button is gone. { name: 'impersonate_user', label: 'Impersonate User', diff --git a/packages/platform-objects/src/pages/sys-user.page.ts b/packages/platform-objects/src/pages/sys-user.page.ts index 4dc9894837..0773ca0b30 100644 --- a/packages/platform-objects/src/pages/sys-user.page.ts +++ b/packages/platform-objects/src/pages/sys-user.page.ts @@ -13,7 +13,7 @@ import type { Page } from '@objectstack/spec/ui'; * personal profile. This page therefore optimizes for the admin * use case: scanning a user's signals (email/verification/2FA/role), * reviewing related sessions/orgs/oauth/api-keys, and triggering - * admin actions (ban / impersonate / set_role). + * admin actions (ban / impersonate). * * Strategy * -------- @@ -21,7 +21,7 @@ import type { Page } from '@objectstack/spec/ui'; * `details`, `tabs` and `discussion`. Header / actions fall through * to the synthesizer so the object's declared actions * (`update_my_profile / change_my_password / resend_verification_email - * / ban_user / set_user_role / impersonate_user / …`) still appear + * / ban_user / impersonate_user / …`) still appear * in the header overflow menu automatically. * - `highlights` promotes the four signals worth scanning at the top: * email, verification state, 2FA, platform role. Highlight fields diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 4b3f8e40a5..a63575c09e 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -229,7 +229,7 @@ describe('@objectstack/platform-objects', () => { // header overflows extras into the ⋯ "More" menu). `record_header` is the // only detail-surface location objectui consumes (it does NOT read // `record_more`), so these must use `record_header` specifically. - const adminActions = ['ban_user', 'unban_user', 'unlock_user', 'set_user_password', 'set_user_role', 'impersonate_user']; + const adminActions = ['ban_user', 'unban_user', 'unlock_user', 'set_user_password', 'impersonate_user']; for (const name of adminActions) { const a = (SysUser.actions ?? []).find((x) => x.name === name); expect(a, `${name} action must exist`).toBeTruthy(); @@ -237,6 +237,40 @@ describe('@objectstack/platform-objects', () => { expect(a?.locations, `${name} must also surface on the detail header`).toContain('record_header'); } }); + + it('#9968 — set_user_role is retired from sys_user, every sibling admin action survives', () => { + // set_user_role's only effect was internalAdapter.updateUser(userId, + // { role }) — a gated UI writer for the legacy sys_user.role scalar + // ADR-0068 D2 stopped synthesizing (platform-admin membership moved to + // sys_user_permission_set / admin_full_access). Removal, not a + // narrowed re-implementation (maintainer ruling, 2026-08-20/2026-08-22, + // Option B). Pinned in one test with its counter-direction so a + // retirement that removed the WRONG entry (or several) cannot pass: + // the retired name must be gone by name, and every sibling survivor + // must still be present by name. + const actionNames = (SysUser.actions ?? []).map((a) => a.name); + expect(actionNames, 'set_user_role must be gone').not.toContain('set_user_role'); + const survivors = [ + 'invite_user', + 'ban_user', + 'unban_user', + 'unlock_user', + 'create_user', + 'set_user_password', + 'impersonate_user', + 'update_my_profile', + 'change_my_password', + 'change_my_email', + 'resend_verification_email', + 'delete_my_account', + 'enable_two_factor', + 'disable_two_factor', + 'generate_backup_codes', + ]; + for (const name of survivors) { + expect(actionNames, `${name} must survive the set_user_role retirement`).toContain(name); + } + }); }); describe('data portability — derived, not declared (#3025 / #3543)', () => { @@ -506,7 +540,6 @@ describe('feature-gate lowering matrix (#2874)', () => { ['SysUser', SysUser, 'unban_user', 'features.admin == true'], ['SysUser', SysUser, 'unlock_user', 'features.admin == true'], ['SysUser', SysUser, 'set_user_password', 'features.admin == true'], - ['SysUser', SysUser, 'set_user_role', 'features.admin == true'], ['SysUser', SysUser, 'impersonate_user', 'features.admin == true'], ['SysUser', SysUser, 'enable_two_factor', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled != true) && features.twoFactor == true'], ['SysUser', SysUser, 'disable_two_factor', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled == true) && features.twoFactor == true'], diff --git a/packages/spec/src/kernel/public-auth-features.ts b/packages/spec/src/kernel/public-auth-features.ts index bbeb54d87d..5ea50c80ac 100644 --- a/packages/spec/src/kernel/public-auth-features.ts +++ b/packages/spec/src/kernel/public-auth-features.ts @@ -200,7 +200,8 @@ export const PUBLIC_AUTH_FEATURES = { 'sys_user.actions.unban_user', 'sys_user.actions.unlock_user', 'sys_user.actions.set_user_password', - 'sys_user.actions.set_user_role', + // 'sys_user.actions.set_user_role' retired (#9968) — see the removal + // note beside `impersonate_user` in sys-user.object.ts. 'sys_user.actions.impersonate_user', ], notes: 'SCIM forces the admin plugin (and this flag) on — ADR-0071.',