From 77d17184516978e061866d460816780d7dddcb07 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:29:03 +0200 Subject: [PATCH] feat(alerting): AlertSilenceController CRUD with time-range validation + audit (Task 34) - POST/GET/DELETE /environments/{envSlug}/alerts/silences - 422 when endsAt <= startsAt ("endsAt must be after startsAt") - OPERATOR+ for create/delete, VIEWER+ for list - Audit: ALERT_SILENCE_CREATE/DELETE with AuditCategory.ALERT_SILENCE_CHANGE - 6 IT tests: create, viewer-list, viewer-cannot-create, bad time-range, delete, audit event Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/AlertSilenceController.java | 151 ++++++++++++++++ .../app/alerting/dto/AlertSilenceRequest.java | 14 ++ .../alerting/dto/AlertSilenceResponse.java | 24 +++ .../controller/AlertSilenceControllerIT.java | 167 ++++++++++++++++++ 4 files changed, 356 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertSilenceController.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertSilenceRequest.java create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertSilenceResponse.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertSilenceControllerIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertSilenceController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertSilenceController.java new file mode 100644 index 00000000..c15b1c89 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertSilenceController.java @@ -0,0 +1,151 @@ +package com.cameleer.server.app.alerting.controller; + +import com.cameleer.server.app.alerting.dto.AlertSilenceRequest; +import com.cameleer.server.app.alerting.dto.AlertSilenceResponse; +import com.cameleer.server.app.web.EnvPath; +import com.cameleer.server.core.admin.AuditCategory; +import com.cameleer.server.core.admin.AuditResult; +import com.cameleer.server.core.admin.AuditService; +import com.cameleer.server.core.alerting.AlertSilence; +import com.cameleer.server.core.alerting.AlertSilenceRepository; +import com.cameleer.server.core.runtime.Environment; +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.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * REST controller for alert silences (env-scoped). + * VIEWER+ can list; OPERATOR+ can create/update/delete. + */ +@RestController +@RequestMapping("/api/v1/environments/{envSlug}/alerts/silences") +@Tag(name = "Alert Silences", description = "Alert silence management (env-scoped)") +@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')") +public class AlertSilenceController { + + private final AlertSilenceRepository silenceRepo; + private final AuditService auditService; + + public AlertSilenceController(AlertSilenceRepository silenceRepo, + AuditService auditService) { + this.silenceRepo = silenceRepo; + this.auditService = auditService; + } + + @GetMapping + public List list(@EnvPath Environment env) { + return silenceRepo.listByEnvironment(env.id()) + .stream().map(AlertSilenceResponse::from).toList(); + } + + @PostMapping + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public ResponseEntity create( + @EnvPath Environment env, + @Valid @RequestBody AlertSilenceRequest req, + HttpServletRequest httpRequest) { + + validateTimeRange(req); + + AlertSilence silence = new AlertSilence( + UUID.randomUUID(), env.id(), req.matcher(), req.reason(), + req.startsAt(), req.endsAt(), + currentUserId(), Instant.now()); + + AlertSilence saved = silenceRepo.save(silence); + + auditService.log("ALERT_SILENCE_CREATE", AuditCategory.ALERT_SILENCE_CHANGE, + saved.id().toString(), Map.of(), AuditResult.SUCCESS, httpRequest); + + return ResponseEntity.status(HttpStatus.CREATED).body(AlertSilenceResponse.from(saved)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public AlertSilenceResponse update( + @EnvPath Environment env, + @PathVariable UUID id, + @Valid @RequestBody AlertSilenceRequest req, + HttpServletRequest httpRequest) { + + AlertSilence existing = requireSilence(id, env.id()); + validateTimeRange(req); + + AlertSilence updated = new AlertSilence( + existing.id(), env.id(), req.matcher(), req.reason(), + req.startsAt(), req.endsAt(), + existing.createdBy(), existing.createdAt()); + + AlertSilence saved = silenceRepo.save(updated); + + auditService.log("ALERT_SILENCE_UPDATE", AuditCategory.ALERT_SILENCE_CHANGE, + id.toString(), Map.of(), AuditResult.SUCCESS, httpRequest); + + return AlertSilenceResponse.from(saved); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public ResponseEntity delete( + @EnvPath Environment env, + @PathVariable UUID id, + HttpServletRequest httpRequest) { + + requireSilence(id, env.id()); + silenceRepo.delete(id); + + auditService.log("ALERT_SILENCE_DELETE", AuditCategory.ALERT_SILENCE_CHANGE, + id.toString(), Map.of(), AuditResult.SUCCESS, httpRequest); + + return ResponseEntity.noContent().build(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void validateTimeRange(AlertSilenceRequest req) { + if (!req.endsAt().isAfter(req.startsAt())) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "endsAt must be after startsAt"); + } + } + + private AlertSilence requireSilence(UUID id, UUID envId) { + AlertSilence silence = silenceRepo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Alert silence not found: " + id)); + if (!silence.environmentId().equals(envId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Alert silence not found in this environment: " + id); + } + return silence; + } + + private String currentUserId() { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || auth.getName() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authentication"); + } + String name = auth.getName(); + return name.startsWith("user:") ? name.substring(5) : name; + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertSilenceRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertSilenceRequest.java new file mode 100644 index 00000000..5e3fdcb4 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertSilenceRequest.java @@ -0,0 +1,14 @@ +package com.cameleer.server.app.alerting.dto; + +import com.cameleer.server.core.alerting.SilenceMatcher; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; + +public record AlertSilenceRequest( + @NotNull @Valid SilenceMatcher matcher, + String reason, + @NotNull Instant startsAt, + @NotNull Instant endsAt +) {} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertSilenceResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertSilenceResponse.java new file mode 100644 index 00000000..8a726b96 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertSilenceResponse.java @@ -0,0 +1,24 @@ +package com.cameleer.server.app.alerting.dto; + +import com.cameleer.server.core.alerting.AlertSilence; +import com.cameleer.server.core.alerting.SilenceMatcher; + +import java.time.Instant; +import java.util.UUID; + +public record AlertSilenceResponse( + UUID id, + UUID environmentId, + SilenceMatcher matcher, + String reason, + Instant startsAt, + Instant endsAt, + String createdBy, + Instant createdAt +) { + public static AlertSilenceResponse from(AlertSilence s) { + return new AlertSilenceResponse( + s.id(), s.environmentId(), s.matcher(), s.reason(), + s.startsAt(), s.endsAt(), s.createdBy(), s.createdAt()); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertSilenceControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertSilenceControllerIT.java new file mode 100644 index 00000000..d06a3df1 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertSilenceControllerIT.java @@ -0,0 +1,167 @@ +package com.cameleer.server.app.alerting.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.search.ClickHouseSearchIndex; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +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.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlertSilenceControllerIT extends AbstractPostgresIT { + + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + + private String operatorJwt; + private String viewerJwt; + private String envSlug; + private UUID envId; + + @BeforeEach + void setUp() { + operatorJwt = securityHelper.operatorToken(); + viewerJwt = securityHelper.viewerToken(); + seedUser("test-operator"); + seedUser("test-viewer"); + + envSlug = "silence-env-" + UUID.randomUUID().toString().substring(0, 6); + envId = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING", + envId, envSlug, envSlug); + } + + @AfterEach + void cleanUp() { + jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId); + jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId); + jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')"); + } + + @Test + void operatorCanCreate() throws Exception { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/silences", + HttpMethod.POST, + new HttpEntity<>(silenceBody(), securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode body = objectMapper.readTree(resp.getBody()); + assertThat(body.path("id").asText()).isNotBlank(); + assertThat(body.path("reason").asText()).isEqualTo("planned-maintenance"); + } + + @Test + void viewerCanList() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/silences", + HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void viewerCannotCreate() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/silences", + HttpMethod.POST, + new HttpEntity<>(silenceBody(), securityHelper.authHeaders(viewerJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void endsAtBeforeStartsAtReturns422() { + Instant now = Instant.now(); + String body = """ + {"matcher":{},"reason":"bad","startsAt":"%s","endsAt":"%s"} + """.formatted(now.plus(1, ChronoUnit.HOURS), now); // endsAt before startsAt + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/silences", + HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(resp.getBody()).contains("endsAt must be after startsAt"); + } + + @Test + void deleteRemovesSilence() throws Exception { + ResponseEntity create = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/silences", + HttpMethod.POST, + new HttpEntity<>(silenceBody(), securityHelper.authHeaders(operatorJwt)), + String.class); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity del = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/silences/" + id, + HttpMethod.DELETE, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + void deleteEmitsAuditEvent() throws Exception { + ResponseEntity create = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/silences", + HttpMethod.POST, + new HttpEntity<>(silenceBody(), securityHelper.authHeaders(operatorJwt)), + String.class); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/silences/" + id, + HttpMethod.DELETE, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + + int count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE action = 'ALERT_SILENCE_DELETE' AND target = ?", + Integer.class, id); + assertThat(count).isGreaterThanOrEqualTo(1); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private void seedUser(String userId) { + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING", + userId, userId + "@example.com", userId); + } + + private static String silenceBody() { + Instant start = Instant.now(); + Instant end = start.plus(2, ChronoUnit.HOURS); + return """ + {"matcher":{},"reason":"planned-maintenance","startsAt":"%s","endsAt":"%s"} + """.formatted(start, end); + } +}