likes
comments
collection
share

通用数据权限设计与实现

作者站长头像
站长
· 阅读数 21

前言

在我们的业务系统中,除了菜单/功能权限外,还有一个非常重要的功能,就是数据权限。数据级权限管理,大多数采用的方案还是硬编码的方式,也就是将这种逻辑以if/else等方式与业务代码耦合在一起,按需做数据权限划分。本文这里采用的是另一种比硬编码相对优雅的方式:mybatis拦截器+spring aop。

技术基础

  • mybatis拦截器

    使用mybatis的自定义拦截器,我们可以对待执行的sql进行一层过滤、包装,如做sql日志记录、追加数据权限相关的sql等。

  • spring aop

    在不改变原代码的基础上增加功能或者横向相同功能抽取,具体实现形式就是代理,经典原型就是事物控制和日志,我们这里用来做数据权限控制。

设计思路

  • 数据权限粒度

    常规的业务系统,数据粒度主要分为如下四种:

    • 全部数据权限:即不做权限区分,记录都可查。
    • 部门数据权限:只能查看用户所在部门的数据
    • 部门及以下数据权限:可以查看用户所在部门及下属部门的数据
    • 仅本人数据权限:只能查看自己管辖的数据
    • 自定义数据权限:用于一个用户对应多个部门的场景
  • 业务表设计规范

    要想让业务数据有归属,则相关的业务表要有相关的字段:dept_id和user_id,即部门id和用户id,,如文章表:

    CREATE TABLE `cms_article` (
      `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
      `category_id` bigint(20) unsigned DEFAULT NULL COMMENT '栏目id',
      `title` varchar(120) NOT NULL COMMENT '标题',
      `description` varchar(255) DEFAULT NULL COMMENT '描述',
      `cover` varchar(255) DEFAULT NULL COMMENT '大图',
      `author` varchar(30) DEFAULT NULL COMMENT '作者',
      `source` varchar(64) DEFAULT NULL COMMENT '文章来源',
      `sort` double(10,2) unsigned DEFAULT '10.00' COMMENT '排序',
      `publish_time` datetime(3) DEFAULT NULL COMMENT '发布时间',
      `is_publish` tinyint(1) DEFAULT NULL COMMENT '是否发布(1->否|NO,2->是|YES)',
      `content` mediumtext COMMENT '文本内容',
      `dept_id` bigint(20) unsigned DEFAULT NULL COMMENT '所属部门',
      `user_id` bigint(20) unsigned DEFAULT NULL COMMENT '所属用户',
      `create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
      `update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
      `is_deleted` tinyint(1) unsigned DEFAULT '1' COMMENT '是否删除(1->未删除|NO,2->已删除|YES)',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
    
  • 数据权限粒度与查询

    • 全部数据

      select * from cms_article
      
    • 部门数据

      -- 需要传入当前用户所属部门id
      select * from cms_article a where a.dept_id = ?
      
    • 部门及以下数据

      -- 需要传入当前用户所属部门id及下属部门id
      select * from cms_article a where a.dept_id in (?,?,?,?)
      
    • 仅本人数据

      -- 需要传入当前用户id
      select * from cms_article a where a.user_id = ?
      
    • 自定义数据权限

      -- 需要传入当前用户id
      select * from cms_article a where a.dept_id in(select dept_id from sys_role_dept rd left join sys_user_role ur on rd.role_id=ur.role_id where ur.user_id=? group by dept_id)
      

      或者

      -- 需要传入录前用户角色id
      select * from cms_article a where a.dept_id in(select dept_id from sys_role_dept role_id in (?,?,?))
      

代码实现

目录结构

├── mldong-framework  框架
    ├── mldong-commom-base  基础模块
		├── src/main/java
      		└── com.mldong.common.dauth
				├── DataScope.java
				├── DataScopeConfig.java
				├── DataScopeHelper.java
				├── DataScopeInterceptor.java
				└── DataSopeAspect.java
		├── src/main/resource/META-INF/spring.factories

核心代码说明

  • DataSope.java

    package com.mldong.common.dauth;
    import java.lang.annotation.*;
    @Target({ ElementType.METHOD, ElementType.TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface DataScope {
        /**
         * 部门表的别名
         */
        public String deptAlias() default "";
        /**
         * 用户表的别名
         */
        public String userAlias() default "";
    }
    

    数据权限注解,加上注解的方法就会自动追加数据权限相关sql。

  • DataScopeHelper.java

    ThreadLocal线程数据共享工具,aop构造数据权限sql存入threadLocal,拦截器读取数据权限sql。

    package com.mldong.common.dauth;
    
    public class DataScopeHelper {
        private static final ThreadLocal<String> LOCAL_DATA_AUTH_SQL = new ThreadLocal();
        public static void setLocalDataAuthSql(String page) {
            LOCAL_DATA_AUTH_SQL.set(page);
        }
        public static String getLocalDataAuthSql() {
            return LOCAL_DATA_AUTH_SQL.get();
        }
        public static void clearDataAuthSql() {
            LOCAL_DATA_AUTH_SQL.remove();
        }
    }
    
  • DataSopeAspect.java

    aop实现,这里维护数据权限sql的生命周期,构造->销毁

    package com.mldong.common.dauth;
    @Aspect
    @Component
    public class DataSopeAspect {
        /**
         * 全部数据权限
         */
        public static final Integer DATA_SCOPE_ALL = 10;
    
        /**
         * 部门数据权限
         */
        public static final Integer DATA_SCOPE_DEPT = 20;
    
        /**
         * 部门及以下数据权限
         */
        public static final Integer DATA_SCOPE_DEPT_AND_CHILD = 30;
        /**
         * 仅本人数据权限
         */
        public static final Integer DATA_SCOPE_SELF = 40;
        /**
         * 自定义数据权限
         */
        public static final Integer DATA_SCOPE_CUSTOM = 50;
        // 加入@DataScope注解的方法执行前执行-用于构造数据权限sql
        @Before("@annotation(dataScope)")
        public void dataScopeBefore(JoinPoint point, DataScope dataScope) throws Throwable     {
            // 超级管理员,不处理
            if(RequestHolder.isSuperAdmin()) {
                return;
            }
    		// TODO 详见源码
        }
        
        
        // 加入@DataScope注解的方法执行完成后执行-用于销毁数据权限sql
        @After("@annotation(dataScope)")
        @AfterThrowing("@annotation(dataScope)")
        public void dataScopeAfter(JoinPoint point, DataScope dataScope) throws Throwable{
            if(StringTool.isNotEmpty(DataScopeHelper.getLocalDataAuthSql())) {
                // 执行完成,要清除当前权限Sql
                DataScopeHelper.clearDataAuthSql();
            }
        }
    
    }
    
  • DataScopeInterceptor.java

    数据权限mybatis拦截器

    package com.mldong.common.dauth;
    
    import com.mldong.common.tool.StringTool;
    import org.apache.ibatis.cache.CacheKey;
    import org.apache.ibatis.executor.Executor;
    import org.apache.ibatis.mapping.BoundSql;
    import org.apache.ibatis.mapping.MappedStatement;
    import org.apache.ibatis.mapping.ParameterMapping;
    import org.apache.ibatis.mapping.SqlSource;
    import org.apache.ibatis.plugin.*;
    import org.apache.ibatis.session.ResultHandler;
    import org.apache.ibatis.session.RowBounds;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    import java.util.Properties;
    
    @Intercepts({@Signature(
            type = org.apache.ibatis.executor.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}
    )})
    public class DataScopeInterceptor implements Interceptor {
        private static final Logger logger= LoggerFactory.getLogger(DataScopeInterceptor.class);
        @Override
        public Object intercept(Invocation invocation) throws Throwable {
            String dataAuthSql = DataScopeHelper.getLocalDataAuthSql();
            // 不为空才处理
            if(StringTool.isNotEmpty(dataAuthSql)) {
                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];
                }
                String newSql = boundSql.getSql() + dataAuthSql ;
                BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), newSql,
                        boundSql.getParameterMappings(), boundSql.getParameterObject());
                // 把新的查询放到statement里
                MappedStatement newMs = newMappedStatement(ms, new BoundSqlSqlSource(newBoundSql));
                for (ParameterMapping mapping : boundSql.getParameterMappings()) {
                    String prop = mapping.getProperty();
                    if (boundSql.hasAdditionalParameter(prop)) {
                        newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop));
                    }
                }
                return executor.query(newMs, parameter, rowBounds, resultHandler, cacheKey, newBoundSql);
            } else {
                return invocation.proceed();
            }
        }
    
        @Override
        public Object plugin(Object target) {
            return Plugin.wrap(target,this);
        }
    
        @Override
        public void setProperties(Properties properties) {
            logger.debug(properties.toString());
        }
    
        /**
         * 定义一个内部辅助类,作用是包装 SQL
         */
        class BoundSqlSqlSource implements SqlSource {
            private BoundSql boundSql;
            public BoundSqlSqlSource(BoundSql boundSql) {
                this.boundSql = boundSql;
            }
            public BoundSql getBoundSql(Object parameterObject) {
                return boundSql;
            }
    
        }
    
        private MappedStatement newMappedStatement (MappedStatement ms, SqlSource newSqlSource) {
            MappedStatement.Builder builder = new
                    MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
            builder.resource(ms.getResource());
            builder.fetchSize(ms.getFetchSize());
            builder.statementType(ms.getStatementType());
            builder.keyGenerator(ms.getKeyGenerator());
            if (ms.getKeyProperties() != null && ms.getKeyProperties().length > 0) {
                builder.keyProperty(ms.getKeyProperties()[0]);
            }
            builder.timeout(ms.getTimeout());
            builder.parameterMap(ms.getParameterMap());
            builder.resultMaps(ms.getResultMaps());
            builder.resultSetType(ms.getResultSetType());
            builder.cache(ms.getCache());
            builder.flushCacheRequired(ms.isFlushCacheRequired());
            builder.useCache(ms.isUseCache());
            return builder.build();
        }
    }
    
  • DataScopeConfig.java

    package com.mldong.common.dauth;
    
    import com.github.pagehelper.autoconfigure.PageHelperAutoConfiguration;
    import org.apache.ibatis.session.SqlSessionFactory;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.autoconfigure.AutoConfigureAfter;
    
    import javax.annotation.PostConstruct;
    import java.util.List;
    
    // 后添加先执行,所以要在mybatis分页插件之后配置拦截器
    // @AutoConfigureAfter注解需与spring.factories配合才生效
    @AutoConfigureAfter(PageHelperAutoConfiguration.class)
    public class DataScopeConfig {
        @Autowired
        private List<SqlSessionFactory> sqlSessionFactoryList;
    
        @PostConstruct
        public void addDataAuthInterceptor() {
            DataScopeInterceptor interceptor = new DataScopeInterceptor();
            for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
                sqlSessionFactory.getConfiguration().addInterceptor(interceptor);
            }
        }
    }
    
    
  • spring.factories

    这里主要是调一下mybatis分页插件与自定义的数据权限插件的加载顺序

    org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    com.github.pagehelper.autoconfigure.PageHelperAutoConfiguration,\
    com.mldong.common.dauth.DataScopeConfig
    

使用样例

  • 在dao层中使用

    @Repository
    public interface CmsArticleDao {
        /**
         * 查询文章列表-数据权限
         * @param param
         * @return
         */
        @DataScope(deptAlias = "a", userAlias = "a")
        public List<CmsArticleWithExt> selectOnDataScope(CmsArticlePageParam param);
    }
    
  • 在service层中使用

    @Service
    public class CmsArticleServiceImpl implements CmsArticleService{
        @DataScope(deptAlias = "a", userAlias = "a")
        @Override
        public CommonPage<CmsArticleWithExt> listOnDataScope2(CmsArticlePageParam param) {
            Page<CmsArticleWithExt> page =param.buildPage(true);
            cmsArticleDao.selectWithExt(param);
            return CommonPage.toPage(page);
        }
    }
    

效果演示

小结

本文使用mybatis的拦截器机制配合aop实现了全局的数据权限处理,基本上能满足大多数有数据权限需求的业务。当然,如果是更为复杂的场景,如涉及到字段权限等的,可能就需要更为细粒化的设计了,这里暂时先不展开。

项目源码地址

  • 后端

gitee.com/mldong/mldo…

  • 前端

gitee.com/mldong/mldo…

相关文章

打造一款适合自己的快速开发框架-先导篇

打造一款适合自己的快速开发框架-后端脚手架搭建

打造一款适合自己的快速开发框架-集成mapper

打造一款适合自己的快速开发框架-集成swaggerui和knife4j

打造一款适合自己的快速开发框架-通用类封装之统一结果返回、统一异常处理

打造一款适合自己的快速开发框架-业务错误码规范及实践

打造一款适合自己的快速开发框架-框架分层及CURD样例

打造一款适合自己的快速开发框架-mapper逻辑删除及枚举类型规范

打造一款适合自己的快速开发框架-数据校验之Hibernate Validator

打造一款适合自己的快速开发框架-代码生成器原理及实现

打造一款适合自己的快速开发框架-通用查询设计与实现

打造一款适合自己的快速开发框架-基于rbac的权限管理

打造一款适合自己的快速开发框架-登录与权限拦截

打造一款适合自己的快速开发框架-http请求日志全局处理

打造一款适合自己的敏捷开发框架-上传模块设计与实现

打造一款适合自己的快速开发框架-持续部署之一键发布脚本设计与实现