feat(audit): add audit_log infrastructure and instrument AnnotationService

- V46 migration: audit_log table with indexes and append-only REVOKE
- audit/ package: AuditKind enum (with Javadoc payloads), AuditLog entity,
  AuditLogRepository, AuditService (@Async on dedicated auditExecutor)
- AsyncConfig: auditExecutor with CallerRunsPolicy and queueCapacity 50
- AnnotationService: ANNOTATION_CREATED on createAnnotation() only,
  deferred via afterCommit() when inside a transaction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-19 13:17:54 +02:00
parent 692c2c0629
commit 793b863096
9 changed files with 277 additions and 1 deletions

View File

@@ -0,0 +1,22 @@
package org.raddatz.familienarchiv.audit;
public enum AuditKind {
/** Payload: none */
FILE_UPLOADED,
/** Payload: {@code {"oldStatus": "UPLOADED", "newStatus": "TRANSCRIBED"}} */
STATUS_CHANGED,
/** Payload: none */
METADATA_UPDATED,
/** Payload: {@code {"pageNumber": 3}} */
TEXT_SAVED,
/** Payload: none */
BLOCK_REVIEWED,
/** Payload: {@code {"pageNumber": 3}} */
ANNOTATION_CREATED,
}

View File

@@ -0,0 +1,46 @@
package org.raddatz.familienarchiv.audit;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "audit_log")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "happened_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private OffsetDateTime happenedAt;
@Column(name = "actor_id")
private UUID actorId;
@Enumerated(EnumType.STRING)
@Column(name = "kind", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private AuditKind kind;
@Column(name = "document_id")
private UUID documentId;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Object> payload;
}

View File

@@ -0,0 +1,8 @@
package org.raddatz.familienarchiv.audit;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
}

View File

@@ -0,0 +1,31 @@
package org.raddatz.familienarchiv.audit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class AuditService {
private final AuditLogRepository auditLogRepository;
@Async("auditExecutor")
public void log(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
try {
auditLogRepository.save(AuditLog.builder()
.kind(kind)
.actorId(actorId)
.documentId(documentId)
.payload(payload)
.build());
} catch (Exception e) {
log.error("Audit log write failed: kind={}, document={}", kind, documentId, e);
}
}
}

View File

@@ -23,4 +23,15 @@ public class AsyncConfig {
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
return executor;
}
@Bean("auditExecutor")
public Executor auditExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("Audit-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}

View File

@@ -2,6 +2,8 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -12,8 +14,11 @@ import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Slf4j
@@ -23,6 +28,7 @@ public class AnnotationService {
private final AnnotationRepository annotationRepository;
private final TranscriptionBlockRepository blockRepository;
private final AuditService auditService;
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
return annotationRepository.findByDocumentId(documentId);
@@ -42,7 +48,10 @@ public class AnnotationService {
.createdBy(userId)
.build();
return annotationRepository.save(annotation);
DocumentAnnotation saved = annotationRepository.save(annotation);
logAfterCommit(AuditKind.ANNOTATION_CREATED, userId, saved.getDocumentId(),
Map.of("pageNumber", saved.getPageNumber()));
return saved;
}
@Transactional
@@ -108,4 +117,17 @@ public class AnnotationService {
});
}
private void logAfterCommit(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
auditService.log(kind, actorId, documentId, payload);
}
});
} else {
auditService.log(kind, actorId, documentId, payload);
}
}
}

View File

@@ -0,0 +1,22 @@
-- Append-only audit trail for domain-level archive activity.
-- Enables dashboard queries (Family Pulse, activity feed, resume card) in #271.
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
happened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- ON DELETE SET NULL is by design: GDPR right-to-erasure. Deleted users' events
-- retain their timestamp and kind but lose actor attribution.
actor_id UUID REFERENCES app_users(id) ON DELETE SET NULL,
kind VARCHAR(50) NOT NULL,
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
payload JSONB
);
CREATE INDEX idx_audit_log_happened_at ON audit_log (happened_at DESC);
CREATE INDEX idx_audit_log_document_id ON audit_log (document_id);
CREATE INDEX idx_audit_log_actor_id ON audit_log (actor_id);
CREATE INDEX idx_audit_log_kind ON audit_log (kind);
-- Enforce append-only at the database layer: the application role may INSERT
-- but must not UPDATE or DELETE audit rows.
REVOKE UPDATE, DELETE ON audit_log FROM app_user;

View File

@@ -0,0 +1,74 @@
package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AuditServiceTest {
@Mock AuditLogRepository auditLogRepository;
@InjectMocks AuditService auditService;
@Test
void log_savesAuditRowWithCorrectFields() {
UUID actorId = UUID.randomUUID();
UUID documentId = UUID.randomUUID();
Map<String, Object> payload = Map.of("pageNumber", 3);
when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
auditService.log(AuditKind.TEXT_SAVED, actorId, documentId, payload);
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
verify(auditLogRepository).save(captor.capture());
AuditLog saved = captor.getValue();
assertThat(saved.getKind()).isEqualTo(AuditKind.TEXT_SAVED);
assertThat(saved.getActorId()).isEqualTo(actorId);
assertThat(saved.getDocumentId()).isEqualTo(documentId);
assertThat(saved.getPayload()).isEqualTo(payload);
}
@Test
void log_doesNotPropagateException_whenRepoThrows() {
when(auditLogRepository.save(any())).thenThrow(new RuntimeException("DB down"));
assertThatCode(() ->
auditService.log(AuditKind.METADATA_UPDATED, UUID.randomUUID(), UUID.randomUUID(), null)
).doesNotThrowAnyException();
}
@Test
void log_acceptsNullPayload() {
when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
auditService.log(AuditKind.FILE_UPLOADED, UUID.randomUUID(), UUID.randomUUID(), null);
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
verify(auditLogRepository).save(captor.capture());
assertThat(captor.getValue().getPayload()).isNull();
}
@Test
void log_acceptsNullActorId() {
when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
auditService.log(AuditKind.BLOCK_REVIEWED, null, UUID.randomUUID(), null);
ArgumentCaptor<AuditLog> captor = ArgumentCaptor.forClass(AuditLog.class);
verify(auditLogRepository).save(captor.capture());
assertThat(captor.getValue().getActorId()).isNull();
}
}

View File

@@ -5,6 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -13,6 +16,8 @@ import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.Map;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -32,6 +37,7 @@ class AnnotationServiceTest {
@Mock AnnotationRepository annotationRepository;
@Mock TranscriptionBlockRepository blockRepository;
@Mock AuditService auditService;
@InjectMocks AnnotationService annotationService;
// ─── createAnnotation ─────────────────────────────────────────────────────
@@ -89,6 +95,40 @@ class AnnotationServiceTest {
assertThat(result.getFileHash()).isNull();
}
@Test
void createAnnotation_logsAnnotationCreatedWithPageNumber() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(5, 0.1, 0.1, 0.3, 0.3, "#ff0000");
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(5)
.x(0.1).y(0.1).width(0.3).height(0.3).color("#ff0000").createdBy(userId).build();
when(annotationRepository.save(any())).thenReturn(saved);
annotationService.createAnnotation(docId, dto, userId, null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
verify(auditService).log(
org.mockito.ArgumentMatchers.eq(AuditKind.ANNOTATION_CREATED),
org.mockito.ArgumentMatchers.eq(userId),
org.mockito.ArgumentMatchers.eq(docId),
payloadCaptor.capture());
assertThat(payloadCaptor.getValue()).containsEntry("pageNumber", 5);
}
@Test
void createOcrAnnotation_doesNotLogAuditEvent() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.8, 0.04, "#00C7B1");
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
annotationService.createOcrAnnotation(docId, dto, userId, null, null);
verify(auditService, never()).log(any(), any(), any(), any());
}
// ─── createOcrAnnotation ──────────────────────────────────────────────────
@Test