green snake
Photo by Pixabay on Pexels.com
目次

FastAPIで考えるマルチテナント設計入門:テナント分離・認可・DB戦略・監査ログまで見据えた実務パターン


要約

  • マルチテナント設計とは、1つのアプリケーションで複数の顧客組織や契約単位を安全に扱う設計です。
  • FastAPIでは、認証済みユーザーにひもづく tenant_id をリクエストごとに確定し、全てのデータアクセスにその条件を必ず通すことが基本になります。
  • 分離戦略は主に3つあります。共有DB・共有テーブル共有DB・テナント別スキーマテナント別DB です。最初は共有テーブルから始めることが多いですが、要件次第で早めに分離度を上げる判断も必要です。
  • 危険なのは「認証はできているのに、別テナントのデータが見える」状態です。テナント境界は認証よりもむしろ認可とクエリ設計で守ります。
  • この記事では、FastAPIでの tenant_id の取り回し、SQLAlchemyでの絞り込み、権限設計、監査ログ、テスト方針、将来の分離戦略までを段階的に整理します。

誰が読んで得をするか

個人開発・学習者さん

SaaSっぽい管理画面やチーム単位の機能を作りたいけれど、「ユーザー単位」と「組織単位」の違いがまだ曖昧な方に向いています。
とくに、user_id だけでデータを管理していて、これから「会社Aと会社Bを分けたい」と考えている段階に役立ちます。

小規模チームのバックエンドエンジニアさん

1つのFastAPIアプリで複数顧客を扱う予定があり、「どの層で tenant_id を扱うべきか」「DB設計をどう切るべきか」に悩んでいる方に向いています。
あとから認可の穴が見つかると痛いので、最初に押さえておきたい考え方を整理できます。

SaaS開発チーム・スタートアップの皆さま

既に複数テナントで運用しており、権限・監査・請求・データ分離の粒度が問題になってきたチーム向けです。
共有テーブルで始めるべきか、スキーマ分離やDB分離へ進むべきかを判断する視点も含めて、設計の棚卸しに使えます。


アクセシビリティ評価

  • まず概念を整理し、そのあとに 認証 → tenant_id解決 → DB分離戦略 → 認可 → 実装例 → 監査 → テスト の順で進めています。
  • 専門語は初出で簡潔に説明し、後半でも同じ表現を使って流れを追いやすくしています。
  • コード例は短めに分割し、1つのブロックで1つの役割だけを見せています。
  • 目標レベルはAA相当です。

1. マルチテナントとは何か

マルチテナントとは、1つのアプリケーション基盤を複数の顧客や組織で共有しつつ、それぞれのデータや権限境界を安全に保つ設計です。

ここでいうテナントは、たとえば次のような単位です。

  • 会社
  • チーム
  • 学校
  • 店舗グループ
  • 契約単位の顧客組織

重要なのは、「ユーザー」と「テナント」は別物だということです。

  • ユーザーは個人
  • テナントは所属先の組織

たとえば、1人のユーザーが複数のテナントに参加できるケースもあります。
そのため、user_id だけではなく、tenant_id を明示的に扱う設計が必要になります。


2. 最初に決めるべきこと:テナント境界をどこで表すか

マルチテナント設計で最初に決めるべきなのは、「このリクエストはどのテナントの文脈で動いているか」をどう表現するかです。

代表的な方法は次の3つです。

  1. サブドメイン
    • 例: acme.example.com, foo.example.com
  2. パス
    • 例: /t/acme/projects, /t/foo/projects
  3. トークンやヘッダ
    • JWTのクレームに tenant_id を入れる
    • あるいは X-Tenant-ID のようなヘッダを使う

実務では、次のように考えると分かりやすいです。

  • ブラウザ向けのSaaSなら、サブドメインやパスでテナントを明示するとUI的にも分かりやすい
  • API中心なら、認証トークンに tenant_id や所属一覧を持たせるのが扱いやすい
  • ただし、ヘッダだけで自由に tenant_id を指定できる設計は危険なので、必ず認証情報と突き合わせる必要がある

3. DB分離戦略の3パターン

マルチテナントのDB設計は、大きく3つの戦略に分けられます。

3.1 共有DB・共有テーブル

すべてのテナントのデータを同じテーブルに入れ、各レコードに tenant_id を持たせる方式です。

例:

projects
- id
- tenant_id
- name
- created_at

メリット

  • 実装がシンプル
  • 運用コストが低い
  • テーブル追加やマイグレーションが一括で済む

デメリット

  • クエリ漏れがあると他テナントのデータが見える
  • 顧客単位のバックアップや削除が面倒
  • 高度な分離要求には弱い

小さく始めるなら、この方式が現実的なことが多いです。
ただし、tenant_id 条件の付け忘れが最大のリスクになります。

3.2 共有DB・テナント別スキーマ

同じDBの中で、テナントごとにスキーマを分ける方法です。

例:

  • tenant_acme.projects
  • tenant_foo.projects

メリット

  • 共有テーブルより分離が強い
  • 顧客単位のバックアップやデータ管理がしやすい

デメリット

  • マイグレーションや管理がやや複雑
  • テナント数が増えると運用が重くなる

3.3 テナント別DB

テナントごとに別データベースを持つ方式です。

メリット

  • 分離が最も強い
  • 法務・契約・高セキュリティ要件に対応しやすい
  • 顧客ごとの復旧や移行がしやすい

デメリット

  • 運用コストが高い
  • 接続先切り替えやマイグレーションが難しい
  • 小規模段階ではオーバースペックになりやすい

4. FastAPIでの基本方針:tenant_idは依存関数で解決する

FastAPIでは、テナント文脈を Depends で解決して、ルーターやサービス層に渡す形が分かりやすいです。

4.1 現在ユーザーを取る

まずは認証済みユーザーを取得します。

# app/deps/auth.py
from fastapi import Depends, HTTPException, status
from app.models.auth import CurrentUser

def get_current_user() -> CurrentUser:
    # 実際はJWTなどを検証
    return CurrentUser(user_id=1, tenant_ids=[10, 20], active_tenant_id=10)

4.2 現在のtenant_idを解決する

トークンやパス、ヘッダから「今回のテナント」を決めつつ、そのユーザーが所属しているかを検証します。

# app/deps/tenant.py
from fastapi import Depends, Header, HTTPException, status
from app.deps.auth import get_current_user
from app.models.auth import CurrentUser

def get_current_tenant_id(
    current_user: CurrentUser = Depends(get_current_user),
    x_tenant_id: int | None = Header(default=None),
) -> int:
    tenant_id = x_tenant_id or current_user.active_tenant_id
    if tenant_id is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="tenant is not selected",
        )
    if tenant_id not in current_user.tenant_ids:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="tenant access is forbidden",
        )
    return tenant_id

ここで大事なのは、x_tenant_id をそのまま信用しないことです。
「このユーザーがそのテナントに所属しているか」を必ず確認します。


5. SQLAlchemyでtenant_idを必ず通す

共有テーブル方式では、あらゆる主要テーブルに tenant_id を持たせるのが基本です。

5.1 モデル例

# app/models/project.py
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Integer, String

from app.db.base import Base

class Project(Base):
    __tablename__ = "projects"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    tenant_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
    name: Mapped[str] = mapped_column(String(200), nullable=False)

5.2 クエリで必ずtenant_idを絞る

サービス層やリポジトリ層で、必ず tenant_id を条件に含めます。

# app/repositories/project_repository.py
from sqlalchemy.orm import Session
from app.models.project import Project

class ProjectRepository:
    def __init__(self, db: Session):
        self.db = db

    def list_by_tenant(self, tenant_id: int) -> list[Project]:
        return (
            self.db.query(Project)
            .filter(Project.tenant_id == tenant_id)
            .order_by(Project.id.desc())
            .all()
        )

    def get_by_id(self, tenant_id: int, project_id: int) -> Project | None:
        return (
            self.db.query(Project)
            .filter(Project.tenant_id == tenant_id, Project.id == project_id)
            .first()
        )

project_id だけで検索しないことが重要です。
id が一意でも、「別テナントのプロジェクトを取得できる」事故を防ぐには、常に tenant_id と組みで見る必要があります。


6. ルーターでは「tenant_id込みのサービス呼び出し」に寄せる

ルーターでは、HTTPのことだけを扱い、サービスやリポジトリには tenant_id を明示的に渡します。

# app/api/v1/routers/projects.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.deps.db import get_db
from app.deps.tenant import get_current_tenant_id
from app.repositories.project_repository import ProjectRepository

router = APIRouter(prefix="/projects", tags=["projects"])

@router.get("")
def list_projects(
    tenant_id: int = Depends(get_current_tenant_id),
    db: Session = Depends(get_db),
):
    repo = ProjectRepository(db)
    items = repo.list_by_tenant(tenant_id)
    return items

この設計の利点は、コードレビューで

  • tenant_id を受け取っているか
  • DBアクセスに渡しているか

が目で追いやすいことです。


7. テナント内ロール設計:同じテナントでも全員が同じ権限ではない

マルチテナントでは、「テナント境界」と「テナント内権限」を分けて考える必要があります。

たとえば同じ会社Aの中でも、

  • Owner
  • Admin
  • Member
  • Viewer

のような違いがあるかもしれません。

7.1 所属テーブルを持つ

userstenants の間に、中間テーブルを置く形がよく使われます。

tenant_memberships
- user_id
- tenant_id
- role

7.2 依存関数やサービスで権限チェック

# app/deps/permissions.py
from fastapi import Depends, HTTPException, status
from app.deps.auth import get_current_user
from app.deps.tenant import get_current_tenant_id

def require_tenant_admin(
    current_user=Depends(get_current_user),
    tenant_id: int = Depends(get_current_tenant_id),
):
    membership = None  # 実際はDBから user_id と tenant_id で検索
    if membership is None or membership.role not in {"owner", "admin"}:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="admin role required",
        )

つまり、

  • そのテナントに所属しているか
  • そのテナント内で何ができるか

を二段で見ます。


8. テナント切り替えUIとトークン設計

ユーザーが複数テナントに所属する場合、UI上で「今どのテナントを見ているか」を明示する必要があります。

よくある設計は次の2つです。

8.1 active_tenant_id をトークンに含める

ログイン時や切り替え時に、現在選択中の tenant_id をJWTへ反映します。

メリット

  • リクエストごとにヘッダを追加しなくてもよい
  • API実装がシンプル

デメリット

  • テナント切り替えのたびにトークン再発行が必要

8.2 所属一覧をトークンに入れて、ヘッダで選ばせる

トークンには tenant_ids=[...] を持たせ、都度 X-Tenant-ID で選択させます。

メリット

  • 切り替えが柔軟
  • 複数タブでも扱いやすい

デメリット

  • ヘッダの扱いを統一しないと混乱しやすい
  • 必ず「所属確認」が必要

どちらを選んでも構いませんが、チームで統一しておくことが大切です。


9. 監査ログ:誰がどのテナントで何をしたかを残す

マルチテナントでは、障害調査や問い合わせ対応のために、監査ログが特に重要になります。

最低限、次の情報を残せると便利です。

  • tenant_id
  • user_id
  • 実行した操作
  • 対象リソース
  • 成功/失敗
  • 時刻
  • リクエストID

例のイメージです。

# app/services/audit.py
import logging

logger = logging.getLogger("audit")

def log_audit(
    tenant_id: int,
    user_id: int,
    action: str,
    resource: str,
    resource_id: int | None = None,
):
    logger.info(
        "audit event",
        extra={
            "tenant_id": tenant_id,
            "user_id": user_id,
            "action": action,
            "resource": resource,
            "resource_id": resource_id,
        },
    )

たとえば「請求書を削除した」「メンバー権限を変更した」といった操作は、必ず監査ログに残しておくと後で助かります。


10. テスト戦略:一番怖いのは「他テナントのデータが見える」こと

マルチテナントで最優先のテストは、正常系よりむしろ境界テストです。

10.1 最低限ほしいテスト

  • テナントAのユーザーはテナントAのデータだけ見られる
  • テナントAのユーザーがテナントBの tenant_id を指定すると403になる
  • project_id が存在しても別テナントなら404または403になる
  • テナント内ロールが不足していると管理操作が拒否される

10.2 APIテスト例

def test_user_cannot_access_other_tenant_project(client, token_for_tenant_a):
    res = client.get(
        "/projects/999",
        headers={
            "Authorization": f"Bearer {token_for_tenant_a}",
            "X-Tenant-ID": "20",  # 他テナント
        },
    )
    assert res.status_code in (403, 404)

404 にするか 403 にするかは設計によります。
「存在自体を隠したい」なら404、「権限不足を明示したい」なら403です。チームで揃えておきましょう。


11. 将来のスケールを見据えた判断ポイント

共有テーブルから始める場合でも、次の兆候が見えたら分離戦略の見直しを検討します。

  • 特定顧客だけデータ量が突出してきた
  • 顧客ごとのバックアップ・削除要件が強い
  • 法令や契約上、より強い分離が必要
  • テナント単位の性能問題が他テナントへ波及している
  • 顧客専用環境が営業要件になってきた

このとき、最初から tenant_id が明確に入っていて、サービス層や監査ログが整理されていれば、移行はかなり楽になります。


12. 読者別ロードマップ

個人開発・学習者さん

  1. まずは共有テーブル方式で tenant_id を全主要テーブルに入れる
  2. JWTやセッションから tenant_id を決める依存関数を作る
  3. すべての一覧・詳細クエリに tenant_id 条件を入れる
  4. 他テナントアクセス防止のテストを1本でも入れる

小規模チームのエンジニアさん

  1. どのモデルがテナント所属かを棚卸しする
  2. tenant_id 条件の有無をリポジトリ層で統一する
  3. テナント内ロール設計を決める
  4. 監査ログに tenant_id を含める
  5. OpenAPIやエラーフォーマットにも「テナントをどう指定するか」を反映する

SaaS開発チーム・スタートアップの皆さま

  1. 現状の分離方式を再評価する
  2. データ分離・性能分離・法務要件を分けて整理する
  3. テナント境界テストと監査ログを強化する
  4. 必要ならスキーマ分離やDB分離の移行計画を立てる
  5. 請求・監査・権限変更フローもテナント単位で再確認する

参考リンク


まとめ

  • マルチテナント設計の本質は、「認証済み」だけで安心せず、「このリクエストはどのテナントの文脈か」を明確にし続けることです。
  • FastAPIでは、tenant_id を依存関数で解決し、サービス・リポジトリ層へ明示的に渡す設計が分かりやすく、安全です。
  • 共有テーブル方式は始めやすいですが、tenant_id 条件漏れが最大のリスクです。テストと監査ログで境界を守ることが重要になります。
  • まずは小さく始め、将来の法務・性能・顧客要件に応じて、スキーマ分離やDB分離へ進める土台を作っておくのが実務的です。

次の記事としては、このテーマと相性が良い「FastAPIの監査ログ設計」「RBAC/ABACによる権限管理」「SaaS向け請求・プラン制御」あたりが自然に続けられます。

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)