玩转 Java 权限体系 第四篇(实战):数据权限、字段权限与多租户——一条 SQL 少个括号就够全公司越权 上一篇聊完框架选型这一篇进入最容易出事的一层数据权限。菜单和按钮的权限判断错了用户会立刻反馈我点不进去数据权限错了没人会告诉你——只会有一天发现某个销售导出了整个大区的订单。笔者见过最贵的一次事故代价就是一个括号。拦截器往 SQL 末尾追加了AND (dept_id IN (...))业务 SQL 里原本有一句ORAND优先级比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 / DELETETenantLineHandler.ignoreTable跳过非租户表getTenantId()返回一个ExpressionDataPermissionInterceptor“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*FROMordersWHEREstatus1ORtype3-- 拦截器追加权限条件字符串 append错SELECT*FROMordersWHEREstatus1ORtype3AND(dept_idIN(101,102))-- 实际语义 status 1 OR (type 3 AND dept_id IN ...)-- → 只要 status1全公司订单都可见正确形态只有一种原条件整体加括号权限条件与它 ANDSELECT*FROMordersWHERE(status1ORtype3)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_id10001;CREATEPOLICY pONordersUSING(tenant_idcurrent_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。来源权限体系综述见 PrivilegesOracle 在 VPD 指南里就有Grants of Privileges on Columns与列选择型 revoke 的章节。来源Oracle VPD 与 PostgreSQL RLS 都能做行级但列级只有 VPD 明确写进定义PostgreSQL 的 RLS 管的是行列要靠GRANT (column)或视图。工程上更常见的是应用层做出参裁剪因为它要解决的是同一个字段对不同人显示不同脱敏、可见性而不是能不能读这一列方案 1VO 分层不同角色不同 VO → 清晰但类爆炸 方案 2序列化阶段按权限裁剪JsonFilter / 自定义 Serializer → 一处生效但要防忘了挂 filter 方案 3DB 列级 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 fixationJava 侧 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权限条件与业务条件之间有显式括号且为 ANDAST 单测断言不看字符串2多策略组合语义被文档化并集/交集谁赢参照 RLS 的 permissiveOR / restrictiveAND3权限条件的值全部参数化无${}MyBatis 官方${}注入警告4列名/表名来自白名单枚举不来自请求参数同上5ignoreTable/ 跳过的表清单有 owner 与评审日期每张被跳过的表都是一次口头承诺6存在绕过 ORM 的 SQL手写Select、原生 JDBC、报表库、存储过程的排查记录拦截器只覆盖走它的路径7应用连接账号非 superuser / 非表 owner / 无 BYPASSRLSPostgreSQL 官方旁路条款8RLS 兜底表已确认FORCE ROW LEVEL SECURITY或独立低权角色同上9row_security off跑过一遍全量回归报错漏配点官方参数语义10视图已确认走security_invoker或按视图 owner 策略评估可接受官方视图条款11每个查询接口都有换一个人来调同一请求的用例水平越权OWASP权限须在每个请求校验12权限变更后有会话失效/ID 更换路径并被测试覆盖OWASPprivilege level change 后必须 renew13失败的访问控制检查由集中逻辑处理日志格式统一可解析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 ControlMITRECWE-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与数据权限插件须注册在分页插件之前本次只核到英文站HibernateFilter的官方表述MariaDB / MySQL 的行级安全等价能力异常授权率等监控指标的官方命名与阈值。“数据权限三层分工”列权限与脱敏分离等为笔者工程判断非标准表述。参考资料 致谢[1] OWASP Authorization Cheat Sheet每请求校验、deny-by-default、集中处理失败检查、纵深防御[2] OWASP Session Management Cheat Sheet权限变更后换 ID、服务端超时、AppSensor 检测点、会话日志[3] OWASP JSON Web Token Cheat Sheetkid注入向量[4] OWASP Top 10A01: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 Policiesdefault-deny、旁路、视图、LEAKPROOF、row_security[9] PostgreSQL 文档CREATE POLICYpermissiveOR / restrictiveAND[10] PostgreSQL 9.5 发行说明行安全引入 GRANT 列级权限 Privileges[11] Oracle Database 19cUsing Oracle Virtual Private Database 章节目录列级授权条目[12] Microsoft SQL ServerRow-Level Security[13] Spring Security 7.1.1 参考Authentication Events异常精确匹配、补充映射 API[14] Spring Session 4.1.1 参考APIFlushMode、deltas 与并发覆盖、索引仓库[15] OWASP Application Security Verification Standard 4.0.3V1.4.1 / V4.1.1[16] OWASP Proactive Controls C7: Enforce Access Controls WSTG v4.2 授权测试[17] OWASP AppSensor 项目