fix: prune unused RBAC permissions

This commit is contained in:
2026-07-17 17:43:45 +08:00
parent b3d0bafc22
commit 7edb4ff853
6 changed files with 97 additions and 29 deletions

View File

@@ -9,7 +9,7 @@ export function getAttendanceExperience(
): AttendanceExperience {
const domains = getRoleDomains(roles, permissions);
if (
permissions.includes('attendance:manage') ||
permissions.includes('attendance:edit') ||
domains.has('academic') ||
domains.has('super')
) {

View File

@@ -37,13 +37,9 @@ const PermissionsPage: React.FC = () => {
class: '班级管理',
schedule: '排课管理',
attendance: '考勤管理',
learning: '学习记录',
exam: '考试管理',
sync: '数据同步',
integration: '集成配置',
department: '部门管理',
notification: '通知中心',
profile: '个人资料',
ai: 'AI 模型配置',
};

View File

@@ -118,18 +118,30 @@ const RolesPage: React.FC = () => {
const groupNames: Record<string, string> = {
dashboard: '数据面板',
notification: '通知中心',
student: '学生管理',
'student-scope': '学生范围权限',
teacher: '教师管理',
'teacher-workspace': '教师工作台',
room: '宿舍管理',
occupancy: '入住管理',
expense: '费用管理',
bill: '账单管理',
deposit: '押金管理',
wallet: '学生余额',
classroom: '教室管理',
organization: '机构管理',
rental: '租赁订单',
class: '班级管理',
schedule: '排课管理',
attendance: '考勤管理',
'attendance-scope': '考勤范围权限',
log: '操作日志',
user: '用户管理',
role: '角色管理',
sync: '数据同步',
integration: '集成配置',
ai: 'AI 配置',
};
const columns = useMemo(() => [

View File

@@ -10,7 +10,7 @@ describe('preset role permissions', () => {
it('keeps teachers read-only in scheduling while preserving class attendance access', () => {
const teacher = permissionsFor('teacher');
expect(teacher.groups).toEqual(['notification', 'profile']);
expect(teacher.groups).toEqual(['notification']);
expect(teacher.extras).toEqual(
expect.arrayContaining([
'teacher-workspace:view',

View File

@@ -30,7 +30,13 @@ describe('RbacService seedData', () => {
),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn(async () => permissions),
find: jest.fn(async (options?: any) => {
if (options?.where?.code) {
return permissions.filter((permission) => permission.code === 'profile:view');
}
return permissions;
}),
remove: jest.fn(async (value) => value),
};
const roleRepo = {
findOne: jest.fn(async ({ where }: any) =>
@@ -58,7 +64,7 @@ describe('RbacService seedData', () => {
expect(teacherRole.name).toBe('任课老师');
expect(teacherRole.permissions.map((permission) => permission.code)).toEqual(
expect.arrayContaining([
'profile:view',
'notification:view',
'teacher-workspace:view',
'schedule:view',
'attendance:create',
@@ -67,6 +73,7 @@ describe('RbacService seedData', () => {
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain(
'schedule:create',
);
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('profile:view');
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('student:view');
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('class:view');
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('room:view');
@@ -106,7 +113,13 @@ describe('RbacService legacy role consolidation', () => {
findOne: jest.fn(async ({ where }: any) => permissions.find((item) => item.code === where.code) ?? null),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn(async () => permissions),
find: jest.fn(async (options?: any) => {
if (options?.where?.code) {
return permissions.filter((permission) => permission.code === 'profile:view');
}
return permissions;
}),
remove: jest.fn(async (value) => value),
};
const roleRepo = {
findOne: jest.fn(async () => targetRole),

View File

@@ -15,7 +15,6 @@ import {
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
{ code: 'profile:view', name: '查看个人资料', group: 'profile' },
{ code: 'notification:view', name: '查看通知', group: 'notification' },
{ code: 'student:view', name: '查看学生管理', group: 'student' },
{ code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' },
@@ -88,25 +87,62 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' },
{ code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' },
{ code: 'attendance:export', name: '导出考勤', group: 'attendance' },
{ code: 'attendance:generate', name: '按课表生成考勤', group: 'attendance' },
{ code: 'learning:create', name: '创建学习任务', group: 'learning' },
{ code: 'learning:edit', name: '编辑学习任务', group: 'learning' },
{ code: 'learning:delete', name: '删除学习任务', group: 'learning' },
{ code: 'exam:create', name: '创建考试', group: 'exam' },
{ code: 'exam:edit', name: '编辑考试', group: 'exam' },
{ code: 'exam:delete', name: '删除考试', group: 'exam' },
{ code: 'sync:trigger', name: '触发数据同步', group: 'sync' },
{ code: 'sync:read', name: '查看同步状态', group: 'sync' },
{ code: 'integration:trigger', name: '触发集成', group: 'integration' },
{ code: 'integration:read', name: '查看集成状态', group: 'integration' },
{ code: 'department:view', name: '查看部门', group: 'department' },
{ code: 'department:edit', name: '编辑部门', group: 'department' },
{ code: 'department:delete', name: '删除部门', group: 'department' },
{ code: 'ai:config:read', name: '查看 AI 配置', group: 'ai' },
{ code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' },
{ code: 'ai:config:test', name: '测试 AI 连接', group: 'ai' },
];
const DEPRECATED_PERMISSION_CODES = [
'profile:view',
'attendance:generate',
'learning:create',
'learning:edit',
'learning:delete',
'exam:create',
'exam:edit',
'exam:delete',
'department:view',
'department:edit',
'department:delete',
// Legacy permission codes from older admin UI / seed data.
'student:add',
'student:update',
'room:add',
'room:update',
'occupancy:add',
'occupancy:update',
'attendance:add',
'attendance:update',
'attendance:delete',
'attendance:batch',
'bill:export',
'deposit:collect',
'expense:add',
'expense:update',
'class:add',
'class:update',
'schedule:add',
'schedule:update',
'classroom:add',
'classroom:update',
'rental:add',
'rental:update',
'role:add',
'role:update',
'user:add',
'user:update',
'archive:view',
'archive:import',
'archive:export',
'report:generate',
] as const;
const DEPRECATED_PERMISSION_CODE_SET = new Set<string>(DEPRECATED_PERMISSION_CODES);
export const PRESET_ROLES: Array<{
name: string;
code: string;
@@ -130,7 +166,7 @@ export const PRESET_ROLES: Array<{
code: 'teacher',
description: '查看自己的排课、今日课程和任教班级考勤',
isSystem: true,
permissionGroups: ['notification', 'profile'],
permissionGroups: ['notification'],
extraPermissions: [
'teacher-workspace:view',
'schedule:view',
@@ -151,11 +187,8 @@ export const PRESET_ROLES: Array<{
'schedule',
'attendance',
'classroom',
'learning',
'exam',
'dashboard',
'notification',
'profile',
],
extraPermissions: [
'teacher-workspace:view',
@@ -180,7 +213,6 @@ export const PRESET_ROLES: Array<{
'wallet',
'dashboard',
'notification',
'profile',
],
extraPermissions: ['student:basic-view'],
legacyNames: ['宿管老师', '宿管', '财务'],
@@ -191,7 +223,7 @@ export const PRESET_ROLES: Array<{
code: 'classroom_operations',
description: '管理教室、教室排期、外部机构和租赁订单',
isSystem: true,
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
permissionGroups: ['classroom', 'rental', 'organization', 'notification'],
legacyNames: ['机构负责人'],
legacyCodes: ['institution_head'],
},
@@ -207,9 +239,7 @@ export const PRESET_ROLES: Array<{
'integration',
'sync',
'ai',
'department',
'notification',
'profile',
],
},
];
@@ -253,7 +283,8 @@ export class RbacService {
where: { code: 'user:delete' },
});
const allPerms = (await this.permRepo.find()).filter(
(permission) => permission.code !== 'user:delete',
(permission) =>
permission.code !== 'user:delete' && !DEPRECATED_PERMISSION_CODE_SET.has(permission.code),
);
// Step 2: 幂等插入预置角色
@@ -288,6 +319,22 @@ export class RbacService {
await this.permRepo.remove(deprecatedUserDeletePermission);
}
const deprecatedPermissions = await this.permRepo.find({
where: { code: In([...DEPRECATED_PERMISSION_CODES]) },
});
if (deprecatedPermissions.length > 0) {
const deprecatedIds = new Set(deprecatedPermissions.map((permission) => permission.id));
for (const role of allRoles) {
const permissions = role.permissions ?? [];
if (permissions.some((permission) => deprecatedIds.has(permission.id))) {
role.permissions = permissions.filter((permission) => !deprecatedIds.has(permission.id));
await this.roleRepo.save(role);
}
}
await this.permRepo.remove(deprecatedPermissions);
this.logger.log(`已清理废弃权限点: ${deprecatedPermissions.map((p) => p.code).join(', ')}`);
}
// Step 3: 合并旧角色并构建新的职责权限矩阵
for (const preset of PRESET_ROLES) {
const matchesPreset = (role: Role) =>