refactor(license): extract cameleer-license-api module from server-core
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 2m57s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Splits the pure license contract types (LicenseInfo, LicenseValidator,
LicenseState, LicenseStateMachine, LicenseLimits, DefaultTierLimits) into a
new cameleer-license-api module under package com.cameleer.license.

Why: cameleer-license-minter previously depended on cameleer-server-core for
these types, dragging cameleer-server-core + cameleer-common onto the
classpath of every minter consumer (notably cameleer-saas). The SaaS
management plane has no business carrying server-runtime types — it only
needs the license contract to mint and verify tokens.

After:
  cameleer-license-minter -> cameleer-license-api  (no server internals)
  cameleer-server-core    -> cameleer-license-api
  cameleer-saas           -> cameleer-license-minter -> cameleer-license-api

Verified: mvn -pl cameleer-license-minter dependency:tree shows the minter
no longer pulls cameleer-server-core or cameleer-common. Full reactor
verify (-DskipITs) green: 371 tests pass.

LicenseGate stays in server-core (server-runtime state holder, not contract).

Closes cameleer/cameleer-server#156

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 20:06:52 +02:00
parent 30db609aff
commit 858975f03f
43 changed files with 130 additions and 52 deletions

View File

@@ -4,8 +4,8 @@ import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseValidator;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -3,7 +3,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.license.LicenseInfo;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseState;
import java.util.Objects;

View File

@@ -3,8 +3,8 @@ 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.license.LicenseLimits;
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;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license;
import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseState;
import java.time.Duration;
import java.time.Instant;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.license.LicenseState;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Value;

View File

@@ -3,9 +3,9 @@ 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.license.LicenseInfo;
import com.cameleer.license.LicenseValidator;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseLimits;
import com.cameleer.license.LicenseLimits;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import org.slf4j.Logger;

View File

@@ -2,7 +2,7 @@ package com.cameleer.server.app;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.license.LicenseInfo;
import com.cameleer.server.core.security.JwtService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;

View File

@@ -22,7 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Verifies that the {@code max_alert_rules} cap from the default tier is enforced at
* {@code POST /api/v1/environments/{envSlug}/alerts/rules}. Default tier
* {@code max_alert_rules = 2}; with no license installed the gate is in
* {@link com.cameleer.server.core.license.LicenseState#ABSENT} and the defaults are
* {@link com.cameleer.license.LicenseState#ABSENT} and the defaults are
* authoritative. The first two creates succeed; the third must be rejected with the
* structured 403 envelope produced by {@link LicenseExceptionAdvice}.
*/

View File

@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_apps} cap from the default tier is enforced at
* {@code POST /api/v1/environments/{envSlug}/apps}. Default tier {@code max_apps = 3}; with no
* license installed the gate is in {@link com.cameleer.server.core.license.LicenseState#ABSENT}
* license installed the gate is in {@link com.cameleer.license.LicenseState#ABSENT}
* and the defaults are authoritative. The fourth create attempt must be rejected with the
* structured 403 envelope produced by {@link LicenseExceptionAdvice}.
*/

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.license.LicenseInfo;
import org.junit.jupiter.api.Test;
import java.time.Instant;

View File

@@ -4,8 +4,8 @@ import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseState;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseState;
import org.junit.jupiter.api.Test;
import java.time.Instant;

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.license.LicenseInfo;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;

View File

@@ -4,9 +4,9 @@ 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.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.server.core.license.LicenseValidator;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseState;
import com.cameleer.license.LicenseValidator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;

View File

@@ -20,7 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Verifies that the {@code max_outbound_connections} cap from the default tier is enforced at
* {@code POST /api/v1/admin/outbound-connections}. Default tier
* {@code max_outbound_connections = 1}; with no license installed the gate is in
* {@link com.cameleer.server.core.license.LicenseState#ABSENT} and the defaults are
* {@link com.cameleer.license.LicenseState#ABSENT} and the defaults are
* authoritative. The first create succeeds; the second must be rejected with the structured
* 403 envelope produced by {@link LicenseExceptionAdvice}.
*/

View File

@@ -1,8 +1,8 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseState;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import org.junit.jupiter.api.BeforeEach;

View File

@@ -3,7 +3,7 @@ package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseState;
import com.cameleer.license.LicenseState;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

View File

@@ -1,64 +0,0 @@
package com.cameleer.server.core.license;
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;
class LicenseGateTest {
@Test
void absent_byDefault() {
LicenseGate gate = new LicenseGate();
assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT);
assertThat(gate.getEffectiveLimits().get("max_apps"))
.isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps"));
assertThat(gate.getCurrent()).isNull();
assertThat(gate.getInvalidReason()).isNull();
}
@Test
void load_setsActiveAndMergesLimits() {
LicenseGate gate = new LicenseGate();
LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", "label",
Map.of("max_apps", 50), Instant.now(),
Instant.now().plusSeconds(86400), 0);
gate.load(info);
assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE);
assertThat(gate.getEffectiveLimits().get("max_apps")).isEqualTo(50);
assertThat(gate.getEffectiveLimits().get("max_users"))
.isEqualTo(DefaultTierLimits.DEFAULTS.get("max_users"));
}
@Test
void markInvalid_overridesActive() {
LicenseGate gate = new LicenseGate();
LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null,
Map.of("max_apps", 50), Instant.now(),
Instant.now().plusSeconds(86400), 0);
gate.load(info);
gate.markInvalid("signature failed");
assertThat(gate.getState()).isEqualTo(LicenseState.INVALID);
assertThat(gate.getEffectiveLimits().get("max_apps"))
.isEqualTo(DefaultTierLimits.DEFAULTS.get("max_apps"));
assertThat(gate.getInvalidReason()).isEqualTo("signature failed");
}
@Test
void clear_returnsToAbsent() {
LicenseGate gate = new LicenseGate();
LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "acme", null,
Map.of(), Instant.now(),
Instant.now().plusSeconds(86400), 0);
gate.load(info);
gate.clear();
assertThat(gate.getState()).isEqualTo(LicenseState.ABSENT);
assertThat(gate.getCurrent()).isNull();
}
}

View File

@@ -1,141 +0,0 @@
package com.cameleer.server.core.license;
import org.junit.jupiter.api.Test;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Signature;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LicenseValidatorTest {
private KeyPair generateKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
return kpg.generateKeyPair();
}
private String sign(PrivateKey key, String payload) throws Exception {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(key);
signer.update(payload.getBytes());
return Base64.getEncoder().encodeToString(signer.sign());
}
@Test
void validate_validLicense_returnsLicenseInfo() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
Instant expires = Instant.now().plus(365, ChronoUnit.DAYS);
String payload = """
{"licenseId":"%s","tenantId":"acme","label":"HIGH","tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d,"gracePeriodDays":7}
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), expires.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
LicenseInfo info = validator.validate(token);
assertThat(info.label()).isEqualTo("HIGH");
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
assertThat(info.isExpired()).isFalse();
assertThat(info.tenantId()).isEqualTo("acme");
assertThat(info.gracePeriodDays()).isEqualTo(7);
}
@Test
void validate_expiredLicense_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
Instant past = Instant.now().minus(1, ChronoUnit.DAYS);
String payload = """
{"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":%d,"exp":%d}
""".formatted(UUID.randomUUID(), past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("expired");
}
@Test
void validate_tamperedPayload_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
String payload = """
{"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":0,"exp":9999999999}
""".formatted(UUID.randomUUID()).trim();
String signature = sign(kp.getPrivate(), payload);
// Tamper with payload
String tampered = payload.replace("LOW", "BUSINESS");
String token = Base64.getEncoder().encodeToString(tampered.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("signature");
}
@Test
void validate_missingTenantId_throws() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
Instant exp = Instant.now().plus(30, ChronoUnit.DAYS);
String payload = """
{"licenseId":"%s","tier":"X","limits":{},"iat":%d,"exp":%d}
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim();
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload);
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("tenantId");
}
@Test
void validate_tenantIdMismatch_throws() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "beta");
Instant exp = Instant.now().plus(30, ChronoUnit.DAYS);
String payload = """
{"licenseId":"%s","tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d}
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim();
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload);
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("tenantId");
}
@Test
void validate_missingLicenseId_throws() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
Instant exp = Instant.now().plus(30, ChronoUnit.DAYS);
String payload = """
{"tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d}
""".formatted(Instant.now().getEpochSecond(), exp.getEpochSecond()).trim();
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload);
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("licenseId");
}
}