主題: 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 的功能,通常會在邊界處踩雷。

動態迷因(展開/收合)
RLS policy 不會取代資料表權限;先分清楚誰能操作資料表,再分清楚誰能碰到哪些資料列。 · 來源:GIPHY

本文用一張共享的 documents 表討論實作。它只有 idworkspace_idtitlebody。每位成員只能操作目前工作區的文件。

第一個邊界:GRANT 管資料表,policy 管資料列

RLS 啟用後,資料庫要同時回答兩個問題:

  1. 這個資料庫角色有沒有 SELECTINSERTUPDATEDELETE 權限?
  2. 如果有,它可以操作哪些列?

前者是 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 看得到哪些列,也決定 UPDATEDELETE 可以挑中哪些列。
  • WITH CHECK 驗證 INSERTUPDATE 後準備寫入的新列。結果為 falseNULL 時,指令會失敗。

先把目前工作區抽成單一函式,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 ... RETURNINGON CONFLICT DO UPDATE 為什麼要和一般 CRUD 一起測試。

動態迷因(展開/收合)
policy 寫完不代表隔離已驗證;要用非 owner 的角色,實際測試同工作區允許、跨工作區拒絕。 · 來源:GIPHY

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 的應用程式角色。對每個操作都要驗證兩件事:

  • 同工作區的 SELECTINSERTUPDATEDELETE 是否可用。
  • 跨工作區讀取、修改、刪除,以及嘗試把資料搬到別的工作區時,是否真的被拒絕。

再加上 RETURNING、upsert、view、function 與 transaction pooling 的情境。這會比「migration 成功,資料表上也看得到 policy」更接近真實的存取路徑。

RLS 很適合替共享 schema 多加一道資料庫層保護,但它不會替你選好多租戶架構。資料隔離尺度、備份還原、工作負載互相干擾和維運模式仍要一起決定。PostgreSQL 社群的多租戶討論可作為架構取捨的採用脈絡,不應取代官方語意與實作測試。

我學到什麼

  • 我會把 GRANT 與 RLS policy 分開審查。前者決定能不能操作資料表,後者決定能操作哪些列。
  • 我會先區分既有列與更新後的新列,再決定 USINGWITH CHECK。這能避免資料在更新時跨越工作區邊界。
  • 我會把新增 permissive policy 視為可能擴權的變更,並用非 owner 角色做 allow/deny 測試。
  • 我不會假定 view 或 SECURITY DEFINER function 一定沿用呼叫者的權限身分;它們需要單獨檢查。

外部參考資料

延伸學習