マルチテナントSaaSのプライバシー設計 — COCKPITOS事例で学ぶ 4 層防御
はじめに
マルチテナント SaaS とは、一つのアプリケーション・データベース基盤を複数顧客(テナント)で共有する設計方式です。SaaS 事業者には開発・運用効率の向上というメリットがある一方、設計を誤ると異なるテナント間でデータが漏洩するリスクが生まれます。
HR SaaS のように給与・人事評価・ストレスチェック結果など高機密データを扱うプロダクトでは、テナント分離は単なる機能ではなく、事業の信頼性そのものです。本記事では、COCKPITOS での実装事例を交えて、マルチテナント SaaS のプライバシー設計で押さえるべき 4 層の防御を解説します。
1. SaaS のマルチテナント設計とは
1.1 3 つの典型的アーキテクチャ
| アーキテクチャ | 特徴 | テナント分離の難易度 |
|---|---|---|
| 共有 DB・共有スキーマ | 全テナントが同一テーブル、tenant_id カラムで論理分離 |
高(アプリ層で細心の注意) |
| 共有 DB・分離スキーマ | テナントごとに別スキーマ、共通DBサーバー | 中 |
| 完全分離 | テナントごとに DB 別サーバー | 低(物理分離) |
COCKPITOS は共有 DB・共有スキーマ型。スケーラビリティと運用効率を優先し、アプリ層で厳密にテナント分離を担保しています。
1.2 マルチテナント設計の 3 原則
- データは全層でテナントを識別する(URL パラメータ・認証トークン・DB クエリ・キャッシュキー)
- 防御は複層化する(一層だけの防御は必ず破られる)
- テスト環境でも本番同様のテナント境界でテストする
2. よくある落とし穴 5 選
2.1 email のグローバル UNIQUE 制約
メールアドレスが DB 全体で一意になっていると、同じユーザーが複数テナントに登録できない。実運用でテストアカウント作成時にコンフリクトし、エラーメッセージから「このメールが別テナントに存在する」ことが漏れるリスクがあります。
対策: UNIQUE (email, tenant_id) の複合制約にするか、tenant_id を subquery でフィルタしてから email チェック。
2.2 ORM の「当たり前」をスキップ
SQLAlchemy 等の ORM で User.query.get(user_id) のように ID 直指定すると、テナント境界をバイパス可能。
対策: カスタム BaseQuery で必ず tenant_id フィルタを自動付与。または全クエリで明示。
2.3 React Query キャッシュのテナント汚染
同じ queryKey で異なるテナントのデータをキャッシュすると、ログイン切替時に前テナントのデータが表示される事故が起きます。
対策: queryKey に orgSlug / tenantId を必ず含める。
const { data } = useQuery({
queryKey: ['clients', orgSlug], // ← orgSlug 必須
queryFn: () => fetchClients(orgSlug),
});
2.4 デモデータのフォールバック
認証失敗時に「デモデータ」を返す実装は共通のダミーデータが全テナントに露出する事故を招きます。
対策: 認証失敗は必ず 401 / 403 を返し、クライアント側でログイン画面に誘導。デモデータは明示的な /demo/* パスのみで返す。
2.5 ログへの機密情報混入
エラーログに邸ユーザー email / 顧客名を含めると、別のテナントの運用担当者がログ閲覧時に漏洩します。
対策: 構造化ログで機密情報を <redacted> に置換、ログ閲覧権限をテナントごとに分離。
3. COCKPITOS の 4 層防御モデル
COCKPITOS では以下 4 層でテナント分離を担保しています。一層だけでは不十分で、各層が独立に機能するように設計するのが要点。
Layer 1: URL / ルーティング層
/org/:orgSlug/* ← URL 自体に組織スラッグ
/consultant/* ← 認証ユーザーの所属組織に基づいてデータ取得
/client/:clientId/dashboard ← clientId を path に含む
URL の orgSlug は OrganizationContext で所属確認してから通す。未所属なら 404 / 403 に遷移。
Layer 2: 認証・認可層(OrganizationContext)
// src/contexts/OrganizationContext.tsx (擬似コード)
const { data: org } = useQuery(
['org-by-slug', orgSlug],
() => fetchOrgBySlug(orgSlug)
);
const isMember = org?.members?.some(m => m.email === user.email);
if (!isMember) {
redirectToLogin(); // ← 所属確認失敗で即リダイレクト
}
重要なのは role === 'consultant' だけで所属判断しないこと。ロール名は所属の証明にならない。メンバーシップテーブル organization_members で検証するのがベスト。
Layer 3: API / DB 層
# server/routes/consultant/dashboard.py (擬似コード)
@require_auth
def get_clients():
consultant_id = g.current_user.consultant_id # ← 認証済みユーザーから取得
if not consultant_id:
return jsonify({"error": "unauthorized"}), 401
# 必ず consultant_id でフィルタ
clients = db.query(Client).filter(
Client.consultant_id == consultant_id
).all()
return jsonify([c.to_dict() for c in clients])
絶対に URL パラメータや request body の consultant_id を信用しない。JWT トークンから取得した ID でフィルタする。
Layer 4: React Query キャッシュ層
// src/features/consultant/hooks/useConsultantQueries.ts
export function useClients(orgSlug: string) {
return useQuery({
queryKey: ['clients', orgSlug], // ← orgSlug を必ず含める
queryFn: () => consultantApi.getClients(orgSlug),
enabled: !!orgSlug,
});
}
queryKey から組織識別子が漏れると、異なる組織でログインした際に前の組織データが一瞬見える事故が起きます。
4. テスト運用時の落とし穴
4.1 テナント別 email の使用
開発者が複数テナントで同じ email(例: dev@cockpitos.ai)を使い回すと、本番と開発で挙動が変わる・UNIQUE 制約に引っかかる等のトラブルを招きます。
推奨運用:
- 開発者: dev+tenant1@cockpitos.ai、dev+tenant2@cockpitos.ai(Gmail の + エイリアス)
- テスト専用: test-<tenant>-<role>@cockpitos.ai
4.2 テスト専用アカウントの分離
COCKPITOS では本番 verification 時に本番ユーザーのパスワードを使用せず、テスト専用アカウント(test-owner 等)を使用しています。
背景: 本番ユーザーの認証情報がセッションに残ると、意図しない操作リスクとパスワード共有リスクが発生します。
4.3 E2E テストで複数テナントを検証
test('クロステナント境界チェック', async ({ page }) => {
// テナント A でログイン
await loginAs('user-a', 'org-a');
const dataA = await page.textContent('[data-testid="client-list"]');
// URL を直打ちして org-b にアクセス試行
await page.goto('/org/org-b/clients');
await expect(page).toHaveURL(/\/unauthorized|\/login/); // ← ブロック確認
});
URL 書き換え攻撃を E2E で必ず検証。Layer 1-4 のどれが破れても他でブロックする防御深度を確認します。
5. Wave 74 でのマルチテナント強化計画
COCKPITOS の Wave 74 では、以下 3 点のマルチテナント強化を予定しています。
5.1 テナント分離 E2E テストの CI 組み込み
Playwright で以下シナリオを自動検証: - URL 書き換えによるクロステナントアクセス - React Query キャッシュのログイン切替時クリア - API 直叩きによる別テナント ID 指定
CI で毎 push 検証し、リグレッションを防止。
5.2 DB ビューでのテナント分離強化
SQL レベルで tenant_id = current_setting('app.current_tenant_id') を条件に含むビューを定義し、アプリケーション層のバグがあってもDB 側で最終防御を発動させる構成を検証中。
PostgreSQL の RLS(Row-Level Security)を段階的に導入予定。
5.3 監査ログの充実
全 API リクエストに対し以下を構造化ログ出力:
- tenant_id(JWT 由来)
- requested_tenant_id(URL / body 由来)
- 両者が一致するか
不一致ログを定期的にレビューし、不正アクセスの早期検知につなげます。
6. まとめ — マルチテナント SaaS 開発者へのメッセージ
マルチテナント設計は「設計で 90%、運用で 10%」の難しさがあります。設計時の一つの漏れが、本番運用で大規模インシデントにつながります。COCKPITOS では 2026-03-11 に実際にきたむら事務所のダッシュボードで桑原事務所の顧問先データが露出するインシデントを経験し、以下の教訓を得ました。
4 つの教訓
- 一層の防御は必ず破られる。4 層(URL / 認証 / DB / キャッシュ)で冗長化
- ロール名は所属証明にならない。メンバーシップテーブルで検証
- デモデータのフォールバックは禁止。認証失敗は 401 / 403
- E2E テストでクロステナント境界を検証。CI で自動化
マルチテナント SaaS を成功させる鍵は、開発速度ではなく運用の信頼性。一度漏洩事故を起こせば、信頼回復には長い時間がかかります。本記事の 4 層防御モデルが、他の SaaS 開発者の助けになれば幸いです。
COCKPITOSで無料デモを試す → cockpitos.ai