diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java new file mode 100644 index 00000000..cac1ef48 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java @@ -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, +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLog.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLog.java new file mode 100644 index 00000000..081a9839 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLog.java @@ -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 payload; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogRepository.java new file mode 100644 index 00000000..2322c8c9 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogRepository.java @@ -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 { +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditService.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditService.java new file mode 100644 index 00000000..0bd1e727 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditService.java @@ -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 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); + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java index acdac4c5..c2a2f55a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java @@ -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; + } } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java index 7616a9a0..ff3f9cb1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java @@ -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 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 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); + } + } + } diff --git a/backend/src/main/resources/db/migration/V46__add_audit_log.sql b/backend/src/main/resources/db/migration/V46__add_audit_log.sql new file mode 100644 index 00000000..2a01126a --- /dev/null +++ b/backend/src/main/resources/db/migration/V46__add_audit_log.sql @@ -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; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceTest.java new file mode 100644 index 00000000..f8c85653 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/audit/AuditServiceTest.java @@ -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 payload = Map.of("pageNumber", 3); + + when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + auditService.log(AuditKind.TEXT_SAVED, actorId, documentId, payload); + + ArgumentCaptor 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 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 captor = ArgumentCaptor.forClass(AuditLog.class); + verify(auditLogRepository).save(captor.capture()); + assertThat(captor.getValue().getActorId()).isNull(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java index bd3064a9..0ac0118d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -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> 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