后台系统最怕什么?不是并发扛不住,也不是慢SQL查不出来,而是用户明明只该看自己负责的那三十条工单,结果通过一个列表查询,把全公司三十万条工单的明细全拉走了。上个月我们刚处理过一起这种问题:销售部普通专员在订单管理页面点了一下“导出”,拿到的Excel里包含所有大区的客户,查了权限配置,菜单权限、按钮权限全都没问题,问题就出在数据层没有任何行级隔离。
数据权限控制,说到底是把“一条SQL能返回哪些行”这件事从业务代码里解耦出来,让它成为一套独立、可统一治理的规则。很多团队一提权限就默认是RBAC、菜单、接口鉴权,但这些只解决“你能不能点某个入口”的问题。进入业务操作之后,真正决定系统安全边界的,反而是每一行数据是否按照“当前登录人是谁、属于哪个部门、什么角色、什么数据范围”被正确过滤。
这篇文章会从一次实际改造讲起:数据权限控制的整体分层思路,怎么和Spring Boot、MyBatis这类常见框架集成,以及在落地过程中踩过的坑和沉淀下来的排查方法。适合后台管理系统、中后台SaaS产品的后端开发、架构师参考。
1. 从业务痛点谈起:功能权限之外,为什么还需要一套数据权限控制
1.1 数据权限和功能权限的边界
功能权限和数据权限经常混在一起聊,但它们在实现上完全是两码事。功能权限管的是“端点”,比如某个用户能不能访问订单管理页、能不能点击删除按钮;数据权限管的是“行”,同一个订单列表页,销售专员看自己的订单,销售主管看本部门的订单,大区经理看整个大区的订单。
功能权限可以用接口拦截、注解鉴权、菜单配置来完成,粒度到URL和按钮就够了。数据权限则必须下沉到数据访问层,否则再严格的接口鉴权也拦不住一个拼接不当的查询。最常见的翻车姿势是先查出所有数据再在Java内存里按部门过滤。这种写法在演示环境没问题,但数据量一旦上来,全表扫描加内存过滤会让数据库和JVM一起遭殃,而且分页会变得极其难做——你还没过滤完,页数就已经不对了。
我在改造之前先划清了一条线:功能权限由统一网关和Spring Security处理,数据权限由专门的数据权限引擎处理,两者互不替代。网关管不了SQL返回的行数,SQL拦截器也代替不了登录认证。边界清楚了,才不会在方案设计阶段陷入“我到底该在哪一层做权限”的争论。
1.2 常见实现路线:ORM插件、SQL改写、应用层过滤
业界做数据权限,大致有三条路,我仔细对比过它们的优劣势。
第一种是直接在业务SQL里手写权限条件,比如每个Mapper的查询都手动拼上WHERE dept_id = ?。看起来直接,但问题很快会暴露:几十个Mapper、几百个查询,每个开发对“本部门”的理解不一样,有人用dept_id = 当前部门,有人用dept_id IN (子部门),还有人漏加了条件。结果是权限规则散落在各处,审计无从下手,越权只是时间问题。
第二种是应用层过滤,先查出所有数据再在代码里筛选。这种方案只适合数据量小、无分页的管理端小表格,不适用于生产核心列表。数据量一上去,数据库压力成倍增长,而且统计报表会连同不该有的数据一起统计,结果不可信。
第三种是基于ORM拦截器做SQL改写,这也是我最终采用的方案。业务Mapper里只需要写正常的业务查询,数据权限引擎在SQL执行前自动解析当前用户的规则,把权限条件拼接到原SQL后。这样权限规则收敛到一处,新接一个查询不需要开发记“别忘了加权限”,框架层面就能兜底。
1.3 我想要的系统边界与核心目标
动手之前,我给这套系统定了四个目标,后面所有设计决策都对标这四个目标来验证。
- 可复用:权限规则不能绑死在某一套业务表上,换项目、换库表时规则模型还能用。
- 可运维:规则调整不需要改代码发版,管理员在后台配置就能生效。
- 可追溯:每一条被拦截的SQL都能查到“按什么规则过滤、过滤后影响范围多大”。
- 可兜底:即使开发漏标注解,框架也能用默认策略拦住高风险查询,而不是裸奔。
这四个目标直接决定了分层架构的选择。如果不追求可复用,直接在拦截器里写死几个if分支就能跑;如果不追求可运维,规则做进枚举常量就行。但数据权限这种横切逻辑,一旦散落或写死,后续每次组织架构调整都是一次事故高发期。所以值得在最开始用分层的方式把模型搭稳。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分层设计:把混乱的权限规则拆成三个层次
2.1 策略层:数据范围规则的抽象
我设计的第一层是策略层,核心是回答一个问题:系统中的数据范围到底有哪几种。
从业务收敛来看,绝大多数系统的数据范围逃不出这几种:仅本人数据、本部门数据、本部门及下属部门数据、全部数据、指定部门或指定人员数据。如果业务复杂,还可以有“按自定义SQL片段”的扩展方式。
我提供了五档标准范围:
| 范围类型 | 含义 | 典型用户 |
|---|---|---|
| ALL | 全部数据 | 超级管理员、审计角色 |
| DEPT | 本部门数据 | 部门主管 |
| DEPT_AND_SUB | 本部门及下级部门数据 | 区域经理 |
| SELF | 仅本人数据 | 普通专员、销售顾问 |
| CUSTOM | 自定义部门/人员集合 | 跨部门协作者、项目经理 |
在策略层里,一个数据范围并不是简单的一个枚举值,而是一条带属性的规则对象。例如“本部门及下级部门”这条规则,必须包含“部门字段对应的表列名”和“组织架构树向下递归的范围”,否则到了执行层不知道该拿这个枚举值去过滤哪一列。我最终落地时规则对象大致长这样:
java复制public class DataScopeRule {
private ScopeType scopeType; // 范围类型
private String targetColumn; // 过滤字段,例如 o.dept_id
private boolean includeChildren; // 是否包含下级部门
private Long scopeDeptId; // custom 时指定的部门ID
private List<Long> scopeUserIds; // custom 时指定的用户ID
}
引入targetColumn字段是个关键决策。如果不做这个抽象,就得在SQL拦截器里硬编码“必须过滤dept_id字段”,可现实中不同表可能叫org_id、department_id,关联方式也五花八门。把目标列作为规则的一部分配置出来,拦截器只认规则不认具体表名,通用性立刻提升。
2.2 上下文层:当前用户到底是谁、属于哪里
数据权限执行前必须有一份可靠的上下文:当前登录用户ID、主属部门ID、兼职部门列表、角色列表、是否管理员标记。我把这些信息封装成DataScopeContext,在一个请求进入业务方法之前组装好,放入ThreadLocal,供后面的拦截器读取。
这个上下文层容易踩坑,我强调两点。
第一,用户主属部门和数据权限部门是两个概念。比如财务部的人被临时调到某项目组,他登录系统后默认看到的还是财务部的数据,而不是项目组的数据。上下文里一定要区分“组织归属”和“业务数据范围”,二者组合时才能正确计算。
第二,异步线程会丢上下文。项目里用@Async或者线程池处理导出任务时,如果子线程里去查数据库,ThreadLocal里的用户信息是空的,权限拦截器就会放行全部数据。这是高危问题。处理方式是在提交异步任务时,把父线程的上下文快照一并传入,子线程执行时先重新绑定再执行业务逻辑。
管理员角色也要在上下文里标记出来。数据权限引擎的默认策略是“能查到规则就过滤,查不到规则就拒绝”,但这条不能用在管理员身上。超级管理员需要看到全部数据,所以上下文里要有一个类似isAdmin的开关,一旦为真,拦截器直接放行。
2.3 执行层:规则到SQL条件的翻译
执行层是整个系统最核心的翻译器。它接收两样输入:一份已经解析好的规则列表、一条即将执行的原始SQL。翻译要做的事情,是把抽象的规则对象翻译成一条可执行的SQL条件,再找准位置拼接进原SQL。
规则翻译成SQL条件的逻辑并不复杂。SELF翻译成user_id = ?,DEPT翻译成dept_id = ?,DEPT_AND_SUB翻译成dept_id IN (SELECT id FROM dept WHERE id = ? OR ancestor链包含?)。关键在于,翻译时必须把过滤字段替换成规则里配置的targetColumn,而不是写死某一个字段。
拼接位置也很讲究。不能直接往SQL末尾加AND,因为原SQL可能带着ORDER BY、LIMIT或者FOR UPDATE,直接加尾巴会把整条SQL弄坏。位置选择逻辑是:优先在FOR UPDATE之前、其次在LIMIT之前、再其次在ORDER BY之前插入条件。如果都没有,才追加到末尾。
翻译的结果不是纯字符串,而是一组“条件片段 + 参数列表”。所有从规则对象解析出来的部门ID、用户ID、组织范围都必须作为预编译参数绑定到PreparedStatement上,而不是直接拼进SQL串。这一点在后面的问题章节我还会详细展开。
2.4 分层到底解决了什么问题
分层最大的好处是让每一层的变更互不影响。
策略层要加一种“按标签”的数据范围,只需要扩展规则对象和翻译器,业务SQL不用动。上下文层要把原来从业务系统本地表的用户改成从统一认证中心获取,也只需要改上下文组装逻辑,规则和执行逻辑完全不知道。执行层调整SQL插入位置算法,更不会波及上两层。
我曾经见过一个没有分层的权限系统,规则判断和SQL拼接全堆在MyBatis拦截器里,加了新需求就要在拦截器里改上一整天,改一次出一次bug。重构之后,每一层都有独立的单元测试覆盖,策略层加一个枚举,执行层换一种SQL方言,都不需要担心牵一发动全身。
另一个价值是对接新框架更容易。分层之后,执行层只依赖标准JDBC和SQL语句,不管业务是用MyBatis、JPA还是Spring JDBC Template,只要能在SQL执行前插入一个钩子,就能复用这套数据权限规则。
3. 框架集成:把权限能力透明地嵌进业务代码
3.1 注解驱动:开发只需要声明“这个查询需要被管”
为了让业务开发容易使用,我设计了一个注解@DataScope,放在Mapper接口方法或者Service方法上,用来声明“这个查询需要纳入数据权限控制”。
java复制@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
String tableAlias() default "";
String column() default "";
}
这里的tableAlias是给拦截器看的关键信息。数据权限过滤必须知道当前查询的主表别名是什么。如果业务SQL写的是SELECT * FROM orders o WHERE o.status = 1,那注解里就要配tableAlias = "o",拦截器拼接出来的条件会是AND (o.dept_id = ?)。如果不声明别名,拦截器就只能默认规则里的列名不带前缀,一旦SQL存在多表JOIN,就很容易出现dept_id列名歧义,让数据库直接报错。
这个方法入口加一个注解,AOP切面会在方法执行前解析当前登录人上下文,并把它放入ThreadLocal。要注意的是,注解的切面必须切在真正进入SQL查询之前的最后一个业务方法上。如果某个Service方法内部调用了另一个Service方法,而数据权限切面切在外层方法,内层查询时ThreadLocal里还是空的,就拦截不到。
3.2 集成Spring Security授权上下文
当前用户信息从哪里拿?我在项目中集成了Spring Security,正常情况下直接从SecurityContextHolder里取Authentication对象,再从中拿到自定义的LoginUser。这块看似简单,实际有三个坑。
第一个坑是接口走的是内部调用或者定时任务,没有走Spring Security的过滤器链,SecurityContextHolder里根本没有Authentication。我在系统里做了一个兜底:如果上下文为空且是定时任务,则从任务配置里读取“以哪个用户身份执行”,主动构造权限上下文。这样定时任务不再裸奔跑全库查询。
第二个坑是token里保存的用户信息过期。用户被调整部门后,旧token里还是老部门ID,数据权限会按旧部门生效,直到token刷新。解决方式是权限上下文里不缓存部门ID和角色列表,只缓存用户ID,真正执行时根据用户ID实时查一次用户-部门-角色的快照,并配合Redis缓存把这次查询的代价压下来。
第三个坑是下游系统调用我们的OpenAPI。OpenAPI通常用的不是登录用户,而是应用凭证。这时候如果也走权限拦截,会把权限范围内所有用户的数据都过滤掉,导致外部系统查不到数据。我的做法是把OpenAPI请求标记为“系统身份”,按调用方申请的权限范围执行,不走个人数据权限。
3.3 基于MyBatis Interceptor自动改写SQL
框架集成里技术含量最高的一步就是SQL改写。我用的是MyBatis的Interceptor机制,拦截StatementHandler.prepare阶段,在Statement真正执行前把原SQL替换成已经拼接好权限条件的SQL。
java复制@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})
})
@Component
public class DataScopeInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = handler.getBoundSql();
DataScopeContext scope = DataScopeContextHolder.getContext();
if (scope != null && scope.isEnabled()) {
SqlRewriter rewriter = new SqlRewriter(boundSql.getSql());
String rewrittenSql = rewriter.rewrite(scope);
reflectSetSql(boundSql, rewrittenSql);
}
return invocation.proceed();
}
}
选StatementHandler.prepare这个点,而不是Executor.query,核心原因是这时候BoundSql里的参数映射已经建好,我们只需要把一个新的SQL串反射替换进去即可。如果是放在Executor层拦截,涉及到MappedStatement的重新构建,复杂度高得多,而且容易跟MyBatis-Plus的分页插件打架。
替换SQL串时,还需要把规则里用到的参数追加到BoundSql的附加参数列表里。这里有个不太容易注意到的问题:反射替换SQL后,如果不追加参数,PreparedStatement的占位符会多出来,执行时直接报“参数个数不匹配”。正确流程是先把权限条件里需要绑定的参数收集起来,再通过反射把参数加到BoundSql的additionalParameters集合中。
3.4 多租户和分库分表场景的适配
如果你的系统本身就带着多租户插件,会让拦截器的实现复杂一些。常见做法是MyBatis-Plus的TenantLineInnerInterceptor已经拦截过一遍,再加上自己的数据权限拦截器,拦截器之间执行顺序必须先定义好。
我的建议是统一在一个拦截器里处理租户条件和数据权限条件,顺序是:先拼租户隔离条件,再拼数据权限条件。两者都是强制条件,不管哪条漏了,后果都可能是跨租户数据泄露。
分库分表场景下,数据权限的过滤字段很可能就是分片键。比如按经销商分库的Saas系统里,每个经销商一个数据库,这种架构天然隔离了一大部分数据,数据权限引擎只需要管库内权限,例如某个子账号只能看到自己门店的数据。如果分片键和权限字段不是同一个,要非常谨慎,不能把权限字段直接当作路由字段使用,否则查询会落到错误的库。
4. 一些值得长期坚持的设计决策与细节实现
4.1 规则解析结果缓存策略
数据权限规则不可能每次请求都从数据库实时查一遍。我在这套系统里加了多级缓存来减少解析开销。
第一级是用户维度缓存,键是用户ID加版本号,缓存该用户当前的数据权限规则列表。第二级是解析结果缓存,键是“规则列表Hash + 原始SQL的MapperId”,缓存该SQL已经被翻译成什么样的最终条件。
这里有个细节要特别注意:不是所有Mapper都需要缓存解析结果。如果一个Mapper查询特别多且带有动态拼接的IN条件,原始SQL本身每次可能不一样,缓存无法命中,白白占内存。我做了一个开关,只有规则解析耗时大于阈值的复杂SQL才启用二次缓存。
组织架构调整之后,缓存必须能立即失效。我在部门表和用户部门关系表上都挂了版本号,每次变更数据权限相关数据,缓存版本号自增。版本号比对放在规则获取的第一道关口,成本极低,但能避免组织调整后用户还能看到旧部门数据的问题。
4.2 规则片段与占位参数的拼装规范
数据权限条件拼装时,最容易出安全问题的环节是参数直接拼接。所有从规则对象取出来的值必须通过占位符绑定。
我对拼装流程做了一道铁律:规则条件只有两类东西可以进入SQL串,一类是固定关键字和符号,比如AND、IN、=、括号;另一类是经过白名单校验的列名,列名只能来自规则配置,不能来自用户输入。所有具体的值一律加入参数列表,执行时由PreparedStatement做安全绑定。
再往深一层说,列名其实也不能盲信配置。因为列名会进入SQL串成为标识符,如果配置是管理端维护的,风险还可控;但如果有外部导入规则,必须校验列名只包含字母、数字、下划线和点号,防止有人把o.dept_id OR 1=1写进配置里。我提供一个工具方法过滤非法字符,通过正则只保留白名单内的列名格式。
4.3 无限层级组织树的处理
组织架构往往是有层级的,最常见的数据权限需求是最小粒度到“本部门及以下所有部门”。要实现对无限层级的向下递归,有两种做法,各自的适用场景不同。
第一种是代码里递归收集所有子部门ID,再把子部门列表放进dept_id IN (...)。这个方案直观,但有两个问题:一是部门层级深、节点多时,生成的IN列表会很长,Oracle的IN还有1000个上限,MySQL虽然宽泛但同样影响性能;二是如果递归期间组织架构变了,内存里的部门列表和数据库实时状态不一致,可能瞬间出现越权数据。
第二种是引入路径编码或者祖先关系表,把“查询本部门及下级部门”翻译成一个子查询:dept_id IN (SELECT dept_id FROM dept_relation WHERE ancestor_id = ?)。这种写法由数据库在事务里实时计算,使用索引可走,不存在IN上限问题。如果公司用MySQL 8,还可以直接写递归CTE。我最终在系统里改用了dept_relation表方案,递归链路在一次部门变更时全部更新,查询时永远只做一次等值关联。
如果你的系统还停留在“部门最多三级”的阶段,第一种递归方案凑合能用,但请为未来留好升级空间。部门层级从三级变五级很容易,但要改动权限过滤核心逻辑就麻烦了。
4.4 默认拒绝和越权校验
权限系统最忌讳的是默认放行。我把框架的行为策略设置成“未获得数据权限上下文的请求,默认拒绝”。也就是说,如果某个业务接口加了@DataScope注解,但AOP切面因为某种原因没把上下文写入ThreadLocal,执行层的规则不是放行,而是抛异常。异常提示是“数据权限上下文缺失,禁止执行全量查询”。
这个策略在改造初期会带来一些误报,比如某些内部查询只是想做配置校验、确实需要全表数据,这时应该显式加上@IgnoreDataScope注解告诉框架“这个查询是白名单”。把白名单放出来,比默认全量放行再一个个堵漏洞要安全一些。
另外我加了一道“越权探测”机制。敏感查询在执行后,对比返回行数和当前用户权限范围内估算的最大可能行数,如果前者显著超出后者,框架会记录一条安全日志并通知管理员。这个机制不是强拦截,因为估算可能不准,但它能在规则配置错误时第一时间发出信号,把潜在越权暴露出来。
5. 实战中必须规避的坑与排查思路
5.1 问题一:注解切面生效但SQL没被拦截
有一次开发反馈说某查询已经加了@DataScope,但执行结果还是全表数据。我第一反应是切面没生效。排查后发现方法没有被拦截的根源是:这个Service方法是从同类内部调用的。Spring AOP基于代理对象,同类内部this.method()调用不会经过代理,注解自然就失效了。
解法有两个。一是把调用方改成注入自身的代理对象,或者拆出一个独立Bean;二是直接把@DataScope放在Mapper接口方法上,由MyBatis的Interceptor层拦截,不依赖Spring AOP,更底层也更可靠。我后来把核心查询的注解都下沉到Mapper层,Service层注解只作为补充。判断切入点时可以记住:越靠近SQL执行点,越不容易被AOP代理问题波及。
5.2 问题二:SQL注入的隐患藏在看不见的拼接里
数据权限最容易被攻击的地方就是规则解析到SQL字符串的过程。如果规则里的部门ID、用户ID直接拼接而不是预编译,攻击者即使没有权限,也能通过修改请求参数让拼接出来的条件失效。
我举一个反例。假设规则翻译成o.dept_id = {inputDeptId},程序把用户传的inputDeptId直接填进去。攻击者构造一个特殊数值,让条件变成o.dept_id = 1 OR 1=1,权限条件就被绕过了。正确写法是o.dept_id = ?,然后PreparedStatement.setLong(1, deptId)。
所以我在拦截器里特别强制了一点:数据权限规则允许出现的操作符和值必须经过白名单校验,目标列来自注解配置,目标值来自服务端解析,绝不接收前端参数。前端只能传数据内容参数,不能传权限字段本身。
5.3 问题三:分页Count查询没有经过数据权限过滤
系统用的分页插件如果和自研拦截器同时存在,拦截顺序必须仔细调。分页插件工作时通常会先执行一条COUNT查询拿到总数,再执行真正的分页SQL。如果自研拦截器只拦住了分页SQL,而COUNT查询没有经过处理,用户看到的总数一直是全表数量,可能还会因此向框架反馈“权限没生效”。
排查方法是打印执行日志,把Mapper执行的所有SQL列出来,查看COUNT语句里是否包含权限条件。解决这两个拦截器的执行顺序,一般通过@Intercepts里的order属性控制,让数据权限拦截器先执行,分页插件后执行。这样分页插件执行前的SQL已经带了权限条件,它派生出的COUNT查询也会自带权限条件,两个数字就一致了。
5.4 问题速查表
| 常见现象 | 可能原因 | 检查顺序 |
|---|---|---|
| 明确加了注解但结果还是全表 | AOP代理没生效,或者上下文没写入 | 先看日志里有没有“数据权限上下文缺失”,再排查切点 |
| 某角色看到的数据多了 | 多角色规则合成时用了OR | 拉该用户的全部角色,检查每条规则的列名是否一致 |
报列名不存在 |
SQL没有表别名,权限列带上别名后无法解析 | 检查原SQL是否为多表查询,规范要求主表必须取别名 |
| 分页总数和实际列表行数不一致 | COUNT查询未经过权限拦截器 | 检查拦截器顺序 |
| 部门调整后权限没变化 | 缓存了旧的组织快照 | 检查组织相关缓存版本号是否递增 |
| 异步导出任务查到了全量数据 | ThreadLocal没有随线程传递 | 检查导出任务是否包装了上下文快照 |
5.5 一句想踩完坑才说的话
数据权限控制的本质不是写一个拦截器,而是建立一套“默认最小可见,特殊最大可见”的工程习惯。很多开发新手觉得权限控制就是加个where条件,真正做下来才发现,难点全在那些不起眼的边界里:异步线程丢上下文、COUNT查询漏条件、多角色规则到底是取交集还是并集、组织树向下递归到几层。
我个人的经验是,上线前必须拿一个高危账号做一轮冒烟测试:普通专员查列表、导数据、看统计报表、调OpenAPI接口,四个入口全部验证一遍,每一个返回的数据行数都必须和人工核对的结果一致。宁可多花半天测权限,也不要等线上用户导出数据后再来救火。数据权限属于那种“不出问题岁月静好,一出问题就是安全事故”的模块,值得在最开始就把边界想清楚、把默认风险堵死。
