PostgreSQL Row Level Security 完全ガイド
PostgreSQL の Row Level Security (RLS) の仕組みと、適用した時のクエリ挙動を深掘りするガイド。 基本的な設定方法は dba-04-security.md の RLS 章を参照。本ドキュメントは仕組み・挙動・落とし穴に焦点を当てる。
対象読者:
- PostgreSQL の RLS を運用で使う/使い始める DBA・バックエンドエンジニア
- マルチテナント分離を RLS で実装している(あるいは検討中の)開発者
- 「なぜか index が使われない」現象を調査している人
目次
- RLS とは何か
- RLS の仕組み
- RLS の有効化方法
- ポリシーの書き方
- RLS 適用時の挙動
- session 変数との連携(マルチテナントパターン)
- 他の機能との相互作用
- 落とし穴と回避策(重要)
- デバッグ・可視化
- 実装パターン集
- パフォーマンスチューニング
- まとめと参考資料
1. RLS とは何か
1.1 概念
Row Level Security (RLS) は、テーブルの行単位でアクセス制御を行う PostgreSQL の機能。 クエリの WHERE 句に依存せず、DB エンジンが自動的に「見える行」を絞り込む。
-- RLS なし: アプリが WHERE で絞る (漏れたら全行見える)
SELECT * FROM orders WHERE tenant_id = 'A';
-- RLS あり: アプリは WHERE なしで叩いても DB 側で自動絞り込み
SELECT * FROM orders; -- 自動で「tenant_id = 'A' のもの」だけ返る
1.2 なぜ必要か
| ユースケース | RLS のメリット |
|---|---|
| マルチテナント SaaS | アプリ層で WHERE 漏れがあっても、DB 層で他テナントの行を物理的に見せない(Defense in Depth) |
| ユーザー別データ分離 | 同じテーブルに複数ユーザーのデータを混在させても、ユーザー間で漏洩しない |
| 管理者と一般ユーザーの区別 | ロールごとに見える行を変える |
| 監査要件への対応 | 「アプリのバグで他テナントが見えた」事故の最終防衛線 |
1.3 登場した背景
- PostgreSQL 9.5 (2016 年リリース) で正式導入
- それ以前は views や triggers で代替実装していたが、性能・保守性に難があった
- 商用 DB(Oracle VPD など)では先行していた機能を PostgreSQL も実装
2. RLS の仕組み
2.1 動作レイヤー
RLS は Rewriter で条件式を追加し、Planner が LEAKPROOF 制約を考慮して実行プランを決定する 2 段構えで動作する:
┌───────────────────────────────────┐
│ Application (アプリの SQL クエリ) │
└────────────────┬──────────────────┘
│
▼
┌───────────────────────────────────┐
│ Parser (構文解析) │
└────────────────┬──────────────────┘
│
▼
┌───────────────────────────────────┐
│ Rewriter (RLS 適用 ★ここ) │
│ - policy の USING/WITH CHECK を │
│ クエリの WHERE に自動追加 │
└────────────────┬──────────────────┘
│
▼
┌───────────────────────────────────┐
│ Planner (実行プラン作成) │
│ - LEAKPROOF 属性を考慮 │
└────────────────┬──────────────────┘
│
▼
┌───────────────────────────────────┐
│ Executor (実行) │
└───────────────────────────────────┘
ポイント:
- Rewriter が policy の
USING/WITH CHECKをクエリの条件式として注入する(書き換え本体) - Planner はその書き換え済みクエリに対して、LEAKPROOF 属性を含む安全性ルールを考慮しながら実行プランを選ぶ
- アプリのクエリは書き換えられた状態で planner に渡されるため、アプリ側のコードは RLS の存在を意識する必要がない(透過的)
- 「LEAKPROOF 制約で index が使われない」現象(8章)は、この Planner 側の判断が原因で発生する
2.2 クエリ書き換えの例
ポリシー定義:
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
アプリのクエリ:
SELECT * FROM orders WHERE status = 'paid';
PostgreSQL 内部での書き換え後:
SELECT * FROM orders
WHERE status = 'paid'
AND tenant_id = current_setting('app.tenant_id')::uuid; -- ← RLS が追加
EXPLAIN で確認すると、Filter 句に RLS 条件が現れる。
2.3 Security Barrier としての性質
RLS は security barrier として動作する。意味:
悪意あるユーザーが任意の関数を WHERE 句に混ぜても、その関数は RLS チェックの後でしか実行されない(保証)
これは攻撃防止のため。詳細は 8章 落とし穴 で扱う。
3. RLS の有効化方法
3.1 基本 (ENABLE)
-- テーブルで RLS を有効化
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- ポリシーを定義
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
3.2 強制 (FORCE)
通常、テーブル所有者は RLS をバイパスする。これを止めるのが FORCE:
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
| 設定 | 一般ユーザー | テーブル所有者 | SUPERUSER |
|---|---|---|---|
ENABLE のみ | RLS 適用 | バイパス | バイパス |
ENABLE + FORCE | RLS 適用 | RLS 適用 | バイパス |
マルチテナント SaaS のような厳格な環境では FORCE を推奨。
3.3 確認
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class
WHERE relname = 'orders';
-- relname | relrowsecurity | relforcerowsecurity
-- ---------+----------------+---------------------
-- orders | t | t
4. ポリシーの書き方
4.1 基本構文
CREATE POLICY policy_name ON table_name
[ AS { PERMISSIVE | RESTRICTIVE } ]
[ FOR { ALL | SELECT | INSERT | UPDATE | DELETE } ]
[ TO role_name [, ...] ]
[ USING (expression) ]
[ WITH CHECK (expression) ];
| 句 | 意味 |
|---|---|
USING | **SELECT / UPDATE / DELETE で「どの行が見えるか」**を定義 |
WITH CHECK | **INSERT / UPDATE で「どの行を書けるか」**を定義 |
FOR ALL | 全コマンドに適用 (デフォルト) |
TO role | 特定ロールにのみ適用 |
PERMISSIVE | 「OR で組み合わせ」(デフォルト) |
RESTRICTIVE | 「AND で組み合わせ」(複数ポリシーがある時の厳格化) |
4.2 例
-- 例 1: テナント分離 (読み書き両方)
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- 例 2: ロール別のポリシー
CREATE POLICY admin_full_access ON orders
FOR ALL
TO admin_role
USING (true);
CREATE POLICY user_self_only ON orders
FOR ALL
TO end_user_role
USING (user_id = current_setting('app.user_id')::uuid);
-- 例 3: 書き込み制限 (削除を禁じる)
CREATE POLICY no_delete ON orders
AS RESTRICTIVE
FOR DELETE
USING (false);
4.3 PERMISSIVE と RESTRICTIVE の合成
複数のポリシーがある場合:
- PERMISSIVE 同士: いずれかが true なら見える (OR)
- RESTRICTIVE 同士: すべて true なら見える (AND)
- 両方ある場合:
(PERMISSIVE の OR) AND (RESTRICTIVE の AND)
つまり PERMISSIVE は「許可リスト」、RESTRICTIVE は「禁止条件」として機能する。
5. RLS 適用時の挙動
5.1 EXPLAIN で見える挙動
RLS が効いている時、EXPLAIN プランは独特の形になる:
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
Result (cost=0.01..4743.46 rows=1000)
One-Time Filter: ((current_setting('app.tenant_id'::text))::uuid = '67e7eae6-...'::uuid)
-> Seq Scan on orders
Filter: (status = 'paid' AND tenant_id = '67e7eae6-...'::uuid)
読み方:
One-Time Filter... RLS のcurrent_settingを 1 回だけ評価 (定数化)Filter... RLS 条件と元のクエリ条件が AND で結合されているSeq Scan... LEAKPROOF 制約等により index が使えない場合に発生(8章)
5.2 SET / unset 時の動作
-- session 変数を設定
SET app.tenant_id = '67e7eae6-...';
SELECT * FROM orders;
-- → tenant_id = '67e7eae6-...' の行のみ返る
-- 未設定で呼ぶと?
RESET app.tenant_id;
SELECT * FROM orders;
-- → ERROR: unrecognized configuration parameter "app.tenant_id"
-- (RLS policy 内で current_setting() がエラー)
-- 第二引数 missing_ok=true なら NULL を返す
-- CREATE POLICY ... USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
-- → NULL = uuid は NULL → 全行除外
設計上、app.tenant_id を必ずセットする運用にするか、missing_ok=true で 0 件返すか、選ぶ必要がある。
5.3 ロール別の挙動
-- 一般ユーザー (RLS 適用)
SET ROLE app_user;
SELECT count(*) FROM orders; -- 自分のテナント分のみ
-- テーブル所有者
SET ROLE orders_owner;
SELECT count(*) FROM orders;
-- ENABLE のみ: 全行見える (バイパス)
-- ENABLE + FORCE: テナント分のみ
-- SUPERUSER は常にバイパス
SET ROLE postgres;
SELECT count(*) FROM orders; -- 全行見える
6. session 変数との連携(マルチテナントパターン)
6.1 典型的な実装
idp-server 等のマルチテナント SaaS で使う典型パターン:
-- 1. ポリシー定義
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
// 2. アプリ層: リクエスト処理時に SET
String tenantId = resolveTenantFromRequest(request);
connection.createStatement().execute(
"SET app.tenant_id = '" + tenantId + "'"
);
// 3. 通常のクエリ (テナント絞り込み不要)
PreparedStatement stmt = connection.prepareStatement(
"SELECT * FROM orders WHERE status = ?"
);
stmt.setString(1, "paid");
ResultSet rs = stmt.executeQuery();
// → DB が自動的に tenant_id で絞り込む
6.2 SET LOCAL の活用
トランザクション内で限定したい時:
BEGIN;
SET LOCAL app.tenant_id = '67e7eae6-...';
-- このトランザクション内のクエリのみ RLS が適用
COMMIT;
-- COMMIT 後は設定が消える (安全)
connection pool 環境では SET LOCAL が安全。なぜなら:
SET(global)だと、connection を返却した後も設定が残る- 別のリクエストが同じ connection を再利用すると、前のテナントの設定が引き継がれる
6.3 注意:session 変数の漏洩リスク
-- 一般ユーザーが現在の設定を覗ける
SELECT current_setting('app.tenant_id');
app.tenant_id 自体は秘密情報じゃないので問題ないが、機密値を session 変数に入れるのは避ける。
7. 他の機能との相互作用
7.1 SUPERUSER / BYPASSRLS / FORCE RLS の優先関係
| 接続ロールの属性 | テーブル設定 | RLS 適用? |
|---|---|---|
| SUPERUSER | 任意 | バイパス (RLS 適用されない) |
| BYPASSRLS = t | 任意 | バイパス |
| 通常ロール | ENABLE のみ | 適用 (ただし所有者はバイパス) |
| 通常ロール | ENABLE + FORCE | 適用 (所有者も適用) |
-- ロールに BYPASSRLS 属性を付与
ALTER ROLE batch_user BYPASSRLS;
-- 確認
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles;
7.2 View との関係
view 経由でテーブルにアクセスする場合:
CREATE VIEW v_orders AS SELECT * FROM orders;
-- view を SELECT する時
SELECT * FROM v_orders;
-- → view を作成したユーザーの権限で RLS が評価される (SECURITY DEFINER 的)
SECURITY INVOKER 属性をつけると、view を呼び出すユーザーの権限で評価される:
CREATE VIEW v_orders WITH (security_invoker = true) AS SELECT * FROM orders;
7.3 Index との関係
RLS 有効テーブルでも、ほとんどの index は通常通り使える。tenant_id = ?::uuid のような equality 検索(uuid_eq / texteq 等の leakproof な関数を使う条件)では、btree index が普通に選ばれる。
例外は、leakproof でない関数を含む条件式(JSONB の @> / ->> など)。この場合 planner は対応する index を選べず Seq Scan に倒れる。詳細は 8章 落とし穴と回避策 で扱う。
| 条件式の例 | 使われる関数 | leakproof | RLS 下で index 使用 |
|---|---|---|---|
tenant_id = '...'::uuid | uuid_eq | ✅ | ✅ 普通に使える |
email = '...' | texteq | ✅ | ✅ 普通に使える |
age > 18 | int4gt | ✅ | ✅ 普通に使える |
payload @> '...'::jsonb | jsonb_contains | ❌ | ❌ index 不使用、Seq Scan |
payload ->> 'key' = '...' | jsonb_object_field_text | ❌ | ❌ index 不使用、Seq Scan |
8. 落とし穴と回避策(重要)
8.1 LEAKPROOF 制約とは
PostgreSQL の関数には LEAKPROOF 属性がある:
| 属性 | 意味 |
|---|---|
LEAKPROOF | 引数の値を side channel(エラーメッセージ・実行時間・例外内容)から漏らさない |
NOT LEAKPROOF(デフォルト) | 上記の保証なし。安全側に倒すと想定 |
RLS 有効テーブルでは、planner は leakproof でない関数を RLS チェックより前に評価しない(攻撃防止のため)。
結果:
- 条件式に leakproof でない関数を含むと、その条件を index で先に評価できない(push down できない)
- 全行 Seq Scan された後に上位レイヤーで filter する羽目に
8.2 実例:jsonb 関数の罠
PostgreSQL の jsonb 関数群はデフォルトで全て non-leakproof:
SELECT proname, proleakproof FROM pg_proc
WHERE proname IN ('jsonb_contains', 'jsonb_object_field_text', 'texteq', 'uuid_eq');
-- proname | proleakproof
-- -------------------------+--------------
-- jsonb_contains | f ← @> 演算子の実装
-- jsonb_object_field_text | f ← ->> 演算子の実装
-- texteq | t ← text = text
-- uuid_eq | t ← uuid = uuid
つまり:
| クエリ | RLS 下での挙動 |
|---|---|
WHERE tenant_id = ?::uuid | uuid_eq は leakproof → index 使える |
WHERE payload @> '...'::jsonb | jsonb_contains は non-leakproof → GIN index 使えない |
WHERE payload ->> 'key' = '...' | jsonb_object_field_text は non-leakproof → expression index 使えない |
8.3 確認方法
-- 1. EXPLAIN で確認
EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM orders
WHERE payload @> '{"key": "value"}'::jsonb
AND tenant_id = ?::uuid;
-- 期待: Bitmap Index Scan on idx_payload (GIN)
-- 実際 (RLS下): Seq Scan + One-Time Filter
-- 2. pg_stat_user_indexes で「使われているかどうか」確認
SELECT indexrelname, idx_scan FROM pg_stat_user_indexes
WHERE relname = 'orders';
-- idx_scan = 0 が長期間続いている index は要注意 → RLS で殺されている可能性
8.4 回避策
| 案 | 内容 | リスク |
|---|---|---|
(A) ALTER FUNCTION ... LEAKPROOF | 組み込み関数を leakproof マーク | 関数のエラー経路等で情報漏洩しないかセキュリティレビュー要 |
(B) ALTER ROLE app BYPASSRLS | アプリロールを RLS バイパス | テナント分離の防御層を 1 段失う |
| (C) RLS policy 削除 | RLS そのものを使わない | アプリの WHERE 句のみが防御 |
| (D) Expression index | ((payload ->> 'key')) 形式の btree index | jsonb_object_field_text が leakproof でないため、これ単独では効かない((A) との併用が前提) |
| (E) 現状維持 | 何もしない | データ量に応じて線形に遅くなる |
LEAKPROOF 制約の実機検証および各回避策の影響比較については idp-server Issue #1550 のコメント群で詳細を扱っている。同 Issue は当初 @> 演算子への書き換え(GIN ops 互換性)を起点に始まったが、その過程で本章で扱う LEAKPROOF 制約が真の原因として明らかになった経緯がある。
8.5 LEAKPROOF マークの安全性評価ポイント
ALTER FUNCTION ... LEAKPROOF を導入する前に:
- 該当関数が データ依存でエラーを投げないか(投げると side channel になる)
- 該当関数の 実行時間がデータ依存しないか(タイミング攻撃の余地)
- アプリ側で エラーメッセージを sanitize しているか( 生エラーをユーザーに返していないか)
- アプリ用 DB ロールに CREATE FUNCTION 権限がないか(悪意ある leakproof 関数を作れないように)
jsonb_contains / jsonb_object_field_text は構造的にデータ依存エラーを投げない設計のため、アプリ経由アクセス限定の環境では LEAKPROOF マーク可能と判断できる。
8.6 マネージド DB 環境での設計指針
AWS RDS / Aurora / Google Cloud SQL / Azure Database for PostgreSQL などの マネージドサービスでは、jsonb_contains 等の組み込み関数の所有者が rdsadmin 等のシステムロールに固定されており、ユーザーは ALTER FUNCTION ... LEAKPROOF を実行できない(実行すると ERROR: must be owner of function jsonb_contains)。
このため、マネージド DB を採用する場合は (A) LEAKPROOF マーク案が使えないことを前提に、index 設計を最初から適切に行う必要がある。
設計時の判断軸
-
動的キー検索は本当に必要か — 多くのケースは固定キーで済む
- 固定なら expression index で対処可能
- 動的なら GIN index に頼ることになるが、RLS との相性問題が残る
-
JSONB を「検索対象」にするか「構造化データ」にするか
- 検索対象なら → 固定キーに絞った設計 + expression index
- 構造化データなら → JSONB のまま保持し検索しない(取得は id 経由)
-
テナント分離の防御層をどう設計するか
- 「RLS + アプリ層 WHERE」二重防御を維持 → BYPASSRLS 付与 + expression index(要 SUPERUSER)
- 「アプリ層のみで十分」と判断 → RLS policy 削除 or BYPASSRLS 付与
マネージド DB で取りうる戦略
| 戦略 | 内容 | 適用条件 |
|---|---|---|
| Expression index + 検索キー限定 | ((payload ->> 'fixed_key')) の btree index | 検索キーが事前に特定できる |
| BYPASSRLS 付与 + index 追加 | アプリロールに BYPASSRLS を付与(ALTER ROLE ... BYPASSRLS の実行可否はマネージド DB のロール権限設計に依存、事前検証必須) | テナント分離をアプリ層で完全保証できる |
| RLS policy 削除 | RLS そのものを使わない | テナント分離の二重防御を諦める判断 |
| Column 化 / 正規化 | JSONB を諦めて通常 column に | スキーマが安定している項目 |
| 検索エンジン外出し | Elasticsearch 等で別途検索 | 全文検索や複雑なクエリが必要な場合 |
設計アンチパターン
- ❌ マネージド DB 採用前提で
jsonb_contains @>を多用する設計 → LEAKPROOF マークが効かないため index が使えない - ❌ 動的キー検索を想定した JSONB スキーマ + RLS 強制 → どの index 戦略も限定的
- ❌ 「とりあえず JSONB に入れておけば後で検索できる」 → 検索パフォーマンスが線形に悪化
設計時の推奨フロー
[新規 JSONB column 導入時]
↓
検索対象にする? ──── No ──→ JSONB のまま保持、取得は id 経由
↓ Yes
検索キーは固定? ──── No ──→ 検索エンジン外出し or GIN + BYPASSRLS 検討
↓ Yes
expression index を migration に含める
↓
RLS の防御層を維持するか? ── No ──→ RLS policy 削除
↓ Yes
BYPASSRLS をアプリロールに付与(マネージド DB 制約を事前検証)