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);
+ }
+}