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

@@ -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 {
List<Environment> findAll();
long count();
Optional<Environment> findById(UUID id);
Optional<Environment> findBySlug(String slug);
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 final EnvironmentRepository repo;
private final CreateGuard createGuard;
public EnvironmentService(EnvironmentRepository repo) {
this(repo, CreateGuard.NOOP);
}
public EnvironmentService(EnvironmentRepository repo, CreateGuard createGuard) {
this.repo = repo;
this.createGuard = createGuard;
}
public List<Environment> listAll() { return repo.findAll(); }
@@ -37,6 +43,7 @@ public class EnvironmentService {
throw new IllegalArgumentException(
"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()) {
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
}