多人共享一套系统时,最危险的问题通常不是“某个按钮不该显示”,而是:
用户能否通过修改请求参数,读到另一个团队的数据?
这正是多租户架构要解决的核心问题。它不仅是一套角色权限表,还要把租户边界贯彻到数据库、缓存、文件、向量索引、密钥和异步任务中。
本文以 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,就直接相信它。系统必须根据已登录账号的成员关系,得到经过验证的当前租户。
二、租户隔离要早于对象权限
权限检查通常分为两层:
- 租户边界:这个资源是否属于当前租户?
- 对象权限:当前成员在租户内是否可以查看或修改它?
例如用户请求编辑一个应用,后端不能只根据应用 ID 查询:
1 | # 错误示意:只按对象 ID 查询 |
更安全的查询应同时限定租户:
1 | app = query(App).where( |
随后再检查当前成员是否具有编辑应用的角色或权限。
这样即使攻击者猜到了其他租户的应用 ID,也只能得到“资源不存在”,不会进入后续操作。
三、为什么每张业务表都需要租户归属
多租户系统常见的共享数据库方案,是让业务表携带 tenant_id:
1 | apps |
这种方式成本较低,也便于统一运维,但它有一个严格前提:
所有读取、更新、删除和统计都必须带上租户条件。
最容易出错的地方包括:
- 根据主键直接读取对象;
- 批量更新或删除;
- 导出、搜索和统计接口;
- 后台定时任务;
- 数据库关联查询;
- 管理员临时脚本。
因此,租户过滤不应该只靠开发者“记得加”。更稳妥的做法是把它封装进服务层、仓储层或统一的控制器装饰器,并为跨租户访问编写自动化测试。
四、团队权限不能只靠角色名称
进入同一个租户后,不同成员看到的资源仍可能不同。
以知识库为例,Dify 支持类似以下可见范围:
ALL_TEAM:团队成员均可访问;ONLY_ME:仅创建者可访问;PARTIAL_TEAM:只对指定成员开放;- 更细粒度的企业级 RBAC 权限。
因此,完整授权条件不是简单的:
1 | 用户是管理员吗? |
而更接近:
1 | 账号属于当前租户 |
租户隔离解决“不同团队之间不能串门”,对象权限解决“同一团队内谁能做什么”。两者缺一不可。
五、缓存也必须有租户命名空间
数据库查询加了 tenant_id,并不代表系统已经安全。缓存若仍使用全局键,也可能造成数据串租户。
例如下面的缓存键存在隐患:
1 | api_token:{token_hash} |
更明确的设计是把租户纳入键或索引:
1 | tenant_tokens:{tenant_id} |
Dify 的 API Token 缓存会维护租户级索引,便于只失效某个租户的令牌;租户隔离任务队列也会把 tenant_id 写入 Redis 键。
缓存隔离还要注意:
- 键生成函数是否遗漏租户;
- 删除资源时是否清理对应租户缓存;
- 切换工作空间后是否复用了旧上下文;
- 本地内存缓存是否把不同租户的数据混在一起。
六、模型凭证为什么要按租户加密
模型供应商的 API Key 是多租户系统中最敏感的数据之一。安全设计至少包含三层:
- 凭证记录带有
tenant_id; - 查询时始终限定当前租户;
- 落库前加密,使用时才解密。
Dify 在处理供应商凭证时,会使用租户 ID 参与凭证的加解密过程,并在查询凭证时限定租户。这带来两个好处:
- 即使应用层查询写错,也更难直接拿另一租户的密文当作有效凭证使用;
- 同一个明文密钥在不同租户下不会形成完全相同的安全上下文。
不过,加密不能替代授权。密文、密钥管理和租户查询条件必须同时存在。
七、异步任务要显式携带租户上下文
HTTP 请求结束后,请求局部变量也随之消失。Celery Worker 或其他后台进程无法天然知道任务属于哪个租户。
因此任务消息至少应携带:
1 | { |
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 租户资源”的反向用例。
建议至少检查:
- 使用租户 A 的成员身份登录;
- 请求租户 B 的应用、知识库或凭证 ID;
- 分别尝试读取、更新、删除、导出和触发异步任务;
- 确认接口拒绝访问,且没有通过缓存、日志或错误信息泄露数据;
- 验证 Worker、向量检索和对象存储同样不能越界。
这类测试可以及时发现典型的 IDOR(不安全的直接对象引用)问题。
十一、总结
Dify 的多租户设计可以归纳为一条连续的安全链:
1 | 验证账号与租户关系 |
真正可靠的多租户架构,不是给表增加一个 tenant_id 字段就结束了,而是让租户边界成为每一次数据访问和任务执行都无法绕开的约束。