diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogAdminController.java index 94a45cd..b31bf2c 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogAdminController.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogAdminController.java @@ -1,10 +1,16 @@ package kr.devslab.kit.admin.audit; +import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Instant; import java.util.List; import java.util.UUID; import kr.devslab.kit.admin.AdminApiPaths; import kr.devslab.kit.audit.core.service.AuditLogQueryService; +import kr.devslab.kit.audit.core.service.AuditLogQueryService.AuditLogSearchCriteria; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -15,23 +21,50 @@ @RequestMapping(AdminApiPaths.AUDIT_LOGS) public class AuditLogAdminController { + private static final int DEFAULT_PAGE_SIZE = 25; + private static final int MAX_PAGE_SIZE = 200; + private final AuditLogQueryService service; + private final ObjectMapper objectMapper; - public AuditLogAdminController(AuditLogQueryService service) { + public AuditLogAdminController(AuditLogQueryService service, ObjectMapper objectMapper) { this.service = service; + this.objectMapper = objectMapper; } + /** + * Filterable, paginated search backing the admin UI's Audit Logs page. + * All filter params are optional; defaults are 25 rows per page sorted + * by {@code occurredAt DESC}. {@code page} is 0-based. + */ @GetMapping - public List queryByTenant( - @RequestParam String tenantId, - @RequestParam(required = false) Instant since + public Page search( + @RequestParam(required = false) String tenantId, + @RequestParam(required = false) String actorLogin, + @RequestParam(required = false) String action, + @RequestParam(required = false) String targetType, + @RequestParam(required = false) String outcome, + @RequestParam(required = false) Instant from, + @RequestParam(required = false) Instant to, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "25") int size ) { - Instant cutoff = since == null ? Instant.EPOCH : since; - return service.findByTenantSince(tenantId, cutoff).stream().map(AuditLogResponse::from).toList(); + AuditLogSearchCriteria criteria = new AuditLogSearchCriteria( + tenantId, actorLogin, action, targetType, outcome, from, to); + Pageable pageable = PageRequest.of( + Math.max(0, page), + Math.min(MAX_PAGE_SIZE, Math.max(1, size == 0 ? DEFAULT_PAGE_SIZE : size)), + Sort.by(Sort.Direction.DESC, "occurredAt")); + return service.search(criteria, pageable).map(entity -> AuditLogResponse.from(entity, objectMapper)); } + /** + * Convenience read used by the diagnostics page — flat list, no paging. + */ @GetMapping("/user/{userId}") public List queryByUser(@PathVariable UUID userId) { - return service.findByUser(userId).stream().map(AuditLogResponse::from).toList(); + return service.findByUser(userId).stream() + .map(e -> AuditLogResponse.from(e, objectMapper)) + .toList(); } } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogResponse.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogResponse.java index 5a13814..b405c81 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogResponse.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogResponse.java @@ -1,32 +1,66 @@ package kr.devslab.kit.admin.audit; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Instant; +import java.util.Map; import java.util.UUID; import kr.devslab.kit.audit.core.entity.PlatformAuditLogEntity; +/** + * Wire shape for {@code GET /admin/api/v1/audit-logs} entries. + * + *

Field names mirror the admin UI's {@code AuditLog} interface: + * the entity's {@code actionCode} / {@code actorUserId} / {@code actorTenantId} + * / {@code actorDisplayName} / {@code metadataJson} columns map to wire fields + * {@code action} / {@code actorId} / {@code tenantId} / {@code actorLogin} / + * {@code payload}. Outcome defaults to {@code SUCCESS} on rows written before + * the column existed (NULL outcome). + */ public record AuditLogResponse( UUID id, - String actionCode, - UUID actorUserId, - String actorTenantId, - String actorDisplayName, + String tenantId, + UUID actorId, + String actorLogin, + String action, String targetType, String targetId, - String metadataJson, + String outcome, + String ip, + String userAgent, + Map payload, Instant occurredAt ) { - public static AuditLogResponse from(PlatformAuditLogEntity entity) { + public static AuditLogResponse from(PlatformAuditLogEntity entity, ObjectMapper objectMapper) { return new AuditLogResponse( entity.getId(), - entity.getActionCode(), - entity.getActorUserId(), entity.getActorTenantId(), + entity.getActorUserId(), entity.getActorDisplayName(), + entity.getActionCode(), entity.getTargetType(), entity.getTargetId(), - entity.getMetadataJson(), + entity.getOutcome() == null ? "SUCCESS" : entity.getOutcome(), + entity.getIpAddress(), + entity.getUserAgent(), + parsePayload(entity.getMetadataJson(), objectMapper), entity.getOccurredAt() ); } + + @SuppressWarnings("unchecked") + private static Map parsePayload(String json, ObjectMapper objectMapper) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readValue(json, Map.class); + } catch (JsonProcessingException e) { + // Corrupt or non-JSON payload — return the raw text under a single + // key so the UI can still surface it for inspection rather than + // failing the whole row. + return Map.of("raw", json); + } + } } diff --git a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/entity/PlatformAuditLogEntity.java b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/entity/PlatformAuditLogEntity.java index 3501eda..add2f4d 100644 --- a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/entity/PlatformAuditLogEntity.java +++ b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/entity/PlatformAuditLogEntity.java @@ -43,6 +43,15 @@ public class PlatformAuditLogEntity { @Column(name = "metadata_json", columnDefinition = "text") private String metadataJson; + @Column(name = "outcome", length = 16) + private String outcome; + + @Column(name = "ip_address", length = 64) + private String ipAddress; + + @Column(name = "user_agent", length = 512) + private String userAgent; + @Column(name = "occurred_at", nullable = false) private Instant occurredAt; @@ -55,6 +64,9 @@ public PlatformAuditLogEntity( String targetType, String targetId, String metadataJson, + String outcome, + String ipAddress, + String userAgent, Instant occurredAt ) { this.id = id; @@ -65,6 +77,9 @@ public PlatformAuditLogEntity( this.targetType = targetType; this.targetId = targetId; this.metadataJson = metadataJson; + this.outcome = outcome; + this.ipAddress = ipAddress; + this.userAgent = userAgent; this.occurredAt = occurredAt; } } diff --git a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/repository/JpaPlatformAuditLogRepository.java b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/repository/JpaPlatformAuditLogRepository.java index b0b4b8e..f3e01dd 100644 --- a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/repository/JpaPlatformAuditLogRepository.java +++ b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/repository/JpaPlatformAuditLogRepository.java @@ -5,8 +5,11 @@ import java.util.UUID; import kr.devslab.kit.audit.core.entity.PlatformAuditLogEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -public interface JpaPlatformAuditLogRepository extends JpaRepository { +public interface JpaPlatformAuditLogRepository + extends JpaRepository, + JpaSpecificationExecutor { List findAllByActorTenantIdAndOccurredAtAfter(String actorTenantId, Instant occurredAt); diff --git a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogQueryService.java b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogQueryService.java index ae6277c..a663a66 100644 --- a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogQueryService.java +++ b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogQueryService.java @@ -1,10 +1,15 @@ package kr.devslab.kit.audit.core.service; +import jakarta.persistence.criteria.Predicate; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import kr.devslab.kit.audit.core.entity.PlatformAuditLogEntity; import kr.devslab.kit.audit.core.repository.JpaPlatformAuditLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.transaction.annotation.Transactional; public class AuditLogQueryService { @@ -24,4 +29,58 @@ public List findByTenantSince(String tenantId, Instant s public List findByUser(UUID userId) { return repository.findAllByActorUserId(userId); } + + /** + * Filterable, paginated search backing {@code GET /admin/api/v1/audit-logs}. + * + *

All filter fields are optional — null means "any". The returned page is + * Spring Data's standard {@link Page}, sorted by {@code occurredAt} descending + * unless the caller overrides via {@link Pageable#getSort()}. + */ + @Transactional(readOnly = true) + public Page search(AuditLogSearchCriteria criteria, Pageable pageable) { + return repository.findAll(toSpecification(criteria), pageable); + } + + private Specification toSpecification(AuditLogSearchCriteria c) { + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + if (c.tenantId() != null && !c.tenantId().isBlank()) { + predicates.add(cb.equal(root.get("actorTenantId"), c.tenantId())); + } + if (c.actorLogin() != null && !c.actorLogin().isBlank()) { + // Case-insensitive contains, matches the entity field that stores + // the actor's display name / login at event time. + predicates.add(cb.like(cb.lower(root.get("actorDisplayName")), + "%" + c.actorLogin().toLowerCase() + "%")); + } + if (c.action() != null && !c.action().isBlank()) { + predicates.add(cb.equal(root.get("actionCode"), c.action())); + } + if (c.targetType() != null && !c.targetType().isBlank()) { + predicates.add(cb.equal(root.get("targetType"), c.targetType())); + } + if (c.outcome() != null && !c.outcome().isBlank()) { + predicates.add(cb.equal(root.get("outcome"), c.outcome())); + } + if (c.from() != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("occurredAt"), c.from())); + } + if (c.to() != null) { + predicates.add(cb.lessThan(root.get("occurredAt"), c.to())); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + + public record AuditLogSearchCriteria( + String tenantId, + String actorLogin, + String action, + String targetType, + String outcome, + Instant from, + Instant to + ) { + } } diff --git a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogService.java b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogService.java index 745d266..f5c358f 100644 --- a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogService.java +++ b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogService.java @@ -26,6 +26,15 @@ 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. + var metadata = event.metadata(); + String outcome = stringFromMetadata(metadata, "outcome"); + String ipAddress = stringFromMetadata(metadata, "ip"); + String userAgent = stringFromMetadata(metadata, "userAgent"); PlatformAuditLogEntity entity = new PlatformAuditLogEntity( UUID.randomUUID(), event.action().code(), @@ -34,12 +43,23 @@ public void record(AuditEvent event) { actor == null ? null : actor.displayName(), target == null ? null : target.type(), target == null ? null : target.id(), - serializeMetadata(event.metadata()), + serializeMetadata(metadata), + outcome, + ipAddress, + userAgent, event.occurredAt() ); repository.save(entity); } + private String stringFromMetadata(Map metadata, String key) { + if (metadata == null) { + return null; + } + Object value = metadata.get(key); + return value == null ? null : value.toString(); + } + private String serializeMetadata(Map metadata) { if (metadata == null || metadata.isEmpty()) { return null; diff --git a/devslab-kit-audit-core/src/main/resources/db/migration/V9__platform_audit_log_outcome.sql b/devslab-kit-audit-core/src/main/resources/db/migration/V9__platform_audit_log_outcome.sql new file mode 100644 index 0000000..b112cc0 --- /dev/null +++ b/devslab-kit-audit-core/src/main/resources/db/migration/V9__platform_audit_log_outcome.sql @@ -0,0 +1,14 @@ +-- Add the columns the admin UI's Audit Logs page filters and renders: +-- outcome -- SUCCESS / FAILURE (nullable; existing rows stay NULL and +-- the response layer treats them as SUCCESS by default) +-- ip_address -- network address the actor was on at event time +-- user_agent -- browser / client identifier +-- +-- All three are nullable so existing audit history survives unchanged. + +ALTER TABLE platform_audit_log + ADD COLUMN outcome VARCHAR(16), + ADD COLUMN ip_address VARCHAR(64), + ADD COLUMN user_agent VARCHAR(512); + +CREATE INDEX IF NOT EXISTS idx_platform_audit_log_outcome ON platform_audit_log(outcome);