メインコンテンツまでスキップ

SecurityEvent 実装ガイド

このドキュメントの目的

SecurityEventの仕組みとリトライ戦略を理解することが目標です。

所要時間

⏱️ 約15分

前提知識


SecurityEventとは

目的: 「何が起きたか」を記録・通知

認証成功 → SecurityEvent(password_success) → 監査ログに記録 → 外部サービスに通知

特徴:

  • 記録中心: security_eventテーブルに永久保存
  • 監視・通知: Security Event Hooksで外部サービスに送信
  • 状態変更しない: ユーザー・トークン等は変更しない

処理フロー概要

SecurityEventの処理は以下の流れで行われます:

  1. イベント発行: EntryServiceが eventPublisher.publish() で発行(同期)
  2. 非同期処理: Spring @EventListener がイベントを受信し、ThreadPoolに投入
  3. イベント処理: SecurityEventHandler がDBへの記録とHook送信を実行
  4. リトライ: 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 テーブル │
└─────────────────────────────────────────────────────┘

実装:


同期処理と非同期処理

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() で既存のコネクションを取得するため、外側のトランザクション内で正常に動作します。

実装


ThreadPool設定

設定説明
CorePoolSize5常駐スレッド数
MaxPoolSize10最大スレッド数
QueueCapacity50キュー待機数
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件目以降は RejectedExecutionHandlerRetryScheduler

スレッド数設定の考え方

スレッド数の上限制約

スレッド数は以下のリソースによって制約されます:

制約説明確認方法
DB接続プールスレッド数 > DB接続数だと接続待ちが発生HikariCPのmaximumPoolSize
メモリ(スタック)1スレッドあたり約1MB消費(デフォルト)-Xssオプション
OS制限プロセスあたりのスレッド数上限ulimit -u
外部API Rate LimitHook送信先のレート制限外部サービスの仕様
実効上限 = min(DB接続プール, 利用可能メモリ/スタックサイズ, OS制限, 外部API制限)

: DB接続プール20、メモリ2GB、スタック1MBの場合

  • メモリ上限: 2048MB / 1MB = 約2000スレッド(理論値)
  • 実効上限: 20スレッド(DB接続プールがボトルネック)

I/O待ち時間を考慮

SecurityEvent処理はI/Oバウンド(DB書き込み、HTTP送信)なので、CPUコア数より多めに設定可能。

推奨スレッド数 = CPUコア数 × (1 + I/O待ち時間 / CPU処理時間)

例:4コアCPU、I/O待ち時間がCPU処理時間の10倍の場合

4 × (1 + 10) = 44スレッド まで効果的

ただし、上記の上限制約を超えないこと。

現在の設定値の根拠

設定根拠
CorePoolSize5通常負荷での安定動作。DB接続プールとのバランス
MaxPoolSize10ピーク時の2倍対応。過度なリソース消費を防止
QueueCapacity50バースト的なイベント増加に対応。メモリ消費とのバランス

調整が必要なケース

状況調整案
リトライが頻発するMaxPoolSize / QueueCapacity を増加
メモリ使用量が高いQueueCapacity を減少
DB接続エラーが発生CorePoolSize を DB接続プール以下に調整
Hook送信が遅いMaxPoolSize を増加(I/O待ち対策)

設定変更方法

環境変数で設定を変更できます:

環境変数説明デフォルト値
SECURITY_EVENT_CORE_POOL_SIZEコアスレッド数5
SECURITY_EVENT_MAX_POOL_SIZE最大スレッド数20
SECURITY_EVENT_QUEUE_CAPACITYキュー容量100

application.yml での設定例:

idp:
async:
security-event:
core-pool-size: ${SECURITY_EVENT_CORE_POOL_SIZE:5}
max-pool-size: ${SECURITY_EVENT_MAX_POOL_SIZE:20}
queue-capacity: ${SECURITY_EVENT_QUEUE_CAPACITY:100}

実装: AsyncConfig.java, AsyncProperties.java


リトライ戦略

ThreadPool満杯時のリトライはSecurityEventRetrySchedulerが担当します。

設定
最大リトライ3回
間隔60秒
超過時ログ出力して破棄

リトライ実装

リトライ回数管理: Mapでイベント別にカウント

@Component
public class SecurityEventRetryScheduler {

private static final int MAX_RETRIES = 3;

Queue<SecurityEvent> retryQueue = new ConcurrentLinkedQueue<>();
Map<String, Integer> retryCountMap = new ConcurrentHashMap<>();

public void enqueue(SecurityEvent event) {
retryQueue.add(event);
retryCountMap.putIfAbsent(event.id(), 0);
}

@Scheduled(fixedDelay = 60_000)
public void resendFailedEvents() {
while (!retryQueue.isEmpty()) {
SecurityEvent event = retryQueue.poll();
String eventId = event.id();

try {
log.info("retry event (attempt {}): {}",
retryCountMap.get(eventId) + 1, eventId);
securityEventApi.handle(event.tenantIdentifier(), event);
retryCountMap.remove(eventId); // 成功時はクリア
} catch (Exception e) {
int count = retryCountMap.merge(eventId, 1, Integer::sum);
if (count < MAX_RETRIES) {
log.warn("retry scheduled ({}/{}): {}", count, MAX_RETRIES, eventId);
retryQueue.add(event);
} else {
log.error("max retries exceeded, dropping event: {}", event.toMap());
retryCountMap.remove(eventId);
}
}
}
}
}

ポイント:

  • retryCountMap: イベントID → リトライ回数のマッピング
  • 成功時・上限到達時にremove()でメモリ解放
  • 最大3回リトライ後は破棄(ログに記録)

主要なイベントタイプ

Application Planeで発行されるSecurityEvent:

イベントタイプ発行タイミング実装箇所
password_successパスワード認証成功OAuthFlowEntryService.java:210
password_failureパスワード認証失敗同上
oauth_authorizeAuthorization Code発行OAuthFlowEntryService.java:330-335
token_request_successトークン発行成功TokenEntryService
userinfo_successUserInfo取得成功UserinfoEntryService
backchannel_authentication_request_successCIBA認証リクエスト成功CibaFlowEntryService
authentication_device_log認証デバイスからのログ受信AuthenticationDeviceLogEntryService.java

完全なリスト: DefaultSecurityEventType.java


Authentication Device Log

クライアント(モバイルアプリ等)から送信される認証デバイスログをセキュリティイベントとして記録します。

エンドポイント

POST /{tenant-id}/v1/authentication-devices/logs

リクエスト例

{
"device_id": "device-12345",
"event": "fido2_authentication_attempt",
"status": "success",
"timestamp": "2025-12-26T10:00:00Z",
"details": {
"authenticator_type": "platform",
"user_verification": true
}
}

処理フロー

クライアント → POST /logs

AuthenticationDeviceV1Api
↓ (ログ出力)
log.info(requestBody)

AuthenticationDeviceLogEntryService

1. device_id または user_id からユーザー検索
2. ユーザーが見つかった場合のみセキュリティイベント発行

SecurityEventPublisher.publish()

ユーザー検索ロジック

  1. device_id がリクエストに含まれる場合 → UserQueryRepository.findByAuthenticationDevice() で検索
  2. user_id がリクエストに含まれる場合 → UserQueryRepository.findById() で検索
  3. ユーザーが見つからない場合 → セキュリティイベントは発行されない(ノイズ防止)

セキュリティイベント詳細

発行されるセキュリティイベントには以下の情報が含まれます:

フィールド内容
event_typeauthentication_device_log
tenantテナント情報(ID、issuer、name)
userユーザー情報(見つかった場合)
execution_resultリクエストボディ全体
ip_addressクライアントIPアドレス
user_agentUser-Agent

実装ファイル


イベント発行の実装

// 10. イベント発行(Security Event)
eventPublisher.publish(
tenant,
authorizationRequest,
result.user(),
result.eventType(), // password_success or password_failure
requestAttributes);

統計データ記録

SecurityEventの処理時に、テナント統計データ(DAU/MAU/YAU、イベントカウント等)を記録できます。

有効化設定

テナントのsecurity_event_log_configstatistics_enabledtrueに設定します:

{
"security_event_log_config": {
"statistics_enabled": true
}
}
設定デフォルト説明
statistics_enabledfalse統計データ記録を有効化

注意: 統計機能を有効にすると、セキュリティイベント発生時にデータベースへの書き込みが追加で発生します。

関連ドキュメント: テナント統計管理


データベーススキーマ

security_event テーブル

CREATE TABLE security_event (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
event_type VARCHAR(255) NOT NULL, -- 'password_success' 等
user_id UUID,
client_id VARCHAR(255),
ip_address VARCHAR(45),
user_agent TEXT,
event_data JSONB,
created_at TIMESTAMP NOT NULL
);

用途:

  • 監査ログとして永久保存
  • Security Event Hooksで外部サービスに通知
  • SIEM(Security Information and Event Management)連携

Security Event Hooks

Hooksとは

SecurityEventを外部サービス(Webhook/Slack/SIEM等)に通知する仕組み。

SecurityEvent発行

SecurityEventHookExecutor(非同期)

┌─────────────────────────────────────────┐
│ 設定されたHookエンドポイントに送信 │
├─────────────────────────────────────────┤
│ POST https://webhook.example.com/events │
│ { │
│ "event_type": "password_failure", │
│ "user_id": "user-12345", │
│ "ip_address": "192.168.1.1", │
│ "timestamp": "2025-10-13T10:00:00Z" │
│ } │
└─────────────────────────────────────────┘

外部サービス(Slack/SIEM/監視ツール)

Hook設定

Management APIで設定:

{
"id": "uuid",
"event_types": ["password_failure", "user_locked", "token_request_success"],
"endpoint": "https://webhook.example.com/events",
"auth_type": "bearer",
"auth_token": "secret-token",
"enabled": true
}

設定API:

POST /v1/management/tenants/{tenant-id}/security-event-hooks

よくある質問

Q1: イベントは同期?非同期?

2つのモードがあります:

モード用途イベント処理トランザクション
非同期publishApplication Plane別スレッド別トランザクション
同期publishSyncControl Plane管理API同一スレッド同一トランザクション

非同期の理由: Application Planeではレスポンス速度が重要。イベント処理の遅延がAPIに影響しない。

同期の理由: 管理APIではイベント処理(ログ保存、統計更新)の完了を保証してからレスポンスを返す。

詳細は「同期処理と非同期処理」セクションを参照。

Q2: イベント発行失敗時は?

SecurityEvent発行失敗:

  • トランザクションロールバック
  • API呼び出し自体が失敗

Hook送信失敗:

  • HTTP層でリトライ(3回)
  • 最終的に失敗 → security_event_hook_results テーブルに記録
  • API呼び出しは成功(非同期のため影響なし)

Q3: リトライ回数を変更するには?

SecurityEventRetrySchedulerMAX_RETRIES定数を変更します。

将来的には設定ファイル(application.yml)から読み込む形式への変更を検討中です。

Q4: Hook設定を変更したのに反映されない?

原因: Hook設定はパフォーマンス向上のためキャッシュされています(TTL 5分)。

キャッシュの動作:

  • 読み取り時: Adapter層(SecurityEventHookConfigurationQueryDataSource)でキャッシュ
  • 更新時: 設定の登録/更新/削除時にキャッシュが自動無効化

設定変更が反映されないケース:

  • 通常は即座に反映(Management APIでの更新時にキャッシュ無効化)
  • DBを直接更新した場合は最大5分待つか、サーバー再起動が必要

実装:


関連ドキュメント


情報源:

最終更新: 2026-02-08