Skip to content

15 · RBAC 权限系统升级方案

把当前"单一 role 字段 + URL 前缀拦截"的简化版权限模型,升级为标准的 用户-角色-权限 三段式 RBAC,支持多角色、按钮级权限、数据级隔离与审计追溯。

当前状态:方案已设计,尚未实现。现有系统使用 sys_user.role 单值字段(admin/user)做基础权限控制,RBAC 四表升级为后续优化方向。


1. 背景与目标

1.1 现状痛点

维度现状问题
用户-角色sys_user.role 单值 VARCHAR一个用户只能有一个角色,无法表达"知识库管理员 + 普通对话用户"
角色-权限没有权限概念,角色字符串 = 权限hasRole('ADMIN') 是粗粒度二元判断;新增子角色需改动代码
鉴权点URL 前缀 /api/admin/** + 散落的 @PreAuthorize多处硬编码 'ADMIN' 字面量,权限点无法集中管理
数据级用户能拿到他人的会话历史 / 上传的知识库(仅靠业务代码自查 user_id,且并非全部接口都做了)
注册流程user.setRole("user") 硬编码(UserServiceImpl.java:108角色取值无白名单校验,updateUserRole 接受任意字符串
前端userStore.isAdmin 仅控制侧边栏显隐路由守卫只校验登录态,不校验 requiresAdminrouter/index.ts:120
审计角色变更无日志;JWT 内 role 缓存到过期,权限变更不能即时生效

1.2 改造目标

  1. 支持多角色:一个用户可挂多个角色(如"知识库管理员 + 数据分析员")
  2. 细粒度权限点:以 资源:动作 命名(kb:uploadmcp:execute),鉴权点不再写 ADMIN 字面量
  3. 按钮级前端控制:前端拿到权限码列表,渲染时控制按钮可见性
  4. 数据级隔离:知识库、会话历史按 owner 隔离,跨用户访问需要显式权限
  5. 可审计、可热更新:角色变更落表,JWT 失效后下次刷新即生效,无需重启

2. 总体架构

2.1 五张表的经典 RBAC 模型

sys_user ──┬── sys_user_role ──┬── sys_role ──┬── sys_role_permission ──┬── sys_permission
            └── (多对多)          └── (多对多)
表名作用关键字段
sys_user用户(保留)id, username, password, status —— 删除 role 字段,迁移到关联表
sys_role角色id, code(唯一), name, description, is_builtin(系统角色不可删), status
sys_permission权限点id, code(唯一,如 kb:upload), name, type(menu/button/api), resource, action, parent_id
sys_user_role用户-角色user_id, role_id, grant_time, granted_by
sys_role_permission角色-权限role_id, perm_id

2.2 权限点设计(基于现有 7 个 controller 的盘点)

模块权限码含义默认归属
知识库kb:read查看知识库列表/详情USER, KB_ADMIN, SUPER_ADMIN
kb:upload上传文档/添加 URLKB_ADMIN, SUPER_ADMIN
kb:delete删除知识库KB_ADMIN, SUPER_ADMIN
kb:reprocess重新切片/刷新KB_ADMIN, SUPER_ADMIN
对话chat:use使用对话/SSE 流USER, KB_ADMIN, SUPER_ADMIN
chat:export导出会话USER, KB_ADMIN, SUPER_ADMIN
MCPmcp:read查看工具列表/统计KB_ADMIN, SUPER_ADMIN
mcp:execute在线测试工具SUPER_ADMIN
mcp:admin启停工具SUPER_ADMIN
AI 配置ai-config:read查看 RAG 参数KB_ADMIN, SUPER_ADMIN
ai-config:write修改 RAG 参数SUPER_ADMIN
用户user:read查看用户列表SUPER_ADMIN
user:manage改状态/改角色SUPER_ADMIN
统计stats:read查看 dashboardKB_ADMIN, SUPER_ADMIN
审计audit:read查看操作日志SUPER_ADMIN

2.3 内置角色

角色码名称典型场景
SUPER_ADMIN超级管理员全权限,迁移时 role='admin' 的用户自动归入
KB_ADMIN知识库管理员管文档、看统计、调 RAG 参数,但不能管理用户
USER普通用户仅对话 + 查看知识库;迁移时 role='user' 的用户归入

3. 鉴权流程改造

3.1 JWT claim:从 role 字符串改为 permission 列表

改造前JwtUtils.java:36):

java
claims.put("role", role);   // 单值 "admin"

改造后

java
claims.put("roles", List.of("KB_ADMIN", "USER"));      // 角色码(用于显示)
claims.put("perms", List.of("kb:upload", "kb:delete")); // 权限码(用于鉴权)

权限码列表在登录时由 PermissionService.loadPermissionCodes(userId) 一次查出后写入 token,避免每次请求查库。

3.2 UserDetailsService 改造

改造前UserDetailsServiceImpl.java:44):

java
authorities(List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase())))

改造后

java
List<String> perms = permissionService.loadPermissionCodes(user.getId());
List<GrantedAuthority> authorities = perms.stream()
    .map(SimpleGrantedAuthority::new)   // 直接用 "kb:upload" 不加前缀
    .collect(Collectors.toList());

3.3 鉴权注解:从 hasRole 改为 hasAuthority

java
// 改造前
@PreAuthorize("hasRole('ADMIN')")

// 改造后
@PreAuthorize("hasAuthority('kb:upload')")

为避免散落字面量,引入常量类 Perms.KB_UPLOAD = "kb:upload",并允许用 SpEL 组合:

java
@PreAuthorize("hasAnyAuthority('kb:upload','kb:delete')")

3.4 数据级权限:Owner 校验切面

新增 @OwnerCheck 注解 + AOP 切面,对带 kbId / conversationId 入参的接口统一校验:

java
@OwnerCheck(resource = "kb", idParam = "id")
@DeleteMapping("/{id}")
public Result delete(@PathVariable Long id) { ... }

切面逻辑:

  • 当前用户拥有 kb:admin:any 权限 → 放行
  • 否则查 kb_knowledge_base.user_id == 当前用户 → 放行
  • 否则抛 AccessDeniedException

3.5 权限热更新

  • 角色/权限变更时,向 Redis 写 user:perm:revoke:{userId}={timestamp}
  • JwtAuthenticationFilter 校验 token 时,比对 token 签发时间与 revoke 时间戳,若 token 早于 revoke → 强制 401,触发前端用 refresh token 重新拿 token

4. 落地步骤(4 个 Phase)

Phase 1:数据模型 & 元数据初始化

  • docs/docmind.sql 新增 4 张表 DDL(sys_rolesys_permissionsys_user_rolesys_role_permission
  • 数据迁移脚本:
    • 内置角色与权限点 INSERT
    • sys_user.role='admin' → 写入 sys_user_role(user_id, SUPER_ADMIN.id)
    • sys_user.role='user' → 写入 sys_user_role(user_id, USER.id)
    • sys_user.role 字段保留 1 个版本作为回滚兜底,下次大版本删除
  • 实体类:新增 SysRoleSysPermissionSysUserRoleSysRolePermission + Mapper

Phase 2:后端鉴权改造

  • 新增 PermissionService.loadPermissionCodes(userId),带 Redis 缓存(key=perm:user:{id},TTL 30min)
  • 改造 JwtUtils:写入/读取 perms claim
  • 改造 UserDetailsServiceImpl:把 perm 码作为 authority
  • 改造 SecurityConfig:删除 .requestMatchers("/api/admin/**").hasRole("ADMIN"),全部下沉到 @PreAuthorize
  • 重写所有 controller 的注解:
  • 新增 RoleManageController/api/admin/role):CRUD 角色、给角色配权限、给用户配角色

Phase 3:前端按钮级权限

  • 后端登录返回值新增 permissions: string[]
  • stores/user.ts 增加 hasPerm(code: string): boolean
  • 路由守卫补全 requiresAdmin / requiresPerm 校验(修复现有 router/index.ts 的"防君子不防小人"漏洞)
  • 自定义指令 v-perm
vue
<el-button v-perm="'kb:delete'" @click="del">删除</el-button>
  • 新增页面:views/admin/role/(角色列表 + 配权限抽屉)

Phase 4:数据级权限 + 审计

  • 新增 @OwnerCheck 注解 + OwnerCheckAspect 切面
  • 新增 sys_audit_log 表,AOP 切面记录登录/角色变更/权限变更
  • 知识库、会话相关查询 SQL 加 user_id 隐式过滤(除非有 *:any 权限)

5. 关键改造文件清单

文件改造类型
docs/docmind.sql新增 4 张表 + 内置数据 + 迁移 SQL
src/main/java/com/simon/DocMind/entity/SysRole.java新增
src/main/java/com/simon/DocMind/entity/SysPermission.java新增
src/main/java/com/simon/DocMind/entity/SysUserRole.java新增
src/main/java/com/simon/DocMind/entity/SysRolePermission.java新增
src/main/java/com/simon/DocMind/service/PermissionService.java新增
src/main/java/com/simon/DocMind/service/RoleService.java新增
src/main/java/com/simon/DocMind/common/utils/JwtUtils.java改 claim 结构
src/main/java/com/simon/DocMind/security/UserDetailsServiceImpl.java改 authority 来源
src/main/java/com/simon/DocMind/security/JwtAuthenticationFilter.java增加 revoke 时间戳校验
src/main/java/com/simon/DocMind/config/SecurityConfig.java移除 URL 级 hasRole
src/main/java/com/simon/DocMind/common/constant/Perms.java新增(权限码常量)
src/main/java/com/simon/DocMind/aop/OwnerCheckAspect.java新增
src/main/java/com/simon/DocMind/aop/AuditLogAspect.java新增
7 个 Controller@PreAuthorize 重写
DocMind-frontend/src/stores/user.tspermissions + hasPerm
DocMind-frontend/src/router/index.ts守卫补 requiresPerm
DocMind-frontend/src/directives/perm.ts新增 v-perm 指令
DocMind-frontend/src/views/admin/role/新增角色管理页

6. 平滑迁移策略

  1. 双写期(Phase 1 上线后 1 周):保留 sys_user.role 字段,每次写用户角色时同步写入旧字段,便于回滚
  2. JWT 兼容:JwtAuthenticationFilter 同时识别旧 role claim 和新 perms claim,老 token 自然过期失效(24h)
  3. 权限码缓存预热:服务启动时把所有用户权限码批量加载到 Redis
  4. 回滚预案:保留旧 SecurityConfiglegacy 分支,发现严重问题可一键切回

7. 验收测试

场景预期
普通用户上传文档403 kb:upload 拒绝
普通用户访问 /api/admin/ai-config403 ai-config:read 拒绝
KB 管理员看不到用户列表403 user:read 拒绝
普通用户 A 删除用户 B 的知识库403(OwnerCheck 拦截)
给某用户加 kb:upload 权限后 5 分钟内生效Redis revoke 触发 token 重签
角色 CRUDsys_audit_log 留痕
老 token(无 perms claim)过期前可继续用兼容期通过

测试入口:

  • 单元:mvn test -Dtest=PermissionServiceTest
  • 集成:mvn test -Dtest=RbacIntegrationTest(用 Testcontainers 拉 MySQL + Redis)
  • E2E:登录三类角色账号,跑 Postman collection docs/rbac-postman.json

8. 风险与权衡

风险缓解
权限码列表过大(用户挂多个高权角色 → JWT 体积膨胀)单用户权限超过 50 个时,JWT 只放角色码,鉴权时查 Redis(用 token bytes 监控触发降级)
权限热更新引入 Redis 强依赖Redis 不可用时,降级为"必须等 token 自然过期才生效",业务可用性不受影响
注解散落仍有遗漏用 ArchUnit 测试强制 controller 包内每个 public 方法必须有 @PreAuthorize@PermitAll
Owner 检查切面性能单接口 +1 次主键查询,可接受;高频接口(如 /chat/stream)改为查询时 SQL 内 WHERE user_id=? 而非切面

9. 面试讲故事的角度

  • 为什么不用 Spring Security ACL? —— ACL 适合对象级 ACE 矩阵(每个文档对每个用户单独授权),DocMind 场景是"按角色批量授权 + owner 隔离",三段式 RBAC 已够,ACL 反而复杂
  • 为什么 JWT 里塞权限而不是只塞角色? —— 权限放 token:鉴权零查询,性能最好;代价是"权限变更延迟"(用 revoke 戳兜底)。塞角色则每次请求都要查"角色→权限"映射,大流量场景吃不消
  • 数据级权限为什么用切面而不是 SQL 拦截器? —— 切面侵入低、可读,覆盖增删改场景;列表查询用 MyBatis 拦截器统一加 user_id 过滤更合适,两者结合
  • 如果再加多租户怎么办? —— sys_roletenant_idsys_permission 保持全局,sys_user_role 增租户上下文,Owner 切面升级为 Tenant + Owner 双校验

10. 同步面试文档

完成实施后需更新: