Supabase RLS 多租戶的坑:42501 錯誤追兇——權限資料,只信驗證過的 JWT
多分店系統上了 Row Level Security 之後,所有受保護的寫入突然全掛 42501。這篇記錄一次真實除錯:問題不在 policy,在應用層讀權限資料的來源——以及一個 zod 驗證的附帶坑。
Kai Wu
• 凱吳科技 負責人
先講場景。我們在幫一個連鎖牙醫集團開發自費療程的批價收款系統:成交單、明細、批價、收款、退費、收據,全部數位化。這種系統有一條不能妥協的需求——多分店資料隔離。每家店只看得到自己的單,這不是「前端記得過濾」等級的事,而是要在資料庫層級用 Row Level Security(RLS)鎖死。
RLS 的概念一句話講完:把「誰能讀寫哪些列」的規則寫進 PostgreSQL 本身,就算應用層寫出了漏洞,資料庫也會把不該過的請求擋下來。這是我們所有多租戶專案的標配。
然後,某一天,所有受保護的寫入全部掛掉,錯誤碼清一色:
ERROR: 42501 permission denied
症狀:不是某一條 policy 錯,是「全部」都被擋
42501 是 PostgreSQL 的權限拒絕錯誤。如果只有某張表、某個操作被擋,那多半是單一 policy 寫錯,好查。但這次的症狀是橫向全面性的——所有需要通過 RLS 檢查的寫入都失敗,讀取卻大致正常。
這種「全面掛」的模式本身就是線索:問題不太可能出在幾十條 policy 各自寫錯,比較可能出在所有 policy 共同依賴的東西——身分資訊本身。
我們的 policy 依賴兩個關鍵值:使用者所屬的 clinic_id(哪家分店)和 role(什麼角色)。所有 policy 都用這兩個值判斷「這一列你能不能碰」。如果這兩個值在資料庫眼中根本不存在,那每一條 policy 的判斷結果都是「不行」——完美解釋症狀。
追兇:app_metadata 不等於驗證過的 JWT claims
問題找到了:應用層取得 clinic_id 和 role 的方式,是直接從 session 物件的 app_metadata 讀出來,而不是從驗證過的 JWT claims 讀。
這兩件事看起來很像,實際上是兩個世界:
app_metadata是客戶端手上 session 物件的一部分——它是「應用程式看到的資料」。- RLS policy 執行時,資料庫看的是隨請求送到伺服器、經過簽章驗證的 JWT 裡面的 claims。
當這兩邊不同步——例如 claims 沒有被正確帶進請求的驗證流程——應用層以為自己知道使用者是誰、屬於哪家店,但資料庫看到的 JWT 裡根本讀不到這些值。於是 policy 一律回答「拒絕」,42501 就這樣下起雨來。
修法:權限資料只走 getClaims
修正方向很明確:所有要拿來做權限判斷的資料,一律改從 getClaims 取得——也就是經過驗證的 JWT claims,而不是 session 物件上看起來很方便的欄位。
// 修正後:權限判斷的資料來源,只信驗證過的 JWT claims
const { data } = await supabase.auth.getClaims();
const clinicId = data?.claims?.clinic_id;
const role = data?.claims?.role;
權限這件事,永遠只信驗證過的來源。「拿得到的資料」和「可以拿來做安全判斷的資料」,是兩回事。
這條教訓值得寫在牆上。多租戶系統裡,身分與權限資料的讀取路徑必須單一且可驗證——任何「反正這裡也讀得到」的捷徑,都是未來某個深夜的 42501。
附帶坑:zod 的 z.uuid() 比你想的更挑
同一輪除錯還撞到一個驗證層的小坑。我們用 zod 驗證外鍵欄位,直覺寫法是 z.uuid()——它看起來就是「驗 UUID」的意思。
但 z.uuid() 的檢查比字面上嚴格:它會驗證 RFC 規範的版本與 variant 位元,格式長得像 UUID 但位元不符規範的識別碼會被拒絕。而實務上系統之間流通的 ID,有些只是「8-4-4-4-12 的十六進位字串」——這種情況要用寬鬆版的 z.guid()。
// 嚴格:檢查 RFC 版本位元,部分合法流通的 ID 會被擋
clinicId: z.uuid()
// 寬鬆:只驗 8-4-4-4-12 格式,跨系統外鍵通常要用這個
clinicId: z.guid()
症狀是「看起來完全正常的資料,過不了驗證」。如果你的驗證層和資料層對「什麼是合法 ID」的定義不一致,錯誤訊息會把你導去完全錯誤的方向。
多租戶 RLS 的三條心法
這次除錯之後,我們把心得濃縮成三條:
- 隔離做在資料庫層,不是應用層。 RLS 的價值就在於應用層出錯時還有最後一道牆。這道牆值得花時間好好蓋。
- 權限資料的讀取路徑要單一。 全系統只有一種方式取得「你是誰、你屬於哪個租戶」,而且那條路徑必須經過驗證。
- 測試先行。 這個專案全程 TDD——每個功能先寫驗證再寫程式。RLS 這種「錯了就是資安事故」的東西,尤其需要測試把行為釘死:A 店的帳號,永遠寫不進 B 店的單。
多租戶資料隔離做得好不好,平常看不出來,出事的時候就是新聞。如果你的系統也有「每個客戶只能看自己資料」的需求,想知道資料庫層級的隔離該怎麼設計,歡迎聊聊——也可以先看看我們處理過的其他案例。
文章標籤
相關文章
探索更多 AI 自動化與流程改造的實戰內容