feat(alerting): AlertRuleController with attribute-key SQL injection validation (Task 32)
- POST/GET/PUT/DELETE /environments/{envSlug}/alerts/rules CRUD
- POST /{id}/enable, /{id}/disable, /{id}/render-preview, /{id}/test-evaluate
- Attribute-key validation: rejects keys not matching ^[a-zA-Z0-9._-]+$ at rule-save time
(CRITICAL: ExchangeMatchCondition attribute keys are inlined into ClickHouse SQL)
- Webhook validation: verifies outboundConnectionId exists and is allowed in env
- Null-safe notification template defaults to "" for NOT NULL DB constraint
- Fixed misleading comment in ClickHouseSearchIndex to document validation contract
- OPERATOR+ for mutations, VIEWER+ for reads
- Audit: ALERT_RULE_CREATE/UPDATE/DELETE/ENABLE/DISABLE with AuditCategory.ALERT_RULE_CHANGE
- 11 IT tests covering RBAC, SQL-injection prevention, enable/disable, audit, render-preview
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
* <p>
|
||||
* 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, '<key>')}, 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<ConditionKind, ConditionEvaluator<?>> evaluators;
|
||||
private final Clock clock;
|
||||
private final String tenantId;
|
||||
|
||||
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
|
||||
public AlertRuleController(AlertRuleRepository ruleRepo,
|
||||
OutboundConnectionService connectionService,
|
||||
AuditService auditService,
|
||||
MustacheRenderer renderer,
|
||||
List<ConditionEvaluator<?>> 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<AlertRuleResponse> 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<AlertRuleResponse> 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<Void> 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<String, Object> 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<WebhookBindingRequest> 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<WebhookBinding> webhooks = req.webhooks().stream()
|
||||
.map(wb -> new WebhookBinding(
|
||||
UUID.randomUUID(),
|
||||
wb.outboundConnectionId(),
|
||||
wb.bodyOverride(),
|
||||
wb.headerOverrides()))
|
||||
.toList();
|
||||
|
||||
List<AlertRuleTarget> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<WebhookBindingRequest> webhooks,
|
||||
List<AlertRuleTarget> targets
|
||||
) {
|
||||
public AlertRuleRequest {
|
||||
webhooks = webhooks == null ? List.of() : List.copyOf(webhooks);
|
||||
targets = targets == null ? List.of() : List.copyOf(targets);
|
||||
}
|
||||
}
|
||||
@@ -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<WebhookBindingResponse> webhooks,
|
||||
List<AlertRuleTarget> targets,
|
||||
Instant createdAt,
|
||||
String createdBy,
|
||||
Instant updatedAt,
|
||||
String updatedBy
|
||||
) {
|
||||
public static AlertRuleResponse from(AlertRule r) {
|
||||
List<WebhookBindingResponse> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> context) {
|
||||
public RenderPreviewRequest {
|
||||
context = context == null ? Map.of() : Map.copyOf(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.cameleer.server.app.alerting.dto;
|
||||
|
||||
public record RenderPreviewResponse(String title, String message) {}
|
||||
@@ -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() {}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> headerOverrides
|
||||
) {
|
||||
public WebhookBindingRequest {
|
||||
headerOverrides = headerOverrides == null ? Map.of() : Map.copyOf(headerOverrides);
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> headerOverrides
|
||||
) {
|
||||
public static WebhookBindingResponse from(WebhookBinding wb) {
|
||||
return new WebhookBindingResponse(
|
||||
wb.id(), wb.outboundConnectionId(), wb.bodyOverride(), wb.headerOverrides());
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> entry : spec.attributes().entrySet()) {
|
||||
String escapedKey = entry.getKey().replace("'", "\\'");
|
||||
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?");
|
||||
|
||||
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user