动态数据权限控制:Spring Boot + MyBatis-Plus 生产级落地方案

📅 发布时间:2026/9/1 6:59:12
动态数据权限控制:Spring Boot + MyBatis-Plus 生产级落地方案 数据权限这玩意儿做业务迭代时最容易背锅安全审计时又最先被查。跟菜单按钮那种“功能权限”不一样数据权限是长在业务逻辑里的强依赖上下文、规则天天变、跨表关联一多就乱。早年靠硬拼WHERE dept_id ?或者写死在 XML 里的做法放到现在多租户、矩阵型组织、SaaS 化的系统里基本就是给自己埋雷。这套方案是我们在线上系统里迭代了三四版后沉淀下来的基于 Spring Boot 3.x MyBatis-Plus 3.5不搞花架子重点说怎么把行级拦截、列级可见、规则热更新和复杂场景跑稳。为什么别再往业务代码里塞 WHERE 条件了痛点就四个SQL 耦合到发指权限逻辑散落在各个 DAO 层WHERE条件复制粘贴改一个漏三个后期根本没人敢动。上下文绑定太深同一个接口销售看自己的经理看部门的总监看全局的。静态 SQL 根本兜不住非得在 Service 层搞一堆if-else。多表 JOIN 直接翻车子查询、别名复用、自关联一上动态拼的条件经常张冠李戴轻则报Unknown column重则查出隔壁租户的数据线上直接 P0。监控盲区条件加进去了到底命中没越权拦截了没延迟加了多少没统一埋点全靠猜。演进路线早期靠硬编码后来试过数据库视图和存储过程部署太耦合换库直接歇菜。AOP 解析 XML 追加条件在过渡期凑合但一碰原生分页就炸。现在基本都收敛到ORM 拦截器 策略引擎这条路上。把权限逻辑下沉到数据访问层业务代码零侵入后期规则调整也不用改一行 Java 代码。架构怎么搭RBAC 打底ABAC 动态收敛纯 RBAC角色控制现在根本不够用组织架构一变角色就得重构。实际生产环境都是RBAC 划基线 ABAC属性做动态收敛。模型设计思路RBAC 基线角色决定基础可见范围比如SELF本人、DEPT本部门、GLOBAL全量。ABAC 动态属性结合用户实时上下文部门树路径、项目组、临时标签、时间窗口、数据密级做二次过滤。策略表达式用 JSON 或 SpEL 描述灵活度高。比如{row_policy:userId #currentUser.id || deptId in #currentUser.deptScope,col_policies:{salary:roles contains HR || roles contains MANAGER,id_card:roles contains AUDITOR audit_time now()}}规则下发与缓存权限规则要是每次查库都去拉数据库迟早被拖垮。我们用的是本地 Caffeine Redis 二级 Pub/Sub 广播的组合。策略中心变更后走 Redisdata-perm:update:*频道发广播。应用实例收到消息直接cache.invalidate(ruleId)清本地缓存下次查询自然走 Redis 或 DB 拉新。版本对比靠version字段增量更新。极端情况下缓存挂了降级直接查 DB 兜底策略保可用性。线上别搞强一致性权限场景 AP 优先最终一致完全够用。核心代码行级拦截与列级脱敏3.1 行级拦截器MyBatis-Plus JSqlParserMP 3.5 提供了InnerInterceptor我们继承JsqlParserSupport重写beforeQuery。注意别直接操作原始 SQL 字符串靠 JSqlParser 转 AST 再拼接才是正路不然单引号、括号优先级能折腾死人。Slf4jComponentRequiredArgsConstructorpublicclassDynamicDataPermInterceptorextendsJsqlParserSupportimplementsInnerInterceptor{privatefinalPermissionRuleLoaderruleLoader;// ThreadLocal 必须随请求清理防止线程池复用导致上下文串流privatestaticfinalThreadLocalPermissionContextCONTEXTnewThreadLocal();OverridepublicvoidbeforeQuery(Executorexecutor,MappedStatementms,Objectparameter,RowBoundsrowBounds,ResultHandlerresultHandler,BoundSqlboundSql){PermissionContextctxCONTEXT.get();if(ctxnull||ctx.isGlobalAdmin())return;StringoriginalSqlboundSql.getSql();DataPermissionRuleruleruleLoader.getRule(ctx.getUserId(),ctx.getRequestUri());if(rulenull)return;// JSqlParser 解析并注入条件StringfinalSqlprocessSelect(originalSql,rule);// 替换 SQL。底层建议用 MetaObject 安全替换避免反射在不同 MP 版本下踩坑MetaObjectmetaBoundSqlSystemMetaObject.forObject(boundSql);metaBoundSql.setValue(sql,finalSql);}OverrideprotectedvoidprocessSelect(Selectselect,intindex,Stringsql,Objectobj){DataPermissionRulerule(DataPermissionRule)obj;if(!(select.getSelectBody()instanceofPlainSelectplainSelect))return;FromItemfromItemplainSelect.getFromItem();if(!(fromIteminstanceofTablemainTable))return;StringaliasmainTable.getAlias()!null?mainTable.getAlias().getName():mainTable.getName();ExpressionwhereExprbuildPermissionExpression(alias,rule);if(whereExpr!null){ExpressionoriginalWhereplainSelect.getWhere();// 用 Parenthesis 包一层防止 AND/OR 优先级混乱plainSelect.setWhere(newParenthesis(newAndExpression(originalWhere,whereExpr)));}}privateExpressionbuildPermissionExpression(StringtableAlias,DataPermissionRulerule){ColumncolnewColumn(tableAlias.rule.getColumn());ExpressionListExpressionvaluesnewExpressionList();rule.getScopeValues().forEach(v-values.add(newStringValue(String.valueOf(v))));returnnewInExpression(col,newParenthesis(values));}// 外部调用DynamicDataPermInterceptor.CONTEXT.set(ctx);}代码说明JsqlParserSupport内部会帮我们处理 SQL 到Select对象的转换直接拿PlainSelect改就行。Parenthesis包裹是必须的不然A AND B OR C这种逻辑一注入执行计划全乱。用MetaObject替ReflectUtil改BoundSql更稳MP 官方对 MetaObject 的支持是长期兼容的。3.2 列级可见性与脱敏列级权限别去改 SQLSELECT *改起来麻烦还影响执行计划。生产环境推荐Jackson 序列化拦截在对象转 JSON 时动态裁剪或脱敏。对 DB 零压力改规则不用重启。DatapublicclassUserDTO{DataPermissionColumn(codesalary,visibleRoles{HR,FINANCE})privateBigDecimalsalary;DataPermissionColumn(codephone,maskTypeMaskType.MIDDLE)privateStringphone;}注册BeanSerializerModifier接管序列化流程ComponentpublicclassDataPermJsonSerializerModifierextendsBeanSerializerModifier{OverridepublicListBeanPropertyWriterchangeProperties(SerializationConfigconfig,BeanDescriptionbeanDesc,ListBeanPropertyWriterbeanProperties){returnbeanProperties.stream().map(writer-{DataPermissionColumnannwriter.getAnnotation(DataPermissionColumn.class);if(ann!null){returnnewDataPermAwareWriter(writer,ann);}returnwriter;}).collect(Collectors.toList());}}DataPermAwareWriter.serialize里读PermissionContext角色不匹配直接jgen.writeNull()或者写脱敏后的字符串。注意这套只适用于 JSON 接口返回如果是导出 Excel 或对接第三方 XML得另写结果集过滤逻辑别指望一套代码全吃。线上踩坑实录JOIN 错乱、缓存与性能多表 JOIN 的坑JSqlParser 解析FROM时遇到子查询、视图、多重 JOIN拿到的第一个表不一定是你想加权限的主表。盲目加条件直接Unknown column或者查错数据。怎么解只认标记了DataPermissionTable的实体白名单过滤不在名单里的直接放行。初始化时扫一遍TableInfoHelper把实体类和表名/别名做映射字典。复杂报表三五个 JOIN 起步别硬上拦截器建议走查询后内存过滤或者数仓层预计算。拦截器不是万能的适可而止。加个降级开关perm.injection.fail-fastfalse解析报错打 WARN 日志放行核心交易链路不能因为权限解析挂了。热更新一致性全量刷缓存太吃 CPU线上做过压测规则量大时一刷新 GC 直接报警。改用增量 Diff 灰度推送。策略中心只推变更的ruleId应用端invalidate单个 key。配合 SpringScheduled每 5 分钟兜底扫一遍防 Pub/Sub 消息丢失。这套跑了一年多没出过权限不一致的客诉。性能损耗到底有多大不开拦截是基线。开启行级列级后单次请求多出来的耗时大概在 1.5 到 3 毫秒。主要花在 JSqlParser 构建 AST 和 Jackson 序列化遍历上。线上缓存命中率过 90% 后这损耗基本感知不到。优化点就两个一是给SQL - AST套个 Guava 缓存相同模板只解析一次二是拦截器前置判断没注解的方法或白名单 URL 直接 return别傻乎乎走一遍解析流程。1 万 QPS 下 P99 延迟涨幅不到 2%CPU 多占 4% 左右业务侧完全能接受。实战跑通一个 SaaS 客户隔离场景需求普通销售只看自己创建的客户。部门经理看本部门及下级部门。临时授权A 销售能看 B 部门数据 48 小时到期自动失效。规则配置YAML 简化版data-perm:rules:sales:table:customercolumn:creator_idscope:SELFmanager:table:customercolumn:dept_idscope:DEPT_TREEtemp_grant:table:customercolumn:dept_idscope:CUSTOM_LISTttl:48hController 透传上下文GetMapping(/api/customer/list)publicPageCustomerVOlist(HttpServletRequestreq){PermissionContextctxnewPermissionContext();ctx.setUserId(TokenUtil.getCurrentUserId());ctx.setDeptTreePath(UserDeptCache.getPath(TokenUtil.getCurrentDeptId()));// 临时授权走 Redis ZSet靠 score 控过期时间ctx.setTempGrantDepts(tempPermClient.getValidDepts(ctx.getUserId()));DynamicDataPermInterceptor.CONTEXT.set(ctx);try{returncustomerService.page(newPage(1,10));}finally{DynamicDataPermInterceptor.CONTEXT.remove();}}拦截器跑完SQL 自动变成SELECT*FROMcustomerWHERE(dept_idIN(101,102)ORdept_idIN(999))ANDtenant_idT001ORDERBYcreate_timeDESC业务代码干干净净权限逻辑全在底层兜底。老鸟建议别追求完美先求稳做数据权限记住几条铁律零侵入是底线。业务代码只透传意图上下文别在 Service 里拼条件。默认拒绝Deny-by-Default。拦截失败直接抛异常或返回空绝不允许静默放行。越权比查不到数据严重得多。可观测性必须跟上。traceId、ruleId、注入后的 SQL、耗时全部打日志推 ELK。线上排查越权没日志就是盲人摸象。别在拦截器里调 RPC。拿权限规则必须走本地缓存网络抖动一次你的查询全挂。字符串拼接 SQL 是红线。100% 用 JSqlParser API。拼错一个括号线上直接 SQL 注入漏洞或者语法报错。复杂报表别死磕拦截器。查询后过滤、数宽表预聚合怎么稳怎么来。拦截器适合标准 CRUD不适合 OLAP。定期清临时授权。ZSet 过期了逻辑上清缓存里也要定时扫防止权限无限膨胀。权限这东西没有一劳永逸的方案。它是一套持续治理的工程体系。先把基座搭稳规则能热更新出了问题能追踪剩下的慢慢迭代。别一上来就想搞 OPA、Rego 语言那一套先把 ThreadLocal 清理干净、缓存别雪崩、SQL 别拼错比什么都强。边界划清楚了业务跑起来才敢放开手脚。 福利时间如果你正在备战面试或者想要学习其他知识给大家推荐一个宝藏知识库作者整理了一些列 Java 程序员需要掌握的核心知识有需要的自取不谢。知识库地址https://farerboy.com/