为什么多租户要从数据库 RLS 做起
SaaS 数据泄露事故里有一类反复出现的模式:某个查询忘了加租户过滤条件。代码评审没看出来,测试没覆盖到,直到某个客户在自己的列表里看到了别人的数据。
这不是工程师不细心的问题,是架构把正确性放错了地方。
应用层过滤的结构性缺陷
常见的多租户实现是在应用代码里过滤:
SELECT * FROM flows WHERE tenant_id = $1 AND ...
它的问题不是写起来麻烦,而是隔离的正确性 = 所有查询路径都记得加条件。查询路径会持续增长:新功能、报表、后台脚本、数据修复、ORM 生成的关联查询……每一条都是一次可能忘记的机会。防线的强度等于最弱的那次提交。
RLS:把防线下沉到数据库
PostgreSQL 行级安全(Row-Level Security)把租户过滤变成表上的策略:
ALTER TABLE flows ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON flows
USING (tenant_id = current_setting('app.tenant_id')::uuid);
应用在每个连接会话上注入当前租户上下文,之后这条连接上的任何查询——不管是精心编写的还是忘了加 WHERE 的——都只能看到本租户的行。忘记过滤的查询返回空集,而不是别人的数据。
防线从「N 个查询点每次都对」收敛为「1 个策略定义 + 1 个上下文注入点」。审计时看两处代码,而不是全部代码。
uuos 的分层:RLS 之上还有什么
RLS 是地基,不是全部。uuos 的租户体系分了几层:
- 控制面 / 租户面分离——租户、工作空间、模型目录、订阅计划在控制面库;Flow、Run、审计日志在租户面库。两套迁移独立管理,控制面的变更不触碰租户数据。
- RLS 行级策略——租户面所有业务表启用 RLS,租户上下文由请求中间件解析后注入数据库会话。
- 应用层仍然写 WHERE——RLS 是兜底不是替代。显式过滤让查询计划更优,也让代码可读;RLS 保证显式过滤写错时不泄露。
- RBAC 在租户之内——owner / admin / editor / viewer 四级角色控制「同租户内谁能做什么」,与 RLS 的「租户之间看不见」正交。
常见的两个反对意见
「RLS 有性能开销。」 策略条件会进入查询计划,等价于你本来就该写的 WHERE 条件;配合 tenant_id 上的索引,开销与手写过滤同量级。真正的性能问题通常来自忘了建索引,而不是 RLS 本身。
「我们测试覆盖很全,不会漏。」 测试验证的是「已知路径正确」,RLS 防的是「未知路径出错」——下季度入职的同事写的那个报表接口不在今天的测试里。
结论
多租户隔离的问题不在于能不能做对,而在于能不能持续地做对。把隔离放在应用层,正确性随代码量线性稀释;放在数据库层,正确性集中在一个可审计的点上。
这也是 uuos 把「真·多租户」写进差异化主张的原因:它不是功能列表里的一行,是从第一张表的 migration 就开始的架构承诺。