:內(nèi)容投稿系統(tǒng)的行級安全設(shè)計)
先別急著開寫 SQL把這個場景想清楚再動手。做過內(nèi)容型產(chǎn)品的人都有體會投稿、審核、公開可見這三件事看著簡單做起來全是權(quán)限細(xì)節(jié)用戶能不能讀自己還沒過審的內(nèi)容審核員怎么高效看到待審列表匿名用戶會不會通過 API 繞過前端直接查到未發(fā)布數(shù)據(jù)如果項目用了 Supabase這些問題繞不開一個核心機(jī)制——RLSRow Level Security行級安全。我前陣子剛好把一個社區(qū)投稿功能完整落地了權(quán)限模型就是“公開讀、投稿寫、待審核可見”。這篇把我當(dāng)時的表結(jié)構(gòu)設(shè)計、策略寫法、踩過的坑一次性梳理清楚。1. 場景拆解先定權(quán)限矩陣再談策略代碼1.1 三種身份與五種訪問訴求動手寫任何一條 RLS 策略之前先把用戶訴求列成表格比什么都管用。我這個項目里有三類人匿名訪客、登錄用戶、運(yùn)營審核員。其中登錄用戶又可以細(xì)分為“投稿過的人”和“還沒投過稿的人”。他們的訴求其實是五種身份能干什么不能干什么匿名訪客讀取狀態(tài)為“已發(fā)布”的文章列表和詳情訪問未發(fā)布、已駁回內(nèi)容登錄用戶未投稿創(chuàng)建新投稿修改、刪除任何人的內(nèi)容登錄用戶已投稿且為內(nèi)容作者讀取自己所有投稿記錄含草稿、審核中、已發(fā)布、被駁回修改已發(fā)布內(nèi)容按業(yè)務(wù)規(guī)則運(yùn)營審核員讀取所有待審核內(nèi)容更新審核狀態(tài)修改業(yè)務(wù)核心字段按業(yè)務(wù)規(guī)則Supabase 服務(wù)端service_role全量讀寫僅在安全邊界內(nèi)使用不該在前端使用我在紙上把這五條畫出來后才意識到“公開讀”和“待審核可見”本身就是兩個不同維度的需求不能揉在一條策略里硬寫?!肮_讀”是寫給所有人的過濾器而“待審核可見”是寫給內(nèi)容所有者的特殊視角。分開設(shè)計策略的可讀性會好很多。1.2 為什么不用 Application Layer 硬判斷有朋友問過我這個權(quán)限用 Next.js 中間件不好嗎響應(yīng)攔截器里判斷一下 status 字段不就行了理論上確實能擋住大多數(shù)普通用戶但絕對擋不住有心人。Supabase 默認(rèn)會把 anon key 放在前端 Bundle 里任何人打開 DevTools 都能看到完整的 API 地址和 Key。這意味著他完全可以繞過你的前端直接用 REST 客戶端或 Supabase 客戶端 SDK 查數(shù)據(jù)。如果權(quán)限只在前端控制數(shù)據(jù)庫層面等于大門敞開。這不是危言聳聽——任何select請求只要帶著 anon key 發(fā)出后端是照單全收的。RLS 的價值就在這里把安全邊界下沉到數(shù)據(jù)庫行級哪怕請求繞過了前端也繞不過 PostgreSQL 的每一行檢查。為了驗證這個說法我搞完策略后專門用 Postman 直接請求 Supabase REST API用 anon key 試查詢 statusdraft 的記錄返回的結(jié)果是空數(shù)組這才算徹底放心。1.3 表結(jié)構(gòu)與時序字段設(shè)計權(quán)限矩陣清晰后表結(jié)構(gòu)的設(shè)計才有依據(jù)。我的文章表大概長這樣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是為公開列表頁的倒序分頁準(zhǔn)備的posts_author_created_idx是為“我的投稿”列表準(zhǔn)備的。如果你的數(shù)據(jù)量到了幾十萬行沒有這兩個索引RLS 就算過濾對了查詢照樣慢得讓你懷疑人生。2. 公開讀策略一條 SQL 里的邊界思考2.1 啟用 RLS 與基礎(chǔ)策略表建好后第一步一定是啟用行級安全alter table posts enable row level security;這一步不做后面寫什么策略都不生效而且 Supabase 默認(rèn)會拒絕所有外部訪問。啟用后我寫的第一條策略就是公開讀create policy public_read_published on posts for select to anon, authenticated using (status published);這里有兩個容易被忽略的點(diǎn)。第一個是to anon, authenticated這段它把匿名用戶和普通登錄用戶都納入了同一規(guī)則。如果漏掉anon游客訪問公開文章時會直接 401 或空結(jié)果排查起來很坑。第二個是using和check的區(qū)別。using管的是“哪些行可以被操作”對 select 而言它決定你能看見什么check管的是“新增或修改時哪些值合法”。很多人一開始把check寫成status draft導(dǎo)致發(fā)布功能永遠(yuǎn)報錯就是這個原因。2.2 公開讀與后續(xù)更新互斥嗎寫公開讀策略時我還刻意確認(rèn)了一件事select策略和update策略是互相獨(dú)立的。也就是說游客能讀statuspublished的行并不意味著他們能更新或刪除這些行——更新刪除需要額外的for update/for delete策略。默認(rèn)情況下沒有對應(yīng)策略操作就是不被允許的。清晰分離這幾種for子句會讓整套權(quán)限模型非常干凈。2.3 公開列表的分頁與計數(shù)細(xì)節(jié)公開讀策略就位后我在列表頁驗證了一個容易被忽略的小細(xì)節(jié)帶count的查詢。很多前端都會用supabase.from(posts).select(*, { count: exact, head: true })來做總數(shù)分頁。但請注意count 的計算是受 RLS 約束的未過審記錄會被自動剔除。這意味著列表頁顯示的總頁數(shù)和管理后臺的“全部文章”計數(shù)很可能不一致這不是 Bug是特性。你在給產(chǎn)品提需求時要把“游客看到的計數(shù)”和“管理端看到的計數(shù)”分開定義避免前端同學(xué)拿著兩個數(shù)跑來問你為什么對不上。注意RLS 對 count 查詢也生效。這既是安全兜底也是產(chǎn)品邏輯的一部分提前和測試對齊預(yù)期。3. 投稿寫策略WITH CHECK 是最后一道閘3.1 一次失敗的插入實驗公開讀策略寫完后我隨手用接口測了一次匿名 insert預(yù)期當(dāng)然是失敗。接著換成登錄用戶去 insert前端控制臺立刻報錯new row violates row-level security policy。當(dāng)時第一反應(yīng)是策略寫錯了后來冷靜下來一查才發(fā)現(xiàn)插入策略和讀策略的邏輯不是一回事。用戶能插入內(nèi)容不代表他能插入給自己看。這條策略的完整寫法應(yīng)該是create policy users_insert_own_post on posts for insert to authenticated with check (author_id auth.uid());with check的含義是這一行新數(shù)據(jù)必須滿足什么條件才允許落庫。我要求author_id必須等于當(dāng)前登錄用戶的 ID這就杜絕了用戶偽造author_id投到別人名下的可能。也許有人覺得“誰會這么干”但安全設(shè)計本來就是默認(rèn)不信任何人——被你信任的只是 PostgreSQL 的約束不是前端的傳參。3.2 author_id 到底該誰填這里引出一個前后端協(xié)作的關(guān)鍵問題author_id應(yīng)該由客戶端傳進(jìn)來嗎我的答案是永遠(yuǎn)不要??蛻舳藗鱝uthor_id等于把權(quán)力交給了不信任的一方哪怕 RLS 兜底也不是最佳實踐。正確姿勢是在創(chuàng)建文章時使用 Supabase 的客戶端返回的user.id來構(gòu)建插入對象。如果你寫的是 Postgres 函數(shù)可以用auth.uid()直接取當(dāng)前用戶任何客戶端傳參都被忽略。順帶提一個細(xì)節(jié)Supabase 的 SDK 在服務(wù)端和瀏覽器環(huán)境下取當(dāng)前用戶的方式不一樣瀏覽器用getUser()服務(wù)端用getUser(token)。如果混用auth.uid()可能取到空值RLS 就會默默把所有行都擋住。這個我踩過浪費(fèi)了整整一個下午排查最后發(fā)現(xiàn)只是環(huán)境變量里 token 沒傳對。3.3 service_role 的濫用是最大的隱患有一個雷必須單獨(dú)拿出來講那就是 service_role key。Supabase 文檔里明確寫了service_role 可以繞過 RLS。很多團(tuán)隊圖省事在前端環(huán)境變量里也放了 service_role等于給數(shù)據(jù)庫開了一扇后門。理論上你可以用它做服務(wù)端管理操作但它絕不能出現(xiàn)在前端。我在項目里把 service_role 的調(diào)用全部限制在 Edge Functions 或本地腳本里前端的 anon key 只能走 RLS。你可以把這理解為“員工可以從正門進(jìn)公司但只有少數(shù)人有萬能鑰匙”萬能鑰匙放前臺抽屜里等于沒鎖門。4. 待審核可見策略一條 OR 搞定的特殊視角4.1 兩種讀路徑的疊加到了這一步真正的難點(diǎn)來了。用戶創(chuàng)建投稿后一定希望立刻看到自己提交的內(nèi)容“等等我發(fā)的帖子去哪兒了”如果公開讀策略擋住了未發(fā)布內(nèi)容用戶本人也會被擋在外面。所以我們需要一個“作者視角”的策略讓用戶能看到自己的全部狀態(tài)記錄。我當(dāng)時的處理方式是再寫一條獨(dú)立的 select 策略create policy users_read_own_posts_all_status on posts for select to authenticated using (author_id auth.uid());策略建好后系統(tǒng)里的實際過濾邏輯其實是兩條select策略的并集游客能讀已發(fā)布內(nèi)容作者能讀自己的全部內(nèi)容。一位作者同時作為一名普通用戶他既能看到自己已發(fā)布的作品通過作者的策略也可通過公開讀策略又能看到自己未過審的草稿只能通過作者的策略還能看到其他人的已發(fā)布內(nèi)容通過公開讀策略。這正是“待審核可見”的完整含義。4.2 審核后可見性自動切換審核通過后發(fā)生了什么其實不需要特殊操作。審核員把status從pending改為published的那一瞬間記錄就從“僅作者可見”自然過渡到“所有人可見”。這一套邏輯是完全由 RLS 動態(tài)判斷的不用寫觸發(fā)器、不用清緩存安全策略本身就在實時生效。審核駁回也是同理狀態(tài)變?yōu)閞ejected后游客立刻看不到作者仍能看到方便他查看駁回原因。這個模型對產(chǎn)品體驗來說相當(dāng)友好不用額外維護(hù)一張“可見性快照表”也不會出現(xiàn)“審核通過了但用戶看不到”的典型緩存問題。你只需要確保審核員的更新操作能成功變更狀態(tài)字段。為此我單獨(dú)寫了審核員的策略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 默認(rèn)的 JWT 里沒有role需要去 Dashboard 的 Custom Claims 里配置或者通過觸發(fā)器在注冊時自動設(shè)置。如果你直接把role存在 users 表里也行但每次更新都要讀表性能略差。數(shù)據(jù)量小的時候感受不到差異但既然用了 JWT就盡量把常用權(quán)限放進(jìn) JWT Claims 里。4.3 審核員的可見范圍與前端視角審核員也需要一個“待審列表”視角。但我不建議給審核員一個“看所有行”的全量策略更合理的做法是只放開待審核狀態(tài)create policy moderator_view_pending on posts for select to authenticated using (auth.jwt() - role moderator and status pending);這樣審核員在前臺只能看到待審信息流不會誤入全文數(shù)據(jù)庫。萬一運(yùn)營同學(xué)手滑翻了不該看的數(shù)據(jù)也能被權(quán)限擋住。你的審核后臺界面也只需要查詢這一個視圖條件前端邏輯立刻變簡單。提示如果你希望審核員能同時看到“已發(fā)布”和“被駁回”的內(nèi)容做歷史留痕可以把status in (pending, rejected, published)寫進(jìn)using但一定別把draft放進(jìn)去。草稿是用戶私人空間運(yùn)營不該窺探。5. 進(jìn)階策略不夠用時函數(shù)來湊5.1 用 SECURITY DEFINER 處理多表關(guān)聯(lián)有些場景的權(quán)限判斷不是單張表能搞定的。比如業(yè)務(wù)上允許某個作者的好友在“待審核可見”階段預(yù)覽文章。這意味著判斷條件要關(guān)聯(lián)作者的好友關(guān)系表而好友關(guān)系表同樣受 RLS 控制。如果策略里直接查好友表會遇到常見的“遞歸策略”問題查 posts 觸發(fā)了對好友表的 RLS 策略然后好友表的策略又引用了其他表形成套娃。我在這種場景下用得比較順手的方式是寫一個 SECURITY DEFINER 函數(shù)把復(fù)雜判斷邏輯封裝在數(shù)據(jù)庫后端執(zhí)行并明確指定使用函數(shù)所有者的權(quán)限來運(yùn)行繞過當(dāng)前的 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時有一點(diǎn)特別容易被忽略函數(shù)體內(nèi)不能使用auth.uid()替代當(dāng)前用戶判斷因為它運(yùn)行時是基于函數(shù)所有者的權(quán)限。正確做法是把要檢查的用戶 ID 作為參數(shù)傳入。比如can_preview_post(target_post_id, auth.uid())。如果你直接在里面寫死了函數(shù)所有者的 ID那任何人都能通過這個函數(shù)讀取所有待審內(nèi)容。這種“函數(shù)化策略”強(qiáng)烈建議只在邏輯確實復(fù)雜到 SQL 寫不清楚時才用。能用簡單策略解決的問題不要去動函數(shù)。函數(shù)一旦多起來審閱和維護(hù)的負(fù)擔(dān)會劇增。5.2 Realtime 訂閱也一樣受 RLS 管制如果你的產(chǎn)品需要列表實時刷新Supabase Realtime 也走 RLS 過濾。也就是說游客只能收到published記錄的變更事件作者能收到自己所有記錄的變更。我遇到過一種情況Realtime 推送的 payload 里的舊值old record對某些用戶可見新值不可見導(dǎo)致前端短暫顯示了不該看的內(nèi)容。這個問題的根源不在 RLS而在 Realtime 的消息格式??蛻舳耸盏降?payload 里同時包含new和old兩套數(shù)據(jù)new是變更后的值old是變更前的值界面往往會先渲染old。如果審核員把某條draft改成published這條信息對游客可讀但old這個草稿內(nèi)容也在同一份 payload 里前端如果直接信任 payload 去渲染就可能泄露“變更前”的草稿內(nèi)容。穩(wěn)妥的方案是前端只渲染new內(nèi)容對old一律不信任——或者后端訂閱時就用 RLS 可讀性判斷后再轉(zhuǎn)發(fā)。5.3 與全文搜索、生成式回復(fù)等擴(kuò)展機(jī)制的整合這個投稿系統(tǒng)后續(xù)大概率還要接全文搜索或 AI 摘要回復(fù)。搜索和 AI 用的數(shù)據(jù)源必須顯式加上“僅 published 狀態(tài)”的過濾條件。因為有些團(tuán)隊習(xí)慣在 Edge Function 里直接連 Postgres而 Edge Function 默認(rèn)擁有高權(quán)限容易越過 RLS 查詢到草稿數(shù)據(jù)。前端對搜索結(jié)果的展示默認(rèn)認(rèn)為“能返回就是能看”高權(quán)限函數(shù)一旦把非公開數(shù)據(jù)混進(jìn)去就等于在搜索框里開了個后門。我通常在函數(shù)入口就強(qiáng)制校驗status published并在文檔里寫明所有面向用戶的數(shù)據(jù)出口都要經(jīng)過 RLS 或顯式狀態(tài)過濾不許出現(xiàn)“先查全表再前端過濾”的寫法。6. 常見問題排障實錄這些坑我一個不落踩過6.1 為什么我的策略寫了卻一直 404 或報 Permission Denied這種情況 90% 是沒啟用 RLS。很多人建表后直接寫策略但忘了執(zhí)行alter table posts enable row level security;。Supabase 默認(rèn)是關(guān)閉所有訪問的你不開就會得到看似“數(shù)據(jù)庫拒絕”的結(jié)果。建議建表后立刻啟用策略可以慢慢調(diào)開關(guān)別留著。另一種情況是enable row level security寫了但策略的to子句只寫了authenticated游客訪問自然被拒。如果產(chǎn)品有公開瀏覽頁面to anon, authenticated要一起帶上。排查時直接用 Supabase 的 SQL Editor 手動模擬set role anon; select * from posts;這條語句能快速判斷 anon 角色到底能看到什么。如果返回為空要么是 RLS 擋了要么是策略真的沒建對。6.2 插入時“new row violates row-level security policy”怎么處理先確認(rèn)author_id有沒有正確落庫。我遇到過一種情況前端插入時用了一個舊的自增字段名user_id而表里是author_id導(dǎo)致策略里的auth.uid()從來沒匹配上。表結(jié)構(gòu)設(shè)計和字段命名一定要統(tǒng)一不然排查成本極高。其次確認(rèn)auth.uid()是否返回空。在 SQL Editor 里執(zhí)行select auth.uid();返回空說明當(dāng)前連接上下文沒帶 JWT。前端 SDK 通常會帶但如果你在本地用 psql 直連測試就要手動set request.jwt.claims。這也是一個典型的“本地好使線上不行”的坑。6.3 更新時報錯但我明明給了 update 權(quán)限更新時的報錯通常和with check有關(guān)。比如審核員更新status為published但策略里with check (status pending)更新后新行不再滿足條件事務(wù)就失敗了。記住for update策略同時有using和with checkusing判斷舊行是否可操作with check判斷新行是否合法。改狀態(tài)時新舊值不同兩段條件都得應(yīng)對。6.4 RLS 生效順序與性能排查RLS 不是加在每個查詢后面的額外過濾條件它是在計劃階段就融合進(jìn) SQL 執(zhí)行計劃的。所以你看explain時能看到 RLS 相關(guān)的 Filter。性能優(yōu)化方式就是給過濾字段建索引比如status和author_id這倆高頻字段。如果遇到Or條件特別復(fù)雜導(dǎo)致查詢變慢可以考慮拆成兩條策略或一條聯(lián)合索引。數(shù)據(jù)量小的時候不用太焦慮數(shù)據(jù)量到了百萬級再回頭調(diào)索引也是來得及的。6.5 策略復(fù)制粘貼的幾個重災(zāi)區(qū)團(tuán)隊里多個人協(xié)作時策略的命名很容易混亂。建議命名時把“操作行為 目標(biāo)角色 數(shù)據(jù)范圍”三要素寫清楚例如user_insert_own_posts、moderator_update_status。同時在 SQL 文件里給每個策略配一兩行注釋解釋這個策略對應(yīng)產(chǎn)品的哪個需求點(diǎn)。不然半年后你自己回來看看到七八條策略不記得哪條是干嘛的只留下一句“誰寫的”的疑問。這一點(diǎn)不算技術(shù)但能極大降低維護(hù)成本。7. 從策略到上線我實際執(zhí)行過的五步走從零到一實現(xiàn)這套“公開讀、投稿寫、待審核可見”的流程我習(xí)慣走五步每一步都有明確的產(chǎn)出和驗證手段。第一步梳理權(quán)限矩陣把上文的五種訪問訴求在文檔里確認(rèn)清楚和產(chǎn)品經(jīng)理對齊。哪怕不畫那種復(fù)雜的 UML 圖至少把表格列出來。這個環(huán)節(jié)最怕遺漏邊緣角色。第二步設(shè)計表結(jié)構(gòu)并啟用 RLS。建索引、建檢查約束一次做完。第三步逐個寫策略寫一條驗證一條。驗證方式用 SQL Editor 切換角色執(zhí)行查詢別用前端一把梭。第四步前端接入時確認(rèn) API 調(diào)用方式。尤其是把getUser()的時機(jī)租在正確位置避免 token 沒加載完就調(diào)用查詢。第五步用模擬抓包方式檢查安全用 anon key 直接請求數(shù)據(jù)接口試著訪問不同狀態(tài)的記錄確認(rèn)返回結(jié)果完全符合預(yù)期。這一步通過后才算真的“上線”。這五步做下來基本上不會再被 RLS 的隱藏邏輯坑到。我在實際項目里還養(yǎng)成了一個習(xí)慣每當(dāng)新增一種用戶角色或數(shù)據(jù)狀態(tài)第一件事先修改權(quán)限矩陣表再動 SQL。順序反過來策略代碼會越來越亂最終連自己都不敢貿(mào)然改動。最后再分享一個小心得RLS 策略本身非常簡潔執(zhí)行效率也高但它的心智負(fù)擔(dān)在于“邏輯是隱式的”。你很難一眼看出系統(tǒng)里所有策略疊加后的效果所以文檔和命名尤其重要。每一條策略都對應(yīng)一個業(yè)務(wù)規(guī)則尤其建議把“為什么這條策略存在”寫在注釋里這樣三個月后的你還能一眼看懂。如果你打算在現(xiàn)成項目上補(bǔ) RLS建議先把所有現(xiàn)有策略導(dǎo)出來審一遍再動手加新的。很多時候你以為缺一條策略其實只是舊的策略寫得太寬把應(yīng)該擋住的請求也放行了。真正動手前在測試環(huán)境把你的 anon key 和 service role key 分別指向兩套 Supabase 實例完全模擬線上環(huán)境后再跑一遍權(quán)限矩陣的測試用例這樣你推送出去的代碼才有底氣。