はじめに
当社(合同会社ジョインクラス)は Next.js / TypeScript / Supabase の構成で、受託開発と自社SaaSを作っています。社員はおらず、開発の大半を Claude Code に任せています。
AIに実装を任せていると、一番怖いのは「動くけど、他社のデータが見えている」状態です。画面は正しく出ますし、テストも通ります。でもクエリの where tenant_id = ... を1箇所書き忘れただけで、別テナントのデータが返ってしまいます。
なので僕は、テナント分離をアプリのコードではなくDBのRow Level Security(RLS)で強制することにしています。アプリ側で条件を書き忘れても、DBが行を返しません。
この記事では次の内容を、そのままコピーして試せる形でまとめます。
- テナント・所属・業務データのテーブル設計
- RLSポリシーの実装(SELECT / INSERT / UPDATE / DELETE)
- ハマりやすい落とし穴(再帰ポリシー、
service_role、ビュー、WITH CHECKの書き忘れ) -
psqlと Playwright で「本当に漏れていないか」を確かめる方法
前提環境
# Supabase CLI でローカル環境を起動
npx supabase init
npx supabase start
# マイグレーションを作成
npx supabase migration new multitenant_rls
当社の自動化スクリプトも、ローカルの Supabase(supabase start)とシードデータ(npm run seed:local)が入っている前提で動かしています。本番に近いデータ構造をローカルで再現しておくと、RLSの検証がかなり楽になります。
1. テーブル設計
ポイントはすべての業務テーブルに tenant_id を持たせることです。JOINをたどってテナントを判定する設計にすると、ポリシーが重くなり、書き忘れも起きやすくなります。
-- テナント(契約企業)
create table public.tenants (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz not null default now()
);
-- ユーザーとテナントの所属(1ユーザーが複数テナントに所属できる)
create table public.tenant_members (
tenant_id uuid not null references public.tenants(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role text not null default 'member' check (role in ('owner', 'admin', 'member')),
primary key (tenant_id, user_id)
);
-- 業務データ(例: 案件)
create table public.projects (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
title text not null,
created_by uuid not null default auth.uid() references auth.users(id),
created_at timestamptz not null default now()
);
-- ポリシーで使う列には必ずインデックスを貼る
create index on public.tenant_members (user_id);
create index on public.projects (tenant_id);
2. RLSポリシーの実装
2-1. 所属判定は SECURITY DEFINER 関数にまとめる
tenant_members の所属チェックを各ポリシーに直接書くと、tenant_members 自身のポリシーが自分自身を参照して infinite recursion detected in policy というエラーになります。いちばんよく見る落とし穴です。
対策として、所属テナントの一覧を返す関数を security definer で作ります。この関数はRLSをバイパスして tenant_members を読むので、再帰が起きません。
-- 自分が所属するテナントID一覧
create or replace function public.my_tenant_ids()
returns setof uuid
language sql
stable
security definer
set search_path = '' -- search_path 汚染対策(必須)
as $$
select tenant_id from public.tenant_members where user_id = auth.uid()
$$;
-- 指定テナントで admin 以上か
create or replace function public.is_tenant_admin(target uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select exists (
select 1 from public.tenant_members
where tenant_id = target
and user_id = auth.uid()
and role in ('owner', 'admin')
)
$$;
-- anon から呼ばせない
revoke execute on function public.my_tenant_ids() from anon, public;
revoke execute on function public.is_tenant_admin(uuid) from anon, public;
grant execute on function public.my_tenant_ids() to authenticated;
grant execute on function public.is_tenant_admin(uuid) to authenticated;
set search_path = '' を付けないと、security definer 関数が攻撃者の作った同名テーブルを読んでしまう可能性があります。Supabase の Security Advisor でも警告が出ます。
2-2. 各テーブルにポリシーを付ける
alter table public.tenants enable row level security;
alter table public.tenant_members enable row level security;
alter table public.projects enable row level security;
-- tenants: 所属しているテナントだけ見える
create policy "tenants_select_member" on public.tenants
for select to authenticated
using (id in (select public.my_tenant_ids()));
-- tenant_members: 同じテナントのメンバー一覧は見える。変更は admin のみ
create policy "members_select_same_tenant" on public.tenant_members
for select to authenticated
using (tenant_id in (select public.my_tenant_ids()));
create policy "members_write_admin" on public.tenant_members
for all to authenticated
using (public.is_tenant_admin(tenant_id))
with check (public.is_tenant_admin(tenant_id));
-- projects: 所属テナント内で読み書き。削除は admin のみ
create policy "projects_select" on public.projects
for select to authenticated
using (tenant_id in (select public.my_tenant_ids()));
create policy "projects_insert" on public.projects
for insert to authenticated
with check (tenant_id in (select public.my_tenant_ids()));
create policy "projects_update" on public.projects
for update to authenticated
using (tenant_id in (select public.my_tenant_ids()))
with check (tenant_id in (select public.my_tenant_ids()));
create policy "projects_delete_admin" on public.projects
for delete to authenticated
using (public.is_tenant_admin(tenant_id));
auth.uid() を直接 using に書くより、(select public.my_tenant_ids()) のようにサブクエリで包む方が、行ごとに関数が評価されず速くなります(initPlan としてクエリ1回につき1回だけ評価されます)。
3. 落とし穴チェックリスト
実際にレビューで引っかかったものを並べます。
| 落とし穴 | 症状 | 対策 |
|---|---|---|
UPDATE に with check がない |
自テナントの行を 他テナントの tenant_id に書き換えて 移せてしまう |
using と with check を両方書く |
enable row level security 忘れ |
ポリシーを書いても全行見える | 下の監査SQLをCIで実行 |
service_role キーをクライアントに置く |
RLSが全部バイパスされる |
service_role はサーバー(Route Handler / Edge Function)だけで使う |
| ビュー経由で参照 | ビューはデフォルトで作成者の権限で動くのでRLSが効かない | create view ... with (security_invoker = true) |
| ポリシー列にインデックスなし | テナントが増えると一覧が急に遅くなる |
tenant_id と user_id にインデックス |
RLSが無効なテーブルを見つける監査SQLです。マイグレーション後に毎回実行しています。
select schemaname, tablename
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
-- 0行なら OK
4. 漏れていないかを検証する
4-1. psql でユーザーになりすまして確認する
ローカルの Supabase なら、JWTのclaimをセットすることで authenticated ロールの特定ユーザーとしてクエリを実行できます。
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" <<'SQL'
begin;
-- テナントAのユーザーとして振る舞う
set local role authenticated;
select set_config('request.jwt.claims',
'{"sub":"<テナントAのユーザーUUID>","role":"authenticated"}', true);
-- 自テナントの件数だけ返ればOK
select tenant_id, count(*) from public.projects group by tenant_id;
-- 他テナントへの INSERT は失敗するはず
insert into public.projects (tenant_id, title)
values ('<テナントBのUUID>', 'should fail');
-- => ERROR: new row violates row-level security policy for table "projects"
rollback;
SQL
group by tenant_id の結果が1行だけになることを確認します。2行出たら漏れています。
4-2. Playwright で画面からも確認する
当社では、ローカル環境にシードしたデモユーザーで Playwright を使ってログインし、画面を自動操作するスクリプトを動かしています(プロモーション動画用の画面収録スクリプト)。そのログイン処理をそのまま流用して、「別テナントのURLを直接開いても中身が出ない」ことをテストにしています。
import { test, expect, type Page } from '@playwright/test';
const BASE = process.env.APP_BASE_URL ?? 'http://localhost:3000';
// 実運用の画面収録スクリプトと同じログイン処理
async function login(page: Page, email: string, password: string) {
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
await page.fill('input[name="email"]', email);
await page.fill('input[name="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15000 });
}
test('テナントAのユーザーはテナントBの案件を開けない', async ({ page }) => {
await login(page, 'tenant-a@test.local', 'demo-password-123');
// シードで固定したテナントBの案件ID
await page.goto(`${BASE}/projects/${process.env.TENANT_B_PROJECT_ID}`);
// RLSで0行になるので、アプリは404表示になる想定
await expect(page.getByText('見つかりません')).toBeVisible();
await expect(page.getByText('テナントBの秘密案件')).toHaveCount(0);
});
シードデータには、わざと他テナントにしかないタイトル(例: 「テナントBの秘密案件」)を入れておきます。画面にその文字列が一度でも出たら失敗、という単純なテストにすると、AIが書いたクエリの書き忘れも確実に拾えます。
5. Claude Code に実装させるときの運用
AIにマイグレーションを書かせるときは、プロジェクトの CLAUDE.md に次のルールを書いています。
## DBルール
- public スキーマの新規テーブルは必ず tenant_id を持ち、同じマイグレーション内で RLS を有効化する
- UPDATE ポリシーには using と with check を両方書く
- service_role キーをクライアントコードで import しない
- マイグレーション後に RLS 無効テーブル監査SQLを実行し、0行であることを確認する
「RLSを付けてください」と毎回お願いするより、ルールとして固定しておいて監査SQLで機械的に確認する方が漏れません。AIの実装を人が1行ずつ読むのは無理なので、DBが最後の防衛線になる設計にしておく、というのが僕の考え方です。
まとめ
- テナント分離はアプリの
where句ではなく、RLSでDBに強制する - 所属判定は
security definer+set search_path = ''の関数にまとめ、再帰を避ける -
UPDATEはwith checkまで書く。ビューはsecurity_invoker - psql のなりすましと Playwright のE2Eで「他テナントの文字列が出ない」ことを確認する
一人会社で受託とSaaSを回していると、情報漏えい事故は一発で信用を失います。「AIは実行に使う。判断は人間。」と決めていますが、人間が全部をチェックできるわけではありません。だからこそ、チェックしなくても壊れない仕組みをDB側に作っています。
関連書籍
AIエージェントに開発を任せるときの権限設計・シークレット管理・監査の考え方は、こちらの書籍で詳しく解説しています。
- 『企業のためのClaude Codeセキュリティガイド』
書籍一覧はこちら: https://zenn.dev/joinclass?tab=books