PostgreSQL 行级安全(RLS)深度工程:从查询改写、Security Barrier 到 Supabase 多租户授权的生产级落地
执行摘要:RLS(Row Level Security)不是"给 SQL 自动加个 WHERE"这么简单。它在重写阶段(rewrite)把基表替换成带谓词的子查询,因此它的安全性来自 PostgreSQL 的查询重写语义,而它的性能则完全取决于 planner 如何对待这个谓词。绝大多数生产事故来自三个地方:auth.uid()这类函数被当作 VOLATILE 导致每行重算、跨表策略引发的 N+1 子查询风暴、以及连接池下SET role的作用域错配导致的串号(一个用户看到另一个用户的数据)。本文拆开这三层,给出可运行的 SQL 与 EXPLAIN 证据,最后落到 Supabase / PostgREST 的真实拓扑。
一、RLS 到底发生在哪一层
一个常见的误解是"RLS 是执行器事后过滤"。不是。RLS 发生在 parse → rewrite → plan → execute 的第二步:重写器扫描 Query 树的 RTE(Range Table Entry),对每个启用了 RLS 的表,把 USING 表达式作为安全限定(securityQuals)挂上去。等到 planner 接手时,它看到的东西大致等价于:
-- 你写的
SELECT * FROM documents;
-- 重写器实际交给 planner 的
SELECT * FROM (
SELECT * FROM documents
WHERE org_id = (SELECT current_org_id()) -- USING 谓词
) AS documents;
这个等价形式解释了 RLS 的两个根本性质:
- 它对 planner 可见但不是普通 WHERE:
securityQuals最终会被并入baserestrictinfo,所以索引选择、选择率估算都会考虑它——但它不能被用户手动覆盖(OR true也没用,因为它是硬性注入的)。 - 它是一个子查询而非标量:这是所有性能问题的根源。planner 必须决定把它变成一次性的
InitPlan还是每行执行的SubPlan。
用 EXPLAIN (ANALYZE, BUFFERS) 验证这件事非常容易:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM documents WHERE title LIKE '%q3%';
如果你在计划里看到 SubPlan 2 出现在 Filter: 行中,且循环次数等于行数,那就踩坑了。
二、性能陷阱一:函数稳定性决定 InitPlan 还是 SubPlan
planner 能否把谓词提升为只求值一次的 InitPlan,取决于表达式里函数的 volatility。VOLATILE 函数(默认)必须在每一行重新求值,因为优化器无法假设它两次调用返回同一结果。
下面是一个真实的对比。先写一个糟糕的版本:
-- ❌ 反例:auth_uid() 未标注稳定性 → 默认 VOLATILE
CREATE FUNCTION auth_uid() RETURNS uuid AS $$
SELECT (current_setting('request.jwt.claims', true)::jsonb ->> 'sub')::uuid;
$$ LANGUAGE sql; -- 注意:没有 STABLE
CREATE POLICY p_doc ON documents
USING (owner_id = auth_uid());
EXPLAIN ANALYZE 的结果会是:
Seq Scan on documents (actual time=0.9..312.4 rows=1200 loops=1)
Filter: (owner_id = auth_uid())
Rows Removed by Filter: 118800
SubPlan 1
-> Function Scan ... (actual time=0.02..0.02 rows=1 loops=120000)
Planning Time: 0.3 ms
Execution Time: 331.7 ms
注意 loops=120000——它扫了多少行就调用了多少次。改成 STABLE 并用子查询包装:
-- ✅ 正确:STABLE + 子查询包装,强制变成 InitPlan
CREATE FUNCTION auth_uid() RETURNS uuid AS $$
SELECT (current_setting('request.jwt.claims', true)::jsonb ->> 'sub')::uuid;
$$ LANGUAGE sql STABLE PARALLEL SAFE;
CREATE POLICY p_doc ON documents
USING (owner_id = (SELECT auth_uid())); -- 外层括号是关键
再 EXPLAIN:
Index Scan using documents_owner_id_idx on documents
Index Cond: (owner_id = $0)
InitPlan 1 (returns $0)
-> Result ...
Execution Time: 1.8 ms
从 331ms 到 1.8ms,两个数量级。 这里有两个要点:
STABLE告诉 planner:同一次查询内、同一个快照下,返回值不变。- 外层
(SELECT ...)让谓词形态变成标量子查询,planner 会把它识别为InitPlan,只算一次并把结果当参数$0下推到索引条件里。没有这个括号,某些版本的 planner 会保守地保留 SubPlan。
Supabase 内置的 auth.uid() 已经是 (select ...) 形式且标注了 STABLE,这就是为什么官方模板里的策略写法总是带一层括号——那不是风格问题,是性能硬要求。
三、性能陷阱二:跨表策略引发的 N+1 子查询
多租户模型通常不是"每行带 owner_id",而是"用户属于某组织"。于是策略写成:
CREATE POLICY p_doc ON documents
USING (org_id IN (SELECT org_id FROM memberships WHERE user_id = (SELECT auth_uid())));
这在语义上完全正确,但在 planner 眼里它是一个 semi-join。PostgreSQL 通常能把它转成 hash semi join,性能尚可;但当策略嵌套多层(documents → projects → orgs)时,每层 RLS 都会生成一棵独立的子查询,形成"策略栈"。行数一大,代价估算会失真,容易退化成嵌套循环。
工程上有三种解法,按推荐度排序:
1)在 JWT 里内联租户 ID(最快,也最脆弱)
-- 登录时把 org_id 写进 JWT claims,策略退化为标量比较
CREATE POLICY p_doc ON documents
USING (org_id = (SELECT current_setting('request.jwt.claims', true)::jsonb ->> 'org_id')::uuid);
零 join,纯索引扫描。代价是租户变更后旧 token 仍然有效——必须配合短 TTL 的 access token 与 refresh 轮换。
2)冗余列 + 触发器维护(推荐)
在 documents 上冗余 owner_id,用外键 ON UPDATE CASCADE 或触发器保持与 memberships 一致,策略只对单表列做比较。这是典型的"用写放大换读性能",对读多写少的 SaaS 几乎总是对的。
3)SECURITY DEFINER 函数 + 语句级缓存
CREATE FUNCTION my_orgs() RETURNS SETOF uuid
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public AS $$
SELECT org_id FROM memberships WHERE user_id = (SELECT auth_uid());
$$;
SECURITY DEFINER 让函数以属主权限执行,绕过 memberships 自身的 RLS——否则会出现策略递归(读 documents 要读 memberships,读 memberships 又要读 memberships)。这是新手最常掉进去的洞。注意必须 SET search_path,否则存在模式劫持风险。
四、安全陷阱:Security Barrier 与侧信道
RLS 只保证"你看不到不该看的行",不保证"你无法推断出这些行的存在"。考虑:
CREATE VIEW v_docs AS SELECT * FROM documents; -- 默认 security_barrier = false
CREATE POLICY p ON documents USING (owner_id = (SELECT auth_uid()));
恶意用户可以这样:
SELECT * FROM v_docs WHERE expensive_leak(title) AND salary > 1000000;
planner 有权先执行代价低的过滤条件。如果 expensive_leak() 是用户自定义函数且有副作用(写日志、抛错、耗时差异),它可能在 RLS 谓词生效之前就对被过滤掉的行执行了,从而泄露信息。
防御手段是给视图加安全屏障:
CREATE VIEW v_docs WITH (security_barrier = true) AS
SELECT * FROM documents;
security_barrier 强制视图的过滤条件在任何用户提供的条件之前求值,并且不允许把用户条件下推穿过屏障(代价是失去索引下推,这是安全换性能的显式权衡)。
更进一步,对于被 planner 允许提前执行的函数,应当标记 LEAKPROOF:
ALTER FUNCTION my_eq(text, text) LEAKPROOF; -- 需要 superuser
LEAKPROOF 承诺该函数不会泄露参数以外的信息。只有极少的内置函数有这个标记,自定义函数通常不该随意添加。
五、策略语义:USING、WITH CHECK 与 permissive/restrictive
两个方向的语义必须分清:
| 子句 | 作用对象 | 失败行为 |
|---|---|---|
USING (expr) | SELECT / UPDATE / DELETE 的可见行 | 行静默消失,不报错 |
WITH CHECK (expr) | INSERT / UPDATE 的写入行 | 抛出 new row violates row-level security policy |
只写 USING 是最常见的授权漏洞:用户能把自己的行 UPDATE 成别人的 org_id(因为写入方向没有约束),数据就这样"漂移"到别的租户里去了。正确写法永远是成对出现:
CREATE POLICY doc_rw ON documents
FOR ALL
TO authenticated
USING (org_id = (SELECT auth_org_id()))
WITH CHECK (org_id = (SELECT auth_org_id()));
多策略的组合规则:
- 默认创建的都是 PERMISSIVE,多个 permissive 策略之间是 OR。
AS RESTRICTIVE创建的是 AND 语义,任何一条 restrictive 不满足就拒绝。- 实用模式:用 permissive 表达"谁能访问",用 restrictive 表达"一票否决"(如封禁、软删除)。
-- 一票否决:被封禁用户什么都读不到
CREATE POLICY banned_deny ON documents
AS RESTRICTIVE
USING (NOT (SELECT is_banned(auth_uid())));
另外三个容易忽略的事实:表属主默认绕过 RLS(除非 ALTER TABLE ... FORCE ROW LEVEL SECURITY);BYPASSRLS 角色与 superuser 始终绕过;COPY、TRUNCATE、外键约束检查、唯一索引冲突检查都不经过 RLS。最后一条意味着:即使 RLS 让你看不到某行,你插入一条与之冲突的主键仍然会报唯一约束错误——这是一个客观存在的信息泄露通道,在高安全场景需要用"每租户独立 schema"或"独立数据库"来彻底隔离。
六、Supabase / PostgREST 拓扑下的三个真实坑
坑 1:连接池与 SET LOCAL 的作用域
Supabase 通过 PostgREST 把 JWT 映射为数据库角色,本质是每个请求执行:
BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL "request.jwt.claims" = '{"sub":"...","org_id":"..."}';
SELECT ...; -- 这里 RLS 生效
COMMIT;
关键在于 SET LOCAL 是事务级的。如果你用的是 Supavisor 事务模式(transaction pooling),连接在每个事务结束后归还给池子,SET LOCAL 自然失效,这是安全的。但如果误配成会话模式(session pooling),或者你在应用代码里用了会话级 SET ROLE 后复用连接,就会出现经典的串号:上一个请求的身份残留在连接上,下一个请求以别人的身份查数据。
自查方法:
-- 在应用层每次取连接后立刻断言身份干净
SELECT current_setting('request.jwt.claims', true) AS claims,
current_user;
任何非空残留都说明连接复用策略有问题。
坑 2:service_role 的扩散
PostgREST 暴露的 service_role key 会绕过全部 RLS。它绝不能出现在客户端、CI 日志或前端 bundle 里。生产上把它限制在服务端最小的一层(例如只用于后台任务与迁移),其余路径一律走 anon / authenticated + RLS。
坑 3:Realtime 与 Storage 的独立授权
Supabase 的 Realtime(WebSocket 订阅变更)与 Storage(对象存储)不复用表的 RLS 语义。Realtime 需要对 supabase_realtime publication 单独授权,Storage 依赖 storage.objects 上的策略与 bucket 配置。很多团队以为开了表的 RLS 就等于全栈隔离,结果对象存储的直链把数据暴露了。
七、生产落地检查清单
- 每个策略同时写
USING与WITH CHECK,避免写入方向越权。 - 所有 RLS 谓词引用的函数必须
STABLE,并用(SELECT fn())包装保证 InitPlan。 - 谓词列必须有索引:
CREATE INDEX ON documents (org_id, created_at DESC),复合索引顺序与查询排序列对齐。 - 用
SECURITY DEFINER打破策略递归,同时强制SET search_path。 - 对外暴露的视图加
security_barrier。 - 表属主与迁移账号显式
FORCE ROW LEVEL SECURITY,避免"属主能看到一切"的错觉。 - 把越权写成自动化测试,而不是靠人工 review:
# pytest 风格:切换身份断言可见性
def test_tenant_isolation(db, user_a, user_b):
doc = db.as_user(user_b).insert("documents", {"title": "secret"})
rows = db.as_user(user_a).execute("SELECT id FROM documents")
assert doc["id"] not in [r["id"] for r in rows], "租户隔离失效"
with pytest.raises(PermissionError): # WITH CHECK 必须拦住写入
db.as_user(user_a).execute(
"UPDATE documents SET org_id = %s WHERE id = %s",
(user_a.org_id, doc["id"]))
- 监控策略开销:开启
auto_explain记录慢查询,定期 grep 计划里出现SubPlan且loops巨大的语句。
八、结论
RLS 的全部价值可以压缩成一句话:把授权从应用层的"记得加 WHERE"变成数据库层的"不可能不加"。它把最容易被遗漏的一行过滤条件,变成了 schema 的一部分——这类"结构性保证"在安全工程里的价值远高于任何规约文档。
但它不是免费的午餐,代价有三笔:planner 对待子查询的方式决定性能(所以 STABLE 与括号不是可选项,是必需项);跨表策略天然带来 join 栈(所以要冗余列或内联 claim);连接池与角色切换的作用域决定了会不会串号(所以事务级 SET LOCAL 是唯一正确姿势)。
真正决定系统是否安全的,从来不是"有没有开 RLS",而是策略里那几行 SQL 有没有写对。USING 与 WITH CHECK 是否成对、函数是 STABLE 还是 VOLATILE、谓词列上有没有索引——这些细节写对了,RLS 是强大的隔离原语;写错了,它只是一层让人安心的幻觉。

发表评论 取消回复