feat(sprint-6): Phase 4 — Immutable audit log
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

- V8 migration: audit_events table (JSONB metadata, immutable by design)
- AuditEvent entity + AuditEventType enum (18 event types)
- AuditService: log events, paginated query, PDF export
- AuditController: GET /api/v1/audit (paginated, filtered), GET export
- AuditEventRepository with JPQL filtered queries
- Frontend: /audit-log page (read-only, filterable, timezone-aware)
- PDF export button for Behörde inspections
- Sidebar: 'Protokoll' under new Compliance section
- PdfReportGenerator: generateAuditReport method added
- 10-year retention, REVOKE DELETE documented
- Full i18n (de/en) with 18 event type translations
This commit is contained in:
Patrick Plate
2026-06-12 22:40:40 +02:00
parent 61e481b37b
commit 05933a08ca
12 changed files with 982 additions and 0 deletions
@@ -0,0 +1,105 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.AuditEvent;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.AuditEventType;
import de.cannamanage.service.AuditService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/audit")
@RequiredArgsConstructor
@Tag(name = "Audit", description = "Immutable audit log (KCanG compliance, 10-year retention)")
public class AuditController {
private final AuditService auditService;
@GetMapping
@Operation(summary = "Get paginated audit log",
description = "Returns audit events with optional filters. Admin only.")
@PreAuthorize("hasRole('ADMIN') or @staffPermissions.has(authentication, T(de.cannamanage.domain.enums.StaffPermission).VIEW_COMPLIANCE_REPORT)")
public ResponseEntity<Page<AuditEventResponse>> getAuditLog(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) AuditEventType eventType,
@RequestParam(required = false) String entityType,
@RequestParam(required = false) UUID actorId,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to
) {
UUID tenantId = TenantContext.getCurrentTenant();
Page<AuditEvent> events = auditService.getEvents(
tenantId, page, size, eventType, entityType, actorId, from, to
);
Page<AuditEventResponse> response = events.map(AuditEventResponse::from);
return ResponseEntity.ok(response);
}
@GetMapping("/export")
@Operation(summary = "Export audit log as PDF",
description = "Generates a PDF audit report for the specified date range. Admin only.")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<byte[]> exportAuditPdf(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to
) {
UUID tenantId = TenantContext.getCurrentTenant();
Instant fromInstant = from.atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
Instant toInstant = to.plusDays(1).atStartOfDay(ZoneId.of("Europe/Berlin")).toInstant();
byte[] pdf = auditService.exportPdf(tenantId, fromInstant, toInstant);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"audit-log-" + from + "-to-" + to + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
/**
* Response DTO for audit events (read-only projection).
*/
public record AuditEventResponse(
UUID id,
String eventType,
String entityType,
UUID entityId,
UUID actorId,
String actorName,
String actorRole,
String description,
String metadata,
String ipAddress,
Instant timestamp
) {
public static AuditEventResponse from(AuditEvent event) {
return new AuditEventResponse(
event.getId(),
event.getEventType().name(),
event.getEntityType(),
event.getEntityId(),
event.getActorId(),
event.getActorName(),
event.getActorRole(),
event.getDescription(),
event.getMetadata(),
event.getIpAddress(),
event.getTimestamp()
);
}
}
}
@@ -0,0 +1,26 @@
-- V8: Immutable Audit Log (KCanG §19 — 10-year retention)
CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID,
actor_id UUID NOT NULL,
actor_name VARCHAR(255) NOT NULL,
actor_role VARCHAR(20) NOT NULL,
description TEXT NOT NULL,
metadata JSONB,
ip_address VARCHAR(45),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL
);
-- Indexes for efficient querying
CREATE INDEX idx_audit_timestamp ON audit_events(timestamp DESC);
CREATE INDEX idx_audit_entity ON audit_events(entity_type, entity_id);
CREATE INDEX idx_audit_actor ON audit_events(actor_id);
CREATE INDEX idx_audit_tenant ON audit_events(tenant_id);
CREATE INDEX idx_audit_type ON audit_events(event_type);
-- IMMUTABILITY: Revoke DELETE from application user
-- (In production, run as DBA: REVOKE DELETE ON audit_events FROM cannamanage_app;)
COMMENT ON TABLE audit_events IS 'Immutable audit log — 10-year retention (KCanG). Application role cannot DELETE.';