diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java new file mode 100644 index 00000000..73477466 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java @@ -0,0 +1,369 @@ +package com.cameleer.server.app.alerting.controller; + +import com.cameleer.server.app.alerting.dto.AlertRuleRequest; +import com.cameleer.server.app.alerting.dto.AlertRuleResponse; +import com.cameleer.server.app.alerting.dto.RenderPreviewRequest; +import com.cameleer.server.app.alerting.dto.RenderPreviewResponse; +import com.cameleer.server.app.alerting.dto.TestEvaluateRequest; +import com.cameleer.server.app.alerting.dto.TestEvaluateResponse; +import com.cameleer.server.app.alerting.dto.WebhookBindingRequest; +import com.cameleer.server.app.alerting.eval.ConditionEvaluator; +import com.cameleer.server.app.alerting.eval.EvalContext; +import com.cameleer.server.app.alerting.eval.EvalResult; +import com.cameleer.server.app.alerting.eval.TickCache; +import com.cameleer.server.app.alerting.notify.MustacheRenderer; +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.AlertCondition; +import com.cameleer.server.core.alerting.AlertRule; +import com.cameleer.server.core.alerting.AlertRuleRepository; +import com.cameleer.server.core.alerting.AlertRuleTarget; +import com.cameleer.server.core.alerting.ConditionKind; +import com.cameleer.server.core.alerting.ExchangeMatchCondition; +import com.cameleer.server.core.alerting.WebhookBinding; +import com.cameleer.server.core.outbound.OutboundConnection; +import com.cameleer.server.core.outbound.OutboundConnectionService; +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.beans.factory.annotation.Value; +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.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; + +/** + * REST controller for alert rules (env-scoped). + *

+ * CRITICAL: {@link ExchangeMatchCondition#filter()} attribute KEYS are inlined into ClickHouse SQL. + * They are validated here at save time to match {@code ^[a-zA-Z0-9._-]+$} before any SQL is built. + */ +@RestController +@RequestMapping("/api/v1/environments/{envSlug}/alerts/rules") +@Tag(name = "Alert Rules", description = "Alert rule management (env-scoped)") +@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')") +public class AlertRuleController { + + /** + * Attribute KEY allowlist. Keys are inlined into ClickHouse SQL via + * {@code JSONExtractString(attributes, '')}, so this pattern is a hard security gate. + * Values are always parameter-bound and safe. + */ + private static final Pattern ATTR_KEY = Pattern.compile("^[a-zA-Z0-9._-]+$"); + + private final AlertRuleRepository ruleRepo; + private final OutboundConnectionService connectionService; + private final AuditService auditService; + private final MustacheRenderer renderer; + private final Map> evaluators; + private final Clock clock; + private final String tenantId; + + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + public AlertRuleController(AlertRuleRepository ruleRepo, + OutboundConnectionService connectionService, + AuditService auditService, + MustacheRenderer renderer, + List> evaluatorList, + Clock alertingClock, + @Value("${cameleer.server.tenant.id:default}") String tenantId) { + this.ruleRepo = ruleRepo; + this.connectionService = connectionService; + this.auditService = auditService; + this.renderer = renderer; + this.evaluators = new java.util.EnumMap<>(ConditionKind.class); + for (ConditionEvaluator e : evaluatorList) { + this.evaluators.put(e.kind(), e); + } + this.clock = alertingClock; + this.tenantId = tenantId; + } + + // ------------------------------------------------------------------------- + // List / Get + // ------------------------------------------------------------------------- + + @GetMapping + public List list(@EnvPath Environment env) { + return ruleRepo.listByEnvironment(env.id()) + .stream().map(AlertRuleResponse::from).toList(); + } + + @GetMapping("/{id}") + public AlertRuleResponse get(@EnvPath Environment env, @PathVariable UUID id) { + AlertRule rule = requireRule(id, env.id()); + return AlertRuleResponse.from(rule); + } + + // ------------------------------------------------------------------------- + // Create / Update / Delete + // ------------------------------------------------------------------------- + + @PostMapping + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public ResponseEntity create( + @EnvPath Environment env, + @Valid @RequestBody AlertRuleRequest req, + HttpServletRequest httpRequest) { + + validateAttributeKeys(req.condition()); + validateWebhooks(req.webhooks(), env.id()); + + AlertRule draft = buildRule(null, env.id(), req, currentUserId()); + AlertRule saved = ruleRepo.save(draft); + + auditService.log("ALERT_RULE_CREATE", AuditCategory.ALERT_RULE_CHANGE, + saved.id().toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest); + + return ResponseEntity.status(HttpStatus.CREATED).body(AlertRuleResponse.from(saved)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public AlertRuleResponse update( + @EnvPath Environment env, + @PathVariable UUID id, + @Valid @RequestBody AlertRuleRequest req, + HttpServletRequest httpRequest) { + + AlertRule existing = requireRule(id, env.id()); + validateAttributeKeys(req.condition()); + validateWebhooks(req.webhooks(), env.id()); + + AlertRule updated = buildRule(existing, env.id(), req, currentUserId()); + AlertRule saved = ruleRepo.save(updated); + + auditService.log("ALERT_RULE_UPDATE", AuditCategory.ALERT_RULE_CHANGE, + id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest); + + return AlertRuleResponse.from(saved); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public ResponseEntity delete( + @EnvPath Environment env, + @PathVariable UUID id, + HttpServletRequest httpRequest) { + + requireRule(id, env.id()); + ruleRepo.delete(id); + + auditService.log("ALERT_RULE_DELETE", AuditCategory.ALERT_RULE_CHANGE, + id.toString(), Map.of(), AuditResult.SUCCESS, httpRequest); + + return ResponseEntity.noContent().build(); + } + + // ------------------------------------------------------------------------- + // Enable / Disable + // ------------------------------------------------------------------------- + + @PostMapping("/{id}/enable") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public AlertRuleResponse enable( + @EnvPath Environment env, + @PathVariable UUID id, + HttpServletRequest httpRequest) { + + AlertRule rule = requireRule(id, env.id()); + AlertRule updated = withEnabled(rule, true); + AlertRule saved = ruleRepo.save(updated); + + auditService.log("ALERT_RULE_ENABLE", AuditCategory.ALERT_RULE_CHANGE, + id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest); + + return AlertRuleResponse.from(saved); + } + + @PostMapping("/{id}/disable") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public AlertRuleResponse disable( + @EnvPath Environment env, + @PathVariable UUID id, + HttpServletRequest httpRequest) { + + AlertRule rule = requireRule(id, env.id()); + AlertRule updated = withEnabled(rule, false); + AlertRule saved = ruleRepo.save(updated); + + auditService.log("ALERT_RULE_DISABLE", AuditCategory.ALERT_RULE_CHANGE, + id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest); + + return AlertRuleResponse.from(saved); + } + + // ------------------------------------------------------------------------- + // Render Preview + // ------------------------------------------------------------------------- + + @PostMapping("/{id}/render-preview") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public RenderPreviewResponse renderPreview( + @EnvPath Environment env, + @PathVariable UUID id, + @RequestBody RenderPreviewRequest req) { + + AlertRule rule = requireRule(id, env.id()); + Map ctx = req.context(); + String title = renderer.render(rule.notificationTitleTmpl(), ctx); + String message = renderer.render(rule.notificationMessageTmpl(), ctx); + return new RenderPreviewResponse(title, message); + } + + // ------------------------------------------------------------------------- + // Test Evaluate + // ------------------------------------------------------------------------- + + @PostMapping("/{id}/test-evaluate") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + @SuppressWarnings({"rawtypes", "unchecked"}) + public TestEvaluateResponse testEvaluate( + @EnvPath Environment env, + @PathVariable UUID id, + @RequestBody TestEvaluateRequest req) { + + AlertRule rule = requireRule(id, env.id()); + ConditionEvaluator evaluator = evaluators.get(rule.conditionKind()); + if (evaluator == null) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "No evaluator registered for condition kind: " + rule.conditionKind()); + } + + EvalContext ctx = new EvalContext(tenantId, Instant.now(clock), new TickCache()); + EvalResult result = evaluator.evaluate(rule.condition(), rule, ctx); + return TestEvaluateResponse.from(result); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Validates that all attribute keys in an {@link ExchangeMatchCondition} match + * {@code ^[a-zA-Z0-9._-]+$}. Keys are inlined into ClickHouse SQL, making this + * a mandatory SQL-injection prevention gate. + */ + private void validateAttributeKeys(AlertCondition condition) { + if (condition instanceof ExchangeMatchCondition emc && emc.filter() != null) { + for (String key : emc.filter().attributes().keySet()) { + if (!ATTR_KEY.matcher(key).matches()) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "Invalid attribute key (must match [a-zA-Z0-9._-]+): " + key); + } + } + } + } + + /** + * Validates that each webhook outboundConnectionId exists and is allowed in this environment. + */ + private void validateWebhooks(List webhooks, UUID envId) { + for (WebhookBindingRequest wb : webhooks) { + OutboundConnection conn; + try { + conn = connectionService.get(wb.outboundConnectionId()); + } catch (org.springframework.web.server.ResponseStatusException ex) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "outboundConnectionId not found: " + wb.outboundConnectionId()); + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "outboundConnectionId not found: " + wb.outboundConnectionId()); + } + if (!conn.isAllowedInEnvironment(envId)) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "outboundConnection " + wb.outboundConnectionId() + + " is not allowed in this environment"); + } + } + } + + private AlertRule requireRule(UUID id, UUID envId) { + AlertRule rule = ruleRepo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Alert rule not found: " + id)); + if (!rule.environmentId().equals(envId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Alert rule not found in this environment: " + id); + } + return rule; + } + + private AlertRule buildRule(AlertRule existing, UUID envId, AlertRuleRequest req, String userId) { + UUID id = existing != null ? existing.id() : UUID.randomUUID(); + Instant now = Instant.now(clock); + Instant createdAt = existing != null ? existing.createdAt() : now; + String createdBy = existing != null ? existing.createdBy() : userId; + boolean enabled = existing != null ? existing.enabled() : true; + + List webhooks = req.webhooks().stream() + .map(wb -> new WebhookBinding( + UUID.randomUUID(), + wb.outboundConnectionId(), + wb.bodyOverride(), + wb.headerOverrides())) + .toList(); + + List targets = req.targets() == null ? List.of() : req.targets(); + + int evalInterval = req.evaluationIntervalSeconds() != null + ? req.evaluationIntervalSeconds() : 60; + int forDuration = req.forDurationSeconds() != null + ? req.forDurationSeconds() : 0; + int reNotify = req.reNotifyMinutes() != null + ? req.reNotifyMinutes() : 0; + + String titleTmpl = req.notificationTitleTmpl() != null ? req.notificationTitleTmpl() : ""; + String messageTmpl = req.notificationMessageTmpl() != null ? req.notificationMessageTmpl() : ""; + + return new AlertRule( + id, envId, req.name(), req.description(), + req.severity(), enabled, + req.conditionKind(), req.condition(), + evalInterval, forDuration, reNotify, + titleTmpl, messageTmpl, + webhooks, targets, + now, null, null, Map.of(), + createdAt, createdBy, now, userId); + } + + private AlertRule withEnabled(AlertRule r, boolean enabled) { + Instant now = Instant.now(clock); + return new AlertRule( + r.id(), r.environmentId(), r.name(), r.description(), + r.severity(), enabled, r.conditionKind(), r.condition(), + r.evaluationIntervalSeconds(), r.forDurationSeconds(), r.reNotifyMinutes(), + r.notificationTitleTmpl(), r.notificationMessageTmpl(), + r.webhooks(), r.targets(), + r.nextEvaluationAt(), r.claimedBy(), r.claimedUntil(), r.evalState(), + r.createdAt(), r.createdBy(), now, currentUserId()); + } + + 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/AlertRuleRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertRuleRequest.java new file mode 100644 index 00000000..c5a4d1fb --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertRuleRequest.java @@ -0,0 +1,32 @@ +package com.cameleer.server.app.alerting.dto; + +import com.cameleer.server.core.alerting.AlertCondition; +import com.cameleer.server.core.alerting.AlertRuleTarget; +import com.cameleer.server.core.alerting.AlertSeverity; +import com.cameleer.server.core.alerting.ConditionKind; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.List; +import java.util.UUID; + +public record AlertRuleRequest( + @NotBlank String name, + String description, + @NotNull AlertSeverity severity, + @NotNull ConditionKind conditionKind, + @NotNull @Valid AlertCondition condition, + Integer evaluationIntervalSeconds, + Integer forDurationSeconds, + Integer reNotifyMinutes, + String notificationTitleTmpl, + String notificationMessageTmpl, + List webhooks, + List targets +) { + public AlertRuleRequest { + webhooks = webhooks == null ? List.of() : List.copyOf(webhooks); + targets = targets == null ? List.of() : List.copyOf(targets); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertRuleResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertRuleResponse.java new file mode 100644 index 00000000..8abc8c74 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertRuleResponse.java @@ -0,0 +1,46 @@ +package com.cameleer.server.app.alerting.dto; + +import com.cameleer.server.core.alerting.AlertCondition; +import com.cameleer.server.core.alerting.AlertRule; +import com.cameleer.server.core.alerting.AlertRuleTarget; +import com.cameleer.server.core.alerting.AlertSeverity; +import com.cameleer.server.core.alerting.ConditionKind; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +public record AlertRuleResponse( + UUID id, + UUID environmentId, + String name, + String description, + AlertSeverity severity, + boolean enabled, + ConditionKind conditionKind, + AlertCondition condition, + int evaluationIntervalSeconds, + int forDurationSeconds, + int reNotifyMinutes, + String notificationTitleTmpl, + String notificationMessageTmpl, + List webhooks, + List targets, + Instant createdAt, + String createdBy, + Instant updatedAt, + String updatedBy +) { + public static AlertRuleResponse from(AlertRule r) { + List webhooks = r.webhooks().stream() + .map(WebhookBindingResponse::from) + .toList(); + return new AlertRuleResponse( + r.id(), r.environmentId(), r.name(), r.description(), + r.severity(), r.enabled(), r.conditionKind(), r.condition(), + r.evaluationIntervalSeconds(), r.forDurationSeconds(), r.reNotifyMinutes(), + r.notificationTitleTmpl(), r.notificationMessageTmpl(), + webhooks, r.targets(), + r.createdAt(), r.createdBy(), r.updatedAt(), r.updatedBy()); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/RenderPreviewRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/RenderPreviewRequest.java new file mode 100644 index 00000000..aa08dc07 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/RenderPreviewRequest.java @@ -0,0 +1,13 @@ +package com.cameleer.server.app.alerting.dto; + +import java.util.Map; + +/** + * Canned context for rendering a Mustache template preview without firing a real alert. + * All fields are optional — missing context keys render as empty string. + */ +public record RenderPreviewRequest(Map context) { + public RenderPreviewRequest { + context = context == null ? Map.of() : Map.copyOf(context); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/RenderPreviewResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/RenderPreviewResponse.java new file mode 100644 index 00000000..653b879a --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/RenderPreviewResponse.java @@ -0,0 +1,3 @@ +package com.cameleer.server.app.alerting.dto; + +public record RenderPreviewResponse(String title, String message) {} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/TestEvaluateRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/TestEvaluateRequest.java new file mode 100644 index 00000000..48685891 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/TestEvaluateRequest.java @@ -0,0 +1,8 @@ +package com.cameleer.server.app.alerting.dto; + +/** + * Request body for POST {id}/test-evaluate. + * Currently empty — the evaluator runs against live data using the saved rule definition. + * Reserved for future overrides (e.g., custom time window). + */ +public record TestEvaluateRequest() {} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/TestEvaluateResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/TestEvaluateResponse.java new file mode 100644 index 00000000..45ce610c --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/TestEvaluateResponse.java @@ -0,0 +1,24 @@ +package com.cameleer.server.app.alerting.dto; + +import com.cameleer.server.app.alerting.eval.EvalResult; + +/** + * Result of a one-shot evaluator run against live data (does not persist any state). + */ +public record TestEvaluateResponse(String resultKind, String detail) { + + public static TestEvaluateResponse from(EvalResult result) { + if (result instanceof EvalResult.Firing f) { + return new TestEvaluateResponse("FIRING", + "currentValue=" + f.currentValue() + " threshold=" + f.threshold()); + } else if (result instanceof EvalResult.Clear) { + return new TestEvaluateResponse("CLEAR", null); + } else if (result instanceof EvalResult.Error e) { + return new TestEvaluateResponse("ERROR", + e.cause() != null ? e.cause().getMessage() : "unknown error"); + } else if (result instanceof EvalResult.Batch b) { + return new TestEvaluateResponse("BATCH", b.firings().size() + " firing(s)"); + } + return new TestEvaluateResponse("UNKNOWN", result.getClass().getSimpleName()); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/WebhookBindingRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/WebhookBindingRequest.java new file mode 100644 index 00000000..d83944a2 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/WebhookBindingRequest.java @@ -0,0 +1,16 @@ +package com.cameleer.server.app.alerting.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.Map; +import java.util.UUID; + +public record WebhookBindingRequest( + @NotNull UUID outboundConnectionId, + String bodyOverride, + Map headerOverrides +) { + public WebhookBindingRequest { + headerOverrides = headerOverrides == null ? Map.of() : Map.copyOf(headerOverrides); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/WebhookBindingResponse.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/WebhookBindingResponse.java new file mode 100644 index 00000000..6e4f203f --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/WebhookBindingResponse.java @@ -0,0 +1,18 @@ +package com.cameleer.server.app.alerting.dto; + +import com.cameleer.server.core.alerting.WebhookBinding; + +import java.util.Map; +import java.util.UUID; + +public record WebhookBindingResponse( + UUID id, + UUID outboundConnectionId, + String bodyOverride, + Map headerOverrides +) { + public static WebhookBindingResponse from(WebhookBinding wb) { + return new WebhookBindingResponse( + wb.id(), wb.outboundConnectionId(), wb.bodyOverride(), wb.headerOverrides()); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java index ce550495..4e2858a0 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/search/ClickHouseSearchIndex.java @@ -354,7 +354,9 @@ public class ClickHouseSearchIndex implements SearchIndex { // attributes is a JSON String column. JSONExtractString does not accept a ? placeholder for // the key argument via ClickHouse JDBC — inline the key as a single-quoted literal. - // Keys originate from internal AlertMatchSpec (evaluator-constructed, not user HTTP input). + // Attribute KEYS originate from user-authored rule JSONB (via ExchangeMatchCondition.filter.attributes); + // they are validated at rule save time by AlertRuleController to match ^[a-zA-Z0-9._-]+$ + // before ever reaching this point. Values are parameter-bound. for (Map.Entry entry : spec.attributes().entrySet()) { String escapedKey = entry.getKey().replace("'", "\\'"); conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?"); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java new file mode 100644 index 00000000..310763f7 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertRuleControllerIT.java @@ -0,0 +1,280 @@ +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.cameleer.server.core.admin.AuditRepository; +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.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlertRuleControllerIT extends AbstractPostgresIT { + + // ExchangeMatchEvaluator and LogPatternEvaluator depend on these concrete beans + // (not the SearchIndex/LogIndex interfaces). Mock them so the context wires up. + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + @Autowired private AuditRepository auditRepository; + + 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"); + + // Create a test environment + envSlug = "test-env-" + UUID.randomUUID().toString().substring(0, 8); + 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_rules 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')"); + } + + // --- Happy path: POST creates rule, returns 201 --- + + @Test + void operatorCanCreateRule() throws Exception { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(routeMetricRuleBody("test-rule"), securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode body = objectMapper.readTree(resp.getBody()); + assertThat(body.path("name").asText()).isEqualTo("test-rule"); + assertThat(body.path("id").asText()).isNotBlank(); + assertThat(body.path("enabled").asBoolean()).isTrue(); + assertThat(body.path("severity").asText()).isEqualTo("WARNING"); + } + + @Test + void operatorCanListRules() { + // Create a rule first + restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(routeMetricRuleBody("list-test"), securityHelper.authHeaders(operatorJwt)), + String.class); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void viewerCanList() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + 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/rules", + HttpMethod.POST, + new HttpEntity<>(routeMetricRuleBody("viewer-rule"), securityHelper.authHeaders(viewerJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + // --- Webhook validation --- + + @Test + void unknownOutboundConnectionIdReturns422() { + String body = """ + {"name":"bad-webhook","severity":"WARNING","conditionKind":"ROUTE_METRIC", + "condition":{"kind":"ROUTE_METRIC","scope":{}, + "metric":"ERROR_RATE","comparator":"GT","threshold":0.05,"windowSeconds":60}, + "webhooks":[{"outboundConnectionId":"%s"}]} + """.formatted(UUID.randomUUID()); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + } + + // --- Attribute key SQL injection prevention --- + + @Test + void attributeKeyWithSqlMetaReturns422() { + String body = """ + {"name":"sqli-test","severity":"WARNING","conditionKind":"EXCHANGE_MATCH", + "condition":{"kind":"EXCHANGE_MATCH","scope":{}, + "filter":{"status":"FAILED","attributes":{"foo'; DROP TABLE executions; --":"x"}}, + "fireMode":"PER_EXCHANGE","perExchangeLingerSeconds":60}} + """; + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(resp.getBody()).contains("Invalid attribute key"); + } + + @Test + void validAttributeKeyIsAccepted() throws Exception { + String body = """ + {"name":"valid-attr","severity":"WARNING","conditionKind":"EXCHANGE_MATCH", + "condition":{"kind":"EXCHANGE_MATCH","scope":{}, + "filter":{"status":"FAILED","attributes":{"order.type":"x"}}, + "fireMode":"PER_EXCHANGE","perExchangeLingerSeconds":60}} + """; + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + // --- Enable / Disable --- + + @Test + void enableAndDisable() throws Exception { + ResponseEntity create = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(routeMetricRuleBody("toggle-rule"), securityHelper.authHeaders(operatorJwt)), + String.class); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity disabled = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules/" + id + "/disable", + HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(operatorJwt)), + String.class); + assertThat(disabled.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(objectMapper.readTree(disabled.getBody()).path("enabled").asBoolean()).isFalse(); + + ResponseEntity enabled = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules/" + id + "/enable", + HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(operatorJwt)), + String.class); + assertThat(enabled.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(objectMapper.readTree(enabled.getBody()).path("enabled").asBoolean()).isTrue(); + } + + // --- Delete emits audit event --- + + @Test + void deleteEmitsAuditEvent() throws Exception { + ResponseEntity create = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(routeMetricRuleBody("audit-rule"), securityHelper.authHeaders(operatorJwt)), + String.class); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules/" + id, + HttpMethod.DELETE, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + + int count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM audit_log WHERE action = 'ALERT_RULE_DELETE' AND target = ?", + Integer.class, id); + assertThat(count).isGreaterThanOrEqualTo(1); + } + + // --- Render preview --- + + @Test + void renderPreview() throws Exception { + ResponseEntity create = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", + HttpMethod.POST, + new HttpEntity<>(routeMetricRuleBody("preview-rule"), securityHelper.authHeaders(operatorJwt)), + String.class); + String id = objectMapper.readTree(create.getBody()).path("id").asText(); + + ResponseEntity preview = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules/" + id + "/render-preview", + HttpMethod.POST, + new HttpEntity<>("{\"context\":{}}", securityHelper.authHeaders(operatorJwt)), + String.class); + assertThat(preview.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + // --- Unknown env returns 404 --- + + @Test + void unknownEnvReturns404() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/nonexistent-env-slug/alerts/rules", + HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + // ------------------------------------------------------------------------- + // 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 routeMetricRuleBody(String name) { + return """ + {"name":"%s","severity":"WARNING","conditionKind":"ROUTE_METRIC", + "condition":{"kind":"ROUTE_METRIC","scope":{}, + "metric":"ERROR_RATE","comparator":"GT","threshold":0.05,"windowSeconds":60}} + """.formatted(name); + } +}