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に渡し、次の観点で読ませます。
tenant_idを持つのにrelrowsecurityがfalseのテーブルは無いかrelrowsecurityがtrueでもpg_policiesに行が無いテーブル(全行拒否になっている)は無いかqualにtenant_idを使っていないポリシーは無いかcmdがSELECTだけで、UPDATEとDELETEに対応するポリシーが無いテーブルは無いか- アプリ用ロールが
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 のように黙って拒否される動作を行数で判定する点です。テストをスーパーユーザーで流すと緑でも何も検証していないので、実行ロールの検査をテストの先頭に置くのが安全です。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。