最初に結論を述べます。マルチテナントSaaSの「テナント分離」は、RLSを設計した時点では一切守られていません。守りになるのは、別テナントになりすまして叩いても他テナントのデータが1件も返らないことを検証で示せたときです。 設計は意図の宣言であり、検証はその意図が実装で壊れていないことの証拠です。この2つは、別々の仕事です。
そしてテナント越境(cross-tenant leak)は、B2B SaaSにとって最も高くつく漏洩の一つです。1人のユーザーの情報が漏れるのではなく、「顧客企業Aの全データが、競合になりうる顧客企業Bに見える」という、契約・信頼・法務のすべてを同時に壊す事故になります。だからこそ、「たぶん効いている」ではなく「効いていることを示せる」状態まで持っていく価値があります。
本記事は、Supabase(PostgreSQL + RLS)でマルチテナントを組んだ前提で、分離が壊れていないことを"検証で証明する"具体的な手段——回帰テスト、所有権チェック、安全な動的プローブ、静的解析との相関——を実コードで示します。RLSをどう設計するかはSupabaseのマルチテナンシー設計パターンやマルチテナントSaaSのデータ分離・認可設計に譲り、ここでは一貫して「書いた分離が、本当に効いているか」だけを扱います。なお、Supabase × Next.js のセキュリティ全体像は総合ガイドにまとめています。
1. 結論:テナント分離は「設計した」では守れない
「全テーブルにRLSを張りました」は、安心の根拠になりません。理由は単純で、RLSは"書いたとおり"にしか効かず、しかもRLSが効かない経路がいくつも併存するからです。ポリシーの条件式に1文字の取りこぼしがあっても、service_role を使う経路が1本あっても、security_invoker を付け忘れたビューが1枚あっても、そこからテナントの壁は崩れます。
ここで重要なのは、「分離が効いていること」は、肯定的な観測(=越境を試したが失敗した)でしか確認できないという性質です。コードを眺めて「正しそう」と思うのは検証ではありません。検証とは、
「テナントBのアクセストークンで、テナントAの行を指して問い合わせたとき、結果が**必ず空(0件)**であること」を、機械的に・繰り返し・退行なく示すこと
です。これを満たして初めて、「設計した分離が壊れていない」と言えます。逆に言えば、この観測を一度も取っていないなら、分離は「祈り」であって「保証」ではありません。
ただし、ここで誠実に線を引いておきます。検証は確信度を上げますが、バグの不在を証明はしません。 テストした境界は守られていると示せても、テストしていない経路・将来追加される経路までは保証できません。だから本記事の手法は「やれば完全に安全」ではなく、「最も起きやすい越境を機械的に潰し、確信度を継続的に高く保つ」ための実践です。この前提は最後まで一貫します。
2. テナント越境はどう起きるか——4つの典型経路
越境の入口は無数にあるように見えて、現場で繰り返し見るのは次の4経路に収束します。検証の設計は「この4経路を全部叩いたか」を基準にすると、抜けが減ります。
| 経路 | 何が起きるか | RLSとの関係 |
|---|---|---|
| A. RLSの抜け | RLS未有効化・条件式の取りこぼしで他テナント行が見える | RLSが「無い/弱い」 |
| B. service_role 経路 | 管理クライアントで所有権を確認せずIDを受け取る | RLSを「飛び越える」 |
| C. 回り込み | ビュー/RPC/関数がRLSを迂回して下層テーブルを読む | RLSが「適用されない」 |
| D. IDを指定するAPI | テナントID/オブジェクトIDを自己申告のまま信じる(IDOR/BOLA) | RLSの「外側」 |
経路A:RLSの抜け——そもそも分離が「無い」
最も基本的な失敗は、RLSを有効化していない、あるいは条件式がテナントを縛れていないケースです。PostgreSQLのRLSはテーブル単位で有効化し、ポリシーで許可を足す設計で、**有効化しただけでポリシーが無ければデフォルト全拒否(fail-secure)**になります(PostgreSQL Row Security Policies)。問題は「有効化を忘れる」「条件が緩い」の2つです。
-- マルチテナントの最小スキーマ
create table tenants (
id uuid primary key default gen_random_uuid(),
name text not null
);
-- ユーザーがどのテナントに属するか(所属=認可の真実の源)
create table memberships (
user_id uuid not null references auth.users,
tenant_id uuid not null references tenants,
role text not null default 'member',
primary key (user_id, tenant_id)
);
create table invoices (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants,
amount integer not null,
created_at timestamptz not null default now()
);
-- 危険:invoices に RLS を張り忘れると、anon キーで全テナントの請求書が読める
-- curl "https://<project>.supabase.co/rest/v1/invoices?select=*" -H "apikey: <anon-key>"
このクラスの設定ミスが「未認証で全テーブル読み書き可能」という最悪の形で現実化したのが、2025年に登録された CVE-2025-48757 です(不十分なRLSによりリモートの未認証攻撃者が任意テーブルを読み書き、CWE-863、CVSS 9.3 CRITICAL)。RLS設定ミスそのものの発見手法はRLS設定ミスの検出・監査ガイドに詳しく書いています。正しい分離は、テナント所属を真実の源にしてこう張ります。
alter table memberships enable row level security;
alter table invoices enable row level security;
-- ユーザーは自分の所属だけ読める
create policy "read own memberships"
on memberships for select
to authenticated
using ( user_id = (select auth.uid()) );
-- 請求書は「自分が所属するテナントの行」だけ読める
create policy "read invoices of my tenants"
on invoices for select
to authenticated
using (
tenant_id in (
select tenant_id from memberships
where user_id = (select auth.uid())
)
);
auth.uid() を (select auth.uid()) で包むのはSupabase公式推奨で、行ごとの再評価を避けて大規模テーブルでの性能を保つためです(Supabase: Row Level Security)。ここまでが「設計」。これが効いているかは、第4節以降で「検証」します。
経路B:service_role 経路で所有権チェックを忘れる
Supabaseの service_role キーは PostgreSQL の BYPASSRLS を持ち、RLSを完全に飛び越えます。公式も「service_role はRLSをバイパスする。サーバー側でのみ使うこと」と明記しています(Supabase: Row Level Security)。第A経路のRLSを完璧に張っても、service_role を使うルートが1本あれば、そこだけRLSが無効化されます。
// app/api/invoices/route.ts — 脆弱:service_role でテナントIDを"自己申告"のまま信じる
import { createClient } from "@supabase/supabase-js";
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // ← RLS をバイパスする管理クライアント
);
export async function POST(req: Request) {
const { tenantId } = await req.json(); // ← クライアントが自由に指定できる値
// このユーザーが tenantId に所属しているか一切確認していない=テナント越境
const { data } = await supabaseAdmin
.from("invoices")
.select("*")
.eq("tenant_id", tenantId);
return Response.json(data); // 他テナントの請求書がそのまま返る
}
service_role キーの所在と漏洩リスクそのものはanonキー/service_roleキー露出ガイドで扱っています。ここでの修正の原則は2つだけです——(1) 可能なら service_role を使わず、ユーザーのトークンで動く anon クライアントでRLSに認可を任せる。(2) どうしても service_role が必要なら、所属(membership)をサーバーで必ず確認してからクエリする。
// 修正:service_role を使うなら、テナント所属をサーバーで必ず検証する
export async function POST(req: Request) {
const { tenantId } = await req.json();
const user = await getAuthenticatedUser(req); // クライアントの自己申告を信じない
if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });
// 所属を確認:このユーザーが tenantId のメンバーでなければ越境として弾く
const { data: membership } = await supabaseAdmin
.from("memberships")
.select("tenant_id")
.eq("user_id", user.id)
.eq("tenant_id", tenantId)
.maybeSingle();
if (!membership) return Response.json({ error: "forbidden" }, { status: 403 });
const { data } = await supabaseAdmin
.from("invoices")
.select("*")
.eq("tenant_id", tenantId);
return Response.json(data);
}
経路C:JOIN・ビュー・RPC 経由の回り込み
RLSはテーブルに対して効きますが、それを"迂回"する経路があることを見落としがちです。代表は2つ。
ひとつはビュー。PostgreSQLのビューは既定でビュー所有者の権限で実行され、問い合わせユーザーのRLSが適用されません。Postgres 15以降は security_invoker = on を付けると問い合わせユーザーの権限(=RLS)が適用されます(PostgreSQL Row Security Policies)。
-- 危険:security_invoker を付けないビューは、所有者権限で下層を読む=RLS回避
create view invoice_totals as
select tenant_id, sum(amount) as total
from invoices
group by tenant_id;
-- このビューを authenticated に grant すると、全テナントの集計が誰にでも見える
-- 修正:問い合わせユーザーの権限(=RLS)を適用させる
create view invoice_totals
with (security_invoker = on)
as
select tenant_id, sum(amount) as total
from invoices
group by tenant_id;
もうひとつは SECURITY DEFINER 関数 / RPC。定義者権限で実行されるため、関数の中で所有権を確認しないと、引数で渡された任意の tenant_id に対してRLSを飛び越えて読み書きできてしまいます。
-- 危険:SECURITY DEFINER が任意 tenant_id を所有権チェックなしで受ける
create function get_tenant_invoices(p_tenant_id uuid)
returns setof invoices
language sql
security definer -- 定義者権限で実行=RLSを飛び越える
set search_path = '' -- search_path 固定は必須(権限昇格対策)
as $$
select * from public.invoices where tenant_id = p_tenant_id
$$;
-- 修正案1:問い合わせユーザーのRLSを効かせる(多くの場合これで十分)
create or replace function get_tenant_invoices(p_tenant_id uuid)
returns setof invoices
language sql
stable
security invoker
set search_path = ''
as $$
select * from public.invoices where tenant_id = p_tenant_id
$$;
-- 修正案2:どうしても definer が必要なら、関数内で所属を必ず確認する
-- where exists (select 1 from public.memberships
-- where user_id = auth.uid() and tenant_id = p_tenant_id)
回り込み経路は「RLSを張ったから安全」という思考停止の死角に正確に入るため、検証ではビューとRPCを"テナント越え"の対象に必ず含めることが重要です。
経路D:IDを指定するAPI(IDOR / BOLA)
最後は、テナントID・オブジェクトIDをクライアントの自己申告のまま信じる経路です。これはOWASP API Security Top 10で2019年以来ずっと第1位の API1:2023 Broken Object Level Authorization(BOLA) そのものです(OWASP API1:2023 BOLA、OWASP API Security Top 10)。
POST /api/invoices { "tenantId": "AAAA..." } ← 自分のテナント(正規)
POST /api/invoices { "tenantId": "BBBB..." } ← IDを差し替えるだけ。これが通れば越境
経路Bと地続きですが、強調したいのは**「画面に出していないIDだから安全」は成立しない**こと。Server Actions("use server")も実質POSTエンドポイントで、引数のIDはHTTPで任意に差し替えられます。SupabaseでのIDORの生まれ方と修正はIDOR/認可欠陥の検出ガイドに詳述しています。本記事では、この4経路すべてを「検証で塞げているか」に話を進めます。
3. 「設計」と「検証」は、まったく別の仕事
ここを混同すると、いつまでも分離は証明できません。2つの仕事を明確に分けます。
| 設計(Design) | 検証(Verification) | |
|---|---|---|
| やること | 正しいRLS・所有権チェックを書く | 別テナントになりすまして叩き、0件/拒否を確認する |
| 成果物 | ポリシー・コード | テスト・プローブ結果・CIログ |
| 答える問い | 「どう分離するつもりか」 | 「分離は本当に壊れていないか」 |
| 失敗の見え方 | レビューで気づける(こともある) | 実行して初めて分かる |
設計は意図の表明で、レビューで質を上げられます。しかし意図が実装で壊れていないかは、動かして観測するしかない。条件式の = が <> になっていても、tenant_id を縛る where が消えていても、コードは正常に動き、エラーも出ません。越境は「壊れていても静かに動く」種類の欠陥だからです。
だから検証の核は、肯定的観測です。具体的には次の不変条件(invariant)を、すべての主要テーブル・経路について確認します。
不変条件:テナントBの主体(JWT / アクセストークン)でテナントAの行を要求したとき、結果は必ず空であり、書き込みは必ず拒否される。
この不変条件を「一度確認した」では足りません。スキーマもポリシーもコードも変わり続けるので、退行(regression)しないことを継続的に示す必要があります。次節の回帰テストは、まさにこの不変条件をCIに固定するための手段です。
4. 検証手法①:回帰テスト——越境を"自動で"再現して拒否を確認する
回帰テストは、テナント越境検証の中心です。狙いは「人がいなくても、コミットのたびに越境が再現されないことを示す」こと。2つの層で書きます——DB内で完結する pgTAP と、API越しに叩く統合テスト。両者は守備範囲が違うので、両方やる価値があります。pgTAPでのRLS回帰テストの土台はpgTAPによるRLSポリシー回帰テストガイドも参照してください。
4-1. pgTAP でRLSポリシーをDB内で直接テストする
pgTAPはPostgreSQL内で動くテストフレームワークで、RLSポリシーの真偽をDBに最も近い層で検証できます。Supabaseでは、認証コンテキストを request.jwt.claims と role の設定で偽装し、そのユーザーになりきって問い合わせます。auth.uid() はこのクレームの sub を読みます。
-- supabase/tests/tenant_isolation.test.sql
begin;
select plan(5);
-- 1) 固定IDでフィクスチャを用意(テナントA・B と、それぞれの所属ユーザー)
insert into tenants (id, name) values
('11111111-1111-1111-1111-111111111111', 'Tenant A'),
('22222222-2222-2222-2222-222222222222', 'Tenant B');
insert into auth.users (id, email) values
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'a@example.com'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'b@example.com');
insert into memberships (user_id, tenant_id) values
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111'),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '22222222-2222-2222-2222-222222222222');
insert into invoices (id, tenant_id, amount) values
('a0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 1000), -- Aの請求書
('b0000000-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 2000); -- Bの請求書
-- 2) テナントBのユーザーになりきる
set local role authenticated;
set local request.jwt.claims to
'{"sub":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","role":"authenticated"}';
-- 3) 不変条件①:Bのユーザーから、Aのテナントの行は1件も見えない
select is(
(select count(*)::int from invoices
where tenant_id = '11111111-1111-1111-1111-111111111111'),
0,
'テナントBのユーザーはテナントAの請求書を読めない'
);
-- 4) 不変条件②:Bのユーザーには自分のテナントの行だけが見える
select is(
(select count(*)::int from invoices),
1,
'テナントBのユーザーに見えるのは自テナントの1件だけ'
);
-- 5) 不変条件③:Aの行へのIDピンポイント参照も空
select is_empty(
$$ select * from invoices where id = 'a0000000-0000-0000-0000-000000000001' $$,
'IDを直接指定してもテナントAの行は取得できない'
);
-- 6) 不変条件④:Aのテナントへの書き込みはRLSで拒否される
select throws_ok(
$$ insert into invoices (tenant_id, amount)
values ('11111111-1111-1111-1111-111111111111', 9999) $$,
'42501', -- insufficient_privilege(RLS の WITH CHECK 違反)
null,
'テナントBのユーザーはテナントAへ請求書を作成できない'
);
-- 7) 不変条件⑤:回り込みビューも越境しない(security_invoker=on の確認)
select is(
(select count(*)::int from invoice_totals
where tenant_id = '11111111-1111-1111-1111-111111111111'),
0,
'ビュー経由でもテナントAの集計は見えない'
);
select * from finish();
rollback;
ポイントを3つ。(1) begin … rollback で囲み、テストは副作用を残しません。(2) 書き込み拒否は throws_ok でエラーコード 42501 を期待し、「静かに0件」ではなく「明示的に拒否」を確認します。(3) ビュー(経路C)まで対象に含めることで、回り込みの退行も捕まえます。request.jwt.claims を手で組むのが煩雑なら、supabase_test_helpers の tests.authenticate_as() などのヘルパーで同じことを簡潔に書けます。
CIでは Supabase CLI から実行します。
# ローカル/CIでRLS回帰テストを実行(DBをリセットしてからテスト)
supabase db reset
supabase test db
4-2. 統合テスト:実トークンでAPI越しに叩く
pgTAPはDB内の真実を確認しますが、アプリのコード(経路B・D)はテストしません。service_role で所有権チェックを忘れたRoute Handlerは、DBのRLSが完璧でも越境します。だから「実際に発行されたアクセストークンで、APIを越境させにいく」統合テストを別に持ちます。
// tests/tenant-isolation.test.ts — 実トークンでAPI越しにテナント越えを試す
import { createClient } from "@supabase/supabase-js";
import { beforeAll, expect, test } from "vitest";
const URL = process.env.SUPABASE_URL!;
const ANON = process.env.SUPABASE_ANON_KEY!;
const TENANT_A = "11111111-1111-1111-1111-111111111111";
let tokenB: string; // テナントBのユーザーのアクセストークン
beforeAll(async () => {
const sb = createClient(URL, ANON);
const { data, error } = await sb.auth.signInWithPassword({
email: "b@example.com",
password: process.env.TEST_USER_B_PASSWORD!, // テスト専用アカウントの資格情報
});
if (error) throw error;
tokenB = data.session!.access_token;
});
// Bのトークンで動くクライアント=RLSが効く。越境していなければ必ず空。
function asTenantB() {
return createClient(URL, ANON, {
global: { headers: { Authorization: `Bearer ${tokenB}` } },
});
}
test("PostgREST: BのトークンでAの請求書は0件", async () => {
const { data, error } = await asTenantB()
.from("invoices")
.select("*")
.eq("tenant_id", TENANT_A);
expect(error).toBeNull();
expect(data).toEqual([]); // 越境していなければ空
});
test("REST API: Bのトークンで tenantId=A を要求しても漏れない", async () => {
const res = await fetch("http://localhost:3000/api/invoices", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${tokenB}`,
},
body: JSON.stringify({ tenantId: TENANT_A }), // 他テナントIDを自己申告(経路D)
});
// 403 で弾く実装が理想。最低でも本文に他テナントの行が含まれないこと。
expect([401, 403, 404]).toContain(res.status);
const body = await res.text();
expect(body).not.toContain(TENANT_A);
});
この2層を合わせると、「DBのRLS」と「アプリの所有権チェック」の両方に対して不変条件を張れます。どちらか一方だけでは、第2節の4経路に必ず穴が残ります。
テスト用アカウントの資格情報は環境変数(Secrets)で渡し、コードにもログにも残さないこと。テストとはいえ実トークンを扱うので、ここでの漏洩は本番の鍵漏洩と同じ重みです。
5. 検証手法②:安全な動的プローブ(DAST)で「疑い」を「確証」に上げる
回帰テストは「自分で書いた不変条件」を守ります。一方で、書き忘れた経路——新しく増えたエンドポイント、テストを書いていないRPC、見落としていたビュー——は、回帰テストの網に最初から入りません。ここを補うのが、動的プローブ(DAST)です。
考え方は、OWASPのWeb Security Testing Guideが示す「認可テスト」の実務そのものです。2つのアイデンティティ(テナントA・B)を用意し、片方の主体でもう片方のリソースを指して叩き、応答を観測する。これを、エンドポイントを横断して機械的に回します。
ただし、ここには厳格な倫理と安全のルールがあります。
- 対象は自分が所有・管理する環境だけ(本番ではなくステージング推奨)。他者の環境へのプローブは攻撃です。
- 非破壊:読み取り中心。書き込みプローブはロールバック可能な範囲・専用テナントに限定。
- スコープ固定:対象ホスト・パスを明示的に絞り、想定外への波及を防ぐ。
静的解析(SAST)は「ここは所有権で絞られていない疑いがある」と疑いを出すところまで。動的プローブは、その疑いを実際に他テナントになって叩き、漏れたか否かの実行時の事実に変えます。私が公開しているOSSでは、この相関を1コマンドで回せます。
# 自分が所有する環境に対してのみ、非破壊・スコープ固定でプローブする
# --correlate で静的解析の「疑い」と実行時の「確証」を突き合わせる
npx @aegiskit/cli probe https://staging.example.com --correlate
誇張せずに言えば、これは魔法ではありません。プローブできるのは「叩けて、観測できる」経路だけで、複雑な業務フローの奥にある越境は人手のテスト設計が要ります。DASTは確証の格上げ装置であって、網羅性の保証ではない——この線引きは、第8節の監査の話と完全に地続きです。
6. 「弱いRLS」×「非管理クライアント」=確定した露出として相関する
検証の精度を上げる鍵は、別々の証拠を突き合わせて"確定"を作ることです。単独では「疑い」止まりの情報も、組み合わせると「確定した露出」に格上げできます。最も効く相関は次の組です。
| 証拠①(静的:SQL側) | 証拠②(静的:コード側) | 相関結論 |
|---|---|---|
| あるテーブルのRLSが弱い/無い | そのテーブルを anon/非管理クライアントから直接クエリしている | 確定した露出(公開APIから素通しで読める) |
| RLSは妥当 | だが service_role で所有権チェックなしにクエリしている | 確定した越境経路(RLSが効かない) |
ビューに security_invoker 無し | そのビューを authenticated に grant | 回り込みによる露出 |
この相関の価値は、ノイズを増やさずに優先度を付けられることにあります。「RLSが弱い」だけでは、内部専用テーブルかもしれず断定できない。「非管理クライアントから読んでいる」だけでも、RLSが守っているかもしれない。しかし両方が同時に成立する箇所は、理屈の上で必ず漏れます。ここに第5節の動的プローブが「実際に漏れた」を重ねれば、静的な疑い → 構造的な確定 → 実行時の確証という三重の証拠が揃い、最優先で直すべき欠陥として迷いなく扱えます。
逆に、相関が取れない単独の警告は後回しにできます。検証で疲弊する最大の原因は「全部が等しく怖い」状態なので、相関で序列を付けることは、検証を継続可能にする実務上の生命線です。
7. テナント越境 検証チェックリスト(B2B向け)
外注・内製・AI生成のいずれであっても、本番投入の前に「分離が壊れていないと示せたか」をこの観点で確認してください。発注者の立場でも、開発者にこの問いを投げれば、検証が設計に組み込まれているかが分かります。
| 観点 | 確認すること(=証拠の有無) | 危険信号 |
|---|---|---|
| RLS有効化 | テナント由来の全テーブルでRLSが有効か | enable row level security の無いテーブルがある |
| テナント条件 | ポリシーが所属(memberships)を真実の源に縛っているか | using (true) や、ユーザー単位で止まりテナントを縛れていない |
| service_role経路 | service_role を使うAPIで所属を必ず検証しているか | IDを受けて所有権チェックなしでクエリしている |
| 回り込み | ビューに security_invoker=on、RPCに所有権ガードがあるか | ビュー/関数を「RLS対象外」と認識していない |
| 越境回帰テスト | 「Bのトークンでも空」をpgTAP/統合テストで固定したか | 「手元で動いた」以外の証拠が無い |
| 書き込み拒否 | 他テナントへのinsert/updateが明示的に拒否されるか | 読み取りしか検証していない |
| 動的プローブ | 自分の環境で2アイデンティティの越境プローブを回したか | 実行時に他テナントを叩いた記録が一度も無い |
| 退行防止 | これらがCIで毎コミット走るか | テストはあるが手動でしか実行されない |
発注者の視点で最も効く2問は、「テナントBのトークンでテナントAのデータを叩いたらどうなりますか?」「その結果を示すテスト/ログはありますか?」です。良い開発者は、設計の説明ではなく検証の証拠で即答します。私自身、B2BサブスクリプションSaaSの開発で、テナント分離は「設計図」ではなく「越境を叩いて落ちないこと」を継続的に示すことで初めて運用に乗る、という前提で組んできました。
8. 自分での検証と、第三者監査——どこまでで「証明」になるのか
ここまでの手法は、すべて自分(チーム)で回せるものです。回帰テストと動的プローブをCIに組み込めば、確信度は大きく上がります。まずはここを固めるのが費用対効果で最良です。
その上で、第三者監査の価値も正直に位置づけます。自分の検証には「自分が想定した経路しか叩かない」という構造的な盲点があります。設計者が「ここは越境しないはず」と思い込んだ経路は、テストにもプローブにも最初から含まれません。第三者は、その思い込みの外側——想定していないRPCの組み合わせ、複数テナントにまたがる招待フロー、共有リソースの端——を脅威モデリングから叩きにいきます。これがOWASP API1:2023 BOLA(OWASP API1:2023 BOLA)が「最頻出かつ最も見落とされる」と言われ続ける理由でもあります。
ただし、ここでも誠実に線を引きます。第三者監査も「完全に安全」を証明はしません。 監査が示せるのは「定めたスコープと期間で、用いた手法では、これだけの越境経路が見つからなかった/見つかった」という事実までです。スコープ外・手法外・将来の変更に対する保証ではありません。だから監査結果は「お墨付き」ではなく、確信度を一段引き上げ、残るリスクを明示するものとして扱うのが正しい。監査がどこまでをカバーし何を保証しないか、いつ依頼すべきかはセキュリティ監査の範囲ガイドに整理しています。
整理すると、テナント分離の「証明」は段階的です。
- 設計:RLS+所有権チェックを書く(=意図の宣言)
- 回帰テスト:自分の不変条件をCIで退行なく守る(=確信度を上げる)
- 動的プローブ:疑いを実行時の確証に格上げする(=確信度をさらに上げる)
- 第三者監査:想定外の盲点を外から叩く(=確信度をもう一段上げ、残リスクを明示)
どの段階も「完全」には到達しませんが、重ねるほど越境が起きる確率は確実に下がり、起きたときに気づける速さも上がります。私は自作のセキュリティツール群(Aegis / npx @aegiskit/cli)でこの2〜3段を自動化し、踏み込んだ確認が必要な場合は第三者によるセキュリティ監査として提供しています。AIで速く作ること自体は正しい。速く作ったものの分離が壊れていないことを、証拠で示せる状態にする——その仕組みづくりや既存アプリのテナント分離レビューが必要であれば、お気軽にご相談ください。
よくある質問(FAQ)
Q. 全テーブルにRLSを張れば、テナント越境は防げますか? A. 必要条件ですが、十分ではありません。第2節のとおり service_role 経路(B)、ビュー/RPCの回り込み(C)、ID自己申告のAPI(D)はRLSの外側・回避経路です。「張った」ことと「効いている」ことは別なので、回帰テストと動的プローブで「越境が再現しない」ことまで示して初めて守りになります。
Q. pgTAPの回帰テストがあれば、統合テストは不要では? A. いいえ。pgTAPはDB内のRLSの真実を確認しますが、アプリのコード(service_role経路や所有権チェックの書き忘れ)は通りません。逆に統合テストはコードを通りますが、DB内部の細かなポリシー条件は網羅しづらい。守備範囲が違うので、両方を持つのが正解です。
Q. 動的プローブ(DAST)は本番に対して実行していいですか? A. 原則ステージングなど自分が所有・管理し、データを壊しても問題ない環境で行ってください。本番に対しては、影響範囲・非破壊・スコープを厳密に管理した上で慎重に。他者の環境へのプローブは、許可なく行えば攻撃に該当します。
Q. 検証をすべて通れば「安全宣言」してよいですか? A. してはいけません。検証が示すのは「テストした境界では越境が再現しなかった」という事実までで、テストしていない経路・将来の変更は保証しません。残るリスクを明示した上で「現時点で確認できた範囲」と述べるのが誠実で、結果的に信頼されます。
Q. テナントIDを「JWTのカスタムクレームに入れる」設計と、「membershipsテーブルで引く」設計はどちらが良いですか? A. どちらも実務で使われます。クレームに入れると速い反面、所属変更の反映やクレーム改ざん耐性の設計が要ります。テーブルで引くと常に最新で監査しやすい反面、ポリシーにサブクエリが増えます。重要なのは選択そのものより、選んだ設計で越境が起きないことを検証で示せることです。設計の比較はマルチテナント分離・認可設計ガイドを参照してください。
まとめ:分離は「書いた」ではなく「壊れていないと示せた」で初めて守りになる
要点を整理します。
- テナント越境はB2B SaaSで最も高くつく漏洩の一つ。RLSを「設計した」ことと、分離が「効いている」ことは別の事実です。
- 越境は4経路で起きる——RLSの抜け(A)/service_role経路の所有権漏れ(B)/ビュー・RPCの回り込み(C)/IDを指定するAPI=IDOR・BOLA(D)。検証はこの4経路を全部叩いたかで設計する。
- 証明の核は不変条件「テナントBの主体でテナントAの行を要求したら必ず空、書き込みは必ず拒否」を、pgTAPと統合テストでCIに固定すること。読み取りだけでなく書き込み拒否まで確認する。
- 静的解析の疑いは、自分が所有する環境への安全な動的プローブ(DAST)で確証に格上げできる。弱RLS × 非管理クライアントは確定した露出として相関する。
- そして正直に——検証も第三者監査も「完全に安全」は証明しない。 確信度を段階的に上げ、残るリスクを明示するための営みです。だから設計・テスト・プローブ・人のレビューを重ねます。
分離は、コードに「書いた」瞬間ではなく、「壊れていないと示せた」瞬間に、初めて守りになります。設計図ではなく、越境を叩いて落ちない証拠を——それが、テナント分離を「祈り」から「保証」に変える唯一の道です。
参考資料
- OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization (BOLA)
- OWASP API Security Top 10(2023年版・全体)
- OWASP Web Security Testing Guide(認可テストの実務)
- Supabase Docs — Row Level Security(service_roleはRLSをバイパスする)
- PostgreSQL — Row Security Policies(ビュー/SECURITY DEFINERとRLSの関係)
- NVD — CVE-2025-48757(不十分なRLSによる未認証アクセス、CWE-863、CVSS 9.3)