首页 / 资讯中心 / 文章详情

Supabase RLS实战:内容投稿系统的行级安全设计

Supabase RLS实战:内容投稿系统的行级安全设计 ★ FEATURED ARTICLE
先别急着开写 SQL把这个场景想清楚再动手。做过内容型产品的人都有体会投稿、审核、公开可见这三件事看着简单做起来全是权限细节用户能不能读自己还没过审的内容审核员怎么高效看到待审列表匿名用户会不会通过 API 绕过前端直接查到未发布数据如果项目用了 Supabase这些问题绕不开一个核心机制——RLSRow 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 试查询 statusdraft 的记录返回的结果是空数组这才算彻底放心。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策略是互相独立的。也就是说游客能读statuspublished的行并不意味着他们能更新或删除这些行——更新删除需要额外的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 checkusing判断旧行是否可操作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 实例完全模拟线上环境后再跑一遍权限矩阵的测试用例这样你推送出去的代码才有底气。
阅读完成 · 觉得有帮助?
咨询建站