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
14 changes: 14 additions & 0 deletions .changeset/retire-set-user-role-action.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -209,15 +209,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
}
}
},
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.",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -209,15 +209,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
}
}
},
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.",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -209,15 +209,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
}
}
},
set_user_role: {
label: "プラットフォームロールを設定",
successMessage: "ロールを更新しました",
params: {
role: {
label: "プラットフォームロール"
}
}
},
impersonate_user: {
label: "代理ログイン",
confirmText: "このユーザーとして代理ログインを開始しますか?正当なサポート対応時のみ使用してください。操作は監査ログに記録されます。",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -209,15 +209,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
}
}
},
set_user_role: {
label: "设置平台角色",
successMessage: "角色已更新",
params: {
role: {
label: "平台角色"
}
}
},
impersonate_user: {
label: "模拟用户",
confirmText: "要为该用户启动模拟会话吗?仅限合法支持场景使用——所有操作都会被记录。",
Expand Down
8 changes: 5 additions & 3 deletions packages/platform-objects/src/feature-gate-guard.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand Down
26 changes: 10 additions & 16 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,15 +13,15 @@ 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
* --------
* - `kind: 'slotted'` + `isDefault: true`: overrides `highlights`,
* `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
Expand Down
37 changes: 35 additions & 2 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -229,14 +229,48 @@ 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();
expect(a?.locations, `${name} locations`).toContain('list_item');
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)', () => {
Expand DownExpand Up@@ -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'],
Expand Down
3 changes: 2 additions & 1 deletion packages/spec/src/kernel/public-auth-features.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.',
Expand Down
Loading