Supabase 上線前必須先定案的三個決定

Schema 變更從哪裡進來、寫入走 Client 還是 Server、函式與 View 的預設安全性。 這三件事的預設值都偏向方便,而且做錯時全部都是靜默失敗——沒有錯誤訊息, 只有影響 0 列、空陣列,或看得到不該看的資料。

系列・Nuxt 4 全棧實戰筆記・第 6/12 篇

Supabase 讓你在幾分鐘內就有一個能用的後端。代價是很多安全性相關的預設值都偏向方便,而且偏向方便的那一邊不會報錯。

這篇是三個必須在上線前定案的決定。它們表面上互不相干,但做錯的後果是同一種形狀:沒有錯誤訊息,只有影響 0 列、空陣列,或看得到不該看的資料。

一、Schema 只能從 migration 進來

第一個決定:資料庫的結構變更,允不允許從 Studio 的圖形介面進去。

答案是不允許——但理由不是「手動操作不專業」,而是圖形介面改出來的東西在你的版本庫裡不存在。

它造成的不是立即的錯誤,而是一組會延後發作的症狀:

  • 本地跑一次重建(supabase db reset)之後,那個欄位就消失了
  • 其他人的環境跟你不一樣,而且雙方都覺得自己是對的
  • 產生出來的 TypeScript 型別跟正式環境的實際結構對不上

所以流程只有一條:本地建 migration → 本地重建驗證 → lint → 重新產生型別 → 進版本庫 → CI 推上去。

supabase migration new add_users_table
# 編輯 supabase/migrations/<timestamp>_add_users_table.sql
supabase db reset                       # 從零重建,確認 migration 真的能跑
supabase db lint --level warning        # 必須零警告
supabase gen types typescript --local | tee app/types/database.types.ts > /dev/null

圖形介面不是完全不能用——查資料、看 policy 都很方便。真的用它做了結構變更,就把變更倒出來變成 migration,別讓它留在只有你知道的地方:

supabase db diff --use-migra -f from_gui

兩個實際踩到的坑

Sequence 不會跟著匯入的資料走。 用 INSERT ... (id, ...) 明確指定 id 匯入一批資料之後,下一筆新增會撞 duplicate key——因為 sequence 還停在匯入前的位置。它不會自己校正:

select setval(
  'public.users_id_seq',
  (select coalesce(max(id), 0) + 1 from public.users),
  false
);

遠端的 migration 記錄可能跟本地對不上。 這種狀態下 supabase db push 會直接停住。先看清楚兩邊的差異,再決定要標記哪一筆:

supabase migration list --linked
supabase migration repair --status reverted <timestamp>

repair 是改記帳、不是改資料——它只是告訴遠端「這筆當作沒發生」。動它之前要確認那筆 migration 的實際效果在遠端是什麼狀態,否則你會得到一個記帳正確、結構錯誤的資料庫。

二、讀走 Client,寫走 Server

第二個決定:瀏覽器端的 Supabase client 可以做到什麼程度。

我們的答案是只讀。查詢直接從瀏覽器打,靠 RLS 保護;所有新增、修改、刪除一律走自己的 Server API,在那裡做身分驗證、業務驗證與紀錄。

理由不是「Client 端寫入不安全」——RLS 寫對的話它是安全的。理由是寫入路徑上要做的事,遠不只權限檢查:業務規則驗證、稽核紀錄、跨表的一致性。這些散在瀏覽器端就沒有單一的把關點,而權限檢查是唯一能靠 RLS 補回來的那一項。

// Client:只查詢
const client = useSupabaseClient<Database>()
const { data } = await client.from('users').select('id, name')

// 寫入:走自己的 API,在 handler 內驗證身分與業務規則
await $fetch('/api/v1/users', { method: 'POST', body })

Server API 寫入時用哪個身分,會決定 RLS 還剩不剩作用:帶著使用者身分去寫,RLS 仍是最後一道防線;改用 service_role,RLS 就整個被繞過(它具有 BYPASSRLS),權限只剩 handler 裡自己寫的檢查。下面「Toast 說成功」這個坑,只會發生在前一種。

你可能會想:那不是每個寫入都要多刻一支 API,很麻煩嗎?是麻煩,但麻煩的部分不是你以為的那個。多寫的那幾行 handler 是固定成本,一次付完;真正貴的是另一條路——把權限規則寫成 RLS policy 之後,每次業務規則變動你都要改資料庫、重跑 migration、而且沒辦法在改之前先跑測試看它會不會擋掉現有使用者。權限邏輯放在應用層,至少它是可以被測試、可以被逐行讀懂的程式碼。

最常踩的一個坑:Toast 說成功,資料沒變

症狀是三步:API 回 200、前端跳出「儲存成功」、重新整理之後資料還是舊的。

根因在 policy 的覆蓋範圍不對稱:多數人先寫了 SELECT policy,UPDATE / DELETE 的 policy 漏了,或條件比 SELECT 窄。於是讀得到,寫入卻被 RLS 靜默過濾成影響 0 列。Postgres 對這個情況的行為是:USING 條件不符的列不會被更新,也不會報錯(只有 WITH CHECK 不符才會整句失敗)。資料庫沒有把它當錯誤,handler 沒有檢查受影響列數,就會照常回成功,前端當然顯示「儲存成功」。

-- 讀得到不代表改得到:UPDATE / DELETE 要各自有 policy
create policy "manager can update" on public.resources for update
using (public.current_user_role() in ('admin', 'manager'))
with check (public.current_user_role() in ('admin', 'manager'));

兩個相關的前提:UPDATE 要能讀到那一列,所以同一張表還必須有能涵蓋該列的 SELECT policy,沒有的話 UPDATE 同樣不會如預期運作。另外,不要只靠「沒有報錯」判斷寫入成功,handler 裡檢查受影響列數(例如 .select() 取回更新後的列,確認不是空陣列),0 列就當失敗處理。

順帶兩個會咬人的細節:

  • auth.uid() 這類函式要寫成 (select auth.uid())。沒包的話它會逐列重算;包起來 Postgres 會把它當成 initPlan,每個 statement 只算一次(Supabase 官方文件也建議這樣寫,前提是函式結果不隨列資料改變)。這在幾百列的表上看不出來,在幾十萬列上是量級差異。
  • policy 裡不要直接查表(select role from user_roles where ...),封裝成 stable 的 helper 函式。理由同上,而且 policy 邏輯散在各表會漂。

三、函式與 View 的預設值都不安全

第三個決定:資料庫函式與 View 的安全性設定要不要強制。

要,而且是無例外的強制——因為這兩者的預設值都偏向不安全。

函式的 search_path 預設是可變的。 沒有釘死的話,函式裡寫的 users 到底解析到哪張表,取決於呼叫當下的 search_path。有能力建立 schema 的人可以放一張同名的表進去,讓你的函式讀到它。修法是釘死成空字串,並在 SQL 內一律用完整名稱:

create function public.get_user()
returns text
language sql
security definer
set search_path = ''
as $$
  select name from public.users where id = auth.uid();  -- 一定要有 public.
$$;

這兩件事是綁在一起的:只設 search_path = '' 而 SQL 裡還寫 from users,函式會直接壞掉(relation "users" does not exist)。這個錯誤反而是好事——它會在第一次呼叫就爆,不像其他兩個坑要等到出事才知道。

View 預設用建立者的權限執行(security_invoker 選項需要 Postgres 15 以上),也就是說它會繞過呼叫者的 RLS。一張基於受保護資料表的 View,如果沒有明確宣告,普通使用者透過它就能看到全部資料:

create view public.user_summary
with (security_invoker = true)   -- 沒有這行 = 繞過呼叫者的 RLS
as select id, name, email from public.users;

SECURITY DEFINER 與 SECURITY INVOKER 的選擇,判準是「這個函式要不要遵守呼叫者的 RLS」。一般業務邏輯用 INVOKER(遵守,較安全的預設);需要跨使用者存取的管理功能才用 DEFINER,而且用了就必須在函式內自己做權限檢查——因為你剛剛把 RLS 這層保護拿掉了。

這幾項不必靠自律,Supabase 的 Security Advisor(Dashboard 的 Advisors)有對應的檢查:function_search_path_mutable(函式的 search_path 沒釘死)、security_definer_view(View 以建立者權限執行)、rls_disabled_in_public(public schema 的表沒開 RLS)。上線前把這些警告清到零。

要小心別把另一個指令當成這道檢查。CLI 的 supabase db lint 跑的是 plpgsql_check,檢查的是 PL/pgSQL 函式本身的錯誤(型別、未使用變數、缺 RETURN、EXECUTE 注入等),不會報上面這三項。它仍值得放進提交前檢查,但兩者看的是不同的東西:

supabase db lint --level warning   # 函式內容的錯誤,不是 Advisor 的安全檢查

這三個決定的共同形狀

決定預設行為做錯時的症狀
Schema 從哪裡進來圖形介面可以直接改重建後欄位消失、環境彼此不一致
誰能寫入Client 端可以寫影響 0 列,但 API 回 200
函式 / View 安全性search_path 可變、View 繞過 RLS看得到不該看的資料

三者的預設值都選了方便,而且做錯的當下都不會有錯誤訊息。這正是它們必須在上線前定案的原因:一旦有了正式資料,這三種靜默失敗都會先被當成別的問題查——查前端、查快取、查網路——而真正的原因在資料庫的設定裡,安靜地正常運作著。