Next.jsとSupabaseで構築するマルチテナントSaaS:開発者が「安眠できる」ソリューション

Development tutorial - IT technology blog
Development tutorial - IT technology blog

2時AMのトラブル:データ障壁が崩壊したとき

深夜、デスクの上のスマートフォンが激しく振動しました。上司からVIP顧客のスクリーンショットと共に、「なぜ彼らが競合他社の売上を見ているんだ?」という短いメッセージが届いたのです。冷や汗が止まりません。これこそがSaaS運営における最大の悪夢、クロス・テナント・データ漏洩(Cross-tenant data leak)です。

以前のシステムは伝統的な手法で動いていました。すべてのテーブルに tenant_id カラムがあり、APIレイヤーで WHERE 句を使ってデータをフィルタリングしていました。しかし、コードを書く際の一瞬の不注意や、新しい関数でフィルタ条件を追加し忘れるだけで、顧客のビジネス上の機密がすべて露呈してしまいます。また、Nginxで数百もの tenant-a.com のようなサブドメインを手動で管理することも、顧客が10社から500社に増えるにつれて、すぐに収拾がつかない状態になりました。

「アプリケーション層でのフィルタリング」という考え方の欠陥

徹夜で修正作業を行った後、旧アーキテクチャには2つの致命的な弱点があることに気づきました。

  • 煩雑なサブドメイン管理: DNSとサーバーの手動設定はスケールしません。新しい顧客ごとに運用チームが約30分を費やしていました。
  • 人間の注意深さへの依存: データのフィルタリングが完全にNext.jsのコードに依存していました。ジュニアデベロッパーが tenant_id のチェックを忘れた場合、その結果は極めて悲惨なものになります。

これを根本的に解決するために、システムを再構築しました。Next.js Middlewareを組み合わせて動的なルーティングを処理し、Supabase Row Level Security (RLS)を使用してデータベース層から直接データをロックするようにしました。

3つのマルチテナント・モデル:最適な選択肢は?

SaaSのデータベース設計には、主に3つのアプローチがあります。

  • Database-per-tenant: 顧客ごとに1つのDB。非常に安全ですが、運用コストが非常に高く、マイグレーションのメンテナンスは苦行です。
  • Schema-per-tenant: 1つのDBに複数のスキーマ。悪くない方法ですが、現在のORMツールの多くは、動的なスキーマの切り替えをスムーズにサポートしていません。
  • Shared Database (Row-level separation): テーブルを共有し、IDで区別する。低レイヤーのセキュリティメカニズムで保護されている限り、リソースを節約でき拡張も容易なため、これが私の選択です。

実装:サブドメインからデータベースまで

1. Next.js Middlewareによる動的なサブドメイン処理

個別の名前ごとにDNSを設定する代わりに、Middlewareを使用してリクエストを「インターセプト」します。システムは、ユーザーがアクセスしているURLに基づいてテナントを自動的に特定します。

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(req: NextRequest) {
  const url = req.nextUrl;
  const hostname = req.headers.get('host');

  // サブドメインを抽出(例:client-a.saas.com -> client-a)
  const currentHost = hostname?.replace(`.mysaas.com`, '');

  if (currentHost && !['www', 'mysaas.com'].includes(currentHost)) {
    // /_tenants/[subdomain] フォルダ構造へ内部的に書き換え(Rewrite)
    return NextResponse.rewrite(new URL(`/_tenants/${currentHost}${url.pathname}`, req.url));
  }

  return NextResponse.next();
}

この方法なら、app/_tenants/[subdomain]/page.tsx フォルダを作成するだけです。あとはNext.jsが処理してくれます。新しい顧客のオンボーディングは、一瞬で、完全に自動化されます。

2. 「鉄壁」のデータベース構築

テーブル構造には密接な連携が必要です。各業務データテーブルには、所有者を特定するための tenant_id が必須となります。

-- 法人顧客リストテーブル
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  slug TEXT UNIQUE -- 識別用サブドメイン
);

-- 特定のテナントに紐付くユーザーデータテーブル
CREATE TABLE profiles (
  id UUID REFERENCES auth.users ON DELETE CASCADE,
  tenant_id UUID REFERENCES tenants(id),
  full_name TEXT,
  PRIMARY KEY (id)
);

3. Row Level Security (RLS):最後の鉄壁の鎧

これこそが私が安眠できるようになった技術です。Next.jsのコードが正しく書かれていることを祈るのではなく、データベース自体にアクセス権限をチェックさせます。まず、RLSを有効にします。

ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;

次に、Postgresがログイン中のユーザーの tenant_id に属するデータのみを返すように、ポリシー(Policy)を作成します。ちょっとしたコツとして、tenant_id をJWTに保存しておくことで、profilesテーブルへのクエリを繰り返す必要がなくなり、クエリ速度を2〜3倍に向上させることができます。

CREATE POLICY "テナント分離ポリシー" 
ON profiles
FOR ALL 
USING (
  tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
);

これで、たとえ supabase.from('profiles').select('*') と書いてフィルタを追加し忘れても、Supabaseはそのテナント의データのみを返します。テナントAが意図的にテナントBのIDにアクセスしようとしても、返される結果は空になります。完璧な安全性です。

Supabaseの複雑なJSON設定をデバッグする際、私はよく toolcraft.appJSON Formatter などのツール)を使って素早くチェックします。急ぎの対応が必要なときに、VS Codeの重い拡張機能を開くよりもずっと便利です。

アプリケーション層でのアクセス制御

データベースが安全であっても、UI層でのチェックは必要です。ユーザーがログインした際、彼らの tenant_id と現在のサブドメインを照合します。もし企業Aの従業員が企業Bのページにログインしようとしたら、即座にブロックします。

const { data: profile } = await supabase.from('profiles').select('tenant_id').single();

if (profile?.tenant_id !== currentTenantIdFromUrl) {
  // 権限がない場合はリダイレクト
  return redirect('/unauthorized');
}

結び:顧客データで賭けをしてはいけない

SaaSを構築することは、単に機能を作ることではなく、信頼を築くことです。RLSを通じてセキュリティの責任をデータベース層に押し下げることは、データを保護するための最も持続可能なアーキテクチャへと導く方法です。車輪の再発明は避けましょう。Next.jsのMiddlewareとSupabase上のPostgreSQLの力を活用して、顧客のための実際の機能開発に集中しましょう。

Share: