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
@@ -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;
Expand All @@ -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<AuditLogResponse> queryByTenant(
@RequestParam String tenantId,
@RequestParam(required = false) Instant since
public Page<AuditLogResponse> 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<AuditLogResponse> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String, Object> 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<String, Object> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -55,6 +64,9 @@ public PlatformAuditLogEntity(
String targetType,
String targetId,
String metadataJson,
String outcome,
String ipAddress,
String userAgent,
Instant occurredAt
) {
this.id = id;
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<PlatformAuditLogEntity, UUID> {
public interface JpaPlatformAuditLogRepository
extends JpaRepository<PlatformAuditLogEntity, UUID>,
JpaSpecificationExecutor<PlatformAuditLogEntity> {

List<PlatformAuditLogEntity> findAllByActorTenantIdAndOccurredAtAfter(String actorTenantId, Instant occurredAt);

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -24,4 +29,58 @@ public List<PlatformAuditLogEntity> findByTenantSince(String tenantId, Instant s
public List<PlatformAuditLogEntity> findByUser(UUID userId) {
return repository.findAllByActorUserId(userId);
}

/**
* Filterable, paginated search backing {@code GET /admin/api/v1/audit-logs}.
*
* <p>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<PlatformAuditLogEntity> search(AuditLogSearchCriteria criteria, Pageable pageable) {
return repository.findAll(toSpecification(criteria), pageable);
}

private Specification<PlatformAuditLogEntity> toSpecification(AuditLogSearchCriteria c) {
return (root, query, cb) -> {
List<Predicate> 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
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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<String, Object> metadata, String key) {
if (metadata == null) {
return null;
}
Object value = metadata.get(key);
return value == null ? null : value.toString();
}

private String serializeMetadata(Map<String, Object> metadata) {
if (metadata == null || metadata.isEmpty()) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Loading