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 文件為準。資料表和欄位名稱都是示範,資料模型還沒定。
這份文件的可信度
- 已實測:2026-11-05 在本機 Windows 用 PostgreSQL 18.6 跑過,看到文中寫的結果。引用舊文件的實測會寫出出處(2026-11-02,同樣是本機 18.6)。時間數字只能看量級。
- 沒有在 RDS 上跑過。RDS 跑的是 PostgreSQL 引擎,RLS 的規則應該相同,但主帳號的權限不同。第 15 節的模擬不等於真的 RDS。
- 未實測 表示行為有文件支持,或是從文件、實測結果推論的,這次沒有直接跑過。
- Claude 的看法 都還沒拍板。
- 沒有標記的事實來自官方文件,來源在第 21 節。
1. 先講結論
RLS(Row-Level Security,列層級安全性):替資料表設定「哪些列可以看、可以改」的政策(policy)。資料庫執行查詢時會自動把政策條件加進去,程式就算忘了寫 WHERE store_id = …,也碰不到別家店的資料。
- 它是第二道防線,不是唯一一道:店家編號仍然要由程式從登入資訊取得,再傳給資料庫。程式傳錯,RLS 就會忠實地給你錯的那家店。
- 它有兩種失敗方式:看不到的列會被靜默略過(
UPDATE 0),寫入不該寫的列會報錯(第 6 節)。 - 開了也可能沒生效:超級使用者、
BYPASSRLS帳號和沒開FORCE的資料表擁有者都會略過(第 5 節)。 - 有些路完全不經過它:外鍵與唯一性檢查、預設的檢視表、materialized view、直接查分割區、
TRUNCATE(第 13、16 節)。 - 擋不住 SQL injection:
app_user自己就能改店家編號,實測能直接讀到別家店(第 14 節)。 - 政策本身幾乎不花時間:單筆查詢實測只多 0.003~0.011 ms。真正的成本是某些條件用不上索引,以及大店小店共用計畫(第 10~12 節)。
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,好處是把隔離集中在資料庫,不用靠每個開發者記得。
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))
這帶來幾個重要的性質:
- 政策用查詢者的權限執行:條件是直接加進查詢的,所以政策裡用到的資料表和函式,查詢者也要有權限,否則會得到 permission denied。
- 每一列都要判斷:文件的說法是對每一列求值,只有結果是
true的列才處理。false和NULL都算不通過,所以store_id是NULL的列,受限的帳號看不到,也寫不進去。已實測 - 政策優先於查詢條件:除了標成
LEAKPROOF的函式,政策條件會先判斷(第 11 節)。
預備陳述式會不會把店家記住?
不會。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 限制 |
擁有者,沒開 FORCE | 2 列 | 擁有者預設略過 |
擁有者,開了 FORCE | 0 列 | 擁有者也受限 |
BYPASSRLS 角色 | 2 列 | 一律略過,FORCE 也沒用 |
| 超級使用者 | 2 列 | 一律略過 |
最容易犯的錯:程式用資料表擁有者連線,又沒開 FORCE。政策全部寫好了,也不會有任何錯誤,但完全沒有效果。這也是帳號要分開的原因。
其他要知道的規則:
- 只有擁有者能管政策:開關 RLS、建立或刪除政策。
app_user試的結果都是must be owner。已實測 DISABLE ROW LEVEL SECURITY不會刪掉政策:政策還在,但完全不生效,所有列都看得到。已實測row_security = off不是略過的開關:受限的帳號設了之後,會讓受政策影響的查詢直接報錯(query would be affected by row-level security policy),不會因此看到更多資料。pg_dump預設就這樣設,免得備份時默默少資料。已實測- 開政策要取最強的鎖:
CREATE POLICY和ALTER TABLE … ENABLE ROW LEVEL SECURITY在pg_locks上都是AccessExclusiveLock,會擋住這張表的所有查詢。線上改政策時要設lock_timeout(postgres-design-sql.html第 12 節)。已實測
可以用 row_security_active('customer') 確認目前帳號會不會受影響:實測 app_user 回傳 t,BYPASSRLS 和超級使用者回傳 f。已實測
6. USING 與 WITH CHECK:一個靜默、一個報錯
USING | WITH CHECK | |
|---|---|---|
| 檢查什麼 | 資料表裡已經有的列 | 即將寫入的新內容 |
| 用在 | SELECT、UPDATE、DELETE | INSERT、UPDATE |
| 不通過時 | 靜默略過,當作不存在 | 報錯,整句失敗 |
如果只寫 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 的 USING | INSERT/ALL 的 CHECK | UPDATE/ALL 的 USING | UPDATE/ALL 的 CHECK | DELETE/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 1UPDATE 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 … FROM | COPY FROM not supported with row-level security | 大量匯入要改用 INSERT,或用 BYPASSRLS 帳號(postgres-design-sql.html 第 7.2 節) |
8. 多條政策怎麼合併
實測:一張表有甲乙兩店的公開與機密資料各一列,以甲店身分查詢。已實測
| 政策組合 | 甲店看到 | 意義 |
|---|---|---|
| 隔離(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" |
| 不存在的店家,例如 999 | 0 列,不報錯。資料庫不知道 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);
storeId只能來自伺服器驗證過的登入資訊,不能信前端傳的。- 不存在的店家不會報錯,所以店家是否存在、是否停權要在登入時檢查。
- 這段要包在共用的資料存取層,不讓各功能自己寫。
NpgsqlBatch可以把set_config和查詢一次送出,省一次往返(同節已實測)。
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 ms | 0.24 ms |
| 大店先跑 7 次,小店沿用預備陳述式 | 349 ms(循序掃描) | 0.51 ms |
| 小店建立的泛用計畫,換大店跑 | 438 ms(Index Scan) | 317 ms(Bitmap) |
對照:BYPASSRLS,明寫 store_id=1,循序掃描 | 約 195 ms(只跑 2 次) | |
前兩列跑 5~7 次取中位數,後三列只跑一次、快取狀態也不同,只看量級。
- 第二列:同樣是循序掃描,寫法一慢一倍多。差的約 225 ms 平均到 200 萬列,每列約 0.1 微秒,應該就是每列重算
current_setting的成本(推論)。寫法二跟沒有 RLS 的對照組差不多。 - 第四列最重要:寫法一的泛用計畫其實還是受「建立當時是哪一家店」影響:大店建立的是循序掃描,小店建立的是 Index Scan。小店沿用大店的計畫,就要掃 200 萬列。這跟
postgres-design-sql.html第 10 節實測的 90 ms 是同一個問題(那次明寫store_id後降到 0.04 ms)。寫法二的計畫不隨店家改變。
寫法二的代價
- 規劃器不知道這家店有多大,所以對大店也可能選小店適用的計畫。這次單表查詢沒有變慢,但只測了單表。多表
JOIN有沒有可能選錯,還沒測。未實測 - 效益不是每次都有:另一組 200 萬列、大店 50 萬列的
count(*),兩種寫法都是約 70 ms。寫法一用了平行查詢,寫法二沒有。已實測
確認過的相容性
下面這些在寫法二都跟寫法一相同:沒設店家編號時報錯;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 節)。已實測
- 不要自己標
LEAKPROOF:標錯就是上表第四列的破口。一般帳號也設不了,實測migration_owner得到only superuser can define a leakproof function。已實測RDS 主帳號能不能設,沒查證。 - 檢查效能一定要用
app_user:用略過 RLS 的帳號跑EXPLAIN,計畫跟程式實際跑的不一樣(同文件第 6 節)。
12. RLS 本身有多慢
用 pgbench 比較單筆查詢。200 萬列,1 條連線,本機 localhost,每組跑 8 秒、3 輪,取中位數。查詢都明寫 store_id 和 id,走主鍵,政策用第 10 節的寫法一。simple 是每次都解析 SQL,prepared 是用預備陳述式。已實測
| 組別 | 帳號 | 做的事 | 每筆延遲(simple) | 每筆延遲(prepared) |
|---|---|---|---|---|
| A | BYPASSRLS | 只有查詢 | 0.117 ms | 0.073 ms |
| B | BYPASSRLS | BEGIN、set_config、查詢、COMMIT | 0.286 ms | 0.223 ms |
| C | app_user | 同 B,RLS 生效 | 0.297 ms | 0.226 ms |
- C 減 B 才是 RLS 本身的成本:多 0.011 ms(simple,約 4%)、0.003 ms(prepared,約 1%),幾乎可以不計。
- B 減 A 是多送三句的成本:多 0.15~0.17 ms。這還是本機,真實環境每句都要過網路,這部分應該會放大(推論)。未實測用
NpgsqlBatch可以少幾次往返。
所以 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,不能改編號,查別店的 customer | RLS 擋住,0 列 |
| 同上,查沒開 RLS 的表 | 查得到 |
防護:REVOKE EXECUTE ON FUNCTION set_config … FROM PUBLIC | set_config 確實被擋,但 SET LOCAL app.store_id = '2' 一樣能換店,所以這招沒有用 |
Claude 的看法:
- 主要防線是參數化查詢:Npgsql 的
$1參數不會被當成 SQL。這點和有沒有 RLS 無關,所有 SQL 都要這樣寫,不用字串組。 - 同一筆交易裡只設一次:放在共用的資料存取層,其他地方不准呼叫
set_config。可以在程式碼檢查時搜尋。 app_user的權限要小:不給TRUNCATE、不建物件,營運資料庫用別的帳號。
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 家店。
| 做法 | 透過母表 | 直接查分割區 |
|---|---|---|
| 只在母表開 RLS | 1 列(正常) | 全部店家 |
分割區也開 RLS 和 FORCE,不建政策 | 1 列(只套母表的政策) | 0 列(預設拒絕) |
收回 app_user 對分割區的權限 | 1 列(只檢查母表權限) | permission denied |
- 之後新建的分割區一樣是
f,要記得開。第 17 節的檢查會把它列出來。 - 分割區裁剪有作用:政策條件也能讓資料庫跳過其他分割區。8 個分割區,寫法一
Subplans Removed: 7;寫法二的計畫列出全部,但只有一個真的執行,其他是never executed。
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 的看法:每張表至少驗這幾件事:
- 甲店身分查不到乙店的列。
- 甲店
UPDATE、DELETE乙店的列都得到 0。 INSERT乙店的列、把自己的列改成乙店,都報錯。- 外鍵指向乙店的列時報錯。
- 沒設店家編號時報錯。
- 用模擬大店的資料跑
EXPLAIN (ANALYZE),大店小店各一次。
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 只負責「店家之間」,不負責「店內誰能看什麼」。
會碰到的還沒定的題目:
- 第 7 題:
set_config跟 RDS Proxy 的相容性(postgres-design-sql.html第 13 節);第一台 RDS 建好時測BYPASSRLS。 - 第 9 題:只還原一家店。
pg_dump --enable-row-security只匯出一家的做法已在舊文件實測,還原流程還沒談。 - 主鍵型別:第 13 節的序列會透露別家店的量。這是全庫共用
bigint序列的一個新代價,要跟之前實測的索引大小一起看,留到資料模型。 - 大店搜尋:
LIKE、numeric用不上索引的取捨(同文件第 6 節)。
19. 還沒查證的事
- RDS:這次所有實測都在本機。
BYPASSRLS能不能設、rds_superuser會不會略過 RLS、能不能設LEAKPROOF,都沒查到 AWS 明確的說法。Aurora 更不確定。 - 寫法二的多表查詢:規劃器不知道店家大小,
JOIN可能選錯計畫。這次只測單表和分割表。 - 明寫
store_id加寫法二的組合:沒有單獨比較過,第 10 節的推薦有一部分是推論。 - 資料超過記憶體時的成本:實測都有足夠的快取。真實的小機器(
t4g.micro)磁碟讀取比例會高很多。 - 網路往返:第 12 節是 localhost,真實環境
BEGIN、set_config、COMMIT的成本沒量過。 - RDS Proxy:
set_config(…, true)會不會釘住連線,要接真的 Proxy 才知道(第 7 題)。 - EF Core:怎麼在每筆交易注入
set_config,沒查(舊文件也列著)。 - 後台專用政策下的
COPY FROM:沒測。 - 觸發器與稽核:只測了寫入符合政策的情況。
這次也沒有改動舊文件。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:
- Row Security Policies(預設拒絕、誰會略過、合併規則、外鍵略過、競爭條件、
row_security) - CREATE POLICY(語法、每種指令套用的條件、對照表、檢視表與函式的權限)
- ALTER TABLE(
ENABLE、FORCE ROW LEVEL SECURITY) - ALTER ROLE(誰能變更
BYPASSRLS) - CREATE VIEW(
security_barrier、security_invoker) - Rules and Privileges(低成本函式的洩漏、
LEAKPROOF、索引掃描限制、隱蔽管道) - Client Connection Defaults(
row_security參數) - Customized Options(兩段式參數名稱)
- Configuration Settings Functions(
set_config、current_setting) - Table Partitioning、CREATE TABLE(查過,都沒有提 RLS)
- CREATE MATERIALIZED VIEW(查過,沒有提 RLS,行為來自實測)
AWS:
- Prescriptive Guidance:Row-level security recommendations(共用資料表需要 RLS、偏好執行時變數)
- Multi-tenant data isolation with PostgreSQL Row Level Security(不要用擁有者連線、每店一個角色的缺點、連線池的警告)
同目錄文件: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。腳本放在暫存目錄,沒有留在專案裡。