Skip to content

Commit 81b32ad

Browse files
Merge pull request #32 from ithsjava25/feature/audit
Audit class
2 parents 172d176 + e88c768 commit 81b32ad

13 files changed

Lines changed: 1001 additions & 0 deletions

File tree

pom.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838
<groupId>org.springframework.boot</groupId>
3939
<artifactId>spring-boot-starter-web</artifactId>
4040
</dependency>
41+
<dependency>
42+
<groupId>com.fasterxml.jackson.core</groupId>
43+
<artifactId>jackson-databind</artifactId>
44+
</dependency>
4145
<dependency>
4246
<groupId>org.springframework.boot</groupId>
4347
<artifactId>spring-boot-starter-validation</artifactId>
@@ -64,6 +68,11 @@
6468
<artifactId>spring-boot-starter-test</artifactId>
6569
<scope>test</scope>
6670
</dependency>
71+
<dependency>
72+
<groupId>org.springframework.boot</groupId>
73+
<artifactId>spring-boot-test-autoconfigure</artifactId>
74+
<scope>test</scope>
75+
</dependency>
6776
<dependency>
6877
<groupId>org.springframework.boot</groupId>
6978
<artifactId>spring-boot-starter-security</artifactId>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package org.example.projektarendehantering.application.service;
2+
3+
import org.example.projektarendehantering.infrastructure.persistence.AuditEventEntity;
4+
import org.example.projektarendehantering.presentation.dto.AuditEventDTO;
5+
import org.springframework.stereotype.Component;
6+
7+
@Component
8+
public class AuditEventMapper {
9+
10+
public AuditEventDTO toDTO(AuditEventEntity entity) {
11+
if (entity == null) return null;
12+
AuditEventDTO dto = new AuditEventDTO();
13+
dto.setId(entity.getId());
14+
dto.setOccurredAt(entity.getOccurredAt());
15+
dto.setActorId(entity.getActorId());
16+
dto.setActorRole(entity.getActorRole());
17+
dto.setPrincipalName(entity.getPrincipalName());
18+
dto.setHttpMethod(entity.getHttpMethod());
19+
dto.setRequestPath(entity.getRequestPath());
20+
dto.setQueryString(entity.getQueryString());
21+
dto.setHandler(entity.getHandler());
22+
dto.setResponseStatus(entity.getResponseStatus());
23+
dto.setErrorType(entity.getErrorType());
24+
dto.setCaseId(entity.getCaseId());
25+
dto.setClientIp(entity.getClientIp());
26+
dto.setUserAgent(entity.getUserAgent());
27+
return dto;
28+
}
29+
}
30+
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
package org.example.projektarendehantering.application.service;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.JsonNode;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.fasterxml.jackson.databind.node.ArrayNode;
7+
import com.fasterxml.jackson.databind.node.ObjectNode;
8+
import org.example.projektarendehantering.common.Actor;
9+
import org.example.projektarendehantering.common.NotAuthorizedException;
10+
import org.example.projektarendehantering.common.Role;
11+
import org.example.projektarendehantering.infrastructure.persistence.AuditEventEntity;
12+
import org.example.projektarendehantering.infrastructure.persistence.AuditEventRepository;
13+
import org.example.projektarendehantering.infrastructure.persistence.CaseRepository;
14+
import org.example.projektarendehantering.presentation.dto.AuditEventDTO;
15+
import org.springframework.data.domain.Page;
16+
import org.springframework.data.domain.Pageable;
17+
import org.springframework.stereotype.Service;
18+
import org.springframework.transaction.annotation.Transactional;
19+
20+
import java.time.Instant;
21+
import java.util.ArrayList;
22+
import java.util.Collection;
23+
import java.util.LinkedHashMap;
24+
import java.util.Locale;
25+
import java.util.List;
26+
import java.util.Map;
27+
import java.util.Objects;
28+
import java.util.Set;
29+
import java.util.UUID;
30+
import java.util.stream.Collectors;
31+
32+
@Service
33+
public class AuditService {
34+
35+
private final AuditEventRepository auditEventRepository;
36+
private final AuditEventMapper auditEventMapper;
37+
private final CaseRepository caseRepository;
38+
private final ObjectMapper objectMapper = new ObjectMapper();
39+
40+
private static final String REDACTED = "[REDACTED]";
41+
private static final Set<String> SENSITIVE_KEYS = Set.of(
42+
"password",
43+
"pass",
44+
"pwd",
45+
"token",
46+
"access_token",
47+
"authorization",
48+
"apikey",
49+
"api_key",
50+
"secret",
51+
"ssn",
52+
"creditcard",
53+
"credit_card",
54+
"cardnumber",
55+
"card_number",
56+
"refresh_token"
57+
);
58+
59+
public AuditService(AuditEventRepository auditEventRepository, AuditEventMapper auditEventMapper, CaseRepository caseRepository) {
60+
this.auditEventRepository = auditEventRepository;
61+
this.auditEventMapper = auditEventMapper;
62+
this.caseRepository = caseRepository;
63+
}
64+
65+
@Transactional
66+
public void record(AuditEventEntity event) {
67+
if (event == null) return;
68+
if (event.getOccurredAt() == null) {
69+
event.setOccurredAt(Instant.now());
70+
}
71+
event.setQueryString(sanitizeAuditPayload(event.getQueryString()));
72+
auditEventRepository.save(event);
73+
}
74+
75+
private String sanitizeAuditPayload(String payload) {
76+
if (payload == null || payload.isBlank()) return payload;
77+
78+
String trimmed = payload.trim();
79+
if (looksLikeJson(trimmed)) {
80+
try {
81+
JsonNode node = objectMapper.readTree(trimmed);
82+
JsonNode sanitized = sanitizeJsonNode(node);
83+
return objectMapper.writeValueAsString(sanitized);
84+
} catch (JsonProcessingException ignored) {
85+
// Fall back to query-string sanitization below.
86+
}
87+
}
88+
return sanitizeQueryString(payload);
89+
}
90+
91+
@SuppressWarnings("unchecked")
92+
private Object sanitizeAuditPayload(Object payload) {
93+
if (payload == null) return null;
94+
if (payload instanceof String s) return sanitizeAuditPayload(s);
95+
96+
if (payload instanceof Map<?, ?> map) {
97+
Map<String, Object> out = new LinkedHashMap<>();
98+
for (Map.Entry<?, ?> e : map.entrySet()) {
99+
String key = e.getKey() == null ? null : String.valueOf(e.getKey());
100+
Object value = e.getValue();
101+
if (key != null && isSensitiveKey(key)) {
102+
out.put(key, REDACTED);
103+
} else {
104+
out.put(key, sanitizeAuditPayload(value));
105+
}
106+
}
107+
return out;
108+
}
109+
110+
if (payload instanceof Collection<?> col) {
111+
List<Object> out = new ArrayList<>(col.size());
112+
for (Object v : col) out.add(sanitizeAuditPayload(v));
113+
return out;
114+
}
115+
116+
return payload;
117+
}
118+
119+
private JsonNode sanitizeJsonNode(JsonNode node) {
120+
if (node == null) return null;
121+
if (node.isObject()) {
122+
ObjectNode obj = (ObjectNode) node.deepCopy();
123+
obj.fieldNames().forEachRemaining(field -> {
124+
JsonNode value = obj.get(field);
125+
if (isSensitiveKey(field)) {
126+
obj.put(field, REDACTED);
127+
} else {
128+
obj.set(field, sanitizeJsonNode(value));
129+
}
130+
});
131+
return obj;
132+
}
133+
if (node.isArray()) {
134+
ArrayNode arr = (ArrayNode) node.deepCopy();
135+
for (int i = 0; i < arr.size(); i++) {
136+
arr.set(i, sanitizeJsonNode(arr.get(i)));
137+
}
138+
return arr;
139+
}
140+
return node;
141+
}
142+
143+
private String sanitizeQueryString(String query) {
144+
if (query == null || query.isBlank()) return query;
145+
146+
String original = query;
147+
String prefix = "";
148+
String body = original;
149+
int qIdx = original.indexOf('?');
150+
if (qIdx >= 0) {
151+
prefix = original.substring(0, qIdx + 1);
152+
body = original.substring(qIdx + 1);
153+
}
154+
155+
String[] parts = body.split("&", -1);
156+
for (int i = 0; i < parts.length; i++) {
157+
String part = parts[i];
158+
if (part.isEmpty()) continue;
159+
160+
int eq = part.indexOf('=');
161+
if (eq < 0) {
162+
String keyOnly = part;
163+
if (isSensitiveKey(keyOnly)) {
164+
parts[i] = keyOnly + "=" + REDACTED;
165+
}
166+
continue;
167+
}
168+
169+
String key = part.substring(0, eq);
170+
if (isSensitiveKey(key)) {
171+
parts[i] = key + "=" + REDACTED;
172+
}
173+
}
174+
return prefix + String.join("&", parts);
175+
}
176+
177+
private boolean looksLikeJson(String s) {
178+
if (s == null) return false;
179+
String t = s.trim();
180+
return (t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"));
181+
}
182+
183+
private boolean isSensitiveKey(String key) {
184+
if (key == null) return false;
185+
String normalized = normalizeKey(key);
186+
if (SENSITIVE_KEYS.contains(normalized)) return true;
187+
188+
// Catch common variants like "user.password", "authToken", "Authorization" etc.
189+
for (String sensitive : SENSITIVE_KEYS) {
190+
if (normalized.contains(sensitive)) return true;
191+
}
192+
return false;
193+
}
194+
195+
private String normalizeKey(String key) {
196+
String k = key.trim();
197+
int dot = k.lastIndexOf('.');
198+
if (dot >= 0 && dot < k.length() - 1) {
199+
k = k.substring(dot + 1);
200+
}
201+
k = k.toLowerCase(Locale.ROOT);
202+
return k.replaceAll("[^a-z0-9_]", "");
203+
}
204+
205+
@Transactional(readOnly = true)
206+
public Page<AuditEventDTO> listEvents(Actor actor, Instant from, Instant to, UUID caseId, Pageable pageable) {
207+
requireActor(actor);
208+
Instant safeFrom = from != null ? from : Instant.EPOCH;
209+
Instant safeTo = to != null ? to : Instant.now();
210+
if (safeFrom.isAfter(safeTo)) {
211+
throw new IllegalArgumentException("Invalid time range: 'from' must be <= 'to'");
212+
}
213+
214+
if (isManager(actor)) {
215+
if (caseId != null) {
216+
return auditEventRepository.findAllByCaseIdAndOccurredAtBetweenOrderByOccurredAtDesc(caseId, safeFrom, safeTo, pageable)
217+
.map(auditEventMapper::toDTO);
218+
}
219+
return auditEventRepository.findAllByOccurredAtBetweenOrderByOccurredAtDesc(safeFrom, safeTo, pageable)
220+
.map(auditEventMapper::toDTO);
221+
}
222+
223+
if (isDoctor(actor) || isNurse(actor)) {
224+
Set<UUID> allowedCaseIds = allowedCaseIdsFor(actor);
225+
if (caseId != null) {
226+
if (!allowedCaseIds.contains(caseId)) {
227+
throw new NotAuthorizedException("Not allowed to view audit events for this case");
228+
}
229+
return auditEventRepository.findAllByCaseIdAndOccurredAtBetweenOrderByOccurredAtDesc(caseId, safeFrom, safeTo, pageable)
230+
.map(auditEventMapper::toDTO);
231+
}
232+
if (allowedCaseIds.isEmpty()) {
233+
return Page.empty(pageable);
234+
}
235+
return auditEventRepository.findAllByCaseIdInAndOccurredAtBetweenOrderByOccurredAtDesc(allowedCaseIds, safeFrom, safeTo, pageable)
236+
.map(auditEventMapper::toDTO);
237+
}
238+
239+
throw new NotAuthorizedException("Not allowed to view audit events");
240+
}
241+
242+
private Set<UUID> allowedCaseIdsFor(Actor actor) {
243+
if (isDoctor(actor)) {
244+
return caseRepository.findAllByOwnerId(actor.userId()).stream()
245+
.map(c -> c.getId())
246+
.filter(Objects::nonNull)
247+
.collect(Collectors.toSet());
248+
}
249+
if (isNurse(actor)) {
250+
return caseRepository.findAllByHandlerId(actor.userId()).stream()
251+
.map(c -> c.getId())
252+
.filter(Objects::nonNull)
253+
.collect(Collectors.toSet());
254+
}
255+
return Set.of();
256+
}
257+
258+
private void requireActor(Actor actor) {
259+
if (actor == null || actor.userId() == null) {
260+
throw new NotAuthorizedException("Missing actor");
261+
}
262+
}
263+
264+
private boolean isManager(Actor actor) {
265+
return actor.role() == Role.MANAGER || actor.role() == Role.ADMIN;
266+
}
267+
268+
private boolean isDoctor(Actor actor) {
269+
return actor.role() == Role.DOCTOR || actor.role() == Role.CASE_OWNER;
270+
}
271+
272+
private boolean isNurse(Actor actor) {
273+
return actor.role() == Role.NURSE || actor.role() == Role.HANDLER;
274+
}
275+
}
276+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package org.example.projektarendehantering.infrastructure.config;
2+
3+
import org.example.projektarendehantering.infrastructure.web.AuditInterceptor;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
6+
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
7+
8+
@Configuration
9+
public class AuditWebMvcConfig implements WebMvcConfigurer {
10+
11+
private final AuditInterceptor auditInterceptor;
12+
13+
public AuditWebMvcConfig(AuditInterceptor auditInterceptor) {
14+
this.auditInterceptor = auditInterceptor;
15+
}
16+
17+
@Override
18+
public void addInterceptors(InterceptorRegistry registry) {
19+
registry.addInterceptor(auditInterceptor)
20+
.addPathPatterns("/ui/**", "/api/**")
21+
.excludePathPatterns(
22+
"/static/**",
23+
"/app.css",
24+
"/app.js",
25+
"/error**",
26+
"/login**"
27+
);
28+
}
29+
}
30+

0 commit comments

Comments
 (0)