RuoYi 权限体系源码解析:菜单、按钮、接口权限与数据权限如何协同
RuoYi 的权限体系表面上围绕“菜单管理”和“角色授权”展开,底层实际由两条相对独立的链路组成:一条负责目录、页面、按钮和接口操作的功能权限,另一条负责限制用户能够查询哪些部门或个人数据。本文以 RuoYi v4.8.3 当前源码为基线,从 sys_menu、Shiro UserRealm、@RequiresPermissions、Thymeleaf 按钮控制、@DataScope 与 MyBatis 动态 SQL 入手,完整梳理权限字符如何从数据库进入运行时,以及多个角色、缓存和数据范围之间如何协作。
先说结论:RuoYi 不是把所有权限都交给菜单
分析权限体系前,需要先确认版本。本文对应的是官方仓库 yangzongzhuan/RuoYi,也就是服务端渲染的经典单体版。仓库当前标记为 v4.8.3,master 已切换到 Spring Boot 4.x,但安全框架仍然是 Apache Shiro,前端主要使用 Thymeleaf、Bootstrap 和 jQuery。
它与前后端分离的 RuoYi-Vue 并不是同一个实现:RuoYi-Vue 使用 Spring Security,常见写法是 @PreAuthorize("@ss.hasPermi(...)");本文仓库使用 Shiro,接口上对应的是 @RequiresPermissions。两者的表结构和权限字符习惯相似,运行时授权机制却不能混着解释。
RuoYi 经典版可以归纳为四层控制:
| 层次 | 回答的问题 | 主要实现 |
|---|---|---|
| 登录认证 | 当前请求是谁发出的 | Shiro UserRealm、Session、过滤链 |
| 菜单权限 | 用户能看到哪些目录和页面入口 | sys_menu、sys_role_menu、菜单树查询 |
| 按钮与接口权限 | 用户能否执行新增、修改、删除、导出等动作 | 权限字符、Shiro 标签、@RequiresPermissions |
| 数据权限 | 用户执行查询后能看到哪些数据行 | 角色 data_scope、@DataScope、AOP 拼接 SQL |
前三层属于功能授权,整体接近 RBAC;数据权限是在 RBAC 之上附加的一层行级过滤。菜单隐藏不等于接口安全,能调用接口也不等于能读取所有数据。把这三件事揉成一个“有权限/没权限”的开关,是理解 RuoYi 时最容易踩的坑。
权限模型与核心表
RuoYi 的主授权链路仍然是经典的:
1 | |
数据范围从角色分叉出去:
1 | |
用实体关系表示如下:
erDiagram
SYS_USER ||--o{ SYS_USER_ROLE : "分配"
SYS_ROLE ||--o{ SYS_USER_ROLE : "包含用户"
SYS_ROLE ||--o{ SYS_ROLE_MENU : "授予功能"
SYS_MENU ||--o{ SYS_ROLE_MENU : "目录、菜单或按钮"
SYS_ROLE ||--o{ SYS_ROLE_DEPT : "限定数据范围"
SYS_DEPT ||--o{ SYS_ROLE_DEPT : "自定义部门"
SYS_ROLE {
bigint role_id
varchar role_key
char data_scope
char status
}
SYS_MENU {
bigint menu_id
bigint parent_id
char menu_type
varchar url
varchar perms
char visible
}
核心表的职责如下:
| 表 | 作用 |
|---|---|
sys_user |
用户、所属部门、状态等身份信息 |
sys_role |
角色、角色权限字符 role_key、数据范围 data_scope |
sys_user_role |
用户和角色的多对多关系 |
sys_menu |
目录、页面菜单、按钮以及对应权限字符 |
sys_role_menu |
角色可访问的菜单和按钮节点 |
sys_dept |
部门树,通过 ancestors 保存祖先路径 |
sys_role_dept |
当角色采用自定义数据范围时,保存可访问部门 |
这里没有独立的 sys_permission 表。RuoYi 将功能资源与权限标识都放在 sys_menu 中,再通过 sys_role_menu 一并授权。因此它是一个很实用的后台管理系统模型,但不是严格分离 Role → Permission → Resource 的通用 IAM 模型。
sys_menu 为什么既是菜单表,也是权限表
SysMenu 对 menu_type 的定义非常直接:
1 | |
三种节点并不是同一性质的资源:
| 类型 | 含义 | 是否进入左侧导航 | 典型字段 |
|---|---|---|---|
M |
目录 | 是 | 名称、父节点、图标、排序 |
C |
页面菜单 | 是 | URL、打开方式、权限字符 |
F |
功能按钮 | 否 | 权限字符,如 system:user:add |
例如用户管理可以形成下面的树:
1 | |
角色授权页面展示的是这棵完整树。勾选一个按钮,本质上不是把按钮 HTML 交给角色,而是在 sys_role_menu 中写入一条 role_id + menu_id 关系;登录后再由该菜单记录的 perms 转换成 Shiro 字符串权限。
这种设计的好处是配置简单:一棵树完成页面入口和操作权限授权,代码生成器也容易生成配套 SQL。代价是菜单资源和授权语义强耦合。一个权限若需要同时保护多个页面、接口或非 UI 任务,只能复用同一字符串,数据库本身没有显式的“权限绑定多个资源”模型。
登录后,角色和权限字符如何进入 Shiro
功能权限真正生效的核心在 UserRealm。登录认证成功后,Shiro 在需要授权信息时调用 doGetAuthorizationInfo:
1 | |
普通用户会装载两类字符串:
sys_role.role_key进入 Shiro Roles,例如common、finance;sys_menu.perms进入 Shiro String Permissions,例如system:user:list。
系统管理员是特殊分支。user.isAdmin() 不是判断用户名,也不是判断 role_key,而是判断内置管理员用户 ID。管理员直接获得角色 admin 和通配权限 *:*:*,不再查询角色菜单授权。
完整调用链如下:
sequenceDiagram
participant U as 用户
participant R as UserRealm
participant S as Role/Menu Service
participant DB as RBAC 表
participant A as Shiro
U->>R: 登录认证
R->>DB: 查询用户、部门和角色
R-->>U: 建立 Session
A->>R: 请求授权信息
R->>S: 查询 role_key 与 perms
S->>DB: 关联角色、菜单
DB-->>S: 字符串集合
S-->>R: roles + permissions
R-->>A: AuthorizationInfo
SysMenuMapper.xml 中的权限查询能够看出数据库链路:
1 | |
SysMenuServiceImpl.selectPermsByUserId 还会把一个字段中的多个逗号分隔权限拆开:
1 | |
因此多个角色的功能权限最终按集合合并,语义是并集:只要任一启用角色通过 sys_role_menu 获得某权限字符,用户就具备该权限。
菜单权限:控制导航入口
首页装载菜单时会调用 SysMenuServiceImpl.selectMenusByUser。管理员查询所有正常菜单,普通用户按用户 ID 关联角色与菜单:
1 | |
普通用户对应的 SQL 只选择 M 和 C:
1 | |
查询出的扁平列表再由 getChildPerms、recursionFn 按 parent_id 递归组装为菜单树。按钮 F 不进入导航树,这是“菜单可见”和“按钮可用”能够共用一张表却不互相干扰的关键。
需要留意 visible 的双重效果。在当前源码中,菜单查询和权限字符查询都要求 visible = 0。所以把节点设为隐藏不仅可能让它不出现在导航中,也可能让其 perms 不进入普通用户的授权集合。若只是希望隐藏入口但仍允许从其他页面访问,不应想当然地把 visible 当作纯前端显示字段,应先验证该节点对应的权限装载行为。
按钮权限:前端有两种隐藏方式
经典版页面使用 Thymeleaf Shiro 方言控制静态按钮。例如用户管理页面中的新增按钮:
1 | |
服务端渲染模板时,Shiro 判断当前 Subject 是否拥有 system:user:add。没有权限时,该元素不会按正常方式呈现。
表格行内按钮由 JavaScript 动态拼接,无法简单依赖模板标签,于是 RuoYi 又提供了 PermissionService:
1 | |
1 | |
JS 再把返回的空字符串或 hidden 拼到按钮 class 中:
1 | |
两种按钮控制最终都调用 Subject.isPermitted(permission),只是适配了不同的渲染场景。
按钮隐藏只改善界面体验,不构成安全边界。攻击者可以直接构造 HTTP 请求,因此对应后端接口必须再次校验同一个权限字符。
接口权限:真正的安全边界在 @RequiresPermissions
用户管理 Controller 很清楚地展示了页面、列表和动作接口的权限划分:
1 | |
ShiroConfig 注册了 AuthorizationAttributeSourceAdvisor,使 Shiro 能够拦截这些注解。用户不具备权限时,请求在进入业务方法前就被拒绝。
过滤链与方法注解分工不同:
1 | |
这条兜底规则主要要求用户已登录,并处理会话、踢出、在线状态和 CSRF。源码中曾预留按数据库 URL 生成 Shiro perms[...] 过滤规则的能力:
1 | |
但当前代码是注释状态。换句话说,当前版本不是每次根据 sys_menu.url 自动完成接口授权,具体业务接口主要依靠 @RequiresPermissions。数据库权限字符与 Java 注解之间也没有外键或编译期约束,它们靠字符串保持一致。
这解释了一个常见故障:角色明明勾选了按钮,接口仍提示无权限,或者页面按钮可见但调用接口失败。排查时应沿着同一字符串检查:
1 | |
任意一处拼写、大小写或动作名不同,前后端行为就会分裂。
权限字符到底表示什么
RuoYi 常用三段式命名:
1 | |
例如:
1 | |
三段式不是数据库强制格式,而是 Shiro WildcardPermission 的约定和项目命名规范。它使通配权限能够表达一组能力:
1 | |
角色还有另一种“权限字符”——sys_role.role_key。它对应 Shiro Role,通常通过 @RequiresRoles("admin") 或 Subject.hasRole(...) 判断。两者不要混淆:
| 字段 | 例子 | 语义 | 推荐用途 |
|---|---|---|---|
sys_role.role_key |
finance |
用户承担什么角色 | 少量角色级入口或特殊管理能力 |
sys_menu.perms |
finance:settlement:audit |
用户能执行什么动作 | 业务接口和按钮的主要授权依据 |
业务接口更适合依赖细粒度权限,而不是写死角色。若退款接口只允许 FINANCE_ADMIN,以后财务经理也需要退款时就必须改代码;若接口要求 order:refund,只需在后台调整角色授权。
数据权限不是接口权限的另一种写法
功能权限回答“能不能查询用户列表”,数据权限回答“查询时能看到哪些用户”。RuoYi 把数据范围配置在角色上,Constants.Dept 定义了五种取值:
| 值 | 数据范围 | SQL 语义 |
|---|---|---|
1 |
全部数据权限 | 不追加限制 |
2 |
自定义数据权限 | 部门 ID 来自 sys_role_dept |
3 |
本部门数据权限 | dept_id = 当前用户部门 |
4 |
本部门及以下数据权限 | 当前部门或 ancestors 包含当前部门 |
5 |
仅本人数据权限 | user_id = 当前用户 |
当角色选择“自定义数据权限”时,角色管理页面提交选中的 deptIds。SysRoleServiceImpl.authDataScope 更新 sys_role.data_scope,先删除旧的 sys_role_dept,再批量写入新的角色部门关系,整个过程在事务中执行。
数据过滤由 @DataScope 标在 Service 方法上。例如:
1 | |
deptAlias 和 userAlias 必须与 Mapper SQL 中的表别名一致。默认字段名分别是 dept_id 和 user_id,也可以通过 deptField、userField 覆盖。
执行流程如下:
flowchart TD
A["调用带 @DataScope 的 Service"] --> B["DataScopeAspect 读取当前用户"]
B --> C{"是否超级管理员"}
C -- 是 --> D["不追加过滤条件"]
C -- 否 --> E["遍历启用角色与 data_scope"]
E --> F["生成部门或本人 SQL 条件"]
F --> G["写入 params.dataScope"]
G --> H["Mapper 通过 ${params.dataScope} 拼入查询"]
以用户列表为例,Mapper 末尾预留了插槽:
1 | |
DataScopeAspect 会根据角色生成类似条件:
1 | |
这些条件使用 OR 合并,因此用户具有多个角色时,最终可见数据通常是各角色范围的并集。只要其中一个符合当前业务权限的角色具有“全部数据权限”,过滤条件就会被清空,用户获得全部数据。
为什么数据权限还要关心权限字符
如果一个用户同时有两个角色:
- 角色 A 拥有
system:user:list,数据范围为本部门; - 角色 B 不拥有
system:user:list,但数据范围为全部数据。
若数据过滤无条件合并所有角色,角色 B 会把角色 A 的用户查询能力意外放大为全量数据。RuoYi 当前源码专门处理了这个问题。
PermissionsAspect 会读取 Controller 的 @RequiresPermissions,把权限字符放入请求级 PermissionContextHolder:
1 | |
登录时,SysLoginService.setRolePermission 又为每个角色单独查询并保存其权限字符集合。DataScopeAspect 遍历角色时,只采用拥有当前接口权限的角色:
1 | |
因此上例中只有角色 A 参与数据范围计算,最终仍限制为本部门。这是 RuoYi 数据权限实现中非常关键、也很容易被忽略的一层:数据范围不是用户所有角色范围的无条件并集,而是与当前权限字符匹配后的角色范围并集。
@DataScope.permission 允许显式指定权限;未指定时,默认读取当前请求中 @RequiresPermissions 提供的字符。若业务调用不经过带权限注解的 Controller,或者异步任务、内部调用没有请求上下文,就需要重新审视权限字符的传递方式,不能假设 AOP 会自动知道业务意图。
数据权限 SQL 拼接的安全边界
Mapper 使用的是 ${params.dataScope},这是文本替换,不是 MyBatis 的 #{} 参数绑定。直接把客户端传入的 params.dataScope 拼进去会形成明显的 SQL 注入风险。
RuoYi 的防护方式是:DataScopeAspect 在生成条件前先把调用参数中的 dataScope 清空,然后只写入服务端根据当前登录用户、角色 ID、用户 ID和固定别名生成的 SQL:
1 | |
这套机制成立依赖几个前提:
- 带
@DataScope的查询对象必须是方法第一个参数; - 第一个参数必须继承
BaseEntity; - Mapper 必须从该对象的
params.dataScope读取条件; - 别名和字段名来自代码中的可信注解配置,不能来自请求参数;
- 业务代码不能绕过 Service,直接调用未受保护的 Mapper 查询。
如果新增业务时只复制 ${params.dataScope},却忘了在对应 Service 方法上加 @DataScope,最好的结果是没有过滤,最坏的结果是给不可信文本留下拼接入口。这个地方看着像一行 XML,实际背后背着整条安全链路。
权限配置修改后为什么要清缓存
UserRealm 的授权信息启用了名为 sys-authCache 的缓存。角色菜单变化后,如果缓存不失效,在线用户可能继续使用旧权限。
RuoYi 在角色和菜单等管理操作中调用:
1 | |
最终由 UserRealm.clearAllCachedAuthorizationInfo() 清空授权缓存。当前实现选择“清全部”而不是精确失效受影响用户,逻辑简单可靠,但在用户和角色规模很大时会造成额外的重载开销。
数据权限还有另一层状态问题:登录时的 SysUser Principal 已经带有角色及其 dataScope、每角色权限集合。修改当前角色的数据范围后,Controller 会重新查询当前用户并写回 Session;对于其他在线用户,应结合实际版本测试其 Principal 与授权缓存是否都能及时刷新。企业改造时更稳妥的方案是引入权限版本号或用户级缓存失效事件,而不是只依赖全量缓存清理。
一次“用户查询”请求的完整权限链
以 POST /system/user/list 为例,从登录到返回数据会经过下面几步:
- 用户登录,
SysLoginService查询用户、部门和角色,并把每个角色拥有的权限字符写入role.permissions。 - Shiro 需要授权信息时,
UserRealm查询用户的role_key与全部sys_menu.perms,形成AuthorizationInfo并缓存。 - 首页查询
M/C节点生成菜单树,决定是否展示“用户管理”入口。 - 用户页面用
shiro:hasPermission或PermissionService决定新增、编辑、删除等按钮是否显示。 - 请求
/system/user/list时,@RequiresPermissions("system:user:list")再做一次后端强制校验。 PermissionsAspect把system:user:list保存到当前请求上下文。- Controller 调用带
@DataScope(deptAlias = "d", userAlias = "u")的selectUserList。 DataScopeAspect只选择拥有system:user:list的角色,根据其data_scope生成 SQL。- MyBatis 将服务端生成的
${params.dataScope}拼入查询,数据库返回允许范围内的用户记录。
可以把最终判定理解成:
1 | |
菜单和按钮只负责让正常用户“看得见、点得到”,接口注解和数据过滤才真正决定“做不做得了、能看到多少”。
这套设计的优点与边界
RuoYi 的方案非常适合典型企业管理后台,原因在于它把管理员最常操作的配置压缩成两棵树:角色菜单树和角色部门树。新增普通 CRUD 模块时,只要保持权限字符一致并套用已有模板,就能快速获得菜单、按钮、接口和部门数据过滤能力。
它的主要优点包括:
- 用户、角色、功能权限关系清晰,符合 RBAC0 的基本结构;
- 目录、页面和按钮统一配置,管理成本低;
- 前端隐藏与后端强制校验复用同一权限字符;
- 数据权限支持全部、自定义部门、本部门、部门及以下和本人;
- 数据范围会按当前接口权限筛选角色,减少多角色造成的越权;
- 超级管理员、禁用角色和授权缓存都有明确处理。
边界同样明显:
菜单、权限和资源耦合
sys_menu 同时承担导航树、按钮资源和权限目录。一旦系统需要 API、字段、文件、工作流节点、消息主题等非菜单资源,这张表会越来越像“万能权限表”。更通用的 IAM 通常会拆为 Permission 与 Resource,让一个业务权限绑定多个资源。
接口与数据库配置靠字符串约定
@RequiresPermissions("system:user:list") 与 sys_menu.perms 没有结构化绑定。重命名、复制代码或手工配置时容易漂移。可以通过启动时扫描注解并与数据库比对、自动生成权限清单、集成测试等方式降低风险。
数据权限以 SQL 文本注入查询
它对 MyBatis 和查询别名有较强侵入,方法参数位置、BaseEntity、别名和 Mapper 插槽都必须遵循约定。复杂报表、多表聚合、子查询、跨库查询或 JPA 场景通常需要专门设计,而不是继续机械复制 ${params.dataScope}。
默认模型不是多租户隔离
部门数据范围不能替代 Tenant 隔离。SaaS 系统必须在所有业务数据和查询链路上独立约束 tenant_id,且租户条件应优先作为不可绕过的安全边界。数据权限是在租户内部继续缩小可见范围,两者不能合并成一个部门条件。
不支持复杂约束和显式拒绝
多个角色主要按权限并集工作,没有内建的互斥角色、职责分离、审批上下文、时间与环境条件,也没有 Deny 优先策略。财务付款申请人与审批人互斥、按金额授权、按数据属性决策等场景,需要 RBAC2、ABAC 或策略引擎补充。
基于 RuoYi 扩展权限体系时的建议
若继续沿用当前设计,至少应保持下面几条工程纪律:
- 权限字符使用稳定的
domain:resource:action命名,不把 URL、中文名称或角色名直接当权限。 - 每个危险接口都必须有后端权限校验,不能因为按钮已隐藏就省略注解。
- 新增接口时同步检查菜单配置、模板权限和 Controller 注解,最好用自动化测试验证三者一致。
@DataScope放在可被 Spring AOP 拦截的 Service 公共方法上,避免同类自调用绕过代理。- 严格核对
deptAlias、userAlias与 SQL 别名;查询模型不是BaseEntity时不要直接照搬。 - 把租户隔离、功能权限和数据权限作为三层独立边界,禁止用部门范围代替租户条件。
- 权限变更后明确设计缓存和在线会话失效策略;高规模场景优先精确失效而非全量清缓存。
- 对导出、批量删除、角色分配、数据权限配置等高风险操作增加审计日志和越权测试。
如果系统已经出现应用、菜单、路由、按钮、API、字段等多种资源类型,更合适的演进方向是:
flowchart LR
U["User"] --> R["Role"]
R --> P["Permission"]
P --> X["Resource"]
X --> T["MENU / API / ACTION / FIELD"]
这样角色只绑定业务能力,权限再绑定一个或多个具体资源。RuoYi 原有的 perms 可以逐步迁移为 Permission.code,菜单和按钮则变成 Resource,而数据范围作为角色权限关系或授权策略的附加约束。比起继续给 sys_menu 塞更多类型,这条路后期更省心——权限表也不必兼职整个宇宙。
总结
RuoYi 经典版的权限体系不是单纯的“角色勾菜单”,而是四套机制围绕同一个权限字符协作:
sys_user_role和sys_role_menu建立用户、角色、菜单/按钮之间的 RBAC 关系;UserRealm把role_key和sys_menu.perms装入 Shiro,并为管理员授予*:*:*;M/C节点生成导航,F节点提供按钮权限,Thymeleaf 只负责界面可见性;- Controller 的
@RequiresPermissions才是业务接口的强制授权边界; - 角色
data_scope、sys_role_dept、@DataScope和${params.dataScope}共同完成行级数据过滤; PermissionsAspect将接口权限传给数据权限切面,使数据范围只由真正授予当前能力的角色决定。
理解这条链路后,许多若依权限问题就不再神秘:菜单不显示要查 M/C 查询与角色菜单关系,按钮不显示要查 F 节点和权限字符,接口 403 要查 Shiro 授权集合与注解,数据过多或过少则要查 data_scope、角色权限匹配、AOP 参数和 Mapper SQL 插槽。它们共用一套配置入口,但每一层解决的是不同问题。