ログイン

マルチテナントSaaSのプライバシー設計 — COCKPITOS事例で学ぶ 4 層防御

マルチテナント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 原則

  1. データは全層でテナントを識別する(URL パラメータ・認証トークン・DB クエリ・キャッシュキー)
  2. 防御は複層化する(一層だけの防御は必ず破られる)
  3. テスト環境でも本番同様のテナント境界でテストする

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 で異なるテナントのデータをキャッシュすると、ログイン切替時に前テナントのデータが表示される事故が起きます。

対策: queryKeyorgSlug / 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.aidev+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 つの教訓

  1. 一層の防御は必ず破られる。4 層(URL / 認証 / DB / キャッシュ)で冗長化
  2. ロール名は所属証明にならない。メンバーシップテーブルで検証
  3. デモデータのフォールバックは禁止。認証失敗は 401 / 403
  4. E2E テストでクロステナント境界を検証。CI で自動化

マルチテナント SaaS を成功させる鍵は、開発速度ではなく運用の信頼性。一度漏洩事故を起こせば、信頼回復には長い時間がかかります。本記事の 4 層防御モデルが、他の SaaS 開発者の助けになれば幸いです。


COCKPITOSで無料デモを試すcockpitos.ai

関連記事

離職予防を、データで実現する

ストレスチェック・パルスサーベイ・1on1・研修管理を
ひとつのプラットフォームで

無料デモを試す