From 71f3b70b86d9ab8882fcd00e48073c786e398e22 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:50:59 +0200 Subject: [PATCH] feat(license): enforce max_alert_rules at AlertRuleController.create Adds AlertRuleRepository.count() and a LicenseEnforcer.assertWithinCap call at the top of the POST handler. Default cap = 2; the 3rd rule gets the standard 403 envelope. Sibling alert ITs that legitimately need more than 2 rules get the cap lifted via the test-license helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/AlertRuleController.java | 8 +- .../storage/PostgresAlertRuleRepository.java | 6 + .../app/alerting/AlertingFullLifecycleIT.java | 6 + .../OutboundConnectionAllowedEnvIT.java | 8 ++ .../controller/AlertRuleControllerIT.java | 6 + .../license/AlertRuleCapEnforcementIT.java | 133 ++++++++++++++++++ .../core/alerting/AlertRuleRepository.java | 3 + 7 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java 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 index 792b87ca..24f23a6f 100644 --- 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 @@ -12,6 +12,7 @@ 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.license.LicenseEnforcer; import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.core.admin.AuditCategory; import com.cameleer.server.core.admin.AuditResult; @@ -78,6 +79,7 @@ public class AlertRuleController { private final Map> evaluators; private final Clock clock; private final String tenantId; + private final LicenseEnforcer licenseEnforcer; @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") public AlertRuleController(AlertRuleRepository ruleRepo, @@ -86,7 +88,8 @@ public class AlertRuleController { MustacheRenderer renderer, List> evaluatorList, Clock alertingClock, - @Value("${cameleer.server.tenant.id:default}") String tenantId) { + @Value("${cameleer.server.tenant.id:default}") String tenantId, + LicenseEnforcer licenseEnforcer) { this.ruleRepo = ruleRepo; this.connectionService = connectionService; this.auditService = auditService; @@ -97,6 +100,7 @@ public class AlertRuleController { } this.clock = alertingClock; this.tenantId = tenantId; + this.licenseEnforcer = licenseEnforcer; } // ------------------------------------------------------------------------- @@ -126,6 +130,8 @@ public class AlertRuleController { @Valid @RequestBody AlertRuleRequest req, HttpServletRequest httpRequest) { + licenseEnforcer.assertWithinCap("max_alert_rules", ruleRepo.count(), 1); + validateAttributeKeys(req.condition()); validateBusinessRules(req); validateWebhooks(req.webhooks(), env.id()); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepository.java index 9c13852f..020f3171 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepository.java @@ -113,6 +113,12 @@ public class PostgresAlertRuleRepository implements AlertRuleRepository { jdbc.update("DELETE FROM alert_rules WHERE id = ?", id); } + @Override + public long count() { + Long n = jdbc.queryForObject("SELECT COUNT(*) FROM alert_rules", Long.class); + return n == null ? 0L : n; + } + @Override public List claimDueRules(String instanceId, int batchSize, int claimTtlSeconds) { String sql = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java index 6043cca6..9969ca74 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/AlertingFullLifecycleIT.java @@ -105,6 +105,11 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT { .dynamicHttpsPort()); wm.start(); + // Lift the default-tier max_alert_rules cap (=2). This lifecycle test creates + // multiple rules via REST + repo across @Test methods (PER_CLASS lifecycle) and + // is not exercising the license cap. Synthetic license is ACTIVE-state. + securityHelper.installSyntheticUnsignedLicense(java.util.Map.of("max_alert_rules", 100)); + // Default clock behaviour: delegate to simulatedNow stubClock(); @@ -145,6 +150,7 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT { @AfterAll void cleanupFixtures() { + securityHelper.clearTestLicense(); if (wm != null) wm.stop(); jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId); jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id = ?)", envId); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/OutboundConnectionAllowedEnvIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/OutboundConnectionAllowedEnvIT.java index f4452ed6..2937537c 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/OutboundConnectionAllowedEnvIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/OutboundConnectionAllowedEnvIT.java @@ -56,6 +56,13 @@ class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT { void setUp() throws Exception { when(agentRegistryService.findAll()).thenReturn(List.of()); + // Lift caps so this connection-allowed-env test, which creates one alert rule per + // method, is never gated by the default-tier max_alert_rules=2 + sibling residue. + // Also lift max_outbound_connections (default=1) — every test creates one connection. + securityHelper.installSyntheticUnsignedLicense(java.util.Map.of( + "max_alert_rules", 100, + "max_outbound_connections", 100)); + adminJwt = securityHelper.adminToken(); operatorJwt = securityHelper.operatorToken(); @@ -93,6 +100,7 @@ class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT { @AfterEach void cleanUp() { + securityHelper.clearTestLicense(); jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id IN (?, ?, ?)", envIdA, envIdB, envIdC); jdbcTemplate.update("DELETE FROM outbound_connections WHERE id = ?", connId); jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?, ?)", envIdA, envIdB, envIdC); 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 index 56d7b9a2..6daad7c0 100644 --- 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 @@ -44,6 +44,11 @@ class AlertRuleControllerIT extends AbstractPostgresIT { seedUser("test-operator"); seedUser("test-viewer"); + // Lift the default-tier max_alert_rules cap (=2) so this suite — which exercises rule + // creation independent of the cap — is not gated by sibling-test residue in the + // shared Spring context's Postgres tables. The synthetic license is ACTIVE-state. + securityHelper.installSyntheticUnsignedLicense(java.util.Map.of("max_alert_rules", 100)); + // Create a test environment envSlug = "test-env-" + UUID.randomUUID().toString().substring(0, 8); envId = UUID.randomUUID(); @@ -54,6 +59,7 @@ class AlertRuleControllerIT extends AbstractPostgresIT { @AfterEach void cleanUp() { + securityHelper.clearTestLicense(); 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')"); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java new file mode 100644 index 00000000..3a203d48 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AlertRuleCapEnforcementIT.java @@ -0,0 +1,133 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import com.cameleer.server.app.search.ClickHouseLogStore; +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 static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the {@code max_alert_rules} cap from the default tier is enforced at + * {@code POST /api/v1/environments/{envSlug}/alerts/rules}. Default tier + * {@code max_alert_rules = 2}; with no license installed the gate is in + * {@link com.cameleer.server.core.license.LicenseState#ABSENT} and the defaults are + * authoritative. The first two creates succeed; the third must be rejected with the + * structured 403 envelope produced by {@link LicenseExceptionAdvice}. + */ +class AlertRuleCapEnforcementIT extends AbstractPostgresIT { + + // ExchangeMatchEvaluator and LogPatternEvaluator depend on the concrete CH log store + // bean. Mock it so the Spring context wires up without real ClickHouse log behaviour. + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + private String adminJwt; + private String envSlug; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + // Defensive: a sibling IT may have left a license installed (LicenseGate is a singleton + // per Spring context; @SpringBootTest reuses contexts across ITs). + securityHelper.clearTestLicense(); + // Strip alert-rule dependents first, then the rules themselves — the cap is per-tenant + // (tenant-wide count, not env-scoped). + jdbcTemplate.update("DELETE FROM alert_notifications"); + jdbcTemplate.update("DELETE FROM alert_instances"); + jdbcTemplate.update("DELETE FROM alert_silences"); + jdbcTemplate.update("DELETE FROM alert_rule_targets"); + jdbcTemplate.update("DELETE FROM alert_rules"); + // Seed user row for the JWT subject — alert_rules.created_by FKs to users(user_id). + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING", + "test-admin", "test-admin@example.com", "test-admin"); + // Use the seeded "default" environment. + envSlug = "default"; + } + + @AfterEach + void tearDown() { + // Defensive cleanup — we never installed a license, but make sure later ITs see ABSENT. + securityHelper.clearTestLicense(); + jdbcTemplate.update("DELETE FROM alert_notifications"); + jdbcTemplate.update("DELETE FROM alert_instances"); + jdbcTemplate.update("DELETE FROM alert_silences"); + jdbcTemplate.update("DELETE FROM alert_rule_targets"); + jdbcTemplate.update("DELETE FROM alert_rules"); + jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-admin'"); + } + + @Test + void createBeyondCap_returns403WithStateAndMessage() throws Exception { + // Default tier: max_alert_rules = 2. First two creates succeed; the third rejects. + ResponseEntity first = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", HttpMethod.POST, + new HttpEntity<>(ruleBody("rule-1"), securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + ResponseEntity second = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", HttpMethod.POST, + new HttpEntity<>(ruleBody("rule-2"), securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(second.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + ResponseEntity response = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/rules", HttpMethod.POST, + new HttpEntity<>(ruleBody("rule-3"), securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.path("error").asText()).isEqualTo("license cap reached"); + assertThat(body.path("limit").asText()).isEqualTo("max_alert_rules"); + assertThat(body.path("cap").asInt()).isEqualTo(2); + assertThat(body.path("state").asText()).isEqualTo("ABSENT"); + assertThat(body.has("message")).isTrue(); + assertThat(body.path("message").asText()).isNotBlank(); + + // And the third rule was NOT persisted. + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM alert_rules WHERE name = 'rule-3'", Integer.class); + assertThat(count).isZero(); + + // Total rules still 2 — the rejection short-circuited before any insert. + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM alert_rules", Integer.class); + assertThat(total).isEqualTo(2); + } + + /** + * Minimal valid alert-rule request body: a ROUTE_METRIC condition with a USER target so + * the controller's "at least one webhook or target" guard passes. The rule is otherwise + * inert — it does not need to evaluate or fire to exercise the license cap. + */ + private static String ruleBody(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}, + "targets":[{"kind":"USER","targetId":"test-admin"}]} + """.formatted(name); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertRuleRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertRuleRepository.java index eecd8b1f..cc4da580 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertRuleRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertRuleRepository.java @@ -14,6 +14,9 @@ public interface AlertRuleRepository { List findRuleIdsByOutboundConnectionId(UUID connectionId); // used by rulesReferencing() void delete(UUID id); + /** Tenant-wide rule count — feeds the {@code max_alert_rules} license cap. */ + long count(); + /** Claim up to batchSize rules whose next_evaluation_at <= now AND (claimed_until IS NULL OR claimed_until < now). * Atomically sets claimed_by + claimed_until = now + ttl. Returns claimed rules. */ List claimDueRules(String instanceId, int batchSize, int claimTtlSeconds);