feat(auth): enforce role level boundaries and owner assignment rules

Add `admin.users.assign-owner` permission to control Owner role assignment.
Restrict role assignment to roles strictly below the assigner's highest level.
This commit is contained in:
2026-05-03 14:50:52 +08:00
parent 1dab650c2c
commit 8f55db9061
4 changed files with 75 additions and 12 deletions

View File

@@ -172,6 +172,11 @@
- 新建权限会自动关联到 `owner` 角色,确保 Owner 始终拥有可用权限全集;`owner` 角色的权限分配不能在管理端被手动删改。 - 新建权限会自动关联到 `owner` 角色,确保 Owner 始终拥有可用权限全集;`owner` 角色的权限分配不能在管理端被手动删改。
- 系统必须始终至少保留一个拥有 `admin.permissions.update` 且可管理权限的有效用户;核心 RBAC 管理权限(`admin.access``admin.users.*``admin.roles.*``admin.permissions.*`)不能被禁用或删除;不能删除最后一个 Owner不能移除最后一个 Owner 的关键权限能力。 - 系统必须始终至少保留一个拥有 `admin.permissions.update` 且可管理权限的有效用户;核心 RBAC 管理权限(`admin.access``admin.users.*``admin.roles.*``admin.permissions.*`)不能被禁用或删除;不能删除最后一个 Owner不能移除最后一个 Owner 的关键权限能力。
- 权限管理能力本身也通过权限控制;只有拥有相应管理权限的用户可以查看、新增、编辑、删除权限、角色和用户角色关系。 - 权限管理能力本身也通过权限控制;只有拥有相应管理权限的用户可以查看、新增、编辑、删除权限、角色和用户角色关系。
- 用户角色分配必须同时满足层级边界:
- `PUT /api/admin/users/:id/roles` 的基础权限为 `admin.users.update`
- 调用者只能分配或移除 `roles.level` 严格低于自己最高启用角色等级的角色。
- `owner` 角色只能由当前拥有启用 `owner` 角色且拥有 `admin.users.assign-owner` 权限的调用者分配或移除。
- 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。
- 管理 API 只返回权限管理所需字段不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。 - 管理 API 只返回权限管理所需字段不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。
## Referral ## Referral
@@ -716,7 +721,7 @@ API 暴露边界:
权限管理 API 权限管理 API
- `GET /api/admin/users`:需要 `admin.users.read` - `GET /api/admin/users`:需要 `admin.users.read`
- `PUT /api/admin/users/:id/roles`:需要 `admin.users.update` - `PUT /api/admin/users/:id/roles`:需要 `admin.users.update`;分配或移除 `owner` 还需要调用者本身是 Owner 且拥有 `admin.users.assign-owner`;所有角色变更受 `roles.level` 层级限制
- `GET /api/admin/roles`:需要 `admin.roles.read` - `GET /api/admin/roles`:需要 `admin.roles.read`
- `POST /api/admin/roles`:需要 `admin.roles.create` - `POST /api/admin/roles`:需要 `admin.roles.create`
- `PUT /api/admin/roles/:id`:需要 `admin.roles.update` - `PUT /api/admin/roles/:id`:需要 `admin.roles.update`

View File

@@ -139,6 +139,7 @@ VALUES
('admin.access', 'Access admin', 'Open the management area.', 'Admin', true), ('admin.access', 'Access admin', 'Open the management area.', 'Admin', true),
('admin.users.read', 'View users', 'View user role assignments.', 'Users', true), ('admin.users.read', 'View users', 'View user role assignments.', 'Users', true),
('admin.users.update', 'Manage user roles', 'Assign and remove roles from users.', 'Users', true), ('admin.users.update', 'Manage user roles', 'Assign and remove roles from users.', 'Users', true),
('admin.users.assign-owner', 'Assign Owner role', 'Assign and remove the Owner role from users.', 'Users', true),
('admin.roles.read', 'View roles', 'View role configuration.', 'Roles', true), ('admin.roles.read', 'View roles', 'View role configuration.', 'Roles', true),
('admin.roles.create', 'Create roles', 'Create configurable roles.', 'Roles', true), ('admin.roles.create', 'Create roles', 'Create configurable roles.', 'Roles', true),
('admin.roles.update', 'Update roles', 'Edit roles and role permission assignments.', 'Roles', true), ('admin.roles.update', 'Update roles', 'Edit roles and role permission assignments.', 'Roles', true),

View File

@@ -140,10 +140,13 @@ type RolePermissionRow = QueryResultRow & {
const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/; const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/;
const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/; const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
const ownerRoleKey = 'owner';
const assignOwnerPermissionKey = 'admin.users.assign-owner';
const criticalPermissionKeys = [ const criticalPermissionKeys = [
'admin.access', 'admin.access',
'admin.users.read', 'admin.users.read',
'admin.users.update', 'admin.users.update',
assignOwnerPermissionKey,
'admin.roles.read', 'admin.roles.read',
'admin.roles.create', 'admin.roles.create',
'admin.roles.update', 'admin.roles.update',
@@ -582,6 +585,10 @@ function cleanIdList(value: unknown): number[] {
return ids; return ids;
} }
function highestRoleLevel(roles: RoleSummary[]): number {
return roles.reduce((highestLevel, role) => Math.max(highestLevel, role.level), -1);
}
async function assertCriticalPermissionsEnabled(client: DbClient): Promise<void> { async function assertCriticalPermissionsEnabled(client: DbClient): Promise<void> {
const row = await clientQueryOne<QueryResultRow & { count: string }>( const row = await clientQueryOne<QueryResultRow & { count: string }>(
client, client,
@@ -1357,28 +1364,74 @@ export async function updateAdminUserRoles(
throw statusError('server.permissions.userNotFound', 404); throw statusError('server.permissions.userNotFound', 404);
} }
if (roleIds.length) { const currentRoleRows = await clientQuery<RoleRow>(
const countRow = await clientQueryOne<QueryResultRow & { count: string }>(
client, client,
'SELECT COUNT(*)::text AS count FROM roles WHERE id = ANY($1::int[])', `
[roleIds] SELECT r.id, r.key, r.name, r.description, r.level, r.enabled, r.system_role
FROM user_roles ur
JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $1
ORDER BY r.id ASC
`,
[targetUserId]
); );
if (Number(countRow?.count ?? 0) !== roleIds.length) {
const requestedRoleRows = roleIds.length
? await clientQuery<RoleRow>(
client,
`
SELECT id, key, name, description, level, enabled, system_role
FROM roles
WHERE id = ANY($1::int[])
ORDER BY id ASC
`,
[roleIds]
)
: [];
if (requestedRoleRows.length !== roleIds.length) {
throw statusError('server.permissions.roleNotFound', 404); throw statusError('server.permissions.roleNotFound', 404);
} }
const currentRoleIds = new Set(currentRoleRows.map((role) => role.id));
const nextRoleIds = new Set(roleIds);
const removedRoleRows = currentRoleRows.filter((role) => !nextRoleIds.has(role.id));
const addedRoleRows = requestedRoleRows.filter((role) => !currentRoleIds.has(role.id));
const changedRoleRows = [...removedRoleRows, ...addedRoleRows];
if (changedRoleRows.length) {
const assignerRoles = await userRoles(assignedByUserId, client);
const assignerMaxLevel = highestRoleLevel(assignerRoles);
const ownerRoleChanged = changedRoleRows.some((role) => role.key === ownerRoleKey);
const assignerIsOwner = assignerRoles.some((role) => role.key === ownerRoleKey);
const assignerPermissionKeys = ownerRoleChanged ? await userPermissions(assignedByUserId, client) : [];
if (ownerRoleChanged && (!assignerIsOwner || !assignerPermissionKeys.includes(assignOwnerPermissionKey))) {
throw statusError('server.permissions.ownerRoleOperationDenied', 403);
}
if (changedRoleRows.some((role) => role.key !== ownerRoleKey && role.level >= assignerMaxLevel)) {
throw statusError('server.permissions.roleLevelOperationDenied', 403);
}
} }
await client.query('DELETE FROM user_roles WHERE user_id = $1', [targetUserId]); if (removedRoleRows.length) {
if (roleIds.length) { await client.query('DELETE FROM user_roles WHERE user_id = $1 AND role_id = ANY($2::int[])', [
targetUserId,
removedRoleRows.map((role) => role.id)
]);
}
if (addedRoleRows.length) {
await client.query( await client.query(
` `
INSERT INTO user_roles (user_id, role_id, assigned_by_user_id) INSERT INTO user_roles (user_id, role_id, assigned_by_user_id)
SELECT $1, unnest($2::int[]), $3 SELECT $1, unnest($2::int[]), $3
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
`, `,
[targetUserId, roleIds, assignedByUserId] [targetUserId, addedRoleRows.map((role) => role.id), assignedByUserId]
); );
} }
await assertAccessControlSafe(client); await assertAccessControlSafe(client);
}); });

View File

@@ -769,6 +769,8 @@ export const systemWordingMessages = {
roleNotFound: 'Role not found', roleNotFound: 'Role not found',
ownerRequired: 'At least one Owner is required', ownerRequired: 'At least one Owner is required',
ownerRoleLocked: 'Owner role permissions cannot be edited', ownerRoleLocked: 'Owner role permissions cannot be edited',
ownerRoleOperationDenied: 'Only Owners with Owner assignment permission can assign or remove the Owner role',
roleLevelOperationDenied: 'You can only assign or remove roles below your highest role level',
permissionKeyInvalid: 'Permission key is invalid', permissionKeyInvalid: 'Permission key is invalid',
permissionNotFound: 'Permission not found', permissionNotFound: 'Permission not found',
criticalPermissionRequired: 'Critical administration permissions must remain enabled', criticalPermissionRequired: 'Critical administration permissions must remain enabled',
@@ -1548,6 +1550,8 @@ export const systemWordingMessages = {
roleNotFound: '角色不存在', roleNotFound: '角色不存在',
ownerRequired: '必须至少保留一个 Owner', ownerRequired: '必须至少保留一个 Owner',
ownerRoleLocked: 'Owner 角色权限不能编辑', ownerRoleLocked: 'Owner 角色权限不能编辑',
ownerRoleOperationDenied: '只有具备 Owner 分配权限的 Owner 可以分配或移除 Owner 角色',
roleLevelOperationDenied: '只能分配或移除低于自己最高角色等级的角色',
permissionKeyInvalid: '权限 Key 不合法', permissionKeyInvalid: '权限 Key 不合法',
permissionNotFound: '权限不存在', permissionNotFound: '权限不存在',
criticalPermissionRequired: '关键管理权限必须保持启用', criticalPermissionRequired: '关键管理权限必须保持启用',