ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MyBatis数据权限控制实战:基于JSqlParser拦截SQL注入权限条件

MyBatis数据权限控制实战:基于JSqlParser拦截SQL注入权限条件 简介基于Mybatis实现数据权限控制的原理与落地方法文档适合使用Mybatis和MySQL的Java后端开发者尤其面向To B系统需要按角色动态过滤数据集的场景。文档先从认证与授权、RBAC模型谈起明确URL、界面元素和数据三类资源随后重点讲解依托Mybatis Plugin机制实现数据权限控制的过程包括拦截Executor、StatementHandler等接口借助jsqlparser解析SQL通过PermissionRule保存角色与过滤规则在查询执行前自动拼装条件做到无需也不能由研发编码控制。还对方案优缺点做了分析减少接口复杂度、提高灵活性但依赖Mybatis和特定数据库方言。包内为1个PDF文件大小93KB内容紧凑适合想快速了解思路的读者。该资源已有4592人学习可作为数据权限控制设计的参考。1. 为什么数据权限控制不能只靠菜单一次真实的“越权查询”翻车去年做的一个 To B 订单系统上线第二周运营反馈说“助理角色能导出全公司对账单”。排查下来菜单和按钮都控制住了但底层那个select * from order where status ?的接口助理角色一样能调只是前端按钮没显示而已。这就是 Java Mybatis 项目里最典型的数据权限漏洞前端菜单挡住的是入口挡不住数据本身。我们最终用 Mybatis 插件机制 jsqlparser 在 SQL 层做了数据权限控制同一个请求方法不同角色返回不同数据集而且研发代码里完全不用写if (role xxx)这种判断。这篇文章把这套方案的模型设计、拦截器实现、SQL 解析细节和踩过的坑完整拆一遍适合已经在用 Mybatis MySQL 的后端团队参考。2. 控制模型先行PermissionRule 的四个字段怎么设计、规则表怎么落库2.1 四个字段各管什么roles、fromEntity、exps、ruleComment数据权限控制的第一步不是写代码而是把规则模型定下来。我们项目里最核心的实体类是PermissionRule字段非常克制只有四个但每一个都直接对应 SQL 拼接的某个环节。public class PermissionRule { private static final Log log LogFactory.getLog(PermissionRule.class); /** * 适用角色列表 * 格式如: ,RoleA,RoleB, */ private String roles; /** * 主实体多表联合时匹配的表名 * 格式如: ,SystemCode,User, */ private String fromEntity; /** * 过滤表达式字段 * {uid} 替换为当前用户的 userId * {bid} 替换为当前用户的 businessId * {me} main entity 主实体名称 * {me.a} main entity alias 主实体别名 */ private String exps; /** * 规则说明 */ private String ruleComment; // getter / setter 省略 }这里最容易被忽略的是roles和fromEntity为什么设计成带前后逗号的字符串。因为匹配逻辑用的不是 equals而是indexOf(, roleStr ,)这样一条规则可以同时挂多个角色、多张表匹配时不会出现RoleA误伤RoleAB的问题。这种设计在做规则加载时非常省事一条配置就能覆盖“主管和经理都能看本部门订单”这种场景不用拆成多条记录。2.2 exps 表达式里的占位符约定{uid}、{bid}、{me}、{me.a}exps是整个模型的核心它本质上是一段 SQL 片段通过四个占位符在运行时被替换成真实值。表结构设计好之后我一般会先把规则表落库规则内容直接写在rule_config表里而不是写死在 Java 代码中这样权限调整时只需要 DBA 改一条配置不需要发版。举几个实际配置例子第一个是“用户只能看自己的订单”roles: ,ROLE_USER, fromEntity: ,sys_order, exps: create_user {uid}第二个是“部门主管可以看本部门订单但不能看其他部门”roles: ,ROLE_MANAGER, fromEntity: ,sys_order, exps: dept_id in (select dept_id from sys_dept where manager_id {uid})第三个是多表场景「订单主表 订单明细表」的 join 查询规则直接写在明细表的 on 条件上roles: ,ROLE_MANAGER, fromEntity: ,sys_order_detail, exps: {me.a}.create_user {uid}这里的{me}和{me.a}设计得非常巧妙。{me}表示主实体名称{me.a}表示主实体别名。SQL 解析时如果主表没写别名{me.a}就会被替换成表名本身从而避免出现sys_order.create_user 1这种带表名却漏掉别名的语法错误。实际写规则时我强烈建议统一给主表起别名否则 join 场景下条件拼接很容易翻车。提示所有占位符的替换逻辑都必须集中在 PermissionHelper 的同一个方法里完成不要散落到业务代码中否则后续排查“为什么这条 SQL 没注入权限条件”时会非常痛苦。2.3 规则如何加载与匹配从数据库到运行时筛选规则在系统启动时加载进内存运行时不查库否则每个请求都查一次权限表性能上完全扛不住。规则表我一般这样设计字段类型说明idbigint主键role_codesvarchar(255)角色编码前后带逗号entity_tablesvarchar(255)匹配的表名前后带逗号expr_sqltext过滤表达式含占位符rule_commentvarchar(255)规则说明statustinyint1 启用0 停用加载完规则后每次请求进来拦截器先拿当前用户的角色列表去匹配roles字段再拿 SQL 里解析出来的表名去匹配fromEntity字段两个条件同时命中的规则才参与条件拼接。注意一个用户可能命中多条规则所以PermissionHelper里接收的是一个ListPermissionRule循环遍历逐条匹配而不是只处理一条。这个加载逻辑看起来简单但实际项目里最容易出问题的恰恰是这里后面的避坑章节我会专门讲“多条规则互相覆盖”的坑。3. 拦截器落地基于 MyBatis Executor 插件注入权限条件3.1 插件签名与生命周期Intercepts 拦截 update 和 query先明确一点Mybatis 插件机制允许拦截 Executor、ParameterHandler、ResultSetHandler、StatementHandler 这四个接口的若干方法。数据权限控制要改的是最终执行的 SQL所以拦截点放在 Executor 上最关键。我们看一下 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 { // 逻辑在 intercept() 中处理 }Signature里的method update实际上对应 Mybatis 的更新操作Update、Delete 都走这个方法method query对应查询操作Select。拦截到之后我们拿到了MappedStatement、参数对象、RowBounds和ResultHandler其中MappedStatement里可以取出BoundSql进而取得原始 SQL 字符串。拿到 SQL 之后再根据SqlCommandType判断是查询还是更新分别走不同的处理方法。这里有一个实操细节要注意插件拦截到query时RowBounds参数用来支持分页如果项目里还接了 PageHelper两个插件会形成一条拦截链顺序不同结果完全不同。我的习惯是把数据权限插件放在 PageHelper 之前这样分页插件拿到的是已经注入完权限条件的 SQLcount查询也会自动带上权限条件否则会出现“列表带了权限过滤但总数没过滤”这种玄幻问题。3.2 processSelectSql 完整实现解析、匹配、替换、拼接核心处理逻辑就是这个processSelectSql方法它完成四件事解析 SQL、识别主表、匹配规则、注入条件。完整代码见下方这是我从线上项目里整理出来的版本去掉了部分业务无关的工具调用private String processSelectSql(String sql, ListPermissionRule rules, UserPrincipal principal) { try { String replaceSql null; // 1. 解析SQL为Select对象 Select select (Select) CCJSqlParserUtil.parse(sql); PlainSelect selectBody (PlainSelect) select.getSelectBody(); String mainTable null; // 2. 识别主表from 后面跟的是普通表还是子查询 if (selectBody.getFromItem() instanceof Table) { mainTable ((Table) selectBody.getFromItem()).getName().replace(, ); } else if (selectBody.getFromItem() instanceof SubSelect) { // 子查询作为主表时递归处理子查询内部的SQL replaceSql processSelectSql( ((SubSelect) selectBody.getFromItem()).getSelectBody().toString(), rules, principal); if (!StringUtils.isEmpty(replaceSql)) { sql sql.replace( ((SubSelect) selectBody.getFromItem()).getSelectBody().toString(), replaceSql); } } // 3. 获取主表别名 String mainTableAlias mainTable; try { mainTableAlias selectBody.getFromItem().getAlias().getName(); } catch (Exception e) { log.debug(当前sql中 mainTable 没有设置别名); } String condExpr null; // 4. 遍历规则匹配角色和主表 for (PermissionRule rule : rules) { for (Object roleStr : principal.getRoles()) { if (rule.getRoles().indexOf(, roleStr ,) -1) { continue; } // 4.1 主表匹配规则 if (rule.getFromEntity().indexOf(, mainTable ,) ! -1) { condExpr rule.getExps() .replace({uid}, principal.getUserId().toString()) .replace({bid}, principal.getBusinessId().toString()) .replace({me}, mainTable) .replace({me.a}, mainTableAlias); if (selectBody.getWhere() null) { selectBody.setWhere(CCJSqlParserUtil.parseCondExpression(condExpr)); } else { selectBody.setWhere(new AndExpression( selectBody.getWhere(), CCJSqlParserUtil.parseCondExpression(condExpr))); } } // 4.2 主表不匹配时匹配join表 try { for (Join j : selectBody.getJoins()) { if (rule.getFromEntity().indexOf(, ((Table) j.getRightItem()).getName() ,) ! -1) { String joinTable ((Table) j.getRightItem()).getName(); String joinTableAlias j.getRightItem().getAlias().getName(); condExpr rule.getExps() .replace({uid}, principal.getUserId().toString()) .replace({bid}, principal.getBusinessId().toString()) .replace({me}, joinTable) .replace({me.a}, joinTableAlias); if (j.getOnExpression() null) { j.setOnExpression(CCJSqlParserUtil.parseCondExpression(condExpr)); } else { j.setOnExpression(new AndExpression( j.getOnExpression(), CCJSqlParserUtil.parseCondExpression(condExpr))); } } } } catch (Exception e) { log.debug(当前sql没有join的部分); } } } // 5. 兼容分页写法 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; }这段代码有几个关键点需要重点说明。第一第 4.1 步用的是setWhereAndExpression效果是把权限条件作为额外的AND分支追加到原查询条件之后。比如原 SQL 是select * from sys_order where status 1注入后的结果是where status 1 and create_user 1001。如果原 SQL 没有where就直接setWhere(parseCondExpression(condExpr))。第二第 4.2 步处理的是from后面主表不匹配、但 join 表命中的情况。这里注入条件是加到on表达式上而不是 where 上这样在多表关联时能保证过滤条件作用于关联后的结果集不会因为 join 的语义差异导致数据漂移。注意joinTableAlias获取时getAlias().getName()没有做判空实际项目里 join 表没写别名时会抛 NPE被外层 catch 吞掉导致这条规则静默失效这个坑在避坑章节会再讲。第三第 5 步的分页兼容逻辑是给那些同时使用limit ?,?和LIMIT ? OFFSET ?两种分页风格的项目准备的。jsqlparser解析后输出的是标准写法LIMIT ? OFFSET ?但老版本的 Mybatis 分页参数绑定写的是limit ?,?两者混用会导致分页参数位置错乱。这里检测到原 SQL 带limit ?,?就强制将解析结果还原回这种写法。3.3 update/delete 语句怎么处理条件拼接的不同策略select 语句通过setWhere注入条件update 和 delete 就不能这么简单粗暴了。我们线上项目的常见做法是在 update/delete 的 where 后面也追加and (权限条件)但前提是必须保证原 SQL 有 where 子句。如果一个 delete 语句没有写 where权限插件一定不能自作主张给整表删除加条件否则会导致业务逻辑静默变更。常见处理方式是这样private String processUpdateSql(String sql, ListPermissionRule rules, UserPrincipal principal) { try { Statement statement CCJSqlParserUtil.parseStatements(sql).getStatements().get(0); if (statement instanceof Update) { Update update (Update) statement; String tableName update.getTable().getName().replace(, ); // 查找匹配该表的规则并拼接条件 for (PermissionRule rule : rules) { if (rule.getFromEntity().indexOf(, tableName ,) -1) { continue; } String condExpr rule.getExps() .replace({uid}, principal.getUserId().toString()) .replace({me}, tableName) .replace({me.a}, tableName); if (update.getWhere() null) { throw new RuntimeException(数据权限拦截失败update语句缺少where条件, table tableName); } update.setWhere(new AndExpression(update.getWhere(), CCJSqlParserUtil.parseCondExpression(condExpr))); return update.toString(); } } // delete 语句同理 } catch (JSQLParserException e) { log.error(change update sql error ., e); } return sql; }这里我故意让“无 where 的 update 抛出异常”而不是放行。因为这类 SQL 往往是代码里漏写了条件一旦放行并且被权限条件误伤就会变成“你以为删了一条实际删了全表”。宁可让运维在日志里看到异常也不能让权限插件把一个危险的 SQL 变成合法的批量操作。这个取舍在真实项目里救过我们一次。4. JSqlParser 在做什么主表、join 表与条件注入的细节4.1 为什么 SQL 解析选 JSqlParser文本替换的边界做数据权限控制之前最容易想到的方案是字符串匹配拿规则里的表名去sql.contains(tableName)判断然后直接在 sql 后面追加条件。这个方案在单表单条件时能用但一旦涉及多表 join、子查询、表别名就会全面崩盘。比如select * from sys_order o join sys_user u on o.user_id u.id如果规则是sys_user表你用字符串匹配会同时命中sys_order和sys_user最后把条件同时注入到两张表上数据直接被过滤没了。JSqlParser 的价值在于它把 SQL 拆成了结构化对象Select、PlainSelect、Table、Join、Expression。我们操作的是内存里的对象图而不是文本所以能精确知道哪张表是主表、哪张表是 join 表、当前 where 条件是什么、on 条件是什么。这也决定了方案的技术边界能力支持情况说明单表查询支持通过 from 识别表把权限条件追加到 where多表 join支持按 join 右表匹配规则条件加到 on嵌套子查询部分支持子查询作为主表时需递归处理有坑union 查询不支持解析结构无法简单映射为主表数据库函数/特殊语法不稳定某些 MySQL 方言会导致解析异常页面展示的这套方案只适配了 MySQL 的常见 SQL 形态如果业务里大量使用了union、FOR UPDATE、STRAIGHT_JOIN等特殊语法需要额外扩展解析逻辑否则就会走进避坑章节里说的“解析翻车”场景。4.2 主表与 join 表的匹配逻辑fromEntity 到底匹配谁规则配置里fromEntity写的是数据库表名而且是蛇形命名。比如实体类叫SysOrder数据库表是sys_order那fromEntity必须写成,sys_order,不能写驼峰格式因为解析器从 SQL 里拿到的就是表名不是实体类名。主表和 join 表的识别逻辑用一段代码来区分// from 后面直接跟的表是主表 if (selectBody.getFromItem() instanceof Table) { mainTable ((Table) selectBody.getFromItem()).getName().replace(, ); } // join 后面跟的表是 join 表 for (Join j : selectBody.getJoins()) { Table joinTable (Table) j.getRightItem(); String joinTableName joinTable.getName().replace(, ); }实际开发中最容易踩的坑是业务系统统一给所有 SQL 都加了反引号像select * from \sys_order where ...如果PermissionRule.fromEntity配置的是sys_order代码里不做replace(, )处理的话匹配永远失败。所以上面代码在拿到表名后的第一件事就是去掉反引号这个细节不写出来新接手的同事一定会在这个位置 debug 一整天。4.3 where 与 on 的条件合并AndExpression 的用法和坑条件合并的核心工具是AndExpression。它的语义是“两个条件同时成立”等价于 SQL 里的AND (expr1 AND expr2)。需要特别注意的是权限条件必须用括号包裹否则遇到多条件组合时会出现运算优先级错误。比如原 where 是status 1 OR type 2权限条件直接代入status 1 OR type 2 AND create_user 1001实际执行时因为 AND 优先级更高会先算type 2 AND create_user 1001导致查询结果把status 1 AND type ! 2的数据也放出来了。我们的处理方式是把权限表达式原样放入AndExpression并且要求配置的exps自带括号比如roles: ,ROLE_MANAGER, fromEntity: ,sys_order, exps: (dept_id {uid} OR dept_id in (select dept_id from sys_dept where manager_id {uid}))这类复杂表达式在加上AndExpression之后生成的 SQL 会是where status 1 AND (dept_id 1001 OR dept_id in (select dept_id from sys_dept where manager_id 1001))只要exps配置的表达式自身结构完整用AndExpression合并就不会出现语义变化。反过来如果你把exps写成dept_id {uid} OR dept_id in (...)这种不带括号的形式注入后的 SQL 语义就完全错了。所以规则配置必须约定“表达式自包含括号”在写入规则表时就校验格式不要等到运行时才发现。5. 避坑手册数据权限插件在真实项目里翻车的六个瞬间5.1 复杂 SQL 解析直接翻车union 和特殊函数让权限静默失效现象某条业务 SQL 用了union all合并两个查询上线后这条接口完全没有权限过滤任何角色都能查全量数据。排查时发现日志里没有抛异常但processSelectSql返回的还是原 SQL。原因JSqlParser 解析union时select.getSelectBody()返回的不是PlainSelect而是SetOperationList代码里直接强转(PlainSelect)抛了ClassCastException被最外层 catch 捕获后静默返回原 SQL。解决在强转前加类型判断遇到SetOperationList时拆出每个 Select 单独处理或者直接记录告警日志并返回原 SQL。我们的选择是拆出每个Select递归调用processSelectSql保证 union 的两个分支都注入条件前提是每条分支都要能匹配到规则。5.2 表名带别名后规则匹配不到现象SQL 写成select * from sys_order so where ...规则fromEntity配置的是,sys_order,但日志里解析出来的mainTable始终带别名匹配失败。原因selectBody.getFromItem()返回的Table对象里getName()返回的是表名getAlias()才是别名。但部分版本里如果表名和别名解析方式有差异直接getName().replace(,)之后拿到的可能是带 schema 前缀的完整名字比如db.sys_order。解决拿到表名后用.toLowerCase()并做一次split(\\.)取最后一段再参与规则匹配。规则配置统一小写避免大小写不一致导致indexOf失败。5.3 多条规则命中时后面的覆盖前面的现象用户同时拥有ROLE_MANAGER和ROLE_ADMIN配置表里两条规则都匹配sys_order表结果实际生效的只有其中一条另一条被静默覆盖。原因看processSelectSql的循环rules遍历中没有在匹配成功后break而是继续循环后面的规则再次命中时直接覆盖了condExpr变量但 SQL 只注入了一次。解决最稳妥的做法是“每条匹配成功的表只注入第一条规则并立即break”。如果确实需要多条规则的过滤条件取交集就把多个exps用AND拼接成一个完整的condExpr后再注入。线上项目里我们限定为一条规则命中后就短路规则设计上尽量合并同类项。5.4 分页插件导致 limit 参数错乱现象项目同时用了 PageHelper 和数据权限插件列表接口第 2 页起显示的数据错乱总数和明细对不上。日志里 SQL 变成了limit ? offset ?,?参数绑定明显错位。原因jsqlparser 解析后再生成 SQL 时会把limit ?,?改写成标准LIMIT ? OFFSET ?但 Mybatis 的BoundSql参数映射还停留在原来的limit ?,?占位符位置导致参数错位。代码里的第 5 步实际上就是在做这种兼容。解决保留limit ?,?与LIMIT ? OFFSET ?的互转逻辑同时把数据权限插件放在分页插件前执行确保分页插件解析到的已经是注入后的 SQL。上线前写一个专门的测试用例覆盖第 2 页、第 3 页的分页场景。5.5 子查询作为主表时规则完全失效现象SQL 是select * from (select * from sys_order where status 1) t配置的fromEntity匹配sys_order却始终不生效。原因fromItem不是Table而是SubSelect代码走的是递归处理分支mainTable仍然是 null后面主表匹配和 join 匹配全部跳过。解决子查询分支里不依赖mainTable而是递归处理子查询后把替换后的 SQL 整个写回原 SQL。要注意递归后sql.replace的字符串必须和原 SQL 完全一致包括空格和大小写否则替换不成功。注意这个坑在原文代码中就存在我在线上改造时把子查询分支单独抽出做了单元测试覆盖才彻底解决。以后凡是遇到“权限条件怎么也加不上”的问题先检查 SQL 里有没有嵌套子查询。5.6 {uid} 取不到或取错用户异步线程丢失登录上下文现象定时任务、MQ 消费线程里执行的 SQL 完全没有权限过滤或者过滤条件用的是上一个用户的信息。原因登录用户信息存在 ThreadLocal 里线程池中的线程复用时ThreadLocal 没有清理导致拿到脏数据。异步场景下新线程根本没有 set 过用户信息principal为 null。解决intercept()方法第一行就做空值校验拿不到用户上下文时直接抛业务异常不允许静默放行。使用线程池时在任务的 finally 块中清理ThreadLocal或者从请求头、MQ 消息体中显式传递 userId不依赖隐式上下文。集群环境下可以考虑把用户上下文放到 Redis按requestId存取。6. 验证方法改完代码怎么证明数据权限真的生效了6.1 前置条件把 MyBatis 日志打开SQL 原形毕露数据权限这种底层拦截肉眼是看不到效果的必须通过日志确认注入后的 SQL 长什么样。在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl运行后控制台会输出每条 SQL 的完整形式。构造一个ROLE_MANAGER的测试账号请求订单列表看到 SQL 里出现and create_user 1001这样的条件就说明插件生效了。6.2 典型测试用例同接口不同角色断言 SQL 的 where 部分推荐直接用现有单测框架写一个专门验证权限插件的用例。核心思路是同一个 Mapper 方法在两个线程里分别模拟不同角色的用户上下文调用一次查询然后用一个SQLInterceptor收集实际执行的 SQL断言 where 条件是否包含规则中的过滤片段。Test public void testDataPermission() { // 模拟两个角色的登录上下文 UserContext.set(UserContext.of(1001L, ,ROLE_MANAGER,)); ListOrder managerList orderMapper.selectList(new QueryWrapper()); UserContext.clear(); UserContext.set(UserContext.of(1002L, ,ROLE_USER,)); ListOrder userList orderMapper.selectList(new QueryWrapper()); UserContext.clear(); // 断言两个角色返回的数据集不同 Assertions.assertNotEquals(managerList.size(), userList.size()); }这一步能验证功能是否正确。下一步上线前检查清单检查项判断依据count 查询是否也带了权限条件日志中select count(*)是否包含注入条件分页插件是否生效第 2 页数据与总数一致join 查询的 on 条件日志中 join 的 on 后面是否追加了过滤条件子查询场景内层查询是否命中规则update/delete 无 where 是否被拦截是否有 RuntimeException 抛出6.3 压测与上线前检查清单插件是在 SQL 执行链路里做字符串解析和替换的jsqlparser 解析本身有开销。我们的压测数据显示在 QPS 3000 的接口上注入权限条件之后 P99 延迟增加约 8ms对绝大多数 To B 系统完全可接受。但如果你的系统是每秒上万 QPS 的 To C 场景建议在插件入口加一个开关通过配置中心动态开启和关闭压测时先全量放行确认瓶颈不在插件层再打开。还有一个习惯想分享从那以后我每次上线涉及权限的改动都会强制走一遍“两个角色跑同一个接口比对日志中 SQL 和返回数据集”的测试流程。数据权限这种功能一旦上线出问题不是功能不可用而是数据泄露性质完全不同。希望这套方案和踩坑记录能帮你把数据权限控制这条路走得稳一点。本文还有配套的精品资源点击获取
返回列表