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 的两个根本性质:

  1. 它对 planner 可见但不是普通 WHERE:securityQuals 最终会被并入 baserestrictinfo,所以索引选择、选择率估算都会考虑它——但它不能被用户手动覆盖(OR true 也没用,因为它是硬性注入的)。
  2. 它是一个子查询而非标量:这是所有性能问题的根源。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 就等于全栈隔离,结果对象存储的直链把数据暴露了。

七、生产落地检查清单

  1. 每个策略同时写 USING 与 WITH CHECK,避免写入方向越权。
  2. 所有 RLS 谓词引用的函数必须 STABLE,并用 (SELECT fn()) 包装保证 InitPlan。
  3. 谓词列必须有索引:CREATE INDEX ON documents (org_id, created_at DESC),复合索引顺序与查询排序列对齐。
  4. 用 SECURITY DEFINER 打破策略递归,同时强制 SET search_path。
  5. 对外暴露的视图加 security_barrier。
  6. 表属主与迁移账号显式 FORCE ROW LEVEL SECURITY,避免"属主能看到一切"的错觉。
  7. 把越权写成自动化测试,而不是靠人工 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"]))
  1. 监控策略开销:开启 auto_explain 记录慢查询,定期 grep 计划里出现 SubPlan 且 loops 巨大的语句。

八、结论

RLS 的全部价值可以压缩成一句话:把授权从应用层的"记得加 WHERE"变成数据库层的"不可能不加"。它把最容易被遗漏的一行过滤条件,变成了 schema 的一部分——这类"结构性保证"在安全工程里的价值远高于任何规约文档。

但它不是免费的午餐,代价有三笔:planner 对待子查询的方式决定性能(所以 STABLE 与括号不是可选项,是必需项);跨表策略天然带来 join 栈(所以要冗余列或内联 claim);连接池与角色切换的作用域决定了会不会串号(所以事务级 SET LOCAL 是唯一正确姿势)。

真正决定系统是否安全的,从来不是"有没有开 RLS",而是策略里那几行 SQL 有没有写对。USING 与 WITH CHECK 是否成对、函数是 STABLE 还是 VOLATILE、谓词列上有没有索引——这些细节写对了,RLS 是强大的隔离原语;写错了,它只是一层让人安心的幻觉。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部