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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user