0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SupabaseのRow Level Securityでマルチテナントを分離する — ポリシー実装例と「漏れていないか」の検証手順

0
Posted at

はじめに

当社(合同会社ジョインクラス)は Next.js / TypeScript / Supabase の構成で、受託開発と自社SaaSを作っています。社員はおらず、開発の大半を Claude Code に任せています。

AIに実装を任せていると、一番怖いのは「動くけど、他社のデータが見えている」状態です。画面は正しく出ますし、テストも通ります。でもクエリの where tenant_id = ... を1箇所書き忘れただけで、別テナントのデータが返ってしまいます。

なので僕は、テナント分離をアプリのコードではなくDBのRow Level Security(RLS)で強制することにしています。アプリ側で条件を書き忘れても、DBが行を返しません。

この記事では次の内容を、そのままコピーして試せる形でまとめます。

  1. テナント・所属・業務データのテーブル設計
  2. RLSポリシーの実装(SELECT / INSERT / UPDATE / DELETE)
  3. ハマりやすい落とし穴(再帰ポリシー、service_role、ビュー、WITH CHECK の書き忘れ)
  4. 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をたどってテナントを判定する設計にすると、ポリシーが重くなり、書き忘れも起きやすくなります。

supabase/migrations/xxxx_multitenant_rls.sql
-- テナント(契約企業)
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を直接開いても中身が出ない」ことをテストにしています。

e2e/tenant-isolation.spec.ts
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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?