Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,72 @@
import java.util.Map;
import java.util.Objects;

/**
* Domain event submitted to {@link AuditEventPublisher}.
*
* <p>The {@code outcome}, {@code ip}, and {@code userAgent} fields are
* promoted to first-class as of this revision; older publishers that
* still construct an event with only {@code action / actor / target /
* occurredAt / metadata} keep working via the backward-compatible
* constructor below. {@code AuditLogService} now persists these fields
* directly into the dedicated columns added by Flyway {@code V9}.
*/
public record AuditEvent(
AuditAction action,
AuditActor actor,
AuditTarget target,
Instant occurredAt,
Map<String, Object> metadata
Map<String, Object> metadata,
AuditOutcome outcome,
String ip,
String userAgent
) {

public AuditEvent {
Objects.requireNonNull(action, "AuditEvent action must not be null");
Objects.requireNonNull(occurredAt, "AuditEvent occurredAt must not be null");
metadata = metadata == null ? Map.of() : Map.copyOf(metadata);
}

/**
* Backward-compatible constructor for publishers written before
* outcome / ip / userAgent were first-class fields.
*/
public AuditEvent(
AuditAction action,
AuditActor actor,
AuditTarget target,
Instant occurredAt,
Map<String, Object> metadata
) {
this(action, actor, target, occurredAt, metadata, null, null, null);
}

public static Builder builder() {
return new Builder();
}

public static final class Builder {
private AuditAction action;
private AuditActor actor;
private AuditTarget target;
private Instant occurredAt;
private Map<String, Object> metadata = Map.of();
private AuditOutcome outcome;
private String ip;
private String userAgent;

public Builder action(AuditAction action) { this.action = action; return this; }
public Builder actor(AuditActor actor) { this.actor = actor; return this; }
public Builder target(AuditTarget target) { this.target = target; return this; }
public Builder occurredAt(Instant occurredAt) { this.occurredAt = occurredAt; return this; }
public Builder metadata(Map<String, Object> metadata) { this.metadata = metadata; return this; }
public Builder outcome(AuditOutcome outcome) { this.outcome = outcome; return this; }
public Builder ip(String ip) { this.ip = ip; return this; }
public Builder userAgent(String userAgent) { this.userAgent = userAgent; return this; }

public AuditEvent build() {
return new AuditEvent(action, actor, target, occurredAt, metadata, outcome, ip, userAgent);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package kr.devslab.kit.audit;

/**
* Two-state outcome for an {@link AuditEvent}.
*
* <p>Stored verbatim in the {@code platform_audit_log.outcome} column and
* surfaced under the same name in the admin UI's Audit Logs page.
*/
public enum AuditOutcome {
SUCCESS,
FAILURE
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.UUID;
import kr.devslab.kit.audit.AuditActor;
import kr.devslab.kit.audit.AuditEvent;
import kr.devslab.kit.audit.AuditOutcome;
import kr.devslab.kit.audit.AuditTarget;
import kr.devslab.kit.audit.core.entity.PlatformAuditLogEntity;
import kr.devslab.kit.audit.core.repository.JpaPlatformAuditLogRepository;
Expand All @@ -26,15 +27,21 @@ public AuditLogService(JpaPlatformAuditLogRepository repository, ObjectMapper ob
public void record(AuditEvent event) {
AuditActor actor = event.actor();
AuditTarget target = event.target();
// outcome / ip / userAgent are honoured if the publisher stuffs them
// into the AuditEvent metadata map under those keys; otherwise the row
// stays NULL and the response layer treats it as a SUCCESS row. The
// AuditEvent record itself doesn't carry these yet — adding them as
// first-class fields lives in a follow-up PR.
// outcome / ip / userAgent are first-class AuditEvent fields now. We
// still honour the metadata-map fallback that the field promotion PR
// (#15) shipped, so legacy publishers built before the SPI extension
// keep recording their outcome/ip/userAgent into the dedicated columns
// without a code change.
var metadata = event.metadata();
String outcome = stringFromMetadata(metadata, "outcome");
String ipAddress = stringFromMetadata(metadata, "ip");
String userAgent = stringFromMetadata(metadata, "userAgent");
String outcome = event.outcome() != null
? event.outcome().name()
: stringFromMetadata(metadata, "outcome");
String ipAddress = event.ip() != null
? event.ip()
: stringFromMetadata(metadata, "ip");
String userAgent = event.userAgent() != null
? event.userAgent()
: stringFromMetadata(metadata, "userAgent");
PlatformAuditLogEntity entity = new PlatformAuditLogEntity(
UUID.randomUUID(),
event.action().code(),
Expand All @@ -44,7 +51,7 @@ public void record(AuditEvent event) {
target == null ? null : target.type(),
target == null ? null : target.id(),
serializeMetadata(metadata),
outcome,
normalizeOutcome(outcome),
ipAddress,
userAgent,
event.occurredAt()
Expand All @@ -60,6 +67,23 @@ private String stringFromMetadata(Map<String, Object> metadata, String key) {
return value == null ? null : value.toString();
}

/**
* Coerce a free-form metadata string into the canonical
* {@link AuditOutcome} vocabulary the column expects. Unknown values
* are dropped to NULL so the response layer's "NULL == SUCCESS"
* default applies, rather than persisting a garbage outcome.
*/
private String normalizeOutcome(String raw) {
if (raw == null) {
return null;
}
try {
return AuditOutcome.valueOf(raw.toUpperCase()).name();
} catch (IllegalArgumentException e) {
return null;
}
}

private String serializeMetadata(Map<String, Object> metadata) {
if (metadata == null || metadata.isEmpty()) {
return null;
Expand Down
Loading