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:
@@ -172,6 +172,11 @@
|
||||
- 新建权限会自动关联到 `owner` 角色,确保 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 或调试字段。
|
||||
|
||||
## Referral
|
||||
@@ -716,7 +721,7 @@ API 暴露边界:
|
||||
权限管理 API:
|
||||
|
||||
- `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`
|
||||
- `POST /api/admin/roles`:需要 `admin.roles.create`
|
||||
- `PUT /api/admin/roles/:id`:需要 `admin.roles.update`
|
||||
|
||||
@@ -139,6 +139,7 @@ VALUES
|
||||
('admin.access', 'Access admin', 'Open the management area.', 'Admin', 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.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.create', 'Create roles', 'Create configurable roles.', 'Roles', true),
|
||||
('admin.roles.update', 'Update roles', 'Edit roles and role permission assignments.', 'Roles', true),
|
||||
|
||||
@@ -140,10 +140,13 @@ type RolePermissionRow = QueryResultRow & {
|
||||
|
||||
const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/;
|
||||
const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
||||
const ownerRoleKey = 'owner';
|
||||
const assignOwnerPermissionKey = 'admin.users.assign-owner';
|
||||
const criticalPermissionKeys = [
|
||||
'admin.access',
|
||||
'admin.users.read',
|
||||
'admin.users.update',
|
||||
assignOwnerPermissionKey,
|
||||
'admin.roles.read',
|
||||
'admin.roles.create',
|
||||
'admin.roles.update',
|
||||
@@ -582,6 +585,10 @@ function cleanIdList(value: unknown): number[] {
|
||||
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> {
|
||||
const row = await clientQueryOne<QueryResultRow & { count: string }>(
|
||||
client,
|
||||
@@ -1357,28 +1364,74 @@ export async function updateAdminUserRoles(
|
||||
throw statusError('server.permissions.userNotFound', 404);
|
||||
}
|
||||
|
||||
if (roleIds.length) {
|
||||
const countRow = await clientQueryOne<QueryResultRow & { count: string }>(
|
||||
client,
|
||||
'SELECT COUNT(*)::text AS count FROM roles WHERE id = ANY($1::int[])',
|
||||
[roleIds]
|
||||
);
|
||||
if (Number(countRow?.count ?? 0) !== roleIds.length) {
|
||||
throw statusError('server.permissions.roleNotFound', 404);
|
||||
const currentRoleRows = await clientQuery<RoleRow>(
|
||||
client,
|
||||
`
|
||||
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]
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 (roleIds.length) {
|
||||
if (removedRoleRows.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(
|
||||
`
|
||||
INSERT INTO user_roles (user_id, role_id, assigned_by_user_id)
|
||||
SELECT $1, unnest($2::int[]), $3
|
||||
ON CONFLICT DO NOTHING
|
||||
`,
|
||||
[targetUserId, roleIds, assignedByUserId]
|
||||
[targetUserId, addedRoleRows.map((role) => role.id), assignedByUserId]
|
||||
);
|
||||
}
|
||||
|
||||
await assertAccessControlSafe(client);
|
||||
});
|
||||
|
||||
|
||||
@@ -769,6 +769,8 @@ export const systemWordingMessages = {
|
||||
roleNotFound: 'Role not found',
|
||||
ownerRequired: 'At least one Owner is required',
|
||||
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',
|
||||
permissionNotFound: 'Permission not found',
|
||||
criticalPermissionRequired: 'Critical administration permissions must remain enabled',
|
||||
@@ -1548,6 +1550,8 @@ export const systemWordingMessages = {
|
||||
roleNotFound: '角色不存在',
|
||||
ownerRequired: '必须至少保留一个 Owner',
|
||||
ownerRoleLocked: 'Owner 角色权限不能编辑',
|
||||
ownerRoleOperationDenied: '只有具备 Owner 分配权限的 Owner 可以分配或移除 Owner 角色',
|
||||
roleLevelOperationDenied: '只能分配或移除低于自己最高角色等级的角色',
|
||||
permissionKeyInvalid: '权限 Key 不合法',
|
||||
permissionNotFound: '权限不存在',
|
||||
criticalPermissionRequired: '关键管理权限必须保持启用',
|
||||
|
||||
Reference in New Issue
Block a user