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());
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
|
||||||
return executor;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.CreateAnnotationDTO;
|
||||||
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
|
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -12,8 +14,11 @@ import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
|||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -23,6 +28,7 @@ public class AnnotationService {
|
|||||||
|
|
||||||
private final AnnotationRepository annotationRepository;
|
private final AnnotationRepository annotationRepository;
|
||||||
private final TranscriptionBlockRepository blockRepository;
|
private final TranscriptionBlockRepository blockRepository;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
|
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
|
||||||
return annotationRepository.findByDocumentId(documentId);
|
return annotationRepository.findByDocumentId(documentId);
|
||||||
@@ -42,7 +48,10 @@ public class AnnotationService {
|
|||||||
.createdBy(userId)
|
.createdBy(userId)
|
||||||
.build();
|
.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
|
@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.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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.CreateAnnotationDTO;
|
||||||
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
|
import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -13,6 +16,8 @@ import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
|||||||
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -32,6 +37,7 @@ class AnnotationServiceTest {
|
|||||||
|
|
||||||
@Mock AnnotationRepository annotationRepository;
|
@Mock AnnotationRepository annotationRepository;
|
||||||
@Mock TranscriptionBlockRepository blockRepository;
|
@Mock TranscriptionBlockRepository blockRepository;
|
||||||
|
@Mock AuditService auditService;
|
||||||
@InjectMocks AnnotationService annotationService;
|
@InjectMocks AnnotationService annotationService;
|
||||||
|
|
||||||
// ─── createAnnotation ─────────────────────────────────────────────────────
|
// ─── createAnnotation ─────────────────────────────────────────────────────
|
||||||
@@ -89,6 +95,40 @@ class AnnotationServiceTest {
|
|||||||
assertThat(result.getFileHash()).isNull();
|
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 ──────────────────────────────────────────────────
|
// ─── createOcrAnnotation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user