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つです。
- サブドメイン
- 例:
acme.example.com,foo.example.com
- 例:
- パス
- 例:
/t/acme/projects,/t/foo/projects
- 例:
- トークンやヘッダ
- JWTのクレームに
tenant_idを入れる - あるいは
X-Tenant-IDのようなヘッダを使う
- JWTのクレームに
実務では、次のように考えると分かりやすいです。
- ブラウザ向けの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.projectstenant_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 所属テーブルを持つ
users と tenants の間に、中間テーブルを置く形がよく使われます。
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_iduser_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. 読者別ロードマップ
個人開発・学習者さん
- まずは共有テーブル方式で
tenant_idを全主要テーブルに入れる - JWTやセッションから
tenant_idを決める依存関数を作る - すべての一覧・詳細クエリに
tenant_id条件を入れる - 他テナントアクセス防止のテストを1本でも入れる
小規模チームのエンジニアさん
- どのモデルがテナント所属かを棚卸しする
tenant_id条件の有無をリポジトリ層で統一する- テナント内ロール設計を決める
- 監査ログに
tenant_idを含める - OpenAPIやエラーフォーマットにも「テナントをどう指定するか」を反映する
SaaS開発チーム・スタートアップの皆さま
- 現状の分離方式を再評価する
- データ分離・性能分離・法務要件を分けて整理する
- テナント境界テストと監査ログを強化する
- 必要ならスキーマ分離やDB分離の移行計画を立てる
- 請求・監査・権限変更フローもテナント単位で再確認する
参考リンク
- FastAPI
- SQLAlchemy
- 設計の背景理解に役立つ資料
まとめ
- マルチテナント設計の本質は、「認証済み」だけで安心せず、「このリクエストはどのテナントの文脈か」を明確にし続けることです。
- FastAPIでは、
tenant_idを依存関数で解決し、サービス・リポジトリ層へ明示的に渡す設計が分かりやすく、安全です。 - 共有テーブル方式は始めやすいですが、
tenant_id条件漏れが最大のリスクです。テストと監査ログで境界を守ることが重要になります。 - まずは小さく始め、将来の法務・性能・顧客要件に応じて、スキーマ分離やDB分離へ進める土台を作っておくのが実務的です。
次の記事としては、このテーマと相性が良い「FastAPIの監査ログ設計」「RBAC/ABACによる権限管理」「SaaS向け請求・プラン制御」あたりが自然に続けられます。
