feat(license): LicenseEnforcer single entry point

assertWithinCap consults LicenseGate.getEffectiveLimits, throws
LicenseCapExceededException on overflow, increments
cameleer_license_cap_rejections_total{limit=...} for telemetry, and
emits an AuditCategory.LICENSE cap_exceeded audit row when an
AuditService is wired (3-arg ctor; the test-only 2-arg ctor passes
null and the audit call short-circuits). Unknown limit keys are
programmer errors (IllegalArgumentException), not 403s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 12:36:58 +02:00
parent e98d790874
commit 4985348827
2 changed files with 119 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseLimits;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Single entry point for license cap enforcement (spec §4).
*
* <p>Consults {@link LicenseGate#getEffectiveLimits()} (license-overrides UNION default tier when
* ACTIVE/GRACE; defaults-only otherwise) and rejects calls whose projected usage would exceed the
* cap. Rejections increment a per-limit Micrometer counter and, when an {@link AuditService} is
* wired, emit an {@link AuditCategory#LICENSE} {@code cap_exceeded} audit row.</p>
*
* <p>Unknown limit keys are treated as programmer errors and surface as
* {@link IllegalArgumentException} (propagated from {@link LicenseLimits#get(String)}), not
* {@link LicenseCapExceededException}.</p>
*/
@Component
public class LicenseEnforcer {
private final LicenseGate gate;
private final MeterRegistry meters;
private final AuditService audit;
private final ConcurrentMap<String, Counter> rejectionCounters = new ConcurrentHashMap<>();
public LicenseEnforcer(LicenseGate gate, MeterRegistry meters, AuditService audit) {
this.gate = gate;
this.meters = meters;
this.audit = audit;
}
/** Test-only ctor with no metrics or audit. */
public LicenseEnforcer(LicenseGate gate) {
this(gate, new SimpleMeterRegistry(), null);
}
public void assertWithinCap(String limitKey, long currentUsage, long requestedDelta) {
LicenseLimits effective = gate.getEffectiveLimits();
int cap = effective.get(limitKey); // throws IllegalArgumentException if unknown key
long projected = currentUsage + requestedDelta;
if (projected > cap) {
rejectionCounters.computeIfAbsent(limitKey, k -> Counter.builder("cameleer_license_cap_rejections_total")
.tag("limit", k).register(meters)).increment();
if (audit != null) {
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("limit", limitKey);
detail.put("current", currentUsage);
detail.put("requested", requestedDelta);
detail.put("cap", cap);
detail.put("state", gate.getState().name());
audit.log("system", "cap_exceeded", AuditCategory.LICENSE, limitKey, detail, AuditResult.FAILURE, null);
}
throw new LicenseCapExceededException(limitKey, projected, cap);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LicenseEnforcerTest {
@Test
void underCap_passes() {
LicenseGate gate = new LicenseGate();
gate.load(license(Map.of("max_apps", 10), 0));
new LicenseEnforcer(gate).assertWithinCap("max_apps", 9, 1);
}
@Test
void atCap_throws() {
LicenseGate gate = new LicenseGate();
gate.load(license(Map.of("max_apps", 10), 0));
assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_apps", 10, 1))
.isInstanceOf(LicenseCapExceededException.class)
.hasMessageContaining("max_apps");
}
@Test
void absent_usesDefaultTier() {
LicenseGate gate = new LicenseGate();
// default max_apps = 3; current 3 + 1 > 3 -> reject
assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_apps", 3, 1))
.isInstanceOf(LicenseCapExceededException.class);
}
@Test
void unknownLimitKey_throwsIllegalArgument() {
LicenseGate gate = new LicenseGate();
assertThatThrownBy(() -> new LicenseEnforcer(gate).assertWithinCap("max_xyz", 0, 1))
.isInstanceOf(IllegalArgumentException.class);
}
private LicenseInfo license(Map<String, Integer> limits, int grace) {
return new LicenseInfo(UUID.randomUUID(), "acme", null,
limits, Instant.now(), Instant.now().plusSeconds(86400), grace);
}
}