文档 / 搜索 / LingCode Cloud / 安全与 RLS
📘 参考 ● 上线前必读 更新于 2026-06-11

安全与行级安全(RLS)

一句话:放在浏览器打包产物里的公开 anon key(匿名密钥)之所以安全,靠的是行级安全(RLS)——由服务端 Postgres 强制执行的策略,而不是把密钥藏起来。anon key 能访问到的每一张表,都应当既启用 RLS 配上策略。一次性启用它,写好「仅所有者可见」的策略去校验 current_setting('app.user_id', true),以已登录用户的身份测试,并在每次上线前跑一遍 advisors(扫描器)

这是你把后端摆到真实用户面前之前该读的一页。它从威胁模型讲起——为什么公开密钥没问题、真正保护数据的是什么——接着给你一套覆盖绝大多数应用的 RLS 模式、常见配方的可直接复制 SQL,以及那张帮你兜住「人人第一次都会犯」的错误的安全网。核心思路:访问权限由你用 SQL 写的策略决定,Postgres 在每一次查询时都会执行它们。把这些写对,其余的自然就成立了。

为什么这很重要:威胁模型

接入 SDK 时,你会往浏览器里塞两个值:一个数据 URL和一个公开的 anon key。anon key 不是秘密。它跟着你的客户端打包产物一起发出去,出现在每一个请求里,任何人都能读到它。这是有意为之的设计。

安全来自把密钥藏起来,而来自行级安全(RLS)——一组用普通 Postgres SQL 写成的策略,数据库会在服务端为每一次查询、在返回任何行之前先评估它们。密钥只用来标明你在跟哪个后端对话;策略才决定这个请求被允许看到和改动什么。

所以规则简单而绝对:anon key 能访问到的每一张表,都应当启用 RLS 并配上策略。一张表恰好处于以下三种状态之一:

anon key 本就是公开设计——永远不要指望它保密。任何不该被未登录访客看到的东西,都必须靠策略挡住,而不是靠密钥「难找」。还有,永远不要把私有厂商密钥(Stripe secret、第三方 API key)放进表里或客户端——它们该进密钥保险库,只在服务端函数里读取。

强制机制是怎么运作的

值得理解一下策略背后的机制,因为正是它让你写的 SQL 成立。

所有者控制台和迁移以管理员身份运行,会绕过 RLS。当你从控制台查询时,无论策略如何你都能看到全部——所以控制台是测试访问规则的错误场所。永远以已登录的应用用户身份测试策略(也要测未登录的),绝不要从控制台测,否则你会误以为一条坏掉的策略是好的。

核心模式

这是覆盖绝大多数应用的模式:一张表,每一行都归属于某一个用户,且只有那个用户能看到或改动它。两步——启用 RLS,然后加上策略。

-- 1) 启用 RLS。没有这一步,策略什么都不做。
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;

-- 2) 仅所有者:一行只对拥有它的用户可见 / 可改。
CREATE POLICY "todos are private to their owner" ON todos
  USING (user_id = current_setting('app.user_id', true)::uuid)
  WITH CHECK (user_id = current_setting('app.user_id', true)::uuid);

这里两个子句在做两件不同的事,区别很关键:

注意那个类型转换:current_setting('app.user_id', true) 返回 TEXT,所以你把它转成那一列的类型——这里是 ::uuid。再注意请求未登录时会发生什么:current_setting 返回 NULL,比较 user_id = NULL 永远不为真,没有任何行匹配,访问被拒绝。未登录场景的安全是自动成立的。

为什么 USING 和 WITH CHECK 都要出现

一个常见的初学错误是只写 USING。那能让已存在的行变私有——但 INSERT 没有已存在的行可供过滤,所以没有 WITH CHECK 时,客户端就能创建带任意 user_id 的行,包括别人的。对于仅所有者的表,两个子句都写上、用同样的条件。当它们应当不同时(公开读、所有者写),改用按命令拆分的策略——见下面的配方。

配方

自动盖上所有者(这样客户端无法伪造)

与其信任客户端发来正确的 user_id,不如给这一列一个默认值,由已登录用户来填。客户端根本不去设置它:

ALTER TABLE todos
  ALTER COLUMN user_id SET DEFAULT current_setting('app.user_id', true)::uuid;

现在,不带 user_idinsert({ title: 'Buy milk' }) 会落下一行,归属于当前登录者。把它和上面的仅所有者策略搭配起来,客户端就没有任何办法去声称一行不属于自己的记录归它所有。

公开读,所有者写

对于博客或公开信息流之类的东西,任何人都可以读每一行,但只有所有者可以创建、修改或删除自己的行。因为命令各不相同,要用 FOR SELECT / FOR INSERT / FOR UPDATE / FOR DELETE 为每个命令各写一条策略:

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

CREATE POLICY "anyone can read" ON posts
  FOR SELECT USING (true);

CREATE POLICY "owner can insert" ON posts
  FOR INSERT WITH CHECK (user_id = current_setting('app.user_id', true)::uuid);

CREATE POLICY "owner can modify" ON posts
  FOR UPDATE USING (user_id = current_setting('app.user_id', true)::uuid);

CREATE POLICY "owner can delete" ON posts
  FOR DELETE USING (user_id = current_setting('app.user_id', true)::uuid);

USING (true)FOR SELECT 策略让每一行都可公开读取。各条写策略把单个命令限定到所有者。每当读访问与写访问不一致时,按命令拆分就是你该拿起的工具——把每个命令收紧到它恰好需要的程度。

安全网:advisors

你迟早会写错某条策略——人人都会。后端自带一个 advisors 扫描器,它读取你的 schema,在真正的 RLS 错误到达用户之前把它们标出来。它能抓到的,包括:

每次上线前都跑一遍 advisors。这是最快的办法,用来抓住「我写了一条策略」和「这条策略真的生效了」之间的那道缝隙。参见用 advisors 扫描后端

实时订阅遵守同一套策略

RLS 不只用于一次性查询。实时变更事件在投递前会拿同一套策略重新校验,所以订阅者收到的,永远只是它用普通 select() 也能读到的那些行。你不必为实时单独写规则——把表的策略写对,实时更新就会继承它。

串起来:上线前检查清单