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で安全なマルチテナントSaaSを作る

0
Posted at

はじめに

マルチテナントSaaSを作るとき、最も怖いのは「他のテナントのデータが見えてしまう」事故だ。

僕は一人法人でAIを活用した業務自動化の会社を経営していて、Next.js + TypeScript + Supabaseでプロダクトを複数運用している。SaaS4つを同時開発した経験があるが、その中でRow Level Security(RLS)の設計をミスって冷や汗をかいたことがある。

この記事では、SupabaseのRLSを使って安全なマルチテナントSaaSを構築する方法を、実体験を交えて解説する。

なぜRLSが必要なのか

アプリケーション層だけでのアクセス制御は危険

典型的なパターンはこうだ。

// ❌ アプリ層だけでテナント制御(危険)
const { data } = await supabase
  .from('projects')
  .select('*')
  .eq('tenant_id', currentUser.tenantId)

一見問題ないが、WHERE句の付け忘れが1箇所でもあれば全テナントのデータが漏れる。開発者が増えるほどリスクは上がる。

RLSはデータベース層でアクセス制御をかけるため、アプリ側でWHERE句を忘れても漏洩しない。

-- ✅ RLSでデータベース層から保護
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "tenant_isolation" ON projects
  USING (tenant_id = auth.jwt() ->> 'tenant_id');

マルチテナントSaaSのRLS設計パターン

1. テーブル設計

まず、全テーブルにtenant_idカラムを持たせる。

CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  plan TEXT DEFAULT 'free',
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE profiles (
  id UUID PRIMARY KEY REFERENCES auth.users(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  role TEXT NOT NULL DEFAULT 'member',
  display_name TEXT
);

CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id UUID NOT NULL REFERENCES projects(id),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  title TEXT NOT NULL,
  status TEXT DEFAULT 'todo',
  assigned_to UUID REFERENCES profiles(id)
);

tasksテーブルにもtenant_idを持たせている点がポイントだ。JOINなしでRLSポリシーを評価できるため、パフォーマンスが大幅に向上する。

2. テナントIDの取得関数

JWTのカスタムクレームからテナントIDを取得するヘルパー関数を作る。

CREATE OR REPLACE FUNCTION auth.tenant_id()
RETURNS UUID AS $$
  SELECT (auth.jwt() ->> 'tenant_id')::UUID;
$$ LANGUAGE sql STABLE SECURITY DEFINER;

3. RLSポリシーの実装

-- tenants: 自分のテナントのみ参照可能
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;

CREATE POLICY "tenants_select" ON tenants
  FOR SELECT USING (id = auth.tenant_id());

-- profiles: 同一テナント内のメンバーを参照可能
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;

CREATE POLICY "profiles_select" ON profiles
  FOR SELECT USING (tenant_id = auth.tenant_id());

CREATE POLICY "profiles_update_self" ON profiles
  FOR UPDATE USING (id = auth.uid());

-- projects: テナント単位でCRUD
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "projects_select" ON projects
  FOR SELECT USING (tenant_id = auth.tenant_id());

CREATE POLICY "projects_insert" ON projects
  FOR INSERT WITH CHECK (tenant_id = auth.tenant_id());

CREATE POLICY "projects_update" ON projects
  FOR UPDATE USING (tenant_id = auth.tenant_id());

CREATE POLICY "projects_delete" ON projects
  FOR DELETE USING (
    tenant_id = auth.tenant_id()
    AND EXISTS (
      SELECT 1 FROM profiles
      WHERE profiles.id = auth.uid()
      AND profiles.role = 'admin'
    )
  );

-- tasks: テナント単位でCRUD
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;

CREATE POLICY "tasks_select" ON tasks
  FOR SELECT USING (tenant_id = auth.tenant_id());

CREATE POLICY "tasks_insert" ON tasks
  FOR INSERT WITH CHECK (tenant_id = auth.tenant_id());

CREATE POLICY "tasks_update" ON tasks
  FOR UPDATE USING (tenant_id = auth.tenant_id());

4. JWTカスタムクレームの設定

Supabaseでサインアップ時にテナントIDをJWTに含める。

CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
DECLARE
  _tenant_id UUID;
BEGIN
  -- 招待経由ならメタデータからテナントIDを取得
  _tenant_id := (NEW.raw_user_meta_data ->> 'tenant_id')::UUID;

  -- 新規テナント作成
  IF _tenant_id IS NULL THEN
    INSERT INTO tenants (name)
    VALUES (COALESCE(NEW.raw_user_meta_data ->> 'company_name', 'My Team'))
    RETURNING id INTO _tenant_id;
  END IF;

  -- プロフィール作成
  INSERT INTO profiles (id, tenant_id, role, display_name)
  VALUES (
    NEW.id,
    _tenant_id,
    CASE WHEN (NEW.raw_user_meta_data ->> 'tenant_id') IS NULL THEN 'admin' ELSE 'member' END,
    COALESCE(NEW.raw_user_meta_data ->> 'display_name', split_part(NEW.email, '@', 1))
  );

  -- JWTにテナントIDを含める
  UPDATE auth.users
  SET raw_app_meta_data = raw_app_meta_data || jsonb_build_object('tenant_id', _tenant_id)
  WHERE id = NEW.id;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

実運用で踏んだ落とし穴

落とし穴1: RLSを有効にしただけでポリシーを作り忘れ

RLSを有効にしてポリシーを定義しないと、全行が拒否される。開発中は動いていたのにステージングで突然データが見えなくなって焦った。

-- RLSを有効にする = デフォルトで全拒否
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- ← ここでポリシーを作らないと、SELECTすら通らない

対策として、マイグレーションファイルではENABLE ROW LEVEL SECURITYとCREATE POLICYを必ずセットで書くルールにした。

落とし穴2: service_roleキーの取り扱い

Supabaseのservice_roleキーはRLSをバイパスする。これをフロントエンドに露出すると全テナントのデータにアクセスできてしまう。

// ❌ 絶対にやってはいけない
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!)

// ✅ フロントエンドはanon keyのみ
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)

service_roleキーはサーバーサイド(API Routes / Server Actions)でのみ使い、管理者向けの操作に限定する。

落とし穴3: JOINを跨いだRLSのパフォーマンス問題

最初はtasksテーブルにtenant_idを持たせず、projectsテーブル経由でテナントを判定していた。

-- ❌ JOINが必要なRLSポリシー(遅い)
CREATE POLICY "tasks_select" ON tasks
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM projects
      WHERE projects.id = tasks.project_id
      AND projects.tenant_id = auth.tenant_id()
    )
  );

データ量が増えるとクエリが劇的に遅くなった。冗長でもtenant_idを各テーブルに持たせることで解決した。

僕の経験上、10事業を同時運用していたときに「正規化を追求するより実用性を優先すべき」と痛感した。RLSのパフォーマンスも同じで、理想より現実を取るべきだ。

RLSのテスト方法

RLSの設定ミスは致命的なので、テストは必須だ。

-- テスト用: 特定ユーザーとしてクエリを実行
SET request.jwt.claims = '{"sub": "user-uuid-1", "tenant_id": "tenant-uuid-1"}';
SET role = 'authenticated';

-- tenant-uuid-1のデータだけ返ることを確認
SELECT * FROM projects;

-- 別テナントのデータが見えないことを確認
SET request.jwt.claims = '{"sub": "user-uuid-2", "tenant_id": "tenant-uuid-2"}';
SELECT * FROM projects;
-- → tenant-uuid-1のデータが返らないこと

TypeScriptでの自動テストも組む。

import { createClient } from '@supabase/supabase-js'

describe('RLS: tenant isolation', () => {
  const tenantAClient = createClient(SUPABASE_URL, ANON_KEY, {
    global: { headers: { Authorization: `Bearer ${tenantAToken}` } }
  })
  const tenantBClient = createClient(SUPABASE_URL, ANON_KEY, {
    global: { headers: { Authorization: `Bearer ${tenantBToken}` } }
  })

  test('テナントAはテナントBのプロジェクトを参照できない', async () => {
    const { data } = await tenantAClient
      .from('projects')
      .select('*')
      .eq('tenant_id', tenantBId)

    expect(data).toHaveLength(0)
  })

  test('テナントAは自分のプロジェクトのみ参照できる', async () => {
    const { data } = await tenantAClient
      .from('projects')
      .select('*')

    data?.forEach(project => {
      expect(project.tenant_id).toBe(tenantAId)
    })
  })
})

管理者向け機能のRLS設計

SaaSには管理者ダッシュボードが必要になる。テナントの管理者だけが特定の操作をできるようにする。

CREATE OR REPLACE FUNCTION auth.is_tenant_admin()
RETURNS BOOLEAN AS $$
  SELECT EXISTS (
    SELECT 1 FROM profiles
    WHERE id = auth.uid()
    AND tenant_id = auth.tenant_id()
    AND role = 'admin'
  );
$$ LANGUAGE sql STABLE SECURITY DEFINER;

-- メンバー管理: adminのみ
CREATE POLICY "profiles_admin_manage" ON profiles
  FOR ALL USING (
    tenant_id = auth.tenant_id()
    AND auth.is_tenant_admin()
  );

チェックリスト

マルチテナントSaaSをリリースする前に確認すべき項目をまとめた。

  • 全テーブルでRLSが有効になっているか
  • 全テーブルにSELECT/INSERT/UPDATE/DELETEのポリシーがあるか
  • tenant_idカラムにインデックスが貼られているか
  • JWTカスタムクレームにtenant_idが含まれているか
  • service_roleキーがフロントエンドに露出していないか
  • テナント間のデータ隔離テストが通っているか
  • supabase_adminロールの使用箇所が限定されているか
  • マイグレーションファイルでRLS有効化とポリシー作成がセットになっているか

まとめ

SupabaseのRLSは、マルチテナントSaaSにおけるデータ隔離の最後の砦だ。アプリケーション層のアクセス制御は「あったら便利」だが、RLSは「なければ事故」になる。

僕が複数のSaaSを同時開発した経験から言えるのは、「プロダクト完成と売上は別の話だが、セキュリティ事故が起きたらその両方を失う」ということだ。RLSの設計は最初にしっかりやっておくべき投資だと思う。

この記事が、安全なマルチテナントSaaS構築の参考になれば幸いだ。


筆者はAI経営の実践知を書籍にまとめています。Supabaseを使ったプロダクト開発やAI活用の実践ノウハウに興味がある方は、ぜひご覧ください。

👉 Zenn書籍一覧

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?