diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AuditLogController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AuditLogController.java new file mode 100644 index 00000000..87663c6a --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AuditLogController.java @@ -0,0 +1,68 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.AuditLogPageResponse; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditRepository; +import com.cameleer3.server.core.admin.AuditRepository.AuditPage; +import com.cameleer3.server.core.admin.AuditRepository.AuditQuery; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; + +@RestController +@RequestMapping("/api/v1/admin/audit") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Audit Log", description = "Audit log viewer (ADMIN only)") +public class AuditLogController { + + private final AuditRepository auditRepository; + + public AuditLogController(AuditRepository auditRepository) { + this.auditRepository = auditRepository; + } + + @GetMapping + @Operation(summary = "Search audit log entries with pagination") + public ResponseEntity getAuditLog( + @RequestParam(required = false) String username, + @RequestParam(required = false) String category, + @RequestParam(required = false) String search, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(defaultValue = "timestamp") String sort, + @RequestParam(defaultValue = "desc") String order, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "25") int size) { + + size = Math.min(size, 100); + + Instant fromInstant = from != null ? from.atStartOfDay(ZoneOffset.UTC).toInstant() : null; + Instant toInstant = to != null ? to.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() : null; + + AuditCategory cat = null; + if (category != null && !category.isEmpty()) { + try { + cat = AuditCategory.valueOf(category.toUpperCase()); + } catch (IllegalArgumentException ignored) { + // invalid category is treated as no filter + } + } + + AuditQuery query = new AuditQuery(username, cat, search, fromInstant, toInstant, sort, order, page, size); + AuditPage result = auditRepository.find(query); + + int totalPages = Math.max(1, (int) Math.ceil((double) result.totalCount() / size)); + return ResponseEntity.ok(new AuditLogPageResponse( + result.items(), result.totalCount(), page, size, totalPages)); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ThresholdAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ThresholdAdminController.java new file mode 100644 index 00000000..56b34cac --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ThresholdAdminController.java @@ -0,0 +1,62 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.ThresholdConfigRequest; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; +import com.cameleer3.server.core.admin.ThresholdConfig; +import com.cameleer3.server.core.admin.ThresholdRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/admin/thresholds") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Threshold Admin", description = "Monitoring threshold configuration (ADMIN only)") +public class ThresholdAdminController { + + private final ThresholdRepository thresholdRepository; + private final AuditService auditService; + + public ThresholdAdminController(ThresholdRepository thresholdRepository, AuditService auditService) { + this.thresholdRepository = thresholdRepository; + this.auditService = auditService; + } + + @GetMapping + @Operation(summary = "Get current threshold configuration") + public ResponseEntity getThresholds() { + ThresholdConfig config = thresholdRepository.find().orElse(ThresholdConfig.defaults()); + return ResponseEntity.ok(config); + } + + @PutMapping + @Operation(summary = "Update threshold configuration") + public ResponseEntity updateThresholds(@Valid @RequestBody ThresholdConfigRequest request, + HttpServletRequest httpRequest) { + List errors = request.validate(); + if (!errors.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors)); + } + + ThresholdConfig config = request.toConfig(); + thresholdRepository.save(config, null); + auditService.log("update_thresholds", AuditCategory.CONFIG, "thresholds", + Map.of("config", config), AuditResult.SUCCESS, httpRequest); + return ResponseEntity.ok(config); + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AuditLogControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AuditLogControllerIT.java new file mode 100644 index 00000000..138bb192 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AuditLogControllerIT.java @@ -0,0 +1,112 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.AbstractPostgresIT; +import com.cameleer3.server.app.TestSecurityHelper; +import com.cameleer3.server.core.admin.AuditCategory; +import com.cameleer3.server.core.admin.AuditResult; +import com.cameleer3.server.core.admin.AuditService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuditLogControllerIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + @Autowired + private AuditService auditService; + + private String adminJwt; + private String viewerJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + viewerJwt = securityHelper.viewerToken(); + } + + @Test + void getAuditLog_asAdmin_returns200() throws Exception { + // Insert a test audit entry + auditService.log("test-admin", "test_action", AuditCategory.CONFIG, + "test-target", Map.of("key", "value"), AuditResult.SUCCESS, null); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("items")).isTrue(); + assertThat(body.has("totalCount")).isTrue(); + assertThat(body.get("totalCount").asLong()).isGreaterThanOrEqualTo(1); + } + + @Test + void getAuditLog_asViewer_returns403() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void getAuditLog_withCategoryFilter_returnsFilteredResults() throws Exception { + auditService.log("filter-test", "infra_action", AuditCategory.INFRA, + "infra-target", null, AuditResult.SUCCESS, null); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit?category=INFRA", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("items").isArray()).isTrue(); + } + + @Test + void getAuditLog_withPagination_respectsPageSize() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit?page=0&size=5", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("pageSize").asInt()).isEqualTo(5); + } + + @Test + void getAuditLog_maxPageSizeEnforced() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/audit?size=500", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.get("pageSize").asInt()).isEqualTo(100); + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java new file mode 100644 index 00000000..11c4aef7 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ThresholdAdminControllerIT.java @@ -0,0 +1,125 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.AbstractPostgresIT; +import com.cameleer3.server.app.TestSecurityHelper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +class ThresholdAdminControllerIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + private String adminJwt; + private String viewerJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + viewerJwt = securityHelper.viewerToken(); + } + + @Test + void getThresholds_asAdmin_returnsDefaults() throws Exception { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/thresholds", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("database")).isTrue(); + assertThat(body.has("opensearch")).isTrue(); + assertThat(body.path("database").path("connectionPoolWarning").asInt()).isEqualTo(80); + } + + @Test + void getThresholds_asViewer_returns403() { + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/thresholds", HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void updateThresholds_asAdmin_returns200() throws Exception { + String json = """ + { + "database": { + "connectionPoolWarning": 70, + "connectionPoolCritical": 90, + "queryDurationWarning": 2.0, + "queryDurationCritical": 15.0 + }, + "opensearch": { + "clusterHealthWarning": "YELLOW", + "clusterHealthCritical": "RED", + "queueDepthWarning": 200, + "queueDepthCritical": 1000, + "jvmHeapWarning": 80, + "jvmHeapCritical": 95, + "failedDocsWarning": 5, + "failedDocsCritical": 20 + } + } + """; + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/thresholds", HttpMethod.PUT, + new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.path("database").path("connectionPoolWarning").asInt()).isEqualTo(70); + } + + @Test + void updateThresholds_invalidWarningGreaterThanCritical_returns400() { + String json = """ + { + "database": { + "connectionPoolWarning": 95, + "connectionPoolCritical": 80, + "queryDurationWarning": 2.0, + "queryDurationCritical": 15.0 + }, + "opensearch": { + "clusterHealthWarning": "YELLOW", + "clusterHealthCritical": "RED", + "queueDepthWarning": 100, + "queueDepthCritical": 500, + "jvmHeapWarning": 75, + "jvmHeapCritical": 90, + "failedDocsWarning": 1, + "failedDocsCritical": 10 + } + } + """; + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/thresholds", HttpMethod.PUT, + new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +}