PostgreSQL 的 RLS:資料庫怎麼替每一句查詢自動加上「只看自己這家店」

寫給耀達 Lite 雲端進銷存(2026-11-05)。第 5 題已定:所有店家共用資料表,用 RLS 隔開。這份解釋它怎麼運作、會在哪裡失效,補充 postgres-design-sql.html 第 3、6 節。
前提:AWS 東京、RDS PostgreSQL、C# ASP.NET Core+Npgsql。PostgreSQL 版本還沒定,內容以 PostgreSQL 18 文件為準。資料表和欄位名稱都是示範,資料模型還沒定。

這份文件的可信度

1. 先講結論

RLS(Row-Level Security,列層級安全性):替資料表設定「哪些列可以看、可以改」的政策(policy)。資料庫執行查詢時會自動把政策條件加進去,程式就算忘了寫 WHERE store_id = …,也碰不到別家店的資料。

2. RLS 解決什麼問題

所有店家共用同一張 customer 表時,沒有 RLS 就只能靠每一句 SQL 都記得寫 WHERE store_id = …。專案裡幾百句 SQL 只要漏一句,甲店就會看到乙店的客戶,而且不會有任何錯誤訊息。

一般的權限管不到這一層:

機制管到哪裡例子
GRANT(資料表)能不能碰這張表app_user 可以讀寫 customer
GRANT(欄位)能不能碰這個欄位某帳號不能讀 password_hash
RLS能碰這張表裡的哪些列app_user 只能碰目前這家店的列

RLS 和 GRANT 是疊加的:先要有 GRANT,RLS 再從裡面篩選。AWS 的多租戶指引也寫明,PostgreSQL 用共用資料表時需要 RLS,好處是把隔離集中在資料庫,不用靠每個開發者記得。

程式送出的 SQL SELECT * FROM customer 沒有寫店家條件 資料庫加上政策條件 store_id = 目前店家 程式不用寫,也拿不掉 實際執行 目前店家來自 app.store_id = 1 甲店客戶一 甲店客戶二 乙店客戶一 回傳 當作不存在,也不會報錯

3. 最小範例,從頭跑一遍

需要三樣東西:帳號要分開、資料表要開啟 RLS、要有政策。

-- ① 帳號:擁有資料表的和程式用的要分開(postgres-design-sql.html 第 3.1 節)
CREATE ROLE migration_owner LOGIN;
CREATE ROLE app_user LOGIN;

-- ② 由 migration_owner 建表並開放給 app_user
CREATE TABLE customer (
  store_id bigint NOT NULL,
  id       bigint NOT NULL,
  code     text   NOT NULL,
  name     text   NOT NULL,
  PRIMARY KEY (store_id, id),
  UNIQUE (store_id, code)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON customer TO app_user;

-- ③ 開啟 RLS。FORCE 讓擁有者也受限(第 5 節)
ALTER TABLE customer ENABLE ROW LEVEL SECURITY;
ALTER TABLE customer FORCE ROW LEVEL SECURITY;

-- ④ 政策:看和寫都只限目前的店。這裡先用最直接的寫法,第 10 節會比較另一種
CREATE POLICY store_isolation ON customer
  USING      (store_id = current_setting('app.store_id')::bigint)
  WITH CHECK (store_id = current_setting('app.store_id')::bigint);

資料表裡有甲店(store_id = 1)兩位、乙店(store_id = 2)一位客戶。用 app_user 連線後的實測結果:已實測

SELECT * FROM customer;
-- ERROR: unrecognized configuration parameter "app.store_id"
-- 還沒說自己是哪一家店,直接報錯

BEGIN;
SELECT set_config('app.store_id', '1', true);

SELECT store_id, id, code, name FROM customer;
--  1 | 1 | A001 | 甲店客戶一
--  1 | 2 | A002 | 甲店客戶二

SELECT count(*) FROM customer WHERE store_id = 2;
-- 0:明確指名乙店也查不到,不會報錯

UPDATE customer SET name = '改' WHERE store_id = 2;
-- UPDATE 0

INSERT INTO customer VALUES (2, 99, 'Z', 'x');
-- ERROR: new row violates row-level security policy for table "customer"

ROLLBACK;

實測是先灌資料再開 RLS。如果反過來,開了 FORCE 的擁有者灌資料時也要先設店家編號。

4. 資料庫實際上做了什麼

RLS 不是在查完之後才把結果過濾掉。政策條件在查詢規劃時就加進查詢裡,所以能用索引。用 EXPLAIN 可以直接看到:已實測

-- 程式只寫了 code,store_id 條件是政策加上去的,還走了索引
EXPLAIN SELECT * FROM customer WHERE code = 'A001';

Index Scan using customer_store_id_code_key on customer
  Index Cond: ((store_id = (current_setting('app.store_id'::text))::bigint)
               AND (code = 'A001'::text))

這帶來幾個重要的性質:

預備陳述式會不會把店家記住?

不會。current_setting 是在執行時才讀值,即使強制用泛用計畫,換了店家也會拿到正確的資料。換角色時,同一個預備陳述式也會依目前角色判斷(超級使用者看到 3 列,SET ROLE app_user 後 2 列,換回來又是 3 列)。已實測

SET plan_cache_mode = force_generic_plan;
PREPARE q AS SELECT min(name), count(*) FROM customer WHERE code = $1;
-- app.store_id = 1:甲店客戶一,1 列
-- app.store_id = 2:乙店客戶一,1 列

資料不會錯,但計畫可能是為另一家店選的,那是效能問題,見第 10 節。

5. 誰受限制、誰會略過

先有一個重要的預設值:開了 RLS 卻沒有適用的政策,就是全部拒絕,不是全部放行。下面用一張開了 RLS、沒有任何政策的表,以及甲乙兩店共 2 列資料實測:已實測

身分看到幾列規則
app_user(一般角色)0 列;INSERT 報錯、UPDATE 和 DELETE 都是 0 列受 RLS 限制
擁有者,沒開 FORCE2 列擁有者預設略過
擁有者,開了 FORCE0 列擁有者也受限
BYPASSRLS 角色2 列一律略過,FORCE 也沒用
超級使用者2 列一律略過

最容易犯的錯:程式用資料表擁有者連線,又沒開 FORCE。政策全部寫好了,也不會有任何錯誤,但完全沒有效果。這也是帳號要分開的原因。

其他要知道的規則:

可以用 row_security_active('customer') 確認目前帳號會不會受影響:實測 app_user 回傳 t,BYPASSRLS 和超級使用者回傳 f。已實測

6. USING 與 WITH CHECK:一個靜默、一個報錯

USINGWITH CHECK
檢查什麼資料表裡已經有的列即將寫入的新內容
用在SELECT、UPDATE、DELETEINSERT、UPDATE
不通過時靜默略過,當作不存在報錯,整句失敗
一句 UPDATE 會過兩關 舊的列 乙店的客戶 第一關 USING 看得到才動得到 新的內容 SET 之後的樣子 第二關 WITH CHECK 新內容合規才能存 不通過:靜默略過 回傳 UPDATE 0 不通過:報錯 整句失敗

如果只寫 USING、沒寫 WITH CHECK,USING 的條件也會拿來檢查寫入:實測只有 USING 的表,寫入別店一樣報錯。已實測不過 INSERT 專用的政策不能有 USING,SELECT 和 DELETE 專用的政策不能有 WITH CHECK。

另外,WITH CHECK 在 BEFORE 觸發器之後才檢查。實測觸發器把新列的 store_id 從 2 改成 1 後,寫入成功。所以觸發器寫錯時也可能改變檢查的結果。已實測

靜默略過對程式的影響

甲店的程式改一張單,有三種情況:已實測

情況結果
改自己店裡存在的單UPDATE 1
改別家店的單UPDATE 0
改一張不存在的單UPDATE 0

後兩種分不出來,這其實是好事:不會透露別家店有沒有這張單。但程式一定要檢查影響列數。收到 0 就要回「找不到」,不能當作成功,否則使用者會看到「已儲存」,資料卻沒改。

7. 每種指令實際碰到什麼

下表整理自 CREATE POLICY 文件的對照表。「篩選」是不通過就靜默略過,「檢查」是不通過就報錯。

指令SELECT/ALL 的 USINGINSERT/ALL 的 CHECKUPDATE/ALL 的 USINGUPDATE/ALL 的 CHECKDELETE/ALL 的 USING
SELECT、COPY … TO篩選舊列
SELECT FOR UPDATE/SHARE篩選舊列篩選舊列
INSERT檢查新列(要讀取時)檢查新列
UPDATE篩選舊列、檢查新列(要讀取時)篩選舊列檢查新列
DELETE篩選舊列(要讀取時)篩選舊列

「要讀取時」指語句用到這張表的欄位,例如有 WHERE 或 RETURNING。ON CONFLICT 和 MERGE 的完整對照見 CREATE POLICY 文件。

用一條 FOR ALL(不寫就是這個)的政策時,上表所有欄位都由它處理,不用記這張表。但把政策拆開時就會踩到:已實測

情況結果說明
只有 FOR SELECT 政策 SELECT 正常;INSERT 報錯;UPDATE 0、DELETE 0 其他指令沒有適用的政策,預設拒絕
只有 FOR UPDATE 政策 UPDATE t SET v='x' 得到 UPDATE 1
UPDATE t SET v='x' WHERE store_id=1 得到 UPDATE 0
加了 WHERE 就要讀取,需要 SELECT 政策,而這裡沒有。同一句語意,結果不同

其他寫入語法

語法實測結果 已實測要注意
UPDATE … RETURNING正常回傳自己的列新列要符合 SELECT 政策,否則報錯,不會默默少回傳
INSERT … ON CONFLICT DO NOTHING,寫入別店報錯不管有沒有衝突,新列都要先過檢查
MERGE,目標是別店的列MERGE 0沒有專屬的 MERGE 政策,依動作套用其他政策
SELECT … FOR UPDATE,別店的列0 列
COPY … TO只輸出自己的列
COPY … FROMCOPY FROM not supported with row-level security大量匯入要改用 INSERT,或用 BYPASSRLS 帳號(postgres-design-sql.html 第 7.2 節)

8. 多條政策怎麼合併

一列要看得到,下面兩邊都要成立 PERMISSIVE(預設) 政策 A OR 政策 B OR … 至少一條通過。多一條,看到的就更多 一條都沒有:全部拒絕 AND RESTRICTIVE 政策 X AND 政策 Y AND … 全部都要通過。多一條,看到的就更少 只有這種:一樣全部拒絕

實測:一張表有甲乙兩店的公開與機密資料各一列,以甲店身分查詢。已實測

政策組合甲店看到意義
隔離(permissive)甲公開、甲機密正常
隔離(permissive)+FOR SELECT USING (kind='public')(permissive)甲公開、甲機密、乙公開隔離被打破。兩條用 OR 合併
只有隔離(restrictive)0 列沒有 permissive 就全部拒絕
隔離(restrictive)+公開(permissive)甲公開交集
隔離(restrictive)+手滑 USING (true)(permissive)甲公開、甲機密;寫入乙店仍報錯restrictive 擋住了手滑

加新政策是「放寬」,不是「再加一條限制」。只有一條隔離政策時,任何人再加一條 permissive 政策,都可能讓別家店的資料被看到。

第二列的組合,寫入不受影響:FOR SELECT 那條不管 UPDATE 的 USING,所以 UPDATE 乙店的公開列得到 UPDATE 0。這也再次說明 FOR SELECT 只放寬讀取。已實測

用 TO 指定角色

政策可以只對某些角色生效:CREATE POLICY a ON t TO app_user USING (…)。沒有寫 TO 就是對所有角色。沒有被任何政策列到的角色,看到的是 0 列。已實測

Claude 的看法:第一版每張表一條隔離政策就好,不要為了防手滑改成兩層,因為後台要看全部時,restrictive 也要多加 TO app_user,每張表至少多一條。手滑交給第 17 節的自動檢查去抓。

9. 店家編號怎麼傳進資料庫

政策要知道「目前是哪一家店」,有兩種做法:

做法好處代價
每家店一個資料庫角色,政策比對 current_user資料庫層就分開身分幾千家店就有幾千個角色;AWS 文章也說不好維護、不好擴充;連線不容易共用
共用一個 app_user,用執行時變數AWS 指引偏好的做法;連線池正常運作變數由程式設定,資料庫無法驗證真假(第 14 節)

我們用第二種。名稱要有前綴,app.store_id 的 app. 就是:PostgreSQL 接受任何兩段式的名稱當作自訂參數,不用事先宣告。

9.1 變數的行為

全部實測過。已實測

情況查詢 customer 的結果
這條連線從沒設定過報錯:unrecognized configuration parameter "app.store_id"
set_config(…, true),同一筆交易內正常
交易結束後(變數變成空字串,不是 NULL)報錯:invalid input syntax for type bigint: ""
沒開交易,單獨一句 set_config(…, true) 後再查同上,一句結束就失效
連線執行過 DISCARD ALL同上
值不是數字,例如 abc報錯:invalid input syntax for type bigint: "abc"
不存在的店家,例如 9990 列,不報錯。資料庫不知道 999 不存在
SAVEPOINT 後改成 2,再 ROLLBACK TO變回 1
session 層設 1,交易內用 true 設 2交易內是 2,交易結束後回到 1

為什麼一定要用 true(只在交易內有效):false 會留在連線上。postgres-design-sql.html 第 3.3 節實測過:打開 No Reset On Close 時,下一位沒設定店家編號的請求,直接讀到上一家店的 20 張單,完全不會報錯。

為什麼不用 current_setting('app.store_id', true):多那個 true 後,變數不存在時回傳 NULL,查詢得到空清單。忘了設定的 bug 看起來就像「這家店沒有資料」(同文件第 3.2 節)。

9.2 C# 這邊怎麼寫

出自 postgres-design-sql.html 第 3.3 節,下面提到的行為當時用 Npgsql 測過。這次沒有重跑 C#。

await using var conn = await dataSource.OpenConnectionAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);

await using (var set = new NpgsqlCommand(
    "SELECT set_config('app.store_id', $1, true)", conn, tx))
{
    set.Parameters.AddWithValue(storeId.ToString());
    await set.ExecuteNonQueryAsync(ct);
}

// 這裡才開始做事。只讀也一樣要包在交易裡。

await tx.CommitAsync(ct);

10. 政策條件的兩種寫法

這是這次實測才發現值得改的地方。postgres-design-sql.html 第 3.2 節和 AWS 指引都用第一種,但第二種差一對括號,行為差很多:

-- 寫法一:直接呼叫
USING (store_id = current_setting('app.store_id')::bigint)

-- 寫法二:包成子查詢
USING (store_id = (SELECT current_setting('app.store_id')::bigint))

差別有兩個。寫法二的子查詢只算一次,不用每列都算;而且規劃器在規劃時看不到值,不會針對某一家店選計畫。第一點是從計畫(InitPlan)和下面的時間推論的。

實測資料:200 萬列,大店 190 萬列,另有 100 家小店各 1,000 列,查詢沒有明寫 store_id,條件是用不上索引的 amount = 777,關閉平行查詢。已實測

情境寫法一寫法二
大店,規劃器自己選約 415 ms(循序掃描)約 310 ms(Bitmap)
大店,強制循序掃描約 420 ms約 193 ms
小店,新規劃0.26 ms0.24 ms
大店先跑 7 次,小店沿用預備陳述式349 ms(循序掃描)0.51 ms
小店建立的泛用計畫,換大店跑438 ms(Index Scan)317 ms(Bitmap)
對照:BYPASSRLS,明寫 store_id=1,循序掃描約 195 ms(只跑 2 次)

前兩列跑 5~7 次取中位數,後三列只跑一次、快取狀態也不同,只看量級。

寫法二的代價

確認過的相容性

下面這些在寫法二都跟寫法一相同:沒設店家編號時報錯;INSERT、UPDATE、DELETE 自己的列正常;寫入別店報錯;改別店 UPDATE 0;索引正常使用;後台政策(第 15 節)正常;分割區裁剪也有作用(第 16 節)。已實測

Claude 的看法:看法改了。舊文件用寫法一,這次實測後改推薦寫法二,再加上查詢一律明寫 store_id = $1。寫法二讓「忘了明寫、小店又沿用大店計畫」時,從幾百毫秒降到不到 1 毫秒;明寫則讓規劃器仍然有機會依店家大小選計畫(這句是推論,沒有單獨比較過)。多表查詢的部分,要等資料模型定了用真實查詢再測一次。

11. 條件的先後順序與 LEAKPROOF

為什麼 RLS 會讓某些索引用不上?關鍵在判斷順序。假設有人寫了這樣的函式,把看過的值印出來,並把成本設得很低,讓規劃器優先執行它:

CREATE FUNCTION peek(s bigint, v text) RETURNS boolean
LANGUAGE plpgsql COST 0.0001 AS $$
BEGIN RAISE NOTICE 'peek 看到 store=%, name=%', s, v; RETURN true; END $$;

四種情況的實測結果:已實測

查詢對象peek 看到的資料說明
一般檢視表(WHERE store_id = 1) 甲店資料、乙店機密 洩漏。結果只回傳甲店,但函式先跑,乙店的值已經印出來了
security_barrier 檢視表 只有甲店 檢視表的條件保證先跑
RLS 資料表,強制循序掃描 只有甲店 計畫的條件順序:先政策,後 peek
RLS 資料表,把 peek 標成 LEAKPROOF 甲店兩位、乙店一位 洩漏。計畫的順序變成先 peek,後政策

同樣的道理也適用於錯誤訊息。WHERE 1/(amount-999) > -1000 只有乙店的單(金額 999)會除以零:BYPASSRLS 帳號得到 division by zero,甲店的 app_user 正常回傳 2。如果條件先跑,甲店就能從這個錯誤猜出別家店有金額 999 的單。已實測

這跟索引有什麼關係

索引條件會在 RLS 條件之前就判斷。所以只有 LEAKPROOF 的運算子才能當索引條件,其他的要等政策過濾完才能判斷。文件明寫:運算子底層的函式不是 LEAKPROOF 時,RLS 資料表不能為它選用索引掃描。

舊文件查過函式目錄,也實測過(postgres-design-sql.html 第 6 節):

能當索引條件不能當索引條件
bigint、int、uuid、date、timestamptz、text 的 = < >,boolean 比較 numeric 的比較(金額、數量)、LIKE、ILIKE、lower()、upper()、jsonb 的比較

小店影響很小,因為先用 store_id 索引找出少量的列,再逐列過濾。這次也看到同樣的計畫:20 萬列的表,amount 明明有索引,app_user 仍然掃 store_id 索引,把 amount 放在 Filter;後台帳號則直接走 amount 索引(第 15 節)。已實測

12. RLS 本身有多慢

用 pgbench 比較單筆查詢。200 萬列,1 條連線,本機 localhost,每組跑 8 秒、3 輪,取中位數。查詢都明寫 store_id 和 id,走主鍵,政策用第 10 節的寫法一。simple 是每次都解析 SQL,prepared 是用預備陳述式。已實測

組別帳號做的事每筆延遲(simple)每筆延遲(prepared)
ABYPASSRLS只有查詢0.117 ms0.073 ms
BBYPASSRLSBEGIN、set_config、查詢、COMMIT0.286 ms0.223 ms
Capp_user同 B,RLS 生效0.297 ms0.226 ms

所以 RLS 真正要注意的不是政策條件本身,而是第 10 節的計畫問題和第 11 節的索引限制。

13. RLS 管不到的地方

下面除了最後一列和註明出處的部分,這次都實測過。已實測

路徑會發生什麼事怎麼處理
外鍵、唯一性檢查 一律略過 RLS。單欄外鍵可以參照別家店的客戶;單欄 UNIQUE 下,別店用過的代號會報 duplicate key(postgres-design-sql.html 第 3.4 節)。ON CONFLICT DO NOTHING 則是 INSERT 0 0,DO UPDATE 報 USING expression 錯誤,都等於透露別家店有這個值 主鍵、外鍵、唯一約束全部帶 store_id。實測複合外鍵會擋下參照別店
檢視表 預設用檢視表擁有者的身分和政策。實測沒開 FORCE 的表,透過檢視表看到兩家店 建立時加 WITH (security_invoker = true),實測只看到自己。對表開 FORCE 也有效
SECURITY DEFINER 函式 用函式擁有者的身分。沒開 FORCE 的表,app_user 呼叫後數到兩家店 除非必要不用。實測開了 FORCE 只數到自己
Materialized view 裡面是一份存好的資料,不能開 RLS(ALTER action ENABLE ROW SECURITY cannot be performed)。實測用甲店身分建立,乙店去查,看到甲店的資料 不開放給 app_user 直接查。報表彙總改用有 store_id 和 RLS 的一般資料表
TRUNCATE 不經過 RLS。給了權限,app_user 只設甲店身分,兩家店的資料都清空了 不授權給 app_user。一般的 GRANT SELECT, INSERT, UPDATE, DELETE 不包含它,實測沒權限時報 permission denied
序列 序列不屬於任何店。甲店拿到 1,乙店新增 100 筆後,甲店拿到 102,可以看出別家店的量 對外的單號用每家店自己的編號,不要顯示全庫共用的流水號
系統目錄與統計 pg_stats 對受限帳號隱藏(0 列,BYPASSRLS 是 4 列),但 pg_class.reltuples 看得到全表筆數。EXPLAIN 的估計值也是,文件稱為隱蔽管道 店家本來就碰不到資料庫,只有 SQL injection 時才有意義
新資料表忘了開 RLS 要每張表各開一次。沒開的表,app_user 看到全部 第 17 節的自動檢查
資料庫以外 快取、匯出檔、日誌、搜尋服務都沒有 RLS(deployment.md 也記了快取鑰匙要帶店家編號) 每一處都要自己處理店家隔離

觸發器函式預設用呼叫者的身分執行,所以它寫入的稽核表也要符合政策。實測寫入自己店的稽核列正常。已實測

14. SQL injection:RLS 擋不住的那一種

很多人以為有 RLS 就不怕 SQL injection,這是錯的。app_user 自己就能設定店家編號,資料庫分不出這句是程式寫的還是攻擊者塞的。

假設有一句查詢用字串組 SQL,而且能一次送多句:已實測

BEGIN;
SELECT set_config('app.store_id', '1', true);

-- 程式本來要跑:SELECT name FROM customer WHERE code = '使用者輸入'
-- 攻擊者輸入:  '; SELECT set_config('app.store_id','2',true); SELECT name FROM customer; --
SELECT name FROM customer WHERE code = '';
SELECT set_config('app.store_id','2',true);
SELECT name FROM customer; --';

-- 結果:乙店客戶一

實測的其他攻擊和防護:已實測

嘗試結果
只能塞 UNION,不能改編號,查別店的 customerRLS 擋住,0 列
同上,查沒開 RLS 的表查得到
防護:REVOKE EXECUTE ON FUNCTION set_config … FROM PUBLICset_config 確實被擋,但 SET LOCAL app.store_id = '2' 一樣能換店,所以這招沒有用

Claude 的看法:

RLS 防的是程式自己的疏忽(忘了 WHERE),不是能執行任意 SQL 的攻擊者。AWS 的 RLS 文章也沒有討論這點,它的範例甚至是用字串組 SET 語句,不要照抄。

15. 後台帳號怎麼看到所有店家

第 5 題接受的代價是「後台要有一個 BYPASSRLS 帳號」,但 rds.html 第 7.2 節查到 RDS 上可能設不了。這次模擬了一個只有 CREATEROLE 的非超級使用者:已實測(只是模擬)

CREATE ROLE try_bypass LOGIN BYPASSRLS;
-- ERROR: permission denied to create role
-- DETAIL: Only roles with the BYPASSRLS attribute may create roles with the BYPASSRLS attribute.

ALTER ROLE try_plain BYPASSRLS;
-- ERROR: permission denied to alter role
-- DETAIL: Only roles with the BYPASSRLS attribute may change the BYPASSRLS attribute.

自己有 BYPASSRLS 之後就可以建立。這證實了社群版 PostgreSQL 的規則,但不代表 RDS 也這樣:RDS 的 rds_superuser 能做的事跟這裡的模擬不同,這次也沒查到 AWS 明確的說法。第一台建好時直接跑一次才算數。

候選做法:後台專用政策

rds.html 留了這個候選,當時沒有實測。這次都跑過了:已實測

CREATE POLICY backoffice_all ON customer TO backoffice USING (true) WITH CHECK (true);
問題實測結果
隔離政策也對 backoffice 生效(沒寫 TO),沒設店家編號會不會報錯?不會。EXPLAIN 看到的是沒有任何過濾條件的 Seq Scan:true 跟隔離條件 OR 起來,整段被規劃器化簡掉
索引條件會不會像 app_user 一樣受限?不會。numeric 條件走 Index Scan,計畫跟 BYPASSRLS 帳號完全相同。這解決了 rds.html 留下的「效能要另外確認」
可以改別家店的資料嗎?可以,UPDATE 1。所以這個帳號權限很大,要留稽核紀錄
只要唯讀?用 FOR SELECT,並把隔離政策寫成 TO app_user:查得到全部,UPDATE 得到 UPDATE 0
隔離政策寫成 TO app_user 的副作用?開了 FORCE 的擁有者沒有適用的政策,查到 0 列,連補資料也做不到
BYPASSRLS 帳號後台專用政策
RDS 上能不能用未查證,要實測標準語法,應該可以(RDS 上沒跑過)
新資料表自動涵蓋每張表要多寫一條,漏寫時後台查不到
能不能限制全庫所有表都略過可以按表、按讀寫分開
查詢計畫沒有 RLS 限制相同(實測)
COPY FROM可以(舊文件已實測)這次沒測

Claude 的看法:先在 RDS 上試 BYPASSRLS;就算能設,也傾向用後台專用政策,因為它能限制範圍,又跟 BYPASSRLS 一樣能用索引。多寫一條政策可以由遷移工具統一產生,第 17 節的檢查也能抓到漏寫。這會牽動第 5 題「後台要有 BYPASSRLS 帳號」的敘述,但第 5 題選共用資料表這件事本身不受影響。

另一點:擁有者開了 FORCE 後,補資料要設定店家編號,實測一次只動那一家(UPDATE 4000),沒設就報錯。這是「報錯而不是空結果」帶來的好處:不會顯示更新了 0 列卻以為成功。

16. 分割表上的 RLS

db-scaling.html 把分割表放在之後的階段。postgres-design-sql.html 第 17 節把「分割表跟 RLS 合用的細節」列為沒查,這次補上。官方文件對這點幾乎沒有著墨(分割、CREATE TABLE、ALTER TABLE 的頁面都沒提),下面主要靠實測。已實測

只在母表開 RLS,直接查分割區可以看到所有店。分割區不會繼承 RLS(relrowsecurity 是 f),ALTER DEFAULT PRIVILEGES 又會讓 app_user 對分割區自動有權限。實測透過母表看到 1 列,直接查兩個分割區看到全部 6 家店。

做法透過母表直接查分割區
只在母表開 RLS1 列(正常)全部店家
分割區也開 RLS 和 FORCE,不建政策1 列(只套母表的政策)0 列(預設拒絕)
收回 app_user 對分割區的權限1 列(只檢查母表權限)permission denied

Claude 的看法:等真的要分割時,兩種做法都做:分割區開 RLS,也收回權限。現在只要記得這件事。

17. 怎麼確認沒有漏洞

17.1 查系統目錄

放進自動測試,查到任何一列就讓測試失敗。分兩次測過:第一次(只含資料表的版本)抓出沒開 RLS、沒開 FORCE、沒有政策的表;第二次(下面這一版)抓出沒開 RLS 的分割區、沒設 security_invoker 的檢視表和 materialized view,設了 security_invoker 的檢視表和正常的表沒有被列出來。已實測

SELECT c.oid::regclass AS table_name, c.relkind, c.relrowsecurity, c.relforcerowsecurity
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
                   AND a.attname = 'store_id' AND NOT a.attisdropped
WHERE n.nspname = 'public'
  AND ( c.relkind = 'm'
     OR (c.relkind = 'v' AND NOT coalesce('security_invoker=true' = ANY (c.reloptions), false))
     OR (c.relkind IN ('r','p')
         AND (NOT c.relrowsecurity OR NOT c.relforcerowsecurity
              OR (NOT c.relispartition
                  AND NOT EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid)))))
ORDER BY 1;

前提是有店家資料的表都叫 store_id。沒有這個欄位的表(例如子明細表如果只靠母單關聯)查不到,所以每張明細表也要帶 store_id。

17.2 看政策內容

第 8 節提到多一條 permissive 就會放寬,所以也要盯政策本身:

SELECT tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies ORDER BY 1, 2;

Claude 的看法:測試裡比對這份清單,只允許 store_isolation 和 backoffice_all 這兩個名字,多出來的就失敗,要人看過才能加進允許清單。

17.3 用兩家店的資料跑功能測試

測試資料庫放甲乙兩家店,用 app_user 連線(不能用擁有者或超級使用者,否則 RLS 根本不生效)。Claude 的看法:每張表至少驗這幾件事:

18. 對耀達 Lite 的意義

Claude 的看法,都還沒拍板:

地方怎麼做因為
政策寫法(SELECT current_setting('app.store_id')::bigint),每張表一條第 10 節
帳號app_user、migration_owner、backoffice 分開,每張表開 FORCE第 5 節
店家編號只從登入資訊取得,每筆交易 set_config(…, true),包在共用的資料存取層第 9 節
查詢RLS 當第二道防線,SQL 仍然明寫 store_id = $1第 10 節
影響列數UPDATE、DELETE 得到 0 就回「找不到」第 6 節。不能顯示已儲存
SQL一律參數化,不用字串組第 14 節
約束主鍵、外鍵、唯一約束全部帶 store_id,每張明細表都有 store_id第 13、17 節
檢視表、報表檢視表加 security_invoker;不用 materialized view 給店家查第 13 節
自動測試第 17 節三項都放進 CI新資料表最容易漏
員工權限店員、主管、老闆能做什麼,留在 C#,不寫進 RLS文件說政策只看當前這一列最簡單、效能最好;查別張表的政策可能有競爭條件(race condition)和資訊洩漏

也就是說,RLS 只負責「店家之間」,不負責「店內誰能看什麼」。

會碰到的還沒定的題目:

19. 還沒查證的事

這次也沒有改動舊文件。postgres-design-sql.html 第 3.2 節的政策仍是寫法一,rds.html 第 7.2 節仍寫著候選做法沒實測。

20. 名詞對照

名詞白話
RLS列層級安全性:由資料庫決定每個連線能碰哪些列
政策(policy)一條規則,寫明什麼條件的列可以看、可以改
USING檢查已經存在的列,不通過就靜默略過
WITH CHECK檢查要寫入的新內容,不通過就報錯
PERMISSIVE寬鬆型政策(預設):多條用 OR 合併,越多條看到越多
RESTRICTIVE限制型政策:多條用 AND 合併,越多條看到越少
預設拒絕(default deny)開了 RLS 但沒有適用的政策時,什麼都看不到
FORCE ROW LEVEL SECURITY讓資料表擁有者也受 RLS 限制
BYPASSRLS角色屬性:這個帳號一律略過 RLS
set_config、current_setting設定、讀取連線上的參數,這裡用來傳店家編號
LEAKPROOF函式保證不會透過錯誤訊息等方式洩漏參數內容的標記
security_barrier檢視表選項:檢視表的條件保證先於使用者的條件
security_invoker檢視表選項:用查詢者的身分和政策,不用擁有者的
SECURITY DEFINER函式選項:用函式擁有者的身分執行
隱蔽管道(covert channel)不是直接讀到資料,而是從錯誤、統計或時間推測出來
泛用計畫(generic plan)預備陳述式重複使用、不針對這次參數的計畫
InitPlan計畫裡只跑一次、結果重複使用的子查詢
分割區裁剪(partition pruning)資料庫跳過不可能有資料的分割區

21. 來源

PostgreSQL 官方文件,2026-11-05 查閱,版本 18:

AWS:

同目錄文件:postgres-design-sql.html(第 3、6、7、10、12、13、17 節與實測)、rds.html(第 7 節)、db-scaling.html;md\deployment.md(第 5 題、快取鑰匙)。

實測環境:Windows 10、PostgreSQL 18.6(EDB 免安裝版)、localhost、預設設定,initdb --locale=C。腳本放在暫存目錄,沒有留在專案裡。