一句话:放在浏览器打包产物里的公开 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 并配上策略。一张表恰好处于以下三种状态之一:
值得理解一下策略背后的机制,因为正是它让你写的 SQL 成立。
current_setting('app.user_id', true) 暴露给你的策略。它是一个 TEXT 值,并且在请求未登录时为 NULL——SDK 只在登录之后才设置它。在策略里把它转换成你那一列的类型(::uuid、::text 等等)。apply_migration——绝不要作为运行时调用。这是覆盖绝大多数应用的模式:一张表,每一行都归属于某一个用户,且只有那个用户能看到或改动它。两步——启用 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);
这里两个子句在做两件不同的事,区别很关键:
USING 决定哪些已存在的行可见——它过滤 SELECT,并把关一个 UPDATE 或 DELETE 被允许触及哪些行。WITH CHECK 校验你被允许写入的行——它在 INSERT 和 UPDATE 时把关新行或被改后的行。没有它,用户就能插入一行打上别人 user_id 的记录。注意那个类型转换:current_setting('app.user_id', true) 返回 TEXT,所以你把它转成那一列的类型——这里是 ::uuid。再注意请求未登录时会发生什么:current_setting 返回 NULL,比较 user_id = NULL 永远不为真,没有任何行匹配,访问被拒绝。未登录场景的安全是自动成立的。
一个常见的初学错误是只写 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_id 的 insert({ 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 扫描器,它读取你的 schema,在真正的 RLS 错误到达用户之前把它们标出来。它能抓到的,包括:
ENABLE ROW LEVEL SECURITY——于是这张表看似受保护,实则门户大开。修复:ALTER TABLE … ENABLE ROW LEVEL SECURITY;。search_path——针对那些 search path 可被操纵的函数发出的安全告警。每次上线前都跑一遍 advisors。这是最快的办法,用来抓住「我写了一条策略」和「这条策略真的生效了」之间的那道缝隙。参见用 advisors 扫描后端。
RLS 不只用于一次性查询。实时变更事件在投递前会拿同一套策略重新校验,所以订阅者收到的,永远只是它用普通 select() 也能读到的那些行。你不必为实时单独写规则——把表的策略写对,实时更新就会继承它。
ENABLE ROW LEVEL SECURITY 并且至少一条策略。USING 和 WITH CHECK 的策略,把 current_setting('app.user_id', true) 转换成那一列的类型。user_id,让客户端无法伪造归属。FOR SELECT / INSERT / UPDATE / DELETE 策略。