必须显式 tenant_id 过滤 + 数据库 RLS 强制策略双保险,WHERE tenant_id = ? 需覆盖所有子查询、JOIN 和原生 SQL,tenant_id 必须来自可信上下文(如 JWT),严禁反查或动态拼接。

不能靠防SQL注入来防跨租户泄露,必须靠显式 tenant_id 过滤 + 数据库强制策略双保险。
WHERE tenant_id = ? 必须出现在每一层子查询里
嵌套查询最容易漏掉租户过滤,尤其在 IN、EXISTS、JOIN 场景下。漏写就等于把全表暴露给当前请求的租户。
- 错误写法:
WHERE id IN (SELECT order_id FROM invoices WHERE user_id = ?)—— 子查询没加tenant_id = ?,可能拉回其他租户的发票 - 正确写法:
WHERE id IN (SELECT order_id FROM invoices WHERE user_id = ? AND tenant_id = ?) -
LEFT JOIN users u ON o.user_id = u.id不够,必须补上AND u.tenant_id = o.tenant_id;如果users是共享表(如系统管理员),就得走中间映射表,不能默认“主表 tenant_id 会透传” - MySQL 5.7 对
IN嵌套优化差,优先用EXISTS,但EXISTS子句里也得写i.tenant_id = o.tenant_id,否则关联失效
禁止从 SQL 里动态查 tenant_id
tenant_id 不该是数据库算出来的,它必须来自认证上下文——JWT 解析后取值、拦截器注入、ThreadLocal 传递。任何试图“反查”的写法都是高危漏洞。
- 绝对禁用:
WHERE tenant_id = (SELECT tenant_id FROM users WHERE id = ?)—— 可能返回多行报错,或用户被禁用导致结果为空,进而让整个条件变成WHERE tenant_id = NULL,查不到数据或意外放行 - 真实场景中,
tenant_id应由网关或认证模块解析并透传到 DAO 层,SQL 只做“给定租户 ID 后查数据” - MyBatis 的
<if test="tenantId != null">动态拼接是陷阱:一旦参数为 null,条件直接跳过,等同于裸奔
PostgreSQL RLS 是最后一道不可绕过的防线
RLS 把 “每条 SQL 都要加 tenant_id” 从开发约定升级为数据库强制行为。即使报表脚本、DBA 直连、临时 SQL 都逃不过它的约束。
- 必须先执行:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY,否则策略不生效 - 策略示例:
CREATE POLICY tenant_isolation ON orders FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid) - 应用层需在连接后执行:
SET LOCAL app.current_tenant_id = 'xxx',且该值必须由可信上下文注入,不能来自 URL 或 header 拼接 - RLS 不替代应用层过滤,而是兜底——窗口函数仍需
PARTITION BY tenant_id,否则编号跨租户混乱;聚合统计仍需外层WHERE tenant_id = ?先筛再算
原生 SQL 和异步任务是高频越权盲区
@Query(native = true)、NamedParameterJdbcTemplate、CompletableFuture、Quartz 定时任务,这些地方天然脱离 ORM 租户拦截器,最容易出事。
- 所有原生查询必须显式写
WHERE tenant_id = :tenantId,且参数:tenantId必须由中间件注入,绝不可绑定前端传入的值 - @Async 方法默认不继承 ThreadLocal,必须配置
TaskDecorator手动传递TenantContext - Quartz Job 中禁用全局
TenantContext.getTenantId(),改用JobDataMap显式传入tenant_id - 日志框架(如 Logback MDC)也需同步注入
tenant_id,否则审计日志无法定位泄露源头
最危险的不是写错 SQL,而是以为“只要防住注入就安全了”。tenant_id 过滤缺失 + 用户输入未校验,才是跨租户泄露的真实组合拳。RLS 策略和中间件自动注入不是可选项,是上线前必须验证的硬性门槛。

















