Files
laker 8c1f4c7c75 feat: initialize EasyNextAdmin
Publish the current verified project state without local development history or personal-path artifacts.
2026-07-17 17:58:57 +08:00

279 lines
13 KiB
Markdown
Raw Permalink 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.
# 数据权限组件
## 适用场景
企业后台里,同一个列表接口通常不是所有人都能看全量数据。管理员可以看全部账号,部门负责人只能看本部门及以下,普通员工只能看本人相关数据。数据权限组件解决的是“能访问接口之后,还能看到哪些数据”的问题。
它适合用于用户、部门、流程任务、调度任务等带有组织归属或负责人字段的查询。它不替代接口权限:`@EasyPermission` 决定用户能不能调用接口,`@DataScope` 决定接口查询结果会被限制到什么范围。
不适合使用数据权限组件的场景:
- 登录、验证码、初始化、权限恢复这类还没有稳定登录上下文的查询。
- 必须跨组织读取的系统内部查询,例如审批参与人补齐、关系校验、任务派发内部查询。
- 无法在 SQL 外层结果中暴露部门字段或本人字段的复杂聚合查询。
## 如何使用
### 1. 数据库存储稳定编码
`sys_role.data_scope` 存储稳定 code不存储中文。中文只作为前端 label、报表展示和文档解释。
| `sys_role.data_scope` | 中文展示 | 运行时类型 | 含义 |
| --- | --- | --- | --- |
| `ALL` | 全部数据 | `ALL` | 不追加数据范围条件 |
| `DEPT_AND_CHILDREN` | 本部门及以下 | `DEPT_AND_CHILDREN` | 当前部门及子部门 |
| `DEPT` | 本部门 | `DEPT` | 当前用户所在部门 |
| `SELF` | 本人数据 | `SELF` | 当前用户本人相关数据 |
| `DEPT_SETS` | 自定义部门 | `DEPT_SETS` | 启用角色绑定的部门集合 |
后端通过 `DataScopeType#fromRoleDataScope` 转换角色数据范围。保存和前端提交都必须使用 code不接受中文展示值作为接口值。
多个角色会合并成一个可见范围。只要包含 `ALL`,最终就是全量范围;多个部门范围会取并集。
数据库初始化脚本对 `sys_role.data_scope` 加了 `NOT NULL``CHECK` 约束,角色授权保存时也会再次校验标准 code。发现中文、旧编码或空值时应通过迁移脚本先修正不在运行时静默降级。
角色授权页会从后端读取当前账号可授予的数据范围。非超级管理员不能把角色授权成高于自身可见边界的范围:
| 当前账号数据范围 | 可授予角色的数据范围 |
| --- | --- |
| `ALL` | `ALL``DEPT_AND_CHILDREN``DEPT``SELF``DEPT_SETS` |
| `DEPT_AND_CHILDREN` | `DEPT_AND_CHILDREN``DEPT``SELF``DEPT_SETS` |
| `DEPT` | `DEPT``SELF``DEPT_SETS` |
| `DEPT_SETS` | `SELF``DEPT_SETS` |
| `SELF` | `SELF` |
选择 `DEPT_SETS` 时,部门 ID 还会校验是否真实存在、是否启用,以及是否落在当前账号可见组织范围内。
### 2. 在 Mapper 类上声明数据范围
类级注解适合 MyBatis-Plus 的 `selectList``selectPage`,这是标准列表页最常用的接入方式。
用户列表示例:
```java
@DataScope(
methods = {DataScopeMapperMethods.SELECT_LIST, DataScopeMapperMethods.SELECT_PAGE},
deptColumn = DataScopeColumns.DB_DEPT_ID,
selfColumn = DataScopeColumns.USER_ID
)
public interface SysUserMapper extends BaseMapper<SysUser> {
}
```
部门列表示例:
```java
@DataScope(
methods = {DataScopeMapperMethods.SELECT_LIST, DataScopeMapperMethods.SELECT_PAGE},
selfColumn = DataScopeColumns.DEPT_ID
)
public interface SysDeptMapper extends BaseMapper<SysDept> {
}
```
流程任务示例:
```java
@DataScope(
methods = {DataScopeMapperMethods.SELECT_LIST, DataScopeMapperMethods.SELECT_PAGE},
deptColumn = DataScopeColumns.DB_ASSIGNEE_DEPT_ID,
selfColumn = DataScopeColumns.DB_ASSIGNEE_ID
)
public interface WfTaskMapper extends BaseMapper<WfTask> {
}
```
`methods` 必须尽量收窄。不要在 Mapper 类上无差别覆盖所有方法,否则 `selectById``selectBatchIds` 这类内部查询也可能被误过滤。
### 3. 在 Mapper 方法上声明数据范围
方法级注解适合自定义查询。它比类级注解优先级更高,适合某个方法的过滤字段和默认字段不一致的场景。
```java
public interface CustomMapper {
@DataScope(deptColumn = "org_id", selfColumn = "owner_id")
List<CustomView> search(CustomQuery query);
}
```
方法级注解的核心价值是把“这个查询到底按哪个字段做部门过滤、按哪个字段做本人过滤”写在查询入口旁边,避免二开人员去 Service 里猜。
### 4. 字段名必须匹配外层查询
`deptColumn``selfColumn` 不是随便写实体属性名,它们必须是原 SQL 被包成子查询之后,外层查询能看到的列名或别名。
这就是为什么项目里会同时出现 `dept_id``deptId`
- `dept_id`:原 SQL 外层直接暴露数据库列名,例如用户表的部门字段。
- `deptId`MyBatis-Plus 或自定义查询把列投影成 Java 属性别名,例如 `id AS deptId`
数据权限改写发生在 SQL 层,不知道 Java 实体字段语义。字段写错时,轻则查不到数据,重则 SQL 执行失败。
### 5. 系统内部查询显式绕过
如果业务逻辑需要查询完整数据用于校验或派单,必须显式写出绕过意图:
```java
SysUser manager = EasyDataScopeContext.ignore(() -> this.lambdaQuery()
.eq(SysUser::getId, managerUserId)
.one());
```
`ignore(...)` 使用 ThreadLocal 记录嵌套深度,执行结束后会恢复现场。不要把它包在 Controller 大范围入口上,只能包住确实需要全量数据的内部查询。
## 请求或执行流程
```mermaid
flowchart TD
A["用户请求列表接口"] --> B["EasyAuthFilter 恢复 AuthPrincipal"]
B --> C["@EasyPermission 校验接口权限"]
C --> D["Service 调用带 @DataScope 的 Mapper"]
D --> E["EasyDataScopeInnerInterceptor 拦截 SELECT"]
E --> F["CurrentUserDataScopeResolver 计算当前账号可见范围"]
F --> G["DataScopeSqlRewriter 包装 SQL 并追加条件"]
G --> H["MyBatis 执行改写后的 SQL"]
H --> I["Controller 返回 Response 或 PageResponse"]
```
一次查询的关键步骤:
1. 登录后,会话快照里包含用户 ID、部门 ID、角色和数据范围。
2. Controller 先通过 `@EasyPermission` 判断能不能访问接口。
3. Service 调用 Mapper。
4. MyBatis-Plus 插件链触发 `EasyDataScopeInnerInterceptor#beforeQuery`
5. 拦截器只处理 `SELECT`,并且只处理命中 `@DataScope` 的 Mapper 方法。
6. `CurrentUserDataScopeResolver``EasySecurityContext` 读取当前用户,计算 `DataScopeCondition`
7. `DataScopeSqlRewriter` 把原 SQL 包成子查询,再追加外层条件。
8. 分页插件在数据权限插件之后执行,保证分页总数和列表数据使用同一套范围条件。
## 原理
数据权限组件分成两层:决策层和执行层。
决策层是 `CurrentUserDataScopeResolver`。它只回答一个问题:当前登录人对“带组织归属的数据”能看到什么范围。它不关心具体表名,也不拼 SQL。
执行层是 `EasyDataScopeInnerInterceptor``DataScopeSqlRewriter`。Mapper 用 `@DataScope` 告诉组件“这个查询结果里哪个字段代表部门,哪个字段代表本人”。拦截器在 SQL 执行前读取注解和权限范围,然后把原 SQL 改写成:
```sql
SELECT *
FROM (
) ea_ds
WHERE ea_ds.deptId IN (10, 20)
```
如果当前用户拥有全部数据权限,原 SQL 不改写。如果没有登录上下文、数据范围无法计算、本人范围但没有 `selfColumn`,组件默认追加 `1 = 0`,宁可查不到数据,也不误放开。
列名会经过白名单校验,只允许普通标识符,例如 `dept_id``deptId``assignee_id`。不允许 `dept_id;drop table` 这类拼接内容进入 SQL。
## 关键类、配置和表
| 类型 | 位置 | 作用 |
| --- | --- | --- |
| 注解 | `DataScope` | 标记 Mapper 查询需要自动追加数据范围 |
| 字段常量 | `DataScopeColumns` | 提供常用外层字段名,如 `deptId``userId` |
| 方法常量 | `DataScopeMapperMethods` | 提供 MyBatis-Plus 常用 Mapper 方法名 |
| 范围对象 | `DataScopeCondition` | 表达当前请求的可见范围 |
| 决策接口 | `DataScopeResolver` | 解耦“计算范围”和“改写 SQL” |
| 决策实现 | `CurrentUserDataScopeResolver` | 从当前用户、角色和部门树计算范围 |
| 元数据端口 | `DataScopeMetadataRepository` | 读取启用部门树和启用角色的自定义部门授权等权限元数据 |
| 元数据缓存 | `CachedDataScopeMetadataRepository` | 通过 `@Cacheable` 复用 Spring Cache避免每次数据范围计算都查部门树和自定义部门表 |
| 缓存失效 | `@CacheEvict` | 部门、角色授权和用户角色变化后清理数据权限元数据缓存,提交后生效 |
| 拦截器 | `EasyDataScopeInnerInterceptor` | MyBatis 查询前识别注解并触发 SQL 改写 |
| SQL 构造 | `DataScopeSqlRewriter` | 包装原 SQL 并追加部门或本人条件 |
| 绕过上下文 | `EasyDataScopeContext` | 对系统内部查询显式跳过数据权限 |
| 插件配置 | `EasyMybatisConfig` | 将数据权限插件放在分页插件之前 |
| 枚举 | `DataScopeType` | 维护数据范围 code 和中文 label |
| 表 | `sys_role` | 保存角色数据范围 code |
| 表 | `sys_role_dept` | 保存自定义部门范围 |
| 表 | `sys_user_role` | 关联用户和角色 |
| 表 | `sys_dept` | 提供未删除且启用的部门树 |
后端分包按职责收敛在 `infrastructure/security/datascope` 下:
```text
annotation # @DataScope 和 DataScopeColumns给 Mapper 声明字段
context # EasyDataScopeContext显式绕过内部查询
model # DataScopeType、DataScopeCondition稳定 code 和运行时范围
resolver # CurrentUserDataScopeResolver计算当前账号可见范围
policy # DataScopeAssignmentPolicy角色授权可授予范围矩阵
repository # DataScopeMetadataRepository读取并缓存数据权限元数据
mybatis # EasyDataScopeInnerInterceptor、DataScopeSqlRewriter执行 SQL 改写
```
## Tradeoff
### 方案一Mapper 注解 + MyBatis 拦截器
这是当前方案。
优点:
- 过滤靠近数据库执行,不会先查出越权数据再在内存过滤。
- 业务 Service 不需要在每个列表里重复拼部门条件。
- 分页前执行,分页总数和当前页数据一致。
- Mapper 必须显式声明字段,能看出哪些查询受数据权限保护。
缺点:
- 查询结果外层必须暴露过滤字段或别名。
- SQL 被包装成子查询后,复杂 SQL 的执行计划需要关注。
- 只适合标准行级过滤,复杂 ABAC 规则仍要在业务服务里补充。
适合 EasyNextAdmin 当前这种单体后台、MyBatis-Plus、MySQL、强 CRUD 的场景。
### 方案二Service 层手写条件
每个 Service 根据当前用户自己拼 `dept_id in (...)``create_by = ?`
优点:
- 逻辑显式,调试时容易从 Service 代码读懂。
- 对复杂业务规则更灵活。
- 不需要 SQL 改写插件。
缺点:
- 容易漏加条件,尤其是新增列表、导出、统计接口时。
- 同一套数据范围逻辑会散落在多个 Service。
- 分页、统计、导出可能各写一遍,长期容易不一致。
适合小项目或只有一两个受控查询的模块,不适合作为企业后台脚手架默认方案。
### 方案三:数据库行级安全或安全视图
把数据范围放进数据库层,例如 PostgreSQL RLS 或安全视图。
优点:
- 安全边界更靠近数据源。
- 多个应用共享数据库时,可以减少应用侧遗漏。
缺点:
- MySQL 没有 PostgreSQL 那种原生 RLS 能力,落地通常要靠视图、存储过程或会话变量,复杂度高。
- 应用用户、业务用户、部门树、角色版本之间的上下文传递难维护。
- 本项目的权限版本、会话快照和菜单权限都在应用层,强行下沉会让模型分裂。
适合数据库治理能力很强、跨应用共享同一套权限规则的企业,不适合作为 EasyNextAdmin 默认脚手架方案。
## 常见坑
- 只加 `@EasyPermission` 不加 `@DataScope`:接口能鉴权,但列表仍可能查到超范围数据。
- `@DataScope` 标了错误字段SQL 外层看不到字段时会执行失败,或者查不到数据。
- 本人范围没有设置 `selfColumn`:组件会追加 `1 = 0`,避免误放开。
- 内部校验查询没有 `ignore(...)`:例如校验直属上级、部门负责人、流程参与人时,可能因为当前用户数据范围太小而查不到真实数据。
- 在 Controller 大范围使用 `ignore(...)`:这等于绕过整个请求的数据权限边界,应该禁止。
- 把数据权限当作接口权限:数据权限只决定“看哪些数据”,不能决定“能不能执行操作”。
- 把中文当作数据库枚举值:数据库、接口和审计日志都应使用稳定 code中文只负责展示。
- 只在前端隐藏高风险选项:后端必须用授权矩阵再次校验,前端禁用只是操作体验,不是安全边界。
## 扩展建议
整理文档和代码时暴露出的后续优化点:
- 当前已缓存部门树和用户自定义部门集合;后续如果部门规模很大,可以继续缓存部门闭包或改用 `tree_path` 前缀查询,但必须保持组织变更后失效。
- SQL 包装成子查询对复杂报表可能影响执行计划,后续复杂查询可以保留手写 SQL + 方法级 `@DataScope`,并用集成测试验证。
- 组件文档稳定后,可以补一篇 `components/security/permission.md`,明确接口权限和数据权限的分工,避免二开时混用。