Files
gongxue-base/openspec/changes/rbac-refactor/design.md

162 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## Context
当前系统采用 NestJS + TypeORM + JWTUser 实体通过 `role` 字段admin/operator`allowedMenus` 字段JSON 数组)控制前端菜单可见性。后端接口仅使用 `JwtAuthGuard` 验证 Token 有效性,不做权限校验。需要重构为标准 RBAC 模型,为后续多角色(超管、机构负责人、老师、宿管老师)和数据级权限扩展提供架构基础。
## Goals / Non-Goals
**Goals:**
- 建立 Role、Permission、RolePermission、UserRole 四表 RBAC 数据模型
- 实现 `@RequirePermission` 装饰器 + `PermissionGuard`,对所有业务接口实施操作级权限校验
- 权限点按 `module:action` 命名规范(如 `student:create``bill:export`),覆盖全部现有功能
- 前端实现路由级和按钮级权限控制
- 提供角色管理、权限一览、用户-角色分配三个管理页面
- 用户可拥有多个角色,权限为所有角色权限的并集
- 现有 admin 用户自动迁移为超管角色,无缝升级
**Non-Goals:**
- 不实现数据级权限过滤(如「老师只能看本班学生」),需等待机构/班级实体
- 不实现用户级别的直接权限覆盖(所有权限通过角色获得,无需单独增删)
- 不改变现有业务接口的功能逻辑
- 不实现权限变更后的实时推送(刷新页面或重新登录后生效)
## Decisions
### 1. 数据模型设计
```
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ User │ │ Role │ │ Permission │
├──────────────┤ ├──────────────────┤ ├──────────────┤
│ id │ │ id │ │ id │
│ username │ │ name │ │ code (UK) │
│ passwordHash │ │ description │ │ name │
│ name │ │ isSystem │ │ group │
│ isActive │ │ status │ │ description │
│ ... │ │ created_at │ └──────┬───────┘
└──────┬───────┘ └────────┬─────────┘ │
│ │ │
│ N:M │ M:N │
▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐
│ UserRole │ │ RolePermission │
├──────────────┤ ├──────────────────┤
│ userId (FK) │ │ roleId (FK) │
│ roleId (FK) │ │ permissionId(FK) │
└──────────────┘ └──────────────────┘
```
**User 实体变更:**
- 移除 `role` 列(原 varchar admin/operator
- 移除 `allowedMenus` 列(原 JSON text
- 新增 `@ManyToMany` → Role 关联
**为什么不用 JSON 字段存储权限?** JSON 字段无法做数据库级的外键约束和联表查询当需要「某权限被哪些角色拥有」时JSON 需全表扫描,而关联表只需索引查询。
**为什么不用用户-权限直连?** 当前阶段不需要。Comet 基本原则是"不实现用户级别的直接权限覆盖",简化模型,所有权限通过角色中转。
### 2. 权限编码规范
权限点采用 `module:action` 格式,模块名与现有目录结构一致:
| Group | 权限点示例 |
|-------|----------|
| dashboard | `dashboard:view` |
| student | `student:view`, `student:create`, `student:edit`, `student:delete`, `student:import`, `student:export` |
| room | `room:view`, `room:create`, `room:edit`, `room:delete` |
| occupancy | `occupancy:view`, `occupancy:checkin`, `occupancy:checkout`, `occupancy:transfer` |
| expense | `expense:view`, `expense:create`, `expense:edit`, `expense:delete` |
| bill | `bill:view`, `bill:generate`, `bill:confirm`, `bill:export-excel`, `bill:export-pdf`, `bill:delete` |
| deposit | `deposit:view`, `deposit:create`, `deposit:edit`, `deposit:delete` |
| classroom | `classroom:view`, `classroom:create`, `classroom:edit`, `classroom:delete` |
| tenant | `tenant:view`, `tenant:create`, `tenant:edit`, `tenant:delete` |
| classroom-rental | `rental:view`, `rental:create`, `rental:edit`, `rental:delete` |
| log | `log:view` |
| user | `user:view`, `user:create`, `user:edit`, `user:delete`, `user:reset-password` |
| role | `role:view`, `role:create`, `role:edit`, `role:delete` |
**为什么不在 code 中包含数据范围(如 `student:view:class-1`** 数据范围属于数据级权限当前阶段只做操作级。code 保持纯粹的操作定义,后续通过 RBAC + 数据策略组合实现数据过滤。
### 3. 预置角色与权限映射
| 角色 | isSystem | 权限范围 |
|------|----------|---------|
| 超管 (super_admin) | true | 全部权限点(不可删除/禁用的系统角色) |
| 机构负责人 (institution_head) | true | 预留角色,暂分配教室/租赁相关权限 |
| 老师 (teacher) | true | 预留角色,暂分配学生查看权限 |
| 宿管老师 (dormitory_supervisor) | true | 学生、宿舍、入住、费用、账单、押金、日志查看权限(替代原 operator |
> 机构负责人和老师角色的大部分实际业务权限点(课表、考勤等)在当前系统中尚不存在,创建为占位角色,后续 change 再补充权限。
### 4. @RequirePermission 装饰器设计
```typescript
// 单一权限
@RequirePermission('student:create')
// 任一权限满足OR
@RequirePermission('bill:export-excel', 'bill:export-pdf')
// 全部权限满足AND
@RequirePermission('bill:view')
@RequirePermission('bill:delete')
```
替代方案考虑:
- **NestJS CASL**:功能强大但引入额外依赖和概念负担,对当前需求过度。当前只需简单的「有权限/无权限」二值判断。
- **Guards 函数**:每个模块写独立 Guard 过于分散,不如装饰器统一声明式管理。
### 5. JWT Payload 扩展
```
变更前: { sub, username, role }
变更后: { sub, username, permissions: ["student:view", "bill:export", ...] }
```
**为什么不每次从数据库查询权限?** 每次请求都查数据库会造成 N+1 问题和性能开销。权限列表在登录时打入 JWTJWT 有有效期(默认 24h角色变更在下次登录时生效。折中方案对于安全性要求极高的操作删除/确认账单),可额外在 Controller 层做一次实时校验。
### 6. 前端权限控制方案
```
路由级App.tsx:
<Route path="users" element={
<PermissionRoute permission="user:view"><UsersPage /></PermissionRoute>
} />
菜单级MainLayout:
菜单项配置 permission 字段,按用户 permissions 过滤
按钮级(各页面):
<PermissionButton permission="student:delete">删除</PermissionButton>
用 display:none 或 disabled 控制
```
权限判断逻辑封装为 `usePermission()` hook
```typescript
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
hasPermission('student:create') // boolean
```
### 7. 迁移策略
```
数据库迁移脚本TypeORM migration
1. 创建 roles, permissions, role_permissions, user_roles 表
2. INSERT 种子数据4 角色 + ~40 权限点 + 角色-权限关联)
3. 查找 role='admin' 的用户 → INSERT user_roles (userId, super_admin_role_id)
4. 查找 role='operator' 的用户 → INSERT user_roles (userId, dormitory_supervisor_role_id)
(因为宿管老师角色权限与原 operator 允许的菜单最接近)
5. ALTER TABLE users DROP COLUMN role, DROP COLUMN allowed_menus
```
回滚策略:
1. 备份 users 表的 role 和 allowedMenus 值到临时表
2. 迁移失败时从备份恢复并 DROP 新表
```
## Risks / Trade-offs
- **[迁移风险] users 表结构变更破坏现有业务** → 迁移脚本在事务中执行,失败自动回滚;部署前在 staging 环境验证
- **[性能风险] JWT payload 增大** → ~40 个权限点编码为短字符串数组JWT 体积增加约 500 bytes在可接受范围内
- **[安全风险] 权限变更在下一次登录才生效** → 对于关键角色变更(如降级用户),提供「强制下线」能力(使当前 JWT 失效);后续可用 Redis 黑名单优化
- **[复杂度风险] 前端现有代码改造面大** → 分步迁移先建组件PermissionButton/usePermission再逐页替换确保每一步可编译可运行