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` 角色的权限分配不能在管理端被手动删改。
|
- 新建权限会自动关联到 `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`
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: '关键管理权限必须保持启用',
|
||||||
|
|||||||
Reference in New Issue
Block a user