掘金 后端 ( ) • 2024-07-01 09:58

一、背景

在一些后端的小项目中,如果第一次涉及到数据权限,一般第一时间想到的就是直接硬代码编写,在查询语句中新增条件;这样做确实可以达到目的,快速上线,但是如果涉及到微服务分模块,就不得不在每个模块服务中进行改动,代码侵入性高,增加工作量。无疑不是最好的解决方案。

二、数据权限的设计

  • 对代码低侵入或零侵入性。
  • 减少硬编码,一键启停。

所有的数据权限最终转化都是对sql语句的执行进行拦截转换,对原始执行sql的条件上新增一个数据权限的过滤条件;就是要对哪个表字段要加什么条件。

资源:数据权限转化为要针对的表字段在这里定义为资源,例如:本人创建的可见,这里对应的要过滤的表字段可能就叫createUserId,那么createUser就是资源;再例如:本部门及以下部门可见,这里要过滤的表字段可能就叫createDeptId。

取值规则:如果资源是定位到要拦截sql的表字段,那么取值规则就理解为拼接sql时表字段=或in后面的内容;例如:本人创建的可见,那么取值规则就是=1(用户本人id),最终本人可见的数据权限拼接后的sql为createUserId=1。

通过资源与取值规则的组合,就可以做数据权限任何拦截sql的处理拼接。

三、详细设计

这里使用RBAC模型;基于角色(Role)的访问控制。

数据权限 (1).jpg

一、资源设计

资源的设计中,在对对应的表查询进行拦截处理时,考虑到数据权限大多数运用在对业务数据的查看上,不需要对所有应用到该表的查询进行处理,所以对资源再进行额外延伸,先是定位到页面请求的url,再是mapper查询对应的方法,最后再是数据库中的表。

二、取值规则

取值规则的设计:在数据权限中,运用的最多的都是部门公司级别的权限;所以在这里对取值规则设计了5个基本类型:本人可见、所在部门可见、所在部门及下级可见、指定值、自定义;在拼接sql时,先根据不同取值规则取出对应的内容,再进行拼接。

举例: 本人可见:那么就要拿到当前登录人的用户id,然后把这个用户id进行拼接,如何告诉执行程序从哪里获取到当前登录人的id呢?在这里使用了全路径类方法名,如果是使用的spring,那么就从spring获取对象执行对应方法获取到当前登录人id。所在部门可见、所在部门及下级可见同理。

指定值:指定值对于脱离公司部门的情况,例如我是A组的组长,我要看到A组下面所有人的订单信息,这个时候就可以使用指定值取值规则。

自定义:自定义的设计的可以是自定义sql,例如:只查看大额订单,就是金额在10万以上的订单,那么就可以使用自定义,设置自定义sql:order_amount>100000;自定义也可以自定义取值全路径类方法名,例如现在的基本类型中并没有公司及下级公司可见,那么就可以自定义取值全路径类名方法名获取当前登录人公司及下级所有公司的id。

三、sql执行拦截

以最主流的orm框架mybatis为例,做数据权限sql的拦截。

在mybatis中也提供了相关插件对执行sql前后做相应的处理。以下代码参照了mybatis pagehelper分页插件的写法。

@Intercepts(
    {
       @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
       @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
    }
)
@Slf4j
public class DataPermissionsHandlerInterceptor implements Interceptor {

    @Resource(name = "org.demo.common.permission.dialect.RedisMysqlDataPermissionDialect")
    private DataPermissionDialect dialect;

    @Override
    public Object intercept(Invocation invocation) throws InvocationTargetException, IllegalAccessException {
       try {
          Object[] args = invocation.getArgs();
          MappedStatement ms = (MappedStatement) args[0];
          Object parameter = args[1];
          RowBounds rowBounds = (RowBounds) args[2];
          ResultHandler resultHandler = (ResultHandler) args[3];
          Executor executor = (Executor) invocation.getTarget();
          CacheKey cacheKey;
          BoundSql boundSql;
          if (args.length == 4) {
             boundSql = ms.getBoundSql(parameter);
             cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
          } else {
             cacheKey = (CacheKey) args[4];
             boundSql = (BoundSql) args[5];
          }
          List<String> allTableName = null;
          Map<String, Map<String, DataPermissionGetValueVO>> dataPermission = null;
          return ExecuteSqlContext.doResponsibility(invocation, ms, parameter, rowBounds, resultHandler, cacheKey, boundSql, executor, allTableName, dialect, dataPermission);
       } catch (Exception e) {
          log.error("数据权限拦截sql时异常:{}", e);
          return invocation.proceed();
       }
    }

    @Override
    public Object plugin(Object target) {
       if (target instanceof Executor) {
          return Interceptor.super.plugin(target);
       }
       return target;
    }

    @Override
    public void setProperties(Properties properties) {
       Interceptor.super.setProperties(properties);
    }
}

四、当前用户数据权限的获取

一般情况下用户的权限信息都包含在用户的token当中;这里使用的redis存储用户数据权限。

根据当前业务场景,如何在redis中快速检索到当前用户的数据权限,判断当前要执行的sql是否要做数据权限的拼接。

在以上执行sql拦截的方法中,能直接获取到的当前用户id,请求url,执行的mapper类方法名;所以直接取以上3个数据作为redis的key,value使用hash类型存储,value的k为用户数据权限的表名,v为字段名及取值规则数据。中间需要一次redis的读取。

  1. sql拦截器进行拦截。
  2. 获取当前执行sql的所有表名。
  3. userId+mapper类方法名+当前请求url拼接redis的key,从redis中获取当前用户的数据权限value。
  4. 从value的k中获取数据权限的表名是否包含当前执行sql的表。
  5. 取出v中的数据转换实体,拼接sql。

五、一键启停(自动装配)

使用springboot自动装配,封装注解,把数据权限要使用到的对象的注入都放在注解里。@EnableDataPermission

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import({DataPermissionAutoImport.class})
public @interface EnableDataPermission {
}
public class DataPermissionAutoImport implements ImportSelector {

    /**
     * @param importingClassMetadata:
     * @return String
     * @description: 需要加入spring容器中的类
     */
    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
       return new String[]{"org.demo.common.permission.dialect.MySqlDataPermissionDialect",
          "org.demo.common.permission.dialect.RedisDataPermissionDialect",
          "org.demo.common.permission.dialect.RedisMysqlDataPermissionDialect",
          "org.demo.common.permission.interceptor.DataPermissionsHandlerInterceptor",
          "org.demo.common.permission.auto.ClassMethodGetValueBeanRegistry"};
    }

}

后续各个模块如需使用,直接启动类上加上@EnableDataPermission注解即可。

三、最后

这种设计适用于业务体量小的项目,业务体量一旦增大,用户量增多,场景复杂,数据权限在页面配置起来也会繁琐。(查询的数据权限都需要手动配置)

若有错误,还望批评指正