Postgres の行レベルセキュリティ (RLS) は細粒度のアクセス制御を実装する強力な機能ですが、性能を破壊したりセキュリティを完全に迂回したりする微妙な罠が散りばめられています。本ガイドは主要な地雷を実用的な修正と現実的な例で網羅します。
性能の地雷
1. LEAKPROOF 関数による性能崩壊
地雷: RLS ポリシー内で LEAKPROOF でない関数を使うと、索引が使えず、壊滅的な性能劣化を招く。
なぜ起きるか: Postgres は RLS フィルタを先に適用し、その後で LEAKPROOF でない関数を評価するため、クエリプランナが索引を使えない。
問題例:
-- title に索引があってもフルテーブルスキャンになる
CREATE POLICY user_documents ON documents
FOR SELECT
USING (owner_id = current_user_id() AND title ILIKE '%search%');修正:
-- LEAKPROOF 関数を使うか、複雑なロジックをポリシーの外に移す
CREATE OR REPLACE FUNCTION safe_ilike(text, text)
RETURNS boolean
LANGUAGE sql
LEAKPROOF
AS $$ SELECT $1 ILIKE $2 $$;
CREATE POLICY user_documents ON documents
FOR SELECT
USING (owner_id = current_user_id() AND safe_ilike(title, '%search%'));性能インパクト: 大きなテーブルでは、クエリがミリ秒から数時間にまで変わり得る。
2. 複雑なポリシーによる性能死
地雷: サブクエリを伴う複雑な RLS ポリシーは行ごとに実行され、クエリコストが指数的に増える。
悪い例:
CREATE POLICY complex_access ON orders
USING (
EXISTS (
SELECT 1 FROM user_permissions up
JOIN departments d ON up.dept_id = d.id
WHERE up.user_id = current_user_id()
AND d.region = orders.region
)
);より良いアプローチ:
-- 複雑さを LEAKPROOF 関数に移す
CREATE OR REPLACE FUNCTION user_has_region_access(region_name text)
RETURNS boolean
LANGUAGE sql
LEAKPROOF
STABLE
AS $$
SELECT EXISTS (
SELECT 1 FROM user_permissions up
JOIN departments d ON up.dept_id = d.id
WHERE up.user_id = current_user_id()
AND d.region = region_name
);
$$;
CREATE POLICY simple_access ON orders
USING (user_has_region_access(region));3. ポリシーカラムの索引欠落
地雷: RLS ポリシーで使うカラムに索引を張り忘れると、シーケンシャルスキャンを強いる。
必須索引:
-- ポリシーで使うカラムには必ず索引を張る
CREATE INDEX ON orders(tenant_id);
CREATE INDEX ON orders(owner_id);
CREATE INDEX ON orders(tenant_id, owner_id); -- AND 条件には複合索引セキュリティの地雷
4. BYPASSRLS スーパーユーザーの罠
地雷: スーパーユーザーとテーブルオーナーは既定で RLS を迂回し、テスト時にあたかも RLS が効いているように見せる。
なぜ危険か: スーパーユーザーアカウントでテストすると、実際には無視されているのに RLS が機能しているように見える。
修正:
-- テーブルオーナーに対しても RLS を強制
ALTER TABLE sensitive_data ENABLE ROW LEVEL SECURITY;
ALTER TABLE sensitive_data FORCE ROW LEVEL SECURITY;
-- アプリケーションロールに BYPASSRLS を絶対に付与しない
-- 専用の非スーパーユーザーアカウントでテストするテストパターン:
-- 適切なテストユーザーを作る
CREATE ROLE test_user;
GRANT app_user TO test_user;
-- 実アプリのユーザーとしてテスト
SET ROLE test_user;
SET app.tenant_id = 'test-tenant-id';
-- ここでテストを実行
RESET ROLE;5. SECURITY DEFINER のビューによる迂回
地雷: ビューは既定で SECURITY DEFINER となり、作成者の権限で動き、RLS を迂回する。
危険な例:
-- スーパーユーザーが作成 — すべての RLS ポリシーを迂回!
CREATE VIEW all_patient_data AS
SELECT * FROM patients;安全なアプローチ:
-- Postgres 15+: SECURITY INVOKER を使う
CREATE VIEW patient_data
WITH (security_invoker = true)
AS SELECT * FROM patients;
-- 古いバージョン: SECURITY DEFINER 内で明示的に RLS を確認
CREATE OR REPLACE FUNCTION get_patient_data()
RETURNS TABLE(id uuid, name text, doctor_id uuid)
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
BEGIN
-- SECURITY DEFINER 関数内で RLS を明示的にチェック
IF NOT row_security_active('patients') THEN
RAISE EXCEPTION 'Row security must be active';
END IF;
RETURN QUERY SELECT p.id, p.name, p.doctor_id FROM patients p;
END;
$$ LANGUAGE plpgsql;6. タイミングサイドチャネル攻撃
地雷: クエリ実行時間が制限データの情報を漏らし、洗練された攻撃を可能にする。
攻撃シナリオ: マルチテナントの医療データベースで、攻撃者がクエリ時間を計測し、他テナントのデータに特定症状の患者が存在するかを推測する。
技術的詳細:
- RLS のポリシー強制が計測可能なタイミング差を生む
- 攻撃者は秘密のカーディナリティ情報を推測できる
- クラウド環境のネットワークレイテンシ越しでも成立する
脆弱なクエリの例:
-- タイミングが、禁じられた患者の存在を漏らす
SELECT COUNT(*) FROM patients
WHERE condition = 'rare_disease'
AND tenant_id = current_setting('app.tenant_id')::uuid;緩和策:
- データ独立 (data-oblivious) なクエリパターンを使う (性能コストあり)
- タイミングを均すための人工的な遅延を入れる
- 信用できないユーザーのクエリ複雑度を制限する
- 不審なタイミングベースのクエリパターンを監視する
研究メモ: この攻撃は学術研究で実証されており、実運用のクラウド環境でも成立する。
7. CVE-2019-10130: 統計情報の漏洩
地雷: CVE-2019-10130。Postgres のクエリプランナ統計が、RLS で保護された行のサンプリングデータを漏らし得た。
技術的詳細:
- クエリプランナはカラムデータをサンプリングして統計を収集する
- ユーザーが演算子を細工し、禁じられたデータを含む統計を読み取れた
- 2019 年 5 月のパッチ前の Postgres 9.5–11 が影響を受けた
ステータス: Postgres 9.5.17、9.6.13、10.8、11.3 で修正済み。
教訓: Postgres を最新に保つこと、内部メカニズムでさえデータを漏らし得ることを忘れないこと。
実装の地雷
8. FORCE ROW LEVEL SECURITY を忘れる
地雷: FORCE 無しで RLS を有効化すると、テーブルオーナーがポリシーを迂回できる。
問題:
-- テーブルオーナーは依然としてすべてを見られる!
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;解決:
-- オーナーを含めすべてに RLS を強制
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;9. USING と WITH CHECK の取り違え
地雷: USING は SELECT/UPDATE/DELETE で既存行をフィルタし、WITH CHECK は INSERT/UPDATE で新規・変更行を検証する。
危険な例:
-- ユーザーが見えないデータを INSERT できてしまう!
CREATE POLICY tenant_data ON orders
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::uuid);正しいアプローチ:
CREATE POLICY tenant_isolation ON orders
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);10. コネクションプールでのコンテキスト消失
地雷: コネクションプール下では current_user は共有 DB ロールで、テナント隔離には使えない。
問題:
-- PgBouncer 下では役に立たない — すべての接続が同一ユーザー
CREATE POLICY user_data ON orders
USING (owner_id = current_user);解決:
-- アプリ制御のセッション変数を使う
-- アプリがトランザクションごとに設定:
SET app.user_id = 'user-uuid';
SET app.tenant_id = 'tenant-uuid';
-- ポリシーはセッションから読む:
CREATE POLICY tenant_isolation ON orders
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);セキュリティ強化:
-- クライアントが直接 app.* を設定するのを防ぐ
REVOKE ALL ON SCHEMA pg_catalog FROM app_user;
-- 信頼できるサーバーコードだけが設定できるようにする11. RLS 下での外部キー失敗
地雷: 親行への SELECT が RLS でブロックされるため、子テーブルへの INSERT で FK チェックが失敗する。
失敗例:
-- 顧客が存在しても、この INSERT は失敗する
INSERT INTO orders (customer_id, tenant_id)
VALUES ('existing-customer-id', 'my-tenant');
-- ERROR: insert or update on table "orders" violates foreign key constraint解決:
-- 親テーブルに FK チェック用の SELECT ポリシーが必要
CREATE POLICY customer_fk_visibility ON customers
FOR SELECT
USING (tenant_id = current_setting('app.tenant_id')::uuid);12. ユニーク制約によるテナント横断漏洩
地雷: グローバルなユニーク制約は、テナントをまたいだデータ存在を露呈する。
問題:
-- email がどのテナントにあっても存在を露呈する
CREATE UNIQUE INDEX users_email_unique ON users(email);
-- 他テナントでも「duplicate key」で INSERT が失敗する解決:
-- ユニーク性をテナントにスコープする
CREATE UNIQUE INDEX users_email_per_tenant
ON users(tenant_id, lower(email));13. 静かな失敗
地雷: RLS の失敗は静か — エラーや警告無しに操作が失敗する。
例: 100 行を変更するはずの UPDATE が、RLS ポリシーのせいで静かに 0 行影響にとどまる。
デバッグ方法:
-- 一時的に RLS を無効化してテスト
SET row_security = off;
-- クエリを実行して RLS が原因か確かめる
SET row_security = on;
-- またはポリシーが有効か確認
SELECT row_security_active('table_name');14. カラムレベルセキュリティの隙間
地雷: RLS は行をフィルタするが、カラムはフィルタしない。許可された行内では機微カラムは見える。
問題:
-- ユーザーは自分のレコードの SSN を見られる
SELECT * FROM users WHERE tenant_id = current_setting('app.tenant_id')::uuid;解決:
-- 選択肢 1: カラム権限
REVOKE SELECT (ssn, salary) ON users FROM app_user;
-- 選択肢 2: セキュアなビュー
CREATE VIEW users_safe AS
SELECT id, name, email,
CASE WHEN has_role('hr_role')
THEN ssn
ELSE 'XXX-XX-' || right(ssn, 4)
END as ssn_masked
FROM users;15. マテリアライズドビューとバックグラウンドジョブ
地雷: マテリアライズドビューにコピーされたり、ジョブでエクスポートされたデータは、ソーステーブルのポリシーで自動的に保護されない。
問題:
-- マテリアライズドビューが RLS を迂回する
CREATE MATERIALIZED VIEW order_summary AS
SELECT * FROM orders; -- 全テナントのデータを含む!
-- バックグラウンドジョブが全部エクスポートする
COPY (SELECT * FROM orders) TO '/tmp/backup.csv';解決:
-- マテリアライズドビュー内でフィルタする
CREATE MATERIALIZED VIEW tenant_order_summary AS
SELECT tenant_id, COUNT(*), SUM(amount)
FROM orders
GROUP BY tenant_id;
-- RLS を意識したエクスポートを使う
COPY (
SELECT * FROM orders
WHERE tenant_id = 'specific-tenant'
) TO '/tmp/tenant_backup.csv';16. 複数ポリシーの混乱
地雷: 複数の permissive ポリシーは OR で合成され、1 つの広いポリシーが厳しいポリシーを上書きし得る。
問題:
-- これらは OR で合成 — どちらかが真ならアクセスが通る
CREATE POLICY user_own_data ON orders
USING (owner_id = current_user_id());
CREATE POLICY admin_all_data ON orders
USING (has_role('admin')); -- まずい、広すぎ!解決:
-- 選択肢 1: restrictive ポリシーを使う (AND で合成)
CREATE POLICY tenant_restriction ON orders
AS RESTRICTIVE
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- 選択肢 2: ロジックを 1 つのポリシーに統合
CREATE POLICY combined_access ON orders
USING (
tenant_id = current_setting('app.tenant_id')::uuid
AND (
owner_id = current_user_id()
OR has_role('tenant_admin')
)
);要点
- RLS はセキュリティの銀の弾丸ではない — 複数の経路で迂回され得る
- 性能影響は深刻 — ポリシーカラムには必ず索引を、ポリシーはシンプルに保つ
- テストの方法論が決定的 — スーパーユーザーアカウントでテストしない
- 静かな失敗がデバッグを苦しめる — ポリシーは警告無く失敗する
- コンテキスト管理が要 —
current_userではなく安全なセッション変数を使う - 多層防御 — RLS をカラム権限、セキュアなビュー、アプリ層の統制と組み合わせる
RLS は正しく実装すれば強力ですが、これらの地雷に注意しないと、壊滅的なセキュリティと性能の失敗を招きます。