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:
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user