Coffee Shooters 咖啡槍手

RLS 明明放行了,為什麼還 permission denied?一整排 403 的家族性根因

管理員在後台幫客戶儲值,畫面回一個 403:permission denied for table users。我的 RLS policy 明明寫了 admin 放行。門是開的,人卻進不去——因為擋我的不是我以為的那道門。

症狀:admin 被自己的系統擋在門外

我的咖啡電商有個儲值功能,客戶可以先儲值、之後扣款。除了客戶自己儲值,管理員也能在 POS 幫客戶代客儲值、代客扣款。

某天我測代客儲值,畫面直接回錯:

permission denied for table users

第一反應是「不可能」。我的 RLS(Row Level Security,資料列層級的權限控制)policy 明明有一條 is_admin() 的放行規則,管理員的身分驗證也過了。門開著,為什麼進不去?

我在 policy 上鑽了一陣子,改來改去都沒用。因為我找錯門了。

根因:RLS 管「哪些列」,GRANT 管「哪些欄」

關鍵的認知是這句話:RLS 放行,不等於你能寫。

Postgres 的權限其實是兩層獨立的門:

  1. column/table 層的 GRANT:你這個角色,有沒有權限碰這張表、這些欄位。
  2. RLS policy:在你有權限的前提下,你能碰「哪些列」。

我一直盯著第 2 層看,但擋我的是第 1 層。

我的儲值 RPC 是 SECURITY INVOKER——意思是它以「呼叫者的身分」執行。管理員在前端是 authenticated 這個角色。而 users 表的那幾個特權欄(balancewalletmember_leveltier_*annual_spent在設計上就沒有 GRANT 給 authenticated——我當初刻意只讓 service_role 和特定的 DEFINER RPC 能寫它們,避免任何一般登入使用者有機會直接改自己的餘額或等級。

於是流程變成這樣:管理員 → authenticated 身分 → 呼叫 INVOKER RPC → RPC 也以 authenticated 身分去寫 users.balance → authenticated 沒有這欄的 GRANT → permission denied

RLS 的 is_admin() 在「列」的層級把管理員放行了,但根本還沒走到那一步——在「欄」的層級就先被 GRANT 擋死了。錯誤訊息只說 permission denied for table users,不會告訴你卡在哪一層,這是它最坑的地方。

修法:用 DEFINER 包一層守門,做乾淨的權限提升

我需要的是:管理員能寫這些特權欄,但一般使用者不行。

直接把特權欄 GRANT 給 authenticated 是錯的——那等於開放所有登入使用者都能寫餘額。我要的是「只有通過 admin 檢查的呼叫,才臨時獲得寫特權欄的能力」。

Postgres 提供的工具是 SECURITY DEFINER:這種函式以「函式擁有者的身分」執行,而不是呼叫者。我的 DEFINER 函式擁有者是 postgres(superuser),它當然能寫任何欄。

所以修法是包一層:

create function fn_admin_topup_balance(...)
returns ...
language plpgsql
security definer          -- 以 owner(postgres) 身分跑,能寫特權欄
as $$
begin
  if not is_admin() then                        -- 守門:先擋
    raise exception 'not authorized';
  end if;
  perform fn_topup_balance(...);                -- 內呼原本的 INVOKER 函式
end;
$$;

revoke all on function fn_admin_topup_balance from anon;
grant execute on function fn_admin_topup_balance to authenticated;

重點在守門的順序:is_admin() 檢查一定要寫在最前面。因為 DEFINER 函式是以 superuser 身分跑的,一旦進到函式體就有無限權力,守門必須在動任何資料之前。這一層守好,authenticated 就能執行它(GRANT execute),但函式內部只放行 admin。前端把管理員的 call site 改指向這個新函式,客戶自助儲值和 service_role 的路徑完全不動。

我修的兩個是 fn_admin_topup_balance(代客儲值)和 fn_admin_deduct_balance(代客扣款)。

轉折:修到第二個,我意識到這是一個家族

修完儲值,同一天我又撞到扣款的一模一樣 403。修法完全一樣。

這時候我停下來想了一件事:如果同一天就冒出兩個一模一樣的 bug,那這不是兩個 bug,是一個家族。系統裡一定還有其他 INVOKER 函式,以 authenticated 身分寫 users 特權欄,只是還沒有人去點到那條路徑。

靠記憶或靠 grep 找不完整。我寫了一個掃描腳本 audit-privileged-writer-invoker.mjs,直接查資料庫的 pg_proc(函式目錄),條件是:

掃出 4 支。兩支是我剛修的(進 allowlist,附上「已由 fn_admin_* 守門」的理由);另外兩支是 cron 維護函式 fn_expire_bonusesfn_reconcile_balances

收尾:不是每個都用同一種修法

那兩支 cron 函式,我沒有套 DEFINER + is_admin() 的修法——因為它們根本不該被前端呼叫。它們是排程任務,跑排程的是 superuser,不需要 authenticated 有任何權限。前端那個 expireBonuses() 的呼叫點我一查,已經是死碼、沒人在用。

所以它們的修法是反過來:REVOKE authenticated,只留 service_role。收窄,不是加門。我上線後實查了一次資料庫的權限清單(ACL),確認這兩支只剩 postgres 和 service_role 能碰。

這是我想強調的一點:一個 bug 家族不代表所有成員都用同一種修法。admin 需要用的,包一層守門提升權限;根本不該被外部碰的,直接收掉權限。掃描器負責找出家族全員,修法還是要一個一個判斷用途。

給你帶走的三件事

  1. Supabase/Postgres 遇到 RLS 已放行卻 permission denied,先查 column-level GRANT,別在 policy 上鑽。 這是兩道獨立的門,錯誤訊息不會告訴你卡在哪一層。
  1. 需要「只有 admin 能寫、一般人不行」時,用 SECURITY DEFINER + is_admin() 守門包一層。 守門檢查一定寫在函式最前面——DEFINER 進了函式體就是 superuser,不能讓沒授權的呼叫走到動資料那一步。
  1. 修到第二個同型 bug,就把它變成掃描器。pg_proc 這種系統目錄,能把「還有沒有漏網的」從一個焦慮變成一個可以回答的問題。修完把已知的加進 allowlist,剩下的就是真正要處理的。

我到現在還留著那個掃描腳本,掛在體檢流程裡。下次有人(包括我自己)又寫了一個以 authenticated 身分偷寫餘額的 INVOKER 函式,它會在上線前先舉手。