💡上一篇聊完框架选型,这一篇进入最容易出事的一层:数据权限。菜单和按钮的权限判断错了,用户会立刻反馈"我点不进去";数据权限错了,没人会告诉你——只会有一天发现某个销售导出了整个大区的订单。
笔者见过最贵的一次事故,代价就是一个括号。拦截器往 SQL 末尾追加了AND (dept_id IN (...)),业务 SQL 里原本有一句OR,AND优先级比OR高,于是权限条件只约束了最后一个分支,前面几条分支照样全表可见。功能测试一切正常,因为测试用例里没有OR。
这一篇把"权限条件到底该在哪一层强制"讲透:应用层、Mapper 层、DB 层三处各自的真实语义与旁路条件,以及一份可以直接抄进上线流程的核对清单。
1. 先分清三件事:谁生成条件、谁拼接、谁强制执行
数据权限的讨论之所以乱,是因为三个角色被混成了一个词。
策略层(决定"这个人在这张表能看哪些行") ↓ 输出一个谓词 拼接层(把谓词塞进 SQL 的执行计划里) ↓ 产出最终 SQL 强制层(保证这条 SQL 无法被绕过)对应到工程上,有三种把三层排布的路线:
| 路线 | 三层位置 | 优点 | 致命短板 |
|---|---|---|---|
| A. 应用层手写 | Service 里手工加条件 | 直观、可按业务灵活变 | 全靠人自觉;新增一个查询接口就可能漏掉;无法证明"没有漏" |
| B. Mapper/ORM 拦截器 | 拦截 SQL 后重写 | 一处生效、覆盖存量 SQL | 只对"走这条路"的 SQL 有效;手写@Select、存储过程、原生 JDBC 会漏 |
| C. 数据库层强制 | RLS / VPD | 默认拒绝,与应用是否忘写条件无关 | 有明确的旁路角色;运维/报表账号常是 owner,等于失效 |
一句话理解:A 是纪律,B 是工具,C 是物理定律。OWASP 的授权备忘单在这里给了条很硬的原则——不要依赖任何单一框架、库、技术或控制作为访问控制的唯一执行者(纵深防御),并且明确配置优于依赖框架默认值,因为第三方代码的逻辑和默认值会在开发者不知情的情况下演化。来源
所以 B + C 组合是笔者推荐的默认答案,理由到第 3 节会给。
2. 拦截器方案:官方组件长什么样,边界在哪
国内 Java 项目这条路上的主要实现是 MyBatis / MyBatis-Plus,两个官方插件分工很清楚:
| 插件 | 官方描述 | 覆盖范围 | 可扩展点 |
|---|---|---|---|
TenantLineInnerInterceptor | “auto-appends the tenant condition” | SELECT / INSERT / UPDATE / DELETE | TenantLineHandler.ignoreTable跳过非租户表;getTenantId()返回一个Expression |
DataPermissionInterceptor | “Intercepts SQL statements…dynamically splice permission-related SQL segments” | 查询为主 | 注册自定义MultiDataPermissionHandler |
来源1 来源2
这里有个容易被忽略但决定安全性的实现差别:MyBatis-Plus 插件是先用 JSqlParser 把 SQL 解析成 AST,再在 AST 上重写,租户值以Expression对象注入,而不是拼字符串。这和手写${}拼接有本质区别。
2.1 为什么绝不能字符串拼接
MyBatis 官方文档把这条线画得很清楚:#{...}走PreparedStatement 参数绑定;${...}是把字符串原样替换进 SQL,文档明确警告这会 “lead to potential SQL Injection attacks”。来源
数据权限在这里比普通查询更危险,因为权限条件的值(部门 ID 列表、租户 ID、负责人 ID)经常来自会话或请求上下文,而这些位置在前端可改的接口里并不总是可信。
可行的安全拼接边界(工程共识,非某句官方原文):
可以拼结构:列名、表名、IN 里占位符的「数量」 绝不能拼值:把 deptId / tenantId / userId 的字面量写进 SQL 文本 正确形态:服务端解析出 Long 列表 → 生成同样数量的 ? 占位符 → 值走参数绑定 列名白名单:字段权限的列名来自枚举/元数据,不来自请求参数Spring Security 对 JWTkid的那句提醒是同一类问题的旁证——“kid也会进入数据库和目录,它是一个注入向量,使用前必须校验或净化”。来源
2.2 那个丢人的括号:AND/OR 组合事故的正确解法
先给最常见的错误形态:
-- 业务原 SQLSELECT*FROMordersWHEREstatus=1ORtype=3-- 拦截器"追加"权限条件(字符串 append,错)SELECT*FROMordersWHEREstatus=1ORtype=3AND(dept_idIN(101,102))-- 实际语义: status = 1 OR (type = 3 AND dept_id IN ...)-- → 只要 status=1,全公司订单都可见正确形态只有一种:原条件整体加括号,权限条件与它 AND:
SELECT*FROMordersWHERE(status=1ORtype=3)AND(dept_idIN(?,?))在 AST 层面就是把两者包成AndExpression(Parenthesis(原 WHERE), 权限表达式),而不是往字符串尾巴上 append。
把这次事故一步步变形的过程拆开,再把错误形态与正确形态并排对照,是这样:
有意思的是,"多个权限条件如何组合"这件事,数据库标准反而比应用层做得更明白。PostgreSQL RLS 对多策略的组合语义有明确定义:
permissive 策略(默认)→ 彼此用 OR 合并 ← 扩大可见集 restrictive 策略 → 与前面结果用 AND 合并 ← 收口 要 AND 语义必须显式 CREATE POLICY ... AS RESTRICTIVE来源1 来源2
这条值得抄进设计规范:多个数据权限条件同时命中时,默认是"并集"还是"交集",必须写死在文档里,而不是靠拦截器的实现顺序决定。用 OR 默认值 + 显式 restrictive 收口,比"谁先注册谁赢"可审计得多。
需要标注的两处未核实:本次调研抓取的是英文站
baomidou.com/plugins/*,其中没有出现@DataPermission注解与DataPermissionAnnotationInterceptor,也没有"数据权限插件必须注册在分页插件之前"这类显式顺序警告;这些在国内中文文档里广泛存在。同理,JPA/Hibernate 侧@Filter/@FilterDef的官方表述本次也未核实。引用前请以官方中文文档或 Hibernate 手册复核,别当已验证事实。
3. DB 层兜底:RLS 的真实语义,以及它为什么经常白配
数据库层的两家代表:PostgreSQL RLS 与 Oracle VPD。
3.1 PostgreSQL RLS:默认拒绝是唯一真正的价值
官方定义:“除了GRANT提供的 SQL 标准权限体系之外,表还可以带行安全策略,按用户维度限制普通查询能返回哪些行、以及能插入/更新/删除哪些行”。而一旦在表上启用行安全:“all normal access to the table … must be allowed by a row security policy”;如果没有策略,则使用 default-deny,即没有任何行可见或可改。来源
这正是它与"应用层忘写条件"的根本区别:应用层忘写 = 全放开;RLS 忘写 = 全不放开。功能会坏,但不会泄露。RLS 引入于 PostgreSQL9.5。来源
但旁路条件必须逐条记住,否则白配:
| 旁路 | 官方原文要点 |
|---|---|
超级用户 /BYPASSRLS | “Superusers and roles with the BYPASSRLS attribute always bypass the row security system” |
| 表 owner | “Table ownersnormallybypass row security as well”,需ALTER TABLE … FORCE ROW LEVEL SECURITY才受约束 |
| 只有 owner 能改策略 | 启用行安全、增删策略"always the privilege of the table owner only" |
| 引用完整性检查 | 唯一键/主键/外键检查"always bypass row security" |
| 全表操作 | TRUNCATE与REFERENCES不受行安全约束 |
| 视图 | 被视图引用的表按视图 owner的权限与策略检查——除非视图用security_invoker定义 |
| 泄漏防护不足 | 被标记LEAKPROOF的函数/操作符可能在策略表达式之前被求值;策略里写子 SELECT 会制造可能导致信息泄漏的竞态 |
| 参数可关 | row_security = off:任何查询结果一旦被策略过滤就直接报错(用于发现漏配);禁用策略不会删除它们,只是忽略 |
来源
把上面这张旁路表按"绕过凭什么发生"归成四类,对得上第 7 节清单的那几类在卡片右上角标了条目号:
结论句:RLS 作为兜底可行的前提是应用连接不用 superuser / owner / BYPASSRLS 角色,要么用独立低权账号,要么对 owner 加FORCE ROW LEVEL SECURITY。否则"应用层忘带租户上下文"这件事会静默失守——你以为有兜底,其实没有。
Q:多租户到底该怎么把租户 ID 传给 RLS?
这里要做一次事实纠正。PostgreSQL 官方的 RLS /CREATE POLICY页面里没有current_setting('app.tenant_id')这类会话变量示例,策略表达式示例只用了current_user。所以:
-- 网上(尤其 Supabase / PostgREST 生态)常见的写法,属社区/厂商做法SETapp.tenant_id='10001';CREATEPOLICY pONordersUSING(tenant_id=current_setting('app.tenant_id')::bigint);current_setting()本身确实是 PostgreSQL 官方函数,但这套多租户模式不是 PostgreSQL 官方推荐,引用时别挂官方名义。来源
用current_user的写法更贴近官方语义,代价是要给每个租户建数据库角色,运维成本高——这是选型时真实存在的一道坎,没有免费的兜底。
3.2 Oracle VPD:它改的是 SQL,不是结果集
官方定义:“Oracle VPD创建控制数据库行级与列级访问的安全策略”,机制是给应用了策略的表/视图/同义词上的 SQL动态追加一个 WHERE 子句;实现入口是DBMS_RLSPL/SQL 包,策略函数可结合application contexts(会话上下文)注入谓词。来源
注意"动态追加 WHERE"这个描述——VPD 的机制和应用层拦截器其实是同构的,只是它把重写放在了数据库内核里,因此应用无法绕过。列级授权在 Oracle 侧也有独立文档入口(“Grants of Privileges on Columns”)。
SQL Server 侧对应能力是 RLS,用CREATE SECURITY POLICY+ 内联表值函数做谓词,官方明确"Row-level security was first introduced to SQL Server 2016 (13.x)",并区分 filter predicate 与 block predicate。来源
未核实:MySQL 无原生 RLS,本次未核实 MariaDB / MySQL 的等价能力,不要写"MySQL 8 也支持行级安全"。
4. 字段级(列)权限:DB 早就支持,只是没人用
列级权限其实是最"标准"的一层:
- PostgreSQL 的
GRANT语法直接支持列对象权限:GRANT { { SELECT | INSERT | UPDATE | REFERENCES } ( column_name [, ...] ) } ON [ TABLE ] table_name。来源,权限体系综述见 Privileges - Oracle 在 VPD 指南里就有"Grants of Privileges on Columns"与列选择型 revoke 的章节。来源
- Oracle VPD 与 PostgreSQL RLS 都能做行级,但列级只有 VPD 明确写进定义,PostgreSQL 的 RLS 管的是行,列要靠
GRANT (column)或视图。
工程上更常见的是应用层做出参裁剪,因为它要解决的是"同一个字段对不同人显示不同"(脱敏、可见性),而不是"能不能读这一列":
方案 1:VO 分层(不同角色不同 VO) → 清晰但类爆炸 方案 2:序列化阶段按权限裁剪(@JsonFilter / 自定义 Serializer) → 一处生效,但要防"忘了挂 filter" 方案 3:DB 列级 GRANT / 视图 → 最硬,运维成本最高,难做动态规则笔者的判断:能读但打码,和应用层裁剪;不能读,交给 DB 或视图。前者是展示规则(易变),后者是权限事实(应稳定)。把二者混在一处是最常见的腐化起点——脱敏规则改十次之后,没人敢确定"身份证号到底谁能看明文"。
另有一条与列权限直接相关的官方提醒:水平越权(能访问到其他用户的资源)是已登录用户最常利用的弱点,并要求"确保查找 ID 即使被猜中也不可访问"(即 BOLA/IDOR 的缓解)。所以自增 ID 做对外标识时,“猜得到"本身就等于"该被拒”,别指望 ID 不可枚举来做权限。来源
5. 多租户:隔离级别决定权限条件的写法
| 隔离模型 | 数据权限条件写法 | 优点 | 代价 |
|---|---|---|---|
共享表 +tenant_id列 | 拦截器/RLS 追加tenant_id = ? | 成本最低、扩容容易 | 每一处查询都要被覆盖,漏一处即跨租户;索引必须带 tenant 前缀 |
| 同库不同 schema | 连接级切换 schema | 天然隔离,DDL 可按租户差异化 | schema 数量上限、迁移要遍历执行 |
| 每租户独立库 | 路由数据源 | 隔离最强、爆炸半径最小 | 运维与版本升级成本随租户线性增长 |
这三条路线和数据权限的关系可以直接写成一句话:隔离做得越强,越不需要靠"每条 SQL 都别忘写条件"来保证安全。反过来,共享表模型把安全责任全压在第 2 节的拦截器上,此时那个括号事故就是必然,只是时间问题。
一个务实的组合(笔者倾向,非标准推荐):
共享表 tenant_id + MyBatis-Plus 租户插件(AST 重写,覆盖 CRUD) + PostgreSQL RLS 兜底(低权应用账号,非 owner) + ignoreTable 白名单定期评审(这张表凭什么不带租户?)ignoreTable是这套组合里最该被治理的点:每一个被跳过的表都是一个"这张表不需要数据权限"的口头承诺。建议把它做成配置 + 评审记录,而不是硬编码在启动类里。
6. 三个绕不过去的疑问
Q1:数据权限到底该放 Service 层、Mapper 层还是 DB 层?
最后一层都得有,但决策和强制要分开。
| 关注点 | 放哪 | 为什么 |
|---|---|---|
| “这个人在这张表的可见范围是什么”(业务语义) | Service / 独立的权限查询服务 | 需要知道调用者上下文,Mapper 不知道业务意图 |
| “把范围变成 SQL 谓词并保证覆盖” | Mapper 拦截器(AST 重写) | 一处生效、可覆盖存量 SQL |
| “就算上面两层都写错了也不泄露” | DB 层(RLS / VPD)+ 最小权限账号 | 唯一能实现 default-deny 的位置 |
同一套分工摊成三层横带,再把第 4 节的字段权限落点叠到右边一列,就是这张图:
OWASP 在这里给的定性依据很明确:访问控制检查必须在服务端、网关或 serverless 函数执行(引 ASVS V1.4.1 / V4.1.1),绝不能依赖客户端的访问控制检查——客户端判断可以为了体验存在,但永远不该是决定性因素;而且必须在每一个请求上校验权限。原文那句适合贴在工位上:“攻击者只需要找到一个入口。即使只有一次访问控制检查被’漏掉’,资源的机密性和/或完整性就可能被破坏。仅在大多数请求上正确校验权限是不够的。”来源
Q2:前端按钮权限算不算权限?
不算。它算体验优化:让不该操作的人看不到入口,减少无效请求和客服工单。
但它在安全上是零分的,原因除了"客户端不可信",还有一层更实际的:前端权限树的权威数据源通常是后端下发的角色-菜单关系,而这个关系本身不包含数据范围。所以"按钮可见"和"这次调用允许"是两个独立判断,第二个必须每次在服务端重算。
前端隐藏按钮 → 减少误操作 (可被绕过,不是控制) 后端接口鉴权 → 能不能调这个接口 (必须是决定性因素) 数据权限过滤 → 能返回哪些行/列 (和前两层是不同维度,三者都要有)Q3:改完权限为什么用户看不到变化?
如果是会话制登录,答案通常是权限被缓存在会话对象里。这里有一条官方依据可以直接用:权限级别变化后必须更换会话 ID——原文说会话 ID 必须在关联用户会话内"after any privilege level change"之后被 renew 或 regenerate,并明确举例包括"password changes,permission changes, or switching from a regular user role to an administrator role",目的是防 session fixation;Java 侧 API 是request.getSession(true)/HttpSession.invalidate()。来源
顺带三条同样有官方出处的会话治理约束,数据权限上线前都该核对:
- 超时必须在服务端强制:“Session timeout management and expiration must be enforced server-side”,客户端计时可被篡改;常见区间是高价值应用 2–5 分钟、低风险应用 15–30 分钟的空闲超时,另有绝对超时与会话中途换 ID 的 renewal timeout(官方明确后者会与攻击者持旧 ID 产生竞态)。
- 会话生命周期要打全日志:创建、续期、销毁,以及 login/logout 中的使用细节、会话内权限级别变化、超时到期、无效会话活动(被检测到时)。
- 异常检测点:参考 OWASP AppSensor,建议检测"已有 cookie 被修改或删除"“新增了 cookie”“复用了其他用户的会话 ID”“会话中途用户位置或 User-Agent 发生变化”。
来源
注:Spring Session 官方文档另有一处与"权限缓存在会话里"直接相关的并发警告:官方建议
SessionRepository实现只保存增量(“only deltas are saved”),因为在高并发下多个请求操作同一个HttpSession会造成互相覆盖属性的竞态;RedisIndexedSessionRepository正是跟踪已变更属性、只更新这些属性的实现。来源
7. 上线核对清单(可直接抄)
| # | 检查项 | 判定依据 |
|---|---|---|
| 1 | 权限条件与业务条件之间有显式括号,且为 AND | AST 单测断言,不看字符串 |
| 2 | 多策略组合语义被文档化(并集/交集谁赢) | 参照 RLS 的 permissive=OR / restrictive=AND |
| 3 | 权限条件的值全部参数化,无${} | MyBatis 官方${}注入警告 |
| 4 | 列名/表名来自白名单枚举,不来自请求参数 | 同上 |
| 5 | ignoreTable/ 跳过的表清单有 owner 与评审日期 | 每张被跳过的表都是一次口头承诺 |
| 6 | 存在绕过 ORM 的 SQL(手写@Select、原生 JDBC、报表库、存储过程)的排查记录 | 拦截器只覆盖走它的路径 |
| 7 | 应用连接账号非 superuser / 非表 owner / 无 BYPASSRLS | PostgreSQL 官方旁路条款 |
| 8 | RLS 兜底表已确认FORCE ROW LEVEL SECURITY或独立低权角色 | 同上 |
| 9 | row_security = off跑过一遍全量回归,报错=漏配点 | 官方参数语义 |
| 10 | 视图已确认走security_invoker或按视图 owner 策略评估可接受 | 官方视图条款 |
| 11 | 每个查询接口都有"换一个人来调同一请求"的用例(水平越权) | OWASP:权限须在每个请求校验 |
| 12 | 权限变更后有会话失效/ID 更换路径并被测试覆盖 | OWASP:privilege level change 后必须 renew |
| 13 | 失败的访问控制检查由集中逻辑处理,日志格式统一可解析 | OWASP:“Centralize the logic for handling failed access control checks” |
| 14 | 不依赖框架默认值,显式配置授权规则 | OWASP:“explicit configuration should be preferred” |
第 13 条值得多说一句,因为它把数据权限和可观测串起来了。OWASP 的明确要求是:集中处理失败的访问控制检查;用一致、定义良好、可直接解析的格式记录(并点名 Apache Logging Services 作为跨语言示例,对应 OWASP Proactive Controls C9);审慎决定记录量——记太多与记太少都被列为缺陷(CWE-778 Insufficient Logging/CWE-779 Excessive Logging);把应用日志接入集中日志服务器或 SIEM;错误信息里不要泄漏敏感数据(CWE-209)。来源
而这一层的监控口径为什么必须自建,有个很具体的 Java 侧原因:Spring Security 的AuthenticationEventPublisher只覆盖认证,不覆盖授权失败。它默认只对一张固定映射表里的异常发布事件(BadCredentialsException、UsernameNotFoundException、AccountExpiredException、LockedException、InvalidBearerTokenException等),并且官方有句关键的坑说明:"publisher 做的是异常精确匹配,因此这些异常的子类不会产出事件。"修复手段是setAdditionalExceptionMappings(...)补映射,或setDefaultAuthenticationFailureEvent(...)设兜底。来源
这就意味着**"403 突增"这类越权信号指望不上认证事件**,必须在AccessDeniedHandler或过滤器链末端自己埋点。同时注意:InvalidBearerTokenException → AuthenticationFailureBadCredentialsEvent这条映射说明Bearer token 验签失败会走认证事件,可以顺手用来统计 JWT 校验失败率。
至于指标定义本身——“异常授权率 =(403 + 授权引擎 deny)/ 总请求数”“同一 principal 短时间对大量不同 resource id 的尝试”“PDP fail-closed 触发计数”——在本次核实的官方文档里没有现成命名与阈值,属于笔者建议的口径,别挂到 OWASP 名义下。定性依据可以引官方那句:日志是最重要的侦测型控制之一,而日志与监控不足被列为 OWASP Top 10 2021 的A09;日志没配好时,“授权违规可能不被发现,或至少无法归因到某个具体个人或群体”。来源1 来源2
越权事故在风险体系里的归属也可核对:OWASP Top 10A01:2021 Broken Access Control,MITRECWE-285。来源1 来源2
最后总结
- 数据权限的真正难点不是"怎么写条件",是"怎么证明没有漏"。手写方案永远只能靠人自觉,而 OWASP 的标准是"每个请求都要校验"。
- 拦截器要用 AST 重写,不要用字符串 append。那个括号事故的本质就是把权限条件当字符串尾巴处理。MyBatis-Plus 的租户/数据权限插件走的是 JSqlParser 重写,这是它安全性的来源。
- DB 层兜底值得做,但要先解决旁路。RLS 的价值是 default-deny,而 superuser、
BYPASSRLS、表 owner、外键/唯一约束、TRUNCATE/REFERENCES都在官方列出的绕过范围里;不解决账号权限,RLS 只是心理安慰。 - "把租户 ID 塞进会话变量再用
current_setting读"是社区做法,不是 PostgreSQL 官方推荐,方案书里要区分开。 - 多策略默认 OR 合并这件事值得写进团队规范。PostgreSQL 用 permissive / restrictive 把语义显式化了,应用层也该有等价约定,而不是靠注册顺序。
- 列权限优先用 DB 的原生能力,脱敏用应用层。把"能不能读"和"怎么显示"分开,能少掉一大半腐化。
- 对于后端/架构岗,笔者个人更看重这三条:被拦截器跳过的表清单有没有 owner;权限变更后的缓存失效路径有没有测试;失败的授权检查能不能被集中归因到某个人。
下一篇(也是本系列最后一篇)讲把授权判断整个搬出应用:Casbin / OpenFGA / Cerbos 三种路线的 Java 集成现状、PDP 宕机时 fail-open 还是 fail-closed,以及从框架迁到外置授权的灰度办法。
事实说明:本版本所有类名、配置项、引文均来自 2026-09-29 实抓的官方文档。以下四处未取到一手依据,正文已作标注且不作断言:
@DataPermission/DataPermissionAnnotationInterceptor与"数据权限插件须注册在分页插件之前"(本次只核到英文站);Hibernate@Filter的官方表述;MariaDB / MySQL 的行级安全等价能力;异常授权率等监控指标的官方命名与阈值。“数据权限三层分工”"列权限与脱敏分离"等为笔者工程判断,非标准表述。
参考资料 & 致谢
[1] OWASP Authorization Cheat Sheet(每请求校验、deny-by-default、集中处理失败检查、纵深防御)
[2] OWASP Session Management Cheat Sheet(权限变更后换 ID、服务端超时、AppSensor 检测点、会话日志)
[3] OWASP JSON Web Token Cheat Sheet(kid注入向量)
[4] OWASP Top 10:A01:2021 Broken Access Control / A09:2021 Security Logging and Monitoring Failures
[5] MITRE CWE-285 Improper Authorization / CWE-778 / CWE-779 / CWE-209
[6] MyBatis 官方文档:SQL Map XML — Parameters(#{}参数绑定与${}注入警告)
[7] MyBatis-Plus 多租户插件TenantLineInnerInterceptor/ 数据权限插件DataPermissionInterceptor
[8] PostgreSQL 文档:Row Security Policies(default-deny、旁路、视图、LEAKPROOF、row_security)
[9] PostgreSQL 文档:CREATE POLICY(permissive=OR / restrictive=AND)
[10] PostgreSQL 9.5 发行说明:行安全引入 / GRANT 列级权限 / Privileges
[11] Oracle Database 19c:Using Oracle Virtual Private Database / 章节目录(列级授权条目)
[12] Microsoft SQL Server:Row-Level Security
[13] Spring Security 7.1.1 参考:Authentication Events(异常精确匹配、补充映射 API)
[14] Spring Session 4.1.1 参考:API(FlushMode、deltas 与并发覆盖、索引仓库)
[15] OWASP Application Security Verification Standard 4.0.3(V1.4.1 / V4.1.1)
[16] OWASP Proactive Controls C7: Enforce Access Controls / WSTG v4.2 授权测试
[17] OWASP AppSensor 项目