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:
hsiegeln
2026-04-19 21:28:46 +02:00
parent d3dd8882bd
commit c1b34f592b
11 changed files with 812 additions and 1 deletions

View File

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