SaaS 多租户权限案例:别把隔离只放在数据库查询里

0阅读10分钟

一条 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 做物理分区,但一个遗漏的过滤条件仍然能让查询跨分区读取。分区是存储组织方式,隔离是访问边界强制机制。两者缺一不可。

下面的流程图展示了一个典型请求需要经过的租户上下文传递路径:

流程图画布 · 115%
Mermaid 流程图加载中...

每一个箭头都是一次租户上下文传递。任何一个节点丢失 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 拿到的订单地址可能属于另一个租户。

更隐蔽的是:这个问题在测试环境不会暴露,因为测试环境只有两三个租户,数据量小,碰撞概率低。

修复

消息体必须包含完整的上下文:tenantIduserId(操作者)和 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 SecurityAWS Cedar、OPASpiceDB、OpenFGA
常见坑角色爆炸(几十个角色)策略调试困难关系图查询性能

对于大多数 B2B SaaS,我的建议是 RBAC + 租户维度作为基座,遇到「某个租户的管理员能否查看另一个租户的公开报表」这类跨租户场景时,再引入 ABAC 策略做补充。不需要一上来就上 ReBAC。

隔离策略与租户规模的匹配

租户数量不同,适合的隔离策略也不同。下面是按规模的推荐:

租户规模数据库策略缓存策略文件存储策略关键动作
10-100共享表 + tenant_id单实例 + Key 前缀共享 Bucket + 路径前缀建立基础隔离模式
100-1000Schema 分离或分区ACL 限制 + 连接池IAM 条件键 + Bucket Policy引入租户级监控和限流
1000-10000混合模式(大客独立库)大客独立 Redis 实例大客独立 Bucket + KMSSilo + 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 去访问,检查是否能穿透。

全链路租户隔离检查清单

请求入口阶段

  1. API 网关或中间件是否在每个请求入口解析了租户上下文?
  2. 租户 ID 是否来自可信来源(JWT、Session),而不是客户端请求体?
  3. 多租户用户的活跃组织切换是否有明确的 API 和上下文刷新机制?
  4. 权限校验失败时是否返回结构化错误码,且不暴露资源存在性?

数据访问阶段

  1. ORM 是否配置了全局租户 Scope,且原生 SQL 有审计机制防止绕过?
  2. 数据库是否启用了 Row-Level Security(PostgreSQL)或等效机制作为兜底?
  3. 批量操作(批量更新、批量删除)是否正确传递了租户上下文?

缓存与队列阶段

  1. 所有缓存 Key 是否包含租户维度?缓存失效策略是否按租户粒度控制?
  2. 消息队列的消息体是否携带 tenantId、userId 和 traceId?
  3. Worker 进程是否在消费消息前初始化了租户上下文?

文件与日志阶段

  1. 文件存储路径是否包含租户前缀?IAM 策略是否限制了前缀级访问?
  2. 日志中是否包含 tenantId 字段?日志查询工具是否按租户做了访问控制?
  3. 导出的文件(CSV、PDF)是否在文件名和内容中绑定了租户标识?

监控与运维阶段

  1. 是否建立了租户维度的监控看板(错误率、调用量、异常访问)?
  2. 是否有跨租户访问的告警规则?例如同一个请求在短时间内访问了多个租户的数据。
  3. 租户停用时,是否能一键切断该租户的所有 API 访问、缓存和队列消费?

小结

多租户隔离不是一个功能,是一种贯穿系统各层的架构约束。数据库的 tenant_id 只是起点,缓存 Key、消息体、文件路径和日志字段都需要带上租户维度。

我在本文里列出的三个案例——缓存覆盖、队列越权和文件泄露——都是从真实项目中提炼出来的。它们的共同点是:数据库层面做了隔离,但其他层漏掉了。

最务实的做法是:在设计阶段就把「租户上下文传递路径」画出来(像本文的 Mermaid 图那样),然后逐层检查每个节点是否正确传递了 tenantId。不要等到上线后靠安全审计来发现问题。

参考资料

评论 0

0 / 1000