From cdd19e180ee10002ea0778099ed6acced1ec977b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:22:33 +0200 Subject: [PATCH] feat: add audit logging framework with immutable append-only log Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cameleer/saas/audit/AuditAction.java | 10 ++ .../cameleer/saas/audit/AuditEntity.java | 144 ++++++++++++++++++ .../cameleer/saas/audit/AuditRepository.java | 16 ++ .../cameleer/saas/audit/AuditService.java | 33 ++++ .../db/migration/V004__create_audit_log.sql | 19 +++ .../saas/audit/AuditRepositoryTest.java | 84 ++++++++++ .../cameleer/saas/audit/AuditServiceTest.java | 80 ++++++++++ 7 files changed, 386 insertions(+) create mode 100644 src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/audit/AuditEntity.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java create mode 100644 src/main/resources/db/migration/V004__create_audit_log.sql create mode 100644 src/test/java/net/siegeln/cameleer/saas/audit/AuditRepositoryTest.java create mode 100644 src/test/java/net/siegeln/cameleer/saas/audit/AuditServiceTest.java diff --git a/src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java b/src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java new file mode 100644 index 0000000..2281748 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java @@ -0,0 +1,10 @@ +package net.siegeln.cameleer.saas.audit; + +public enum AuditAction { + AUTH_REGISTER, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT, + TENANT_CREATE, TENANT_UPDATE, TENANT_SUSPEND, TENANT_REACTIVATE, TENANT_DELETE, + APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE, + SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE, + CONFIG_UPDATE, + TEAM_INVITE, TEAM_REMOVE, TEAM_ROLE_CHANGE +} diff --git a/src/main/java/net/siegeln/cameleer/saas/audit/AuditEntity.java b/src/main/java/net/siegeln/cameleer/saas/audit/AuditEntity.java new file mode 100644 index 0000000..9826160 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/audit/AuditEntity.java @@ -0,0 +1,144 @@ +package net.siegeln.cameleer.saas.audit; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Entity +@Table(name = "audit_log") +public class AuditEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "actor_id") + private UUID actorId; + + @Column(name = "actor_email") + private String actorEmail; + + @Column(name = "tenant_id") + private UUID tenantId; + + @Column(name = "action", nullable = false, length = 100) + private String action; + + @Column(name = "resource", length = 500) + private String resource; + + @Column(name = "environment", length = 50) + private String environment; + + @Column(name = "source_ip", length = 45) + private String sourceIp; + + @Column(name = "result", nullable = false, length = 20) + private String result = "SUCCESS"; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", columnDefinition = "jsonb") + private Map metadata; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + // Getters and setters + + public UUID getId() { + return id; + } + + public UUID getActorId() { + return actorId; + } + + public void setActorId(UUID actorId) { + this.actorId = actorId; + } + + public String getActorEmail() { + return actorEmail; + } + + public void setActorEmail(String actorEmail) { + this.actorEmail = actorEmail; + } + + public UUID getTenantId() { + return tenantId; + } + + public void setTenantId(UUID tenantId) { + this.tenantId = tenantId; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getResource() { + return resource; + } + + public void setResource(String resource) { + this.resource = resource; + } + + public String getEnvironment() { + return environment; + } + + public void setEnvironment(String environment) { + this.environment = environment; + } + + public String getSourceIp() { + return sourceIp; + } + + public void setSourceIp(String sourceIp) { + this.sourceIp = sourceIp; + } + + public String getResult() { + return result; + } + + public void setResult(String result) { + this.result = result; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java b/src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java new file mode 100644 index 0000000..1764798 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java @@ -0,0 +1,16 @@ +package net.siegeln.cameleer.saas.audit; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Repository +public interface AuditRepository extends JpaRepository { + + List findByTenantIdAndCreatedAtBetween(UUID tenantId, Instant from, Instant to); + + List findByActorId(UUID actorId); +} diff --git a/src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java b/src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java new file mode 100644 index 0000000..6d6ea90 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java @@ -0,0 +1,33 @@ +package net.siegeln.cameleer.saas.audit; + +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.UUID; + +@Service +public class AuditService { + + private final AuditRepository auditRepository; + + public AuditService(AuditRepository auditRepository) { + this.auditRepository = auditRepository; + } + + public void log(UUID actorId, String actorEmail, UUID tenantId, + AuditAction action, String resource, + String environment, String sourceIp, + String result, Map metadata) { + var entry = new AuditEntity(); + entry.setActorId(actorId); + entry.setActorEmail(actorEmail); + entry.setTenantId(tenantId); + entry.setAction(action.name()); + entry.setResource(resource); + entry.setEnvironment(environment); + entry.setSourceIp(sourceIp); + entry.setResult(result != null ? result : "SUCCESS"); + entry.setMetadata(metadata); + auditRepository.save(entry); + } +} diff --git a/src/main/resources/db/migration/V004__create_audit_log.sql b/src/main/resources/db/migration/V004__create_audit_log.sql new file mode 100644 index 0000000..1ef015a --- /dev/null +++ b/src/main/resources/db/migration/V004__create_audit_log.sql @@ -0,0 +1,19 @@ +CREATE TABLE audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_id UUID, + actor_email VARCHAR(255), + tenant_id UUID, + action VARCHAR(100) NOT NULL, + resource VARCHAR(500), + environment VARCHAR(50), + source_ip VARCHAR(45), + result VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC); +CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC); +CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC); + +COMMENT ON TABLE audit_log IS 'Immutable audit trail. No UPDATE or DELETE allowed.'; diff --git a/src/test/java/net/siegeln/cameleer/saas/audit/AuditRepositoryTest.java b/src/test/java/net/siegeln/cameleer/saas/audit/AuditRepositoryTest.java new file mode 100644 index 0000000..ce23da3 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/audit/AuditRepositoryTest.java @@ -0,0 +1,84 @@ +package net.siegeln.cameleer.saas.audit; + +import net.siegeln.cameleer.saas.TestcontainersConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@DataJpaTest +@Import(TestcontainersConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +class AuditRepositoryTest { + + @Autowired + private AuditRepository auditRepository; + + @Test + void save_persistsAuditEntry() { + var entry = new AuditEntity(); + entry.setActorId(UUID.randomUUID()); + entry.setActorEmail("test@example.com"); + entry.setAction("AUTH_LOGIN"); + entry.setResult("SUCCESS"); + + var saved = auditRepository.save(entry); + + assertNotNull(saved.getId()); + assertNotNull(saved.getCreatedAt()); + assertEquals("AUTH_LOGIN", saved.getAction()); + } + + @Test + void findByTenantId_returnsFilteredResults() { + UUID tenantA = UUID.randomUUID(); + UUID tenantB = UUID.randomUUID(); + + var entryA = new AuditEntity(); + entryA.setTenantId(tenantA); + entryA.setAction("AUTH_LOGIN"); + entryA.setResult("SUCCESS"); + auditRepository.save(entryA); + + var entryB = new AuditEntity(); + entryB.setTenantId(tenantB); + entryB.setAction("AUTH_LOGIN"); + entryB.setResult("SUCCESS"); + auditRepository.save(entryB); + + Instant from = Instant.now().minus(1, ChronoUnit.HOURS); + Instant to = Instant.now().plus(1, ChronoUnit.HOURS); + + var results = auditRepository.findByTenantIdAndCreatedAtBetween(tenantA, from, to); + + assertEquals(1, results.size()); + assertEquals(tenantA, results.get(0).getTenantId()); + } + + @Test + void save_persistsJsonMetadata() { + var entry = new AuditEntity(); + entry.setAction("CONFIG_UPDATE"); + entry.setResult("SUCCESS"); + entry.setMetadata(Map.of("setting", "timeout", "oldValue", "30", "newValue", "60")); + + var saved = auditRepository.save(entry); + + var found = auditRepository.findById(saved.getId()).orElseThrow(); + assertNotNull(found.getMetadata()); + assertFalse(found.getMetadata().isEmpty()); + assertEquals("timeout", found.getMetadata().get("setting")); + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/audit/AuditServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/audit/AuditServiceTest.java new file mode 100644 index 0000000..1f67568 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/audit/AuditServiceTest.java @@ -0,0 +1,80 @@ +package net.siegeln.cameleer.saas.audit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AuditServiceTest { + + @Mock + private AuditRepository auditRepository; + + private AuditService auditService; + + @BeforeEach + void setUp() { + auditService = new AuditService(auditRepository); + } + + @Test + void log_createsAuditEntryWithAllFields() { + UUID actorId = UUID.randomUUID(); + UUID tenantId = UUID.randomUUID(); + String actorEmail = "user@example.com"; + String resource = "/api/tenants/123"; + String environment = "production"; + String sourceIp = "192.168.1.1"; + String result = "SUCCESS"; + Map metadata = Map.of("key", "value"); + + auditService.log(actorId, actorEmail, tenantId, + AuditAction.TENANT_CREATE, resource, + environment, sourceIp, result, metadata); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditEntity.class); + verify(auditRepository).save(captor.capture()); + + AuditEntity saved = captor.getValue(); + assertEquals(actorId, saved.getActorId()); + assertEquals(actorEmail, saved.getActorEmail()); + assertEquals(tenantId, saved.getTenantId()); + assertEquals("TENANT_CREATE", saved.getAction()); + assertEquals(resource, saved.getResource()); + assertEquals(environment, saved.getEnvironment()); + assertEquals(sourceIp, saved.getSourceIp()); + assertEquals("SUCCESS", saved.getResult()); + assertEquals(metadata, saved.getMetadata()); + } + + @Test + void log_worksWithNullOptionalFields() { + auditService.log(null, null, null, + AuditAction.AUTH_LOGIN, null, + null, null, null, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditEntity.class); + verify(auditRepository).save(captor.capture()); + + AuditEntity saved = captor.getValue(); + assertNull(saved.getActorId()); + assertNull(saved.getActorEmail()); + assertNull(saved.getTenantId()); + assertEquals("AUTH_LOGIN", saved.getAction()); + assertNull(saved.getResource()); + assertNull(saved.getEnvironment()); + assertNull(saved.getSourceIp()); + assertEquals("SUCCESS", saved.getResult()); // defaults to SUCCESS when null + assertNull(saved.getMetadata()); + } +}