diff --git a/AUDIT.md b/AUDIT.md index 7597efb..6e8663c 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -39,7 +39,6 @@ Table **`audit_events`** (entity `AuditEventEntity`): | `actorId` | UUID | From `Actor.userId()` when the interceptor can resolve the current user | | `actorRole` | String | `Role.name()` when present | | `principalName` | String | `request.getUserPrincipal().getName()` | -| `httpMethod` | String | e.g. GET, POST | | `requestPath` | String | `request.getRequestURI()` | | `queryString` | String | Raw query string; **sanitized before persist** (see below) | | `handler` | String | For `HandlerMethod`: `SimpleClassName#methodName`; otherwise simple class name | @@ -74,7 +73,7 @@ For each request the interceptor: 1. Tries `SecurityActorAdapter.currentUser()` → on failure (e.g. not authenticated), continues with `actorId` / `actorRole` unset rather than failing the HTTP request. 2. Sets identity fields: `actorId`, `actorRole`, `principalName`. -3. Sets request metadata: method, URI, query string, resolved handler name. +3. Sets request metadata: URI, query string, resolved handler name. 4. Sets `responseStatus`, `errorType` (from `ex`). 5. Sets **`caseId`** via `extractCaseId(request)`: - Reads `HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE`. @@ -176,7 +175,7 @@ Goal: avoid storing secrets in `queryString`. - **Route:** `GET /ui/audit` → template `audit/list.html`. - **Navigation:** Link in `fragments/header.html` (“Audit”). -- Table shows: time, actor role/id, method, path + query, status, caseId, handler, error type. Empty state copy mentions access as well as “no rows.” +- Table shows: time, actor role/id, event, description, status, caseId, details. Empty state copy mentions access as well as “no rows.” **Security (HTTP layer):** `SecurityConfig` requires authentication for any request not on the permit-all list; there is **no extra** `@PreAuthorize` on audit endpoints—**fine-grained rules are entirely in `AuditService.listEvents`**. diff --git a/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java b/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java index 41abe34..ae97c41 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java +++ b/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java @@ -15,7 +15,6 @@ public AuditEventDTO toDTO(AuditEventEntity entity) { .actorId(entity.getActorId()) .actorRole(entity.getActorRole()) .principalName(entity.getPrincipalName()) - .httpMethod(entity.getHttpMethod()) .requestPath(entity.getRequestPath()) .queryString(entity.getQueryString()) .handler(entity.getHandler()) @@ -23,6 +22,8 @@ public AuditEventDTO toDTO(AuditEventEntity entity) { .errorType(entity.getErrorType()) .caseId(entity.getCaseId()) .statusChange(entity.getStatusChange()) + .eventName(entity.getEventName()) + .description(entity.getDescription()) .clientIp(entity.getClientIp()) .userAgent(entity.getUserAgent()) .build(); 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 0ff6eb6..65ed3aa 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/AuditService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/AuditService.java @@ -16,8 +16,13 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import java.io.IOException; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; @@ -28,8 +33,10 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class AuditService { @@ -39,6 +46,8 @@ public class AuditService { private final CaseRepository caseRepository; private final ObjectMapper objectMapper = new ObjectMapper(); + private final List emitters = new CopyOnWriteArrayList<>(); + private static final String REDACTED = "[REDACTED]"; private static final Set SENSITIVE_KEYS = Set.of( "password", @@ -69,6 +78,66 @@ public void record(AuditEventEntity event) { } event.setQueryString(sanitizeAuditPayload(event.getQueryString())); auditEventRepository.save(event); + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + broadcast(event); + } catch (Throwable t) { + log.warn("Failed to broadcast audit event {} after commit: {}", event.getId(), t.getMessage(), t); + } + } + }); + } + + private void broadcast(AuditEventEntity event) { + if (emitters.isEmpty()) return; + + AuditEventDTO dto = auditEventMapper.toDTO(event); + List failedEmitters = new ArrayList<>(); + + for (SseEmitter emitter : emitters) { + try { + emitter.send(SseEmitter.event() + .name("audit-event") + .data(dto)); + } catch (IOException | IllegalStateException e) { + failedEmitters.add(emitter); + } + } + emitters.removeAll(failedEmitters); + } + + public SseEmitter createEmitter() { + SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); // 30 minute timeout + emitters.add(emitter); + + emitter.onCompletion(() -> { + log.debug("SSE emitter completed"); + emitters.remove(emitter); + }); + emitter.onTimeout(() -> { + log.debug("SSE emitter timed out"); + emitters.remove(emitter); + }); + emitter.onError((ex) -> { + log.debug("SSE emitter error: {}", ex.getMessage()); + emitters.remove(emitter); + }); + + // Send an initial event to confirm connection + try { + emitter.send(SseEmitter.event() + .name("connected") + .data("Audit stream connected")); + log.debug("SSE emitter created and initial event sent"); + } catch (IOException e) { + log.error("Failed to send initial SSE event", e); + emitters.remove(emitter); + } + + return emitter; } private String sanitizeAuditPayload(String payload) { diff --git a/src/main/java/org/example/projektarendehantering/application/service/CaseService.java b/src/main/java/org/example/projektarendehantering/application/service/CaseService.java index 16e7f35..268fb8a 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/CaseService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/CaseService.java @@ -327,11 +327,17 @@ private boolean isPatient(Actor actor) { private void recordStatusChange(Actor actor, UUID caseId, CaseStatus from, CaseStatus to) { String statusChange = (from != null ? from.name() : "NEW") + " -> " + to.name(); + String eventName = from == null ? "CASE_CREATED" : "STATUS_CHANGED"; + String description = from == null ? "Case was created" : "Status changed from " + from + " to " + to; + AuditEventEntity event = AuditEventEntity.builder() .caseId(caseId) .statusChange(statusChange) + .eventName(eventName) + .description(description) .actorId(actor != null ? actor.userId() : null) .actorRole(actor != null && actor.role() != null ? actor.role().name() : null) + .occurredAt(Instant.now()) .build(); auditService.record(event); } diff --git a/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java b/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java index 55a1ded..f08e89f 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java @@ -109,8 +109,20 @@ public void afterCompletion(int status) { auditService.record(AuditEventEntity.builder() .caseId(caseEntity.getId()) .statusChange(statusChange) + .eventName("STATUS_CHANGED") + .description("Status changed to COMMUNICATION after document upload") .actorId(actor.userId()) .actorRole(actor.role() != null ? actor.role().name() : null) + .occurredAt(Instant.now()) + .build()); + } else { + auditService.record(AuditEventEntity.builder() + .caseId(caseEntity.getId()) + .eventName("DOCUMENT_UPLOADED") + .description("Document uploaded: " + entity.getFileName()) + .actorId(actor.userId()) + .actorRole(actor.role() != null ? actor.role().name() : null) + .occurredAt(Instant.now()) .build()); } return documentMapper.toDTO(saved); diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java b/src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java index 80f7bcd..cf17a12 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/config/SecurityConfig.java @@ -14,7 +14,10 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.http.HttpStatus; @Configuration @EnableWebSecurity @@ -52,6 +55,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomOAuth2Us .invalidateHttpSession(true) .deleteCookies("JSESSIONID") ) + .exceptionHandling(ex -> ex + .defaultAuthenticationEntryPointFor( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), + request -> request.getRequestURI().startsWith("/api/") + ) + ) .userDetailsService(localUserDetailsService); return http.build(); } diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java index 55eeaa8..c792b47 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java @@ -40,7 +40,6 @@ public class AuditEventEntity { private String actorRole; private String principalName; - private String httpMethod; private String requestPath; private String queryString; private String handler; @@ -51,6 +50,8 @@ public class AuditEventEntity { private UUID caseId; private String statusChange; + private String eventName; + private String description; private String clientIp; private String userAgent; diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/web/AuditInterceptor.java b/src/main/java/org/example/projektarendehantering/infrastructure/web/AuditInterceptor.java index edc2648..9f6f04f 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/web/AuditInterceptor.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/web/AuditInterceptor.java @@ -28,6 +28,17 @@ public AuditInterceptor(AuditService auditService, SecurityActorAdapter security @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + String method = request.getMethod(); + boolean isMutation = "POST".equalsIgnoreCase(method) || + "PUT".equalsIgnoreCase(method) || + "DELETE".equalsIgnoreCase(method) || + "PATCH".equalsIgnoreCase(method); + + // Only record mutations or errors to reduce noise + if (!isMutation && ex == null) { + return; + } + Actor actor = null; try { actor = securityActorAdapter.currentUser(); @@ -43,10 +54,11 @@ public void afterCompletion(HttpServletRequest request, HttpServletResponse resp } 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.setEventName("WEB_ACTION"); + event.setDescription(method + " " + request.getRequestURI()); event.setResponseStatus(response != null ? response.getStatus() : null); event.setErrorType(ex != null ? ex.getClass().getSimpleName() : null); diff --git a/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java b/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java index c9cf283..35e100d 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java +++ b/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java @@ -21,7 +21,6 @@ public class AuditEventDTO { private String actorRole; private String principalName; - private String httpMethod; private String requestPath; private String queryString; private String handler; @@ -32,6 +31,8 @@ public class AuditEventDTO { private UUID caseId; private String statusChange; + private String eventName; + private String description; private String clientIp; private String userAgent; diff --git a/src/main/java/org/example/projektarendehantering/presentation/rest/AuditController.java b/src/main/java/org/example/projektarendehantering/presentation/rest/AuditController.java index b27b5cf..f559bd7 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/rest/AuditController.java +++ b/src/main/java/org/example/projektarendehantering/presentation/rest/AuditController.java @@ -9,11 +9,15 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.format.annotation.DateTimeFormat; +import org.example.projektarendehantering.common.Actor; +import org.example.projektarendehantering.common.Role; +import org.springframework.http.MediaType; 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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.time.Instant; import java.util.UUID; @@ -42,4 +46,13 @@ public ResponseEntity> list( return ResponseEntity.ok(auditService.listEvents(securityActorAdapter.currentUser(), from, to, caseId, pageable)); } + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream() { + Actor actor = securityActorAdapter.currentUser(); + if (actor.role() != Role.MANAGER) { + throw new org.example.projektarendehantering.common.NotAuthorizedException("Only managers can view the audit stream"); + } + return auditService.createEmitter(); + } } diff --git a/src/main/java/org/example/projektarendehantering/presentation/web/GlobalControllerAdvice.java b/src/main/java/org/example/projektarendehantering/presentation/web/GlobalControllerAdvice.java index 8e598d0..b270d9b 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/web/GlobalControllerAdvice.java +++ b/src/main/java/org/example/projektarendehantering/presentation/web/GlobalControllerAdvice.java @@ -19,6 +19,10 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.Map; @Slf4j @@ -100,6 +104,39 @@ public Object handleResponseStatusException(ResponseStatusException e, HttpServl return "error"; } + @ExceptionHandler(NoResourceFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public void handleNotFound(NoResourceFoundException e) { + } + + @ExceptionHandler(AsyncRequestNotUsableException.class) + public void handleAsyncDisconnect(AsyncRequestNotUsableException e) { + log.debug("SSE client disconnected: {}", e.getMessage()); + } + + @ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentNotValidException.class}) + public Object handleBadRequestException(Exception e, HttpServletRequest request, HttpServletResponse response, Model model) { + HttpStatus status = HttpStatus.BAD_REQUEST; + String message = "Invalid request payload"; + if (e instanceof MethodArgumentNotValidException) { + message = "Validation failed"; + } + + if (isRestRequest(request)) { + return ResponseEntity.status(status) + .body(Map.of( + "errorCode", "BAD_REQUEST", + "message", message, + "status", status.value() + )); + } + response.setStatus(status.value()); + model.addAttribute("status", status.value() + " " + status.getReasonPhrase()); + model.addAttribute("message", message); + model.addAttribute("errorCode", "BAD_REQUEST"); + return "error"; + } + @ExceptionHandler(Exception.class) public Object handleGeneralException(Exception e, HttpServletRequest request, HttpServletResponse response, Model model) { log.error("Unhandled exception for request {}: {}", request.getRequestURI(), e.getMessage(), e); diff --git a/src/main/resources/static/app.css b/src/main/resources/static/app.css new file mode 100644 index 0000000..28dd506 --- /dev/null +++ b/src/main/resources/static/app.css @@ -0,0 +1,270 @@ +:root { + --bg: #0b1020; + --panel: #101a33; + --panel2: #0f1730; + --text: #e7ecff; + --muted: rgba(231, 236, 255, 0.7); + --border: rgba(231, 236, 255, 0.12); + --accent: #6ea8ff; + --accent-strong: #8fbeff; + --success: #7be495; + --danger: #ff6e8a; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.35); + --radius: 16px; + --transition-fast: 160ms ease; +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + background: radial-gradient(1200px 700px at 20% 0%, rgba(110, 168, 255, 0.25), transparent 60%), + radial-gradient(900px 500px at 70% 20%, rgba(142, 97, 255, 0.18), transparent 60%), + var(--bg); + color: var(--text); + line-height: 1.5; +} + +code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } +code { color: rgba(231, 236, 255, 0.95); } +a { color: inherit; } + +.container { + width: min(1140px, calc(100% - 40px)); + margin: 0 auto; +} + +.app-header { + position: sticky; + top: 0; + z-index: 10; + background: rgba(11, 16, 32, 0.75); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); +} + +.header-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 20px; + align-items: center; + padding: 14px 0 12px; +} + +.brand-link { + color: var(--text); + text-decoration: none; + font-weight: 700; + letter-spacing: 0.25px; + font-size: 18px; +} + +.nav { display: flex; gap: 8px; flex-wrap: wrap; } +.nav-link { + color: var(--muted); + text-decoration: none; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid transparent; + transition: all var(--transition-fast); +} +.nav-link:hover { + color: var(--text); + border-color: var(--border); + background: rgba(255, 255, 255, 0.03); +} + +.auth { + display: flex; + gap: 10px; + align-items: center; +} +.auth-label { font-size: 14px; color: var(--muted); margin-right: 10px; } + +.page { padding: 30px 0 46px; } +.page-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; } +h1 { margin: 0 0 12px; font-size: clamp(28px, 4vw, 34px); line-height: 1.18; letter-spacing: 0.1px; } +h2 { margin: 0 0 10px; font-size: 20px; line-height: 1.3; } +h3 { margin: 0 0 8px; font-size: 16px; line-height: 1.35; } +h4 { margin: 0 0 8px; font-size: 16px; line-height: 1.35; } +p { margin: 0 0 14px; color: var(--muted); max-width: 75ch; } +.muted { color: var(--muted); } + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin-top: 20px; +} +.card { + display: block; + text-decoration: none; + color: var(--text); + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + box-shadow: var(--shadow); + transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast); +} +.card:hover { + border-color: rgba(110, 168, 255, 0.45); + background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03)); + transform: translateY(-1px); +} +.card p { margin: 6px 0 0; } + +.panel { + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px; + box-shadow: var(--shadow); +} +.panel + .panel { margin-top: 14px; } +.panel-error { border-color: rgba(255, 110, 138, 0.35); } +.panel-notice { border-color: rgba(110, 168, 255, 0.45); } +.panel-success { + border-color: rgba(123, 228, 149, 0.42); + background: linear-gradient(180deg, rgba(123, 228, 149, 0.12), rgba(123, 228, 149, 0.06)); +} + +.auth-grid { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 18px; + margin-top: 20px; +} +.auth-panel-title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} +.auth-kicker { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} +.auth-divider { + height: 1px; + margin: 14px 0 10px; + border: 0; + background: var(--border); +} +.auth-list { + margin: 0 0 14px; + padding-left: 18px; + color: var(--muted); +} +.auth-list li + li { + margin-top: 6px; +} + +.notice-badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(110, 168, 255, 0.55); + background: rgba(110, 168, 255, 0.18); + color: var(--text); + font-size: 12px; + font-weight: 600; +} + +.form { margin-top: 14px; display: grid; gap: 12px; } +.field { display: grid; gap: 6px; } +.label { color: var(--muted); font-size: 12px; } +.input { + width: 100%; + border-radius: 12px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--text); + padding: 10px 12px; + transition: border-color var(--transition-fast), background var(--transition-fast), outline-color var(--transition-fast); +} +.input:focus-visible { + outline: 2px solid var(--accent-strong); + outline-offset: 2px; +} +textarea.input { padding: 12px; resize: vertical; } +.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; } + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 36px; + padding: 0 14px; + border-radius: 12px; + border: 1px solid rgba(110, 168, 255, 0.55); + background: rgba(110, 168, 255, 0.18); + color: var(--text); + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all var(--transition-fast); +} +.button:hover { + background: rgba(110, 168, 255, 0.28); + border-color: rgba(143, 190, 255, 0.75); +} +.button-secondary { + border-color: var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--text); +} +.button-secondary:hover { background: rgba(255, 255, 255, 0.06); } + +.kv { + display: grid; + grid-template-columns: 120px 1fr; + gap: 10px; + margin-top: 10px; +} +.k { color: var(--muted); } +.v { overflow: auto; } + +.table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; +} +.table th, .table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} +.table th { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.app-footer { + border-top: 1px solid var(--border); + padding: 20px 0; + color: var(--muted); +} +.footer-row { display: flex; justify-content: space-between; align-items: center; } + +@media (max-width: 700px) { + .container { width: min(1140px, calc(100% - 24px)); } + .page { padding-top: 24px; } + .page-title { align-items: flex-start; flex-direction: column; } + .auth-label { margin-right: 0; display: block; margin-bottom: 8px; } +} + +@media (max-width: 980px) { + .header-row { grid-template-columns: 1fr; align-items: start; } + .cards { grid-template-columns: 1fr; } + .auth-grid { grid-template-columns: 1fr; } +} diff --git a/src/main/resources/templates/audit/list.html b/src/main/resources/templates/audit/list.html index 52a4231..04a228c 100644 --- a/src/main/resources/templates/audit/list.html +++ b/src/main/resources/templates/audit/list.html @@ -34,34 +34,206 @@

Audit

- +
+

Audit Log

+
+ Real-time: Disconnected +
+
+ + + +
- - - - + + + - - + - - - - + + + - - - + +
TimeActorRoleActorIdMethodPathActorEventDescription Status CaseIdHandlerErrorDetails
timeroleactorGETtime -
/path
-
?q
+
role
+
actor
+
+ Event + +
desc
+
change
200casehandlererr + case + +
/path
+
err
+
diff --git a/src/main/resources/templates/fragments/head.html b/src/main/resources/templates/fragments/head.html index 36804f7..798a549 100644 --- a/src/main/resources/templates/fragments/head.html +++ b/src/main/resources/templates/fragments/head.html @@ -4,277 +4,6 @@ Ärendehantering - + diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index bb28a52..e0edaf7 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -1,5 +1,7 @@ - +
@@ -10,33 +12,76 @@
-
- User +
+ + Logged in as: + User + User +
-
+
+ + + + +
- diff --git a/src/test/java/org/example/projektarendehantering/AuditIntegrationTest.java b/src/test/java/org/example/projektarendehantering/AuditIntegrationTest.java index 26c5188..1949afa 100644 --- a/src/test/java/org/example/projektarendehantering/AuditIntegrationTest.java +++ b/src/test/java/org/example/projektarendehantering/AuditIntegrationTest.java @@ -20,7 +20,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; @@ -48,28 +51,26 @@ void setUp() { @Test @WithMockUser(username = "testuser", roles = {"MANAGER"}) - void anyRequest_shouldBeAudited() throws Exception { + void mutationRequest_shouldBeAudited() throws Exception { long countBefore = auditEventRepository.count(); - mockMvc.perform(get("/api/cases")) - .andExpect(status().isOk()); + mockMvc.perform(post("/api/cases").with(csrf())) + .andExpect(status().isBadRequest()); List events = auditEventRepository.findAll(); assertThat(events.size()).isGreaterThan((int) countBefore); - + AuditEventEntity latest = events.stream() .max(Comparator.comparing(AuditEventEntity::getOccurredAt, Comparator.nullsLast(Comparator.naturalOrder())) .thenComparing(AuditEventEntity::getId, Comparator.nullsLast(Comparator.naturalOrder()))) .orElseThrow(); assertThat(latest.getRequestPath()).isEqualTo("/api/cases"); - assertThat(latest.getHttpMethod()).isEqualTo("GET"); } @Test void record_shouldSanitizeSensitiveQueryParameters() { AuditEventEntity event = new AuditEventEntity(); event.setRequestPath("/api/login"); - event.setHttpMethod("POST"); event.setQueryString("username=oscar&password=secretPassword123&token=abc-123"); auditService.record(event); @@ -89,7 +90,6 @@ void record_shouldSanitizeSensitiveQueryParameters() { void record_shouldSanitizeSensitiveJsonPayload() { AuditEventEntity event = new AuditEventEntity(); event.setRequestPath("/api/users"); - event.setHttpMethod("POST"); event.setQueryString("{\"name\": \"Oscar\", \"secret\": \"top-secret\", \"ssn\": \"12345\"}"); auditService.record(event); @@ -98,7 +98,7 @@ void record_shouldSanitizeSensitiveJsonPayload() { .max(Comparator.comparing(AuditEventEntity::getOccurredAt, Comparator.nullsLast(Comparator.naturalOrder())) .thenComparing(AuditEventEntity::getId, Comparator.nullsLast(Comparator.naturalOrder()))) .orElseThrow(); - + assertThat(saved.getQueryString()) .contains("\"name\":\"Oscar\"") .contains("\"secret\":\"[REDACTED]\"") @@ -107,17 +107,17 @@ void record_shouldSanitizeSensitiveJsonPayload() { @Test @WithMockUser(username = "doctor", roles = {"DOCTOR"}) - void auditInterceptor_shouldCaptureCaseId_fromUri() throws Exception { + void auditInterceptor_shouldCaptureCaseId_fromMutationUri() throws Exception { UUID caseId = UUID.randomUUID(); - - mockMvc.perform(get("/api/cases/{id}", caseId)) + + mockMvc.perform(delete("/api/cases/{id}", caseId).with(csrf())) .andExpect(status().isNotFound()); // Case doesn't exist, but that's fine for auditing AuditEventEntity latest = auditEventRepository.findAll().stream() .max(Comparator.comparing(AuditEventEntity::getOccurredAt, Comparator.nullsLast(Comparator.naturalOrder())) .thenComparing(AuditEventEntity::getId, Comparator.nullsLast(Comparator.naturalOrder()))) .orElseThrow(); - + assertThat(latest.getCaseId()).isEqualTo(caseId); } } diff --git a/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java b/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java index 1d49fc2..23bccbb 100644 --- a/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java +++ b/src/test/java/org/example/projektarendehantering/ProjektArendehanteringApplicationTests.java @@ -10,7 +10,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; @@ -35,8 +37,9 @@ void uiRequest_createsAuditEvent() throws Exception { .build(); long before = auditEventRepository.count(); - mockMvc.perform(get("/ui/cases")) - .andExpect(status().isOk()); + // Must be a mutation (POST/PUT/DELETE) to trigger auditing + mockMvc.perform(post("/ui/cases/new").with(csrf())) + .andExpect(status().isOk()); // Returns 200 (re-renders form) because body is missing long after = auditEventRepository.count(); assertThat(after).isGreaterThan(before); diff --git a/src/test/java/org/example/projektarendehantering/presentation/rest/AuditControllerTest.java b/src/test/java/org/example/projektarendehantering/presentation/rest/AuditControllerTest.java index 9332692..6d662d2 100644 --- a/src/test/java/org/example/projektarendehantering/presentation/rest/AuditControllerTest.java +++ b/src/test/java/org/example/projektarendehantering/presentation/rest/AuditControllerTest.java @@ -62,7 +62,6 @@ void setUp() { void list_shouldReturnAuditEvents() throws Exception { AuditEventDTO dto = new AuditEventDTO(); dto.setRequestPath("/api/cases"); - dto.setHttpMethod("GET"); Page page = new PageImpl<>(List.of(dto)); @@ -100,6 +99,6 @@ void list_withFilters_shouldPassParameters() throws Exception { @Test void list_shouldBeUnauthorized_whenNotLoggedIn() throws Exception { mockMvc.perform(get("/api/audit")) - .andExpect(status().is3xxRedirection()); + .andExpect(status().isUnauthorized()); } } diff --git a/src/test/java/org/example/projektarendehantering/presentation/rest/CaseControllerTest.java b/src/test/java/org/example/projektarendehantering/presentation/rest/CaseControllerTest.java index 745307e..8bdbdec 100644 --- a/src/test/java/org/example/projektarendehantering/presentation/rest/CaseControllerTest.java +++ b/src/test/java/org/example/projektarendehantering/presentation/rest/CaseControllerTest.java @@ -168,7 +168,7 @@ void updateCase_shouldReturnUnauthorized_whenNotLoggedIn() throws Exception { .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(inputDTO))) - .andExpect(status().is3xxRedirection()); + .andExpect(status().isUnauthorized()); } @@ -176,7 +176,7 @@ void updateCase_shouldReturnUnauthorized_whenNotLoggedIn() throws Exception { void deleteCase_shouldReturnUnauthorized_whenNotLoggedIn() throws Exception { mockMvc.perform(delete("/api/cases/{id}", caseId) .with(csrf())) - .andExpect(status().is3xxRedirection()); + .andExpect(status().isUnauthorized()); } @Test @@ -196,7 +196,7 @@ void getCase_shouldReturnForbidden_whenNotAuthorized() throws Exception { void getAllCases_shouldReturnUnauthorized_whenNotLoggedIn() throws Exception { // Without @WithMockUser mockMvc.perform(get("/api/cases")) - .andExpect(status().is3xxRedirection()); // Redirect to login in OAuth2 setup + .andExpect(status().isUnauthorized()); } @Test @@ -218,8 +218,8 @@ void getClosedCases_shouldReturnList_forManager() throws Exception { } @Test - void getClosedCases_shouldReturnRedirect_whenNotLoggedIn() throws Exception { + void getClosedCases_shouldReturnUnauthorized_whenNotLoggedIn() throws Exception { mockMvc.perform(get("/api/cases/closed")) - .andExpect(status().is3xxRedirection()); + .andExpect(status().isUnauthorized()); } }