feat: add audit logging framework with immutable append-only log
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
144
src/main/java/net/siegeln/cameleer/saas/audit/AuditEntity.java
Normal file
144
src/main/java/net/siegeln/cameleer/saas/audit/AuditEntity.java
Normal file
@@ -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<String, Object> 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<String, Object> getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public void setMetadata(Map<String, Object> metadata) {
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -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<AuditEntity, UUID> {
|
||||
|
||||
List<AuditEntity> findByTenantIdAndCreatedAtBetween(UUID tenantId, Instant from, Instant to);
|
||||
|
||||
List<AuditEntity> findByActorId(UUID actorId);
|
||||
}
|
||||
@@ -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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
19
src/main/resources/db/migration/V004__create_audit_log.sql
Normal file
19
src/main/resources/db/migration/V004__create_audit_log.sql
Normal file
@@ -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.';
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> metadata = Map.of("key", "value");
|
||||
|
||||
auditService.log(actorId, actorEmail, tenantId,
|
||||
AuditAction.TENANT_CREATE, resource,
|
||||
environment, sourceIp, result, metadata);
|
||||
|
||||
ArgumentCaptor<AuditEntity> 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<AuditEntity> 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user