feat(license): enforce max_environments at EnvironmentService.create
Adds CreateGuard functional interface to core (preserves the no-Spring boundary between core and app) and wires LicenseEnforcer into the EnvironmentService bean in RuntimeBeanConfig so POST /api/v1/admin/environments rejects with the structured 403 envelope (error/limit/cap/state/message) once the cap is reached. Default tier max_environments=1; the V1 baseline seeds the default env, so the very next create through the API is rejected unless a license lifts the cap. Also adds EnvironmentRepository.count() (with PostgresEnvironmentRepository impl), TestSecurityHelper.installTestLicenseWithCaps(...) so existing ITs that POST envs keep working, and a defensive cleanup in LicenseUsageReaderIT/EnvironmentAdminControllerIT to stay order-independent under Testcontainer reuse (deletes deployments+apps before envs to avoid FK violations). Test: EnvironmentCapEnforcementIT (new) drives the rejection path end-to-end and asserts the 403 body shape produced by LicenseExceptionAdvice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,8 +50,10 @@ public class RuntimeBeanConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public EnvironmentService environmentService(EnvironmentRepository repo) {
|
public EnvironmentService environmentService(EnvironmentRepository repo,
|
||||||
return new EnvironmentService(repo);
|
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
|
||||||
|
return new EnvironmentService(repo, current ->
|
||||||
|
enforcer.assertWithinCap("max_environments", current, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
|
|||||||
(rs, rowNum) -> mapRow(rs));
|
(rs, rowNum) -> mapRow(rs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long count() {
|
||||||
|
return jdbc.queryForObject("SELECT COUNT(*) FROM environments", Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Environment> findById(UUID id) {
|
public Optional<Environment> findById(UUID id) {
|
||||||
var results = jdbc.query(
|
var results = jdbc.query(
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package com.cameleer.server.app;
|
package com.cameleer.server.app;
|
||||||
|
|
||||||
import com.cameleer.server.core.agent.AgentRegistryService;
|
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer.server.core.license.LicenseGate;
|
||||||
|
import com.cameleer.server.core.license.LicenseInfo;
|
||||||
import com.cameleer.server.core.security.JwtService;
|
import com.cameleer.server.core.security.JwtService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test utility for creating JWT-authenticated requests in integration tests.
|
* Test utility for creating JWT-authenticated requests in integration tests.
|
||||||
@@ -20,10 +26,39 @@ public class TestSecurityHelper {
|
|||||||
|
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final AgentRegistryService agentRegistryService;
|
private final AgentRegistryService agentRegistryService;
|
||||||
|
private final LicenseGate licenseGate;
|
||||||
|
|
||||||
public TestSecurityHelper(JwtService jwtService, AgentRegistryService agentRegistryService) {
|
@Autowired
|
||||||
|
public TestSecurityHelper(JwtService jwtService,
|
||||||
|
AgentRegistryService agentRegistryService,
|
||||||
|
LicenseGate licenseGate) {
|
||||||
this.jwtService = jwtService;
|
this.jwtService = jwtService;
|
||||||
this.agentRegistryService = agentRegistryService;
|
this.agentRegistryService = agentRegistryService;
|
||||||
|
this.licenseGate = licenseGate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a synthetic, signature-bypassing license into {@link LicenseGate} so the test can
|
||||||
|
* exercise paths that would otherwise be rejected by default-tier caps. The license is
|
||||||
|
* always-ACTIVE (1 day from now, no grace) and limits are merged over defaults — only
|
||||||
|
* supply the keys you want to lift. Use this from {@code @BeforeEach} in ITs that need to
|
||||||
|
* create more than the default-tier allowance of envs/apps/users/etc.
|
||||||
|
*/
|
||||||
|
public void installTestLicenseWithCaps(Map<String, Integer> caps) {
|
||||||
|
LicenseInfo info = new LicenseInfo(
|
||||||
|
UUID.randomUUID(),
|
||||||
|
"default",
|
||||||
|
"test-license",
|
||||||
|
Map.copyOf(caps),
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now().plus(1, ChronoUnit.DAYS),
|
||||||
|
0);
|
||||||
|
licenseGate.load(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears any test license previously installed via {@link #installTestLicenseWithCaps}. */
|
||||||
|
public void clearTestLicense() {
|
||||||
|
licenseGate.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ class DeploymentControllerAuditIT extends AbstractPostgresIT {
|
|||||||
aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR"));
|
aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR"));
|
||||||
adminJwt = securityHelper.adminToken();
|
adminJwt = securityHelper.adminToken();
|
||||||
|
|
||||||
|
// Lift default-tier caps so the promote-target env + apps can be created via the API.
|
||||||
|
securityHelper.installTestLicenseWithCaps(Map.of(
|
||||||
|
"max_environments", 100,
|
||||||
|
"max_apps", 100));
|
||||||
|
|
||||||
// Clean up deployment-related tables and test-created environments
|
// Clean up deployment-related tables and test-created environments
|
||||||
jdbcTemplate.update("DELETE FROM deployments");
|
jdbcTemplate.update("DELETE FROM deployments");
|
||||||
jdbcTemplate.update("DELETE FROM app_versions");
|
jdbcTemplate.update("DELETE FROM app_versions");
|
||||||
@@ -90,6 +95,11 @@ class DeploymentControllerAuditIT extends AbstractPostgresIT {
|
|||||||
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
|
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
securityHelper.clearTestLicense();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception {
|
void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception {
|
||||||
String json = String.format("""
|
String json = String.format("""
|
||||||
|
|||||||
@@ -35,8 +35,21 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
|
|||||||
adminJwt = securityHelper.adminToken();
|
adminJwt = securityHelper.adminToken();
|
||||||
viewerJwt = securityHelper.viewerToken();
|
viewerJwt = securityHelper.viewerToken();
|
||||||
operatorJwt = securityHelper.operatorToken();
|
operatorJwt = securityHelper.operatorToken();
|
||||||
// Clean up test environments (keep default)
|
// Clean up test environments (keep default). Strip dependents first — sibling ITs
|
||||||
|
// (e.g., DeploymentControllerAuditIT) may have left deployments/apps that FK back to
|
||||||
|
// their non-default envs when the testcontainer is reused across runs.
|
||||||
|
jdbcTemplate.update("DELETE FROM deployments");
|
||||||
|
jdbcTemplate.update("DELETE FROM app_versions");
|
||||||
|
jdbcTemplate.update("DELETE FROM apps");
|
||||||
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
|
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
|
||||||
|
// Lift max_environments cap so existing IT scenarios that POST envs through the
|
||||||
|
// controller succeed; the cap itself is exercised by EnvironmentCapEnforcementIT.
|
||||||
|
securityHelper.installTestLicenseWithCaps(java.util.Map.of("max_environments", 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
securityHelper.clearTestLicense();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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.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_environments} cap from the default tier is enforced at
|
||||||
|
* {@code POST /api/v1/admin/environments}. Default tier {@code max_environments = 1}; the V1
|
||||||
|
* baseline migration seeds a single {@code default} environment, so the very next create
|
||||||
|
* attempt must be rejected with the structured 403 envelope produced by
|
||||||
|
* {@link LicenseExceptionAdvice}.
|
||||||
|
*/
|
||||||
|
class EnvironmentCapEnforcementIT extends AbstractPostgresIT {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TestRestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TestSecurityHelper securityHelper;
|
||||||
|
|
||||||
|
private String adminJwt;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
adminJwt = securityHelper.adminToken();
|
||||||
|
// Defensive: clear any license a previous IT may have left installed (each IT gets its own
|
||||||
|
// Spring context only on first @SpringBootTest reuse boundary; LicenseGate is a singleton).
|
||||||
|
securityHelper.clearTestLicense();
|
||||||
|
// Ensure starting state: only the seeded "default" env (count = 1, equals the cap).
|
||||||
|
// Strip dependents first — sibling ITs may have left deployments/apps that FK back to
|
||||||
|
// non-default envs when the testcontainer is reused across runs.
|
||||||
|
jdbcTemplate.update("DELETE FROM deployments");
|
||||||
|
jdbcTemplate.update("DELETE FROM app_versions");
|
||||||
|
jdbcTemplate.update("DELETE FROM apps");
|
||||||
|
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createBeyondCap_returns403WithStateAndMessage() throws Exception {
|
||||||
|
// Default tier: max_environments = 1; V1 seeds the default env, so the next create rejects.
|
||||||
|
String json = """
|
||||||
|
{"slug":"prod","displayName":"Prod","production":true}
|
||||||
|
""";
|
||||||
|
|
||||||
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
|
"/api/v1/admin/environments", HttpMethod.POST,
|
||||||
|
new HttpEntity<>(json, 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_environments");
|
||||||
|
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 env was not created.
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM environments WHERE slug = 'prod'", Integer.class);
|
||||||
|
assertThat(count).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.cameleer.server.app.license;
|
package com.cameleer.server.app.license;
|
||||||
|
|
||||||
import com.cameleer.server.app.AbstractPostgresIT;
|
import com.cameleer.server.app.AbstractPostgresIT;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
@@ -10,6 +11,16 @@ class LicenseUsageReaderIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
@Autowired LicenseUsageReader reader;
|
@Autowired LicenseUsageReader reader;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void cleanDb() {
|
||||||
|
// Defensive cleanup so the test is order-independent under Testcontainer reuse — sibling
|
||||||
|
// ITs may have left envs/apps that would otherwise inflate the snapshot counts.
|
||||||
|
jdbcTemplate.update("DELETE FROM deployments");
|
||||||
|
jdbcTemplate.update("DELETE FROM app_versions");
|
||||||
|
jdbcTemplate.update("DELETE FROM apps");
|
||||||
|
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void emptyDb_returnsZeros() {
|
void emptyDb_returnsZeros() {
|
||||||
var snap = reader.snapshot();
|
var snap = reader.snapshot();
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.cameleer.server.core.runtime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook called by domain services before creating a new entity. Implementations enforce
|
||||||
|
* environment-level policy (e.g., license caps) without dragging Spring or app-module types
|
||||||
|
* into core.
|
||||||
|
*
|
||||||
|
* <p>The guard is consulted with the current count; implementations throw to abort creation.
|
||||||
|
* The {@link #NOOP} singleton is the default for tests and for boot configurations that haven't
|
||||||
|
* wired enforcement yet.</p>
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface CreateGuard {
|
||||||
|
|
||||||
|
/** Throw to reject creation; otherwise return normally. */
|
||||||
|
void check(long current);
|
||||||
|
|
||||||
|
CreateGuard NOOP = c -> { };
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public interface EnvironmentRepository {
|
public interface EnvironmentRepository {
|
||||||
List<Environment> findAll();
|
List<Environment> findAll();
|
||||||
|
long count();
|
||||||
Optional<Environment> findById(UUID id);
|
Optional<Environment> findById(UUID id);
|
||||||
Optional<Environment> findBySlug(String slug);
|
Optional<Environment> findBySlug(String slug);
|
||||||
UUID create(String slug, String displayName, boolean production);
|
UUID create(String slug, String displayName, boolean production);
|
||||||
|
|||||||
@@ -17,9 +17,15 @@ public class EnvironmentService {
|
|||||||
private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$");
|
private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$");
|
||||||
|
|
||||||
private final EnvironmentRepository repo;
|
private final EnvironmentRepository repo;
|
||||||
|
private final CreateGuard createGuard;
|
||||||
|
|
||||||
public EnvironmentService(EnvironmentRepository repo) {
|
public EnvironmentService(EnvironmentRepository repo) {
|
||||||
|
this(repo, CreateGuard.NOOP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EnvironmentService(EnvironmentRepository repo, CreateGuard createGuard) {
|
||||||
this.repo = repo;
|
this.repo = repo;
|
||||||
|
this.createGuard = createGuard;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Environment> listAll() { return repo.findAll(); }
|
public List<Environment> listAll() { return repo.findAll(); }
|
||||||
@@ -37,6 +43,7 @@ public class EnvironmentService {
|
|||||||
throw new IllegalArgumentException(
|
throw new IllegalArgumentException(
|
||||||
"Invalid slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)");
|
"Invalid slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)");
|
||||||
}
|
}
|
||||||
|
createGuard.check(repo.count());
|
||||||
if (repo.findBySlug(slug).isPresent()) {
|
if (repo.findBySlug(slug).isPresent()) {
|
||||||
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user