← 返回 旧式终端显示器在昏暗的木质办公桌上发光,周围散放着纸张,柔和的阴天光线

Supabase RLS:我在每个项目中编写的策略

早在 2021 年,一个 SaaS 客户在产品上线六周后找到我。他们的产品是一个项目管理工具。UI 不错、入职流程完善、留存率也不错。然后他们的一个测试用户发现了一个问题:通过在 GET 请求中修改 project_id,他们可以读取其他用户的数据。没有身份验证绕过。没有 SQL 注入。只是缺少一个策略。该表启用了 RLS,但 SELECT 策略完全开放,USING (true)。有人从教程中盲目复制了它,从未回头看过。

这个事件对我产生了深远的影响。从那时起,我把 RLS 当作架构来对待,而不是事后补救。在 Seahawk 构建了超过 12,000 个网站和应用后,我有一些策略几乎是反射性地编写的,甚至在我写一行前端代码之前。

这就是那个列表。

为什么 RLS 值得投入精力

Supabase 运行在 PostgreSQL 上,这意味着行级安全是一流的数据库功能,而不是附加组件。当你在表上启用 RLS,用户通过 Supabase 客户端查询它时,每一行都会自动通过你的策略进行过滤。无论你的 API 层做什么或不做什么。

最后一点就是要点。我曾与使用 REST、GraphQL、边缘函数和后台任务访问同一数据库的团队合作过。在应用层跨所有这些强制执行访问控制是一场协调噩梦。在数据库层强制执行它就是...完成了。

说实话,编写策略的麻烦在初级开发人员第一次忘记添加 WHERE 子句时就已经值回票价了。

不过有一点人们容易误解。启用 RLS 而没有任何策略并不意味着"开放访问"。对于普通角色来说意味着完全无权访问。默认情况下每一行都被拒绝。所以如果你启用 RLS 你的应用立即崩溃,这就是原因。

我总是优先进行 RLS 保护的四种表

不是所有内容都需要相同级别的审查。但这四种表类型在我接触任何其他内容之前就会被锁定。

  • 用户资料表(profiles、users、accounts):显而易见。用户应该只能读取自己的行。也许管理员可以读取所有行。没有人应该写入他人的资料。
  • 资源/内容表(projects、documents、posts):无论你的应用中的核心"事物"是什么。所有权通常很直接。
  • 计费和订阅表:如果你使用 Stripe 并在 Supabase 中存储计划数据、订阅状态或发票历史记录,这需要严格锁定。我见过应用意外向其他用户暴露试用期结束日期的情况。
  • 审计日志:这些对用户是只读的(如果有的话)。只有服务角色应该写入它们。

我实际编写的策略

1. 仅所有者策略(我最常用的模式)

这是我编写得最多的。简单的前提:用户只能看到他们拥有的行。

`` create policy "Users can view own rows" on profiles for select using (auth.uid() = user_id); ``

auth.uid() 是一个 Supabase 帮助函数,返回当前已认证用户的 UUID。简洁、快速、如果 user_id 已建立索引则已建立索引。我将其与 insert 策略配对,将 user_id 默认设置为 auth.uid(),这样用户就无法插入冒充他人身份的行。

`` create policy "Users can insert own rows" on profiles for insert with check (auth.uid() = user_id); ``

with check 子句用于写入操作。using 用于读取。很多人会把这两个搞混,最后写出来的策略看起来没问题,但实际上根本防不了坏的插入。

2. 组织/团队策略(多租户应用)

这里事情才变得有意思。对于任何多租户 SaaS,我需要用户只能看到属于他们组织的行,而不仅仅是他们自己的。

我最终采用的模式:一个 memberships 联接表,将用户链接到组织。

`` create policy "Org members can view org resources" on projects for select using ( exists ( select 1 from memberships where memberships.org_id = projects.org_id and memberships.user_id = auth.uid() ) ); ``

Seahawk 有一个金融科技项目,组织里有几十个用户,有些是只读角色,有些有写入权限。我们用 role 列扩展了这个模式,直接在策略中使用它。所以一个 viewer 角色根本无法在数据库级别运行 UPDATE 或 DELETE。不是由 API 强制执行。是由数据库强制执行。

3. 公开读取/所有者写入策略

对于公开可见但只有所有者能编辑的内容。博客文章、公开资料、产品列表。

``` create policy "Anyone can read published posts" on posts for select using (published = true);

create policy "Authors can update own posts" on posts for update using (auth.uid() = author_id) with check (auth.uid() = author_id); ```

两个单独的策略。我看到有人试图把这些合并成一个,最后逻辑很难理解。保持它们分开。当需要时,Postgres 会自动为同一操作用 OR 把它们连接起来。

4. 服务角色逃生舱

有些操作确实需要绕过 RLS。后台任务、webhook、管理脚本。对于这些,我使用 service_role 密钥,它完全绕过 RLS。

但关键是:我从不在前端代码中暴露服务角色密钥。永远不。它只存在于服务器端的环境变量中。我见过代码库把它硬编码在 Next.js pages/ 目录里。那就是整个数据库,完全敞开。

如果你使用 Supabase edge functions,可以在里面安全地使用服务角色客户端,因为 edge functions 运行在服务器端。Supabase 关于身份验证和服务角色的文档值得从头到尾读一遍,如果你还没读过的话。

5. 管理员覆盖策略

对于有管理面板的应用,我添加一个策略来授予管理员完全访问权限,根据存储在用户 JWT 元数据中的角色或单独的 user_roles 表检查。

`` create policy "Admins can do everything" on projects for all using ( exists ( select 1 from user_roles where user_roles.user_id = auth.uid() and user_roles.role = 'admin' ) ); ``

我曾经在 JWT 自定义声明中存储角色,这更快(没有子查询),但这意味着每当角色改变时都必须重新颁发 JWT。对于大多数应用,子查询就可以了。如果你在规模上看到性能问题,通过 Supabase Auth hooks 的 JWT 自定义声明是最好的办法。

我犯过的常见错误(以及见过的)

让我直言不讳地说说真正给我或我的客户造成过麻烦的事情。

  1. 忘记 UPDATE 和 DELETE 策略。写一个 SELECT 策略很容易就觉得大功告成。其实没有。测试所有四个操作:SELECT、INSERT、UPDATE、DELETE。我现在用 Supabase 仪表板的内置策略测试器,但多年来我一直在 psql 里写原生 SQL 手动测试。
  2. USING WITH CHECK 混淆。USING 过滤查询可以看到哪些行。WITH CHECK 验证写入操作是否被允许。对于 UPDATE,你需要两个:USING 控制哪些行可以被针对,WITH CHECK 控制更新后行看起来什么样。
  3. 递归策略循环。如果你表 A 上的策略查询表 B,而表 B 的策略查询表 A,你会得到无限递归。我在一个相互引用的 teams team_members 表上遇到过一次。修复方法:使用安全定义器函数来打破循环。
  4. 没有作为匿名用户测试。Supabase 让你使用匿名角色(anon)。总是以 authenticated anon 两种身份测试你的策略。我使用 Postman 和不同的身份验证令牌来模拟这种情况,在没有令牌、有效用户令牌和不同用户的令牌之间切换。
  5. 在存储桶上关闭 RLS。RLS 也适用于 storage.objects 表。如果你创建了一个 Supabase Storage 桶并且没有保护该表,任何人只要猜对路径就能读取你的"私密"文件。我在一个存储用户上传文档的客户项目中血的教训学到了这一点。

我如何在发布前测试我的策略

这是我的实际流程,不是理论清单。

  1. 在 Supabase SQL 编辑器中编写策略。
  2. 打开第二个浏览器标签页,以不同的测试用户身份登录。
  3. 尝试访问应该被阻止的数据。确认它被阻止了。
  4. 尝试访问应该可见的数据。确认它正常工作。
  5. 对一行我不拥有的数据运行 UPDATE 和 DELETE。应该失败。
  6. 检查 Supabase 日志中是否有行级安全策略违反错误。

对于任何复杂的情况,特别是多租户组织策略,我会用 supabase-js 客户端编写一个小测试脚本,使用两个不同的用户会话并验证预期的结果。写起来可能需要 20 分钟,但能节省生产环境中数小时的调试时间。

何时不使用 RLS

RLS 并不总是正确的工具。

如果你在构建一个内部管理工具,其中所有用户都是受信任的员工,RLS 会增加复杂性而回报有限。简单的服务器端身份验证检查就足够了。如果你的数据模型非常复杂,策略需要 5 层深的子查询,你可能最好在 API 层中通过适当的服务分解来强制实施访问控制。

另外:如果你纯粹使用 Supabase 作为后端,在它前面放有自己的 API(从不向客户端暴露 Supabase URL 或 anon key),RLS 是可选的。API 成为你的安全层。也就是说,我在这些情况下仍然会添加基本策略,因为纵深防御是值得的。

看,RLS 是一个工具。不是信仰。在它让你的系统更简单更安全的地方使用它。不要仅仅因为教程告诉你而盲目复制它。

FAQ

如果我只用 Supabase 配合服务器端 API,我需要 RLS 吗?

严格来说不需要。如果你的前端永远不直接接触 Supabase,一切都通过你自己的服务器,那么你的 API 就是安全层。但我仍然建议至少添加基于所有者的策略作为第二道防线。如果有人在你的 API 中找到一个漏洞,RLS 会捕获漏掉的部分。

RLS 会影响性能吗?

可能会,如果你的策略涉及大表上的昂贵子查询。修复方法几乎总是建立索引。确保策略条件中使用的列(user_id、org_id 等)有索引。去年一个项目中,在 org_id 上添加索引将策略评估时间从约 40ms 降低到了不到 2ms(表中有 80 万行)。

我能在 Supabase Realtime 中使用 RLS 吗?

可以。Realtime 订阅遵守 RLS 策略。如果用户订阅了一个表上的更改,他们只会收到其策略允许他们看到的行的事件。这是 Supabase 架构中的一个真正不错的设计决策。

`for all` 和编写单独的策略之间有什么区别?

for all 创建一个涵盖 SELECT、INSERT、UPDATE 和 DELETE 的单一策略。它对管理员覆盖模式很方便。对于其他一切,我按操作编写单独的策略,因为条件通常不同。SELECT 可能允许公开读取,而 INSERT 需要所有权。当凌晨 11 点出了问题时,单独的策略更容易理解。

我如何调试一个正在阻止它不应该阻止的请求的策略?

首先:检查 auth.uid() 是否真的返回了一个值。如果用户未经身份验证,它会返回 null,大多数策略都会失败。其次:暂时将策略设置为 USING (true) 以确认查询本身正常工作。第三:添加一个测试策略,记录你正在检查的值(使用一个安全定义函数来抛出通知)。Supabase 仪表板日志也会显示 RLS 违反,这使得这个过程比过去痛苦得少很多。

---

行级安全不是性感的工作。没人会写博文讲述没有发生的数据泄露。但2021年那次项目数据泄露事件让我意识到,"启用了RLS"和"正确实施RLS"之间的差距比大多数人想象的要大得多。这些策略弥补了这个差距。至少对我来说是这样。

← 返回