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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.EvalResult;
|
||||||
import com.cameleer.server.app.alerting.eval.TickCache;
|
import com.cameleer.server.app.alerting.eval.TickCache;
|
||||||
import com.cameleer.server.app.alerting.notify.MustacheRenderer;
|
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.app.web.EnvPath;
|
||||||
import com.cameleer.server.core.admin.AuditCategory;
|
import com.cameleer.server.core.admin.AuditCategory;
|
||||||
import com.cameleer.server.core.admin.AuditResult;
|
import com.cameleer.server.core.admin.AuditResult;
|
||||||
@@ -78,6 +79,7 @@ public class AlertRuleController {
|
|||||||
private final Map<ConditionKind, ConditionEvaluator<?>> evaluators;
|
private final Map<ConditionKind, ConditionEvaluator<?>> evaluators;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final String tenantId;
|
private final String tenantId;
|
||||||
|
private final LicenseEnforcer licenseEnforcer;
|
||||||
|
|
||||||
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
|
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
|
||||||
public AlertRuleController(AlertRuleRepository ruleRepo,
|
public AlertRuleController(AlertRuleRepository ruleRepo,
|
||||||
@@ -86,7 +88,8 @@ public class AlertRuleController {
|
|||||||
MustacheRenderer renderer,
|
MustacheRenderer renderer,
|
||||||
List<ConditionEvaluator<?>> evaluatorList,
|
List<ConditionEvaluator<?>> evaluatorList,
|
||||||
Clock alertingClock,
|
Clock alertingClock,
|
||||||
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
|
@Value("${cameleer.server.tenant.id:default}") String tenantId,
|
||||||
|
LicenseEnforcer licenseEnforcer) {
|
||||||
this.ruleRepo = ruleRepo;
|
this.ruleRepo = ruleRepo;
|
||||||
this.connectionService = connectionService;
|
this.connectionService = connectionService;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
@@ -97,6 +100,7 @@ public class AlertRuleController {
|
|||||||
}
|
}
|
||||||
this.clock = alertingClock;
|
this.clock = alertingClock;
|
||||||
this.tenantId = tenantId;
|
this.tenantId = tenantId;
|
||||||
|
this.licenseEnforcer = licenseEnforcer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -126,6 +130,8 @@ public class AlertRuleController {
|
|||||||
@Valid @RequestBody AlertRuleRequest req,
|
@Valid @RequestBody AlertRuleRequest req,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
licenseEnforcer.assertWithinCap("max_alert_rules", ruleRepo.count(), 1);
|
||||||
|
|
||||||
validateAttributeKeys(req.condition());
|
validateAttributeKeys(req.condition());
|
||||||
validateBusinessRules(req);
|
validateBusinessRules(req);
|
||||||
validateWebhooks(req.webhooks(), env.id());
|
validateWebhooks(req.webhooks(), env.id());
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ public class PostgresAlertRuleRepository implements AlertRuleRepository {
|
|||||||
jdbc.update("DELETE FROM alert_rules WHERE id = ?", id);
|
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
|
@Override
|
||||||
public List<AlertRule> claimDueRules(String instanceId, int batchSize, int claimTtlSeconds) {
|
public List<AlertRule> claimDueRules(String instanceId, int batchSize, int claimTtlSeconds) {
|
||||||
String sql = """
|
String sql = """
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT {
|
|||||||
.dynamicHttpsPort());
|
.dynamicHttpsPort());
|
||||||
wm.start();
|
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
|
// Default clock behaviour: delegate to simulatedNow
|
||||||
stubClock();
|
stubClock();
|
||||||
|
|
||||||
@@ -145,6 +150,7 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
@AfterAll
|
@AfterAll
|
||||||
void cleanupFixtures() {
|
void cleanupFixtures() {
|
||||||
|
securityHelper.clearTestLicense();
|
||||||
if (wm != null) wm.stop();
|
if (wm != null) wm.stop();
|
||||||
jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId);
|
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);
|
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id = ?)", envId);
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT {
|
|||||||
void setUp() throws Exception {
|
void setUp() throws Exception {
|
||||||
when(agentRegistryService.findAll()).thenReturn(List.of());
|
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();
|
adminJwt = securityHelper.adminToken();
|
||||||
operatorJwt = securityHelper.operatorToken();
|
operatorJwt = securityHelper.operatorToken();
|
||||||
|
|
||||||
@@ -93,6 +100,7 @@ class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void cleanUp() {
|
void cleanUp() {
|
||||||
|
securityHelper.clearTestLicense();
|
||||||
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id IN (?, ?, ?)", envIdA, envIdB, envIdC);
|
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 outbound_connections WHERE id = ?", connId);
|
||||||
jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?, ?)", envIdA, envIdB, envIdC);
|
jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?, ?)", envIdA, envIdB, envIdC);
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ class AlertRuleControllerIT extends AbstractPostgresIT {
|
|||||||
seedUser("test-operator");
|
seedUser("test-operator");
|
||||||
seedUser("test-viewer");
|
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
|
// Create a test environment
|
||||||
envSlug = "test-env-" + UUID.randomUUID().toString().substring(0, 8);
|
envSlug = "test-env-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
envId = UUID.randomUUID();
|
envId = UUID.randomUUID();
|
||||||
@@ -54,6 +59,7 @@ class AlertRuleControllerIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void cleanUp() {
|
void cleanUp() {
|
||||||
|
securityHelper.clearTestLicense();
|
||||||
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
|
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
|
||||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
||||||
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')");
|
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')");
|
||||||
|
|||||||
@@ -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<String> 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<String> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ public interface AlertRuleRepository {
|
|||||||
List<UUID> findRuleIdsByOutboundConnectionId(UUID connectionId); // used by rulesReferencing()
|
List<UUID> findRuleIdsByOutboundConnectionId(UUID connectionId); // used by rulesReferencing()
|
||||||
void delete(UUID id);
|
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).
|
/** 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. */
|
* Atomically sets claimed_by + claimed_until = now + ttl. Returns claimed rules. */
|
||||||
List<AlertRule> claimDueRules(String instanceId, int batchSize, int claimTtlSeconds);
|
List<AlertRule> claimDueRules(String instanceId, int batchSize, int claimTtlSeconds);
|
||||||
|
|||||||
Reference in New Issue
Block a user