上一篇文章里我写过一句话,当时觉得挺得意,其实还没资格说出口:隔离要么是结构性的,要么就是幻觉。那会儿我把单租户定为第一个版本的诚实边界,多租户的事往后放。
v0.2.0 在 HivePlane 上把多租户发了出去。然后我拿一套对抗性测试去打自己划的这条边界,那句话就不再是一个论点,变成了一张 bug 清单。
![]()
下面这张表里有七项发现,如果把表里合并的两项拆开算,是九项。每一项都是同一个毛病:系统在推断租户身份,而不是断言它。
把这句话拿去测一遍
把那张表当成论据来读。靠一个请求头、一个模型默认值、一个声明字段来执行的租户隔离,都是幻觉。它必须在存储层的主键和已认证的主体上执行,绝不能依赖客户端提供的值。
先说我逢人就讲的那个。一个 worker 注册在租户 A 名下。租户 B 的代码提交了一次运行,用 worker id 去寻址它——worker_id: "worker-7"。因为主键只有 worker_id,不是 (worker_id, tenant_id),这次查找成功了。租户 B 差一点就在租户 A 的 worker 上执行了一次运行。
没有任何策略说不。存储层说了可以。存储层才是真相。
修法属于那种事后看很明显、当时做起来很丢人的改动:每一个持久化键都变成复合键。(worker_id, tenant_id)、(workload_id, tenant_id)、(tool_id, tenant_id)。一次读取如果不在当前操作的租户范围内,结果看起来就像这条记录不存在——因为在那个租户的世界里,它确实不存在。
如果你的隔离依赖应用记得加上一句 WHERE tenant_id = ?,那你拥有的是一次推断,不是一条边界。把主键做成复合的,就算查询忘了带条件,存储层也没法返回别的租户的行。
那个可以伪造的请求头
限流器当时拿 X-Hiveplane-Tenant 这个请求头当键。认证关掉的时候(本地默认就是这样),这个头是被信任的管道。认证打开之后,一个知道别的租户 id 的调用方,只要设上这个头,就能借用那个租户的限流预算。
一个请求变成 TenantContext 的唯一入口是 get_tenant_context。认证打开时,已认证主体的租户必须和请求头一致;只有系统主体才能选择任意租户。就是这一条断言,把请求头从身份降格成了提示。在它之前的一切,都是推断。
默认租户是一个你忘了命名的安全边界
一半的发现其实是同一个 bug 长在不同子系统里:一条没有显式租户的记录,默认落到 default。审批、安全事件、事故状态、缓存写入——它们都有租户字段,没人设置的时候,模型就填上 default。
于是租户 A 的一次运行里产生的安全事件,本该落进租户 A 的审计里,除非某处忘了把上下文传下去——那它就落进了 default,而租户 B 的操作员在那里能看到它。
教训是:默认租户不是一个安全的默认值。它是一个静默的跨租户泄漏,等着某一条忘了传上下文的代码路径。修法是让没有归属的事件不落默认值——它进死信队列并告警。那个未归属计数器必须是零。
我学到了什么
推断不等于执行。应用自己加的一句 WHERE、应用选择信任的一个请求头、模型自动填上的一个默认值——每一个都是猜测。我那九个 bug,每一个都是在压力下猜错的猜测。
修法在每处都一样:复合主键、跨租户写入时抛 TenantScopeError、读取结果表现为“未找到”,以及一场对抗性的实地测试,去攻击每一条边界,而不是只测顺利路径。这张 bug 清单本身就是论据,证明那句话是对的。
v0.2.0 的实地测试端到端演示了 34 道发布门禁,包括按租户隔离的预算、策略和密钥,以及一个不能审批的查看者。完整证据在实地测试报告里;迁移指南记录了这次破坏性的 schema 变更(只向前的迁移 0003,先备份)。
参考
- HivePlane —— pip install hiveplane==0.2.0
- 实地测试报告(36/36)
- 迁移指南(v0.1.0 → v0.2.0)
- 多租户隔离方案
- 密钥、RBAC 与访问审计设计
你的平台在哪里推断租户身份,而不是执行它?说出一个客户端提供的字段变成身份的地方。那就是你的下一个 bug。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.