多租户系统最危险的 bug 往往非常普通:一个新列表接口忘了加 WHERE tenant_id = ?。2022 年代码评审里我们发现过一次类似问题,幸好还没上线。查询语法完全正确,测试数据又只有一个租户,所有用例都通过。

如果租户隔离只依靠开发者记得加条件,它迟早会失效。我们把 tenantId 从可选业务参数提升为安全上下文,在认证、服务、仓储、数据库和审计五层重复约束。

多租户请求从认证上下文到数据库策略与审计的五道边界

图 1:纵深防御不是重复浪费;任意一层遗漏时,下一层仍能阻止跨租户数据返回。

tenantId 只能来自可信身份

客户端可以选择当前工作空间,但不能自由声明自己属于哪个租户。网关根据会话和成员关系解析上下文:

type RequestContext = {
  actorId: string;
  tenantId: string;
  roles: string[];
  requestId: string;
};
 
async function resolveContext(session: Session, requestedTenant: string) {
  const membership = await memberships.find(session.userId, requestedTenant);
  if (!membership) throw new ForbiddenError("TENANT_ACCESS_DENIED");
  return { actorId: session.userId, tenantId: requestedTenant, roles: membership.roles };
}

后续代码接收 RequestContext,不再从 query/body 重新读取 tenantId。这样用户输入和授权结果不会混成同一个字符串。

仓储接口强制要求上下文

class CustomerRepository {
  constructor(private readonly db: Database) {}
 
  list(ctx: RequestContext, filter: CustomerFilter) {
    return this.db.query(
      "SELECT * FROM customers WHERE tenant_id = ? AND status = ?",
      [ctx.tenantId, filter.status],
    );
  }
}

不提供 listAll() 给普通业务层。确实需要跨租户的后台任务使用另一套明确命名、权限更高的接口,并要求 reason 和审计。

数据库层再做一次策略兜底

支持 Row-Level Security 的数据库可以在事务开始时设置租户上下文,让策略自动过滤:

ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON customers
USING (tenant_id = current_setting('app.tenant_id'));

应用连接池必须在每个事务显式设置并结束后清理上下文,不能让租户状态泄漏到下一位请求。数据库策略也不能替代服务层授权:它保护行范围,不一定理解“某角色能不能导出”这样的动作。

唯一约束和缓存 key 也要带租户

隔离不仅发生在 SELECT:

UNIQUE (tenant_id, external_customer_id)

缓存 key 使用 tenant:{tenantId}:customer:{id};对象存储路径、搜索索引、消息 payload 和指标标签都携带租户。漏掉任何一个共享系统,都可能形成旁路。

边界 典型遗漏 验证
数据库 查询漏过滤 两租户同 ID 的集成测试
缓存 key 只含资源 ID 交替请求检查缓存污染
队列 Worker 无租户上下文 消息 schema 强制 tenantId
导出 后台任务使用管理员连接 导出数量与租户数据对账
搜索 索引 filter 可选 服务端固定注入租户过滤

测试必须故意制造相同 ID

如果测试数据里两个租户的资源 ID 永不重复,漏过滤可能仍然看不出来。我们给租户 A、B 创建相同业务编号、相似名称和不同秘密字段,所有读取、更新、删除、搜索、导出都做负向断言。

跨租户管理员功能单独测试:授权明确、界面显著提示当前范围、操作需要理由、结果进入审计。不能因为运维方便就在普通请求里增加 allTenants=true

这次险情没有造成真实数据泄漏,但它改变了架构标准。tenantId 不是开发者自觉添加的过滤器,而是贯穿系统的安全上下文。让错误必须同时穿过五层防线,远比要求每个人永远不忘一行 WHERE 可靠。