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

監査ログ

📍 このドキュメントの位置づけ

対象読者: 監査ログの実装詳細を理解したい開発者

このドキュメントで学べること:

  • 監査ログ(Audit Log)の構造
  • AuditLogWriter プラグインの実装方法
  • 非同期ログ処理の仕組み(AuditLogPublisher)
  • データベースへの永続化
  • カスタムログ出力先の実装(CloudWatch Logs、Splunk等)

前提知識:


🏗️ 監査ログアーキテクチャ

idp-serverは、すべての重要な操作を**監査ログ(Audit Log)**として記録します。

監査ログフロー

┌─────────────────────────────────────────────────────────────┐
│ 1. API操作(Control Plane / Application Plane) │
│ - ユーザー作成、設定変更、認証、トークン発行等 │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 2. AuditLogCreator │
│ - AuditableContext から AuditLog を生成 │
│ - UUID生成、タイムスタンプ追加 │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 3. AuditLogPublisher │
│ - 非同期イベント発行(Spring Events等) │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 4. AuditLogWriters │
│ - 各 AuditLogWriter の shouldExecute() 判定 │
│ - 実行すべき Writer の write() 実行 │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 5. AuditLogWriter 実装 │
│ - AuditLogDataBaseWriter: データベースに保存 │
│ - カスタムWriter: CloudWatch Logs、Splunk等に送信 │
└─────────────────────────────────────────────────────────────┘

📦 AuditLog モデル

監査ログは、以下の情報を含みます。

public class AuditLog {
String id; // 監査ログID(UUID)
String type; // ログタイプ(例: "user.created")
String description; // 説明
String tenantId; // テナントID
String clientId; // クライアントID(オプション)
String userId; // ユーザーID(オプション)
String externalUserId; // 外部ユーザーID(オプション)
JsonNodeWrapper userPayload; // ユーザー情報
String targetResource; // 対象リソース(例: "user")
String targetResourceAction; // 操作(例: "create")
JsonNodeWrapper request; // リクエスト内容
JsonNodeWrapper before; // 変更前の状態
JsonNodeWrapper after; // 変更後の状態
String outcomeResult; // 結果(例: "success", "failure")
String outcomeReason; // 理由
String targetTenantId; // 対象テナントID(マルチテナント操作時)
String ipAddress; // IPアドレス
String userAgent; // User-Agent
JsonNodeWrapper attributes; // 追加属性
boolean dryRun; // Dry Run モード
LocalDateTime createdAt; // 作成日時
}

参考実装: AuditLog.java:25

AuditLog生成例

public class AuditLogCreator {

public static AuditLog create(AuditableContext context) {
String id = UUID.randomUUID().toString();
LocalDateTime createdAt = SystemDateTime.now();

return new AuditLog(
id,
context.type(), // "user.created"
context.description(), // "User registration"
context.tenantId(),
context.clientId(),
context.userId(),
context.externalUserId(),
JsonNodeWrapper.fromMap(context.userPayload()),
context.targetResource(), // "user"
context.targetResourceAction(), // "create"
JsonNodeWrapper.fromMap(context.request()),
JsonNodeWrapper.fromMap(context.before()),
JsonNodeWrapper.fromMap(context.after()),
context.outcomeResult(), // "success"
context.outcomeReason(),
context.targetTenantId(),
context.ipAddress(),
context.userAgent(),
JsonNodeWrapper.fromMap(context.attributes()),
context.dryRun(),
createdAt);
}
}

参考実装: AuditLogCreator.java:25


🔌 AuditLogWriter プラグイン

インターフェース

public interface AuditLogWriter {

/**
* このWriterを実行すべきか判定
*
* @param tenant テナント情報
* @param auditLog 監査ログ
* @return 実行する場合 true
*/
default boolean shouldExecute(Tenant tenant, AuditLog auditLog) {
return true; // デフォルトは常に実行
}

/**
* 監査ログを書き込む
*
* @param tenant テナント情報
* @param auditLog 監査ログ
*/
void write(Tenant tenant, AuditLog auditLog);
}

参考実装: AuditLogWriter.java:21

デフォルト実装: AuditLogDataBaseWriter

データベースに監査ログを保存します。

public class AuditLogDataBaseWriter implements AuditLogWriter {

AuditLogCommandRepository auditLogCommandRepository;

public AuditLogDataBaseWriter(AuditLogCommandRepository auditLogCommandRepository) {
this.auditLogCommandRepository = auditLogCommandRepository;
}

@Override
public void write(Tenant tenant, AuditLog auditLog) {
auditLogCommandRepository.register(tenant, auditLog);
}
}

参考実装: AuditLogDataBaseWriter.java:21

AuditLogWriters(複数Writer管理)

public class AuditLogWriters {

List<AuditLogWriter> writers;

public AuditLogWriters(List<AuditLogWriter> writers) {
this.writers = writers;
}

public void write(Tenant tenant, AuditLog auditLog) {
for (AuditLogWriter writer : writers) {
// shouldExecute() で判定
if (writer.shouldExecute(tenant, auditLog)) {
log.info(
"TenantId {} AuditLogWriter execute: {}",
tenant.identifierValue(),
writer.getClass().getSimpleName());

// 実行
writer.write(tenant, auditLog);
}
}
}
}

参考実装: AuditLogWriters.java:23

特徴:

  • 複数のWriterを登録可能(データベース + CloudWatch Logs等)
  • shouldExecute() で条件判定(テナントごとに異なる出力先等)
  • すべてのWriterを順次実行

🔄 非同期処理: AuditLogPublisher

監査ログは、非同期で処理されます。

AuditLogPublisher インターフェース

public interface AuditLogPublisher {

/**
* 監査ログイベントを非同期処理のために発行
*
* @param auditLog 非同期処理される監査ログ
*/
void publish(AuditLog auditLog);
}

参考実装: AuditLogPublisher.java:28

実装パターン: Spring Events

// 1. Publisher実装(Spring Events使用)
@Component
public class SpringAuditLogPublisher implements AuditLogPublisher {

@Autowired
private ApplicationEventPublisher eventPublisher;

@Override
public void publish(AuditLog auditLog) {
// Spring Eventとして発行
eventPublisher.publishEvent(new AuditLogEvent(auditLog));
}
}

// 2. Event定義
public class AuditLogEvent {
private final AuditLog auditLog;

public AuditLogEvent(AuditLog auditLog) {
this.auditLog = auditLog;
}

public AuditLog getAuditLog() {
return auditLog;
}
}

// 3. EventListener実装
@Component
public class AuditLogEventListener {

@Autowired
private AuditLogWriters auditLogWriters;

@Autowired
private TenantQueryRepository tenantRepository;

@EventListener
@Async // 非同期実行
public void handleAuditLogEvent(AuditLogEvent event) {
AuditLog auditLog = event.getAuditLog();
Tenant tenant = tenantRepository.get(auditLog.tenantIdentifier());

// すべてのWriterを実行
auditLogWriters.write(tenant, auditLog);
}
}

非同期処理のメリット:

  • レスポンス速度向上: API応答を待たずにログ処理
  • スケーラビリティ: ログ処理の負荷をバックグラウンド化
  • 耐障害性: ログ出力エラーがAPIレスポンスに影響しない

🧩 カスタムAuditLogWriter実装例

例1: CloudWatch Logs Writer

public class CloudWatchLogsAuditLogWriter implements AuditLogWriter {

private final CloudWatchLogsClient cloudWatchClient;
private final String logGroupName;
private final String logStreamName;

public CloudWatchLogsAuditLogWriter(
CloudWatchLogsClient cloudWatchClient,
String logGroupName,
String logStreamName) {
this.cloudWatchClient = cloudWatchClient;
this.logGroupName = logGroupName;
this.logStreamName = logStreamName;
}

@Override
public boolean shouldExecute(Tenant tenant, AuditLog auditLog) {
// 本番環境のテナントのみCloudWatch Logsに出力
return tenant.type() == TenantType.PUBLIC
&& !auditLog.dryRun();
}

@Override
public void write(Tenant tenant, AuditLog auditLog) {
try {
// AuditLog を JSON 形式に変換
String logMessage = new ObjectMapper().writeValueAsString(auditLog.toMap());

// CloudWatch Logs に送信
InputLogEvent logEvent = InputLogEvent.builder()
.message(logMessage)
.timestamp(System.currentTimeMillis())
.build();

PutLogEventsRequest request = PutLogEventsRequest.builder()
.logGroupName(logGroupName)
.logStreamName(logStreamName)
.logEvents(logEvent)
.build();

cloudWatchClient.putLogEvents(request);

} catch (Exception e) {
// ログ出力エラーをログ(メタログ)
log.error("Failed to write audit log to CloudWatch Logs", e);
}
}
}

例2: Splunk Writer

public class SplunkAuditLogWriter implements AuditLogWriter {

private final HttpClient httpClient;
private final String splunkUrl;
private final String splunkToken;

@Override
public boolean shouldExecute(Tenant tenant, AuditLog auditLog) {
// 特定タイプのログのみSplunkに送信
return auditLog.type().startsWith("security.")
|| auditLog.outcomeResult().equals("failure");
}

@Override
public void write(Tenant tenant, AuditLog auditLog) {
try {
// Splunk HEC (HTTP Event Collector) 形式
Map<String, Object> event = Map.of(
"time", System.currentTimeMillis() / 1000,
"source", "idp-server",
"sourcetype", "audit_log",
"event", auditLog.toMap()
);

String json = new ObjectMapper().writeValueAsString(event);

// Splunk HEC に送信
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(splunkUrl + "/services/collector"))
.header("Authorization", "Splunk " + splunkToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();

HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());

if (response.statusCode() != 200) {
log.error("Failed to send audit log to Splunk: {}", response.body());
}

} catch (Exception e) {
log.error("Failed to write audit log to Splunk", e);
}
}
}

プラグイン登録

META-INF/services/org.idp.server.platform.audit.AuditLogWriterProvider

com.example.idp.audit.CloudWatchLogsAuditLogWriterProvider
com.example.idp.audit.SplunkAuditLogWriterProvider

AuditLogWriterProvider 実装:

public class CloudWatchLogsAuditLogWriterProvider implements AuditLogWriterProvider {

@Override
public AuditLogWriter provide(ApplicationComponentContainer container) {
CloudWatchLogsClient client = container.get(CloudWatchLogsClient.class);
String logGroupName = System.getenv("CLOUDWATCH_LOG_GROUP");
String logStreamName = System.getenv("CLOUDWATCH_LOG_STREAM");

return new CloudWatchLogsAuditLogWriter(client, logGroupName, logStreamName);
}
}

📋 監査ログのフィールド詳細

基本情報

フィールド必須説明
idString監査ログID(UUID)"a1b2c3d4-..."
typeStringログタイプ"user.created"
descriptionString説明"User registration"
tenantIdStringテナントID"tenant-123"
createdAtLocalDateTime作成日時"2025-12-07T10:30:00"

操作情報

フィールド必須説明
targetResourceString対象リソース"user"
targetResourceActionString操作"create", "update", "delete"
requestJsonNodeWrapperリクエスト内容{"username": "test"}
beforeJsonNodeWrapper変更前の状態{"status": "active"}
afterJsonNodeWrapper変更後の状態{"status": "inactive"}

操作者情報

フィールド必須説明
clientIdStringクライアントID"client-abc"
userIdStringユーザーID"user-xyz"
externalUserIdString外部ユーザーID"external-123"
userPayloadJsonNodeWrapperユーザー情報{"email": "test@example.com"}
ipAddressStringIPアドレス"192.168.1.1"
userAgentStringUser-Agent"Mozilla/5.0..."

結果情報

フィールド必須説明
outcomeResultString結果"success", "failure"
outcomeReasonString理由"Invalid credentials"
dryRunbooleanDry Runモードfalse

追加情報

フィールド必須説明
targetTenantIdString対象テナントID"tenant-456"
attributesJsonNodeWrapper追加属性{"custom": "value"}

🧪 テスト実装例

AuditLog生成テスト

@Test
void testAuditLogCreation() {
// 1. AuditableContext作成
AuditableContext context = AuditableContext.builder()
.type("user.created")
.description("User registration")
.tenantId("tenant-123")
.userId("user-456")
.targetResource("user")
.targetResourceAction("create")
.request(Map.of("username", "testuser"))
.after(Map.of("username", "testuser", "status", "active"))
.outcomeResult("success")
.ipAddress("192.168.1.1")
.userAgent("Mozilla/5.0")
.dryRun(false)
.build();

// 2. AuditLog生成
AuditLog auditLog = AuditLogCreator.create(context);

// 3. 検証
assertNotNull(auditLog.id());
assertEquals("user.created", auditLog.type());
assertEquals("tenant-123", auditLog.tenantId());
assertEquals("user", auditLog.targetResource());
assertEquals("create", auditLog.targetResourceAction());
assertEquals("success", auditLog.outcomeResult());
}

カスタムWriter テスト

@Test
void testCustomAuditLogWriter() {
// 1. カスタムWriter作成
List<String> writtenLogs = new ArrayList<>();

AuditLogWriter customWriter = new AuditLogWriter() {
@Override
public boolean shouldExecute(Tenant tenant, AuditLog auditLog) {
return auditLog.type().startsWith("security.");
}

@Override
public void write(Tenant tenant, AuditLog auditLog) {
writtenLogs.add(auditLog.id());
}
};

// 2. AuditLogWriters作成
AuditLogWriters writers = new AuditLogWriters(List.of(customWriter));

// 3. セキュリティログ書き込み
AuditLog securityLog = new AuditLog(..., "security.login_failed", ...);
writers.write(tenant, securityLog);

// 4. 検証: セキュリティログは書き込まれる
assertEquals(1, writtenLogs.size());

// 5. 通常ログ書き込み
AuditLog normalLog = new AuditLog(..., "user.created", ...);
writers.write(tenant, normalLog);

// 6. 検証: 通常ログは書き込まれない(shouldExecute=false)
assertEquals(1, writtenLogs.size());
}

📋 実装チェックリスト

カスタムAuditLogWriterを実装する際のチェックリスト:

  • AuditLogWriter実装:

    public class MyAuditLogWriter implements AuditLogWriter {
    @Override
    public boolean shouldExecute(Tenant tenant, AuditLog auditLog) { ... }

    @Override
    public void write(Tenant tenant, AuditLog auditLog) { ... }
    }
  • shouldExecute判定:

    • テナント条件(本番環境のみ等)
    • ログタイプ条件(security.*のみ等)
    • Dry Runモードの扱い
  • write実装:

    • 外部システムへの送信ロジック
    • エラーハンドリング(送信失敗時)
    • タイムアウト設定
  • Provider実装:

    public class MyAuditLogWriterProvider implements AuditLogWriterProvider {
    @Override
    public AuditLogWriter provide(ApplicationComponentContainer container) {
    return new MyAuditLogWriter(...);
    }
    }
  • プラグイン登録:

    META-INF/services/org.idp.server.platform.audit.AuditLogWriterProvider
    com.example.idp.audit.MyAuditLogWriterProvider
  • テスト作成:

    • shouldExecute のテスト
    • write のテスト
    • エラー時の動作テスト

🚨 よくある間違い

1. 同期処理の実装

// ❌ 誤り: write() で時間のかかる処理(APIレスポンス遅延)
@Override
public void write(Tenant tenant, AuditLog auditLog) {
httpClient.send(request, ...); // 同期送信(遅い)
}

// ✅ 正しい: AuditLogPublisher経由で非同期処理
auditLogPublisher.publish(auditLog); // 非同期

2. エラーハンドリング不足

// ❌ 誤り: エラー時に例外をthrow(他のWriterが実行されない)
@Override
public void write(Tenant tenant, AuditLog auditLog) {
httpClient.send(request, ...); // 例外が発生すると後続Writerが実行されない
}

// ✅ 正しい: try-catchでエラーをキャッチ
@Override
public void write(Tenant tenant, AuditLog auditLog) {
try {
httpClient.send(request, ...);
} catch (Exception e) {
log.error("Failed to write audit log", e);
// 他のWriterは実行される
}
}

3. テナント分離の考慮不足

// ❌ 誤り: すべてのテナントを同じログストリームに出力
String logStreamName = "audit-logs";

// ✅ 正しい: テナントごとにログストリームを分離
String logStreamName = "audit-logs-" + tenant.identifierValue();

4. 機密情報のログ出力

// ❌ 誤り: パスワードやトークンをログに含める
auditLog.request().put("password", "secret123");
auditLog.request().put("access_token", "xxx");

// ✅ 正しい: 機密情報はマスクまたは除外
auditLog.request().put("password", "***"); // マスク
// または password フィールド自体を含めない

🔗 関連ドキュメント

概念・基礎:

実装詳細:

参考実装クラス:


最終更新: 2025-12-07 難易度: ⭐⭐⭐ (中級)