From 49853488272e8545e245b9d8b7b91bda874d5a04 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:36:58 +0200 Subject: [PATCH] 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) --- .../server/app/license/LicenseEnforcer.java | 68 +++++++++++++++++++ .../app/license/LicenseEnforcerTest.java | 51 ++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java new file mode 100644 index 00000000..30482877 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseEnforcer.java @@ -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). + * + *

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.

+ * + *

Unknown limit keys are treated as programmer errors and surface as + * {@link IllegalArgumentException} (propagated from {@link LicenseLimits#get(String)}), not + * {@link LicenseCapExceededException}.

+ */ +@Component +public class LicenseEnforcer { + + private final LicenseGate gate; + private final MeterRegistry meters; + private final AuditService audit; + private final ConcurrentMap 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 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); + } + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java new file mode 100644 index 00000000..db23264a --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcerTest.java @@ -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 limits, int grace) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, + limits, Instant.now(), Instant.now().plusSeconds(86400), grace); + } +}