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";
+ }
+}