前言

本章以下小节主要介绍了数据权限的基础知识、代码生成和配置操作。

  • 代码生成。
  • 启用数据权限。
  • 数据权限配置。
  • 数据过滤规则。

最后几小节则主要介绍了橙单数据权限的技术实现和开发调试过程中的注意事项。

  • 技术详解。
  • 开调试注意事项。
  • 部门关系同步。

代码生成

仅当为「应用」同时配置了「支持部门」和「支持数据权限」后,才会生成与之相关的代码,具体操作见下图,

生成后工程中,将会包含 common-datafilter 模块。同时也会为业务服务生成相关的配置项数据,见下图。

启用数据权限

在橙单中,我们为静态路由表单、在线表单、报表统计表单和流程工单等不同类型的表单页面,提供了统一的数据权限配置方式和过滤策略。

静态表单

在一切开始之前,我们要先介绍一下,如何开启某一业务主表的数据权限过滤功能,对于没有开启该功能的业务主表,其「删改查」操作,均不会受到数据权限的影响。在以下示例代码中,重点关注 @EnableDataPerm 注解的  excluseMethodName 参数。

// 使用@EnableDataPerm注解,标注主表对应的访问层接口。该接口中所有自定义的方法,
// 以及Mybatis Plus内置的方法,都会受到数据权限的过滤。如果希望排除某些方法,可
// 通过该注解的excluseMethodName参数指定,如方法 ”getCourseListWithoutDataPermFilter“。
@EnableDataPerm(excluseMethodName = {"getCourseListWithoutDataPermFilter"})
public interface CourseMapper extends BaseDaoMapper<Course> {

   // 获取过滤后的对象列表。
   List<Course> getCourseList(
          @Param("courseFilter") Course courseFilter, @Param("orderBy") String orderBy);

   // 因为在当前类注解@EnableDataPerm的excluseMethodName参数中指定了该方法,
   // 因此该方法将不会被数据权限过滤。
   List<Course> getCourseListWithoutDataPermFilter();
   
   // ... ... 这里忽略了其他的方法定义。
}

在实体对象中,指定需要参与数据权限过滤的「用户过滤字段」和「部门过滤字段」。

// UserFilterColumn 注解指定的字段,用于参与“仅看当前用户、本部门用户和本部门及子部门用户”的过滤策略。
// DeptFilterColumn 注解指定的字段,用于参与其他所有和部门相关的过滤策略。
@Data
@TableName(value = "zz_course")
public class Course {

   // 主键Id。
   @TableId(value = "course_id")
   private Long courseId;

   // 课程名称。
   @TableField(value = "course_name")
   private String courseName;

   // 主讲老师。
   @UserFilterColumn
   @TableField(value = "teacher_id")
   private Long teacherId;

   // 所属校区。
   @DeptFilterColumn
   @TableField(value = "school_id")
   private Long schoolId;
   
   // ... ... 这里省略其他字段的定义。
}

在线表单

在线表单的配置相对简单,只需配置如下图中的两个字段标记即可,且保存后即刻生效。

  • 配置用户过滤字段。
  • 配置部门过滤字段。

报表表单

报表内可能包含多个数据集数据,而橙单报表的数据权限是基于「数据集」的,因此需要为每个数据集配置指定的「用户过滤字段」和「部门过滤字段」,配置后即可生效。

流程工单

无论是在线表单还是静态表单的流程工单,最终都是基于 zz_flow_work_order 数据表。而该表所对应的实体类 (FlowWorkOrder) 和数据访问接口 (FlowWorkOrderMapper),均已配置好与数据权限相关的注解,因此流程工单无需任何额外的代码配置,即可被应用于数据权限的过滤功能。

// 可以看到流程工单的数据访问接口,已经被@EnableDataPerm注解标注,其内所有方法均会参数数据权限的过滤。
// 另外需要补充说明的是mustIncludeUserRule参数,应用于流程工单的数据权限中,无论是否包含“仅看当前用户”
// 数据权限,所有的流程发起者都能看到自己发起的工单。这个就是mustIncludeUserRule = true的功能含义。
@EnableDataPerm(mustIncludeUserRule = true)
public interface FlowWorkOrderMapper extends BaseDaoMapper<FlowWorkOrder> {

   // 获取过滤后的对象列表。
   List<FlowWorkOrder> getFlowWorkOrderList(
           @Param("flowWorkOrderFilter") FlowWorkOrder flowWorkOrderFilter, @Param("orderBy") String orderBy);
}

在如下代码中,我们为流程工单实体对象 FlowWorkOrder 分别指定了「用户过滤字段」和「部门过滤字段」。

@Data
@TableName(value = "zz_flow_work_order")
public class FlowWorkOrder {

   // 主键Id。
   @TableId(value = "work_order_id")
   private Long workOrderId;

   // 工单编码字段。
   @TableField(value = "work_order_code")
   private String workOrderCode;
   
   // 参数数据权限过滤的部门过滤字段。
   @DeptFilterColumn
   @TableField(value = "dept_id")
   private Long deptId;
   
   // 参数数据权限过滤的用户过滤字段。
   @UserFilterColumn
   @TableField(value = "create_user_id")
   private Long createUserId;
   
   // ... ... 这里省略其他字段的定义。
}

数据权限配置

如果同一用户的同一菜单包含多个数据过滤权限,多个过滤权限之间是「OR」的关系。

指定菜单

下图所配置的过滤规则,仅对选择的关联菜单生效。其他菜单不会受到该规则的影响。

全部菜单

这里需要重点解释一下,如上图所示的几个菜单,均配置了指定的数据权限,因此这几个菜单的数据过滤规则,不会被下图配置的数据权限所影响。只有那些没有配置任何指定数据权限的菜单,才会被该数据权限所过滤。

用户授权

如下图所示,userA 同时包含了三个数据权限,该用户的数据权限过滤规则如下。

  • 「甲方管理」和「产品管理」菜单拥有「只看当前用户」和「所在部门及子部门」的数据权限。
  • 「合同管理」菜单拥有「所在部门及子部门」的数据权限。
  • 其他菜单拥有「查看全部」的数据权限。

数据过滤规则

这里我们以橙单教学版工程中的 Course 课程对象为例,代码如下。

@Data
@TableName(value = "zz_course")
public class Course {

   // 主键Id。
   @TableId(value = "course_id")
   private Long courseId;

   // 课程名称。
   @TableField(value = "course_name")
   private String courseName;

   // 主讲老师。
   @UserFilterColumn
   @TableField(value = "teacher_id")
   private Long teacherId;

   // 所属校区。
   @DeptFilterColumn
   @TableField(value = "school_id")
   private Long schoolId;
   
   // ... ... 这里省略其他字段的定义。
}

查看全部数据

如果当前 SQL 操作所对应的数据权限中包含「查看全部数据」权限,根据短路优化原则,其他所有数据权限过滤条件均将被忽略。即仍然执行原有的 SQL 过滤条件,不会自动拼接任何新的「过滤从句」。

-- 最终执行的SQL,与原有SQL保持一致。
SELECT * FROM zz_course WHERE course_name like '%小学%'

仅看自己

只能查看被 @UserFilterColumn 注解标记的字段值等于当前用户的数据。

-- 原始SQL。
SELECT * FROM zz_course WHERE course_name like '%小学%'
-- 被数据权限自动拼接后的SQL
SELECT * FROM zz_course WHERE course_name like '%小学%' 
AND (teacher_id = 'loginUserId')

本部门全部用户

与「仅看自己」过滤策略一样,都是基于 @UserFilterColumn 注解标记的字段值进行过滤。不同的是,该过滤策略会查看「本部门内所有用户」的数据。

-- 原始SQL。
SELECT * FROM zz_course WHERE course_name like '%小学%'
-- 被数据权限自动拼接后的SQL。其中用户Id “1,2,3”,是当前用户所在部门所有用户的ID值。
-- 这里需要明确指出的是,这些userId列表的获取,都是在登录接口中实现并缓存到 Redis中的,后续代码直接使用即可。
SELECT * FROM zz_course WHERE course_name like '%小学%' 
AND (teacher_id IN (1, 2, 3))

本部门及子部门全部用户

与「仅看自己」过滤策略一样,都是基于 @UserFilterColumn 注解标记的字段值进行过滤。不同的是,该过滤策略会查看「本部门及子部门内所有用户」的数据。

-- 原始SQL。
SELECT * FROM zz_course WHERE course_name like '%小学%'
-- 被数据权限自动拼接后的SQL。其中用户Id “1,2,3,4,5,6,7,8”,是当前用户所在部门及其子部门所有用户的ID值。
-- 这里需要明确指出的是,这些userId列表的获取,都是在登录接口中实现并缓存到 Redis中的,后续代码直接使用即可。
SELECT * FROM zz_course WHERE course_name like '%小学%' 
AND (teacher_id IN (1, 2, 3, 4, 5, 6, 7, 8))

仅看所在部门

只能查看被 @DeptFilterColumn 注解标记的字段值等于当前用户所在部门的数据。

-- 原始SQL。
SELECT * FROM zz_course WHERE course_name like '%小学%'
-- 被数据权限自动拼接后的SQL
SELECT * FROM zz_course WHERE course_name like '%小学%' 
AND (school_id = 'loginUserDeptId')

仅看所在部门及子部门

和「仅看所在部门」规则相比,该规则不仅可以看到当前部门的数据,同时还能查看当前部门所有各级子部门的数据。在权限模块中,包含一张部门关系表 (zz_sys_dept_relation),该表主要用于维护部门间的层级结构关系,并将原有的树形部门关系列表化存储。下面是部门间的树状关系图。

├── dept-one
│       └── dept-two-A
│              └── dept-three-A
│              └── dept-three-B
│       └── dept-two-B
│              └── dept-three-C
│              └── dept-three-D
│              └── dept-three-E

下面是以上树形部门结构,在 zz_sys_dept_relation 表中存储的数据记录。

parentDeptId deptId
dept-one dept-one
dept-one  dept-two-A
dept-one  dept-three-A
dept-one  dept-three-B
dept-one dept-two-B
dept-one dept-three-C
dept-one dept-three-D
dept-one dept-three-E
dept-two-A dept-two-A
dept-two-A dept-three-A
dept-two-A dept-three-B
dept-two-B dept-two-B
dept-two-B dept-three-C
dept-two-B dept-three-D
dept-two-B dept-three-E
dept-three-A dept-three-A
dept-three-B dept-three-B
dept-three-C dept-three-C
dept-three-D dept-three-D
dept-three-E dept-three-E
-- 原始SQL。
SELECT * FROM zz_course WHERE course_name like '%小学%'
-- 被数据权限自动拼接后的SQL
-- EXISTS从句是数据权限添加的过滤条件。子查询将返回当前部门及其所有子部门的列表。
SELECT zz_course.* FROM zz_course 
WHERE course_name like '%小学%' AND
   -- 下面括号中的EXISTS从句,是数据权限拦截器自动添加的。
   (EXISTS (SELECT 1 FROM zz_sys_dept_relation WHERE
       zz_sys_dept_relation.parent_dept_id = 'loginUserDeptId'
       AND zz_course.school_id = zz_sys_dept_relation.dept_id))

自选部门及子部门

相比于「仅看所在部门及子部门」的过滤规则,该规则可以查看指定多个父部门及其子部门的数据。子部门的获取逻辑和「仅看所在部门及子部门」完全一致。具体可见如下 SQL。

-- 原始SQL。
SELECT * FROM zz_course WHERE course_name like '%小学%'
-- 被数据权限自动拼接后的SQL
-- EXISTS从句是数据权限添加的过滤条件。子查询将返回当前部门及其所有子部门的列表。
SELECT zz_course.* FROM zz_course 
WHERE course_name like '%小学%' AND
   -- 下面括号中的EXISTS从句,是数据权限拦截器自动添加的。
   -- 和「仅看所在部门及子部门」规则相比,parent_dept_id 不再是等于当前登录用户的所在部门Id,
   -- 而是改为指定的父部门Id列表了。
   (EXISTS (SELECT 1 FROM zz_sys_dept_relation WHERE
       zz_sys_dept_relation.parent_dept_id IN (1, 2, 3)
       AND zz_course.school_id = zz_sys_dept_relation.dept_id))

仅自选部门

和「仅看所在部门」规则相比,该规则可以看到指定部门列表的数据,但是不能看到他们子部门的数据。

-- 原始SQL。
SELECT * FROM zz_course WHERE course_name like '%小学%'
-- 被数据权限自动拼接后的SQL。
-- 下面的(1,2,3)就是自选部门Id。
SELECT * FROM zz_course WHERE course_name like '%小学%' 
AND (school_id IN (1, 2, 3))

技术详解

橙单数据权限的基本功能如下。

  • 现已支持路由表单、在线表单、流程工单、流程业务表单、报表打印和多租户等所有功能模块的数据权限过滤功能。
  • 数据权限支持多种过滤策略。如查看全部、仅当前用户、所在部门、所在部门及子部门、指定部门及子部门、自定义部门、本部门用户和本部门及子部门用户等。过滤规则的详解可参考开发文档 [数据权限管理章节的数据过滤规则小节](http://www.orangeforms.com/data-perm/#数据过滤规则)。
  • 数据权限的配置可精确到具体菜单,没有指定菜单的数据权限,则默认应用于全部菜单。
  • 数据权限防越权处理,所有 MENU_ID 与 URL 的关联关系,在用户登录时即以计算并缓存,因此防越权检测对系统性能的影响极小。
  • 所有与数据权限相关的数据,全部支持基于 Caffeine 的一级缓存,过期后可继续从二级缓存 Redis 中读取并同步回一级缓存。
  • 用户登录时即以完成过滤策略的合并与优化,并存入缓存,运行时可直接读取优化后的过滤数据,以提升 IO 和计算效率。
  • 支持基于 Mybatis 插件的统一拦截方式,由 JSqlParser 解析后,动态拼接数据权限的过滤从句。
  • 支持多种方式控制数据权限的启用和停止,如模块配置项、方法入口、局部代码块等,非常方便、直观且灵活。
  • 在多数据库架构中,支持部门及关联子部门数据的多库实时同步,从而可显著提升数据过滤效率,同时还能有效降低代码的复杂度。

表关系图

从下图可以看出,数据权限表 (zz_sys_data_perm) 与用户、部门和菜单等实体表,均保持了灵活的多对多关联关系。

登录授权

  • 本小节的代码会在 LoginController 类的 doLogin 登录接口中调用。
  • 我们先根据上面的数据表关系图,查询到当前用户所分配的全部数据权限数据。
  • 多个数据权限之间会存在合并优化的可能,比如「查看本部门」与「查看本部门及子部门」两个规则,合并后可以只保留后者。
  • 最后会将合并优化后的数据结构,保存到当前会话的缓存中,后续高频使用该数据时,可直接从缓存中获取优化后的过滤规则数据结构,从而保证运行时的效率最大化。

在登录时,我们会根据当前用户 ID,通过 zz_sys_dept_perm_user 多对多关联表,获取该用户的数据权限列表。同时还要获取与数据权限关联的部门 ID 和菜单 ID 数据列表。见如下 SQL。

SELECT
   zz_sys_data_perm.*,
   zz_sys_data_perm_dept.*,
   zz_sys_data_perm_menu.*
FROM
   zz_sys_data_perm_user
INNER JOIN
   zz_sys_data_perm ON zz_sys_data_perm_user.data_perm_id = zz_sys_data_perm.data_perm_id
LEFT JOIN
   zz_sys_data_perm_dept ON zz_sys_data_perm.data_perm_id = zz_sys_data_perm_dept.data_perm_id
LEFT JOIN
   zz_sys_data_perm_menu ON zz_sys_data_perm.data_perm_id = zz_sys_data_perm_menu.data_perm_id
WHERE 
   zz_sys_data_perm_user.user_id = #{userId}

查询后的 SQL 结果集,还需要做进一步的数据结构处理后,才能存入到当前会话的 Redis 缓存中。具体详见如下代码及关键性注释。

// 代码位于SysDataPermServiceImpl.java文件。该方法会调用上面的SQL,再将结果集格式化为可以缓存的
// 数据结构,并由当前对象的putDataPermCache方法,存入Redis缓存,以备数据权限过滤时使用。
// 该方法返回的数据结构为Map<MenuId, Map<RuleType, DeptIds>>。返回数据结构的讲解,可参考后面
// mergeAndOptimizeDataPermRule的方法详解。
@Override
public Map<String, Map<Integer, String>> getSysDataPermListByUserId(Long userId, Long deptId) {
   // 这里调用就是上面的SQL查询语句。
   List<SysDataPerm> dataPermList = sysDataPermMapper.getSysDataPermListByUserId(userId);
   // 这里要做一次数据归并,将每个数据权限关联的部门Id列表,压缩为一个逗号分隔的字符串,
   // 并赋值给数据权限的临时字段deptIdListString,以备后用。
   // 这里稍微解释一下为啥要存成逗号分隔的字符串,这是因为部门Id可能会在数据过滤条件中,
   // 以 IN (deptIdStringList) 的形式出现,这样就无需在数据过滤时做任何数据处理了,
   // 直接拿来就用了,从而降低运行时的性能开销。
   // 因为数据权限过滤是一个非常高频的调用,因此我们尽可能的优化每一处小细节。
   dataPermList.forEach(dataPerm -> {
       if (CollUtil.isNotEmpty(dataPerm.getDataPermDeptList())) {
           Set<Long> deptIdSet = dataPerm.getDataPermDeptList().stream()
                   .map(SysDataPermDept::getDeptId).collect(Collectors.toSet());
           dataPerm.setDeptIdListString(StrUtil.join(",", deptIdSet));
       }
   });
   // 因为我们的数据权限是精确到menuId的,所以menuIdMap这个数据结构的key是menuId。
   // 这里就需要遍历结果集,并按照menuId的粒度,去归类数据权限。
   Map<String, List<SysDataPerm>> menuIdMap = new HashMap<>(4);
   for (SysDataPerm dataPerm : dataPermList) {
       if (CollUtil.isNotEmpty(dataPerm.getDataPermMenuList())) {
           for (SysDataPermMenu dataPermMenu : dataPerm.getDataPermMenuList()) {
               menuIdMap.computeIfAbsent(dataPermMenu.getMenuId().toString(), 
                       k -> new LinkedList<>()).add(dataPerm);
           }
       } else {
           // 如果某个数据权限不是指定到菜单的,那么就是应用于所有其他菜单的数据权限
           // 这里我们用一个特殊的字符串 “AllMenuId” 作为该类数据权限的统一菜单Id了。
           menuIdMap.computeIfAbsent(ApplicationConstant.DATA_PERM_ALL_MENU_ID, 
                   k -> new LinkedList<>()).add(dataPerm);
       }
   }
   // 执行到这里,menuIdMap中包含了当前用户所关联的数据权限,会按照menuId进行分类,
   // 每个菜单Id可关联一到多个数据权限对象。对于没有指定菜单的数据权限,都归类到key = "AllMenuId"
   // 的特殊菜单中。
   // 这里的menuIdMap数据结构,对于实时数据过滤来说,仍然不是最优的数据结构。
   // 在下面的方法中,我们会更进一步的合并优化,每一个menuId所关联的数据过滤规则。
   return this.mergeAndOptimizeDataPermRule(menuIdMap, deptId);
}

在上面的代码中,我们已经将原始的结果集数据,进行了基于 menuId 的分类,为了使数据权限过滤在运行时,可以得到更高的执行效率,这里还需做更进一步的合并优化处理,因为很多规则之间,会存在一定的过滤条件重合。下面是针对每个 menuId 的具体合并优化规则。

  • 包含「查看全部」过滤权限时,合并后只保留该规则即可。
  • 同时包含「仅看自己」和「本部门所有用户」时,合并后仅保留后者。
  • 同时包含「仅看自己」和「本部门及子部门所有用户」时,合并后仅保留后者。
  • 同时包含「本部门所有用户」和「本部门及子部门所有用户」时,合并后仅保留后者。
  • 同时包含「仅看所在部门」和「仅看所在部门及子部门」数据权限时,合并后将只保留「仅看所在部门及子部门」数据权限,因为后者兼容了前者。
  • 同时包含「仅看所在部门及子部门」和「自选部门及子部门」数据权限时,如果自选的父部门列表中包含当前用户所在部门,合并后将只保留「自选部门及子部门」 数据权限。否则仍然仅保留「自选部门及子部门」数据权限,不同的是,自选父部门列表中将多出当前用户的所在部门 ID。
  • 同时包含「仅看所在部门」和「自选部门列表」数据权限时,如果自选部门列表中包含用户所在部门,合并后将只保留「自选部门列表」数据权限,否则仍然仅保留 「自选部门列表」数据权限,不同的是,自选部门列表中将多出当前用户的所在部门 ID
// 先重点介绍一下返回的数据结构Map<MenuId, Map<RuleType, DeptIds>>,该数据会被直接缓存到Redis中。
// 第一层级的分类是MenuId,这个很好理解,我们的数据权限是精确到菜单的。
// 第二层级的分类是RuleType,不同的规则产生不同的SQL过滤从句,其中与自选部门
private Map<String, Map<Integer, String>> mergeAndOptimizeDataPermRule(
       Map<String, List<SysDataPerm>> menuIdMap, Long deptId) {
   Map<String, Map<Integer, String>> menuResultMap = new HashMap<>(menuIdMap.size());
   // 为了更方便进行后续的合并优化处理,这里再基于菜单Id和规则类型进行分组。ruleMap的key是规则类型。
   for (Map.Entry<String, List<SysDataPerm>> entry : menuIdMap.entrySet()) {
       Map<Integer, List<SysDataPerm>> ruleMap = entry.getValue()
               .stream().collect(Collectors.groupingBy(SysDataPerm::getRuleType));
       Map<Integer, String> resultMap = new HashMap<>(ruleMap.size());
       menuResultMap.put(entry.getKey(), resultMap);
       // 如有有ALL存在,就可以直接退出了,没有必要在处理后续的规则了。
       if (ruleMap.containsKey(DataPermRuleType.TYPE_ALL)) {
           resultMap.put(DataPermRuleType.TYPE_ALL, "null");
           continue;
       }
       // 这里优先合并最复杂的多部门及子部门场景。
       // 在下面的私有方法中,会将 “仅看所在部门及子部门” 和 “自选部门及子部门” 两个过滤规则
       // 合二为一,仅仅保留前者,同时从ruleMap中移除后者。
       String deptIds = processMultiDeptAndChildren(ruleMap, deptId);
       if (deptIds != null) {
           resultMap.put(DataPermRuleType.TYPE_MULTI_DEPT_AND_CHILD_DEPT, deptIds);
       }
       // 合并当前部门及子部门的优化。
       // 在下面的代码块中,会将 “仅看所在部门” 和 “仅看所在部门及子部门” 两个过滤规则合二为一,
       // 仅仅保留后者,同时从ruleMap中移除前者。
       if (ruleMap.get(DataPermRuleType.TYPE_DEPT_AND_CHILD_DEPT) != null) {
           // 需要与仅仅当前部门规则进行合并。
           ruleMap.remove(DataPermRuleType.TYPE_DEPT_ONLY);
           resultMap.put(DataPermRuleType.TYPE_DEPT_AND_CHILD_DEPT, "null");
       }
       // 合并自定义部门了。
       // 在下面的私有方法中,会将 “仅看所在部门” 和 “自选部门列表” 两个过滤规则合二为一,
       // 仅仅保留后者,同时从ruleMap中移除前者。
       deptIds = processMultiDept(ruleMap, deptId);
       if (deptIds != null) {
           resultMap.put(DataPermRuleType.TYPE_CUSTOM_DEPT_LIST, deptIds);
       }
       // 最后处理当前部门和当前用户。
       if (ruleMap.get(DataPermRuleType.TYPE_DEPT_ONLY) != null) {
           resultMap.put(DataPermRuleType.TYPE_DEPT_ONLY, "null");
       }
       if (ruleMap.get(DataPermRuleType.TYPE_USER_ONLY) != null) {
           resultMap.put(DataPermRuleType.TYPE_USER_ONLY, "null");
       }
   }
   return menuResultMap;
}

在登录接口的最后,会调用 SysDataPermServiceImpl.java 中的如下方法,将之前 getSysDataPermListByUserId 方法返回的当前用户优化后的数据权限结构存入 Redis 缓存,以用于后续的数据权限过滤。

@Override
public void putDataPermCache(String sessionId, Long userId, Long deptId) {
   Map<String, Map<Integer, String>> menuDataPermMap = 
         this.getSysDataPermListByUserId(userId, deptId);
   if (menuDataPermMap.size() > 0) {
       // 当前sessionId会参与当前会话数据权限缓存键的计算。
       String dataPermSessionKey = RedisKeyUtil.makeSessionDataPermIdKey(sessionId);
       RBucket<String> bucket = redissonClient.getBucket(dataPermSessionKey);
       bucket.set(JSON.toJSONString(menuDataPermMap),
               applicationConfig.getSessionExpiredSeconds(), TimeUnit.SECONDS);
   }
}

防止数据越权

这里我们先介绍一下可能出现的数据越权行为。

  • 当前用户包含两个数据权限,其中一个是指向特定菜单的「仅看自己」的过滤规则,另一个是面向所有菜单的「查看全部」过滤规则。
  • 从上图两个数据权限的配置可以看出,用户 userA 在访问「产品管理」菜单所对应的接口时,数据过滤权限是「只看自己」,而访问其他菜单指向的接口时,其数据权限是「查看全部」。
  • 试想一下,如果前端在请求头中传给后台的 MenuId 不是「产品管理」菜单所对应的的 MenuId,然而实际访问的接口却是「产品管理」菜单所对应的查询接口。
  • 上一步的查询结果是显而易见的,userA 越权查询到了全部的「产品列表」数据。

下面我们介绍一下橙单是如何解决上述数据越权问题的。

  • 在用户登录时,我们已经查询到了用户菜单列表、操作权限列表、数据权限列表和系统白名单接口列表等数据。
  • 基于上一步获取的菜单和权限数据列表,我们可以推演出每一个菜单所关联的权限资源 (后台接口 URL) 数据列表。并将推演后的关联结果,存入 Redis 缓存。
  • 在进行权限过滤之前,MybatisDataPermInterceptor 中的统一拦截方法,会先获取当前请求头中的 MenuId 值,然后再到缓存中判断当前请求的 URL 是否与该菜单存在关联关系。这样就能彻底的解决了,因伪造 MenuId 而引发的数据越权访问问题。

统一数据过滤

这里我们先介绍一下,橙单数据基础架构中数据过滤权限的设计思路。

  • 代码低侵入性。我们通过 Mybatis 拦截器对象 (MybatisDataFilterInterceptor) ,对所有「删改查」语句进行统一的拦截,并根据当前用户在登录时缓存的过滤权限数据,为当前 SQL 动态添加数据权限过滤所需的 WHERE 从句。
  • 高可靠性。基于 JSqlParser 动态解析 SQL,相比于普通的字符串拼接,在处理相对复杂的 SQL 语句时,也能准确的将数据权限过滤从句自动拼接到当前 SQL 之中,
  • 高效率。所有数据权限所需数据,在用户登录时即以完成数据结构的合并与优化,并存入 Redis 缓存。在统一拦截时,会先从 Redis 中读取所需数据,然后再同步到基于 Caffeine 的一级缓存中,以此提升运行时的整体效率。
  • 灵活性。一个注解 @EnableDataPerm 即可标记该业务表是否支持数据权限过滤。@UserFilterColumn 和 @DeptFilterColumn 注解可以分别指定用户过滤字段和部门过滤字段。@DisableDataFilter 注解可以取消任何接口方法内的数据过滤能力,同时我们还支持对代码块级别的数据权限启停控制。

逻辑流程图

过滤代码详解

下面的代码,是将数据权限转换为 SQL 过滤从句的实现逻辑,如果同一菜单下存在多个数据权限,那么他们之间的转换结果会使用 OR 进行连接。

  • 「查看自己」过滤规则的实现方法。
// 下面的代码位于Mybatis拦截器对象MybatisDataFilterInterceptor.java文件中。 
private String processUserDataPermRule(
       ModelDataPermInfo info, Integer ruleType, String deptIds) {
   TokenData tokenData = TokenData.takeFromRequest();
   StringBuilder filter = new StringBuilder(128);
   String tableName = info.getMainTableName();
   if (StrUtil.isBlank(info.getUserFilterColumn())) {
       // 为了方便调试,如果运行时发现过滤没有生效,可以查看该警告级别的日志,
       // 用于数据权限配置相关问题的快速定位。
       log.warn("No UserFilterColumn for table [{}] but USER_FILTER_DATA_PERM exists !!!", tableName);
       return filter.toString();
   }
   if (properties.getAddTableNamePrefix()) {
       filter.append(info.getMainTableName()).append(".");
   }
   // 这里的逻辑就非常简单了,为实体对象中@UserFilterColumn注解标记的字段,
   // 动态添加 “= userId” 的过滤条件。
   filter.append(info.getUserFilterColumn()).append(" = ").append(tokenData.getUserId());
   return filter.toString();
}
  • 其他与「部门相关」过滤规则的实现方法。具体的逻辑细节,可参考以下代码中的注释说明。
// 下面的代码位于Mybatis拦截器对象MybatisDataFilterInterceptor.java文件中。 
private String processDeptDataPermRule(ModelDataPermInfo info, Integer ruleType, String deptIds) {
   StringBuilder filter = new StringBuilder(128);
   String tableName = info.getMainTableName();
   if (StrUtil.isBlank(info.getDeptFilterColumn())) {
       // 为了方便调试,如果运行时发现过滤没有生效,可以查看该警告级别的日志,
       // 用于数据权限配置相关问题的快速定位。
       log.warn("No DeptFilterColumn for table [{}] but DEPT_FILTER_DATA_PERM exists !!!", tableName);
       return filter.toString();
   }
   TokenData tokenData = TokenData.takeFromRequest();
   // “仅看所在部门” 过滤规则,只需要给@DeptFilterColumn注解标记的字段,
   // 添加 “ = deptId” 的过滤条件。
   if (ruleType == DataPermRuleType.TYPE_DEPT_ONLY) {
       if (properties.getAddTableNamePrefix()) {
           filter.append(info.getMainTableName()).append(".");
       }
       filter.append(info.getDeptFilterColumn())
               .append(" = ")
               .append(tokenData.getDeptId());
   } else if (ruleType == DataPermRuleType.TYPE_DEPT_AND_CHILD_DEPT) {
       // “仅看所在部门及子部门” 过滤规则相对复杂,当前部门Id的所有层级的子部门,
       // 通过zz_sys_dept_relation即可查询得出。如:
       // SELECT dept_id FROM zz_sys_dept_relation WHERE parent_dept_id = #{deptId}
       // zz_sys_dept_relation存在完整的索引,同时单条记录的字节数极小,因为与他关联,性能较高。
       // 该规则生成的过滤从句结果如下。
       // (EXISTS (SELECT 1 FROM zz_sys_dept_relation WHERE
       //         zz_sys_dept_relation.parent_dept_id = tokenData.getDeptId()
       //         AND ${table}.${dept_id} = zz_sys_dept_relation.dept_id))
       // 在上面的SQL示例中,
       // ${table} 表示当前的业务主表。
       // ${dept_id} 表示实体类中被@DeptFilterColumn,注解标记字段所对应的列名。
       // tokenData.getDeptId() 表示获取当前登录用户部门Id的方法。
       filter.append(" EXISTS ")
               .append("(SELECT 1 FROM ")
               .append(properties.getDeptRelationTablePrefix())
               .append("sys_dept_relation WHERE ")
               .append(properties.getDeptRelationTablePrefix())
               .append("sys_dept_relation.parent_dept_id = ")
               .append(tokenData.getDeptId())
               .append(" AND ");
       if (properties.getAddTableNamePrefix()) {
           filter.append(info.getMainTableName()).append(".");
       }
       filter.append(info.getDeptFilterColumn())
               .append(" = ")
               .append(properties.getDeptRelationTablePrefix())
               .append("sys_dept_relation.dept_id) ");
   } else if (ruleType == DataPermRuleType.TYPE_MULTI_DEPT_AND_CHILD_DEPT) {
       // “自选部门及子部门” 过滤规则,和上面的 “仅看所在部门及子部门” 过滤规则基本一致。
       // 只是获取部门Id的方式,不再是通过tokenData.getDeptId()方法获得,而是从当前
       // 数据权限规则的deptIds中读取。deptIds字段的拼接,是在登录时刻完成并缓存的,这
       // 里只需直接使用即可,从而在高频的数据过滤调用中,节省了字符串拼接的额外性能开销。
       filter.append(" EXISTS ")
               .append("(SELECT 1 FROM ")
               .append(properties.getDeptRelationTablePrefix())
               .append("sys_dept_relation WHERE ")
               .append(properties.getDeptRelationTablePrefix())
               .append("sys_dept_relation.parent_dept_id IN (")
               .append(deptIds)
               .append(") AND ");
       if (properties.getAddTableNamePrefix()) {
           filter.append(info.getMainTableName()).append(".");
       }
       filter.append(info.getDeptFilterColumn())
               .append(" = ")
               .append(properties.getDeptRelationTablePrefix())
               .append("sys_dept_relation.dept_id) ");
   } else if (ruleType == DataPermRuleType.TYPE_CUSTOM_DEPT_LIST) {
       // “自选部门” 过滤规则,和 “仅看所在部门” 基本一致,都是不需要关心子部门Id的
       // 数据了。唯一的差别就是获取部门Id的方式,也不是通过tokenData.getDeptId()
       // 方法获取了,而是从当前数据权限规则的deptIds中读取。deptIds字段的拼接,是
       // 在登录时刻完成并缓存的,这里只需直接使用即可,从而在高频的数据过滤调用中,节
       // 省了字符串拼接的额外性能开销。
       if (properties.getAddTableNamePrefix()) {
           filter.append(info.getMainTableName()).append(".");
       }
       filter.append(info.getDeptFilterColumn())
               .append(" IN (")
               .append(deptIds)
               .append(") ");
   }
   return filter.toString();
}

开发调试注意事项

本小节将从代码使用视角,介绍一些在开发过程中经常遇到的二次开发问题。

手动添加接口

  • 首先必须要配置数据权限,并将数据权限分配给指定用户。
  • 在指定业务主表的访问层接口中,添加 @EnableDataPerm 注解。
  • 在实体类中,分别通过 @DeptFilterColumn 和 @UserFilterColumn 注解标记部门过滤字段和用户过滤字段。
  • 上一步的字段注解,也可以在生成器中直接配置,并生成相应的代码。

禁用数据权限

这里有个重要的前提是,已经正常启用并配置了数据权限。然后只是想在特殊的场景局部禁用数据权限过滤。

  • 排除 Mapper 中指定的访问方法。
  • 禁用指定 Controller 接口内,所有数据表操作的数据权限过滤。
  • 代码块级别的控制,可临时性的局部禁用某一条数据库操作的数据权限过滤。

数据权限未生效

用户偶尔会问,为啥数据权限过滤没有起作用。这里我们主要通过分析 MybatisDataFilterInterceptor 中的拦截代码,看一下有哪些逻辑分支会导致 intercept 方法中途返回,没有执行到最后,为当前 SQL 语句自动注入数据权限的过滤从句。

  • 是否打开工程的全局配置。具体示例可参考下图中的后一张截图。
  • 是否临时禁用了数据权限过滤。具体示例可参考下图中的后两张截图。

  • 管理员缺省是全部数据权限。
  • 当前方法是 @EnableDataPerm 注解中排除的方法。
// getCourseList方法将不会被自动注入数据权限过滤从句。
@EnableDataPerm(excluseMethodName = {"getCourseList"})
public interface CourseMapper extends BaseDaoMapper<Course> {

   // 批量插入对象列表。
   void insertList(List<Course> courseList);

   // 获取过滤后的对象列表。
   List<Course> getCourseList(
           @Param("courseFilter") Course courseFilter, @Param("orderBy") String orderBy);
}
  • 指定了「仅看自己』、「本部门所有用户」和「本部门及子部门所有用户」的过滤规则,但是在当前实体对象中,没有用 @UserFilterColumn 注解标记用户过滤字段。
// 在下面的实体类定义中,我们指定 teacher_id 字段作为数据权限中,"仅看自己" 规则的过滤字段。
@Data
@TableName(value = "zz_course")
public class Course {

   // 主键Id。
   @TableId(value = "course_id")
   private Long courseId;
   
   // 主讲老师。
   @UserFilterColumn
   @TableField(value = "teacher_id")
   private Long teacherId;    
}
  • 指定了「部门相关」的过滤规则,但是在当前实体对象中,没有用 @DeptFilterColumn 注解标记部门过滤字段。
// 在下面的实体类定义中,我们指定 school_id 字段作为数据权限中,"部门相关" 规则的过滤字段。
@Data
@TableName(value = "zz_course")
public class Course {

   // 主键Id。
   @TableId(value = "course_id")
   private Long courseId;
   
   // 所属校区。
   @DeptFilterColumn
   @TableField(value = "school_id")
   private Long schoolId;
}
  • 用户被分配多个数据权限,其中之一为「查看全部」,根据短路优化原则,就无需再注入任何数据权限过滤条件了。

数据权限异常

本小节我们将主要介绍数据权限中最典型的一个配置错误。

前端提示 No Related DataPerm found for menuId [ xxxxx ] and SQL_ID [ xxxxx ].

具体错误原因,请仔细阅读以下代码中的关键注释说明。

// 抛出异常的代码位于Mybatis拦截器类MybatisDataFilterInterceptor的getAndVerifyMenuDataPerm方法。
private JSONObject getAndVerifyMenuDataPerm(JSONObject allMenuDataPermMap, String sqlId) {
   String menuId = ContextUtil.getHttpRequest().getHeader(ApplicationConstant.HTTP_HEADER_MENU_ID);
   if (menuId == null) {
       menuId = ContextUtil.getHttpRequest().getParameter(ApplicationConstant.HTTP_HEADER_MENU_ID);
   }
   if (BooleanUtil.isFalse(properties.getEnableMenuPermVerify()) && menuId == null) {
       menuId = ApplicationConstant.DATA_PERM_ALL_MENU_ID;
   }
   Assert.notNull(menuId);
   // 根据前端请求头中的MenuId,查询当前用户对于该MenuId指向的菜单,是否分配了数据权限。
   // allMenuDataPermMap中的数据,是在用户登录时读取并存入redis缓存的。该数据结构以menuId为key,进行了分组。
   JSONObject menuDataPermMap = allMenuDataPermMap.getJSONObject(menuId);
   // 如果对于MenuId指定的菜单没有找到分配的数据权限,此时就会降级查看,当前用户是否包含任何没有指定任何菜单的数据权限。
   // 上面这句话有些拗口,后面的例子会给出详细的说明。
   if (menuDataPermMap == null) {
       // 对于任何没有绑定到指定菜单的数据权限,如果给当前用户分配了,这里就可以找到。
       menuDataPermMap = allMenuDataPermMap.getJSONObject(ApplicationConstant.DATA_PERM_ALL_MENU_ID);
   }
   // 如果menuDataPermMap == null条件成立,说明该用户的数据权限配置存在问题。
   if (menuDataPermMap == null) {
       throw new NoDataPermException(StrFormatter.format(
               "No Related DataPerm found for menuId [{}] and SQL_ID [{}].", menuId, sqlId));
   }
   // ... ... 省略其余不相关的代码实现。
}

下面我们介绍一下通过配置修复该问题的几种方式。

  • 假设 MenuId 所对应的菜单是下图中的「产品管理」,现在我们为该菜单指定了过滤规则「仅看自己」。配置后再将该数据权限指定给报错的用户,用户重新登录后,即可正常访问「产品管理」菜单内的所有页面,其过滤规则为「仅看自己」。
  • 如果因业务需要,无法为上图的「产品管理」菜单配置特定的过滤规则,那么则可以配置一个适用于所有菜单的数据权限,如下图所示的「查看全部」数据权限。配置后再将该数据权限指定给报错的用户,用户重新登录后,即可正常访问「产品管理」菜单内的所有页面,其过滤规则为「查看全部」。

部门关系同步

主要的应用场景如下。

  • 系统中包含多个业务数据库,每个业务数据库中都包含一定数量的业务表。
  • 每个数据库中都有部分业务表存在数据权限过滤的需求,并且还是基于「所在部门及子部门」和「自选部门及子部门」等与部门相关的过滤规则。
  • 以上两种过滤,都会依赖部门关联关系表 (zz_sys_dept_relation) ,数据权限拦截器会自动添加基于该表的过滤从句。见如下 SQL 示例。
SELECT zz_course.* FROM zz_course 
WHERE course_name like '%小学%' AND
   -- 下面括号中的EXISTS从句,是数据权限拦截器自动添加的,
   -- 可以看到是依赖了部门关联关系表(zz_sys_dept_relation)
   (EXISTS (SELECT 1 FROM zz_sys_dept_relation WHERE
       zz_sys_dept_relation.parent_dept_id = 'loginUserDeptId'
       AND zz_course.school_id = zz_sys_dept_relation.dept_id))
  • 通常情况下,只有用户权限服务 (UPMS) 所在的业务数据库才会包含该表 (zz_sys_dept_relation),由此可见,其他业务数据库是没法正确执行以上 SQL 的。
  • 为了让所有业务数据库都能执行以上 SQL,即业务表的「删改查」语句被注入数据权限过滤从句后,仍然能正确的执行。我们需要在所有的业务数据库中都创建该表 (zz_sys_dept_relation)。
  • 部门的「增删改查」操作接口仍然由 UPMS 服务提供。然而一旦出现部门「增删」,或修改部门的「上级部门」时,就需要将变化的部门关联关系数据,实时同步到所有业务数据库中的 zz_sys_dept_relation 表。
  • 数据同步过程中,要务必保证消息发送与用户操作的顺序完全一致,消费端同样要严格按照该顺序执行数据同步逻辑,否则就会产生后果极为严重的数据不一致问题。比如我先增加一个部门,然后修改了该部门的上级部门,那么如果同步顺序与之相反,后果可想而知。
  • 最后再次强调,在整个同步过程中,从部门操作事务的发生,到生产者消息的投递,以及消费者的最终同步,无论中间环节出现任何系统故障,都必须要保证数据的最终一致性。

代码生成

生产者实现

生产者只能是用户权限服务 (UPMS),因为部门的「增删改查」等业务接口都是由该服务提供的。下面的代码截图,我们仅以「添加新部门」为例。

  • 可以看到 saveNew 是事务方法,该方法在将部门及部门关联数据保存到本地数据库后,会调用 common-data-sync 基础模块中的 sendOrderly 方法。
  • 橙单数据同步模块 common-datasync 中的 sendOrderly 方法,会保证部门数据的操作与消息发送的顺序完全一致,同时还会保证在同一事务内完成,即成功同时提交,失败同时回滚。
  • 有关数据同步组件 common-datasync 的实现细节,可参考开发文档 [实时数据同步章节](http://www.orangeforms.com/data-sync/)。

消费者实现

在本例中,我们选择了两个目标数据库。因此在生成后的 upms-dept-sync 模块中,就会生成两个消费者类,分别用于向不同的目标数据库进行数据同步。这样有任何一个目标数据库出现故障时,都不会影响到其他消费者线程的数据同步。故障数据库恢复后,与其对应的消费者线程也会自动恢复并完成数据补偿,整个过程都会与生产者发送的顺序完全一致。

结语

赠人玫瑰,手有余香,感谢您的支持和关注,选择橙单,效率乘三,收入翻番。