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
9 changes: 9 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
Expand All @@ -64,6 +68,11 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test-autoconfigure</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
Expand Down
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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 1

Repository: ithsjava25/project-backend-code-trauma-team

Length of output: 673


Remove unused method overload: sanitizeAuditPayload(Object).

The Object overload is never called. The only external call site (line 71) passes a String, which routes to the String overload (line 75). The Object overload's recursive calls (lines 104, 112) are only reachable if the Object overload itself were initially invoked, which never happens in practice. Remove this dead code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/org/example/projektarendehantering/application/service/AuditService.java`
around lines 91 - 117, Remove the unused overload sanitizeAuditPayload(Object):
delete the private Object sanitizeAuditPayload(Object) method and ensure callers
use the existing sanitizeAuditPayload(String) and other type-specific logic;
update any recursive sanitization to be handled within the String overload
(sanitizeAuditPayload(String)) or by converting/dispatching callers to the
appropriate overload so there are no orphaned recursive calls like the ones that
referenced sanitizeAuditPayload(Object).


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();
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**"
);
}
}

Loading