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,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
}