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 { ): AttendanceExperience {
const domains = getRoleDomains(roles, permissions); const domains = getRoleDomains(roles, permissions);
if ( if (
permissions.includes('attendance:manage') || permissions.includes('attendance:edit') ||
domains.has('academic') || domains.has('academic') ||
domains.has('super') domains.has('super')
) { ) {

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,6 @@ import {
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
{ code: 'profile:view', name: '查看个人资料', group: 'profile' },
{ code: 'notification:view', name: '查看通知', group: 'notification' }, { code: 'notification:view', name: '查看通知', group: 'notification' },
{ code: 'student:view', name: '查看学生管理', group: 'student' }, { code: 'student:view', name: '查看学生管理', group: 'student' },
{ code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' }, { 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:edit', name: '编辑全部考勤', group: 'attendance' },
{ code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' }, { code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' },
{ code: 'attendance:export', name: '导出考勤', group: 'attendance' }, { 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:trigger', name: '触发数据同步', group: 'sync' },
{ code: 'sync:read', name: '查看同步状态', group: 'sync' }, { code: 'sync:read', name: '查看同步状态', group: 'sync' },
{ code: 'integration:trigger', name: '触发集成', group: 'integration' }, { code: 'integration:trigger', name: '触发集成', group: 'integration' },
{ code: 'integration:read', 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:read', name: '查看 AI 配置', group: 'ai' },
{ code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' }, { code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' },
{ code: 'ai:config:test', 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<{ export const PRESET_ROLES: Array<{
name: string; name: string;
code: string; code: string;
@@ -130,7 +166,7 @@ export const PRESET_ROLES: Array<{
code: 'teacher', code: 'teacher',
description: '查看自己的排课、今日课程和任教班级考勤', description: '查看自己的排课、今日课程和任教班级考勤',
isSystem: true, isSystem: true,
permissionGroups: ['notification', 'profile'], permissionGroups: ['notification'],
extraPermissions: [ extraPermissions: [
'teacher-workspace:view', 'teacher-workspace:view',
'schedule:view', 'schedule:view',
@@ -151,11 +187,8 @@ export const PRESET_ROLES: Array<{
'schedule', 'schedule',
'attendance', 'attendance',
'classroom', 'classroom',
'learning',
'exam',
'dashboard', 'dashboard',
'notification', 'notification',
'profile',
], ],
extraPermissions: [ extraPermissions: [
'teacher-workspace:view', 'teacher-workspace:view',
@@ -180,7 +213,6 @@ export const PRESET_ROLES: Array<{
'wallet', 'wallet',
'dashboard', 'dashboard',
'notification', 'notification',
'profile',
], ],
extraPermissions: ['student:basic-view'], extraPermissions: ['student:basic-view'],
legacyNames: ['宿管老师', '宿管', '财务'], legacyNames: ['宿管老师', '宿管', '财务'],
@@ -191,7 +223,7 @@ export const PRESET_ROLES: Array<{
code: 'classroom_operations', code: 'classroom_operations',
description: '管理教室、教室排期、外部机构和租赁订单', description: '管理教室、教室排期、外部机构和租赁订单',
isSystem: true, isSystem: true,
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'], permissionGroups: ['classroom', 'rental', 'organization', 'notification'],
legacyNames: ['机构负责人'], legacyNames: ['机构负责人'],
legacyCodes: ['institution_head'], legacyCodes: ['institution_head'],
}, },
@@ -207,9 +239,7 @@ export const PRESET_ROLES: Array<{
'integration', 'integration',
'sync', 'sync',
'ai', 'ai',
'department',
'notification', 'notification',
'profile',
], ],
}, },
]; ];
@@ -253,7 +283,8 @@ export class RbacService {
where: { code: 'user:delete' }, where: { code: 'user:delete' },
}); });
const allPerms = (await this.permRepo.find()).filter( 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: 幂等插入预置角色 // Step 2: 幂等插入预置角色
@@ -288,6 +319,22 @@ export class RbacService {
await this.permRepo.remove(deprecatedUserDeletePermission); 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: 合并旧角色并构建新的职责权限矩阵 // Step 3: 合并旧角色并构建新的职责权限矩阵
for (const preset of PRESET_ROLES) { for (const preset of PRESET_ROLES) {
const matchesPreset = (role: Role) => const matchesPreset = (role: Role) =>