SecurityEvent 実装ガイド
このドキュメントの目的
SecurityEventの仕組みとリトライ戦略を理解することが目標です。
所要時間
⏱️ 約15分
前提知識
- 04. Authentication実装
- Spring Frameworkの
@EventListenerの基礎知識
SecurityEventとは
目的: 「何が起きたか」を記録・通知
認証成功 → SecurityEvent(password_success) → 監査ログに記録 → 外部サービスに通知
特徴:
- ✅ 記録中心: security_eventテーブルに永久保存
- ✅ 監視・通知: Security Event Hooksで外部サービスに送信
- ❌ 状態変更しない: ユーザー・トークン等は変更しない
処理フロー概要
SecurityEventの処理は以下の流れで行われます:
- イベント発行: EntryServiceが
eventPublisher.publish()で発行(同期) - 非同期処理: Spring
@EventListenerがイベントを受信し、ThreadPoolに投入 - イベント処理:
SecurityEventHandlerがDBへの記録とHook送信を実行 - リトライ: ThreadPool満杯時は
SecurityEventRetrySchedulerが後で再実行
EntryService → publish() → ThreadPool → SecurityEventHandler → DB記録 + Hook送信
↓ (満杯時)
RetryScheduler → 60秒後に再実行(最大3回)
ポイント: イベント発行は同期だが、処理は非同期。API呼び出しはイベント処理完了を待たずに返却される。
アーキテクチャ
Application Plane API(認証・認可・トークン発行等)
↓
EntryService - eventPublisher.publish()
↓ (同期)
┌─────────────────────────────────────────────────────┐
│ SecurityEventPublisher(インターフェース) │
├─────────────────────────────────────────────────────┤
│ SecurityEventPublisherService(Adapter層) │
│ → applicationEventPublisher.publishEvent() │
│ (Spring ApplicationEventPublisher) │
└─────────────────────────────────────────────────────┘
↓ (同期で即座に返却)
EntryService処理完了 → HTTPレスポンス返却
↓
↓ (非同期 - Spring @EventListener)
↓
┌─────────────────────────────────────────────────────┐
│ SecurityEventListener(Spring Bean) │
├─────────────────────────────────────────────────────┤
│ @EventListener │
│ handleSecurityEvent(SecurityEvent event) │
│ ↓ │
│ SecurityEventRunnable作成 │
│ - TenantLoggingContext設定 │
│ - SecurityEventHandler呼び出し │
│ ↓ │
│ securityEventTaskExecutor.execute(runnable) │
│ → ThreadPoolに投入 │
└─────────────────────────────────────────────────────┘
↓ (ThreadPoolで非同期実行)
├─ 正常時: 別スレッドで実行
└─ ThreadPool満杯時: RejectedExecutionHandler
↓
┌─────────────────────────────────────────────────┐
│ RejectedExecutionHandler │
├─────────────────────────────────────────────────┤
│ SecurityEventRetryScheduler.enqueue() │
│ → retryQueueに追加 │
└────── ───────────────────────────────────────────┘
↓ (別スレッド - 正常実行時)
┌─────────────────────────────────────────────────────┐
│ SecurityEventHandler(Platform層) │
├─────────────────────────────────────────────────────┤
│ 1. SecurityEventLogService.logEvent() │
│ → security_event テーブルに記録 │
│ │
│ 2. updateStatistics() ※statistics_enabled時のみ │
│ → テナント統計データを更新(DAU/MAU/YAU等) │
│ │
│ 3. SecurityEventHookConfiguration取得 │
│ → 設定されたHookを取得(キャッシュ使用、TTL 5分) │
│ │
│ 4. SecurityEventHook.shouldExecute() │
│ → イベントタイプフィルタリング │
│ │
│ 5. SecurityEventHook.execute() │
│ → 外部サービスに送信(Webhook/Slack/SIEM) │
│ │
│ 6. SecurityEventHookResult保存 │
│ → security_event_hook_results テーブル │
└─────────────────────────────────────────────────────┘
実装:
- Publisher: SecurityEventPublisherService.java
- Runnable: SecurityEventRunnable.java
- Handler: SecurityEventHandler.java
- Retry Scheduler: SecurityEventRetryScheduler.java
- ThreadPool設定: AsyncConfig.java:46-69
同期処理と非同期処理
SecurityEventの発行には 非同期(publish) と 同期(publishSync) の2つのモードがあります。
使い分け
| モード | メソッド | 用途 |
|---|---|---|
| 非同期 | publish() | Application Plane(認証・認可・トークン発行等) |
| 同期 | publishSync() | Control Plane(ユーザー作成・更新・削除等の管理API) |
非同期: APIレスポンスを高速に返したい場合。イベント処理の成否はレスポンスに影響しない。
同期: イベント処理(ログ保存、統計更新、フック実行)が完了してからレスポンスを返したい場合。管理APIではイベント処理の完了を保証する必要がある。
スレッドとトランザクションの違い
【非同期: publish()】
リクエストスレッド イベント処理スレッド
────────────────── ──────────────────
ManagementEntryServiceProxy SecurityEventListerService
│ beginTransaction() │
│ ┌─────────────────────┐ │
│ │ Transaction A │ │
│ │ │ │
│ │ UserCreationService │ │
│ │ register(user) │ │
│ │ publish(event) ───────────→ @Async @EventListener
│ │ │ │ beginTransaction()
│ │ │ │ ┌──────────────────┐
│ └─────────────────────┘ │ │ Transaction B │
│ commitTransaction() │ │ │
│ │ │ SecurityEvent │
↓ HTTPレスポンス返却 │ │ Handler.handle() │
(イベント処理完了を待たない) │ └──────────────────┘
│ commitTransaction()
↓
【同期: publishSync()】
リクエストスレッド(同一スレッドで全処理)
──────────────────────────────────────
ManagementEntryServiceProxy
│ beginTransaction()
│ ┌───────────────────────────────┐
│ │ Transaction A │
│ │ │
│ │ UserCreationService │
│ │ register(user) │
│ │ publishSync(event) │
│ │ │ │
│ │ ↓ │
│ │ rawSecurityEventApi │
│ │ .handle() │
│ │ │ │
│ │ ↓ │
│ │ SecurityEventHandler │
│ │ .handle() │
│ │ - logEvent() │
│ │ - updateStatistics() │
│ │ - executeHooks() │
│ │ │
│ └───────────────────────────────┘
│ commitTransaction()
↓ HTTPレスポンス返却
(イベント処理完了後に返す)
| 項目 | 非同期(publish) | 同期(publishSync) |
|---|---|---|
| スレッド | 別スレッド(TaskExecutor経由) | 同一スレッド |
| トランザクション | 別トランザクション | 同一トランザクション |
| プロキシ | securityEventApi(Proxy経由) | rawSecurityEventApi(Proxy不要) |
| ログ コンテキスト | 再設定が必要 | リクエストスレッドで設定済み |
| エラー時 | 呼び出し元に影響なし | 呼び出し元にも例外伝搬 |
| ロールバック | イベント処理のみロールバック | ユーザー操作ごとロールバック |
rawSecurityEventApi が必要な理由
securityEventApi() は TenantAwareEntryServiceProxy でラップされており、呼び出し時に TransactionManager.beginTransaction() を実行します。同期パスでは既にトランザクションが開始されているため、二重に beginTransaction() が走り SqlRuntimeException: Transaction already started が発生します。
securityEventApi() → Proxy → beginTransaction() → ❌ 二重トランザクション
rawSecurityEventApi() → 直接 → 既存コネクション再利用 → ✅ 正常動作
rawSecurityEventApi() はプロキシを経由しない生の SecurityEventEntryService を返します。内部の Repository 呼び出しは TransactionManager.getConnection() で既存のコネクションを取得するため、外側のトランザクション内で正常に動作します。
実装
- Publisher インターフェース: SecurityEventPublisher.java
- Publisher 実装(Adapter層): SecurityEventPublisherService.java
- Management用 Publisher: ManagementEventPublisher.java
- rawSecurityEventApi 提供: IdpServerApplication.java
ThreadPool設定
| 設定 | 値 | 説明 |
|---|---|---|
| CorePoolSize | 5 | 常駐スレッド数 |
| MaxPoolSize | 10 | 最大スレッド数 |
| QueueCapacity | 50 | キュー待機数 |
| RejectedExecutionHandler | カスタム | 満杯時にRetrySchedulerへ |
処理の流れ
新規イベント到着
↓
┌─────────────────────────────────────────────────────────┐
│ ThreadPool (securityEventTaskExecutor) │
├─────────────────────────────────────────────────────────┤
│ │
│ [Worker 1] [Worker 2] [Worker 3] [Worker 4] [Worker 5] │ ← CorePoolSize: 5
│ │
│ ───────────────────────────────────────────────────── │
│ 負荷増加時に追加 │
│ [Worker 6] [Worker 7] [Worker 8] [Worker 9] [Worker 10]│ ← MaxPoolSize: 10
│ │
│ ───────────────────────────────────────────────────── │
│ 全Worker稼働中は待機 │
│ [Queue: 最大50件まで待機可能] │ ← QueueCapacity: 50
│ │
└─────────────────────────────────────────────────────────┘
↓ (Queue も満杯の場合)
┌─────────────────────────────────────────────────────────┐
│ RejectedExecutionHandler │
├─────────────────────────────────────────────────────────┤
│ SecurityEventRetryScheduler.enqueue(event) │
│ → 60秒後にリトライ(最大3回) │
└─────────────────────────────────────────────────────────┘
設定の意味
- CorePoolSize (5): 通常時に稼働するスレッド数。イベント処理の基本キャパシティ
- MaxPoolSize (10): 負荷が高い時に増加できる最大スレッド数
- QueueCapacity (50): 全スレッドが稼働中でも50件までキューで待機可能
- RejectedExecutionHandler: キューも満杯になった場合の処理。RetrySchedulerに委譲
キャパシティ計算
同時に処理可能なイベント数:
- 即時処理: 最大10件(MaxPoolSize)
- 待機可能: 50件(QueueCapacity)
- 合計: 60件まで受け入れ可能
61件目以降は RejectedExecutionHandler → RetryScheduler へ