saas-multi-tenant について
マルチテナント SaaS を構築する開発者向けのアーキテクチャガイドです。テナント分離、データベースの行レベルセキュリティ、リクエストコンテキストに加え、マイグレーションや管理用 API、バックグラウンドジョブも扱います。
主な機能
- 01テナント分離モデルの比較
- 02tenant_id 列とインデックスの設計
- 03PostgreSQL の行レベルセキュリティ設定
- 04テナント対応ミドルウェアの構築
- 05マイグレーションとジョブ分離の計画
活用シーン
- 01共有データベースの SaaS にテナント分離を追加
- 02PostgreSQL RLS でテナント間読み取りを防止
- 03保護されたテナント横断管理 API を実装
- 04テナントのエクスポートとジョブの範囲を限定
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ sickn33/ agentic-awesome-skills --skill saas-multi-tenantインストール後、Claude Code で/saas-multi-tenantを入力して読み込まれたか確認します。
詳細
SaaS マルチテナントアーキテクチャ
このスキルを使用する場合
- 複数の顧客が同じデータベースを共有する SaaS アプリケーションを構築している場合
- テナントの分離、行レベルセキュリティ、またはデータ漏えいの防止について質問された場合
- 手動で WHERE 句を追加せずに、すべてのデータベースクエリを特定のテナントにスコープする必要がある場合
- 共有スキーマ、テナントごとのスキーマ、テナントごとのデータベースのトレードオフについて質問された場合
- テナントをまたいでデータにアクセスする必要がある管理者向けエンドポイントを実装している場合
- 既存のシングルテナントアプリケーションに
tenant_id列を追加する必要がある場合 - テナント分離のための PostgreSQL RLS ポリシーについて質問された場合
- Express、Fastify、または Next.js API ルートでテナント対応ミドルウェアを構築している場合
次の場合はこのスキルを使用しないでください。
- 共有インフラストラクチャのないシングルユーザー向けアプリケーションを構築している場合
- テナントのスコープ設定を伴わない認証のみについて質問された場合(代わりに認証スキルを使用してください)
- マルチテナント要件のない一般的なデータベーススキーマ設計が必要な場合
基本ワークフロー
-
テナンシーモデルを決定します。想定規模と分離要件についてユーザーに確認してください。1000 テナント未満のほとんどの SaaS アプリでは、すべてのテーブルに
tenant_id列を持たせる共有スキーマが適切なデフォルトです。テナントごとのスキーマでは運用上の負担が増え(マイグレーションを N 回実行)、分離、ノイジーネイバー、復旧、顧客管理キー、または運用上のニーズがあれば、テナントごとのデータベースも正当な選択肢になります。実際の要件を比較してください。 -
テナントスコープ対象のすべてのテーブルに
tenant_idを追加します。この列はNOT NULLとし、型はUUIDまたはTEXTにして、すべての複合インデックスに含める必要があります。この列のないテナントスコープ対象テーブルは決して作成しないでください。tenant_idの欠落は、いつデータ漏えいが起きてもおかしくない状態です。 -
PostgreSQL Row-Level Security(RLS)を設定します。テナントスコープ対象の各テーブルに、
current_setting('app.current_tenant_id')で行をフィルタリングするポリシーを作成します。これはデータベースレベルのセーフティネットとして機能し、アプリケーションコードが WHERE 句を忘れても、RLS がテナントをまたぐ読み取りをブロックします。 -
テナント対応ミドルウェアを構築します。各リクエストの開始時に、認証済みセッションまたは JWT クレームから
tenant_idを取得します。トランザクション内でSET LOCAL app.current_tenant_id = '...'を使い、データベース接続に設定します。そのリクエスト内の後続のクエリはすべて、自動的にテナントスコープを引き継ぎます。 -
すべての ORM クエリをテナントにスコープします。Prisma を使用する場合は、すべての
findMany、findFirst、update、delete呼び出しにwhere: { tenantId }を挿入するグローバルミドルウェアを適用します。Drizzle を使用する場合は、テナントフィルターを含むベースクエリビルダーを作成します。開発者が手動でフィルターを追加することを決して前提にしないでください。 -
テナント対応のマイグレーションを処理します。新しいテーブルを作成するすべてのマイグレーションに、列として
tenant_idを含める必要があります。plansやfeature_flagsなど、グローバルであることが明示されたテーブルを除き、tenant_idのないテーブルを作成するマイグレーションを拒否するリンティングルールまたは CI チェックを作成します。 -
テナントをまたぐ管理者向けルートは分けて構築します。テナントをまたいでデータを集計する管理者向けエンドポイントでは、
SET LOCAL role = 'admin_bypass'または専用のデータベースロールを使って、明示的に RLS をバイパスする必要があります。これらのルートは別個の管理者認証フローで保護し、管理者アクセスにテナントユーザーのセッションを決して再利用しないでください。 -
テナントのプロビジョニングを実装します。新しい顧客が登録したら、そのテナントレコードを作成し、デフォルトデータ(ロール、設定、オンボーディング状態)をシードし、創設ユーザーを割り当てます。これをデータベーストランザクション内で実行し、プロビジョニングが途中で失敗して孤立したレコードが残らないようにします。
例
例 1:テナント分離のための PostgreSQL RLS ポリシー
-- Enable RLS on the table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
-- Policy: users can only see rows where tenant_id matches the session variable
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Policy for INSERT: new rows must match the current tenant
CREATE POLICY tenant_insert ON projects
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
例 2:明示的なトランザクション境界
HTTP レスポンスの finish イベントでコミットしないでください。ハンドラーが失敗したり、クライアントが切断したり、COMMIT が失敗する前にレスポンスがすでに成功を報告したりする可能性があります。await するトランザクションラッパーを使用し、コミット後にのみレスポンスを送信してください。
async function withTenantTransaction(pool, tenantId, work) {
const client = await pool.connect();
let destroy = false;
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.current_tenant_id', $1, true)", [tenantId]);
const result = await work(client); // Every tenant query uses this client.
await client.query("COMMIT");
return result;
} catch (error) {
try { await client.query("ROLLBACK"); }
catch { destroy = true; }
throw error;
} finally {
client.release(destroy); // Destroy on rollback failure; release exactly once.
}
}
使用前に、リクエストのタイムアウトとキャンセル、および対象固有の統合テストを追加してください。呼び出す前に、ユーザーが現在そのテナントに所属していることをサーバー側で検証してください。未検証のテナントクレームやルーティングヘッダーを決して信頼しないでください。
例 3:過去の Prisma ミドルウェアの概略
この不完全な旧式の $use の例は、分離レイヤーではありません。findUnique、upsert、ネストされた操作、リレーションの読み込み、raw クエリを対象としておらず、トランザクションローカルな RLS コンテキストも設定しません。現行の Prisma バージョンでは、代わりに拡張機能が必要な場合があります。自動的な保護機能としてデプロイしないでください。すべての操作を列挙し、クライアントによるテナント所有権の変更を禁止し、ポリシーをテストしたうえで、所有者でも BYPASSRLS でもないデータベースロールを使って実行してください。
import { PrismaClient } from "@prisma/client";
// Tables that do NOT have tenant_id (global tables)
const GLOBAL_TABLES = new Set(["Plan", "FeatureFlag", "SystemConfig"]);
function createTenantPrisma(tenantId: string): PrismaClient {
const prisma = new PrismaClient();
prisma.$use(async (params, next) => {
if (GLOBAL_TABLES.has(params.model ?? "")) return next(params);
// Initialize args.where — Prisma passes undefined args for calls like findMany()
params.args = params.args ?? {};
params.args.where = params.args.where ?? {};
// Inject tenant filter on reads (skip findUnique — it only accepts unique-field selectors)
if (["findMany", "findFirst", "count", "aggregate"].includes(params.action)) {
params.args.where = { ...params.args.where, tenantId };
}
// Inject tenant_id on creates
if (["create", "createMany"].includes(params.action)) {
params.args.data = params.args.data ?? {};
if (params.action === "createMany") {
params.args.data = params.args.data.map((d: any) => ({ ...d, tenantId }));
} else {
params.args.data = { ...params.args.data, tenantId };
}
}
// Scope updates and deletes
if (["update", "updateMany", "delete", "deleteMany"].includes(params.action)) {
params.args.where = { ...params.args.where, tenantId };
}
return next(params);
});
return prisma;
}
絶対にしてはいけないこと
-
tenant_idフィルターなしで、テナントスコープのテーブルをクエリしないでください。 ORM ミドルウェアが処理していても、raw SQL クエリはミドルウェアを完全に迂回します。すべての raw クエリにはWHERE tenant_id = $1を含めるか、RLS を利用する必要があります。スコープ指定のないSELECT * FROM invoicesが一つあるだけで、すべての顧客の請求データが漏えいします。 -
データベースレベルで強制せず、
tenant_idをアプリケーションセッションにだけ保存しないでください。 アプリケーション層でのフィルタリングは提案にすぎません。RLS は強制するものです。ミドルウェアのバグでテナントフィルターが省かれた場合、データ漏えいを防ぐのは RLS だけです。両方のレイヤーを使用してください。 -
テナントスコープのリソースに、自動採番の整数 ID を使わないでください。 連番 ID(
invoice #1042)を使うと、攻撃者は ID を増やして、他のテナントのリソースを列挙できます。テナントスコープの主キーにはすべて UUID を使用してください。整数 ID は内部専用テーブルにのみ使用してください。 -
テナントユーザーに管理者向け集計エンドポイントを利用させないでください。 全テナントを対象にクエリする
GET /admin/metricsのようなルートを、通常のテナント JWT で決して利用可能にしてはいけません。テナント横断のルートには、別の認証方式(API キー、異なる発行者による管理者ロールクレーム)を使用してください。 -
最小権限の移行専用ロールを使用してください。 RLS が制御するのは行へのアクセスであり、DDL の可視性ではありません。ALTER TABLE により、一つのテナントだけが暗黙に変更されることはありません。データ移行は対象となるすべてのテナントに対してテストし、昇格権限は必要な操作に限って付与してください。デフォルトでスーパーユーザーを使ってはいけません。
-
確実なトランザクション後処理を行う場合に限り、コネクションプールを共有してください。 トランザクション内で
SET LOCAL app.current_tenant_idを使う場合、その設定はトランザクション内に限定されます。しかし、前のリクエストのトランザクションが正しくコミットまたはロールバックされていないと、接続に古いテナントコンテキストが残ったままプールに戻されます。接続を解放する前にコミットまたはロールバックしてください。ロールバックに失敗した場合は、接続を破棄してください。SET LOCAL はトランザクション終了時に失効しますが、RESET では開いたまま、または失敗したトランザクションを修復できません。
エッジケース
-
テナントの削除とデータ保持。 テナントがサブスクリプションを解約したとき、単純に
DELETE FROM tenants WHERE id = $1を実行することはできません。大規模なデータセットでは、外部キーのカスケード削除がタイムアウトする可能性があります。代わりに、テナントを論理削除し(deleted_atを設定)、すべてのユーザーセッションを無効化したうえで、バックグラウンドジョブを実行して、数時間から数日にわたりテナントデータをバッチ単位で削除します。 -
GDPR/コンプライアンス対応のテナントデータエクスポート。 テナントから全データのエクスポートを求められた場合、すべてのテナント単位のテーブルを
tenant_idで検索し、パッケージ化する必要があります。エクスポートジョブが、エクスポート機能の構築後に追加されたテーブルを見落とさないよう、すべてのテナント単位のテーブルのレジストリを作成してください(マイグレーションファイルを解析するか、マニフェストを管理します)。 -
テナント間で共有されるリソース。 マーケットプレイスで、テナント A の商品をテナント B のユーザーにも表示する場合など、共有状態が必要な機能もあります。これらのテーブルには異なる RLS ポリシーが必要です。読み取りアクセスは公開(テナントによるフィルタリングなし)としますが、書き込みアクセスは所有テナントに引き続き限定します。これらのテーブルは
tenant_idではなくowner_tenant_idとしてモデル化します。 -
テナントを認識するバックグラウンドジョブ。 cron ジョブやキューワーカーがタスクを処理する際、
tenant_idを取得する HTTP リクエストはありません。ジョブのペイロードにtenant_idを含め、処理前にワーカーがデータベースのセッション変数を設定する必要があります。テナントコンテキストなしでバックグラウンドジョブを実行してはいけません。RLS によって失敗するか、RLS を完全に迂回することになります。 -
スキーマごとにテナントを分ける場合の接続プール枯渇。 テナントごとに PostgreSQL スキーマを 1 つ使用し、スキーマごとに専用の接続プールが必要な場合、テナントが 500 件あればプールも 500 個になります。これでは
max_connectionsをすぐに使い切ってしまいます。PgBouncer のトランザクションモードなどの接続プーラーを使用するか、この問題が起きる前に共有スキーマ方式へ切り替えてください。
ベストプラクティス
-
信頼できる唯一の情報源として
tenantsテーブルを作成します。 すべてのテーブルのtenant_id外部キーがtenants.idを参照するようにします。name、slug(サブドメインによるルーティング用)、plan_id、created_at、deleted_atの各列を含めてください。このテーブルが、データモデル全体のルートになります。 -
すべての複合インデックスで、
tenant_idを最初の列にします。 PostgreSQL は、複合インデックスで最左プレフィックスマッチングを使用します。(tenant_id, created_at)のインデックスは、「テナント X のすべてのアイテム」と「日付順に並べたテナント X のアイテム」の両方に対応します。(created_at, tenant_id)のインデックスは、すべてのテナントを対象とした日付範囲クエリにしか役立ちません。 -
テナントのルーティングにはサブドメインまたはパスプレフィックスを使用します。
acme.yourapp.comとyourapp.com/org/acmeのどちらでも機能します。エッジ(ミドルウェアまたはリバースプロキシ)でサブドメインまたはパスをtenant_idに対応付けます。この対応付けはすべてのリクエストで実行されるため、キャッシュ(Redis、または TTL 60 秒のインメモリキャッシュ)する必要があります。 -
テナント単位のテーブルとグローバルテーブルを明確に分けます。 どのテーブルがグローバル(
tenant_idなし)で、どのテーブルがテナント単位なのかをリスト(コード定数またはデータベーステーブル)として管理します。このリストを ORM ミドルウェア、マイグレーションのリンター、データエクスポートジョブで使用してください。いずれのリストにも含まれていないテーブルがあれば、CI チェックを失敗させます。 -
シードデータには少なくとも 3 つのテナントを用意してテストします。 開発環境にテナントが 1 つしかないと、マルチテナントのバグをすべて見逃します。テナントが 2 つの場合、最初のテナントのデータが 2 番目には漏れるが、その逆は起きないバグを見逃します。テナントが 3 つあれば、複数の同格テナントがいる場合にのみ発生する、順序やフィルタリングに関するバグを検出できます。
-
レート制限とクォータはグローバルではなく、テナントごとに設定します。 グローバルなレート制限が毎分 1000 リクエストの場合、リクエストの多いテナントが全員分のクォータを使い切る可能性があります。スライディングウィンドウカウンターを使い、
ratelimit:{tenant_id}:{endpoint}のような Redis キーパターンでテナントごとのレート制限を実装してください。
制限事項
- タスクが上記の対象範囲に明確に当てはまる場合にのみ、このスキルを使用してください。
- 出力を、環境固有の検証、テスト、または専門家によるレビューの代わりとして扱わないでください。
- 必要な入力、権限、安全上の境界、または成功基準が不足している場合は、作業を中断して確認を求めてください。