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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<AlertSilenceResponse> list(@EnvPath Environment env) {
|
||||||
|
return silenceRepo.listByEnvironment(env.id())
|
||||||
|
.stream().map(AlertSilenceResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
|
||||||
|
public ResponseEntity<AlertSilenceResponse> 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<Void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user