ENGINEERING NOTES · 工程笔记

用 PostgreSQL 行级安全(RLS)做多租户隔离:设计、实现与踩坑

先说结论:多租户 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 属性不带不带,按需临时授予并审计

四、踩坑清单

  1. 超级用户与表属主绕过 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;
  1. 连接池串租户——从慢查询日志里揪出来的。 这是最隐蔽的坑。预发压测后例行巡检,慢查询日志里躺着一条按 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;加一条串号检测用例——同一连接先后模拟两家请求,断言第二条不出现前一家的商户号。
  1. 测试怎么覆盖:双商户互攻。 验收标准不是"策略建了",而是互攻:造 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 直接红。

  1. 性能:策略过滤要吃索引。 RLS 的谓词会被拼进每条 SQL,如果业务表没有以 merchant_id 为前缀的索引,等于每条查询都全表过滤一遍。解法:复合外键索引直接复用,且 FORCE 之后连属主查询也走策略,压测数据别只看应用角色。
  1. 后台任务别图省事。 定时对账、超时关单这类跨租户任务,要么逐租户循环注入上下文、继续走受限角色,要么显式用 DBA 角色并单独审计——最忌讳给应用角色临时加 BYPASSRLS"先跑起来再说"。

三句话总结

  1. 应用层的 merchant_id 过滤是性能与语义优化,不是安全边界;安全边界要下沉到数据库。
  2. RLS 落地三件事:策略成对写(USING + WITH CHECK)、上下文用事务级注入、应用跑在不带 BYPASSRLS 的受限角色下。
  3. 隔离不靠自信靠互攻:双商户互查互改的自动化用例进 CI,才是策略真的在挡子弹的证据。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询