feat(sprint-6): Phase 4 — Immutable audit log
- 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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user