数据权限

目前文档内容对标 ballcat v1.1.0 以上版本

简介

为了数据安全与企业组织分工,有时会需要划分每个用户可见的数据范围,例如:

  • 普通的销售人员只能看到自己的数据

  • 地区的销售经理能看到当前地区的所有数据

  • 销售总监可以看到所有的销售数据

如果这些规则使用硬编码的方式进行处理,开发和维护成本极高。

而 Ballat 提供的数据权限组件只需进行少量的规则代码配置,即可实现数据权限控制

使用方式

依赖引入

  • 组件已经推送到中央仓库,直接引入即可使用:
<dependency>
  <groupId>com.hccake</groupId>
  <artifactId>ballcat-spring-boot-starter-datascope</artifactId>
  <version>${lastedVersion}</version>
</dependency>

数据规则定制

在使用数据权限之前,首先要定义自己项目的数据权限规则,在 Ballcat 中,数据权限规则对应的抽象表示为 DataScope

public interface DataScope {

	/**
	 * 数据所对应的资源
	 * @return 资源标识
	 */
	String getResource();

	/**
	 * 判断当前数据权限范围是否需要管理此表
	 * @param tableName 当前需要处理的表名
	 * @return 如果当前数据权限范围包含当前表名,则返回 true,否则返回 false
	 */
	boolean includes(String tableName);

	/**
	 * 根据表名和表别名,动态生成的 where/or 筛选条件
	 * @param tableName 表名
	 * @param tableAlias 表别名,可能为空
	 * @return 数据规则表达式
	 */
	Expression getExpression(String tableName, Alias tableAlias);

}
  • getResource()

    用于标识控制的数据资源,如按部门维护划分数据资源,则可以返回标识 “dept”,按班级维度划分,则可返回 “class”,这个标识会在部分需要进行数据权限忽略的场景使用

  • includes(tableName)

    根据传入的表名,判断是否需要进行数据权限控制,判断逻辑用户可以完全自定义,例如通过前缀匹配、正则匹配、全等判断等。

  • getExpression(tableName, tableAlias)

    返回数据权限的控制表达式,如用户 A 只能看到研发部的数据,则在 sql 中,应该追加 where 条件 dept = '研发部'

    方法返回类型 Expressionjsqlparser 工具对这个 Where 条件的 SQL 的抽象表示,返回 null 时则不拼接。

用户需要编写自己的 DataScope,并注册到 Spring 容器中,可以同时注册多个 DataScope 实例,以实现多个维度的数据权限规则。

使用示例

无法打开 github 的小伙伴可以去 gitee 镜像库上查看代码示例。

示例源码

本节的示例代码,可以在 ballcat-spring-boot-starter-datascopeopen in new window 模块的单元测试用例中查看。

更多示例

ballcat-sample-adminopen in new window 模块下有一个基于部门的完整数据权限使用示例

示例背景

数据结构

假设系统中只有一张学生表需要做数据权限控制,学生表的数据如下:

idnameclass_name
1张三一班
2李四一班
3王五二班
4老六三班

数据权限规则

在本示例中,有如下两个维度的数据权限规则:

  1. 针对于班级维度的数据资源:老师可以看到他所带的班级的所有数据,根据班级 class_name 划分
  2. 针对于学生维度的数据资源:学生只能看到他自己的数据,根据 id 划分

比如:

  • 对于只教授一班的老师 B,他登录系统后可以看到张三和李四的数据信息

  • 对于同时教授二班和三班的老师 A,他登录系统后可以看到王五和老六的数据信息

  • 对于学生王五,登录后只能看到王五本人的数据信息

登录用户

对于登录用户的数据信息抽象为一个 LoginUser 对象,在用户登录后将 LoginUser 的信息存储到 LoginUserHolder

@Data
public class LoginUser {

	/**
	 * 登录用户 id
	 */
	private Integer id;

	/**
	 * 用户角色: 老师或者学生
	 */
	private UserRoleType userRoleType;

	/**
	 * 当前登录用户所拥有的班级,只有老师才有此属性
	 */
	private List<String> classNameList;

}

DataScope 编写

针对两种数据维度的权限规则,我们只需要对应编写两个 DataScope,并注册到 spring 容器中,即完成了数据权限的控制处理。

班级维度

public class ClassDataScope implements DataScope {

	private final Set<String> tableNames = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);

	public ClassDataScope() {
		tableNames.addAll(Arrays.asList("h2student", "h2teacher"));
	}

	@Override
	public String getResource() {
		return "class";
	}

	@Override
	public boolean includes(String tableName) {
		// 使用 new TreeSet<>(String.CASE_INSENSITIVE_ORDER) 的形式判断,可忽略表名大小写
		return tableNames.contains(tableName);
	}

	@Override
	public Expression getExpression(String tableName, Alias tableAlias) {
        // 假设登录用户信息可以从内存中获取
		LoginUser loginUser = LoginUserHolder.get();

		// 如果当前登录用户为空,或者是老师,但是没有任何班级权限
		if (loginUser == null || (UserRoleType.TEACHER.equals(loginUser.getUserRoleType())
				&& CollectionUtils.isEmpty(loginUser.getClassNameList()))) {
			// where 1 = 2 永不满足
			return new EqualsTo(new LongValue(1), new LongValue(2));
		}

		// 如果是学生,则不控制,因为学生的权限会在 StudentDataScope 中处理
		if (UserRoleType.STUDENT.equals(loginUser.getUserRoleType())) {
			return null;
		}

		
		// 提取当前登录用户拥有的班级权限
        List<Expression> list = loginUser.getClassNameList().stream().map(StringValue::new)
            .collect(Collectors.toList());
        ExpressionList expressionList = new ExpressionList();
		expressionList.setExpressions(list);
        // 列名
        Column column = new Column(tableAlias == null ? "class_name" : tableAlias.getName() + "." + "class_name");
        // 条件:class_name in (xxx, xxx)
		return new InExpression(column, expressionList);
	}

}

学生维度

public class StudentDataScope implements DataScope {

	public static final String RESOURCE_NAME = "student";
	
	private static final Pattern TABLE_NAME_PATTEN = Pattern.compile("^h2student*$");

	@Override
	public String getResource() {
		return RESOURCE_NAME;
	}

	@Override
	public boolean includes(String tableName) {
		// 可以利用正则做匹配
		Matcher matcher = TABLE_NAME_PATTEN.matcher(tableName);
		return matcher.matches();
	}

	@Override
	public Expression getExpression(String tableName, Alias tableAlias) {
		LoginUser loginUser = LoginUserHolder.get();

		// 如果当前登录用户为空
		if (loginUser == null) {
			// where 1 = 2 永不满足
			return new EqualsTo(new LongValue(1), new LongValue(2));
		}

		// 如果是老师则直接放行
		if (UserRoleType.TEACHER.equals(loginUser.getUserRoleType())) {
			return null;
		}

		// 学生只能查到他自己的数据 where id = xx
		Column column = new Column(tableAlias == null ? "id" : tableAlias.getName() + "." + "id");
		return new EqualsTo(column, new LongValue(loginUser.getId()));
	}

}

权限测试

我们在 StudentService 中提供一个 listStudent() 方法,对应的查询 SQL 为 select * from h2student

在不同用户登录后调用同一个方法,会根据他们的角色以及拥有的班级返回不同的数据。

老师用户查询

void testStudentSelect1() {
    // 用户登录
    LoginUser loginUser = new LoginUser();
    loginUser.setId(10);
    loginUser.setUserRoleType(UserRoleType.TEACHER); // 教师
    loginUser.setClassNameList(Collections.singletonList("一班"));
    LoginUserHolder.set(loginUser);

    // 一班有两个学生:张三和李四
    List<Student> studentList1 = studentService.listStudent();
    Assertions.assertEquals(2, studentList1.size());
    Assertions.assertEquals("张三", studentList1.get(0).getName());
    Assertions.assertEquals("李四", studentList1.get(1).getName());

    // 切换登录用户所管理的班级
    loginUser.setClassNameList(Collections.singletonList("二班"));
    
    // 二班只有一个学生:王五
    List<Student> studentList2 = studentService.listStudent();
    Assertions.assertEquals(1, studentList2.size());
    Assertions.assertEquals("王五", studentList2.get(0).getName());
}

学生用户查询

void testStudentSelect2() {
    // 用户登录
    LoginUser loginUser = new LoginUser();
    loginUser.setId(1);
    loginUser.setUserRoleType(UserRoleType.STUDENT); // 学生
    LoginUserHolder.set(loginUser);

    // id 为 1 的学生叫 张三
    List<Student> studentList1 = studentService.listStudent();
    Assertions.assertEquals(1, studentList1.size());
    Assertions.assertEquals("张三", studentList1.get(0).getName());

    // 切换登录用户
    loginUser.setId(2);
    
    // id 为 2 的学生叫 李四
    List<Student> studentList2 = studentService.listStudent();
    Assertions.assertEquals(1, studentList2.size());
    Assertions.assertEquals("李四", studentList2.get(0).getName());
}

局部规则修改

默认注册的 DataScope 会对全局的所有 SQL 进行拦截处理。

对于不涉及到权限的表的 SQL,在第一次执行后将会被记录下来,后续直接跳过 SQL 处理,提升性能

而在这些使用场景下,我们需要进行局部的数据权限规则修改,例如:

  • 某些方法需要忽略数据权限控制
  • 配置了多个 DataScope,只想其中的某个规则生效
  • 配置了多个 DataScope,需要忽略其中的某个规则

声明式规则修改

@DataPermission 注解可标记在类或者方法上,用于动态控制数据权限

public @interface DataPermission {

	/**
	 * 当前类或方法是否忽略数据权限
	 * @return boolean 默认返回 false
	 */
	boolean ignore() default false;

	/**
	 * 仅对指定资源类型进行数据权限控制,只在开启情况下有效,当该数组有值时,exclude不生效
	 * @see DataPermission#excludeResources
	 * @return 资源类型数组
	 */
	String[] includeResources() default {};

	/**
	 * 对指定资源类型跳过数据权限控制,只在开启情况下有效,当该includeResources有值时,exclude不生效
	 * @see DataPermission#includeResources
	 * @return 资源类型数组
	 */
	String[] excludeResources() default {};
}

基本使用

// 该方法忽略数据权限控制
@DataPermission(ignore = true)
List<SysUser> listIgnoreDataPermission();

// 该方法执行时只会根据资源标识为 gender 和 class 的 DataScope 进行数据权限处理
@DataPermission(includeResources = {"gender", "class"})
List<SysUser> list1();
  
// 该方法执行时排除资源标识为 class 的 DataScope,不对其进行数据权限处理,其他 DataScope 不受影响
@DataPermission(excludeResources = "class")
List<SysUser> list2();

注解的查找规则

方法执行时,会按以下顺序依次查找可用的 @DataPermission 注解:

当前方法上的注解 => 超类方法上的注解 => 当前类上注解 => 超类上的注解

@DataPermission(includeResources = {"gender", "class"})
class A {
    
    test(){
        method1(); // 该方法只对 class 资源进行控制
        method2(); // 该方法只对 gender 资源进行控制
        method3(); // 该方法忽略所有的数据权限控制
        method4(); // 该方法只对 gender 和 class 进行数据权限控制
    }
    
    @DataPermission(includeResources = {"class"})
    method1(){}
    
    @DataPermission(includeResources = {"gender"})
    method2(){}
    
    @DataPermission(ignore = true)
    method3(){}
    
    method4(){}
    
}

局部规则的传递

当执行方法上根据注解的查找规则,没有查询到注解时,则会使用调用者的数据权限规则:

class A {

    @DataPermission(includeResources = {"gender"})
    test1(){
        B.method1(); // 该方法上有自己的 @DataPermission 注解,只会对 class 资源进行控制
        B.method2(); // 该方法则跟随 test() 方法的数据权限注解,只对 gender 资源进行控制
    };
    
    test2(){
        B.method1(); // 依然只会对 class 资源进行控制
        B.method2(); // 跟随全局的数据权限规则
    };

}

class B {
    @DataPermission(includeResources = {"class"})
    method1(){};
    
    method2(){};
}

编程式规则修改

从版本 v0.7.0 开始,DataPermissionHandler 提供了编程式的局部数据权限修改功能。

基本使用

@DataPermission 注解的属性对应,我们需要构建一个 DataPermissionRule 对象来标识当前的数据权限规则:

// 数据权限规则:忽略全部数据权限
DataPermissionRule dataPermissionRule = new DataPermissionRule(true);
// 或者
DataPermissionRule dataPermissionRule = new DataPermissionRule().setIgnore(true); 
// 数据权限规则:只根据班级维度查询
DataPermissionRule dataPermissionRule = new DataPermissionRule()
        .setIncludeResources(new String[] { "class" });
// 数据权限规则:不根据班级维度查询
DataPermissionRule dataPermissionRule = new DataPermissionRule()
        .setExcludeResources(new String[] { "class" });

在指定的规则下进行操作

// 在指定数据权限规则下进行查询
List<Student> studentList = null;
DataPermissionUtils.executeWithDataPermissionRule(dataPermissionRule,
				() -> studentList = studentService.listStudent());

嵌套使用

// 编程式数据权限,
DataPermissionRule dataPermissionRule = new DataPermissionRule()
        .setIncludeResources(new String[] { "class" });
DataPermissionUtils.executeWithDataPermissionRule(dataPermissionRule, () -> {
    // 编程式数据权限内部方法,根据 class 维度进行数据权限控制
    List<Student> studentList = studentService.listStudent();

    // 嵌套的权限控制: 内部忽略数据权限控制
    DataPermissionRule innerIgnoreRule = new DataPermissionRule(true);
    DataPermissionUtils.executeWithDataPermissionRule(innerRule, () -> {
        // 规则嵌套时,优先使用内部规则, 会忽略数据权限查询出全部学生
        List<Student> allStudent = studentService.listStudent();
    });
});

声明式和编程式的混合使用

混合使用时,权限规则按照由近及远的顺序查找:

方法内部的编程式规则 > 当前方法查找出来的注解规则 > 调用者的权限规则 > 全局规则

public class StudentService {

	public List<Student> listStudent() {}

    // 忽略权限控制
	@DataPermission(ignore = true)
	public List<Student> listStudentWithoutDataPermission() {}
    
}
@DataPermission(includeResources = {"gender"})
void testStudentSelect() {
    
    // 根据方法上的 @DataPermission 注解,只根据 gender 维度进行数据权限控制
    List<Student> studentList1 = studentService.listStudent();
    
    // 编程式数据权限规则
    DataPermissionRule dataPermissionRule = new DataPermissionRule()
            .setIncludeResources(new String[] { "class" });
    DataPermissionUtils.executeWithDataPermissionRule(dataPermissionRule, () -> {
        // 编程式数据权限内部方法,根据 class 维度进行数据权限控制
        List<Student> studentList2 = studentService.listStudent();

        // 由于 listStudentWithoutDataPermission 添加了 @DataPermission 注解
        // 会忽略权限控制,查询出所有的学生
        List<Student> allStudent = studentService.listStudentWithoutDataPermission();
    });
}