多人共享一套系统时,最危险的问题通常不是“某个按钮不该显示”,而是:

用户能否通过修改请求参数,读到另一个团队的数据?

这正是多租户架构要解决的核心问题。它不仅是一套角色权限表,还要把租户边界贯彻到数据库、缓存、文件、向量索引、密钥和异步任务中。

本文以 Dify 的 Workspace 设计为例,认识一套多租户系统应该如何建立完整的隔离链路。

一、先分清账号、租户和成员关系

Dify 中可以把几个核心概念理解为:

  • Account:登录系统的人。
  • Tenant / Workspace:团队拥有的工作空间。
  • TenantAccountJoin:账号与工作空间之间的成员关系,记录角色和状态。
  • 业务资源:应用、知识库、模型凭证等,归属于某个租户。

一个账号可以加入多个工作空间,一个工作空间也可以拥有多个成员,因此账号和租户是多对多关系。

erDiagram
    ACCOUNT ||--o{ TENANT_ACCOUNT_JOIN : joins
    TENANT ||--o{ TENANT_ACCOUNT_JOIN : has
    TENANT ||--o{ APP : owns
    TENANT ||--o{ DATASET : owns
    TENANT ||--o{ PROVIDER_CREDENTIAL : owns

    ACCOUNT {
        uuid id
        string email
    }
    TENANT {
        uuid id
        string name
    }
    TENANT_ACCOUNT_JOIN {
        uuid tenant_id
        uuid account_id
        string role
    }

这里有一个很重要的区别:

“用户当前选择了哪个工作空间”只是上下文,“用户是否有权访问该工作空间”才是授权。

后端不能因为请求中带了一个 tenant_id,就直接相信它。系统必须根据已登录账号的成员关系,得到经过验证的当前租户。

二、租户隔离要早于对象权限

权限检查通常分为两层:

  1. 租户边界:这个资源是否属于当前租户?
  2. 对象权限:当前成员在租户内是否可以查看或修改它?

例如用户请求编辑一个应用,后端不能只根据应用 ID 查询:

1
2
# 错误示意:只按对象 ID 查询
app = query(App).where(App.id == app_id).first()

更安全的查询应同时限定租户:

1
2
3
4
app = query(App).where(
App.id == app_id,
App.tenant_id == current_tenant_id,
).first()

随后再检查当前成员是否具有编辑应用的角色或权限。

这样即使攻击者猜到了其他租户的应用 ID,也只能得到“资源不存在”,不会进入后续操作。

三、为什么每张业务表都需要租户归属

多租户系统常见的共享数据库方案,是让业务表携带 tenant_id

1
2
3
4
5
apps
├── id
├── tenant_id
├── name
└── ...

这种方式成本较低,也便于统一运维,但它有一个严格前提:

所有读取、更新、删除和统计都必须带上租户条件。

最容易出错的地方包括:

  • 根据主键直接读取对象;
  • 批量更新或删除;
  • 导出、搜索和统计接口;
  • 后台定时任务;
  • 数据库关联查询;
  • 管理员临时脚本。

因此,租户过滤不应该只靠开发者“记得加”。更稳妥的做法是把它封装进服务层、仓储层或统一的控制器装饰器,并为跨租户访问编写自动化测试。

四、团队权限不能只靠角色名称

进入同一个租户后,不同成员看到的资源仍可能不同。

以知识库为例,Dify 支持类似以下可见范围:

  • ALL_TEAM:团队成员均可访问;
  • ONLY_ME:仅创建者可访问;
  • PARTIAL_TEAM:只对指定成员开放;
  • 更细粒度的企业级 RBAC 权限。

因此,完整授权条件不是简单的:

1
用户是管理员吗?

而更接近:

1
2
3
4
账号属于当前租户
AND 资源属于当前租户
AND 成员角色允许执行该动作
AND 资源可见范围允许访问

租户隔离解决“不同团队之间不能串门”,对象权限解决“同一团队内谁能做什么”。两者缺一不可。

五、缓存也必须有租户命名空间

数据库查询加了 tenant_id,并不代表系统已经安全。缓存若仍使用全局键,也可能造成数据串租户。

例如下面的缓存键存在隐患:

1
api_token:{token_hash}

更明确的设计是把租户纳入键或索引:

1
2
3
tenant_tokens:{tenant_id}
app:{tenant_id}:{app_id}
dataset_permission:{tenant_id}:{account_id}

Dify 的 API Token 缓存会维护租户级索引,便于只失效某个租户的令牌;租户隔离任务队列也会把 tenant_id 写入 Redis 键。

缓存隔离还要注意:

  • 键生成函数是否遗漏租户;
  • 删除资源时是否清理对应租户缓存;
  • 切换工作空间后是否复用了旧上下文;
  • 本地内存缓存是否把不同租户的数据混在一起。

六、模型凭证为什么要按租户加密

模型供应商的 API Key 是多租户系统中最敏感的数据之一。安全设计至少包含三层:

  1. 凭证记录带有 tenant_id
  2. 查询时始终限定当前租户;
  3. 落库前加密,使用时才解密。

Dify 在处理供应商凭证时,会使用租户 ID 参与凭证的加解密过程,并在查询凭证时限定租户。这带来两个好处:

  • 即使应用层查询写错,也更难直接拿另一租户的密文当作有效凭证使用;
  • 同一个明文密钥在不同租户下不会形成完全相同的安全上下文。

不过,加密不能替代授权。密文、密钥管理和租户查询条件必须同时存在。

七、异步任务要显式携带租户上下文

HTTP 请求结束后,请求局部变量也随之消失。Celery Worker 或其他后台进程无法天然知道任务属于哪个租户。

因此任务消息至少应携带:

1
2
3
4
5
{
"tenant_id": "...",
"resource_id": "...",
"operator_id": "..."
}

Worker 收到任务后,应重新验证资源归属,并使用任务中的租户 ID 构造数据库查询、缓存键、文件路径和向量索引命名空间。

flowchart LR
    A[已登录账号] --> B[验证成员关系]
    B --> C[确定当前租户]
    C --> D[查询 tenant_id 下的资源]
    D --> E[检查对象级权限]
    E --> F[投递携带 tenant_id 的任务]
    F --> G[Worker 重建租户上下文]
    G --> H[(数据库)]
    G --> I[(Redis)]
    G --> J[(文件与向量索引)]

不要让后台任务依赖“当前请求的租户”,也不要只传资源 ID 后做全局查询。这类疏忽很容易变成越权访问。

八、隔离范围不止数据库

一套完整的租户边界通常需要覆盖:

组件 常见隔离方式
关系数据库 每行携带 tenant_id,所有查询强制过滤
Redis 缓存键和集合索引包含租户 ID
对象存储 路径前缀包含租户或资源归属
向量数据库 Collection、Namespace 或元数据过滤
消息队列 消息携带租户 ID,并限制单租户并发
日志与追踪 记录租户 ID,但不泄露凭证和隐私数据
模型凭证 租户级查询、加密和轮换

其中,向量检索特别容易被忽略。即使关系数据库已隔离,如果向量查询没有 Namespace 或租户过滤,召回结果仍可能混入其他团队的文档。

九、隔离之外还要考虑资源公平

多租户不仅要防止数据互相访问,还要避免一个租户占满系统资源。

例如某个团队一次导入大量网页,可能持续占用索引 Worker。Dify 的租户隔离任务队列会按租户组织排队状态,并配合并发限制,让任务调度更公平。

常见治理手段包括:

  • 单租户并发上限;
  • API 速率限制;
  • 模型调用额度;
  • 文件容量和知识库数量限制;
  • 超时、取消和失败重试;
  • 按租户统计成本与用量。

这里的“隔离”已经从数据安全延伸到了性能和成本边界。

十、如何测试跨租户安全

多租户测试不能只覆盖正常流程。最值得自动化的是“拿 A 租户身份访问 B 租户资源”的反向用例。

建议至少检查:

  1. 使用租户 A 的成员身份登录;
  2. 请求租户 B 的应用、知识库或凭证 ID;
  3. 分别尝试读取、更新、删除、导出和触发异步任务;
  4. 确认接口拒绝访问,且没有通过缓存、日志或错误信息泄露数据;
  5. 验证 Worker、向量检索和对象存储同样不能越界。

这类测试可以及时发现典型的 IDOR(不安全的直接对象引用)问题。

十一、总结

Dify 的多租户设计可以归纳为一条连续的安全链:

1
2
3
4
5
6
7
验证账号与租户关系
→ 确定可信的当前租户
→ 按 tenant_id 查询资源
→ 执行对象级权限检查
→ 隔离缓存、凭证、存储和索引
→ 把租户上下文传入异步任务
→ 限制单租户资源使用

真正可靠的多租户架构,不是给表增加一个 tenant_id 字段就结束了,而是让租户边界成为每一次数据访问和任务执行都无法绕开的约束。