多租户系统最危险的 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 可靠。