ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Supabase RLS实战:投稿审核系统行级安全策略设计

Supabase RLS实战:投稿审核系统行级安全策略设计 做社区内容平台尤其是“游客能看、登录用户能投稿、后台审核后才公开”的内容模型权限设计往往比业务代码更早卡住你。最近我在 Supabase 上做了一套投稿审核系统把 RLSRow Level Security策略拆成公开读、投稿写、待审核可见三段跑通之后发现里面有不少细节值得单独写一篇。如果你正打算在 Supabase 里实现类似的“投稿箱 编辑部”流程或者只是想把 RLS 用得更稳一点这篇可以直接拿来参考。我要先说明一点RLS 不是 Supabase 发明的它是 PostgreSQL 自带的行级安全机制Supabase 只是帮你把“建策略”这件事做成了可视化和 SQL 脚本。所以这篇文章里的所有 SQL几乎可以原封不动用在任何配了 Postgres 的项目里只不过我会用 Supabase 的auth.uid()、JWT 解析习惯来写。1. 需求拆解从“谁能看什么”到可落地的策略矩阵1.1 三种角色、三种状态先画一张权限表我做的这个模块业务上是一个“投稿箱”游客访问社区首页能看到已经过审的文章登录用户可以投稿但投稿后内容不会立刻公开要等编辑审核编辑能看到所有待审核和已发布的文案并决定“通过”还是“退回”。把角色和行状态摊开会得到一张权限矩阵行状态匿名用户 anon普通登录用户 authenticated审核员 moderatorpending待审核不可见仅作者本人可见全部可见published已发布全部可见全部可见全部可见rejected已退回不可见仅作者本人可见全部可见注意普通登录用户对“他人 pending/rejected”的内容不可见这一点是“待审核可见”策略最容易写崩的地方。很多人会把策略写成“状态等于 published 就公开否则只有管理员可见”结果作者自己提交完就看不到自己的稿子了后台也看不到退回理由产品逻辑直接断裂。RLS 的优势恰好在这同一张表、同一行数据可以同时存在多条 SELECT 策略。多角色看到的行集合是策略“并集”也就是满足任意一条策略的行就会被返回。这样自然实现了“公开读 作者读自己的 审核员读全部”的叠加效果。1.2 为什么这个场景最适合用 RLS有人可能会问我直接在应用层判断一下角色查数据库时不带 statuspending 的行不就行了吗我试过这条路最后放弃了。原因有三第一个坑是“查询入口太多”。应用层如果走 REST API你还能统一下逻辑但如果 Supabase 的客户端直连数据库、后端定时任务也要读、运营后台还要导出数据每个入口都得重复写一遍过滤条件早晚会漏一个。第二个坑是“行级控制做不到”。应用层只能控制“查哪张表”控制不了“同一张表里哪些行谁能查”。像文章这种数据是必须存在同一张表里的否则审核通过后要把数据搬到另一张表事务、外键、更新时间都会很麻烦。第三个坑是“安全兜底问题”。RLS 是数据库层面的约束无论如何绕过应用逻辑直查数据库它都会生效。应用层判断只是业务逻辑RLS 才是最后一道锁。1.3 被很多人忽略的设计决策作者到底能不能改已发布内容在设计这张权限矩阵时我特意把“普通用户”的 UPDATE 权限全部去掉而不是让他们改自己的文章。原因很实际如果作者能 UPDATE 已发布文章的状态和内容就会出现“审核通过一篇文章作者再悄悄改回草稿或者替换内容”的问题。RLS 只能判断“你改的这行你是否可见/可写”判断不了“你从 pending 改成 published 是不是经过了审核流程”。所以我采用的策略是作者只有 INSERT 和 SELECT 权限没有 UPDATE 权限已发布内容如果写错了走“撤回 重新投稿”流程。审核员的 UPDATE 权利也不直接给角色策略而是封装成一个审核函数函数里写死状态机。后面第 5 章我会展开说。2. 建表与启用 RLSenum 状态机和 FORCE 策略缺一不可2.1 表结构设计状态枚举 审核字段建表之前先把状态枚举定义好。我不喜欢用字符串裸存状态那样写策略时容易拼错值也很容易被应用层塞进一个非法状态。create type public.post_status as enum ( pending, published, rejected );然后建文章表create table public.posts ( id uuid primary key default gen_random_uuid(), title text not null check (char_length(title) between 1 and 120), content text not null check (char_length(content) between 1 and 30000), author_id uuid not null references auth.users(id) on delete cascade, status public.post_status not null default pending, reject_reason text, reviewed_by uuid references auth.users(id), reviewed_at timestamptz, published_at timestamptz, created_at timestamptz not null default now(), updated_at timestamptz not null default now() );几个字段的设计意图说一下author_id记录投稿人RLS 的作者自读策略全靠它。reject_reason审核退回的原因作者可见但不公开。reviewed_by/reviewed_at审核审计信息只有审核函数能写。后面你会看到这些字段天然成了审核流程的防伪标记。published_at首次公开时间。用户投稿时这个字段必须为空防止“预写公开时间自导自演”。updated_at建议搞一个触发器自动维护否则每次 UPDATE 都要手动带很容易忘。2.2 别忘了 force row level security建完表以后光enable row level security还不够。PostgreSQL 默认情况下表的 owner也就是超级管理员会绕过 RLS。你在 Supabase 的 SQL Editor 里查数据时身份往往就是 owner这会导致一个非常具有迷惑性的现象明明策略已经写了但你一查还是能看到所有行。正确姿势是连force一起用alter table public.posts enable row level security; alter table public.posts force row level security;force row level security的意思是即使是表 owner也必须遵守 RLS 策略。这样你在 SQL Editor 里调试时行为才更接近真实前端用户而不是被自己的管理权限掩盖问题。提示Supabase 的service_rolekey 走的是另一个完全绕过 RLS 的通道这个 key 只能放在受信任的后端环境里千万别丢到浏览器端。2.3 配套索引怎么设计才不拖查询后腿RLS 策略写在 SQL 里看起来像是一层“额外的过滤条件”。但千万别低估它对查询计划的影响。以我的经验RLS 最常拖慢的查询就是“公开列表页”和“作者草稿列表”这类高频场景。我建了这两个复合索引create index idx_posts_status_created on public.posts (status, created_at desc); create index idx_posts_author_status on public.posts (author_id, status);第一个索引给公开读where status published order by created_at desc走索引非常快。 第二个索引给作者自读where author_id auth.uid()同时也能覆盖author_id status的过滤。索引不用建太多这两个基本够用。复杂查询可以再用explain analyze看是否走到了索引没走到就针对具体 SQL 调整。3. SELECT 三连策略公开读、作者读、审核者读怎么叠加3.1 三条策略对应的 SQL 写法SELECT 是整个权模型里最核心的部分我一次性写了三条策略-- 策略1公开读 create policy select_published_for_everyone on public.posts for select to anon, authenticated using (status published); -- 策略2作者读自己的所有内容 create policy select_own_for_author on public.posts for select to authenticated using (author_id auth.uid()); -- 策略3审核员读全部 create policy select_all_for_moderator on public.posts for select to authenticated using (public.is_moderator());这三条策略对应开头那张权限矩阵一行都不差。第一条“公开读”明确把anon和authenticated都包含进来游客和登录用户看到的公开内容一致。 第二条“作者读自己的”不限制 status因为作者要能看到自己“待审核”“已退回”“已发布”的完整状态尤其在文章被退回时需要看到退回理由。 第三条“审核员读全部”通过函数判断当前用户是不是 moderator函数写法在第 5 章给出。3.2 策略叠加是 OR 不是 AND很多人第一次接触 RLS 会误以为同类型策略像多个 WHERE 条件一样是 AND实际上 PostgreSQL 对同一张表的 permissive 策略取的是 OR 关系。也就是说登录用户查posts时会把“公开读”“作者自读”“审核员读”三条策略都跑一遍满足任何一条的行都会被返回。验证逻辑很简单如果我是普通用户 A访问一篇statuspublished且author_id用户B的文章那么“公开读”策略成立直接能读“作者自读”策略不成立因为author_id不等于我的 uid。但 OR 关系下“公开读”成立就足够放行了。这不叫权限泄漏它正是“已发布内容对所有人可见”的预期。反过来如果一篇文章是用户 B 的pending草稿用户 A 访问时三条策略都不满足看不到用户 B 自己访问时“作者自读”成立看得到审核员访问时“审核员读全部”成立也能看到。这正是“待审核可见”的精髓同一行数据对三种身份呈现三种可见性靠的是多策略叠加而不是把数据复制多少份。3.3 匿名用户的并发风险与 auth.uid() 空值问题匿名用户走第一条策略时auth.uid()在 SQL 里会变成null。如果某天有人为了图省事把“作者自读”策略也开给anonSQL 看起来是using (author_id auth.uid())因为auth.uid()是 null任何author_id null都不会成立所以不会泄露数据。但千万别依赖这个偶然行为更不要在这种策略里写auth.uid() is null之类的条件那等于把未登录用户当成作者放进来结果会把人家的文章全漏出去。匿名用户的另一个问题是并发刷公开接口。RLS 只控制谁能读不控制读多少。公开读策略本身没问题但要防爬虫、防刷接口必须在更上层的 API 网关做限流RLS 不负责这个。4. INSERT 投稿策略WITH CHECK 才是真正的守门员4.1 为什么 INSERT 用 WITH CHECK 而不是 USING写投稿策略时有个新手必经的坑INSERT 策略只有with check没有using。using判断的是“现有行是否对当前用户可见/可操作”而 INSERT 是在制造一行历史上不存在的数据所以 PostgreSQL 只用with check来校验“新插入的这一行是否满足条件”。所以投稿策略长这样create policy insert_own_pending_post on public.posts for insert to authenticated with check ( author_id auth.uid() and status pending and published_at is null and reject_reason is null and reviewed_by is null and reviewed_at is null );它的意思是登录用户可以插入一行文章但这行必须是作者自己、初始状态 pending、没有任何审核痕迹、也没有预写公开时间。任何试图伪装成别人投稿、直接写成 published、或者偷偷塞审核人字段的 INSERT 请求都会被数据库拒绝。4.2 投稿入口的字段级约束有人可能会问author_id和status都能被with check拦住但title、content这些正文内容为什么不在策略里写因为这类“非空”“长度限制”用数据库的not null和check约束处理更直接性能也更好。RLS 的with check适合做“跨字段、靠当前用户身份判断”的复杂规则不适合重复做基础校验。举个例子我建表时已经写了title text not null check (char_length(title) between 1 and 120), content text not null check (char_length(content) between 1 and 30000)如果我再在策略里写char_length(title) 0就是重复造轮子还影响查询性能因为策略表达式会逐行执行。真正的字段级约束应该交给两层数据库约束非空、长度、类型。WITH CHECK用户身份、状态机初始值、审核字段为空。这两层缺一不可但职责必须分开。4.3 前端直接写库到底安不安全Supabase 的典型玩法是前端拿着 anon key 或用户 JWT 直接操作数据库不走自建后端。很多从传统后端转过来的人第一反应是害怕担心前端写库等于裸奔。实际上只要 RLS 开启前端用supabase.from(posts).insert({...})发请求时PostgREST 会把当前用户的 JWT 转成 SQL 里的auth.uid()然后所有行都过一遍策略。insert请求最终能不能落库完全取决于with check是否通过。所以“前端直接写库”在 RLS 模型下是可控的。你应该担心的不是 RLS 本身而是两个边界千万别把service_rolekey 发到前端它会完全绕过 RLS。别在前端写“先查再写”的逻辑因为你查到的数据和你写入的数据可能不是同一套权限。比如前端查到自己能读某个published行不代表它能把这个status改成别的值UPDATE 策略是另一套判断。5. 审核动作UPDATE 策略与 SECURITY DEFINER 函数的取舍5.1 用角色策略直接授权 update 的利与弊最直觉的做法是给审核员一个 UPDATE 策略允许他把pending改成published或rejectedcreate policy moderator_update_post on public.posts for update to authenticated using (public.is_moderator()) with check (public.is_moderator());这条策略能跑通但它有一个隐患using只控制旧行是否可见with check只控制新行是否满足两者都不限制“你改的是哪一列”。也就是说审核员一旦拥有 UPDATE 权限不仅能改 status还能顺手改title、content甚至把reject_reason抹掉。如果审核员只是误操作问题还不大如果审核员账号被盗攻击者可以直接篡改所有已发布内容这在内容社区里是灾难性的。所以我的建议是审核动作不要开放通用 UPDATE而是封装一个专门的审核函数。5.2 用函数封装审核流状态机更可靠审核函数的核心思路是普通用户没有任何 UPDATE 策略数据库层面禁止一切直接 UPDATE审核员也只能通过调用函数来改状态。函数内部写死状态机、校验身份、更新审计字段。create or replace function public.review_post( target_id uuid, new_status public.post_status, review_note text default null ) returns void language plpgsql security definer set search_path public as $$ begin if not public.is_moderator() then raise exception permission denied; end if; -- 只允许审核为 published 或 rejected if new_status not in (published, rejected) then raise exception invalid target status; end if; update public.posts set status new_status, reject_reason case when new_status rejected then review_note else null end, reviewed_by auth.uid(), reviewed_at now(), published_at case when new_status published then coalesce(published_at, now()) else published_at end, updated_at now() where id target_id; if not found then raise exception post not found; end if; end; $$; revoke execute on function public.review_post from public, anon; grant execute on function public.review_post to authenticated;这个函数的好处一眼就能看出来状态机被锁死在函数里审核员只能从pending改成published或rejected不能改成奇奇怪怪的中间值。reviewed_by和reviewed_at由函数自动填充想伪造审核记录必须攻破这个函数。函数只做“审核”这一件事不会像通用 UPDATE 策略一样把修改正文的权力顺手交出去。调用方式也很简单select public.review_post(文章id, published); select public.review_post(文章id, rejected, 内容不够完整请补充你的实践细节);5.3 is_moderator() 的正确打开方式审核函数和前面 SELECT 策略都用到了is_moderator()。这个函数要从 JWT 里读角色我最初的写法是直接取字符串create or replace function public.is_moderator() returns boolean language sql stable security definer set search_path public as $$ select coalesce(auth.jwt() - app_metadata - role, ) moderator; $$;这个写法简单但后来我发现有些团队会把 roles 存成数组而不是单字符串JWT 结构一变函数直接失效。所以后来我改成兼容数组和单个字符串的写法create or replace function public.is_moderator() returns boolean language sql stable security definer set search_path public as $$ select exists ( select 1 from jsonb_array_elements_text( coalesce( auth.jwt() - app_metadata - roles, auth.jwt() - app_metadata - role, []::jsonb ) ) role_name where role_name moderator ); $$;jsonb_array_elements_text会把 JSON 数组展开成一行行文本然后匹配moderator。如果 JWT 里只存了一个字符串role上面这个 SQL 会把它转成单元素数组也能匹配上兼容性更好。提示在 Supabase Dashboard 的 Authentication → Users 里给某个用户添加role: moderator到app_metadata后需要重新登录一次JWT 才会带上新角色。还有个权限细节我revoke execute on function public.review_post from public, anon只保留authenticated的执行权。这样匿名用户和没有登录的会话连调用审核函数的入口都没有而不是等函数内部报错。6. 上线前必查的坑递归策略、身份绕过和 Realtime 订阅6.1 策略里千万不能 SELECT 同一张表RLS 策略里写子查询要非常小心。如果你在posts的策略里写using ( exists (select 1 from public.posts where ...) )那 PostgreSQL 在执行这条策略时内部查询又会触发对posts的 RLS 策略就形成无限递归直接报infinite recursion detected in policy for relation posts。遇到这种情况正确的做法是把判断逻辑挪到另一张表或函数里。比如我要判断“这篇文章的作者是否被封禁”可以建一张user_bans表策略里去查user_bans而不是在posts的策略里对posts做子查询。6.2 SQL Editor 测试结果不准超级用户会绕过 RLS这是最容易误导人的地方。你在 Supabase SQL Editor 里跑select * from public.posts;如果看到所有行包括别人的 pending 草稿千万别立刻怀疑策略写错了。先确认你连接的身份是不是超级用户。因为force row level security只在你显式执行后才对 owner 生效而且如果你是在旧表上临时改的最好检查一下表的 owner 和当前用户。最靠谱的测试方法是模拟真实用户的 JWT 上下文。Supabase 的 SQL Editor 里可以使用这样的方式set local role authenticated; set local request.jwt.claims {sub: 某个用户id, role: authenticated, app_metadata: {role: moderator}}; select * from public.posts;注意set local只在当前事务生效要包在begin; ... commit;里。或者更简单直接在浏览器端用 Supabase JS 客户端登录测试账号去查询那个路径走的才是真实 RLS 链路。6.3 别忘了 Realtime 订阅同样受 RLS 约束如果你的社区页面想用 Supabase Realtime 做“审核通过后文章自动出现在首页”一定要注意Realtime 推送同样受 RLS 策略过滤。也就是说当审核函数把某行从pending改成published时前端如果有个游客正订阅着posts表他能收到这条变更消息吗只有当这行数据满足该游客的 SELECT 策略也就是status published时Realtime 才会把消息推给他。反过来如果审核通过前还处于pending不管你怎么订阅游客客户端都收不到更新因为那行数据对它根本不可见。这其实是个很好的安全特性但也意味着你在调“实时列表不刷新”的 bug 时先别怀疑前端代码先检查行的状态是不是满足了订阅者的 SELECT 策略。6.4 团队维护策略时的节奏建议最后聊点工程化的体会。RLS 策略的数量一多我在团队里就强制把“上线 RLS 策略”当成“上线代码”来对待。每个策略命名采用“动作_目标_角色”的格式比如select_published_for_everyone、insert_own_pending_post。不要起policy1、new_policy这种名字三个月后没人知道它管什么。所有策略都用 migration SQL 管理放版本控制不要只在小黑框里手动点一遍 Dashboard。否则环境一多开发、测试、生产各有一套手工策略出问题没法追溯。每次调整 RLS 策略后至少跑三类自测匿名访问、普通用户访问、审核员访问。这三类对应的查询结果完全符合预期才敢接前端。我个人在实际操作中的体会是RLS 策略写得再好也只是一把数据库层的锁它解决的是“数据能不能被看到/写入”这件事解决不了产品流程上的所有问题。你仍然需要在前端做状态提示、在应用层做限流、在运营端做审核队列。但反过来如果没有这把锁那些应用层手段再完备也总有漏掉一条水路的风险。先把 RLS 这块地基打稳后面的审核流、公开流、通知流跑起来才踏实。
返回列表