☰
Supabase RLS实战:内容投稿系统的行级安全设计
2026/10/5 11:19:10 网站建设 项目流程

先别急着开写 SQL,把这个场景想清楚再动手。做过内容型产品的人都有体会,投稿、审核、公开可见这三件事看着简单,做起来全是权限细节:用户能不能读自己还没过审的内容?审核员怎么高效看到待审列表?匿名用户会不会通过 API 绕过前端直接查到未发布数据?如果项目用了 Supabase,这些问题绕不开一个核心机制——RLS(Row Level Security,行级安全)。我前阵子刚好把一个社区投稿功能完整落地了,权限模型就是“公开读、投稿写、待审核可见”。这篇把我当时的表结构设计、策略写法、踩过的坑一次性梳理清楚。

1. 场景拆解:先定权限矩阵,再谈策略代码

1.1 三种身份与五种访问诉求

动手写任何一条 RLS 策略之前,先把用户诉求列成表格比什么都管用。我这个项目里有三类人:匿名访客、登录用户、运营审核员。其中登录用户又可以细分为“投稿过的人”和“还没投过稿的人”。他们的诉求其实是五种:

身份能干什么不能干什么
匿名访客读取状态为“已发布”的文章列表和详情访问未发布、已驳回内容
登录用户(未投稿)创建新投稿修改、删除任何人的内容
登录用户(已投稿,且为内容作者)读取自己所有投稿记录(含草稿、审核中、已发布、被驳回)修改已发布内容(按业务规则)
运营审核员读取所有待审核内容,更新审核状态修改业务核心字段(按业务规则)
Supabase 服务端(service_role)全量读写(仅在安全边界内使用)不该在前端使用

我在纸上把这五条画出来后,才意识到“公开读”和“待审核可见”本身就是两个不同维度的需求,不能揉在一条策略里硬写。“公开读”是写给所有人的过滤器,而“待审核可见”是写给内容所有者的特殊视角。分开设计,策略的可读性会好很多。

1.2 为什么不用 Application Layer 硬判断

有朋友问过我:这个权限用 Next.js 中间件不好吗?响应拦截器里判断一下 status 字段不就行了?理论上确实能挡住大多数普通用户,但绝对挡不住有心人。Supabase 默认会把 anon key 放在前端 Bundle 里,任何人打开 DevTools 都能看到完整的 API 地址和 Key。这意味着他完全可以绕过你的前端,直接用 REST 客户端或 Supabase 客户端 SDK 查数据。

如果权限只在前端控制,数据库层面等于大门敞开。这不是危言耸听——任何select请求只要带着 anon key 发出,后端是照单全收的。RLS 的价值就在这里:把安全边界下沉到数据库行级,哪怕请求绕过了前端,也绕不过 PostgreSQL 的每一行检查。为了验证这个说法,我搞完策略后专门用 Postman 直接请求 Supabase REST API,用 anon key 试查询 status='draft' 的记录,返回的结果是空数组,这才算彻底放心。

1.3 表结构与时序字段设计

权限矩阵清晰后,表结构的设计才有依据。我的文章表大概长这样:

create table posts ( id uuid primary key default gen_random_uuid(), title text not null, content text not null, author_id uuid not null references auth.users(id) on delete cascade, status text not null default 'draft' check (status in ('draft', 'pending', 'published', 'rejected')), created_at timestamptz not null default now(), reviewed_at timestamptz, reviewed_by uuid references auth.users(id) ); create index posts_status_created_idx on posts (status, created_at desc); create index posts_author_status_idx on posts (author_id, status);

两个索引都别省。posts_status_created_idx是为公开列表页的倒序分页准备的,posts_author_created_idx是为“我的投稿”列表准备的。如果你的数据量到了几十万行,没有这两个索引,RLS 就算过滤对了,查询照样慢得让你怀疑人生。

2. 公开读策略:一条 SQL 里的边界思考

2.1 启用 RLS 与基础策略

表建好后,第一步一定是启用行级安全:

alter table posts enable row level security;

这一步不做,后面写什么策略都不生效,而且 Supabase 默认会拒绝所有外部访问。启用后我写的第一条策略就是公开读:

create policy "public_read_published" on posts for select to anon, authenticated using (status = 'published');

这里有两个容易被忽略的点。第一个是to anon, authenticated这段,它把匿名用户和普通登录用户都纳入了同一规则。如果漏掉anon,游客访问公开文章时会直接 401 或空结果,排查起来很坑。第二个是using和check的区别。using管的是“哪些行可以被操作”,对 select 而言它决定你能看见什么;check管的是“新增或修改时哪些值合法”。很多人一开始把check写成status = 'draft',导致发布功能永远报错,就是这个原因。

2.2 公开读与后续更新互斥吗

写公开读策略时,我还刻意确认了一件事:select策略和update策略是互相独立的。也就是说,游客能读status='published'的行,并不意味着他们能更新或删除这些行——更新删除需要额外的for update/for delete策略。默认情况下没有对应策略,操作就是不被允许的。清晰分离这几种for子句,会让整套权限模型非常干净。

2.3 公开列表的分页与计数细节

公开读策略就位后,我在列表页验证了一个容易被忽略的小细节:带count的查询。很多前端都会用supabase.from('posts').select('*', { count: 'exact', head: true })来做总数分页。但请注意,count 的计算是受 RLS 约束的,未过审记录会被自动剔除。这意味着列表页显示的总页数和管理后台的“全部文章”计数很可能不一致,这不是 Bug,是特性。你在给产品提需求时,要把“游客看到的计数”和“管理端看到的计数”分开定义,避免前端同学拿着两个数跑来问你为什么对不上。

注意:RLS 对 count 查询也生效。这既是安全兜底,也是产品逻辑的一部分,提前和测试对齐预期。

3. 投稿写策略:WITH CHECK 是最后一道闸

3.1 一次失败的插入实验

公开读策略写完后,我随手用接口测了一次匿名 insert,预期当然是失败。接着换成登录用户去 insert,前端控制台立刻报错:new row violates row-level security policy。当时第一反应是策略写错了,后来冷静下来一查,才发现插入策略和读策略的逻辑不是一回事。

用户能插入内容,不代表他能插入给自己看。这条策略的完整写法应该是:

create policy "users_insert_own_post" on posts for insert to authenticated with check (author_id = auth.uid());

with check的含义是:这一行新数据必须满足什么条件才允许落库。我要求author_id必须等于当前登录用户的 ID,这就杜绝了用户伪造author_id投到别人名下的可能。也许有人觉得“谁会这么干”,但安全设计本来就是默认不信任何人——被你信任的只是 PostgreSQL 的约束,不是前端的传参。

3.2 author_id 到底该谁填

这里引出一个前后端协作的关键问题:author_id应该由客户端传进来吗?

我的答案是:永远不要。客户端传author_id等于把权力交给了不信任的一方,哪怕 RLS 兜底,也不是最佳实践。正确姿势是在创建文章时,使用 Supabase 的客户端返回的user.id来构建插入对象。如果你写的是 Postgres 函数,可以用auth.uid()直接取当前用户,任何客户端传参都被忽略。

顺带提一个细节:Supabase 的 SDK 在服务端和浏览器环境下取当前用户的方式不一样,浏览器用getUser(),服务端用getUser(token)。如果混用,auth.uid()可能取到空值,RLS 就会默默把所有行都挡住。这个我踩过,浪费了整整一个下午排查,最后发现只是环境变量里 token 没传对。

3.3 service_role 的滥用是最大的隐患

有一个雷必须单独拿出来讲,那就是 service_role key。Supabase 文档里明确写了,service_role 可以绕过 RLS。很多团队图省事,在前端环境变量里也放了 service_role,等于给数据库开了一扇后门。理论上你可以用它做服务端管理操作,但它绝不能出现在前端。

我在项目里把 service_role 的调用全部限制在 Edge Functions 或本地脚本里,前端的 anon key 只能走 RLS。你可以把这理解为“员工可以从正门进公司,但只有少数人有万能钥匙”,万能钥匙放前台抽屉里,等于没锁门。

4. 待审核可见策略:一条 OR 搞定的特殊视角

4.1 两种读路径的叠加

到了这一步,真正的难点来了。用户创建投稿后,一定希望立刻看到自己提交的内容:“等等,我发的帖子去哪儿了?”如果公开读策略挡住了未发布内容,用户本人也会被挡在外面。所以我们需要一个“作者视角”的策略,让用户能看到自己的全部状态记录。

我当时的处理方式是再写一条独立的 select 策略:

create policy "users_read_own_posts_all_status" on posts for select to authenticated using (author_id = auth.uid());

策略建好后,系统里的实际过滤逻辑其实是两条select策略的并集:游客能读已发布内容,作者能读自己的全部内容。一位作者同时作为一名普通用户,他既能看到自己已发布的作品(通过作者的策略,也可通过公开读策略),又能看到自己未过审的草稿(只能通过作者的策略),还能看到其他人的已发布内容(通过公开读策略)。这正是“待审核可见”的完整含义。

4.2 审核后可见性自动切换

审核通过后发生了什么?其实不需要特殊操作。审核员把status从pending改为published的那一瞬间,记录就从“仅作者可见”自然过渡到“所有人可见”。这一套逻辑是完全由 RLS 动态判断的,不用写触发器、不用清缓存,安全策略本身就在实时生效。审核驳回也是同理,状态变为rejected后,游客立刻看不到,作者仍能看到,方便他查看驳回原因。

这个模型对产品体验来说相当友好:不用额外维护一张“可见性快照表”,也不会出现“审核通过了但用户看不到”的典型缓存问题。你只需要确保审核员的更新操作能成功变更状态字段。为此我单独写了审核员的策略:

create policy "moderator_update_status" on posts for update to authenticated using (auth.jwt() ->> 'role' = 'moderator') with check (auth.jwt() ->> 'role' = 'moderator');

注意,我用的是 JWT 里的自定义role声明。Supabase 默认的 JWT 里没有role,需要去 Dashboard 的 Custom Claims 里配置,或者通过触发器在注册时自动设置。如果你直接把role存在 users 表里也行,但每次更新都要读表,性能略差。数据量小的时候感受不到差异,但既然用了 JWT,就尽量把常用权限放进 JWT Claims 里。

4.3 审核员的可见范围与前端视角

审核员也需要一个“待审列表”视角。但我不建议给审核员一个“看所有行”的全量策略,更合理的做法是只放开待审核状态:

create policy "moderator_view_pending" on posts for select to authenticated using (auth.jwt() ->> 'role' = 'moderator' and status = 'pending');

这样审核员在前台只能看到待审信息流,不会误入全文数据库。万一运营同学手滑翻了不该看的数据,也能被权限挡住。你的审核后台界面也只需要查询这一个视图条件,前端逻辑立刻变简单。

提示:如果你希望审核员能同时看到“已发布”和“被驳回”的内容做历史留痕,可以把status in ('pending', 'rejected', 'published')写进using,但一定别把draft放进去。草稿是用户私人空间,运营不该窥探。

5. 进阶:策略不够用时,函数来凑

5.1 用 SECURITY DEFINER 处理多表关联

有些场景的权限判断不是单张表能搞定的。比如,业务上允许某个作者的好友在“待审核可见”阶段预览文章。这意味着判断条件要关联作者的好友关系表,而好友关系表同样受 RLS 控制。如果策略里直接查好友表,会遇到常见的“递归策略”问题:查 posts 触发了对好友表的 RLS 策略,然后好友表的策略又引用了其他表,形成套娃。

我在这种场景下用得比较顺手的方式是写一个 SECURITY DEFINER 函数,把复杂判断逻辑封装在数据库后端执行,并明确指定使用函数所有者的权限来运行,绕过当前的 RLS 层级。

create or replace function can_preview_post(target_post_id uuid) returns boolean language sql security definer set search_path = public as $$ select exists ( select 1 from posts p where p.id = target_post_id and p.status in ('draft', 'pending') and exists (select 1 from friendships f where f.user_id = p.author_id) ) $$;

启用security definer时,有一点特别容易被忽略:函数体内不能使用auth.uid()替代当前用户判断,因为它运行时是基于函数所有者的权限。正确做法是把要检查的用户 ID 作为参数传入。比如can_preview_post(target_post_id, auth.uid())。如果你直接在里面写死了函数所有者的 ID,那任何人都能通过这个函数读取所有待审内容。

这种“函数化策略”强烈建议只在逻辑确实复杂到 SQL 写不清楚时才用。能用简单策略解决的问题,不要去动函数。函数一旦多起来,审阅和维护的负担会剧增。

5.2 Realtime 订阅也一样受 RLS 管制

如果你的产品需要列表实时刷新,Supabase Realtime 也走 RLS 过滤。也就是说,游客只能收到published记录的变更事件,作者能收到自己所有记录的变更。我遇到过一种情况:Realtime 推送的 payload 里的旧值(old record)对某些用户可见,新值不可见,导致前端短暂显示了不该看的内容。

这个问题的根源不在 RLS,而在 Realtime 的消息格式。客户端收到的 payload 里同时包含new和old两套数据,new是变更后的值,old是变更前的值,界面往往会先渲染old。如果审核员把某条draft改成published,这条信息对游客可读,但old这个草稿内容也在同一份 payload 里,前端如果直接信任 payload 去渲染,就可能泄露“变更前”的草稿内容。稳妥的方案是前端只渲染new内容,对old一律不信任——或者后端订阅时就用 RLS 可读性判断后再转发。

5.3 与全文搜索、生成式回复等扩展机制的整合

这个投稿系统后续大概率还要接全文搜索或 AI 摘要回复。搜索和 AI 用的数据源必须显式加上“仅 published 状态”的过滤条件。因为有些团队习惯在 Edge Function 里直接连 Postgres,而 Edge Function 默认拥有高权限,容易越过 RLS 查询到草稿数据。前端对搜索结果的展示默认认为“能返回就是能看”,高权限函数一旦把非公开数据混进去,就等于在搜索框里开了个后门。我通常在函数入口就强制校验:

status = 'published'

并在文档里写明:所有面向用户的数据出口,都要经过 RLS 或显式状态过滤,不许出现“先查全表再前端过滤”的写法。

6. 常见问题排障实录:这些坑我一个不落踩过

6.1 为什么我的策略写了却一直 404 或报 Permission Denied

这种情况 90% 是没启用 RLS。很多人建表后直接写策略,但忘了执行alter table posts enable row level security;。Supabase 默认是关闭所有访问的,你不开就会得到看似“数据库拒绝”的结果。建议建表后立刻启用,策略可以慢慢调,开关别留着。

另一种情况是enable row level security写了,但策略的to子句只写了authenticated,游客访问自然被拒。如果产品有公开浏览页面,to anon, authenticated要一起带上。排查时直接用 Supabase 的 SQL Editor 手动模拟:

set role anon; select * from posts;

这条语句能快速判断 anon 角色到底能看到什么。如果返回为空,要么是 RLS 挡了,要么是策略真的没建对。

6.2 插入时“new row violates row-level security policy”怎么处理

先确认author_id有没有正确落库。我遇到过一种情况:前端插入时用了一个旧的自增字段名user_id,而表里是author_id,导致策略里的auth.uid()从来没匹配上。表结构设计和字段命名一定要统一,不然排查成本极高。

其次确认auth.uid()是否返回空。在 SQL Editor 里执行select auth.uid();,返回空说明当前连接上下文没带 JWT。前端 SDK 通常会带,但如果你在本地用 psql 直连测试,就要手动set request.jwt.claims。这也是一个典型的“本地好使,线上不行”的坑。

6.3 更新时报错,但我明明给了 update 权限

更新时的报错通常和with check有关。比如,审核员更新status为published,但策略里with check (status = 'pending'),更新后新行不再满足条件,事务就失败了。记住:for update策略同时有using和with check,using判断旧行是否可操作,with check判断新行是否合法。改状态时新旧值不同,两段条件都得应对。

6.4 RLS 生效顺序与性能排查

RLS 不是加在每个查询后面的额外过滤条件,它是在计划阶段就融合进 SQL 执行计划的。所以你看explain时能看到 RLS 相关的 Filter。性能优化方式就是给过滤字段建索引,比如status和author_id这俩高频字段。如果遇到Or条件特别复杂导致查询变慢,可以考虑拆成两条策略或一条联合索引。数据量小的时候不用太焦虑,数据量到了百万级再回头调索引,也是来得及的。

6.5 策略复制粘贴的几个重灾区

团队里多个人协作时,策略的命名很容易混乱。建议命名时把“操作行为 + 目标角色 + 数据范围”三要素写清楚,例如user_insert_own_posts、moderator_update_status。同时,在 SQL 文件里给每个策略配一两行注释,解释这个策略对应产品的哪个需求点。不然半年后你自己回来看,看到七八条策略不记得哪条是干嘛的,只留下一句“谁写的”的疑问。这一点不算技术,但能极大降低维护成本。

7. 从策略到上线:我实际执行过的五步走

从零到一实现这套“公开读、投稿写、待审核可见”的流程,我习惯走五步,每一步都有明确的产出和验证手段。

第一步,梳理权限矩阵,把上文的五种访问诉求在文档里确认清楚,和产品经理对齐。哪怕不画那种复杂的 UML 图,至少把表格列出来。这个环节最怕遗漏边缘角色。

第二步,设计表结构并启用 RLS。建索引、建检查约束一次做完。

第三步,逐个写策略,写一条验证一条。验证方式用 SQL Editor 切换角色执行查询,别用前端一把梭。

第四步,前端接入时确认 API 调用方式。尤其是把getUser()的时机租在正确位置,避免 token 没加载完就调用查询。

第五步,用模拟抓包方式检查安全:用 anon key 直接请求数据接口,试着访问不同状态的记录,确认返回结果完全符合预期。这一步通过后,才算真的“上线”。

这五步做下来,基本上不会再被 RLS 的隐藏逻辑坑到。我在实际项目里还养成了一个习惯:每当新增一种用户角色或数据状态,第一件事先修改权限矩阵表,再动 SQL。顺序反过来,策略代码会越来越乱,最终连自己都不敢贸然改动。

最后再分享一个小心得:RLS 策略本身非常简洁,执行效率也高,但它的心智负担在于“逻辑是隐式的”。你很难一眼看出系统里所有策略叠加后的效果,所以文档和命名尤其重要。每一条策略都对应一个业务规则,尤其建议把“为什么这条策略存在”写在注释里,这样三个月后的你还能一眼看懂。如果你打算在现成项目上补 RLS,建议先把所有现有策略导出来审一遍,再动手加新的。很多时候你以为缺一条策略,其实只是旧的策略写得太宽,把应该挡住的请求也放行了。真正动手前,在测试环境把你的 anon key 和 service role key 分别指向两套 Supabase 实例,完全模拟线上环境后再跑一遍权限矩阵的测试用例,这样你推送出去的代码才有底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询