SaaS 多租户权限案例:别把隔离只放在数据库查询里
一条 WHERE 子句漏掉之后
我见过一个 SaaS 项目的线上事故:运维在排查慢查询时,发现一条 SELECT * FROM orders WHERE status = 'pending' 语句没有带 tenant_id 条件。这条查询本身逻辑没问题,它确实返回了「待处理订单」——但返回的是一千多个租户的全部待处理订单。
问题不在数据库层面。团队在数据库里给每张表都加了 tenant_id 字段,ORM 层也有全局 scope 自动拼接租户条件。但有人写报表导出功能时绕过了 ORM,直接拼了原生 SQL。一条 SQL 就穿透了隔离边界。
这类事情之所以反复发生,是因为很多团队把「多租户隔离」等同于「数据库查询加 tenant_id」。实际上,请求从入口到响应,要经过网关、鉴权中间件、业务逻辑、缓存、消息队列、文件存储和日志系统。任何一环丢掉租户上下文,隔离就失效了。
这篇文章把整条链路上容易出问题的地方逐个拆开,配合真实案例和代码,最后给一份可执行的检查清单。
隔离不是一个点,是一条链路
WorkOS 的多租户架构指南把隔离模型分成三档:共享运行时(Shared-Runtime)、多实例(Multi-Instance)和单租户(Single-Tenant)。绝大多数 B2B SaaS 走的是共享运行时路线——同一套基础设施,靠应用层逻辑做隔离。这种模式成本低,但要求开发者把租户标识当作「一等维度」贯穿所有系统层。
Redis 官方博客在讨论多租户数据隔离时提到一个关键区分:数据分区不等于数据隔离。你可以按 tenant_id 做物理分区,但一个遗漏的过滤条件仍然能让查询跨分区读取。分区是存储组织方式,隔离是访问边界强制机制。两者缺一不可。
下面的流程图展示了一个典型请求需要经过的租户上下文传递路径:
每一个箭头都是一次租户上下文传递。任何一个节点丢失 tenantId,下游就会出问题。
案例一:缓存 Key 没带租户前缀,A 公司看到了 B 公司的报表
场景
某 SaaS 数据看板项目,用 Redis 缓存每个租户的聚合报表数据。缓存 Key 的命名规则是 report:daily:{date}。
翻车
上线三个月后,一个客户在工单里截图说:「我们今天的日活数据里,出现了另一个客户的公司名。」
排查发现,缓存 Key 没有租户维度。两个同一天生成报表的租户,后写入的会覆盖前一个的缓存。读缓存时,所有租户拿到的是同一份数据。
修复
把缓存 Key 改为 report:{tenantId}:daily:{date},所有读写缓存的地方都必须带上 tenantId。同时在缓存服务入口加一层断言:如果 tenantId 为空,直接抛异常,不静默降级。
坏的写法——缓存 Key 没有租户维度:
// ❌ 所有租户共享同一个 Key,后写入覆盖前写入
async function getCachedReport(date: string) {
const key = `report:daily:${date}`
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const report = await db.report.generateDaily(date)
await redis.set(key, JSON.stringify(report), 'EX', 3600)
return report
}好的写法——缓存 Key 包含租户标识,且强制校验:
// ✅ 租户维度作为 Key 的一部分,物理隔离缓存数据
async function getCachedReport(tenantId: string, date: string) {
if (!tenantId) {
throw new TenantContextError('tenantId is required for cache key')
}
const key = `report:${tenantId}:daily:${date}`
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const report = await db.report.generateDaily(tenantId, date)
await redis.set(key, JSON.stringify(report), 'EX', 3600)
return report
}Redis 官方建议的多租户缓存 Key 命名格式是 tenant:{tenant-id}:{resource-type}:{resource-id},配合 ACL 的 key-pattern 限制,可以做到应用层和基础设施层的双重隔离。
案例二:后台队列任务只传了订单 ID,没传租户 ID
场景
一个电商 SaaS 平台,订单支付成功后会发一条消息到 RabbitMQ,后台 worker 消费消息后调用物流接口发货。
翻车
某次发版后,worker 开始给错误的租户发货。排查发现,消息体里只有 orderId,worker 拿到后直接查 orders 表取地址信息。但查询没有带 tenant_id(或者说,ORM 的租户 scope 在 worker 进程里没有正确初始化)。worker 拿到的订单地址可能属于另一个租户。
更隐蔽的是:这个问题在测试环境不会暴露,因为测试环境只有两三个租户,数据量小,碰撞概率低。
修复
消息体必须包含完整的上下文:tenantId、userId(操作者)和 resourceId。Worker 在消费消息时,第一步是建立租户上下文,然后再执行业务逻辑。
坏的消息体——只传资源 ID:
// ❌ Worker 拿到 orderId 后需要自己查租户,容易漏掉隔离
{
"eventType": "order.paid",
"orderId": "ord_abc123"
}好的消息体——携带完整租户上下文:
// ✅ Worker 可以直接建立租户上下文,不需要额外查询
{
"eventType": "order.paid",
"tenantId": "tenant_001",
"userId": "user_xyz",
"orderId": "ord_abc123",
"traceId": "trace_20260626_001"
}Worker 端的处理逻辑也应该对称——先解析租户上下文,再执行业务:
// ✅ Worker 入口:先建立上下文,再做业务
async function handleOrderPaid(message: OrderPaidMessage) {
const { tenantId, userId, orderId } = message
// 用租户上下文包装业务逻辑
await runWithTenantContext(tenantId, async () => {
const order = await orderRepo.getById(orderId) // 内部自动带 tenantId
if (!order) {
logger.warn({ tenantId, orderId }, 'Order not found in tenant scope')
return
}
await shippingService.dispatch(order, userId)
})
}Kafka 的多租户指南同样强调:Topic 命名要带租户前缀,Consumer Group 不是安全边界,分区键(partition key)应基于租户 ID 路由。这些措施和消息体里携带 tenantId 是互补关系,不是替代关系。
案例三:S3 文件存储共用一个 Bucket 前缀
场景
一个项目管理 SaaS,用户可以上传附件(需求文档、设计稿等)。早期开发时,所有文件上传到同一个 S3 Bucket,Key 的格式是 attachments/{fileId}。
翻车
安全审计时发现:任何已登录用户,只要猜到或枚举到 fileId,就能下载其他租户的附件。因为 S3 的访问控制只做了「已登录用户可读」,没有做租户级别的隔离。
修复
文件存储路径改为 attachments/{tenantId}/{fileId},同时在 S3 Bucket Policy 里限制:每个租户的 IAM Role 只能访问 attachments/{tenantId}/* 前缀下的对象。对于合规要求更高的租户,使用独立的 KMS 加密密钥。
坏的路由——文件路径没有租户维度:
# ❌ 任何人拿到 fileId 就能访问,无法按租户隔离
def upload_file(file_id: str, file_data: bytes):
s3_key = f"attachments/{file_id}"
s3_client.put_object(Bucket="app-files", Key=s3_key, Body=file_data)
return s3_key好的路由——路径包含租户维度,配合 IAM 策略:
# ✅ 文件路径按租户隔离,配合 IAM Policy 限制前缀访问
def upload_file(tenant_id: str, file_id: str, file_data: bytes):
s3_key = f"attachments/{tenant_id}/{file_id}"
s3_client.put_object(Bucket="app-files", Key=s3_key, Body=file_data)
return s3_key
# IAM Policy 示例(绑定到租户专属 Role)
# {
# "Effect": "Allow",
# "Action": ["s3:GetObject", "s3:PutObject"],
# "Resource": "arn:aws:s3:::app-files/attachments/${aws:PrincipalTag/tenantId}/*"
# }AWS 在多租户 S3 访问控制的博客中总结了四种模式:独立 Bucket、共享 Bucket + 前缀隔离、S3 Access Points 和 S3 Access Grants。对于大多数中小规模 SaaS,共享 Bucket + 前缀隔离配合 IAM 条件键是最务实的选择。
不同层的隔离策略对比
下面这张表把数据库、缓存、队列、文件存储和日志五层的隔离手段做了对比:
| 层级 | 隔离手段 | 最小成本做法 | 高安全做法 | 常见翻车点 |
|---|---|---|---|---|
| 数据库 | tenant_id 字段 / 独立 Schema / 独立库 | 共享表 + tenant_id + ORM Scope | 每租户独立库 + RLS | 绕过 ORM 写原生 SQL |
| 缓存 | Key 前缀 / ACL 限制 / 独立实例 | tenant:{id}: 前缀 + ACL | 每租户独立 Redis 实例 | Key 拼写漏掉租户维度 |
| 消息队列 | Topic 前缀 / 消息体携带 tenantId | 共享 Topic + 消息体带 tenantId | 每租户独立 Topic + 分区键 | Worker 进程没初始化租户上下文 |
| 文件存储 | 路径前缀 / IAM Policy / KMS | {tenantId}/ 前缀 + Bucket Policy | 独立 Bucket + 独立 KMS Key | 文件 Key 没有租户维度 |
| 日志系统 | 结构化字段 + 查询过滤 | 日志带 tenantId 字段 | 每租户独立日志流 | 日志泄露其他租户敏感信息 |
请求入口:租户上下文的建立和传递
请求进入 API 时,第一件事是解析身份上下文:用户是谁、属于哪个组织、当前操作的是哪个租户、角色和权限是什么。这个上下文应该在入口处一次性解析完毕,注入到请求对象或异步本地存储(AsyncLocalStorage)中,后续所有服务层直接从上下文中读取,而不是让每层自己去猜。
WorkOS 的架构指南把这个过程称为「两阶段认证」:先验证用户全局身份(你是谁),再解析租户归属和当前活跃组织(你在替谁操作)。这两个阶段不能合并,因为一个用户可能属于多个租户。
坏的写法——每层自己解析租户,来源不统一:
// ❌ 租户 ID 来自请求体,客户端可以伪造
app.post('/api/orders', async (req, res) => {
const { tenantId, items } = req.body // 租户 ID 由客户端传入
const orders = await orderService.create(tenantId, items)
res.json(orders)
})好的写法——租户上下文由中间件统一注入,业务层不接触原始 tenantId:
// ✅ 中间件解析 JWT → 确定租户 → 注入上下文
// 业务层从上下文中读取,不接受客户端传入的 tenantId
app.post('/api/orders', tenantMiddleware, async (req, res) => {
const ctx = req.tenantContext // 由中间件注入,来源是 JWT + 租户解析
const { items } = req.body
const orders = await orderService.create(ctx.tenantId, items)
res.json(orders)
})
async function tenantMiddleware(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '')
const payload = await verifyJWT(token)
// 从 JWT 的 org_id claim 解析租户,而不是从请求体
const tenant = await tenantService.resolve(payload.org_id)
if (!tenant) {
return res.status(403).json({ code: 'TENANT_NOT_FOUND' })
}
req.tenantContext = {
tenantId: tenant.id,
userId: payload.sub,
roles: payload.roles,
}
next()
}权限校验失败时,错误码要稳定且明确。我推荐用结构化的错误码而不是笼统的 403 Forbidden,方便前端做差异化处理和日志审计:
| 错误码 | 含义 | 前端处理 |
|---|---|---|
TENANT_NOT_FOUND | 用户不属于该租户 | 提示切换到正确组织 |
TENANT_SUSPENDED | 租户已停用 | 显示停用说明页 |
ROLE_INSUFFICIENT | 角色权限不足 | 提示联系管理员 |
RESOURCE_NOT_IN_TENANT | 资源不属于当前租户 | 返回 404,不暴露资源存在性 |
注意最后一条:当请求的资源不属于当前租户时,应该返回 404 而不是 403。如果返回 403,等于告诉攻击者「这个资源存在,但你没权限」,这会暴露跨租户资源的存在性信息。
权限模型选型对比
不同的 SaaS 业务场景适合不同的权限模型。下面这张表对比了三种常见模型:
| 维度 | RBAC(基于角色) | ABAC(基于属性) | ReBAC(基于关系) |
|---|---|---|---|
| 核心概念 | 用户 → 角色 → 权限 | 用户属性 + 资源属性 + 环境属性 → 策略 | 用户与资源的关系 → 权限 |
| 适合场景 | 内部系统、角色明确 | 多租户 SaaS、需要细粒度控制 | 社交/协作类、关系复杂 |
| 多租户适配 | 每租户独立角色体系 | 租户作为属性参与策略计算 | 租户作为命名空间隔离关系 |
| 复杂度 | 低 | 中高 | 高 |
| 典型实现 | Casbin、Spring Security | AWS Cedar、OPA | SpiceDB、OpenFGA |
| 常见坑 | 角色爆炸(几十个角色) | 策略调试困难 | 关系图查询性能 |
对于大多数 B2B SaaS,我的建议是 RBAC + 租户维度作为基座,遇到「某个租户的管理员能否查看另一个租户的公开报表」这类跨租户场景时,再引入 ABAC 策略做补充。不需要一上来就上 ReBAC。
隔离策略与租户规模的匹配
租户数量不同,适合的隔离策略也不同。下面是按规模的推荐:
| 租户规模 | 数据库策略 | 缓存策略 | 文件存储策略 | 关键动作 |
|---|---|---|---|---|
| 10-100 | 共享表 + tenant_id | 单实例 + Key 前缀 | 共享 Bucket + 路径前缀 | 建立基础隔离模式 |
| 100-1000 | Schema 分离或分区 | ACL 限制 + 连接池 | IAM 条件键 + Bucket Policy | 引入租户级监控和限流 |
| 1000-10000 | 混合模式(大客独立库) | 大客独立 Redis 实例 | 大客独立 Bucket + KMS | Silo + Pool 混合架构 |
| 10000+ | 分片 + 租户感知路由 | 集群 + 租户感知分区 | CDN + 租户感知签名 URL | 自动化租户生命周期管理 |
Redis 官方博客特别提到:分片是一扇「单向门」——一旦做了分片,回滚极其复杂。所以在 1000 租户以下时,优先通过索引优化、读写分离和连接池来解决性能问题,不要轻易分片。
验证隔离有效性
写了隔离代码不算完,还要证明它有效。测试要覆盖以下场景:
单元测试层——验证 ORM Scope、缓存 Key 生成、消息体序列化等基础组件是否正确携带租户上下文:
// ✅ 测试:跨租户查询应该被 Scope 拦截
it('should not return orders from other tenants', async () => {
const tenantAOrders = await runWithTenantContext('tenant_a', () =>
orderRepo.findAll()
)
const tenantBOrders = await runWithTenantContext('tenant_b', () =>
orderRepo.findAll()
)
// 两个租户的结果集不应该有交集
const aIds = new Set(tenantAOrders.map(o => o.id))
const bIds = new Set(tenantBOrders.map(o => o.id))
expect([...aIds].filter(id => bIds.has(id))).toHaveLength(0)
})集成测试层——模拟跨租户请求,验证 API 层、缓存层和队列层的隔离是否端到端生效。
安全测试层——用 DAST 工具(如 Escape.tech 的多用户测试功能)自动化检测 IDOR(不安全直接对象引用)漏洞。这类工具会创建一个租户的数据,然后用另一个租户的 Token 去访问,检查是否能穿透。
全链路租户隔离检查清单
请求入口阶段
- API 网关或中间件是否在每个请求入口解析了租户上下文?
- 租户 ID 是否来自可信来源(JWT、Session),而不是客户端请求体?
- 多租户用户的活跃组织切换是否有明确的 API 和上下文刷新机制?
- 权限校验失败时是否返回结构化错误码,且不暴露资源存在性?
数据访问阶段
- ORM 是否配置了全局租户 Scope,且原生 SQL 有审计机制防止绕过?
- 数据库是否启用了 Row-Level Security(PostgreSQL)或等效机制作为兜底?
- 批量操作(批量更新、批量删除)是否正确传递了租户上下文?
缓存与队列阶段
- 所有缓存 Key 是否包含租户维度?缓存失效策略是否按租户粒度控制?
- 消息队列的消息体是否携带 tenantId、userId 和 traceId?
- Worker 进程是否在消费消息前初始化了租户上下文?
文件与日志阶段
- 文件存储路径是否包含租户前缀?IAM 策略是否限制了前缀级访问?
- 日志中是否包含 tenantId 字段?日志查询工具是否按租户做了访问控制?
- 导出的文件(CSV、PDF)是否在文件名和内容中绑定了租户标识?
监控与运维阶段
- 是否建立了租户维度的监控看板(错误率、调用量、异常访问)?
- 是否有跨租户访问的告警规则?例如同一个请求在短时间内访问了多个租户的数据。
- 租户停用时,是否能一键切断该租户的所有 API 访问、缓存和队列消费?
小结
多租户隔离不是一个功能,是一种贯穿系统各层的架构约束。数据库的 tenant_id 只是起点,缓存 Key、消息体、文件路径和日志字段都需要带上租户维度。
我在本文里列出的三个案例——缓存覆盖、队列越权和文件泄露——都是从真实项目中提炼出来的。它们的共同点是:数据库层面做了隔离,但其他层漏掉了。
最务实的做法是:在设计阶段就把「租户上下文传递路径」画出来(像本文的 Mermaid 图那样),然后逐层检查每个节点是否正确传递了 tenantId。不要等到上线后靠安全审计来发现问题。
参考资料
- Data isolation in multi-tenant SaaS environments — Redis Blog
- The developer's guide to SaaS multi-tenant architecture — WorkOS
- Design patterns for multi-tenant access control on Amazon S3 — AWS Blog
- SaaS Tenant Isolation Strategies — AWS Whitepaper
- 多租户身份验证和授权设置的终极指南 — Logto Blog
- 使用 RBAC 和 ABAC 进行多租户访问控制 — AWS Documentation
- 一文讲透企业级权限管理:RBAC+数据权限+多租户隔离 — 博客园