From a0944a1c72e5974f2e6433041ba7d137e6f847ea Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:36:21 +0100 Subject: [PATCH] feat: add audit domain model, repository interface, AuditService, and unit test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/app/admin/AuditServiceTest.java | 49 +++++++++++++++++++ cameleer3-server-core/pom.xml | 10 ++++ .../server/core/admin/AuditCategory.java | 5 ++ .../server/core/admin/AuditRecord.java | 24 +++++++++ .../server/core/admin/AuditRepository.java | 25 ++++++++++ .../server/core/admin/AuditResult.java | 5 ++ .../server/core/admin/AuditService.java | 49 +++++++++++++++++++ 7 files changed, 167 insertions(+) create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/admin/AuditServiceTest.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRecord.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRepository.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditResult.java create mode 100644 cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditService.java diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/admin/AuditServiceTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/admin/AuditServiceTest.java new file mode 100644 index 00000000..abf75a0c --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/admin/AuditServiceTest.java @@ -0,0 +1,49 @@ +package com.cameleer3.server.app.admin; + +import com.cameleer3.server.core.admin.*; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AuditServiceTest { + private AuditRepository mockRepository; + private AuditService auditService; + + @BeforeEach + void setUp() { + mockRepository = mock(AuditRepository.class); + auditService = new AuditService(mockRepository); + } + + @Test + void log_withExplicitUsername_insertsRecordWithCorrectFields() { + var request = mock(HttpServletRequest.class); + when(request.getRemoteAddr()).thenReturn("192.168.1.1"); + when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0"); + + auditService.log("admin", "kill_query", AuditCategory.INFRA, "PID 42", + Map.of("query", "SELECT 1"), AuditResult.SUCCESS, request); + + var captor = ArgumentCaptor.forClass(AuditRecord.class); + verify(mockRepository).insert(captor.capture()); + var record = captor.getValue(); + assertEquals("admin", record.username()); + assertEquals("kill_query", record.action()); + assertEquals(AuditCategory.INFRA, record.category()); + assertEquals("PID 42", record.target()); + assertEquals("192.168.1.1", record.ipAddress()); + assertEquals("Mozilla/5.0", record.userAgent()); + } + + @Test + void log_withNullRequest_handlesGracefully() { + auditService.log("admin", "test", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, null); + verify(mockRepository).insert(any(AuditRecord.class)); + } +} diff --git a/cameleer3-server-core/pom.xml b/cameleer3-server-core/pom.xml index 544d4a93..5e2e517c 100644 --- a/cameleer3-server-core/pom.xml +++ b/cameleer3-server-core/pom.xml @@ -27,6 +27,16 @@ org.slf4j slf4j-api + + jakarta.servlet + jakarta.servlet-api + provided + + + org.springframework.security + spring-security-core + provided + org.junit.jupiter junit-jupiter diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java new file mode 100644 index 00000000..39854a79 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java @@ -0,0 +1,5 @@ +package com.cameleer3.server.core.admin; + +public enum AuditCategory { + INFRA, AUTH, USER_MGMT, CONFIG +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRecord.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRecord.java new file mode 100644 index 00000000..6e4f6f1b --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRecord.java @@ -0,0 +1,24 @@ +package com.cameleer3.server.core.admin; + +import java.time.Instant; +import java.util.Map; + +public record AuditRecord( + long id, + Instant timestamp, + String username, + String action, + AuditCategory category, + String target, + Map detail, + AuditResult result, + String ipAddress, + String userAgent +) { + /** Factory for creating new records (id and timestamp assigned by DB) */ + public static AuditRecord create(String username, String action, AuditCategory category, + String target, Map detail, AuditResult result, + String ipAddress, String userAgent) { + return new AuditRecord(0, null, username, action, category, target, detail, result, ipAddress, userAgent); + } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRepository.java new file mode 100644 index 00000000..a7bfdce0 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditRepository.java @@ -0,0 +1,25 @@ +package com.cameleer3.server.core.admin; + +import java.time.Instant; +import java.util.List; + +public interface AuditRepository { + + void insert(AuditRecord record); + + record AuditQuery( + String username, + AuditCategory category, + String search, + Instant from, + Instant to, + String sort, + String order, + int page, + int size + ) {} + + record AuditPage(List items, long totalCount) {} + + AuditPage find(AuditQuery query); +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditResult.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditResult.java new file mode 100644 index 00000000..d8be64ed --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditResult.java @@ -0,0 +1,5 @@ +package com.cameleer3.server.core.admin; + +public enum AuditResult { + SUCCESS, FAILURE +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditService.java new file mode 100644 index 00000000..5a5b3324 --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditService.java @@ -0,0 +1,49 @@ +package com.cameleer3.server.core.admin; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Map; + +public class AuditService { + private static final Logger log = LoggerFactory.getLogger(AuditService.class); + private final AuditRepository repository; + + public AuditService(AuditRepository repository) { + this.repository = repository; + } + + /** Log an action using the current SecurityContext for username */ + public void log(String action, AuditCategory category, String target, + Map detail, AuditResult result, + HttpServletRequest request) { + String username = extractUsername(); + log(username, action, category, target, detail, result, request); + } + + /** Log an action with explicit username (for pre-auth contexts like login) */ + public void log(String username, String action, AuditCategory category, String target, + Map detail, AuditResult result, + HttpServletRequest request) { + String ip = request != null ? request.getRemoteAddr() : null; + String userAgent = request != null ? request.getHeader("User-Agent") : null; + AuditRecord record = AuditRecord.create(username, action, category, target, detail, result, ip, userAgent); + + repository.insert(record); + + log.info("AUDIT: user={} action={} category={} target={} result={}", + username, action, category, target, result); + } + + private String extractUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getName() != null) { + String name = auth.getName(); + return name.startsWith("user:") ? name.substring(5) : name; + } + return "unknown"; + } +}