「一覧で他テナントのデータが見えないから、テナント分離はできている」と思っていないだろうか。読む行をテナントで絞れていることと、書き込みで受け取った ID がどのテナントの行を指すかは、別の話だ。
筆者が運営する SaaS でも、予約 API に別の店のスタッフ ID を入れると通る穴があった。一覧には他店の予約が1件も出ない。それなのに、他店のスタッフを担当者にした予約は作れた。
この記事は、マルチテナントのアプリを AI コーディングエージェントと作っているエンジニア向けに、自分のアプリで穴を確かめる方法と、塞ぎ方3つの比較をまとめる。
なぜ一覧が絞れていても書き込めるのか
外部キーは、参照先の行が存在するかしか見ない。たとえば予約表のスタッフ ID の列がスタッフ表の id だけを参照していると、DB は他テナントのスタッフでも通す。DB にとっては、他テナントのスタッフも実在するスタッフだ。テナントの確認が要る場所は、読む行のほかに3つある。
| 確かめる対象 | よくある絞り方 | 抜けたときに起きること |
|---|---|---|
| 読む行 | 一覧・取得の WHERE に tenant_id | 抜ければ画面で気づきやすい |
| 書き換える行 | update / delete の WHERE | WHERE が id だけなら、他テナントの行を書き換えられる |
| body・クエリの ID が指す行 | 外部キー任せ | 外部キーが1列なら、他テナントの行を指したまま保存される |
| テナント列の無い中間表を経由する読み取り | 渡された ID で引く | 他テナントの ID を渡すと、その行の関連データが返る |
自分のアプリで確かめる
候補は、body とクエリで受け取る、他の表の行を指す ID だ。通常の作成・更新のほかに、一括保存・取り込み・AI が作った案の保存のような別の経路も探す。筆者の例でも、穴は手動の作成だけでなく一括保存と AI 案の保存にもあった。
確かめ方は1つで、テナント A のトークンでテナント B の ID を送り、応答と書き込み件数を見る。読み取りなら応答の中身を見る。期待する結果は次のとおりだ。
- 応答は 404。 403 だと、その ID が存在することを教えてしまう
- 書き込みは 0 件。 一括処理は、1件でも混ざればリクエスト全体を拒否する。行ごとのエラーで返すと、残りの行が保存されてしまう
- 読み取りは、B の行も関連データも返らない。 中間表を経由する取得や、AI に案を作らせるだけで保存しない経路も、ここで見る
body の ID の確認は、無くても正常系のテストがすべて通る。エージェントに任せるなら、直させる前に経路の棚卸しをさせる。
書き込み系の API(作成・更新・一括保存・取り込み・AI が作った案の保存を含む)と、ID を受け取る読み取りの API を調べ、
次を表で報告してください。この段階ではまだ直さないでください。
- body とクエリで受け取る、他の表の行を指す ID をすべて列挙する(staffId など)
- それぞれ、書き込みの前に自テナントの条件で存在を確かめているか。確かめている箇所のファイルと行
- 他テナントの ID を渡したときの応答が 404 か。そのとき何も書き込まれないか
- 一括の処理で、1 件だけ他テナントの ID が混ざったとき、残りが保存されないか
- リポジトリの update / delete のうち、tenantId を受け取らず WHERE が id だけのもの
- テナント A のトークンでテナント B の ID を送り、404 と書き込み 0 件を確かめる統合テストがあるか
- 渡された ID で他テナントのデータを返す読み取りが無いか(テナントの列を持たない中間表の経由や、保存しない AI の案の生成を含む)
報告のあと、確認の無い経路に「重複を除いた ID の件数と、自テナントで見つかった件数を比べ、
合わなければ 404」を足してください。WHERE が id だけの update / delete には tenantId を
必須の引数として足して WHERE に入れ、中間表を経由する読み取りは、渡された ID を
自テナントの行に絞ってから引いてください。最後に、上の統合テストを経路ごとに書いてください。
報告と修正を分けるのは、経路の一覧を人が先に見るためだ。直させてからでは、エージェントが見つけた経路しか直っていないことに気づきにくい。
テナント分離の3つの守り方を比べる
| 都度確認(アプリ) | リポジトリで tenantId 必須 | 複合外部キー(DB) | |
|---|---|---|---|
| 仕組み | 書く前に body の ID を自テナントの条件で引き直す | update / delete の引数に tenantId を必須にし、WHERE に入れる | 子の参照列と tenant_id の組を、親の (id, tenant_id) に向ける |
| 防げる範囲 | 確認を書いた経路だけ | 書き換える行のテナント。指す先の行は見ない | DB を通るすべての書き込み |
| 書き換えの量 | 経路ごとに数行 | インターフェースと呼び出し元。渡し忘れは型チェックで落ちる | マイグレーションと、親表の UNIQUE (id, tenant_id) |
| 既存データ | 触らない | 触らない | 違反行があると、そのままでは制約を張れない |
| エージェントに任せたとき | 新しい経路で書き忘れやすい | 型が止めるので漏れにくい | 一度入れれば経路を問わない |
| 違反時の応答 | 404 を自分で返す | 該当なしの例外を 404 に変換する | 外部キー違反を 404 に変換する |
「エージェントに任せたとき」の行は、測った値ではなく筆者の見込みだ。
都度確認:今すぐ入るが、経路の数だけ書く
ID が複数なら、重複を除いた件数と自テナントで見つかった件数を比べれば、問い合わせは1回で済む。弱いのは、確認を書いた経路しか守れないことだ。前の節で探した経路の数だけ、同じ確認を書くことになる。
リポジトリで tenantId 必須:書き換える行は守るが、指す先は守らない
update(entity) を update(entity, tenantId) に変え、WHERE を id と tenant_id の組にする。Prisma なら 5.0 以降、update の where に一意でない tenantId を並べられ、該当が無ければ P2025 の例外になる。
ただし守れるのは「どの行を書き換えるか」までだ。自テナントの行を正しい WHERE で更新しても、スタッフ ID に入れる値が他テナントのスタッフなら通る。鍵は自分の部屋のものなのに、表札には他人の名前を書ける状態だ。都度確認とは守る場所が違うので、どちらか一方では足りない。
複合外部キー:全経路を守るが、既存データが先に立ちはだかる
親表に (id, tenant_id) の一意制約を足し、子表の外部キーを2列にする。
ALTER TABLE staff ADD CONSTRAINT staff_id_tenant_key UNIQUE (id, tenant_id);
ALTER TABLE bookings ADD CONSTRAINT bookings_staff_tenant_fk
FOREIGN KEY (staff_id, tenant_id) REFERENCES staff (id, tenant_id)
ON DELETE SET NULL (staff_id)
NOT VALID;
-- 1 列の外部キーが残ると、削除時に古いほうの動作が効くので消す(名前は環境に合わせる)
ALTER TABLE bookings DROP CONSTRAINT bookings_staff_id_fkey;
PostgreSQL 17.11 で試すと、次のようになった。
- 他テナントのスタッフを指す予約は外部キー違反で拒否される。スタッフ ID が NULL の予約は通る(既定の MATCH SIMPLE)
- 違反行が残った表には、そのままでは制約を足せない。NOT VALID を付けると足せて、新しい書き込みだけが止まる
- 列を指定しない ON DELETE SET NULL は、スタッフを消すと tenant_id まで NULL にしようとして NOT NULL 違反で失敗した。列の指定は PostgreSQL 15 以降で使える
すぐにマイグレーションを入れられないなら、先に都度確認とリポジトリの必須化で塞いでおく。入れられる段階で NOT VALID で足して新しい書き込みを止め、違反行を1本の join で見つけて直してから VALIDATE する。
SELECT b.id, b.tenant_id, b.staff_id, s.tenant_id AS staff_tenant
FROM bookings b JOIN staff s ON s.id = b.staff_id
WHERE s.tenant_id <> b.tenant_id;
-- 違反行を直してから
ALTER TABLE bookings VALIDATE CONSTRAINT bookings_staff_tenant_fk;
別案:テナントを比べるだけの行レベルセキュリティでは、指す先は止まらない
PostgreSQL の行レベルセキュリティ(RLS)でテナントを分ければ、全部済むのだろうか。両方の表に tenant_id を比べるポリシーを張って試すと、他テナントのスタッフが1人も見えない設定のはずが、そのスタッフを指す予約は作れた。公式ドキュメントにも、外部キーの参照などの参照整合性のチェックは常に行セキュリティを迂回するとある。止めるには、予約表のポリシーの WITH CHECK に、参照先のスタッフが見えるかを確かめる副問い合わせ(EXISTS)を、参照する列ごとに足すことになる。
まとめ
テナント分離の穴は、読む行ではなく、リクエストで受け取った ID の指す先に開きやすい。まず自分のアプリで、テナント A のトークンに B の ID を載せて、経路ごとに 404 と書き込み 0 件を確かめる。リポジトリでの tenantId 必須や、テナントを比べるだけの RLS は書き換える行を守り、指す先は都度確認か複合外部キーが守る。エージェントを使った開発の進め方やテストの設計を外から整えたい場合は、AI開発支援で相談を受け付けている。



