Files
easy-next-admin/docs/components/security/data-scope.md
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

13 KiB
Raw Blame History

数据权限组件

适用场景

企业后台里,同一个列表接口通常不是所有人都能看全量数据。管理员可以看全部账号,部门负责人只能看本部门及以下,普通员工只能看本人相关数据。数据权限组件解决的是“能访问接口之后,还能看到哪些数据”的问题。

它适合用于用户、部门、流程任务、调度任务等带有组织归属或负责人字段的查询。它不替代接口权限:@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 NULLCHECK 约束,角色授权保存时也会再次校验标准 code。发现中文、旧编码或空值时应通过迁移脚本先修正不在运行时静默降级。

角色授权页会从后端读取当前账号可授予的数据范围。非超级管理员不能把角色授权成高于自身可见边界的范围:

当前账号数据范围 可授予角色的数据范围
ALL ALLDEPT_AND_CHILDRENDEPTSELFDEPT_SETS
DEPT_AND_CHILDREN DEPT_AND_CHILDRENDEPTSELFDEPT_SETS
DEPT DEPTSELFDEPT_SETS
DEPT_SETS SELFDEPT_SETS
SELF SELF

选择 DEPT_SETS 时,部门 ID 还会校验是否真实存在、是否启用,以及是否落在当前账号可见组织范围内。

2. 在 Mapper 类上声明数据范围

类级注解适合 MyBatis-Plus 的 selectListselectPage,这是标准列表页最常用的接入方式。

用户列表示例:

@DataScope(
        methods = {DataScopeMapperMethods.SELECT_LIST, DataScopeMapperMethods.SELECT_PAGE},
        deptColumn = DataScopeColumns.DB_DEPT_ID,
        selfColumn = DataScopeColumns.USER_ID
)
public interface SysUserMapper extends BaseMapper<SysUser> {
}

部门列表示例:

@DataScope(
        methods = {DataScopeMapperMethods.SELECT_LIST, DataScopeMapperMethods.SELECT_PAGE},
        selfColumn = DataScopeColumns.DEPT_ID
)
public interface SysDeptMapper extends BaseMapper<SysDept> {
}

流程任务示例:

@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 类上无差别覆盖所有方法,否则 selectByIdselectBatchIds 这类内部查询也可能被误过滤。

3. 在 Mapper 方法上声明数据范围

方法级注解适合自定义查询。它比类级注解优先级更高,适合某个方法的过滤字段和默认字段不一致的场景。

public interface CustomMapper {

    @DataScope(deptColumn = "org_id", selfColumn = "owner_id")
    List<CustomView> search(CustomQuery query);
}

方法级注解的核心价值是把“这个查询到底按哪个字段做部门过滤、按哪个字段做本人过滤”写在查询入口旁边,避免二开人员去 Service 里猜。

4. 字段名必须匹配外层查询

deptColumnselfColumn 不是随便写实体属性名,它们必须是原 SQL 被包成子查询之后,外层查询能看到的列名或别名。

这就是为什么项目里会同时出现 dept_iddeptId

  • dept_id:原 SQL 外层直接暴露数据库列名,例如用户表的部门字段。
  • deptIdMyBatis-Plus 或自定义查询把列投影成 Java 属性别名,例如 id AS deptId

数据权限改写发生在 SQL 层,不知道 Java 实体字段语义。字段写错时,轻则查不到数据,重则 SQL 执行失败。

5. 系统内部查询显式绕过

如果业务逻辑需要查询完整数据用于校验或派单,必须显式写出绕过意图:

SysUser manager = EasyDataScopeContext.ignore(() -> this.lambdaQuery()
        .eq(SysUser::getId, managerUserId)
        .one());

ignore(...) 使用 ThreadLocal 记录嵌套深度,执行结束后会恢复现场。不要把它包在 Controller 大范围入口上,只能包住确实需要全量数据的内部查询。

请求或执行流程

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. CurrentUserDataScopeResolverEasySecurityContext 读取当前用户,计算 DataScopeCondition
  7. DataScopeSqlRewriter 把原 SQL 包成子查询,再追加外层条件。
  8. 分页插件在数据权限插件之后执行,保证分页总数和列表数据使用同一套范围条件。

原理

数据权限组件分成两层:决策层和执行层。

决策层是 CurrentUserDataScopeResolver。它只回答一个问题:当前登录人对“带组织归属的数据”能看到什么范围。它不关心具体表名,也不拼 SQL。

执行层是 EasyDataScopeInnerInterceptorDataScopeSqlRewriter。Mapper 用 @DataScope 告诉组件“这个查询结果里哪个字段代表部门,哪个字段代表本人”。拦截器在 SQL 执行前读取注解和权限范围,然后把原 SQL 改写成:

SELECT *
FROM (
    原始查询
) ea_ds
WHERE ea_ds.deptId IN (10, 20)

如果当前用户拥有全部数据权限,原 SQL 不改写。如果没有登录上下文、数据范围无法计算、本人范围但没有 selfColumn,组件默认追加 1 = 0,宁可查不到数据,也不误放开。

列名会经过白名单校验,只允许普通标识符,例如 dept_iddeptIdassignee_id。不允许 dept_id;drop table 这类拼接内容进入 SQL。

关键类、配置和表

类型 位置 作用
注解 DataScope 标记 Mapper 查询需要自动追加数据范围
字段常量 DataScopeColumns 提供常用外层字段名,如 deptIduserId
方法常量 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 下:

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,明确接口权限和数据权限的分工,避免二开时混用。