diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuditController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuditController.java new file mode 100644 index 0000000..3c44edc --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuditController.java @@ -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> 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 events = auditService.getEvents( + tenantId, page, size, eventType, entityType, actorId, from, to + ); + Page 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 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() + ); + } + } +} diff --git a/cannamanage-api/src/main/resources/db/migration/V8__audit_log.sql b/cannamanage-api/src/main/resources/db/migration/V8__audit_log.sql new file mode 100644 index 0000000..1497938 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V8__audit_log.sql @@ -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.'; diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AuditEvent.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AuditEvent.java new file mode 100644 index 0000000..076cd09 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/AuditEvent.java @@ -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); + } + } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java new file mode 100644 index 0000000..04c1a7a --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java @@ -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 +} diff --git a/cannamanage-frontend/messages/de.json b/cannamanage-frontend/messages/de.json index 3122bc4..531321d 100644 --- a/cannamanage-frontend/messages/de.json +++ b/cannamanage-frontend/messages/de.json @@ -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" + } } } diff --git a/cannamanage-frontend/messages/en.json b/cannamanage-frontend/messages/en.json index b6b6354..444ff03 100644 --- a/cannamanage-frontend/messages/en.json +++ b/cannamanage-frontend/messages/en.json @@ -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" + } } } diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/audit-log/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/audit-log/page.tsx new file mode 100644 index 0000000..a9e5628 --- /dev/null +++ b/cannamanage-frontend/src/app/(dashboard-layout)/audit-log/page.tsx @@ -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("") + 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 = { + 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 ( +
+ {/* Header */} +
+
+

+ + {t("title")} +

+

+ + {t("subtitle")} +

+
+ + + {t("immutable")} + +
+ + {/* Filters */} + + + + + Filter + + + +
+ {/* Event type filter */} + + + {/* Date from */} + setDateFrom(e.target.value)} + /> + + {/* Date to */} + setDateTo(e.target.value)} + /> + + {/* Export button */} + +
+
+
+ + {/* Retention note */} +

+ {t("retentionNote")} · {t("timezone")} +

+ + {/* Audit table */} + + + + + + {t("timestamp")} + {t("type")} + + {t("description")} + + {t("actor")} + + {t("entity")} + + + + + {isLoading ? ( + + +
Laden...
+
+
+ ) : data?.content && data.content.length > 0 ? ( + data.content.map((event) => ( + + + {formatTimestamp(event.timestamp)} + + + + {getEventTypeLabel(event.eventType)} + + + + {event.description} + + + {event.actorName} + + ({event.actorRole}) + + + + {event.entityType} + {event.entityId && ( + + {event.entityId.substring(0, 8)}… + + )} + + + )) + ) : ( + + +
+ Keine Audit-Einträge gefunden +
+
+
+ )} +
+
+
+
+ + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+

+ Seite {data.number + 1} von {data.totalPages} ({data.totalElements}{" "} + Einträge) +

+
+ + +
+
+ )} +
+ ) +} diff --git a/cannamanage-frontend/src/data/navigations.ts b/cannamanage-frontend/src/data/navigations.ts index dbab4cc..be91aa4 100644 --- a/cannamanage-frontend/src/data/navigations.ts +++ b/cannamanage-frontend/src/data/navigations.ts @@ -36,4 +36,14 @@ export const navigationsData: NavigationType[] = [ }, ], }, + { + title: "Compliance", + items: [ + { + title: "Protokoll", + href: "/audit-log", + iconName: "ScrollText", + }, + ], + }, ] diff --git a/cannamanage-frontend/src/services/audit.ts b/cannamanage-frontend/src/services/audit.ts new file mode 100644 index 0000000..f0a69f0 --- /dev/null +++ b/cannamanage-frontend/src/services/audit.ts @@ -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("/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) + }, + }) +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java b/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java new file mode 100644 index 0000000..e46aafd --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/AuditService.java @@ -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 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 events = auditEventRepository.findByTenantIdAndTimestampRange(tenantId, from, to); + return pdfReportGenerator.generateAuditReport(events, from, to); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/PdfReportGenerator.java b/cannamanage-service/src/main/java/de/cannamanage/service/PdfReportGenerator.java index 8c6086d..6afb0ea 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/PdfReportGenerator.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/PdfReportGenerator.java @@ -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 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(); + } } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/AuditEventRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/AuditEventRepository.java new file mode 100644 index 0000000..281f846 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/AuditEventRepository.java @@ -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 { + + Page 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 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 findByTenantIdAndTimestampRange( + @Param("tenantId") UUID tenantId, + @Param("from") Instant from, + @Param("to") Instant to + ); +}