0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PostgreSQL行レベルセキュリティでマルチテナントのデータ分離を実装する

0
Posted at

はじめに

マルチテナントの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へ入れてから、ポリシーを書き始めるくらいでちょうどよいと思います。

参考文献

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?