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:
hsiegeln
2026-03-30 10:22:33 +02:00
parent d7cc3a3e04
commit cdd19e180e
7 changed files with 386 additions and 0 deletions

View File

@@ -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
}

View 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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View 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.';