feat(license): enforce max_outbound_connections at OutboundConnectionServiceImpl.create
Adds LicenseEnforcer.assertWithinCap call at the top of create() using repo.listByTenant(tenantId).size() as the current count. Lifts the cap in OutboundConnectionAdminControllerIT (duplicateNameReturns409 needs 2 creates in one test). LicenseExceptionAdvice maps the rejection to the standard 403 envelope; cap_exceeded audit row emitted via the LicenseEnforcer 3-arg ctor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.cameleer.server.app.outbound;
|
||||
|
||||
import com.cameleer.server.app.license.LicenseEnforcer;
|
||||
import com.cameleer.server.core.alerting.AlertRuleRepository;
|
||||
import com.cameleer.server.core.outbound.OutboundConnection;
|
||||
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
|
||||
@@ -18,21 +19,25 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService
|
||||
private final OutboundConnectionRepository repo;
|
||||
private final AlertRuleRepository ruleRepo;
|
||||
private final SsrfGuard ssrfGuard;
|
||||
private final LicenseEnforcer licenseEnforcer;
|
||||
private final String tenantId;
|
||||
|
||||
public OutboundConnectionServiceImpl(
|
||||
OutboundConnectionRepository repo,
|
||||
AlertRuleRepository ruleRepo,
|
||||
SsrfGuard ssrfGuard,
|
||||
LicenseEnforcer licenseEnforcer,
|
||||
String tenantId) {
|
||||
this.repo = repo;
|
||||
this.ruleRepo = ruleRepo;
|
||||
this.ssrfGuard = ssrfGuard;
|
||||
this.licenseEnforcer = licenseEnforcer;
|
||||
this.tenantId = tenantId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutboundConnection create(OutboundConnection draft, String actingUserId) {
|
||||
licenseEnforcer.assertWithinCap("max_outbound_connections", repo.listByTenant(tenantId).size(), 1);
|
||||
assertNameUnique(draft.name(), null);
|
||||
validateUrl(draft.url());
|
||||
OutboundConnection c = new OutboundConnection(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.cameleer.server.app.outbound.config;
|
||||
|
||||
import com.cameleer.server.app.license.LicenseEnforcer;
|
||||
import com.cameleer.server.app.outbound.OutboundConnectionServiceImpl;
|
||||
import com.cameleer.server.app.outbound.SsrfGuard;
|
||||
import com.cameleer.server.app.outbound.crypto.SecretCipher;
|
||||
@@ -33,7 +34,8 @@ public class OutboundBeanConfig {
|
||||
OutboundConnectionRepository repo,
|
||||
AlertRuleRepository ruleRepo,
|
||||
SsrfGuard ssrfGuard,
|
||||
LicenseEnforcer licenseEnforcer,
|
||||
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
|
||||
return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, tenantId);
|
||||
return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, licenseEnforcer, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.cameleer.server.app.license;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.TestSecurityHelper;
|
||||
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.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_outbound_connections} cap from the default tier is enforced at
|
||||
* {@code POST /api/v1/admin/outbound-connections}. Default tier
|
||||
* {@code max_outbound_connections = 1}; with no license installed the gate is in
|
||||
* {@link com.cameleer.server.core.license.LicenseState#ABSENT} and the defaults are
|
||||
* authoritative. The first create succeeds; the second must be rejected with the structured
|
||||
* 403 envelope produced by {@link LicenseExceptionAdvice}.
|
||||
*/
|
||||
class OutboundCapEnforcementIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
private String adminJwt;
|
||||
|
||||
@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 outbound_connections so we start at zero — the cap is per-tenant.
|
||||
jdbcTemplate.update("DELETE FROM outbound_connections");
|
||||
// Seed user row for the JWT subject — outbound_connections.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");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
// Defensive cleanup — we never installed a license, but make sure later ITs see ABSENT.
|
||||
securityHelper.clearTestLicense();
|
||||
jdbcTemplate.update("DELETE FROM outbound_connections");
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-admin'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createBeyondCap_returns403WithStateAndMessage() throws Exception {
|
||||
// Default tier: max_outbound_connections = 1. First create succeeds; the second rejects.
|
||||
String first = """
|
||||
{"name":"hook-1","url":"https://hooks.example.com/1","method":"POST",
|
||||
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
|
||||
ResponseEntity<String> ok = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(first, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(ok.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
|
||||
String second = """
|
||||
{"name":"hook-2","url":"https://hooks.example.com/2","method":"POST",
|
||||
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>(second, 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_outbound_connections");
|
||||
assertThat(body.path("cap").asInt()).isEqualTo(1);
|
||||
assertThat(body.path("state").asText()).isEqualTo("ABSENT");
|
||||
assertThat(body.has("message")).isTrue();
|
||||
assertThat(body.path("message").asText()).isNotBlank();
|
||||
|
||||
// And the second connection was NOT persisted.
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM outbound_connections WHERE name = 'hook-2'", Integer.class);
|
||||
assertThat(count).isZero();
|
||||
|
||||
// Total connections still 1 — the rejection short-circuited before any insert.
|
||||
Integer total = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM outbound_connections", Integer.class);
|
||||
assertThat(total).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM deployments WHERE created_by IN ('test-admin','test-operator','test-viewer')");
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-admin','test-operator','test-viewer')");
|
||||
// Clear the lifted license so later ITs see ABSENT state.
|
||||
securityHelper.clearTestLicense();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
@@ -51,6 +53,10 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
|
||||
seedUser("test-operator");
|
||||
seedUser("test-viewer");
|
||||
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
|
||||
// Lift the max_outbound_connections cap — duplicateNameReturns409 needs 2 creates,
|
||||
// and the default tier caps at 1. Other tests in this class don't exceed the cap
|
||||
// but lifting at the class level keeps the suite robust against ordering surprises.
|
||||
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of("max_outbound_connections", 100));
|
||||
}
|
||||
|
||||
private void seedUser(String userId) {
|
||||
|
||||
Reference in New Issue
Block a user