FastAPIで実践する監査ログ設計入門:誰が・いつ・何をしたかを安全に残すための設計と実装パターン
要約
- 監査ログは、アプリの通常ログとは目的が異なります。障害調査だけでなく、権限変更、データ更新、ダウンロード、削除といった重要操作の証跡を残すための仕組みです。
- FastAPIでは、リクエスト単位の情報、認証済みユーザー、テナント、対象リソース、操作結果を整理し、サービス層や共通ヘルパーから監査イベントを記録する形が扱いやすいです。
- 重要なのは「何を記録するか」を先に決めることです。すべてを記録しようとすると運用しづらくなり、逆に少なすぎると後から追えません。
- 実務では、構造化ログとして出す方法と、専用の監査テーブルへ保存する方法があります。最初は構造化ログでもよいですが、検索性や保持期間を考えると専用保存が有力です。
- この記事では、FastAPIでの監査ログの基本方針、イベント設計、DB保存、ミドルウェアとの役割分担、マルチテナント対応、テスト観点までを段階的に整理します。
誰が読んで得をするか
個人開発・学習者さん
管理画面やログイン機能を作っていて、「誰が削除したのか」「いつ設定変更したのか」を後から確認したい方に向いています。
最初は簡単な形でも、監査ログを入れておくとアプリの信頼性が大きく上がります。
小規模チームのバックエンドエンジニアさん
ユーザー管理、権限変更、CSV出力、ファイルダウンロードなどが増えてきて、「通常ログだけでは追跡しづらい」と感じている方に向いています。
監査ログ専用の設計を入れることで、問い合わせ対応やインシデント調査が楽になります。
SaaS開発チーム・スタートアップの皆さま
マルチテナント環境や管理権限の強い操作があり、法務・セキュリティ・顧客要求の観点から証跡が必要なチームに向いています。
とくに、誰がどのテナントで何をしたかを一貫したフォーマットで残したい場合に役立ちます。
アクセシビリティ評価
- まず「監査ログとは何か」を整理し、その後に「何を記録するか」「どこに保存するか」「どう実装するか」という順で説明しています。
- 専門語は初出で短く補足し、以降は同じ用語を使って流れを追いやすくしています。
- コード例は1つの責務ごとに短く分けています。
- 目標レベルはAA相当です。
1. 監査ログとは何か
監査ログは、単なるデバッグ用ログとは違います。
目的は、「重要な操作の証跡を後から確認できるようにすること」です。
たとえば次のような問いに答えられる必要があります。
- 誰がこのレコードを削除したのか
- いつユーザー権限を変更したのか
- どのテナントで、誰がファイルをダウンロードしたのか
- 管理者がどの設定を変更したのか
- エラーではないが、重大な操作がいつ行われたのか
つまり監査ログは、障害調査だけでなく、運用・セキュリティ・問い合わせ対応の土台になります。
2. 通常ログと監査ログの違い
混同しやすいので、最初に切り分けておくと楽です。
通常ログ
- 開発や障害調査向け
- スタックトレース、DBエラー、処理時間などを記録
- DEBUG / INFO / WARNING / ERROR などのレベルで運用
監査ログ
- 証跡向け
- 成功した操作も記録対象
- 「誰が、何を、どこに、どうしたか」を一定フォーマットで残す
- 基本的に削除や改ざんをしにくい保存先が望ましい
たとえば「ログイン失敗」は通常ログにも出す価値がありますが、「ユーザー権限変更」は監査ログとしても必ず残したいイベントです。
3. 何を記録するか:まずイベント設計を決める
監査ログで最初にやるべきことは、実装ではなくイベント設計です。
つまり、「どの操作を監査対象にするか」を決めます。
3.1 最低限おすすめの対象
- ログイン、ログアウト
- パスワード変更
- 権限変更、ロール変更
- 作成、更新、削除
- CSVやPDFなどのエクスポート
- ファイルダウンロード
- APIキー発行、無効化
- テナント設定変更
- 課金プラン変更
3.2 記録しすぎない
監査ログに向くのは「重要な操作」です。
一覧取得や通常の閲覧を全部保存すると量が多くなりすぎることがあります。
最初は次の基準で考えると整理しやすいです。
- 後から説明責任が発生しそうか
- 問い合わせ対応で追跡したくなるか
- セキュリティ上、証跡が必要か
- 失敗していなくても残したい操作か
4. 監査ログ1件に含めたい項目
監査ログのフォーマットは、最初に揃えておくほど後が楽です。
最低限あると便利な項目は次のとおりです。
timestamp
いつ起きたかactor_type
実行主体の種別。例:user,service,systemactor_id
実行主体のIDtenant_id
どのテナントで起きたかaction
何をしたか。例:user.create,project.deleteresource_type
対象の種類。例:user,project,invoiceresource_id
対象IDresult
successまたはfailurerequest_id
リクエスト単位の追跡用IDip_address
実行元IPuser_agent
クライアント情報detail
補足情報。変更前後や理由など
全部を毎回埋める必要はありませんが、スキーマとしては持っておくと扱いやすいです。
5. イベント名の命名規則を決める
あとから検索しやすくするために、action の命名規則は揃えるのがおすすめです。
たとえば次のようにします。
user.loginuser.logoutuser.role.updateproject.createproject.updateproject.deletefile.downloadapi_key.revoke
<resource>.<verb> または <resource>.<field>.<verb> の形にすると、一覧性が高くなります。
6. FastAPIでの基本方針:ミドルウェアではなくサービス層中心で残す
監査ログは、ミドルウェアだけで完結させない方が安全です。
なぜか
ミドルウェアは「HTTPリクエストが来たこと」は分かりますが、
- 実際に何を更新したのか
- どのリソースIDに対して何が起きたのか
- ビジネス的に重要なイベントだったのか
までは分かりません。
そのため、監査ログの基本は次の役割分担が分かりやすいです。
- ミドルウェア
request_id, IP, User-Agent など共通情報の取得
- サービス層
- 実際の重要イベントの記録
- 監査ログ保存層
- DBや構造化ログへの永続化
7. まずはPydanticモデルを作る
監査イベントの形をコードで固定しておくと、どこからでも同じ形式で使えます。
# app/audit/schemas.py
from pydantic import BaseModel
from typing import Any, Literal
from datetime import datetime
class AuditEvent(BaseModel):
timestamp: datetime
actor_type: Literal["user", "service", "system"]
actor_id: str | None = None
tenant_id: int | None = None
action: str
resource_type: str | None = None
resource_id: str | None = None
result: Literal["success", "failure"]
request_id: str | None = None
ip_address: str | None = None
user_agent: str | None = None
detail: dict[str, Any] | None = None
このモデルを共通の入れ物として扱います。
8. 最小実装:構造化ログへ出す
まずはDB保存ではなく、構造化ログとして出す方法です。
導入が簡単で、最初の一歩として向いています。
# app/audit/logger.py
import logging
from app.audit.schemas import AuditEvent
audit_logger = logging.getLogger("audit")
def write_audit_log(event: AuditEvent) -> None:
audit_logger.info(
"audit_event",
extra={
"timestamp": event.timestamp.isoformat(),
"actor_type": event.actor_type,
"actor_id": event.actor_id,
"tenant_id": event.tenant_id,
"action": event.action,
"resource_type": event.resource_type,
"resource_id": event.resource_id,
"result": event.result,
"request_id": event.request_id,
"ip_address": event.ip_address,
"user_agent": event.user_agent,
"detail": event.detail,
},
)
この方式なら、既に整えたJSONロギング基盤にそのまま流せます。
ただし、後から細かく検索したい場合は、専用テーブル保存の方が便利です。
9. DBに保存する専用テーブルを作る
運用が本格化するなら、監査ログ専用テーブルを持つと扱いやすくなります。
# app/audit/models.py
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Integer, String, DateTime, Text, JSON
from datetime import datetime
from app.db.base import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False)
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
actor_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
resource_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
result: Mapped[str] = mapped_column(String(20), nullable=False)
request_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
ip_address: Mapped[str | None] = mapped_column(String(100), nullable=True)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
設計上のポイント
tenant_id,action,request_idにはインデックスを付けると検索しやすいですdetailはJSONで柔軟に持つと便利ですが、よく検索する値は通常カラムに出した方が効率的です
10. 監査ログ保存用のリポジトリを作る
# app/audit/repository.py
from sqlalchemy.orm import Session
from app.audit.models import AuditLog
from app.audit.schemas import AuditEvent
class AuditLogRepository:
def __init__(self, db: Session):
self.db = db
def create(self, event: AuditEvent) -> AuditLog:
row = AuditLog(
timestamp=event.timestamp,
actor_type=event.actor_type,
actor_id=event.actor_id,
tenant_id=event.tenant_id,
action=event.action,
resource_type=event.resource_type,
resource_id=event.resource_id,
result=event.result,
request_id=event.request_id,
ip_address=event.ip_address,
user_agent=event.user_agent,
detail=event.detail,
)
self.db.add(row)
self.db.flush()
return row
ここまでで、サービス層から監査イベントをDB保存できる形が整いました。
11. リクエスト情報を集める:コンテキストヘルパーを作る
サービス層から request_id や ip_address を毎回直接拾うのは面倒なので、共通のコンテキストを作ると扱いやすいです。
# app/audit/context.py
from pydantic import BaseModel
class AuditContext(BaseModel):
request_id: str | None = None
ip_address: str | None = None
user_agent: str | None = None
actor_type: str = "user"
actor_id: str | None = None
tenant_id: int | None = None
FastAPIの依存関数で組み立てます。
# app/deps/audit.py
from fastapi import Depends, Request
from app.audit.context import AuditContext
from app.deps.auth import get_current_user
from app.deps.tenant import get_current_tenant_id
def get_audit_context(
request: Request,
current_user=Depends(get_current_user),
tenant_id: int = Depends(get_current_tenant_id),
) -> AuditContext:
return AuditContext(
request_id=request.headers.get("X-Request-ID"),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("User-Agent"),
actor_type="user",
actor_id=str(current_user.user_id),
tenant_id=tenant_id,
)
12. サービス層で監査ログを記録する
ここが本体です。
重要操作の成功時、必要なら失敗時にもイベントを記録します。
# app/services/project_service.py
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.audit.context import AuditContext
from app.audit.schemas import AuditEvent
from app.audit.repository import AuditLogRepository
from app.models.project import Project
class ProjectService:
def __init__(self, db: Session):
self.db = db
self.audit_repo = AuditLogRepository(db)
def create_project(self, tenant_id: int, name: str, audit_ctx: AuditContext) -> Project:
project = Project(tenant_id=tenant_id, name=name)
self.db.add(project)
self.db.flush()
event = AuditEvent(
timestamp=datetime.now(timezone.utc),
actor_type=audit_ctx.actor_type,
actor_id=audit_ctx.actor_id,
tenant_id=audit_ctx.tenant_id,
action="project.create",
resource_type="project",
resource_id=str(project.id),
result="success",
request_id=audit_ctx.request_id,
ip_address=audit_ctx.ip_address,
user_agent=audit_ctx.user_agent,
detail={"name": name},
)
self.audit_repo.create(event)
return project
このように「ビジネス的に意味のある場所」で残すと、後で読んだときに価値の高いログになります。
13. 失敗時も残すべきか
結論から言うと、重要な失敗は残す価値があります。
たとえば次のようなケースです。
- 権限変更が拒否された
- 他テナントアクセスがブロックされた
- APIキー無効化に失敗した
- 課金プラン変更が外部決済エラーで失敗した
成功だけを残すと、「怪しい試行」が見えません。
ただし、すべてのバリデーションエラーまで監査ログに残すとノイズが多くなることもあります。
実務では次のように分けるのが現実的です。
- 通常の入力ミス → 通常ログ
- セキュリティや権限に関わる失敗 → 監査ログ
- 重要業務操作の失敗 → 監査ログ
14. 差分を残す:何が変わったかをどう記録するか
更新系の操作では、「変更前」「変更後」の差分を残したくなることがあります。
たとえば権限変更なら、
- before:
member - after:
admin
のような差分があると便利です。
detail={
"before": {"role": "member"},
"after": {"role": "admin"},
}
ただし、差分をまるごと入れすぎると、個人情報や機密情報まで監査ログに入る危険があります。
そのため、差分記録の対象は慎重に選ぶのが大切です。
おすすめは次の方針です。
- パスワード、トークン、秘密情報は絶対に残さない
- 差分は必要なフィールドだけ残す
- 監査ログに全文保存するより、IDと主要項目だけに留める
15. マルチテナントでの監査ログ
前回の記事の続きとして、マルチテナント環境では tenant_id が特に重要です。
最低限守りたいこと
- すべての監査イベントに
tenant_idを含める - テナント跨ぎの操作は、その事実が分かるように
detailに残す - 検索画面や管理画面でも、他テナントの監査ログが混ざらないようにする
たとえば、内部管理者だけが全テナントの監査ログを見られる場合でも、通常のテナント管理者は自テナント分しか見えないようにする必要があります。
16. 監査ログの保存先:DBか、ログ基盤か、両方か
監査ログの保存先は、主に3パターンあります。
16.1 DBだけ
- 検索しやすい
- 管理画面を作りやすい
- ただし、アプリDBと同居させると肥大化しやすい
16.2 ログ基盤だけ
- 既存のログ集約と相性が良い
- 運用が簡単
- ただし、厳密な検索や保持制御は基盤依存
16.3 DB + ログ基盤
- 最も実務的
- DBには検索用の要点を保存
- ログ基盤には詳細や周辺文脈も出す
迷ったら、小規模ではDB保存、もう少し運用が進んだらDB+ログ基盤の二重化が扱いやすいです。
17. 削除・改ざんへの配慮
監査ログは「後から消せる」状態だと価値が下がります。
現実的な対策としては次のようなものがあります。
- 監査ログの削除APIを作らない
- 一般管理者には更新・削除権限を与えない
- 監査テーブルへのUPDATEを原則禁止する
- 長期保存が必要なら、外部基盤にも送る
- バックアップやアーカイブを分ける
最初から完璧な改ざん耐性を作る必要はありませんが、「通常データと同じ感覚で編集できる」状態は避けたいところです。
18. APIとして監査ログを参照する場合の設計
監査ログを管理画面で見せたい場合、一覧APIが必要になります。
おすすめのフィルタ
tenant_idactor_idactionresource_typeresource_idfrom,toの日時範囲
例
@router.get("/audit-logs")
def list_audit_logs(
tenant_id: int = Depends(get_current_tenant_id),
action: str | None = None,
):
...
ここでも重要なのは、通常の業務データと同様に「誰が見てよいか」を厳密にすることです。
監査ログ自体が機密情報になることもあるので、閲覧権限はかなり慎重に設計した方が安全です。
19. テスト観点:最低限守りたいこと
監査ログは「動いているか」だけでなく、「残るべきものが抜けていないか」を見る必要があります。
最低限のテスト
- 重要操作をすると監査ログが1件作られる
tenant_idが正しく入るactor_idが正しく入る- 他テナントの監査ログを取得できない
- 失敗時の重要イベントが記録される
- 機密情報が
detailに含まれていない
テスト例
def test_project_create_writes_audit_log(client, db_session, auth_headers):
res = client.post("/projects", json={"name": "New Project"}, headers=auth_headers)
assert res.status_code == 201
rows = db_session.execute("SELECT action, result FROM audit_logs").fetchall()
assert len(rows) == 1
assert rows[0][0] == "project.create"
assert rows[0][1] == "success"
20. 導入ロードマップ
個人開発・学習者さん
- まずは重要操作を3つ決める
AuditEventモデルを作る- 構造化ログに出す
- 慣れたら専用テーブルへ保存する
小規模チームのエンジニアさん
- 監査対象イベントの一覧を作る
- イベント名と項目の命名規則を決める
- サービス層から記録する共通関数を整える
tenant_idとrequest_idを必須項目にする- 一覧APIや管理画面を作る
SaaS開発チーム・スタートアップの皆さま
- 法務・CS・セキュリティ観点で必要イベントを棚卸しする
- DB保存とログ基盤送信の両方を設計する
- 監査ログの閲覧権限と保持期間を決める
- 他テナント閲覧防止と改ざん防止を強化する
- アラート対象の監査イベントも定義する
参考リンク
- FastAPI Documentation
- FastAPI Dependencies
- FastAPI Security
- SQLAlchemy ORM
- OWASP Logging Cheat Sheet
まとめ
- 監査ログは、単なるエラーログではなく、重要操作の証跡を残す仕組みです。
- FastAPIでは、ミドルウェアで共通情報を集めつつ、実際の監査イベントはサービス層で記録する設計が扱いやすいです。
- 最初に決めるべきなのは、保存先よりも「どのイベントを、どの形式で残すか」です。
- マルチテナントでは
tenant_idを必ず含め、閲覧権限も厳密に設計する必要があります。 - 完璧を目指すより、まずは権限変更、削除、ダウンロードのような重要操作から始めると導入しやすいです。
次の記事としては、この流れと相性が良い「FastAPIで実装するRBAC/ABAC権限管理」「SaaS向けプラン・請求・機能制限設計」あたりが自然につながります。

