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 + "') = ?");
|
||||
|
||||
Reference in New Issue
Block a user