diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java index 81d6719f..6875ffd4 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java @@ -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( diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java index 6c8a2182..646bccf8 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/config/OutboundBeanConfig.java @@ -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); } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/OutboundCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/OutboundCapEnforcementIT.java new file mode 100644 index 00000000..01994871 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/OutboundCapEnforcementIT.java @@ -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 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 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java index dd81092e..2d4dce84 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/controller/OutboundConnectionAdminControllerIT.java @@ -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) {