主題: PostgreSQL
PostgreSQL RLS 不是自動 WHERE:多租戶資料隔離要先釐清的四個邊界
用 PostgreSQL Row-Level Security 保護共享資料表時,先拆開 GRANT、policy、既有列與新列、以及 view/function 的權限身分。
共享資料表很省事。所有工作區都用一張 documents 表,查詢、migration 和觀測都集中。但只要某個 API 漏了 WHERE workspace_id = ...,隔離就可能失效。
PostgreSQL 的 Row-Level Security,RLS,可以把「這個角色可以讀或改哪些列」放到資料庫層判斷。它很適合作為多租戶的第二道防線。不過它不會自動驗證 HTTP 身分,也不會代替 GRANT。把它當成會自己補上 WHERE 的功能,通常會在邊界處踩雷。
動態迷因(展開/收合)
本文用一張共享的 documents 表討論實作。它只有 id、workspace_id、title 和 body。每位成員只能操作目前工作區的文件。
第一個邊界:GRANT 管資料表,policy 管資料列
RLS 啟用後,資料庫要同時回答兩個問題:
- 這個資料庫角色有沒有
SELECT、INSERT、UPDATE或DELETE權限? - 如果有,它可以操作哪些列?
前者是 GRANT,後者是 policy。兩者都要通過。只有 policy 而沒有 GRANT SELECT,讀取仍會被拒絕;只有 GRANT 而沒有適用 policy,啟用 RLS 的表對一般角色採預設拒絕。這是 PostgreSQL RLS 文件定義的行為。
一個 migration 的起點可以長這樣:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO app_member;
GRANT 不該給得比應用程式需求更寬。RLS 也不該成為鬆散資料表權限的補丁。把這兩層拆開看,審查 migration 時會清楚很多。
第二個邊界:可信的工作區身分從哪裡來
policy 需要知道「目前是哪個工作區」,但 request body 的 workspace_id、query parameter 或瀏覽器儲存的值都不是答案。使用者可以改它們。
比較安全的流程是:後端先驗證登入身分與工作區成員資格,再在同一個 transaction 內帶入工作區 context。下面用自訂設定值示意;實際程式仍要以參數化查詢傳入後端已驗證的值。
BEGIN;
SELECT set_config('app.workspace_id', $1, true);
-- $1 只能由已驗證工作區成員資格的後端程式提供。
SELECT id, title
FROM documents;
COMMIT;
set_config 的第三個參數為 true,等同 transaction-local 的設定。transaction 結束就失效。這在 transaction pooling 特別重要,因為下一個 request 可能借到同一條 PostgreSQL 連線。不能在服務啟動時設定一次,然後假設 session state 永遠屬於同一位使用者。
更重要的是信任邊界本身:瀏覽器不能取得可對這張表任意送 SQL 的資料庫連線,後端也必須在設定 context 前完成授權。current_setting() 或 set_config() 不是身分驗證機制,只是把已驗證結果傳進 policy 的管道。
第三個邊界:USING 看舊列,WITH CHECK 看新列
RLS 最常出錯的,就是這兩種資料列的差別。
USING篩選已存在的資料列。它決定SELECT看得到哪些列,也決定UPDATE與DELETE可以挑中哪些列。WITH CHECK驗證INSERT或UPDATE後準備寫入的新列。結果為false或NULL時,指令會失敗。
先把目前工作區抽成單一函式,policy 比較不會重複一大段設定值處理:
CREATE FUNCTION app.current_workspace_id()
RETURNS uuid
LANGUAGE sql
STABLE
AS $$
SELECT NULLIF(current_setting('app.workspace_id', true), '')::uuid;
$$;
接著依操作拆開 policy:
CREATE POLICY documents_read ON documents
FOR SELECT TO app_member
USING (workspace_id = app.current_workspace_id());
CREATE POLICY documents_add ON documents
FOR INSERT TO app_member
WITH CHECK (workspace_id = app.current_workspace_id());
CREATE POLICY documents_edit ON documents
FOR UPDATE TO app_member
USING (workspace_id = app.current_workspace_id())
WITH CHECK (workspace_id = app.current_workspace_id());
CREATE POLICY documents_remove ON documents
FOR DELETE TO app_member
USING (workspace_id = app.current_workspace_id());
documents_edit 同時需要兩個條件。假設使用者原本能操作 workspace A 的文件。USING 允許它成為更新目標;WITH CHECK 阻止它把 workspace_id 改成 workspace B。只寫其中一個,很容易在「原本能不能碰」或「改完是否仍合格」之間留下洞。
完整語意以 CREATE POLICY 文件為準,其中也說明了 INSERT ... RETURNING 與 ON CONFLICT DO UPDATE 為什麼要和一般 CRUD 一起測試。
動態迷因(展開/收合)
policy 不是越多越安全
PostgreSQL 預設的 permissive policy 用 OR 組合。已有「目前工作區」規則時,再加一條 is_public = true 的 permissive SELECT policy,符合任一條的列都會變得可見。這不一定錯,但它是擴大資料集合的改動,不是補強限制。
restrictive policy 則用 AND 組合,但仍要至少有一條 permissive policy 放行。審查時先列出每個 command 的所有 policy,再問「任何一條 permissive 規則會不會意外放行?」比只讀最新一條 migration 更可靠。
owner、view 和 function 會換掉實際權限身分
RLS 不會限制 superuser 或帶有 BYPASSRLS 的角色。table owner 通常也會繞過自己表的 RLS,除非使用 ALTER TABLE documents FORCE ROW LEVEL SECURITY。因此,不要用 owner 帳號當作應用程式隔離測試的唯一證據。
view 與 SECURITY DEFINER function 同樣要特別審查。一般 view 對底層表的權限與 policy,通常依 view owner 評估;若希望依呼叫者身分判斷,可明確使用 security_invoker。例如:
CREATE VIEW workspace_documents
WITH (security_invoker = true)
AS
SELECT id, workspace_id, title
FROM documents;
SECURITY DEFINER function 則以 function owner 的權限執行。只有真的需要提升權限時才使用它,並讓受信任、最小權限的角色擁有 function,固定安全的 search_path,並收斂 EXECUTE 權限。view 文件與 function security 文件分別說明這些邊界。
還有兩個容易被忽略的例外:TRUNCATE 不受 row policy 約束,仍要靠資料表權限限制;constraint 和 referential integrity 檢查也可能繞過 row security 以維持資料完整性。若錯誤訊息可能透露資料是否存在,也要把它視為可觀察的資訊。
測試的是 allow 與 deny,不是 policy 有沒有建立成功
一份能捕捉錯誤的測試至少應有兩個 workspace,以及非 owner 的應用程式角色。對每個操作都要驗證兩件事:
- 同工作區的
SELECT、INSERT、UPDATE、DELETE是否可用。 - 跨工作區讀取、修改、刪除,以及嘗試把資料搬到別的工作區時,是否真的被拒絕。
再加上 RETURNING、upsert、view、function 與 transaction pooling 的情境。這會比「migration 成功,資料表上也看得到 policy」更接近真實的存取路徑。
RLS 很適合替共享 schema 多加一道資料庫層保護,但它不會替你選好多租戶架構。資料隔離尺度、備份還原、工作負載互相干擾和維運模式仍要一起決定。PostgreSQL 社群的多租戶討論可作為架構取捨的採用脈絡,不應取代官方語意與實作測試。
我學到什麼
- 我會把
GRANT與 RLS policy 分開審查。前者決定能不能操作資料表,後者決定能操作哪些列。 - 我會先區分既有列與更新後的新列,再決定
USING和WITH CHECK。這能避免資料在更新時跨越工作區邊界。 - 我會把新增 permissive policy 視為可能擴權的變更,並用非 owner 角色做 allow/deny 測試。
- 我不會假定 view 或
SECURITY DEFINERfunction 一定沿用呼叫者的權限身分;它們需要單獨檢查。
外部參考資料
- PostgreSQL:Row Security Policies
- PostgreSQL:CREATE POLICY
- PostgreSQL:CREATE VIEW
- PostgreSQL:Writing SECURITY DEFINER Functions Safely
- Supabase:Row Level Security
- Reddit:Multitenant SaaS architecture discussion
延伸學習
- Supabase:Advanced Row Level Security Policies:適合接著理解 policy 組合與授權設計。
- Supabase:Row Level Security step by step:適合從啟用 RLS、建立 policy 到驗證流程重新走一次。