先说结论:多租户 SaaS 的数据隔离只写在应用层是不够的——where merchant_id = ? 防得住"忘了写",防不住"写错"和"漏写"。我们把隔离下沉到数据库层,用 PostgreSQL 行级安全(Row Level Security,RLS)做最后一道防线。本文记录完整的设计思路、实现要点和踩过的坑,SQL 均为脱敏示意。
一、背景:一个 bug 就能越权
我们的多租户微信小程序 SaaS 是一套库服务 N 个商户,库里存的是订单、核销记录、会员手机号这类数据——任何一条串了商户,都是直接的商业事故。早期隔离全靠应用层:每条查询手工带 merchant_id。上线前自查,翻出了三类真实风险:
- 一个 JOIN 子查询忘了带租户条件,聚合口径直接串了商户;
- 一个按单号查详情的接口,绕过了租户校验;
- 新写的导出功能,where 拼错了字段。
这三类问题有个共同点:单测全绿、功能验收全过——因为测试数据只有一个商户,越权根本暴露不出来。应用层隔离的本质是"约定",约定靠 code review 保证,而 review 是会漏的:SQL 越写越多,漏的概率只会越来越大。对 SaaS 来说,A 商户看到 B 商户的订单不是 bug,是事故。所以我们把原则定死:应用层过滤照写(为了性能和语义),但数据库必须兜底——哪怕应用 SQL 全忘了带条件,也不允许漏出一行别家数据。
二、设计:三层防线,层层兜底
| 层 | 手段 | 防什么 |
|---|---|---|
| 第一层 | 复合租户外键 | 防结构性脏数据:任何一行必须"挂在"某个商户下 |
| 第二层 | RLS 策略 | 防应用层漏写:按会话租户上下文过滤行 |
| 第三层 | 双数据库角色 | 防权限失控:应用连库的账号改不了策略 |
第一层:复合租户外键。 所有业务表都冗余 merchant_id,子表用 (merchant_id, 父表id) 复合外键指向父表。比如订单明细表引用订单表时,外键是 (merchant_id, order_id) 而不是裸的 order_id——这样订单明细永远不可能指向别家商户的订单,越权数据在约束层面就插不进去。顺带的好处是:查询索引天然带上了租户前缀,RLS 过滤也走得上索引。
第二层:RLS 策略。 PG 9.5+ 的原生能力,按行而不是按表过滤数据。策略读取会话变量里的当前租户,自动作用于每条 SQL,应用代码零改动。它和应用层过滤是"与"的关系:应用层忘了写,数据库这层还在。
第三层:双运行角色。 应用日常只用受限角色连接(只有 DML 权限、不带 BYPASSRLS);DDL、迁移、DBA 操作走独立角色。如果应用账号同时能 CREATE POLICY,那 RLS 就形同虚设——攻击者拖了库,顺手 DROP POLICY 即可。角色分离之后,就算应用账号泄露,能动的也只有自己租户上下文里的那几行数据。
三、实现要点
1. 策略怎么写(脱敏示意 SQL)
ALTER TABLE t_order ENABLE ROW LEVEL SECURITY; -- 打开开关:策略自此参与该表每条 SQL
ALTER TABLE t_order FORCE ROW LEVEL SECURITY; -- 属主默认绕过 RLS,FORCE 之后属主也吃策略
CREATE POLICY tenant_isolation_on_t_order ON t_order
FOR ALL -- 一条策略覆盖 SELECT/INSERT/UPDATE/DELETE
USING (merchant_id = current_setting('app.merchant_id', true)::bigint) -- 管读:哪些行可见可改删
WITH CHECK (merchant_id = current_setting('app.merchant_id', true)::bigint); -- 管写:落库前校验新行租户
USING 管查询,WITH CHECK 管插入和更新——两个都要写,后者能拦住"把别家的行改成自己的"这类写操作。current_setting 第二个参数传 true,变量未设置时返回 NULL 而不是抛异常,策略判不通过,默认拒绝,安全。
2. 租户上下文怎么注入
登录态解析出商户后,把租户写进连接的会话变量:
-- 事务级注入(推荐,配合连接池最稳)
SET LOCAL app.merchant_id = '10086';
Java 侧在事务开始的同步回调里执行 SET LOCAL,事务结束变量自动失效,天然规避连接复用串号(普通 SET 的后果见踩坑第 2 条)。为什么不干脆在应用里传参、走 ThreadLocal?因为 ThreadLocal 只能约束"自己写的代码",原生 SQL、第三方组件、报表工具都不经过它;会话变量是长在连接上的,谁用这条连接执行 SQL,谁就被策略罩住。
3. 迁移怎么管
RLS、角色、授权都是 PG 专属语法,H2 不认。我们用 Flyway 双目录:通用迁移放 db/migration,PG 专属迁移单独一个目录,生产与预发启用,本地 H2 开发环境直接跳过。目前 85 个 Flyway 迁移里,有十余个专门的 RLS/数据守卫迁移:每加一张业务表,就伴随一个"开 RLS + 建策略 + 授权"迁移,全部幂等可重放。进主干前由流水线强制校验"业务表必须全部挂策略",防止有人建了表忘了配策略。
4. 角色授权与权限矩阵
CREATE ROLE app_rw LOGIN PASSWORD '***'; -- 应用运行角色
CREATE ROLE app_dba LOGIN PASSWORD '***'; -- DDL/DBA 角色
-- 应用角色只拿 DML,且不含 BYPASSRLS
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA public TO app_rw;
两个角色各管一段,边界用矩阵钉死:
| 动作 | app_rw(应用角色) | app_dba(DBA 角色) |
|---|---|---|
| 业务表增删改查 | 可以,但全被 RLS 过滤 | 可以,仅限运维窗口 |
| 建表、改表结构 | 不行,无 DDL 权限 | 可以,仅经迁移流水线 |
| 创建 / 删除 POLICY | 不行 | 可以 |
| 给自己加权限(自提权) | 不行 | 可以,须双人复核 |
| BYPASSRLS 属性 | 不带 | 不带,按需临时授予并审计 |
四、踩坑清单
- 超级用户与表属主绕过 RLS——一次乌龙测试。 超级用户和带
BYPASSRLS的角色无视策略,表属主默认也绕过。第一次本地验收就撞上:同事建完策略,SELECT * FROM t_order把三家测试商户的数据全查了出来,他第一反应是"策略没生效",差点在群里给方案判了死刑。排查了半个多小时:策略名、表名、字段类型都没错,current_setting返回的也确实是 NULL,按理一条都不该查出来;最后SELECT current_user一看,他连的是初始化超管账号——超管天生绕过 RLS,策略从头到尾没被评估过。此后立规矩:测试环境也用受限角色验收,表一律FORCE ROW LEVEL SECURITY。当前账号会不会绕过,一查便知:SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user;
- 连接池串租户——从慢查询日志里揪出来的。 这是最隐蔽的坑。预发压测后例行巡检,慢查询日志里躺着一条按
merchant_id = 10086过滤的统计 SQL——但这家测试商户当时没有任何登录会话。先怀疑应用传错参数,翻完调用链,参数一路都对。把慢日志里每条语句的app.merchant_id与访问日志里的登录商户逐条对账,对到第三十几条对上了:同一个物理连接(backend pid 相同)先后跑过商户 A 和 B 的语句——A 用普通SET设过变量,连接归还后变量还活着,复用给 B 时,B 就顶着 A 的上下文跑了 SQL。根因是一条旧代码路径漏改,用了SET而不是SET LOCAL。修复三步:全量替换SET LOCAL;归还钩子兜底RESET app.merchant_id;加一条串号检测用例——同一连接先后模拟两家请求,断言第二条不出现前一家的商户号。
- 测试怎么覆盖:双商户互攻。 验收标准不是"策略建了",而是互攻:造 A、B 两个商户的全量数据,以 A 的租户上下文去查、改、删 B 的每一类核心数据,断言 0 行受影响,再反向一遍。示意伪代码:
function 双商户互攻测试(核心表清单):
造商户 A、B,为每张核心表各造数行(含相邻单号、同手机号边界)
对每张核心表 t:
开事务, SET LOCAL app.merchant_id = A
查 / 改 / 删 B 的行 → 断言 0 行可见、0 行受影响
插一行 merchant_id = B → 断言被 WITH CHECK 拒绝
回滚
交换 A、B 重跑一遍
收尾断言: A 读写自家数据正常(防误杀)
这类互攻用例已进主干流水线,随全量 1241 个测试方法回归——谁改坏了策略,CI 直接红。
- 性能:策略过滤要吃索引。 RLS 的谓词会被拼进每条 SQL,如果业务表没有以
merchant_id为前缀的索引,等于每条查询都全表过滤一遍。解法:复合外键索引直接复用,且FORCE之后连属主查询也走策略,压测数据别只看应用角色。
- 后台任务别图省事。 定时对账、超时关单这类跨租户任务,要么逐租户循环注入上下文、继续走受限角色,要么显式用 DBA 角色并单独审计——最忌讳给应用角色临时加
BYPASSRLS"先跑起来再说"。
三句话总结
- 应用层的
merchant_id过滤是性能与语义优化,不是安全边界;安全边界要下沉到数据库。 - RLS 落地三件事:策略成对写(USING + WITH CHECK)、上下文用事务级注入、应用跑在不带 BYPASSRLS 的受限角色下。
- 隔离不靠自信靠互攻:双商户互查互改的自动化用例进 CI,才是策略真的在挡子弹的证据。
运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。
