Blogへ戻る

Language: 日本語

「監査ログが落ちたらどうしますか?」 個人情報開示を Outbox トランザクションに束縛する Fail-Closed セキュリティ設計

管理者がユーザーのメールアドレスや機密情報を照会・開示する画面(BAN 申請、登録審査、アカウント削除依頼、ユーザー検索など)を実装するとき、**「監査ログ(Audit Log)をどう記録するか」** は最も重大なセキュリティ課題です。

管理者がユーザーのメールアドレスや機密情報を照会・開示する画面(BAN 申請、登録審査、アカウント削除依頼、ユーザー検索など)を実装するとき、「監査ログ(Audit Log)をどう記録するか」 は最も重大なセキュリティ課題です。

多くのシステムでは、次のように「ビジネスロジックの末尾でロガーを 1 行呼ぶ」という実装がなされがちです。

【 よくある危うい開示フロー 】
1. データベースからユーザーの email を取得する
2. AppLogger.info("Admin revealed email...") を 1 行呼ぶ
3. HTTP 200 で email を画面に返す

しかし、この構成には致命的な脆弱性が潜んでいます。「ロガーのバッファ溢れやログレベル設定のミス、転送エラーで監査ログが消滅しても、API は何食わぬ顔で 200 を返し、機密情報だけが画面に渡ってしまう」 という事態を防げない点です(『「ログが出ないのに送信完了扱い?」 監査ログの silent 破棄と OTel の沈黙を暴いた観測設計』)。

一度画面に渡ってしまった個人情報は、後から絶対に取り消せません。

この記事では、「監査イベントの DB トランザクション(Outbox)がコミットされるまで、絶対に個人情報を返さない(失敗時は 503 で遮断する)」 という Fail-Closed 原則の設計と、監査ログへの PII 混入を型で防ぐスキーマ設計を解説します。

得られること

  • ログ出力とレスポンスを分離せず、Outbox テーブルの Commit 成功に束縛する Fail-Closed な開示アーキテクチャ がわかる
  • 監査ログ自体が個人情報の複製ストレージになるのを防ぐ、z.strictObject による厳格なスキーマ設計が学べる
  • 「使わない不要なフィールド(理由欄など)」を互換性ごと型・OpenAPI から完全抹消する安全なリファクタリング手法が身につく!

1. logger を呼ぶだけの非同期性と「生存条件」の乖離

なぜ、単なる logger.info() では不十分なのでしょうか? それは、「値を渡す条件」と「監査ログの生存条件」がまったく接続されていないから です。

  • ロガーの生存条件: プロセス内のメモリバッファ、ログレベル設定、Fluentd や Cloud Logging への非同期ネットワーク転送に依存している
  • 値の返却条件: API コールバックが完了したかどうかだけに依存している

ネットワーク瞬断やプロセスの OOM、設定ミスでログ行が消失しても、エンドポイントは何事もなかったかのように機密情報を返してしまいます。

これでは、「誰がいつ個人情報を閲覧したのかという証跡(Audit Trail)が完全に欠落したまま、機密情報が外部に漏洩した状態」 を検知することすらできません。

2. Outbox トランザクション commit に束縛された Fail-Closed 境界

この問題を根本から解決するため、「監査イベントがデータベースの Outbox テーブルに永続化されたこと」を値返却の必須条件 とする設計へと変更しました。

【 Fail-Closed な機密開示アーキテクチャ 】
[ 管理者リクエスト ]

1. typed な監査イベントを組み立てる

2. DB トランザクション内で Outbox テーブルに INSERT & COMMIT 実行 🔒
   ├─ [成功 (Durable ACK)] ──→ 初めて email を取得して HTTP 200 で返す ⚡️
   └─ [失敗 (エラー/タイムアウト)] ──→ 値を返さず HTTP 503 (Unavailable) で即座に遮断 🛑
// Usecase レイヤーでの実装イメージ
export async function revealUserEmail(
  actor: AdminActor,
  targetUserId: string,
): Promise<string> {
  const auditEvent = createAuditRecord({
    action: 'ADMIN_SENSITIVE_REVEAL',
    actorId: actor.id,
    actorType: 'ADMIN',
    targetId: targetUserId,
    targetType: 'USER',
  });

  try {
    // 監査レコードの Outbox INSERT を含むトランザクションをコミット
    await prisma.$transaction(async (tx) => {
      await tx.securityAuditOutbox.create({ data: auditEvent });
    });
  } catch (error) {
    // 監査の永続化に失敗した場合は、専用例外を投げて即座に遮断する
    throw new SensitiveRevealAuditUnavailableError('Failed to record security audit', { cause: error });
  }

  // 監査のコミットが物理的に成功した後にのみ、機密データを解決して返す
  const user = await userRepository.findSecretDataById(targetUserId);
  return user.email;
}

障害時の挙動の違い

監査の書き込みが失敗した際、システムは専用の例外をスローし、Controller は即座に 503 Service Unavailable を返します。

  • 503 で止まった場合: 画面には何も表示されず、管理者は時間を置いてリトライすればよい(安全な停止)
  • 証跡なしで渡った場合: 漏洩した個人情報は二度と取り消せず、インシデントになる(取り返しのつかない事故)

「監査が書けないなら、値を見せない」。この優先順位をコードの実行順序として厳密に固定しました。

3. Zod strictObject による監査 Payload への PII 混入防止

監査ログを設計する際によくあるもう 1 つの落とし穴が、「開示したメールアドレスそのものを監査ログの payload に保存してしまう」 ことです。

監査ログに個人情報を載せると、「監査ログ基盤そのものが PII の巨大なコピー場所」 になってしまい、ログの閲覧権限管理やデータ削除要求(Right to be forgotten)への対応が極めて困難になります。

// 厳格な Zod スキーマで未知のキーの混入を構造的に禁止する
const auditPayloadSchema = z.strictObject({
  actorId: z.string().uuid(),
  actorType: z.enum(['ADMIN', 'SYSTEM']),
  targetId: z.string().uuid(),
  targetType: z.enum(['USER', 'ORGANIZATION']),
  action: z.literal('ADMIN_SENSITIVE_REVEAL'),
});

z.strictObject を使用することで、スキーマに定義されていないキー(開示した email、検索クエリ、認証トークン等)は バリデーション時点で型レベル・実行時レベルで拒絶 されます。

項目監査ログに保存するか理由
Actor / Target の内部 ID保存する誰が誰のデータを閲覧したかの追跡に必須
開示されたメールアドレス保存しない監査ログの個人情報ストレージ化を防ぐため
自由記述の理由(reason)保存しない自由入力欄からクレデンシャルや PII が紛れ込むのを防ぐため
認証トークン・セッション ID保存しないセキュリティ上の二次被害を防ぐため

監査に必要なのは「主体」「対象」「行為」の特定までであり、機密データ本体の複写は一切不要です。

4. 不要な自由記述フィールド(reason)の完全抹消

以前の開示 API には、管理者が入力する「開示理由(reason)」という自由記述フィールドが存在していました。

しかし、自由記述テキストは機密情報やパスワードが誤って入力される温床です。監査ログの payload から自由記述を締め出す方針に合わせ、この reason 入力欄を フロントエンドの UI、バックエンドの DTO、OpenAPI 定義から完全に削除 しました。

  • 後方互換性のためのエイリアスやダミーフィールドは残さない
  • フロントエンド・バックエンドのクライアントコードを再生成し、型レベルで存在を抹消する

スキーマから消し去ることで、「将来別の開発者がうっかり reason を監査ログに繋ぎ直してしまう」リスクを永久に排除しました。

5. テスト駆動によるセキュリティ契約の保証

この Fail-Closed な境界が将来のリファクタリングで壊れないよう、単体テストおよび実 DB を使った統合テストで契約を固定しています。

describe('revealUserEmail Security Contract', () => {
  it('監査トランザクションが失敗した場合、email を一切返さず 503 例外をスローすること', async () => {
    // Outbox への書き込みが失敗するようモック
    prismaMock.securityAuditOutbox.create.mockRejectedValue(new Error('DB Timeout'));

    await expect(revealUserEmail(mockAdmin, 'user-123'))
      .rejects
      .toThrow(SensitiveRevealAuditUnavailableError);
  });

  it('同一 event_id の重複書き込みは DB の UNIQUE 制約でブロックされること', async () => {
    // 冪等性と多重実行防止の検証
    await expect(insertAuditEvent(duplicateEvent)).rejects.toThrow();
  });

  it('Controller が成功時に AppLogger を直接呼ばないこと', async () => {
    // 監査が usecase の Outbox で完結していることを保証
    expect(loggerSpy).not.toHaveBeenCalled();
  });
});

まとめ: 値と証跡の順序を逆転させない

機密情報を取り扱うシステムにおいて、信頼性を担保するための鉄則は次の 3 点です。

  1. Fail-Closed に倒す: 監査ログの永続化(Outbox Commit)が完了するまで機密情報を渡さず、失敗時は 503 で安全に止める
  2. 監査ログをサニタイズする: 監査ログに機密データ本体を載せず、strictObject で未知の属性の混入を型で防ぐ
  3. 不要な入力欄を消す: 自由記述欄など機密混入のリスクがあるフィールドは、型とスキーマから完全に削除する

「ログを出してから値を返す」のではなく、「証跡の生存が確定して初めて値を返す」。この順序の徹底が、真に堅牢なエンタープライズセキュリティの基盤となります。

参考リンク