任何一个多租户系统上线一段时间后,都会遇到同一个问题:某张表的查询漏了 tenant_id 条件。代码评审时它看起来完全正常——SQL 语法没问题,业务逻辑也说得通,只是多返回了几行本不该被看到的数据。
这类问题的根源是把安全边界放在了「开发者记得写」这个前提上。而人会忘、新人不知道、重构会丢。所以我们的做法是:把租户谓词的强制检查下沉到数据访问层。
集中声明,而不是分散记忆
第一步是把「哪些表属于租户作用域」变成一份集中注册表。45 张表在 TenantScopedTables 里声明一次,而不是散落在几十个 Mapper 里靠约定。
uiam-core — 租户作用域表注册
01// 集中注册:租户作用域表是一份清单,不是隐式约定02object TenantScopedTables {03 val ALL: Set<String> = setOf(04 "sys_user", "sys_role", "sys_permission",05 "sys_dept", "sys_space", "sys_audit_event",06 // …共 45 张07 )08}在查询层拦截缺失的谓词
第二步是让 jOOQ 在每次查询时检查:这条 SQL 命中了租户作用域表吗?如果命中,它的 WHERE 里带 tenant 谓词了吗?没带的话,按配置决定是记录安全日志还是直接抛错。
uiam-core — TenantScopeVisitListener
01override fun visit(ctx: VisitContext): Queries? {02 if (!touchesTenantScopedTable(ctx.query())) return null0304 if (!hasTenantPredicate(ctx.query())) {05 // DETECT_LOG:只记录,用于灰度观察06 // DETECT_THROW:直接失败,生产环境默认07 when (mode) {08 DETECT_LOG -> audit.missingTenantPredicate(ctx)09 DETECT_THROW -> throw MissingTenantPredicateException()10 }11 }12 return null13}唯一可信源是令牌,不是请求头
tenant_id 只从 JWT 里取。如果请求头里也声明了租户,两者不一致就直接拒绝——请求头是调用方可以随便写的,令牌不是。
- TenantContextFilter 从 JWT 解析 tenant_id 写入请求上下文
- 请求头声明与令牌不一致 → TenantMismatchException(403)
- 确需跨租户的系统级操作走 runAsSystem 显式绕过,绕过本身进审计
- 请求结束清理 UserContext,避免线程池复用导致跨请求泄漏
代价是什么
这套机制不是免费的。它要求所有租户作用域表都必须有一个统一的租户列名约定;它也要求开发者在写确需跨租户的查询时,必须显式调用绕过通道——这比「随手写个不带条件的查询」麻烦。
我们接受这个麻烦。因为在多租户系统里,「麻烦一点」和「数据泄漏」不是同一量级的问题。