-
Notifications
You must be signed in to change notification settings - Fork 0
Audit class #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Audit class #32
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,276 @@ | ||
| 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; | ||
| 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.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; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @Service | ||
| 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<String> 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; | ||
| this.auditEventMapper = auditEventMapper; | ||
| this.caseRepository = caseRepository; | ||
| } | ||
|
|
||
| @Transactional | ||
| public void record(AuditEventEntity event) { | ||
| if (event == null) return; | ||
| 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<String, Object> 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<Object> out = new ArrayList<>(col.size()); | ||
| for (Object v : col) out.add(sanitizeAuditPayload(v)); | ||
| return out; | ||
| } | ||
|
|
||
| return payload; | ||
| } | ||
|
Comment on lines
+91
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify that sanitizeAuditPayload(Object) is never called
rg -n 'sanitizeAuditPayload\s*\(' --type java | grep -v 'private.*sanitizeAuditPayload'Repository: ithsjava25/project-backend-code-trauma-team Length of output: 727 🏁 Script executed: #!/bin/bash
# Find all sanitizeAuditPayload method signatures/definitions
rg -n 'private.*sanitizeAuditPayload' --type java -A 1Repository: ithsjava25/project-backend-code-trauma-team Length of output: 673 Remove unused method overload: The Object overload is never called. The only external call site (line 71) passes a 🤖 Prompt for AI Agents |
||
|
|
||
| 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<AuditEventDTO> 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(); | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (safeFrom.isAfter(safeTo)) { | ||
| throw new IllegalArgumentException("Invalid time range: 'from' must be <= 'to'"); | ||
| } | ||
|
|
||
| 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<UUID> allowedCaseIds = allowedCaseIdsFor(actor); | ||
| 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); | ||
| } | ||
| if (allowedCaseIds.isEmpty()) { | ||
| return Page.empty(pageable); | ||
| } | ||
| return auditEventRepository.findAllByCaseIdInAndOccurredAtBetweenOrderByOccurredAtDesc(allowedCaseIds, safeFrom, safeTo, pageable) | ||
| .map(auditEventMapper::toDTO); | ||
| } | ||
|
|
||
| throw new NotAuthorizedException("Not allowed to view audit events"); | ||
| } | ||
|
|
||
| private Set<UUID> 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; | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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**" | ||
| ); | ||
| } | ||
| } | ||
|
|
Uh oh!
There was an error while loading. Please reload this page.