Claude Media
Claude Codeでマルチテナントのテナント分離漏れを洗う — tenant_idとRLSの検証

Claude Codeでマルチテナントのテナント分離漏れを洗う — tenant_idとRLSの検証

tenant_idの条件が抜けたクエリをサブエージェントで洗い出し、PostgreSQLのRLSが効いているかをカタログと自動テストで確かめる手順です。

マルチテナントSaaSで怖いのは、別の顧客のデータが画面に出る事故です。原因の多くは、クエリのWHERE句から tenant_id が一つ抜けたという地味なミスです。

この記事では、Claude Codeを使って分離漏れを二つの層で洗います。

  • アプリのコード側: tenant_id の条件が無いクエリを、読み取り専用のサブエージェントに横断検索させる
  • データベース側: PostgreSQLの行レベルセキュリティ(RLS)が本当に効いているかを、カタログと自動テストで確かめる

Supabase固有のポリシー設計は扱いません。自社で運用するPostgreSQLの汎用的な検証に絞ります。

テナント分離は「コード」と「DB」の二重で確かめる

アプリ側でWHERE句に tenant_id を入れる方式は、書き忘れた一箇所で破れます。RLSはその保険になります。ただしRLSも、設定を誤ると何も守りません。

そこで確かめる対象を二つに分けます。

分離の層

洗う対象は2層

  • アプリのクエリ

    SELECT / UPDATE / DELETEのうち、テナント所属のテーブルを tenant_id 条件なしで触っているもの。生SQLとORMの両方が対象です。

  • DBのポリシー

    RLSが有効か、テーブル所有者や特権ロールで素通りしていないか、別テナントの行が実際に見えないか。

コード側の洗い出しは文章を読む作業なのでClaude Codeが得意です。DB側の検証は、読むだけでなく実際にSQLを流して結果を見る作業です。ここは自動テストに落とします。

条件漏れを探す読み取り専用サブエージェント

サブエージェントは .claude/agents/ にMarkdownファイルとして置きます。frontmatterで設定し、本文がシステムプロンプトになります。必須なのは name と description だけです。

分離漏れの調査では、コードを書き換えさせません。tools で読み取り系に絞ります。

---
name: tenant-scope-auditor
description: テナント所属テーブルへのクエリで tenant_id 条件が無い箇所を洗い出す。マルチテナントの分離レビューで使う。
tools: Read, Grep, Glob
model: inherit
---
 
あなたはマルチテナントSaaSの分離レビュー担当です。
コードは変更しません。
 
手順:
1. db/schema を読み、tenant_id 列を持つテーブルの一覧を作る
2. そのテーブルを参照する SELECT / UPDATE / DELETE / JOIN を
   生SQLとORM呼び出しの両方から Grep で集める
3. WHERE または JOIN 条件に tenant_id が無いものを抜き出す
4. 各件を「ファイル:行 / 対象テーブル / 修正の方向」の表で報告する
 
テナント横断が意図されたバッチや管理画面は、
「意図的な横断の可能性あり」と分けて書き、断定しない。

tools を省くとメインの会話が使えるツールをすべて引き継ぐので、読み取り専用にしたいときは明示します。Read, Grep, Glob だけなら、ファイルを編集したりコマンドを実行したりはできません。

サブエージェントの出力が長くなる調査は、メインの会話に流し込むとコンテキストを食います。サブエージェントに任せると、詳しい探索はサブエージェント側に留まり、要約だけが戻ります。数百ファイルを横断する今回の作業に向いた形です。

呼び出し方と読み方

Claude Codeに次のように頼みます。

tenant-scope-auditor サブエージェントで、
src/ 配下の全クエリから tenant_id 条件が無いものを洗い出して

返ってくる表は、次のように分類して読むと整理しやすくなります。

分類例次の動き
条件の書き忘れ例一覧APIのWHEREに tenant_id が無い次の動き修正対象。修正後にテストを足す
JOIN先の条件漏れ例親は絞っているが子テーブルを無条件にJOIN次の動きJOINのON句に tenant_id を足す
意図的な横断例夜間バッチ、運営の管理画面次の動き実行ロールとRLSの扱いを次の節で確かめる
判断不能例動的にSQLを組み立てる箇所次の動き人が読む。サブエージェントの推測に頼らない

表の「意図的な横断」を、一覧から勝手に除外させないのがコツです。横断が許されているコードこそ、RLSを迂回する特権ロールで動いている可能性が高く、次の検証の対象になります。

RLSが効いているかをカタログで確かめる

RLSは、テーブルでRLSを有効にしただけでは意図どおりになりません。PostgreSQLの仕様を押さえます。

  • RLSを有効にしたテーブルにポリシーが一つも無いと、既定で全行が拒否される
  • スーパーユーザーと BYPASSRLS 属性を持つロールは、常にRLSを迂回する
  • テーブルの所有者も通常は迂回する。所有者にも適用するには ALTER TABLE ... FORCE ROW LEVEL SECURITY を使う
  • USING だけのポリシーは、同じ式の WITH CHECK を暗黙に持つ。他テナントの行をINSERTやUPDATEで作ることも防げる
  • TRUNCATE と REFERENCES のようなテーブル全体の操作は、RLSの対象外

実務で最初に踏むのは所有者の素通りです。マイグレーションを実行したロールがテーブルを所有し、アプリも同じロールで接続していると、ポリシーがあっても効きません。

設定の状態をクエリで一覧にする

テーブルごとのRLS状態は pg_class の二つの列で分かります。relrowsecurity が有効、relforcerowsecurity が所有者にも適用かどうかです。ポリシーの中身は pg_policies ビューで読めます。

-- RLSの有効 / 所有者への適用状態
SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
ORDER BY c.relname;
 
-- ポリシーの式(qual が USING、with_check が WITH CHECK)
SELECT tablename, policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, policyname;
 
-- アプリが接続するロールが特権を持っていないか
SELECT rolname, rolsuper, rolbypassrls
FROM pg_roles
WHERE rolname = 'app_user';

この出力をClaude Codeに渡し、次の観点で読ませます。

  1. tenant_id を持つのに relrowsecurity がfalseのテーブルは無いか
  2. relrowsecurity がtrueでも pg_policies に行が無いテーブル(全行拒否になっている)は無いか
  3. qual に tenant_id を使っていないポリシーは無いか
  4. cmd が SELECT だけで、UPDATEとDELETEに対応するポリシーが無いテーブルは無いか
  5. アプリ用ロールが rolsuper または rolbypassrls になっていないか

カタログのクエリ自体は、読み取り専用のロールで流します。読み込みだけの調査なら、サブエージェントの tools に Bash を足したうえで、書き込み系のSQLを PreToolUse フックで止める構成が使えます。INSERT や UPDATE を含むコマンドをexit code 2で拒否する検証スクリプトは、サブエージェントの公式例にもあります。

「別テナントが見えない」を自動テストにする

カタログの設定が正しく見えても、実際に別テナントの行が見えないことは別問題です。テストで確かめます。

動作確認は、SET ROLE で別ロールになって結果を見るのが定番です。アプリ用ロールに切り替え、テナントAとして接続してテナントBの行を狙います。

テーブルとポリシーの例

アプリが接続ごとに app.tenant_id という設定値へテナントIDを入れる設計を例にします。設計は一例で、自社の方式に合わせて読み替えてください。

CREATE TABLE invoices (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tenant_id uuid NOT NULL,
  amount    integer NOT NULL
);
 
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

検証用のSQL

テストデータはテナントAとBに一行ずつ入れておきます。以下を、トランザクション内でアプリ用ロールとして実行します。

BEGIN;
SET LOCAL ROLE app_user;
SELECT set_config('app.tenant_id', :'tenant_a', true);
 
-- 1. 他テナントの行を直接狙っても0件
SELECT count(*) FROM invoices WHERE tenant_id = :'tenant_b';
 
-- 2. 自テナントの行は見える(ポリシーが全拒否になっていない確認)
SELECT count(*) FROM invoices WHERE tenant_id = :'tenant_a';
 
-- 3. 他テナントの行は更新も削除もできない(0行)
UPDATE invoices SET amount = 0 WHERE tenant_id = :'tenant_b';
DELETE FROM invoices WHERE tenant_id = :'tenant_b';
 
-- 4. 他テナントの行は作れない(WITH CHECKで拒否される)
INSERT INTO invoices (tenant_id, amount) VALUES (:'tenant_b', 1);
ROLLBACK;

変数 :'tenant_a' と :'tenant_b' は psql -v tenant_a=… -v tenant_b=… -f check.sql のように渡します。期待値は、1が0、2が1以上、3が UPDATE 0 と DELETE 0、4がエラーです。他人の行へのUPDATEはエラーではなく UPDATE 0 になり、RLSは該当行を黙って対象から外します。エラーが出ないことを「成功」と読み違えないよう、行数で判定します。

テストをClaude Codeに育てさせる

SQLの骨格ができたら、プロジェクトのテストフレームワークに合わせて書き直させます。依頼は具体的にします。

tests/tenant_isolation の形式で、tenant-scope-auditor が見つけた
テーブルごとに「別テナントの行が SELECT / UPDATE / DELETE / INSERT
のいずれでも見えない・変えられない」テストを生成して。
接続ロールは app_user を使い、スーパーユーザーでは流さない

最後の一文が重要です。スーパーユーザーや所有者のままテストすると、RLSを迂回して常に通ってしまいます。テストが緑でも何も守っていない状態になります。テストの冒頭で SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user を流し、どちらかがtrueなら失敗させる検査を入れておきます。

テスト生成をClaude Codeに任せるなら、実際にテストを実行させ、緑になった出力と、わざとポリシーを外した状態で赤くなる出力の両方を確かめさせます。赤くならないテストは、分離を検証できていません。

ポリシーの式で漏れやすい3つの穴

テナント条件を式に書いただけでは足りない場面があります。PostgreSQLの仕様上の注意点を、テナント分離の文脈で読み替えます。

参照整合性のチェックはRLSを迂回する

ユニーク制約・主キー制約・外部キーのチェックは、整合性を守るためRLSを常に迂回します。ここから、正規の出力に現れない隠れた経路(covert channel)で情報が漏れることがあります。

たとえばテナントをまたいで一意なキー(メールアドレスなど)を持つと、他テナントに同じ値が存在するかが、INSERTの失敗から推測できます。テナント内でのみ一意にしたい列は、(tenant_id, 列) の複合ユニーク制約にします。

ポリシーのサブクエリは競合で漏れうる

ポリシーの式から別テーブルを参照すると、READ COMMITTEDでの競合により古い権限で評価される場合があります。対策は、サブSELECTに FOR SHARE を付けるか、更新時に参照先へACCESS EXCLUSIVEロックを取ることです。

簡単な設計に寄せるのが基本です。ポリシー式は行自身の現在値だけを見る形が最も単純で、性能も最も良くなります。tenant_id = current_setting(...) のように行の列だけで決まる式にしておけば、この穴は生まれません。

関数が条件より先に評価されることがある

ポリシーの式は、ユーザーのクエリの条件や関数より先に評価されます。例外は leakproof と宣言された関数で、情報を漏らさない前提でオプティマイザーが先に適用することがあります。自作関数に LEAKPROOF を付けるのは、本当に漏らさないと言い切れるときだけに留めます。

洗い出しを運用に載せる

一回きりの調査にせず、再発を防ぐ仕組みに変えます。

  • 分離漏れの検査項目を CLAUDE.md に書く。「テナント所属テーブルを触るクエリは tenant_id 条件を必須とする」「新テーブルは tenant_id 列とRLSをセットで作る」といった規約を置くと、Claude Codeが新規コードを書く時点で守りやすくなる
  • 新しいテナント所属テーブルを足すマイグレーションの後に、カタログ照会の結果をテストで固定する。relrowsecurity がfalseのテナント所属テーブルがあれば失敗させる
  • コードレビューの段階で別文脈のサブエージェントに差分を読ませる設計は、敵対的レビューの記事で扱っています
  • PostgreSQLをCloud SQLで動かしている場合は、アプリ用ロールの接続のしかたをCloud SQL for PostgreSQLプラグインの手順と見比べ、テストと同じロールで接続しているかを確かめる

まとめ

テナント分離の検証は、コードを読む作業とデータベースに問い合わせる作業に分かれます。前者は読み取り専用のサブエージェントで条件漏れを洗い、後者はカタログ照会と、アプリ用ロールでの自動テストで確かめます。

押さえるのは、RLSが所有者と特権ロールを迂回する点と、UPDATE 0 のように黙って拒否される動作を行数で判定する点です。テストをスーパーユーザーで流すと緑でも何も検証していないので、実行ロールの検査をテストの先頭に置くのが安全です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/sub-agents
  2. postgresql.org/docs/current/ddl-rowsecurity.html
  3. postgresql.org/docs/current/view-pg-policies.html
この記事を共有:XはてブLinkedIn