Skip to content

Commit 033a34c

Browse files
os-samclaude
andauthored
fix(platform-objects): retire the set_user_role action from sys_user (#11530)
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. Claude-Session: https://claude.ai/code/session_01APWX2AwT3a4xDcjPCe8bk4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c179dd7 commit 033a34c

10 files changed

Lines changed: 68 additions & 60 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/spec": patch
4+
---
5+
6+
**Fix:** `sys_user`'s **`set_user_role`** action ("Set Platform Role") is retired — removed from the object's declared actions, not re-implemented (#9968).
7+
8+
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).
9+
10+
**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.
11+
12+
**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.
13+
14+
`@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.

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -209,15 +209,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
209209
}
210210
}
211211
},
212-
set_user_role: {
213-
label: "Set Platform Role",
214-
successMessage: "Role updated",
215-
params: {
216-
role: {
217-
label: "Platform Role"
218-
}
219-
}
220-
},
221212
impersonate_user: {
222213
label: "Impersonate User",
223214
confirmText: "Start an impersonation session for this user? Use only for legitimate support cases — actions will be logged.",

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -209,15 +209,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
209209
}
210210
}
211211
},
212-
set_user_role: {
213-
label: "Establecer rol de plataforma",
214-
successMessage: "Rol actualizado",
215-
params: {
216-
role: {
217-
label: "Rol de plataforma"
218-
}
219-
}
220-
},
221212
impersonate_user: {
222213
label: "Suplantar usuario",
223214
confirmText: "¿Iniciar una sesión de suplantación para este usuario? Úsela solo para casos legítimos de soporte; las acciones se registrarán.",

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -209,15 +209,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
209209
}
210210
}
211211
},
212-
set_user_role: {
213-
label: "プラットフォームロールを設定",
214-
successMessage: "ロールを更新しました",
215-
params: {
216-
role: {
217-
label: "プラットフォームロール"
218-
}
219-
}
220-
},
221212
impersonate_user: {
222213
label: "代理ログイン",
223214
confirmText: "このユーザーとして代理ログインを開始しますか?正当なサポート対応時のみ使用してください。操作は監査ログに記録されます。",

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -209,15 +209,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
209209
}
210210
}
211211
},
212-
set_user_role: {
213-
label: "设置平台角色",
214-
successMessage: "角色已更新",
215-
params: {
216-
role: {
217-
label: "平台角色"
218-
}
219-
}
220-
},
221212
impersonate_user: {
222213
label: "模拟用户",
223214
confirmText: "要为该用户启动模拟会话吗?仅限合法支持场景使用——所有操作都会被记录。",

packages/platform-objects/src/feature-gate-guard.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,11 @@ describe('feature-gate completeness guard (#2874)', () => {
113113
}
114114

115115
it('finds the gated surface (guards the walker itself)', () => {
116-
// 38 booked inputs exist today; if the walker ever goes blind and finds
117-
// none, the it.each below would vacuously pass — pin a floor instead.
118-
expect(referencedInputs.length).toBeGreaterThanOrEqual(38);
116+
// 37 booked inputs exist today (38 before #9968 retired
117+
// sys_user.actions.set_user_role); if the walker ever goes blind and
118+
// finds none, the it.each below would vacuously pass — pin a floor
119+
// instead.
120+
expect(referencedInputs.length).toBeGreaterThanOrEqual(37);
119121
});
120122

121123
it.each(referencedInputs)('%s references registered flag %s and is booked', (path, flag) => {

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -249,22 +249,16 @@ export const SysUser = ObjectSchema.create({
249249
],
250250
},
251251
},
252-
{
253-
name: 'set_user_role',
254-
label: 'Set Platform Role',
255-
icon: 'shield-check',
256-
variant: 'secondary',
257-
locations: ['list_item', 'record_header'],
258-
type: 'api',
259-
target: '/api/v1/auth/admin/set-role',
260-
requiresFeature: 'admin',
261-
recordIdParam: 'userId',
262-
successMessage: 'Role updated',
263-
refreshAfter: true,
264-
params: [
265-
{ name: 'role', label: 'Platform Role', type: 'text', required: true },
266-
],
267-
},
252+
// #9968 — `set_user_role` (target: /api/v1/auth/admin/set-role) retired
253+
// from here. Its only effect was `internalAdapter.updateUser(userId, {
254+
// role })` — a gated, UI-driven writer for the legacy `sys_user.role`
255+
// scalar ADR-0068 D2 stopped synthesizing. Platform-admin membership is
256+
// granted through `sys_user_permission_set` / `admin_full_access`; a
257+
// working "Set Platform Role" button was a supported, one-user-at-a-time
258+
// resurrection channel for the dual identity representation the
259+
// 2026-08-18 ruling permanently vetoed (Option 3). Removal, not a
260+
// narrowed re-implementation — the vendor route itself stays mounted
261+
// and vendor-gated, unchanged; only this action/button is gone.
268262
{
269263
name: 'impersonate_user',
270264
label: 'Impersonate User',

packages/platform-objects/src/pages/sys-user.page.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ import type { Page } from '@objectstack/spec/ui';
1313
* personal profile. This page therefore optimizes for the admin
1414
* use case: scanning a user's signals (email/verification/2FA/role),
1515
* reviewing related sessions/orgs/oauth/api-keys, and triggering
16-
* admin actions (ban / impersonate / set_role).
16+
* admin actions (ban / impersonate).
1717
*
1818
* Strategy
1919
* --------
2020
* - `kind: 'slotted'` + `isDefault: true`: overrides `highlights`,
2121
* `details`, `tabs` and `discussion`. Header / actions fall through
2222
* to the synthesizer so the object's declared actions
2323
* (`update_my_profile / change_my_password / resend_verification_email
24-
* / ban_user / set_user_role / impersonate_user / …`) still appear
24+
* / ban_user / impersonate_user / …`) still appear
2525
* in the header overflow menu automatically.
2626
* - `highlights` promotes the four signals worth scanning at the top:
2727
* email, verification state, 2FA, platform role. Highlight fields

packages/platform-objects/src/platform-objects.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,48 @@ describe('@objectstack/platform-objects', () => {
229229
// header overflows extras into the ⋯ "More" menu). `record_header` is the
230230
// only detail-surface location objectui consumes (it does NOT read
231231
// `record_more`), so these must use `record_header` specifically.
232-
const adminActions = ['ban_user', 'unban_user', 'unlock_user', 'set_user_password', 'set_user_role', 'impersonate_user'];
232+
const adminActions = ['ban_user', 'unban_user', 'unlock_user', 'set_user_password', 'impersonate_user'];
233233
for (const name of adminActions) {
234234
const a = (SysUser.actions ?? []).find((x) => x.name === name);
235235
expect(a, `${name} action must exist`).toBeTruthy();
236236
expect(a?.locations, `${name} locations`).toContain('list_item');
237237
expect(a?.locations, `${name} must also surface on the detail header`).toContain('record_header');
238238
}
239239
});
240+
241+
it('#9968 — set_user_role is retired from sys_user, every sibling admin action survives', () => {
242+
// set_user_role's only effect was internalAdapter.updateUser(userId,
243+
// { role }) — a gated UI writer for the legacy sys_user.role scalar
244+
// ADR-0068 D2 stopped synthesizing (platform-admin membership moved to
245+
// sys_user_permission_set / admin_full_access). Removal, not a
246+
// narrowed re-implementation (maintainer ruling, 2026-08-20/2026-08-22,
247+
// Option B). Pinned in one test with its counter-direction so a
248+
// retirement that removed the WRONG entry (or several) cannot pass:
249+
// the retired name must be gone by name, and every sibling survivor
250+
// must still be present by name.
251+
const actionNames = (SysUser.actions ?? []).map((a) => a.name);
252+
expect(actionNames, 'set_user_role must be gone').not.toContain('set_user_role');
253+
const survivors = [
254+
'invite_user',
255+
'ban_user',
256+
'unban_user',
257+
'unlock_user',
258+
'create_user',
259+
'set_user_password',
260+
'impersonate_user',
261+
'update_my_profile',
262+
'change_my_password',
263+
'change_my_email',
264+
'resend_verification_email',
265+
'delete_my_account',
266+
'enable_two_factor',
267+
'disable_two_factor',
268+
'generate_backup_codes',
269+
];
270+
for (const name of survivors) {
271+
expect(actionNames, `${name} must survive the set_user_role retirement`).toContain(name);
272+
}
273+
});
240274
});
241275

242276
describe('data portability — derived, not declared (#3025 / #3543)', () => {
@@ -506,7 +540,6 @@ describe('feature-gate lowering matrix (#2874)', () => {
506540
['SysUser', SysUser, 'unban_user', 'features.admin == true'],
507541
['SysUser', SysUser, 'unlock_user', 'features.admin == true'],
508542
['SysUser', SysUser, 'set_user_password', 'features.admin == true'],
509-
['SysUser', SysUser, 'set_user_role', 'features.admin == true'],
510543
['SysUser', SysUser, 'impersonate_user', 'features.admin == true'],
511544
['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'],
512545
['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'],

packages/spec/src/kernel/public-auth-features.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,8 @@ export const PUBLIC_AUTH_FEATURES = {
200200
'sys_user.actions.unban_user',
201201
'sys_user.actions.unlock_user',
202202
'sys_user.actions.set_user_password',
203-
'sys_user.actions.set_user_role',
203+
// 'sys_user.actions.set_user_role' retired (#9968) — see the removal
204+
// note beside `impersonate_user` in sys-user.object.ts.
204205
'sys_user.actions.impersonate_user',
205206
],
206207
notes: 'SCIM forces the admin plugin (and this flag) on — ADR-0071.',

0 commit comments

Comments
 (0)