はじめに
マルチテナントのSaaSを書いていると、どこかで必ず AND tenant_id = $1 を書き忘れます。金曜の夕方に足したエンドポイント、リクエストコンテキストの外で動くバッチ、CSVエクスポート、検索インデックスの再構築。テナント分離が「全てのクエリが正しく書かれていること」だけで守られている状態は、設計というより願望に近いものです。
PostgreSQLの行レベルセキュリティ(RLS)は、その判断をアプリケーションからデータベースへ移す機能です。本記事では、コピーして動かせる最小構成と、実際に動かして初めて分かった2つの落とし穴をまとめます。必要なところだけ拾ってもらえれば十分です。
検証環境: PostgreSQL 16.13(postgres:16-alpine、Docker)、2026年9月24日実行。本文中の出力はすべてこの環境の実行結果です。
全体像
アプリのクエリ: SELECT * FROM invoice WHERE amount > 1000
↓
PostgreSQLが付加: ... AND (ポリシー式) ← アプリのWHEREより前に評価される
↓
自テナントの行だけが返る
ポリシー式はユーザーのクエリ由来の条件より先に評価されます。つまり、テナント条件を書き忘れた開発者がいても、返ってくるのは自テナントの行だけです。
1. ロールを分ける
最初にして、いちばん飛ばされやすい手順です。アプリがテーブル所有者で接続していると、後述の FORCE を付けない限りポリシーは効きません。
CREATE ROLE app_owner LOGIN PASSWORD 'xxx'; -- マイグレーション用
CREATE ROLE app_user LOGIN PASSWORD 'xxx'; -- アプリの接続用
CREATE DATABASE shop OWNER app_owner;
2. テーブルとポリシー
CREATE TABLE invoice (
id bigserial PRIMARY KEY,
tenant_id uuid NOT NULL,
amount integer NOT NULL,
issued_at timestamptz NOT NULL DEFAULT now()
);
GRANT SELECT, INSERT, UPDATE, DELETE ON invoice TO app_user;
GRANT USAGE, SELECT ON SEQUENCE invoice_id_seq TO app_user;
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoice
FOR ALL
TO app_user
USING (tenant_id = nullif(current_setting('app.current_tenant', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.current_tenant', true), '')::uuid);
CREATE INDEX invoice_tenant_issued_idx ON invoice (tenant_id, issued_at DESC);
USING と WITH CHECK は別物です。
| 句 | 制御するもの | 効くコマンド |
|---|---|---|
USING |
既存のどの行が見えるか | SELECT / UPDATE / DELETE |
WITH CHECK |
どの行を書き込めるか | INSERT / UPDATE |
WITH CHECK を書かないと、自社の行しか読めないのに他社の tenant_id を持つ行は書ける、という逆向きの破綻が起きます。読み取り側の漏洩より発見が遅れます。
nullif(..., '') を挟んでいる理由は、後述の落とし穴1です。
3. テナントコンテキストはトランザクション単位で
BEGIN;
SELECT set_config('app.current_tenant', $1, true); -- 第3引数 true = トランザクションスコープ
SELECT id, amount FROM invoice ORDER BY issued_at DESC LIMIT 50;
COMMIT;
$1 に入れる値は、サーバー側で検証済みのセッションやJWTから取ります。クライアントが差し替えられるリクエストパラメータから取ってはいけません。
4. 分離できているか確認する
ポリシーを書いただけでは、効いているかどうか分かりません。必ず app_user で接続して確かめます。以下は実際の出力です。
自テナントの行だけが見えるか
BEGIN;
SELECT set_config('app.current_tenant','11111111-1111-1111-1111-111111111111', true);
SELECT id, tenant_id, amount FROM invoice ORDER BY id;
COMMIT;
id | tenant_id | amount
----+--------------------------------------+--------
2 | 11111111-1111-1111-1111-111111111111 | 1000
(1 row)
テーブルには2テナント分の行がありますが、返るのは1行だけです。
他テナントの行を更新できないか
UPDATE invoice SET amount = 9999 WHERE tenant_id = '22222222-2222-2222-2222-222222222222';
UPDATE 0
エラーではなく UPDATE 0 です。他テナントの行はそもそも見えていないため、更新対象が0件になります。
他テナントの行を挿入できないか
INSERT INTO invoice (tenant_id, amount) VALUES ('22222222-2222-2222-2222-222222222222', 500);
ERROR: new row violates row-level security policy for table "invoice"
これは WITH CHECK が効いている証拠です。ここでエラーが出ない場合、WITH CHECK を書き忘れています。
インデックスが使われているか
EXPLAIN (COSTS OFF) SELECT * FROM invoice ORDER BY issued_at DESC LIMIT 50;
Limit
-> Sort
Sort Key: issued_at DESC
-> Bitmap Heap Scan on invoice
Recheck Cond: (tenant_id = (NULLIF(current_setting('app.current_tenant'::text, true), ''::text))::uuid)
-> Bitmap Index Scan on invoice_tenant_issued_idx
Index Cond: (tenant_id = ...)
ポリシーの述語が Index Cond に入っていれば設計どおりです。ここが Filter に落ちていたら、インデックスの先頭列が tenant_id になっていません。
テーブルの取りこぼしがないか
SELECT c.relname,
c.relrowsecurity AS enabled,
c.relforcerowsecurity AS forced,
count(p.polname) AS policies
FROM pg_class c
LEFT JOIN pg_policy p ON p.polrelid = c.oid
WHERE c.relkind = 'r'
AND c.relnamespace = 'public'::regnamespace
GROUP BY 1, 2, 3
ORDER BY 1;
テナントデータを持つのに enabled = f のテーブルがあれば、それはトラフィックを待っているだけのインシデントです。この確認はCIに入れておくと、テーブル追加時の付け忘れを拾えます。
落とし穴1.コミット後、設定値はNULLではなく空文字に戻る
多くの解説では「コンテキスト未設定なら current_setting(..., true) がNULLを返すので、どの行にも一致しない(fail closed)」と書かれています。これはそのセッションで一度も設定していない場合の話です。
実際に試すと、トランザクションスコープで一度設定したあとは、コミット後に値が空文字に戻ります。
BEGIN;
SELECT set_config('app.current_tenant','11111111-1111-1111-1111-111111111111', true);
COMMIT;
SELECT current_setting('app.current_tenant', true) IS NULL AS is_null,
quote_literal(current_setting('app.current_tenant', true)) AS val;
is_null | val
---------+-----
f | ''
この状態で ''::uuid を評価しようとすると、行が0件になるのではなく例外になります。
ERROR: invalid input syntax for type uuid: ""
情報が漏れるわけではないので危険度は高くありませんが、接続を使い回すアプリでは、コンテキスト設定を忘れたクエリが「0件」ではなく「500エラー」として表面化します。原因を追う側からすると、まったく別の障害に見えます。
対策は、ポリシー式で nullif を挟むことです。
USING (tenant_id = nullif(current_setting('app.current_tenant', true), '')::uuid)
これで、未設定でも空文字でも同じく0件になります。
落とし穴2.FORCE を付けると、所有者ロールで初期データが入らなくなる
手順どおりに FORCE ROW LEVEL SECURITY まで設定したあと、所有者ロールでシードデータを投入しようとすると失敗します。
ERROR: new row violates row-level security policy for table "invoice"
ポリシーを TO app_user で定義しているため、app_owner に適用されるポリシーが1つも存在せず、既定の全拒否になるからです。これは設定ミスではなく、FORCE が意図どおりに働いている状態です。
対策は次のいずれかです。
- シードデータはRLSを有効化する前に投入する
- スーパーユーザー、または
BYPASSRLS属性を持つ保守用ロールで投入する - マイグレーション専用のロールを
TOに含めたポリシーを別途定義する
どれを選ぶかは運用次第ですが、「マイグレーションが本番だけ落ちる」形で気付くのがいちばん困るので、ステージングでも同じロール構成にしておくことをおすすめします。
そのほか、静かに分離を壊すもの
コネクションプーラー配下での SET。SET LOCAL や set_config(..., true) ではなく素の SET を使うと、値がトランザクションを越えて接続に残ります。手元でも確認できます。
BEGIN;
SET app.current_tenant = '11111111-1111-1111-1111-111111111111';
COMMIT;
SELECT current_setting('app.current_tenant', true); -- コミット後も値が残る
トランザクションモードのプーラーは、その接続を次のリクエストへ即座に渡します。症状は同時実行時だけに出るので、ステージングでの再現はほぼ不可能です。
ビューが所有者権限で走る。特権ロールが所有するビューはポリシーを迂回する経路になります。PostgreSQL 15以降では WITH (security_invoker = true) を指定します。
グローバルな一意制約。email に全体で UNIQUE を張ると、重複エラーを受け取ったテナントは「他社がそのアドレスを使っている」事実を知ることができます。UNIQUE (tenant_id, email) にします。
この方法で守れないこと
RLSは床であって天井ではありません。運用中の共有テーブル方式のシステムで踏んだ範囲でも、次は別の統制が必要でした。
- キャッシュ、バックグラウンドジョブ、検索インデックス、オブジェクトストレージのパス。SQLの外側はRLSの管轄外です。キーにテナントを含めているか、1つずつ実地で確認するしかありません。
- 論理分離であって物理分離ではない。データを独立したデータベースやリージョンに置くことを契約条件とされる場合、RLSでは要件を満たせません。
- テナント数が少なく各社が大きい構成。5社から10社程度なら、データベースを分けたほうが単純で、個別リストアも監査説明も楽です。
- パッチ運用が前提。RLSはエンジン側のコードなので、CVEが出ます。「RLSを有効にしています」という説明には、バージョン番号が伴って初めて意味があります。
- 本記事の挙動はPostgreSQL 16.13で確認したものです。15以前では
security_invokerが使えず、メジャーバージョンによって細部が変わる可能性があります。
最後に
RLSを入れる作業自体は30分ほどで終わります。時間がかかるのは、その後の「本当に効いているか」の確認です。4章の確認クエリを先にCIへ入れてから、ポリシーを書き始めるくらいでちょうどよいと思います。
参考文献
- PostgreSQL Documentation: Row Security Policies (§5.9)
- PostgreSQL Documentation: CREATE POLICY
- AWS Prescriptive Guidance: Row-level security recommendations for multi-tenant PostgreSQL
- Supabase Docs: RLS performance and best practices
- PostgreSQL Security: CVE-2024-10976
- PostgreSQL Security: CVE-2025-8713