UIAM · 架构

为什么租户隔离要写在查询层,而不是文档里

多租户系统最危险的漏洞不是「写错了」,而是「忘了写」。我们把租户谓词从开发者的记忆里搬到了 jOOQ 的访问监听器上。

任何一个多租户系统上线一段时间后,都会遇到同一个问题:某张表的查询漏了 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 null
03
04 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 null
13}

唯一可信源是令牌,不是请求头

tenant_id 只从 JWT 里取。如果请求头里也声明了租户,两者不一致就直接拒绝——请求头是调用方可以随便写的,令牌不是。

  • TenantContextFilter 从 JWT 解析 tenant_id 写入请求上下文
  • 请求头声明与令牌不一致 → TenantMismatchException(403)
  • 确需跨租户的系统级操作走 runAsSystem 显式绕过,绕过本身进审计
  • 请求结束清理 UserContext,避免线程池复用导致跨请求泄漏

代价是什么

这套机制不是免费的。它要求所有租户作用域表都必须有一个统一的租户列名约定;它也要求开发者在写确需跨租户的查询时,必须显式调用绕过通道——这比「随手写个不带条件的查询」麻烦。

我们接受这个麻烦。因为在多租户系统里,「麻烦一点」和「数据泄漏」不是同一量级的问题。

More Notes
Get in touch

把身份、Agent 与私域的复杂度,交给一套可治理的内核

无论你是要替换现有的 IAM、搭建 Agent 中台,还是想把企微私域真正运营起来——先从一次 30 分钟的架构沟通开始。我们会先判断问题是不是我们擅长解的那类,不合适会直接说。