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:
hsiegeln
2026-04-26 13:16:41 +02:00
parent f291d7c24d
commit 8a64a9e04c
10 changed files with 186 additions and 4 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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();
} }
/** /**

View File

@@ -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("""

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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();

View File

@@ -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 -> { };
}

View File

@@ -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);

View File

@@ -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");
} }