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.';
@@ -0,0 +1,120 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.AuditEventType;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Immutable audit log entry.
* No setters for fields post-persist — once written, never changed.
* 10-year retention per KCanG compliance requirements.
*/
@Entity
@Table(name = "audit_events")
public class AuditEvent {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Enumerated(EnumType.STRING)
@Column(name = "event_type", nullable = false, updatable = false, length = 50)
private AuditEventType eventType;
@Column(name = "entity_type", nullable = false, updatable = false, length = 50)
private String entityType;
@Column(name = "entity_id", updatable = false)
private UUID entityId;
@Column(name = "actor_id", nullable = false, updatable = false)
private UUID actorId;
@Column(name = "actor_name", nullable = false, updatable = false)
private String actorName;
@Column(name = "actor_role", nullable = false, updatable = false, length = 20)
private String actorRole;
@Column(name = "description", nullable = false, updatable = false, columnDefinition = "TEXT")
private String description;
@Column(name = "metadata", updatable = false, columnDefinition = "jsonb")
private String metadata;
@Column(name = "ip_address", updatable = false, length = 45)
private String ipAddress;
@Column(name = "timestamp", nullable = false, updatable = false)
private Instant timestamp;
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
protected AuditEvent() {
// JPA
}
private AuditEvent(Builder builder) {
this.eventType = builder.eventType;
this.entityType = builder.entityType;
this.entityId = builder.entityId;
this.actorId = builder.actorId;
this.actorName = builder.actorName;
this.actorRole = builder.actorRole;
this.description = builder.description;
this.metadata = builder.metadata;
this.ipAddress = builder.ipAddress;
this.timestamp = Instant.now();
this.tenantId = builder.tenantId;
}
public static Builder builder() {
return new Builder();
}
// Read-only getters
public UUID getId() { return id; }
public AuditEventType getEventType() { return eventType; }
public String getEntityType() { return entityType; }
public UUID getEntityId() { return entityId; }
public UUID getActorId() { return actorId; }
public String getActorName() { return actorName; }
public String getActorRole() { return actorRole; }
public String getDescription() { return description; }
public String getMetadata() { return metadata; }
public String getIpAddress() { return ipAddress; }
public Instant getTimestamp() { return timestamp; }
public UUID getTenantId() { return tenantId; }
public static class Builder {
private AuditEventType eventType;
private String entityType;
private UUID entityId;
private UUID actorId;
private String actorName;
private String actorRole;
private String description;
private String metadata;
private String ipAddress;
private UUID tenantId;
public Builder eventType(AuditEventType eventType) { this.eventType = eventType; return this; }
public Builder entityType(String entityType) { this.entityType = entityType; return this; }
public Builder entityId(UUID entityId) { this.entityId = entityId; return this; }
public Builder actorId(UUID actorId) { this.actorId = actorId; return this; }
public Builder actorName(String actorName) { this.actorName = actorName; return this; }
public Builder actorRole(String actorRole) { this.actorRole = actorRole; return this; }
public Builder description(String description) { this.description = description; return this; }
public Builder metadata(String metadata) { this.metadata = metadata; return this; }
public Builder ipAddress(String ipAddress) { this.ipAddress = ipAddress; return this; }
public Builder tenantId(UUID tenantId) { this.tenantId = tenantId; return this; }
public AuditEvent build() {
return new AuditEvent(this);
}
}
}
@@ -0,0 +1,44 @@
package de.cannamanage.domain.enums;
/**
* All auditable event types in the system.
* Immutable audit trail for KCanG compliance (10-year retention).
*/
public enum AuditEventType {
// Distribution events
DISTRIBUTION_RECORDED,
DISTRIBUTION_VOIDED,
// Member events
MEMBER_CREATED,
MEMBER_UPDATED,
MEMBER_SUSPENDED,
MEMBER_EXPELLED,
// Stock events
BATCH_CREATED,
BATCH_RECALLED,
// Auth events
LOGIN_SUCCESS,
LOGIN_FAILED,
LOGOUT,
PASSWORD_CHANGED,
// Staff events
STAFF_INVITED,
STAFF_PERMISSIONS_CHANGED,
STAFF_REVOKED,
// Consent events
CONSENT_GRANTED,
CONSENT_REVOKED,
DATA_EXPORTED,
DATA_DELETED,
// Billing events
SUBSCRIPTION_STARTED,
SUBSCRIPTION_CANCELED,
PAYMENT_RECEIVED,
PAYMENT_FAILED
}
+45
View File
@@ -399,5 +399,50 @@
"active": "Aktiv",
"pastDue": "Zahlung ausstehend",
"canceled": "Gekündigt"
},
"audit": {
"title": "Audit-Protokoll",
"subtitle": "Unveränderliches Protokoll aller Vorgänge (10 Jahre Aufbewahrung)",
"timestamp": "Zeitstempel",
"type": "Typ",
"description": "Beschreibung",
"actor": "Akteur",
"entity": "Objekt",
"filterType": "Ereignistyp filtern",
"filterDateFrom": "Von",
"filterDateTo": "Bis",
"filterActor": "Akteur suchen",
"exportPdf": "Als PDF exportieren",
"exporting": "PDF wird generiert...",
"exported": "Audit-Protokoll exportiert.",
"allTypes": "Alle Typen",
"immutable": "Unveränderbar",
"timezone": "Europe/Berlin",
"retentionNote": "Aufbewahrungsfrist: 10 Jahre (KCanG-konform)",
"types": {
"DISTRIBUTION_RECORDED": "Ausgabe erfasst",
"DISTRIBUTION_VOIDED": "Ausgabe storniert",
"MEMBER_CREATED": "Mitglied angelegt",
"MEMBER_UPDATED": "Mitglied aktualisiert",
"MEMBER_SUSPENDED": "Mitglied gesperrt",
"MEMBER_EXPELLED": "Mitglied ausgeschlossen",
"BATCH_CREATED": "Charge angelegt",
"BATCH_RECALLED": "Charge zurückgerufen",
"LOGIN_SUCCESS": "Anmeldung",
"LOGIN_FAILED": "Fehlgeschlagene Anmeldung",
"LOGOUT": "Abmeldung",
"PASSWORD_CHANGED": "Passwort geändert",
"STAFF_INVITED": "Mitarbeiter eingeladen",
"STAFF_PERMISSIONS_CHANGED": "Berechtigungen geändert",
"STAFF_REVOKED": "Zugang entzogen",
"CONSENT_GRANTED": "Einwilligung erteilt",
"CONSENT_REVOKED": "Einwilligung widerrufen",
"DATA_EXPORTED": "Daten exportiert",
"DATA_DELETED": "Daten gelöscht",
"SUBSCRIPTION_STARTED": "Abo gestartet",
"SUBSCRIPTION_CANCELED": "Abo gekündigt",
"PAYMENT_RECEIVED": "Zahlung erhalten",
"PAYMENT_FAILED": "Zahlung fehlgeschlagen"
}
}
}
+45
View File
@@ -399,5 +399,50 @@
"active": "Active",
"pastDue": "Past due",
"canceled": "Canceled"
},
"audit": {
"title": "Audit Log",
"subtitle": "Immutable log of all operations (10-year retention)",
"timestamp": "Timestamp",
"type": "Type",
"description": "Description",
"actor": "Actor",
"entity": "Entity",
"filterType": "Filter by event type",
"filterDateFrom": "From",
"filterDateTo": "To",
"filterActor": "Search actor",
"exportPdf": "Export as PDF",
"exporting": "Generating PDF...",
"exported": "Audit log exported.",
"allTypes": "All types",
"immutable": "Immutable",
"timezone": "Europe/Berlin",
"retentionNote": "Retention period: 10 years (KCanG-compliant)",
"types": {
"DISTRIBUTION_RECORDED": "Distribution recorded",
"DISTRIBUTION_VOIDED": "Distribution voided",
"MEMBER_CREATED": "Member created",
"MEMBER_UPDATED": "Member updated",
"MEMBER_SUSPENDED": "Member suspended",
"MEMBER_EXPELLED": "Member expelled",
"BATCH_CREATED": "Batch created",
"BATCH_RECALLED": "Batch recalled",
"LOGIN_SUCCESS": "Login",
"LOGIN_FAILED": "Failed login",
"LOGOUT": "Logout",
"PASSWORD_CHANGED": "Password changed",
"STAFF_INVITED": "Staff invited",
"STAFF_PERMISSIONS_CHANGED": "Permissions changed",
"STAFF_REVOKED": "Access revoked",
"CONSENT_GRANTED": "Consent granted",
"CONSENT_REVOKED": "Consent revoked",
"DATA_EXPORTED": "Data exported",
"DATA_DELETED": "Data deleted",
"SUBSCRIPTION_STARTED": "Subscription started",
"SUBSCRIPTION_CANCELED": "Subscription canceled",
"PAYMENT_RECEIVED": "Payment received",
"PAYMENT_FAILED": "Payment failed"
}
}
}
@@ -0,0 +1,287 @@
"use client"
import { useState } from "react"
import { useAuditLogQuery, useExportAuditPdfMutation } from "@/services/audit"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Download, FileCheck, Lock, ScrollText, Search } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Select } from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const EVENT_TYPES = [
"DISTRIBUTION_RECORDED",
"DISTRIBUTION_VOIDED",
"MEMBER_CREATED",
"MEMBER_UPDATED",
"MEMBER_SUSPENDED",
"MEMBER_EXPELLED",
"BATCH_CREATED",
"BATCH_RECALLED",
"LOGIN_SUCCESS",
"LOGIN_FAILED",
"LOGOUT",
"PASSWORD_CHANGED",
"STAFF_INVITED",
"STAFF_PERMISSIONS_CHANGED",
"STAFF_REVOKED",
"CONSENT_GRANTED",
"CONSENT_REVOKED",
"DATA_EXPORTED",
"DATA_DELETED",
"SUBSCRIPTION_STARTED",
"SUBSCRIPTION_CANCELED",
"PAYMENT_RECEIVED",
"PAYMENT_FAILED",
]
export default function AuditLogPage() {
const t = useTranslations("audit")
// Filter state
const [page, setPage] = useState(0)
const [eventType, setEventType] = useState<string>("")
const [dateFrom, setDateFrom] = useState("")
const [dateTo, setDateTo] = useState("")
// Query
const { data, isLoading } = useAuditLogQuery({
page,
size: 20,
eventType: eventType || undefined,
from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
to: dateTo ? new Date(dateTo + "T23:59:59").toISOString() : undefined,
})
// Export mutation
const exportMutation = useExportAuditPdfMutation()
const handleExport = () => {
if (!dateFrom || !dateTo) {
toast.error("Bitte Zeitraum auswählen")
return
}
exportMutation.mutate(
{ from: dateFrom, to: dateTo },
{
onSuccess: () => toast.success(t("exported")),
onError: () => toast.error("Export fehlgeschlagen"),
}
)
}
const formatTimestamp = (ts: string) => {
return new Date(ts).toLocaleString("de-DE", {
timeZone: "Europe/Berlin",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
const getEventTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
DISTRIBUTION_RECORDED: t("types.DISTRIBUTION_RECORDED"),
MEMBER_CREATED: t("types.MEMBER_CREATED"),
MEMBER_UPDATED: t("types.MEMBER_UPDATED"),
MEMBER_SUSPENDED: t("types.MEMBER_SUSPENDED"),
BATCH_CREATED: t("types.BATCH_CREATED"),
BATCH_RECALLED: t("types.BATCH_RECALLED"),
LOGIN_SUCCESS: t("types.LOGIN_SUCCESS"),
LOGIN_FAILED: t("types.LOGIN_FAILED"),
STAFF_INVITED: t("types.STAFF_INVITED"),
CONSENT_GRANTED: t("types.CONSENT_GRANTED"),
SUBSCRIPTION_STARTED: t("types.SUBSCRIPTION_STARTED"),
PAYMENT_RECEIVED: t("types.PAYMENT_RECEIVED"),
}
return labels[type] || type.replace(/_/g, " ")
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<ScrollText className="h-6 w-6" />
{t("title")}
</h1>
<p className="mt-1 flex items-center gap-1 text-muted-foreground">
<Lock className="h-3.5 w-3.5" />
{t("subtitle")}
</p>
</div>
<Badge variant="outline" className="gap-1 text-xs">
<FileCheck className="h-3 w-3" />
{t("immutable")}
</Badge>
</div>
{/* Filters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Search className="h-4 w-4" />
Filter
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{/* Event type filter */}
<Select
value={eventType}
onChange={(e) => setEventType(e.target.value)}
>
<option value="">{t("allTypes")}</option>
{EVENT_TYPES.map((type) => (
<option key={type} value={type}>
{getEventTypeLabel(type)}
</option>
))}
</Select>
{/* Date from */}
<Input
type="date"
placeholder={t("filterDateFrom")}
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
{/* Date to */}
<Input
type="date"
placeholder={t("filterDateTo")}
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
{/* Export button */}
<Button
variant="outline"
onClick={handleExport}
disabled={exportMutation.isPending || !dateFrom || !dateTo}
>
<Download className="mr-2 h-4 w-4" />
{exportMutation.isPending ? t("exporting") : t("exportPdf")}
</Button>
</div>
</CardContent>
</Card>
{/* Retention note */}
<p className="text-xs text-muted-foreground">
{t("retentionNote")} · {t("timezone")}
</p>
{/* Audit table */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[160px]">{t("timestamp")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead className="hidden md:table-cell">
{t("description")}
</TableHead>
<TableHead>{t("actor")}</TableHead>
<TableHead className="hidden lg:table-cell">
{t("entity")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center">
<div className="text-muted-foreground">Laden...</div>
</TableCell>
</TableRow>
) : data?.content && data.content.length > 0 ? (
data.content.map((event) => (
<TableRow key={event.id}>
<TableCell className="font-mono text-xs">
{formatTimestamp(event.timestamp)}
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{getEventTypeLabel(event.eventType)}
</Badge>
</TableCell>
<TableCell className="hidden max-w-[300px] truncate text-sm text-muted-foreground md:table-cell">
{event.description}
</TableCell>
<TableCell className="text-sm">
{event.actorName}
<span className="ml-1 text-xs text-muted-foreground">
({event.actorRole})
</span>
</TableCell>
<TableCell className="hidden text-xs text-muted-foreground lg:table-cell">
{event.entityType}
{event.entityId && (
<span className="ml-1 font-mono">
{event.entityId.substring(0, 8)}
</span>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center">
<div className="text-muted-foreground">
Keine Audit-Einträge gefunden
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Seite {data.number + 1} von {data.totalPages} ({data.totalElements}{" "}
Einträge)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= data.totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Weiter
</Button>
</div>
</div>
)}
</div>
)
}
@@ -36,4 +36,14 @@ export const navigationsData: NavigationType[] = [
},
],
},
{
title: "Compliance",
items: [
{
title: "Protokoll",
href: "/audit-log",
iconName: "ScrollText",
},
],
},
]
@@ -0,0 +1,88 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { apiClient, apiDownload } from "@/lib/api-client"
// --- Types ---
export interface AuditEventData {
id: string
eventType: string
entityType: string
entityId: string | null
actorId: string
actorName: string
actorRole: string
description: string
metadata: string | null
ipAddress: string | null
timestamp: string
}
export interface AuditLogPage {
content: AuditEventData[]
totalElements: number
totalPages: number
number: number
size: number
}
export interface AuditLogFilters {
page?: number
size?: number
eventType?: string
entityType?: string
actorId?: string
from?: string
to?: string
}
// --- Query Hooks ---
export function useAuditLogQuery(filters: AuditLogFilters = {}) {
const {
page = 0,
size = 20,
eventType,
entityType,
actorId,
from,
to,
} = filters
return useQuery({
queryKey: ["audit", page, size, eventType, entityType, actorId, from, to],
queryFn: () =>
apiClient<AuditLogPage>("/audit", {
params: {
page: page.toString(),
size: size.toString(),
eventType: eventType || undefined,
entityType: entityType || undefined,
actorId: actorId || undefined,
from: from || undefined,
to: to || undefined,
},
}),
})
}
// --- Mutation Hooks ---
export function useExportAuditPdfMutation() {
return useMutation({
mutationFn: async ({ from, to }: { from: string; to: string }) => {
const { blob, filename } = await apiDownload(
`/audit/export?from=${from}&to=${to}`
)
// Trigger browser download
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename || `audit-log-${from}-to-${to}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
})
}
@@ -0,0 +1,98 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.AuditEvent;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.AuditEventType;
import de.cannamanage.service.repository.AuditEventRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* Immutable audit log service.
* Events can only be written (log) and read (query/export) — never updated or deleted.
*/
@Service
@Transactional
public class AuditService {
private static final Logger log = LoggerFactory.getLogger(AuditService.class);
private final AuditEventRepository auditEventRepository;
private final PdfReportGenerator pdfReportGenerator;
public AuditService(AuditEventRepository auditEventRepository, PdfReportGenerator pdfReportGenerator) {
this.auditEventRepository = auditEventRepository;
this.pdfReportGenerator = pdfReportGenerator;
}
/**
* Writes an immutable audit event. Once persisted, it cannot be modified or deleted.
*/
public AuditEvent log(
AuditEventType eventType,
String entityType,
UUID entityId,
UUID actorId,
String actorName,
String actorRole,
String description,
String metadata,
String ipAddress
) {
UUID tenantId = TenantContext.getCurrentTenant();
AuditEvent event = AuditEvent.builder()
.eventType(eventType)
.entityType(entityType)
.entityId(entityId)
.actorId(actorId)
.actorName(actorName)
.actorRole(actorRole)
.description(description)
.metadata(metadata)
.ipAddress(ipAddress)
.tenantId(tenantId)
.build();
AuditEvent saved = auditEventRepository.save(event);
log.debug("Audit event logged: {} {} on {}:{}", eventType, actorName, entityType, entityId);
return saved;
}
/**
* Paginated, filtered query of audit events.
*/
@Transactional(readOnly = true)
public Page<AuditEvent> getEvents(
UUID tenantId,
int page,
int size,
AuditEventType eventType,
String entityType,
UUID actorId,
Instant from,
Instant to
) {
return auditEventRepository.findFiltered(
tenantId, eventType, entityType, actorId, from, to,
PageRequest.of(page, size)
);
}
/**
* Export audit events as PDF bytes for a given date range.
*/
@Transactional(readOnly = true)
public byte[] exportPdf(UUID tenantId, Instant from, Instant to) {
List<AuditEvent> events = auditEventRepository.findByTenantIdAndTimestampRange(tenantId, from, to);
return pdfReportGenerator.generateAuditReport(events, from, to);
}
}
@@ -4,6 +4,7 @@ import com.lowagie.text.*;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import de.cannamanage.domain.entity.AuditEvent;
import de.cannamanage.domain.entity.Club;
import de.cannamanage.service.model.report.MemberListReport;
import de.cannamanage.service.model.report.MonthlyReport;
@@ -12,8 +13,10 @@ import org.springframework.stereotype.Component;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* Generates PDF reports using OpenPDF (librepdf fork of iText 2.x).
@@ -239,4 +242,63 @@ public class PdfReportGenerator {
valueCell.setPadding(4);
table.addCell(valueCell);
}
/**
* Generates a PDF audit report for compliance/Behörde export.
*/
public byte[] generateAuditReport(List<AuditEvent> events, Instant from, Instant to) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Document document = new Document(PageSize.A4.rotate(), 30, 30, 40, 40);
try {
PdfWriter writer = PdfWriter.getInstance(document, baos);
writer.setPageEvent(new PdfFooterHandler());
document.open();
// Title
Paragraph title = new Paragraph("Audit-Protokoll", HEADER_FONT);
title.setSpacingAfter(5);
document.add(title);
// Date range subtitle
String fromStr = DATETIME_FMT.format(from);
String toStr = DATETIME_FMT.format(to);
Paragraph subtitle = new Paragraph("Zeitraum: " + fromStr + " " + toStr, NORMAL_FONT);
subtitle.setSpacingAfter(5);
document.add(subtitle);
Paragraph countPara = new Paragraph("Einträge: " + events.size(), NORMAL_FONT);
countPara.setSpacingAfter(15);
document.add(countPara);
// Immutability note
Paragraph note = new Paragraph(
"Unveränderliches Protokoll — 10 Jahre Aufbewahrungsfrist (KCanG-konform)", NORMAL_FONT);
note.setSpacingAfter(15);
document.add(note);
// Table
PdfPTable table = new PdfPTable(new float[]{14, 12, 10, 24, 14, 10});
table.setWidthPercentage(100);
table.setSpacingBefore(10);
addTableHeader(table, "Zeitstempel", "Typ", "Objekt", "Beschreibung", "Akteur", "Rolle");
for (AuditEvent event : events) {
addCell(table, DATETIME_FMT.format(event.getTimestamp()));
addCell(table, event.getEventType().name());
addCell(table, event.getEntityType());
addCell(table, event.getDescription());
addCell(table, event.getActorName());
addCell(table, event.getActorRole());
}
document.add(table);
document.close();
} catch (DocumentException e) {
throw new RuntimeException("Failed to generate audit PDF", e);
}
return baos.toByteArray();
}
}
@@ -0,0 +1,52 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.AuditEvent;
import de.cannamanage.domain.enums.AuditEventType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.UUID;
@Repository
public interface AuditEventRepository extends JpaRepository<AuditEvent, UUID> {
Page<AuditEvent> findByTenantIdOrderByTimestampDesc(UUID tenantId, Pageable pageable);
@Query("""
SELECT a FROM AuditEvent a
WHERE a.tenantId = :tenantId
AND (:eventType IS NULL OR a.eventType = :eventType)
AND (:entityType IS NULL OR a.entityType = :entityType)
AND (:actorId IS NULL OR a.actorId = :actorId)
AND (:from IS NULL OR a.timestamp >= :from)
AND (:to IS NULL OR a.timestamp <= :to)
ORDER BY a.timestamp DESC
""")
Page<AuditEvent> findFiltered(
@Param("tenantId") UUID tenantId,
@Param("eventType") AuditEventType eventType,
@Param("entityType") String entityType,
@Param("actorId") UUID actorId,
@Param("from") Instant from,
@Param("to") Instant to,
Pageable pageable
);
@Query("""
SELECT a FROM AuditEvent a
WHERE a.tenantId = :tenantId
AND a.timestamp >= :from
AND a.timestamp <= :to
ORDER BY a.timestamp DESC
""")
java.util.List<AuditEvent> findByTenantIdAndTimestampRange(
@Param("tenantId") UUID tenantId,
@Param("from") Instant from,
@Param("to") Instant to
);
}