Java利用Mybatis进行数据权限控制

权限控制主要分为两块,认证(Authentication)与受权(Authorization)。认证以后确认了身份正确,业务系统就会进行受权,如今业界比较流行的模型就是RBAC(Role-Based Access Control)。RBAC包含为下面四个要素:用户、角色、权限、资源。用户是源头,资源是目标,用户绑定至角色,资源与权限关联,最终将角色与权限关联,就造成了比较完整灵活的权限控制模型。
资源是最终须要控制的标的物,可是咱们在一个业务系统中要将哪些元素做为待控制的资源呢?我将系统中待控制的资源分为三类:html

  1. URL访问资源(接口以及网页)
  2. 界面元素资源(增删改查导入导出的按钮,重要的业务数据展现与否等)
  3. 数据资源

如今业内广泛的实现方案实际上很粗放,就是单纯的“菜单控制”,经过菜单显示与否来达到控制权限的目的。
我仔细分析过,如今你们作的平台分为To C和To B两种:前端

  1. To C通常不会有太多的复杂权限控制,甚至大部分连菜单控制都不用,所有均可以访问。
  2. To B通常都不是开放的,只要作好认证关口,可以进入系统的只有内部员工。大部分企业内部的员工互联网知识有限,并且做为内部员工不敢对系统进行破坏性的尝试。

因此针对如今的状况,考虑成本与产出,大部分设计者也不肯意在权限上进行太多的研发力量。
菜单和界面元素通常都是由前端编码配合存储数据实现,URL访问资源的控制也有一些框架好比SpringSecurity,Shiro。
目前我尚未找到过数据权限控制的框架或者方法,因此本身整理了一份。java

数据权限控制原理

数据权限控制最终的效果是会要求在同一个数据请求方法中,根据不一样的权限返回不一样的数据集,并且无需而且不能由研发编码控制。这样你们的第一想法应该就是AOP,拦截全部的底层方法,加入过滤条件。这样的方式兼容性较强,可是复杂程度也会更高。咱们这套系统中,采用的是利用Mybatis的plugin机制,在底层SQL解析时替换增长过滤条件。
这样一套控制机制存在很明显的优缺点,首先缺点:git

  1. 适用性有限,基于底层的Mybatis。
  2. 方言有限,针对了某种数据库(咱们使用Mysql),并且因为须要在底层解析处理条件因此有可能形成不一样的数据库不能兼容。固然Redis和NoSQL也没法限制。

固然,假如你如今就用Mybatis,并且数据库使用的是Mysql,这方面就没有太大影响了。github

接下来讲说优势:sql

  1. 减小了接口数量及接口复杂度。本来针对不一样的角色,可能会区分不一样的接口或者在接口实现时利用流程控制逻辑来区分不一样的条件。有了数据权限控制,代码中只用写基本逻辑,权限过滤由底层机制自动处理。
  2. 提升了数据权限控制的灵活性。例如本来只有主管能查本部门下组织架构/订单数据,如今新增助理角色,可以查询本部门下组织架构,不能查询订单。这样的话普通的写法就须要调整逻辑控制,使用数据权限控制的话,直接修改配置就好。

数据权限实现

上一节就说起了实现原理,是基于Mybatis的plugins(查看官方文档)实现。数据库

MyBatis 容许你在已映射语句执行过程当中的某一点进行拦截调用。默认状况下,MyBatis 容许使用插件来拦截的方法调用包括:
Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
ParameterHandler (getParameterObject, setParameters)
ResultSetHandler (handleResultSets, handleOutputParameters)
StatementHandler (prepare, parameterize, batch, update, query)缓存

Mybatis的插件机制目前比较出名的实现应该就是PageHelper项目了,在作这个实现的时候也参考了PageHelper项目的实现方式。因此权限控制插件的类命名为PermissionHelper。
机制是依托于Mybatis的plugins机制,实际SQL处理的时候基于jsqlparser这个包。
设计中包含两个类,一个是保存角色与权限的实体类命名为PermissionRule,一个是根据实体变动底层SQL语句的主体方法类PermissionHelper。数据结构

首先来看下PermissionRule的结构:mybatis

public class PermissionRule {

    private static final Log log = LogFactory.getLog(PermissionRule.class);
    /**
     * codeName<br>
     * 适用角色列表<br>
     * 格式如: ,RoleA,RoleB,
     */
    private String roles;
    /**
     * codeValue<br>
     * 主实体,多表联合
     * 格式如: ,SystemCode,User,
     */
    private String fromEntity;
    /**
     * codeDesc<br>
     * 过滤表达式字段, <br>
     * <code>{uid}</code>会自动替换为当前用户的userId<br>
     * <code>{me}</code> main entity 主实体名称
     * <code>{me.a}</code> main entity alias 主实体别名
     * 格式如:
     * <ul>
     * <li>userId = {uid}</li>
     * <li>(userId = {uid} AND authType > 3)</li>
     * <li>((userId = {uid} AND authType) > 3 OR (dept in (select dept from depts where manager.id = {uid})))</li>
     * </ul>
     */
    private String exps;

    /**
     * codeShowName<br>
     * 规则说明
     */
    private String ruleComment;

}

看完这个结构,基本可以理解设计的思路了。数据结构中保存以下几个字段:

  • 角色列表:须要使用此规则的角色,能够多个,使用英文逗号隔开。
  • 实体列表:对应的规则应用的实体(这里指的是表结构中的表名,可能你的实体是驼峰而数据库是蛇形,因此这里要放蛇形那个),能够多个,使用英文逗号隔开。
  • 表达式:表达式就是数据权限控制的核心了。简单的说这里的表达式就是一段SQL语句,其中设置了一些可替换值,底层会用对应运行时的变量替换对应内容,从而达到增长条件的效果。
  • 规则说明:单纯的一个说明字段。

核心流程
系统启动时,首先从数据库加载出全部的规则。底层利用插件机制来拦截全部的查询语句,进入查询拦截方法后,首先根据当前用户的权限列表筛选出PermissionRule列表,而后循环列表中的规则,对语句中符合实体列表的表进行条件增长,最终生成处理后的SQL语句,退出拦截器,Mybatis执行处理后SQL并返回结果。

讲完PermissionRule,再来看看PermissionHelper,首先是头:

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

头部只是标准的Mybatis拦截器写法,注解中的Signature决定了你的代码对哪些方法拦截,update实际上针对修改(Update)、删除(Delete)生效,query是对查询(Select)生效。

下面给出针对Select注入查询条件限制的完整代码:

private String processSelectSql(String sql, List<PermissionRule> rules, UserDefaultZimpl principal) {
        try {
            String replaceSql = null;
            Select select = (Select) CCJSqlParserUtil.parse(sql);
            PlainSelect selectBody = (PlainSelect) select.getSelectBody();
            String mainTable = null;
            if (selectBody.getFromItem() instanceof Table) {
                mainTable = ((Table) selectBody.getFromItem()).getName().replace("`", "");
            } else if (selectBody.getFromItem() instanceof SubSelect) {
                replaceSql = processSelectSql(((SubSelect) selectBody.getFromItem()).getSelectBody().toString(), rules, principal);
            }
            if (!ValidUtil.isEmpty(replaceSql)) {
                sql = sql.replace(((SubSelect) selectBody.getFromItem()).getSelectBody().toString(), replaceSql);
            }
            String mainTableAlias = mainTable;
            try {
                mainTableAlias = selectBody.getFromItem().getAlias().getName();
            } catch (Exception e) {
                log.debug("当前sql中, " + mainTable + " 没有设置别名");
            }


            String condExpr = null;
            PermissionRule realRuls = null;
            for (PermissionRule rule :
                    rules) {
                for (Object roleStr :
                        principal.getRoles()) {
                    if (rule.getRoles().indexOf("," + roleStr + ",") != -1) {
                        if (rule.getFromEntity().indexOf("," + mainTable + ",") != -1) {
                            // 若主表匹配规则主体,则直接使用本规则
                            realRuls = rule;

                            condExpr = rule.getExps().replace("{uid}", UserDefaultUtil.getUserId().toString()).replace("{bid}", UserDefaultUtil.getBusinessId().toString()).replace("{me}", mainTable).replace("{me.a}", mainTableAlias);
                            if (selectBody.getWhere() == null) {
                                selectBody.setWhere(CCJSqlParserUtil.parseCondExpression(condExpr));
                            } else {
                                AndExpression and = new AndExpression(selectBody.getWhere(), CCJSqlParserUtil.parseCondExpression(condExpr));
                                selectBody.setWhere(and);
                            }
                        }

                        try {
                            String joinTable = null;
                            String joinTableAlias = null;
                            for (Join j :
                                    selectBody.getJoins()) {
                                if (rule.getFromEntity().indexOf("," + ((Table) j.getRightItem()).getName() + ",") != -1) {
                                    // 当主表不能匹配时,匹配全部join,使用符合条件的第一个表的规则。
                                    realRuls = rule;
                                    joinTable = ((Table) j.getRightItem()).getName();
                                    joinTableAlias = j.getRightItem().getAlias().getName();

                                    condExpr = rule.getExps().replace("{uid}", UserDefaultUtil.getUserId().toString()).replace("{bid}", UserDefaultUtil.getBusinessId().toString()).replace("{me}", joinTable).replace("{me.a}", joinTableAlias);
                                    if (j.getOnExpression() == null) {
                                        j.setOnExpression(CCJSqlParserUtil.parseCondExpression(condExpr));
                                    } else {
                                        AndExpression and = new AndExpression(j.getOnExpression(), CCJSqlParserUtil.parseCondExpression(condExpr));
                                        j.setOnExpression(and);
                                    }
                                }
                            }
                        } catch (Exception e) {
                            log.debug("当前sql没有join的部分!");
                        }
                    }
                }
            }
            if (realRuls == null) return sql; // 没有合适规则直接退出。

            if (sql.indexOf("limit ?,?") != -1 && select.toString().indexOf("LIMIT ? OFFSET ?") != -1) {
                sql = select.toString().replace("LIMIT ? OFFSET ?", "limit ?,?");
            } else {
                sql = select.toString();
            }

        } catch (JSQLParserException e) {
            log.error("change sql error .", e);
        }
        return sql;
    }

重点思路
重点其实就在于Sql的解析和条件注入,使用开源项目JSqlParser

  • 解析出MainTable和JoinTable。from以后跟着的称为MainTable,join以后跟着的称为JoinTable。这两个就是咱们PermissionRule须要匹配的表名,PermissionRule::fromEntity字段。
  • 解析出MainTable的where和JoinTable的on后面的条件。使用and链接本来的条件和待注入的条件,PermissionRule::exps字段。
  • 使用当前登陆的用户信息(放在缓存中),替换条件表达式中的值。
  • 某些状况须要忽略权限,能够考虑使用ThreadLocal(单机)/Redis(集群)来控制。

结束语

想要达到无感知的数据权限控制,只有机制控制这么一条路。本文选择的是经过底层拦截Sql语句,而且针对对应表注入条件语句这么一种作法。应该是很是经济的作法,只是基于文本处理,不会给系统带来太大的负担,并且可以达到理想中的效果。你们也能够提出其余的看法和思路。

相关文章
相关标签/搜索