From f324ec9bb1ab3c1732c8ad197ac5888f979bb0a5 Mon Sep 17 00:00:00 2001 From: Linus Westling Date: Thu, 2 Apr 2026 10:30:10 +0200 Subject: [PATCH 1/3] Audit class --- .../application/service/AuditEventMapper.java | 30 ++++ .../application/service/AuditService.java | 112 ++++++++++++ .../persistence/AuditEventEntity.java | 159 ++++++++++++++++++ .../persistence/AuditEventRepository.java | 29 ++++ .../presentation/dto/AuditEventDTO.java | 140 +++++++++++++++ 5 files changed, 470 insertions(+) create mode 100644 src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java create mode 100644 src/main/java/org/example/projektarendehantering/application/service/AuditService.java create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventRepository.java create mode 100644 src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java diff --git a/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java b/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java new file mode 100644 index 0000000..57e12e6 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java @@ -0,0 +1,30 @@ +package org.example.projektarendehantering.application.service; + +import org.example.projektarendehantering.infrastructure.persistence.AuditEventEntity; +import org.example.projektarendehantering.presentation.dto.AuditEventDTO; +import org.springframework.stereotype.Component; + +@Component +public class AuditEventMapper { + + public AuditEventDTO toDTO(AuditEventEntity entity) { + if (entity == null) return null; + AuditEventDTO dto = new AuditEventDTO(); + dto.setId(entity.getId()); + dto.setOccurredAt(entity.getOccurredAt()); + dto.setActorId(entity.getActorId()); + dto.setActorRole(entity.getActorRole()); + dto.setPrincipalName(entity.getPrincipalName()); + dto.setHttpMethod(entity.getHttpMethod()); + dto.setRequestPath(entity.getRequestPath()); + dto.setQueryString(entity.getQueryString()); + dto.setHandler(entity.getHandler()); + dto.setResponseStatus(entity.getResponseStatus()); + dto.setErrorType(entity.getErrorType()); + dto.setCaseId(entity.getCaseId()); + dto.setClientIp(entity.getClientIp()); + dto.setUserAgent(entity.getUserAgent()); + return dto; + } +} + diff --git a/src/main/java/org/example/projektarendehantering/application/service/AuditService.java b/src/main/java/org/example/projektarendehantering/application/service/AuditService.java new file mode 100644 index 0000000..328c1e0 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/application/service/AuditService.java @@ -0,0 +1,112 @@ +package org.example.projektarendehantering.application.service; + +import org.example.projektarendehantering.common.Actor; +import org.example.projektarendehantering.common.NotAuthorizedException; +import org.example.projektarendehantering.common.Role; +import org.example.projektarendehantering.infrastructure.persistence.AuditEventEntity; +import org.example.projektarendehantering.infrastructure.persistence.AuditEventRepository; +import org.example.projektarendehantering.infrastructure.persistence.CaseRepository; +import org.example.projektarendehantering.presentation.dto.AuditEventDTO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class AuditService { + + private final AuditEventRepository auditEventRepository; + private final AuditEventMapper auditEventMapper; + private final CaseRepository caseRepository; + + public AuditService(AuditEventRepository auditEventRepository, AuditEventMapper auditEventMapper, CaseRepository caseRepository) { + this.auditEventRepository = auditEventRepository; + this.auditEventMapper = auditEventMapper; + this.caseRepository = caseRepository; + } + + @Transactional + public void record(AuditEventEntity event) { + if (event == null) return; + if (event.getOccurredAt() == null) { + event.setOccurredAt(Instant.now()); + } + auditEventRepository.save(event); + } + + @Transactional(readOnly = true) + public Page listEvents(Actor actor, Instant from, Instant to, UUID caseId, Pageable pageable) { + requireActor(actor); + Instant safeFrom = from != null ? from : Instant.EPOCH; + Instant safeTo = to != null ? to : Instant.now(); + + if (isManager(actor)) { + if (caseId != null) { + return auditEventRepository.findAllByCaseIdAndOccurredAtBetweenOrderByOccurredAtDesc(caseId, safeFrom, safeTo, pageable) + .map(auditEventMapper::toDTO); + } + return auditEventRepository.findAllByOccurredAtBetweenOrderByOccurredAtDesc(safeFrom, safeTo, pageable) + .map(auditEventMapper::toDTO); + } + + if (isDoctor(actor) || isNurse(actor)) { + Set allowedCaseIds = allowedCaseIdsFor(actor); + if (allowedCaseIds.isEmpty()) { + return Page.empty(pageable); + } + if (caseId != null) { + if (!allowedCaseIds.contains(caseId)) { + throw new NotAuthorizedException("Not allowed to view audit events for this case"); + } + return auditEventRepository.findAllByCaseIdAndOccurredAtBetweenOrderByOccurredAtDesc(caseId, safeFrom, safeTo, pageable) + .map(auditEventMapper::toDTO); + } + return auditEventRepository.findAllByCaseIdInAndOccurredAtBetweenOrderByOccurredAtDesc(allowedCaseIds, safeFrom, safeTo, pageable) + .map(auditEventMapper::toDTO); + } + + throw new NotAuthorizedException("Not allowed to view audit events"); + } + + private Set allowedCaseIdsFor(Actor actor) { + if (isDoctor(actor)) { + return caseRepository.findAllByOwnerId(actor.userId()).stream() + .map(c -> c.getId()) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + if (isNurse(actor)) { + return caseRepository.findAllByHandlerId(actor.userId()).stream() + .map(c -> c.getId()) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + return Set.of(); + } + + private void requireActor(Actor actor) { + if (actor == null || actor.userId() == null) { + throw new NotAuthorizedException("Missing actor"); + } + } + + private boolean isManager(Actor actor) { + return actor.role() == Role.MANAGER || actor.role() == Role.ADMIN; + } + + private boolean isDoctor(Actor actor) { + return actor.role() == Role.DOCTOR || actor.role() == Role.CASE_OWNER; + } + + private boolean isNurse(Actor actor) { + return actor.role() == Role.NURSE || actor.role() == Role.HANDLER; + } +} + diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java new file mode 100644 index 0000000..2661435 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java @@ -0,0 +1,159 @@ +package org.example.projektarendehantering.infrastructure.persistence; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table( + name = "audit_events", + indexes = { + @Index(name = "idx_audit_events_occurred_at", columnList = "occurredAt"), + @Index(name = "idx_audit_events_actor_id", columnList = "actorId"), + @Index(name = "idx_audit_events_case_id", columnList = "caseId") + } +) +public class AuditEventEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + private Instant occurredAt; + + private UUID actorId; + private String actorRole; + private String principalName; + + private String httpMethod; + private String requestPath; + private String queryString; + private String handler; + + private Integer responseStatus; + private String errorType; + + private UUID caseId; + + private String clientIp; + private String userAgent; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Instant getOccurredAt() { + return occurredAt; + } + + public void setOccurredAt(Instant occurredAt) { + this.occurredAt = occurredAt; + } + + public UUID getActorId() { + return actorId; + } + + public void setActorId(UUID actorId) { + this.actorId = actorId; + } + + public String getActorRole() { + return actorRole; + } + + public void setActorRole(String actorRole) { + this.actorRole = actorRole; + } + + public String getPrincipalName() { + return principalName; + } + + public void setPrincipalName(String principalName) { + this.principalName = principalName; + } + + public String getHttpMethod() { + return httpMethod; + } + + public void setHttpMethod(String httpMethod) { + this.httpMethod = httpMethod; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + this.requestPath = requestPath; + } + + public String getQueryString() { + return queryString; + } + + public void setQueryString(String queryString) { + this.queryString = queryString; + } + + public String getHandler() { + return handler; + } + + public void setHandler(String handler) { + this.handler = handler; + } + + public Integer getResponseStatus() { + return responseStatus; + } + + public void setResponseStatus(Integer responseStatus) { + this.responseStatus = responseStatus; + } + + public String getErrorType() { + return errorType; + } + + public void setErrorType(String errorType) { + this.errorType = errorType; + } + + public UUID getCaseId() { + return caseId; + } + + public void setCaseId(UUID caseId) { + this.caseId = caseId; + } + + public String getClientIp() { + return clientIp; + } + + public void setClientIp(String clientIp) { + this.clientIp = clientIp; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } +} + diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventRepository.java b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventRepository.java new file mode 100644 index 0000000..724d7a6 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventRepository.java @@ -0,0 +1,29 @@ +package org.example.projektarendehantering.infrastructure.persistence; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.Collection; +import java.util.UUID; + +public interface AuditEventRepository extends JpaRepository { + + Page findAllByOccurredAtBetweenOrderByOccurredAtDesc(Instant from, Instant to, Pageable pageable); + + Page findAllByCaseIdInAndOccurredAtBetweenOrderByOccurredAtDesc( + Collection caseIds, + Instant from, + Instant to, + Pageable pageable + ); + + Page findAllByCaseIdAndOccurredAtBetweenOrderByOccurredAtDesc( + UUID caseId, + Instant from, + Instant to, + Pageable pageable + ); +} + diff --git a/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java b/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java new file mode 100644 index 0000000..85f9d6a --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java @@ -0,0 +1,140 @@ +package org.example.projektarendehantering.presentation.dto; + +import java.time.Instant; +import java.util.UUID; + +public class AuditEventDTO { + + private UUID id; + private Instant occurredAt; + + private UUID actorId; + private String actorRole; + private String principalName; + + private String httpMethod; + private String requestPath; + private String queryString; + private String handler; + + private Integer responseStatus; + private String errorType; + + private UUID caseId; + + private String clientIp; + private String userAgent; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Instant getOccurredAt() { + return occurredAt; + } + + public void setOccurredAt(Instant occurredAt) { + this.occurredAt = occurredAt; + } + + public UUID getActorId() { + return actorId; + } + + public void setActorId(UUID actorId) { + this.actorId = actorId; + } + + public String getActorRole() { + return actorRole; + } + + public void setActorRole(String actorRole) { + this.actorRole = actorRole; + } + + public String getPrincipalName() { + return principalName; + } + + public void setPrincipalName(String principalName) { + this.principalName = principalName; + } + + public String getHttpMethod() { + return httpMethod; + } + + public void setHttpMethod(String httpMethod) { + this.httpMethod = httpMethod; + } + + public String getRequestPath() { + return requestPath; + } + + public void setRequestPath(String requestPath) { + this.requestPath = requestPath; + } + + public String getQueryString() { + return queryString; + } + + public void setQueryString(String queryString) { + this.queryString = queryString; + } + + public String getHandler() { + return handler; + } + + public void setHandler(String handler) { + this.handler = handler; + } + + public Integer getResponseStatus() { + return responseStatus; + } + + public void setResponseStatus(Integer responseStatus) { + this.responseStatus = responseStatus; + } + + public String getErrorType() { + return errorType; + } + + public void setErrorType(String errorType) { + this.errorType = errorType; + } + + public UUID getCaseId() { + return caseId; + } + + public void setCaseId(UUID caseId) { + this.caseId = caseId; + } + + public String getClientIp() { + return clientIp; + } + + public void setClientIp(String clientIp) { + this.clientIp = clientIp; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } +} + From 5d27e44dcfcd82b98f24a5f99306841f59ecbcac Mon Sep 17 00:00:00 2001 From: Linus Westling Date: Thu, 2 Apr 2026 10:50:40 +0200 Subject: [PATCH 2/3] Continued audit impl. and rabbit feedback fixes --- pom.xml | 13 ++ .../application/service/AuditService.java | 170 +++++++++++++++++- .../config/AuditWebMvcConfig.java | 30 ++++ .../infrastructure/web/AuditInterceptor.java | 99 ++++++++++ .../presentation/rest/AuditController.java | 49 +++++ .../presentation/web/AuditUiController.java | 53 ++++++ src/main/resources/templates/audit/list.html | 94 ++++++++++ .../resources/templates/fragments/header.html | 1 + ...rojektArendehanteringApplicationTests.java | 29 +++ 9 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/config/AuditWebMvcConfig.java create mode 100644 src/main/java/org/example/projektarendehantering/infrastructure/web/AuditInterceptor.java create mode 100644 src/main/java/org/example/projektarendehantering/presentation/rest/AuditController.java create mode 100644 src/main/java/org/example/projektarendehantering/presentation/web/AuditUiController.java create mode 100644 src/main/resources/templates/audit/list.html diff --git a/pom.xml b/pom.xml index 9b52e9c..9565b11 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,10 @@ org.springframework.boot spring-boot-starter-web + + com.fasterxml.jackson.core + jackson-databind + org.springframework.boot spring-boot-starter-validation @@ -64,6 +68,11 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-test-autoconfigure + test + org.springframework.boot spring-boot-starter-security @@ -103,6 +112,10 @@ org.springframework.boot spring-boot-starter-oauth2-client + + com.fasterxml.jackson.core + jackson-databind + diff --git a/src/main/java/org/example/projektarendehantering/application/service/AuditService.java b/src/main/java/org/example/projektarendehantering/application/service/AuditService.java index 328c1e0..240c17f 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/AuditService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/AuditService.java @@ -1,5 +1,10 @@ package org.example.projektarendehantering.application.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.example.projektarendehantering.common.Actor; import org.example.projektarendehantering.common.NotAuthorizedException; import org.example.projektarendehantering.common.Role; @@ -13,7 +18,12 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Locale; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -25,6 +35,26 @@ public class AuditService { private final AuditEventRepository auditEventRepository; private final AuditEventMapper auditEventMapper; private final CaseRepository caseRepository; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String REDACTED = "[REDACTED]"; + private static final Set SENSITIVE_KEYS = Set.of( + "password", + "pass", + "pwd", + "token", + "access_token", + "authorization", + "apikey", + "api_key", + "secret", + "ssn", + "creditcard", + "credit_card", + "cardnumber", + "card_number", + "refresh_token" + ); public AuditService(AuditEventRepository auditEventRepository, AuditEventMapper auditEventMapper, CaseRepository caseRepository) { this.auditEventRepository = auditEventRepository; @@ -38,14 +68,148 @@ public void record(AuditEventEntity event) { if (event.getOccurredAt() == null) { event.setOccurredAt(Instant.now()); } + event.setQueryString(sanitizeAuditPayload(event.getQueryString())); auditEventRepository.save(event); } + private String sanitizeAuditPayload(String payload) { + if (payload == null || payload.isBlank()) return payload; + + String trimmed = payload.trim(); + if (looksLikeJson(trimmed)) { + try { + JsonNode node = objectMapper.readTree(trimmed); + JsonNode sanitized = sanitizeJsonNode(node); + return objectMapper.writeValueAsString(sanitized); + } catch (JsonProcessingException ignored) { + // Fall back to query-string sanitization below. + } + } + return sanitizeQueryString(payload); + } + + @SuppressWarnings("unchecked") + private Object sanitizeAuditPayload(Object payload) { + if (payload == null) return null; + if (payload instanceof String s) return sanitizeAuditPayload(s); + + if (payload instanceof Map map) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : map.entrySet()) { + String key = e.getKey() == null ? null : String.valueOf(e.getKey()); + Object value = e.getValue(); + if (key != null && isSensitiveKey(key)) { + out.put(key, REDACTED); + } else { + out.put(key, sanitizeAuditPayload(value)); + } + } + return out; + } + + if (payload instanceof Collection col) { + List out = new ArrayList<>(col.size()); + for (Object v : col) out.add(sanitizeAuditPayload(v)); + return out; + } + + return payload; + } + + private JsonNode sanitizeJsonNode(JsonNode node) { + if (node == null) return null; + if (node.isObject()) { + ObjectNode obj = (ObjectNode) node.deepCopy(); + obj.fieldNames().forEachRemaining(field -> { + JsonNode value = obj.get(field); + if (isSensitiveKey(field)) { + obj.put(field, REDACTED); + } else { + obj.set(field, sanitizeJsonNode(value)); + } + }); + return obj; + } + if (node.isArray()) { + ArrayNode arr = (ArrayNode) node.deepCopy(); + for (int i = 0; i < arr.size(); i++) { + arr.set(i, sanitizeJsonNode(arr.get(i))); + } + return arr; + } + return node; + } + + private String sanitizeQueryString(String query) { + if (query == null || query.isBlank()) return query; + + String original = query; + String prefix = ""; + String body = original; + int qIdx = original.indexOf('?'); + if (qIdx >= 0) { + prefix = original.substring(0, qIdx + 1); + body = original.substring(qIdx + 1); + } + + String[] parts = body.split("&", -1); + for (int i = 0; i < parts.length; i++) { + String part = parts[i]; + if (part.isEmpty()) continue; + + int eq = part.indexOf('='); + if (eq < 0) { + String keyOnly = part; + if (isSensitiveKey(keyOnly)) { + parts[i] = keyOnly + "=" + REDACTED; + } + continue; + } + + String key = part.substring(0, eq); + if (isSensitiveKey(key)) { + parts[i] = key + "=" + REDACTED; + } + } + return prefix + String.join("&", parts); + } + + private boolean looksLikeJson(String s) { + if (s == null) return false; + String t = s.trim(); + return (t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]")); + } + + private boolean isSensitiveKey(String key) { + if (key == null) return false; + String normalized = normalizeKey(key); + if (SENSITIVE_KEYS.contains(normalized)) return true; + + // Catch common variants like "user.password", "authToken", "Authorization" etc. + for (String sensitive : SENSITIVE_KEYS) { + if (normalized.contains(sensitive)) return true; + } + return false; + } + + private String normalizeKey(String key) { + String k = key.trim(); + int dot = k.lastIndexOf('.'); + if (dot >= 0 && dot < k.length() - 1) { + k = k.substring(dot + 1); + } + k = k.toLowerCase(Locale.ROOT); + return k.replaceAll("[^a-z0-9_]", ""); + } + @Transactional(readOnly = true) public Page listEvents(Actor actor, Instant from, Instant to, UUID caseId, Pageable pageable) { requireActor(actor); Instant safeFrom = from != null ? from : Instant.EPOCH; Instant safeTo = to != null ? to : Instant.now(); + if (safeFrom.isAfter(safeTo)) { + throw new IllegalArgumentException("Invalid time range: 'from' must be <= 'to'"); + } if (isManager(actor)) { if (caseId != null) { @@ -58,9 +222,6 @@ public Page listEvents(Actor actor, Instant from, Instant to, UUI if (isDoctor(actor) || isNurse(actor)) { Set allowedCaseIds = allowedCaseIdsFor(actor); - if (allowedCaseIds.isEmpty()) { - return Page.empty(pageable); - } if (caseId != null) { if (!allowedCaseIds.contains(caseId)) { throw new NotAuthorizedException("Not allowed to view audit events for this case"); @@ -68,6 +229,9 @@ public Page listEvents(Actor actor, Instant from, Instant to, UUI return auditEventRepository.findAllByCaseIdAndOccurredAtBetweenOrderByOccurredAtDesc(caseId, safeFrom, safeTo, pageable) .map(auditEventMapper::toDTO); } + if (allowedCaseIds.isEmpty()) { + return Page.empty(pageable); + } return auditEventRepository.findAllByCaseIdInAndOccurredAtBetweenOrderByOccurredAtDesc(allowedCaseIds, safeFrom, safeTo, pageable) .map(auditEventMapper::toDTO); } diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/config/AuditWebMvcConfig.java b/src/main/java/org/example/projektarendehantering/infrastructure/config/AuditWebMvcConfig.java new file mode 100644 index 0000000..7f543ac --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/config/AuditWebMvcConfig.java @@ -0,0 +1,30 @@ +package org.example.projektarendehantering.infrastructure.config; + +import org.example.projektarendehantering.infrastructure.web.AuditInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class AuditWebMvcConfig implements WebMvcConfigurer { + + private final AuditInterceptor auditInterceptor; + + public AuditWebMvcConfig(AuditInterceptor auditInterceptor) { + this.auditInterceptor = auditInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(auditInterceptor) + .addPathPatterns("/ui/**", "/api/**") + .excludePathPatterns( + "/static/**", + "/app.css", + "/app.js", + "/error**", + "/login**" + ); + } +} + diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/web/AuditInterceptor.java b/src/main/java/org/example/projektarendehantering/infrastructure/web/AuditInterceptor.java new file mode 100644 index 0000000..c2d12d8 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/infrastructure/web/AuditInterceptor.java @@ -0,0 +1,99 @@ +package org.example.projektarendehantering.infrastructure.web; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.example.projektarendehantering.common.Actor; +import org.example.projektarendehantering.application.service.AuditService; +import org.example.projektarendehantering.infrastructure.persistence.AuditEventEntity; +import org.example.projektarendehantering.infrastructure.security.HeaderCurrentUserAdapter; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Component +public class AuditInterceptor implements HandlerInterceptor { + + private final AuditService auditService; + private final HeaderCurrentUserAdapter currentUserAdapter; + + public AuditInterceptor(AuditService auditService, HeaderCurrentUserAdapter currentUserAdapter) { + this.auditService = auditService; + this.currentUserAdapter = currentUserAdapter; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + Actor actor = null; + try { + actor = currentUserAdapter.currentUser(); + } catch (RuntimeException ignored) { + // If not authenticated (or adapter throws), we still avoid failing the request. + } + + AuditEventEntity event = new AuditEventEntity(); + event.setOccurredAt(Instant.now()); + if (actor != null) { + event.setActorId(actor.userId()); + event.setActorRole(actor.role() != null ? actor.role().name() : null); + } + event.setPrincipalName(request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : null); + + event.setHttpMethod(request.getMethod()); + event.setRequestPath(request.getRequestURI()); + event.setQueryString(request.getQueryString()); + event.setHandler(handlerName(handler)); + + event.setResponseStatus(response != null ? response.getStatus() : null); + event.setErrorType(ex != null ? ex.getClass().getSimpleName() : null); + + event.setCaseId(extractCaseId(request)); + + event.setClientIp(clientIp(request)); + event.setUserAgent(request.getHeader("User-Agent")); + + try { + auditService.record(event); + } catch (RuntimeException ignored) { + // Audit should never break user flows. + } + } + + private String handlerName(Object handler) { + if (handler instanceof HandlerMethod hm) { + return hm.getBeanType().getSimpleName() + "#" + hm.getMethod().getName(); + } + return handler != null ? handler.getClass().getSimpleName() : null; + } + + private UUID extractCaseId(HttpServletRequest request) { + Object attr = request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); + if (!(attr instanceof Map vars)) return null; + + Object caseId = vars.get("caseId"); + if (caseId == null) { + caseId = vars.get("id"); + } + if (caseId == null) return null; + + try { + return UUID.fromString(caseId.toString()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private String clientIp(HttpServletRequest request) { + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + int comma = forwardedFor.indexOf(','); + return (comma >= 0 ? forwardedFor.substring(0, comma) : forwardedFor).trim(); + } + return request.getRemoteAddr(); + } +} + diff --git a/src/main/java/org/example/projektarendehantering/presentation/rest/AuditController.java b/src/main/java/org/example/projektarendehantering/presentation/rest/AuditController.java new file mode 100644 index 0000000..998a69e --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/presentation/rest/AuditController.java @@ -0,0 +1,49 @@ +package org.example.projektarendehantering.presentation.rest; + +import org.example.projektarendehantering.application.service.AuditService; +import org.example.projektarendehantering.infrastructure.security.HeaderCurrentUserAdapter; +import org.example.projektarendehantering.presentation.dto.AuditEventDTO; +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.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.UUID; + +@RestController +@RequestMapping("/api/audit") +public class AuditController { + + private final AuditService auditService; + private final HeaderCurrentUserAdapter currentUserAdapter; + + public AuditController(AuditService auditService, HeaderCurrentUserAdapter currentUserAdapter) { + this.auditService = auditService; + this.currentUserAdapter = currentUserAdapter; + } + + @GetMapping + public ResponseEntity> list( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to, + @RequestParam(required = false) UUID caseId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + Pageable pageable = PageRequest.of( + Math.max(page, 0), + Math.min(Math.max(size, 1), 200), + Sort.by(Sort.Direction.DESC, "occurredAt") + ); + + return ResponseEntity.ok(auditService.listEvents(currentUserAdapter.currentUser(), from, to, caseId, pageable)); + } +} + diff --git a/src/main/java/org/example/projektarendehantering/presentation/web/AuditUiController.java b/src/main/java/org/example/projektarendehantering/presentation/web/AuditUiController.java new file mode 100644 index 0000000..be8bbbc --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/presentation/web/AuditUiController.java @@ -0,0 +1,53 @@ +package org.example.projektarendehantering.presentation.web; + +import org.example.projektarendehantering.application.service.AuditService; +import org.example.projektarendehantering.infrastructure.security.HeaderCurrentUserAdapter; +import org.example.projektarendehantering.presentation.dto.AuditEventDTO; +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.format.annotation.DateTimeFormat; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.Instant; +import java.util.UUID; + +@Controller +public class AuditUiController { + + private final AuditService auditService; + private final HeaderCurrentUserAdapter currentUserAdapter; + + public AuditUiController(AuditService auditService, HeaderCurrentUserAdapter currentUserAdapter) { + this.auditService = auditService; + this.currentUserAdapter = currentUserAdapter; + } + + @GetMapping("/ui/audit") + public String list( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to, + @RequestParam(required = false) UUID caseId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size, + Model model + ) { + Pageable pageable = PageRequest.of( + Math.max(page, 0), + Math.min(Math.max(size, 1), 200), + Sort.by(Sort.Direction.DESC, "occurredAt") + ); + + Page events = auditService.listEvents(currentUserAdapter.currentUser(), from, to, caseId, pageable); + model.addAttribute("events", events); + model.addAttribute("from", from); + model.addAttribute("to", to); + model.addAttribute("caseId", caseId); + return "audit/list"; + } +} + diff --git a/src/main/resources/templates/audit/list.html b/src/main/resources/templates/audit/list.html new file mode 100644 index 0000000..52a4231 --- /dev/null +++ b/src/main/resources/templates/audit/list.html @@ -0,0 +1,94 @@ + + + + + +
+ +
+
+

Audit

+
+ +
+
+ + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimeActorRoleActorIdMethodPathStatusCaseIdHandlerError
timeroleactorGET +
/path
+
?q
+
200casehandlererr
+ +

+ No audit events found (or you don't have access to any). +

+ +
+
Page
+ +
+
+
+ +
+ + + diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index 321788a..4a4756b 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -10,6 +10,7 @@
diff --git a/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java b/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java index 2242d2f..3a8cec1 100644 --- a/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java +++ b/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java @@ -1,13 +1,42 @@ package org.example.projektarendehantering; +import org.example.projektarendehantering.infrastructure.persistence.AuditEventRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.context.WebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; @SpringBootTest class ProjektArendehanteringApplicationTests { + @Autowired + private WebApplicationContext webApplicationContext; + + @Autowired + private AuditEventRepository auditEventRepository; + @Test void contextLoads() { } + @Test + @WithMockUser(username = "handler1", roles = {"HANDLER"}) + void uiRequest_createsAuditEvent() throws Exception { + MockMvc mockMvc = webAppContextSetup(webApplicationContext).build(); + long before = auditEventRepository.count(); + + mockMvc.perform(get("/ui/cases")) + .andExpect(status().isOk()); + + long after = auditEventRepository.count(); + assertThat(after).isGreaterThan(before); + } + } From e88c7682923c6f0602765b920879370fcd107a95 Mon Sep 17 00:00:00 2001 From: Linus Westling Date: Thu, 2 Apr 2026 15:14:51 +0200 Subject: [PATCH 3/3] bunny feedback fix --- pom.xml | 4 ---- .../ProjektArendehanteringApplicationTests.java | 5 ++++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index 9565b11..f841b70 100644 --- a/pom.xml +++ b/pom.xml @@ -112,10 +112,6 @@ org.springframework.boot spring-boot-starter-oauth2-client - - com.fasterxml.jackson.core - jackson-databind - diff --git a/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java b/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java index 3a8cec1..5a5633f 100644 --- a/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java +++ b/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java @@ -9,6 +9,7 @@ import org.springframework.web.context.WebApplicationContext; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; @@ -29,7 +30,9 @@ void contextLoads() { @Test @WithMockUser(username = "handler1", roles = {"HANDLER"}) void uiRequest_createsAuditEvent() throws Exception { - MockMvc mockMvc = webAppContextSetup(webApplicationContext).build(); + MockMvc mockMvc = webAppContextSetup(webApplicationContext) + .apply(springSecurity()) + .build(); long before = auditEventRepository.count(); mockMvc.perform(get("/ui/cases"))