PostgreSQL Row-Level Security — マルチテナント分離を DB で強制する
マルチテナント SaaS の最悪の事故は、性能ではなく他社のデータが見えることです。原因はたいてい 1 行です。どこかのクエリで WHERE tenant_id = ? が抜けていたこと。コードレビューと規律で防ぐのには限界があります。クエリは数百個あり、新しいメンバーは入り続け、1 回の漏れがそのまま事故です。Row-Level Security(RLS)は、このフィルタをアプリケーションの規律から DB の強制へ移します。 基礎第 9 回で「整合性は DB 層で」と言った原則の、アクセス制御版です。
基本形: ポリシー 1 つから始めます #
-- 1) テーブルで RLS を有効にします
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 2) ポリシー: セッションに設定されたテナントの行だけ見え、作れます
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);これでこのテーブルのすべての SELECT・UPDATE・DELETE には USING の条件が、INSERT・UPDATE の新しい値には WITH CHECK の条件が自動的に適用されます。 アプリケーションのクエリで WHERE が抜けても、他のテナントの行は存在しないかのように見え、他人のテナントで行を作ろうとする試みはエラーになります。USING は「何が見えるか」、WITH CHECK は「何を書けるか」の区分です。
2 つの前提を見落とすと、RLS は飾りになります。1 つ目、テーブルの所有者と superuser には、既定ではポリシーが適用されません。 アプリケーションが所有者アカウントで接続すると RLS は丸ごと無視されるので、基礎第 9 回で作ったような非所有者の最小権限ロールで接続する構造が前提条件です(所有者にも強制するなら FORCE ROW LEVEL SECURITY)。2 つ目、ポリシーが 1 つもない RLS 有効テーブルは、全部拒否が既定値です。「有効にしたら何も見えない」は正常な動作です。
テナントの指定: current_setting パターンとコネクションプール #
ポリシーが参照する current_setting('app.tenant_id') はセッション(またはトランザクション)の変数です。リクエスト処理の最初で、認証結果のテナントを植えるのがアプリケーションの仕事です。
BEGIN;
SET LOCAL app.tenant_id = '42'; -- このトランザクションの間だけ有効
-- ... このリクエストのすべてのクエリ ...
COMMIT;核心は SET ではなく SET LOCAL です。コネクションプールを使う以上、コネクションはリクエストたちが使い回す共有物で、セッション単位の SET は次のリクエストに他人のテナント設定が残る事故につながります。SET LOCAL はトランザクションの終了とともに消えるので、PgBouncer の transaction モードとも相性が合います。ほとんどのフレームワークで「リクエスト開始のミドルウェアでトランザクションを開いて SET LOCAL」が標準の形です。
性能: 条件はシンプルに、インデックスは必須に #
RLS の実体は「すべてのクエリに自動で付く WHERE 条件」なので、性能の原則も一般のクエリと同じです。tenant_id にインデックスがあれば、ポリシーのフィルタは平凡なインデックス条件で、オーバーヘッドはごくわずかです。 逆にインデックスがなければ、すべてのクエリがテナントフィルタのフルスキャンになります。複合インデックスなら tenant_id を先頭カラムに置くのが、インデックスの基本ルールと重なるところです。そしてポリシーの条件の中でサブクエリや他のテーブルの参照をする設計は、すべてのクエリにそのコストを掛け算するものなので、ポリシーは「現在の行のカラム = セッション変数」レベルの単純な比較に保つのが定石です。確認はいつもどおり EXPLAIN で、計画にポリシーの条件がフィルタとして現れます。
RLS が正解ではない場合 #
- アクセスルールが複雑なビジネスロジックのとき(役割・状態・所有権が絡む文書共有など)。ポリシーがロジックの塊になると、デバッグと性能の両方が苦しくなります。RLS は tenant_id のような単純な境界に使い、複雑な認可はアプリケーション層が正解です。
- テナント数が少なく分離の要求が極端なときは、スキーマ分離・DB 分離が候補です。RLS は「共有テーブル + 行の分離」という中間地帯の答えです。
- 分析・バッチのように全テナントをなめる処理には、ポリシーを迂回する別のロール(BYPASSRLS)を明示的に置くほうが良いです。ポリシーに例外の分岐を入れて通常の処理まで複雑にするよりきれいです。
まとめ #
- RLS は「WHERE tenant_id の漏れ」事故を、コードの規律ではなく DB の強制で防ぎます。USING は可視性、WITH CHECK は書き込みの検証です。
- 所有者・superuser には既定で適用されません。非所有者の最小権限ロールでの接続が前提条件です。
- テナントの指定はトランザクション + SET LOCAL が標準です。コネクションプール環境でのセッション SET は汚染事故の種です。
- 性能の条件は tenant_id のインデックス(複合なら先頭カラム)とシンプルなポリシー条件です。これだけ守ればオーバーヘッドはごくわずかです。
- 複雑な認可ロジック・極端な分離要求・全テナントのバッチは RLS の外(アプリケーション層・スキーマ分離・BYPASSRLS ロール)が正解です。