Claude CodeでマルチテナントSaaSを安全に実装する実務ガイド
Claude Codeでtenant_id、RLS、認証、課金、ジョブ、ログを安全に実装する実務ガイド。
マルチテナントSaaSとは、1つのアプリケーションで複数の顧客組織を扱う作り方です。ここでいうテナントは「顧客ごとの境界」です。Claude Codeに画面やAPIを速く作らせるほど、この境界を曖昧にしたまま実装が進みやすくなります。
一番怖い事故は、Tenant AのユーザーがTenant Bの請求、案件、ログ、添付ファイルを読めることです。コードレビューでwhere: { tenantId }を探すだけでは足りません。認証、セッション、DB、ジョブ、ログ、課金の全部にtenant_idを流し、どこか1つが抜けてもDBが最後に止める構成にします。
この記事はClaude Codeを「実装係」ではなく「境界条件を一緒に潰す相棒」として使う前提で書きます。RLSはRow Level Securityの略で、行ごとにアクセス可否をDB側で判定する仕組みです。公式仕様はPostgreSQLのRow Security Policies、ポリシー構文はCREATE POLICY、セッション変数はcurrent_settingとset_configを基準に確認してください。
関連する基礎として、認証はClaude Codeで認証を実装する方法、権限設計はRBAC実装ガイド、スキーマ変更はデータベースマイグレーションも合わせて見ると、この記事の設計がつながります。
分離モデルを先に決める
テナント分離には大きく3つのモデルがあります。
| モデル | 向いている場面 | 強み | 落とし穴 |
|---|---|---|---|
| 共有DB・共有スキーマ | 初期SaaS、顧客数が多いB2Bツール | 運用が軽く、分析もしやすい | tenant_id漏れが即事故になる |
| 共有DB・テナント別スキーマ | 大口顧客だけ分離したい業務SaaS | マイグレーションと権限を少し分けられる | スキーマ数が増えると運用が重い |
| テナント別DB | 金融、医療、強い契約分離が必要な案件 | バックアップ、鍵、停止範囲を分けやすい | コストとデプロイ手順が重い |
最初の判断は「顧客が100社を超えたときに運用できるか」です。ほとんどの小中規模SaaSでは共有DB・共有スキーマから始め、tenant_id、RLS、監査ログ、バックアップ復元手順を強くする方が現実的です。ただし、特定顧客だけ契約で物理分離を求めるなら、最初から分離DBへ逃がせる設計にしておきます。
実例は3つあります。1つ目はB2B CRMです。案件、会社、担当者、メモがすべてテナント配下にあり、営業担当は自社の行だけ見ます。2つ目は制作会社向けのクライアントポータルです。代理店スタッフは複数テナントに所属しますが、顧客担当者は自社ポータルだけです。3つ目はAI機能付きSaaSです。プロンプト実行回数、トークン、保存ファイル容量をテナント単位で集計し、プラン上限を超えたら止めます。
Claude Codeへの最初の依頼は、実装ではなく設計レビューにします。
このリポジトリのSaaS機能をマルチテナント化したい。
共有DB・共有スキーマを前提に、tenant_idが必要なテーブル、不要な参照テーブル、RLSを必須にするテーブルを分類してください。
危険なショートカットは禁止です。特に「アプリ側whereだけで十分」「ログにリクエスト本文を丸ごと出す」「ジョブはtenant_idなしで後から検索する」は不可。
出力は、変更対象ファイル、DB制約、テストケース、移行リスクに分けてください。
tenant_idを信頼できる場所から流す
tenant_idはURLパラメータやクライアント送信ヘッダーをそのまま信用しません。安全な順序は、ホスト名やパスから候補テナントを解決し、サーバー側セッションでそのユーザーが所属しているかを確認し、確認済みのtenantIdだけをアプリ内部に渡すことです。
Auth.jsはセッションへユーザーIDを追加する例を公式のExtending the Sessionで示しています。ただし、セッションに大量の権限や顧客データを詰めるのは避けます。セッションは「誰か」を示し、テナント所属はサーバー側DBで毎回確認する方がレビューしやすいです。
// src/lib/tenant-context.ts
import { z } from "zod";
const hostSchema = z.string().min(1).max(255);
export type SessionUser = {
id: string;
email: string;
};
export type TenantContext = {
tenantId: string;
userId: string;
role: "owner" | "admin" | "member" | "viewer";
requestId: string;
};
type TenantRecord = {
id: string;
subdomain: string | null;
custom_domain: string | null;
};
type MembershipRecord = {
tenant_id: string;
user_id: string;
role: TenantContext["role"];
};
export async function resolveTenantContext(input: {
request: Request;
sessionUser: SessionUser | null;
requestId: string;
findTenantByHost: (host: string) => Promise<TenantRecord | null>;
findMembership: (tenantId: string, userId: string) => Promise<MembershipRecord | null>;
}): Promise<TenantContext> {
if (!input.sessionUser) {
throw new Response("Unauthorized", { status: 401 });
}
const host = hostSchema.parse(new URL(input.request.url).host.toLowerCase());
const tenant = await input.findTenantByHost(host);
if (!tenant) {
throw new Response("Tenant not found", { status: 404 });
}
const membership = await input.findMembership(tenant.id, input.sessionUser.id);
if (!membership) {
throw new Response("Forbidden for this tenant", { status: 403 });
}
return {
tenantId: tenant.id,
userId: input.sessionUser.id,
role: membership.role,
requestId: input.requestId,
};
}
この関数のポイントは、x-tenant-idを読まないことです。社内APIやジョブでも同じです。外から来たtenant_idは「候補」にすぎず、必ずmembership、API key、または署名済みジョブデータで検証します。
PostgreSQL RLSを最後の防波堤にする
アプリのwhere tenant_id = ...は必要ですが、それだけに頼ると1つのクエリ漏れで終わります。共有DB・共有スキーマでは、全テナント表にtenant_id、外部キー、インデックス、RLSを入れます。PostgreSQLではテーブル所有者やBYPASSRLS属性を持つロールがRLSを迂回できるため、アプリ接続ロールを所有者にしない、必要な表ではFORCE ROW LEVEL SECURITYを使う、という運用も重要です。
-- db/migrations/20260602_multi_tenant_rls.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
name text NOT NULL,
plan text NOT NULL DEFAULT 'starter',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE app_users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tenant_memberships (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE TABLE projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_by uuid NOT NULL REFERENCES app_users(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX projects_tenant_id_id_idx ON projects (tenant_id, id);
CREATE INDEX tenant_memberships_user_id_tenant_id_idx ON tenant_memberships (user_id, tenant_id);
ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_memberships FORCE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_memberships_isolation
ON tenant_memberships
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY projects_isolation
ON projects
FOR ALL
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
RLSで使うapp.tenant_idは接続ごとの設定値です。接続プールを使う場合、セッションに残る値は危険なので、トランザクション内でset_config(..., true)を使い、処理が終わったら自動で消えるようにします。
// src/db/tenant-db.ts
import { Pool, PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export async function withTenant<T>(
tenantId: string,
work: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!uuidPattern.test(tenantId)) {
throw new Error("Invalid tenant id");
}
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
const result = await work(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function listProjects(tenantId: string) {
return withTenant(tenantId, async (db) => {
const result = await db.query(
"SELECT id, name, status, created_at FROM projects ORDER BY created_at DESC",
);
return result.rows;
});
}
この例ではSQLにWHERE tenant_id = $1を書いていません。わざとです。RLSが効いていれば、DBが自動的に現在のテナント行だけを返します。ただし実務では、性能のためにアプリ側にもtenant_id条件を入れることがあります。その場合でもRLSを外さないことが大事です。
認証、課金、ジョブを同じ境界にそろえる
認証境界は「ログインしているか」ではなく「このテナントで何ができるか」です。user_idだけでレコードを引くと、複数組織に所属する代理店ユーザーで事故が起きます。すべての操作にtenantId、userId、role、requestIdを渡します。
課金も同じです。Stripeのusage-based billingでは使用量を送る設計が中心になりますが、アプリ側では請求前にテナント別の利用量を正しく集計できなければいけません。プラン上限はUIの表示だけでなく、API実行前にサーバー側で止めます。
// src/lib/plan-limits.ts
type Plan = "starter" | "growth" | "enterprise";
type Metric = "users" | "projects" | "aiRuns";
const PLAN_LIMITS: Record<Plan, Record<Metric, number>> = {
starter: { users: 5, projects: 20, aiRuns: 500 },
growth: { users: 50, projects: 500, aiRuns: 10_000 },
enterprise: { users: 10_000, projects: 1_000_000, aiRuns: 50_000_000 },
};
export function assertPlanLimit(input: {
plan: Plan;
metric: Metric;
currentUsage: number;
increment?: number;
}) {
const nextUsage = input.currentUsage + (input.increment ?? 1);
const limit = PLAN_LIMITS[input.plan][input.metric];
if (nextUsage > limit) {
throw new Response(
JSON.stringify({
error: "PLAN_LIMIT_EXCEEDED",
metric: input.metric,
limit,
nextUsage,
}),
{ status: 402, headers: { "content-type": "application/json" } },
);
}
}
バックグラウンドジョブはtenant_id漏れが起きやすい場所です。キューにprojectIdだけを入れて、ワーカー側で全体検索するのは危険です。ジョブ投入時に検証済みのtenantIdを入れ、ワーカーでもwithTenantでRLSコンテキストをセットします。
// src/jobs/send-project-digest.ts
import { withTenant } from "../db/tenant-db";
type ProjectDigestJob = {
tenantId: string;
projectId: string;
requestedBy: string;
};
export async function handleProjectDigestJob(job: ProjectDigestJob) {
return withTenant(job.tenantId, async (db) => {
const project = await db.query(
"SELECT id, name FROM projects WHERE id = $1",
[job.projectId],
);
if (project.rowCount !== 1) {
throw new Error("Project not visible for tenant");
}
await db.query(
"INSERT INTO audit_events (tenant_id, actor_user_id, event_name) VALUES ($1, $2, $3)",
[job.tenantId, job.requestedBy, "project_digest_sent"],
);
});
}
ログと監査で漏らさない
ログは第二のデータベースになりがちです。OWASPのLogging Cheat Sheetでも、ログ注入対策や機密情報を除くことが重視されています。マルチテナントSaaSでは、ログにtenantIdは必要ですが、本文、Cookie、Authorization、API key、プロンプト全文、請求先住所を雑に入れてはいけません。
// src/lib/safe-log.ts
type LogLevel = "info" | "warn" | "error";
const SENSITIVE_KEYS = new Set([
"authorization",
"cookie",
"password",
"token",
"apiKey",
"prompt",
"secret",
]);
function redact(value: unknown): unknown {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(redact);
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
SENSITIVE_KEYS.has(key) ? "[REDACTED]" : redact(child),
]),
);
}
export function tenantLog(level: LogLevel, message: string, fields: Record<string, unknown>) {
const safeFields = redact(fields);
console[level](JSON.stringify({ message, ...safeFields }));
}
監査ログは通常ログと分けます。監査ログには「誰が、どのテナントで、どの種類の操作を、どのIDに対して行ったか」を残します。値の差分を全部入れると個人情報の複製になるので、差分本文は必要最小限にします。
漏洩を狙うテストを書く
テストは正常系だけでは足りません。Tenant AでログインしてTenant BのIDをURLに入れる、ジョブに別テナントのprojectIdを混ぜる、app.tenant_idをセットし忘れる、プラン上限を超える、ログにtokenが出ない、という攻撃寄りのケースを作ります。
-- db/tests/rls-smoke-test.sql
\set ON_ERROR_STOP on
BEGIN;
INSERT INTO tenants (id, slug, name)
VALUES
('00000000-0000-4000-8000-000000000001', 'alpha', 'Alpha Inc'),
('00000000-0000-4000-8000-000000000002', 'beta', 'Beta Inc');
INSERT INTO app_users (id, email)
VALUES ('10000000-0000-4000-8000-000000000001', 'masa@example.com');
INSERT INTO projects (id, tenant_id, name, created_by)
VALUES
('20000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000001', 'Alpha project', '10000000-0000-4000-8000-000000000001'),
('20000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000002', 'Beta project', '10000000-0000-4000-8000-000000000001');
SELECT set_config('app.tenant_id', '00000000-0000-4000-8000-000000000001', true);
DO $$
DECLARE visible_count integer;
BEGIN
SELECT count(*) INTO visible_count FROM projects;
IF visible_count <> 1 THEN
RAISE EXCEPTION 'RLS failed: expected 1 visible project, got %', visible_count;
END IF;
END $$;
ROLLBACK;
#!/usr/bin/env bash
set -euo pipefail
: "${DATABASE_URL:?DATABASE_URL is required}"
psql "$DATABASE_URL" -f db/migrations/20260602_multi_tenant_rls.sql
psql "$DATABASE_URL" -f db/tests/rls-smoke-test.sql
Claude Codeには、テストの観点を明示します。
次の観点でマルチテナント漏洩テストを追加してください。
1. Tenant AのセッションでTenant BのprojectIdを指定しても404または403になる。
2. RLSのapp.tenant_idをセットしないDBクエリは0件またはエラーになる。
3. バックグラウンドジョブはtenantIdなしのpayloadを拒否する。
4. プラン上限超過時はDB書き込み前に402を返す。
5. ログにauthorization、cookie、prompt、apiKeyが出ない。
テストが失敗する実装を先に説明し、修正後に実行コマンドと結果を示してください。
移行で失敗しやすいポイント
既存SaaSを後からマルチテナント化する場合、最大の失敗は「nullableなtenant_idを入れて少しずつ直す」ことです。移行期間は仕方ない場合もありますが、公開前にはNOT NULL、外部キー、インデックス、RLSまで完了させます。
具体的な落とし穴は5つあります。1つ目は、マスターデータとテナントデータを分けないことです。国コードや機能フラグ定義は共通でよい一方、顧客設定や価格例外はテナント配下です。2つ目は、添付ファイルのパスにtenant_idがないことです。DBが守れてもS3キーがuploads/project-id/file.pdfだと推測されやすくなります。3つ目は、検索インデックスにtenant_idを入れ忘れることです。Algolia、Meilisearch、OpenSearchも絞り込みキーが必要です。4つ目は、管理画面だけRLSを外すことです。サポート担当の閲覧も監査ログ付きの明示的なimpersonationにします。5つ目は、バックアップ復元です。1社だけ復元する手順がないと、障害時に全社巻き戻しを検討することになります。
Claude Codeにマイグレーションを依頼するときは、単に「tenant_idを追加して」ではなく、段階を切ります。
既存テーブルをマルチテナント化する移行計画を作ってください。
Phase 1: tenant_id列を追加し、既存データの対応表を作る。
Phase 2: backfill SQLを作り、NULL件数を検証する。
Phase 3: NOT NULL、外部キー、tenant_id付き一意制約、インデックスを追加する。
Phase 4: RLSを有効化し、FORCE ROW LEVEL SECURITYの対象を提案する。
Phase 5: API、ジョブ、ログ、検索インデックスの漏れをテストする。
各Phaseにロールバック条件と検証SQLを付けてください。
Claude Code用の安全な依頼テンプレート
マルチテナントSaaSでは、Claude Codeへの依頼文そのものにも境界を入れます。特に「tenant_idを自動で補って」「今動けばよい」「管理者は全部見えるでよい」という依頼は危険です。安全なテンプレートは、禁止事項、検証、公式根拠、変更範囲を含めます。
あなたはマルチテナントSaaSの安全な実装を担当します。
変更範囲は src/app、src/lib、db/migrations、tests のみです。
必須要件:
- tenant_idはサーバー側で解決し、クライアント入力を信用しない。
- すべてのテナント表にPostgreSQL RLSを使う。
- 接続プールではset_config('app.tenant_id', value, true)をトランザクション内で使う。
- 認証、課金、ジョブ、ログ、検索インデックスにtenantIdを渡す。
- cross-tenantアクセスを狙うテストを追加する。
禁止:
- RLSを外すこと。
- x-tenant-idヘッダーを信頼すること。
- Authorization、Cookie、API key、プロンプト全文をログへ出すこと。
- テストなしで完了扱いにすること。
完了報告には、変更ファイル、実行したコマンド、失敗したケース、残リスクを含めてください。
AnthropicのClaude Codeセキュリティ資料でも、権限確認、プロンプトインジェクション、レビュー責任が説明されています。最新の運用はClaude Code Securityで確認し、社内リポジトリでは許可コマンドやMCPの接続先を固定しておくと安全です。
収益につなげるCTAと実務導線
マルチテナント対応は、見た目の機能追加より地味ですが、B2B SaaSの商談では強い差別化になります。「顧客ごとのデータ分離」「監査ログ」「プラン上限」「サポート担当の閲覧証跡」を説明できると、導入前のセキュリティチェックに答えやすくなります。
チームでClaude Codeを導入するなら、境界条件をCLAUDE.mdとテストに固定し、レビュー時に「tenant_idが伝播しているか」「RLSが効いているか」「ログに漏れていないか」を毎回見る運用にします。導入支援が必要な場合はClaude Code研修・導入相談へ、個人でまず手順を固めたい場合は無料チートシートから始めてください。
この記事で紹介した内容を実際に試した結果
Masaが小さなCRMサンプルでこの構成を試したとき、最初に漏れたのはAPIではなくジョブでした。画面側はtenantIdを渡していたのに、日次メールのキューpayloadがprojectIdだけだったため、ワーカーが別テナント行を検索できる形になっていました。RLS用のwithTenantをジョブにも強制し、app.tenant_id未設定時のSQLテストを入れたことで、同じ漏れを再現できなくなりました。Claude Codeには「正常系を追加」ではなく「Tenant AからTenant Bを読みに行くテストを先に書く」と頼む方が、実装の質が明らかに上がりました。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。