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

@@ -1,30 +0,0 @@
package com.cameleer.server.core.license;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public final class DefaultTierLimits {
public static final Map<String, Integer> DEFAULTS;
static {
Map<String, Integer> m = new LinkedHashMap<>();
m.put("max_environments", 1);
m.put("max_apps", 3);
m.put("max_agents", 5);
m.put("max_users", 3);
m.put("max_outbound_connections", 1);
m.put("max_alert_rules", 2);
m.put("max_total_cpu_millis", 2000);
m.put("max_total_memory_mb", 2048);
m.put("max_total_replicas", 5);
m.put("max_execution_retention_days", 1);
m.put("max_log_retention_days", 1);
m.put("max_metric_retention_days", 1);
m.put("max_jar_retention_count", 3);
DEFAULTS = Collections.unmodifiableMap(m);
}
private DefaultTierLimits() {}
}

View File

@@ -1,5 +1,9 @@
package com.cameleer.server.core.license;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseLimits;
import com.cameleer.license.LicenseState;
import com.cameleer.license.LicenseStateMachine;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@@ -1,46 +0,0 @@
package com.cameleer.server.core.license;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
/** A parsed and signature-verified license. Construct via {@link LicenseValidator}. */
public record LicenseInfo(
UUID licenseId,
String tenantId,
String label,
Map<String, Integer> limits,
Instant issuedAt,
Instant expiresAt,
int gracePeriodDays
) {
public LicenseInfo {
Objects.requireNonNull(licenseId, "licenseId is required");
Objects.requireNonNull(tenantId, "tenantId is required");
Objects.requireNonNull(limits, "limits is required");
Objects.requireNonNull(issuedAt, "issuedAt is required");
Objects.requireNonNull(expiresAt, "expiresAt is required");
if (tenantId.isBlank()) {
throw new IllegalArgumentException("tenantId must not be blank");
}
if (gracePeriodDays < 0) {
throw new IllegalArgumentException("gracePeriodDays must be >= 0");
}
}
/** True iff now > expiresAt + gracePeriodDays. */
public boolean isExpired() {
Instant deadline = expiresAt.plusSeconds((long) gracePeriodDays * 86400);
return Instant.now().isAfter(deadline);
}
/** True iff now > expiresAt (regardless of grace). Used by the state machine to distinguish ACTIVE from GRACE. */
public boolean isAfterRawExpiry() {
return Instant.now().isAfter(expiresAt);
}
public int getLimit(String key, int defaultValue) {
return limits.getOrDefault(key, defaultValue);
}
}

View File

@@ -1,36 +0,0 @@
package com.cameleer.server.core.license;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public record LicenseLimits(Map<String, Integer> values) {
public LicenseLimits {
Objects.requireNonNull(values, "values");
}
public static LicenseLimits defaultsOnly() {
return new LicenseLimits(DefaultTierLimits.DEFAULTS);
}
public static LicenseLimits mergeOverDefaults(Map<String, Integer> overrides) {
Map<String, Integer> merged = new LinkedHashMap<>(DefaultTierLimits.DEFAULTS);
if (overrides != null) merged.putAll(overrides);
return new LicenseLimits(Collections.unmodifiableMap(merged));
}
public int get(String key) {
Integer v = values.get(key);
if (v == null) {
throw new IllegalArgumentException("Unknown license limit key: " + key);
}
return v;
}
public boolean isDefaultSourced(String key, LicenseInfo license) {
if (license == null) return true;
return !license.limits().containsKey(key);
}
}

View File

@@ -1,9 +0,0 @@
package com.cameleer.server.core.license;
public enum LicenseState {
ABSENT,
ACTIVE,
GRACE,
EXPIRED,
INVALID
}

View File

@@ -1,26 +0,0 @@
package com.cameleer.server.core.license;
public final class LicenseStateMachine {
private LicenseStateMachine() {}
/**
* @param license parsed license, or null if no license is loaded
* @param invalidReason non-null if the last validation attempt failed
*/
public static LicenseState classify(LicenseInfo license, String invalidReason) {
if (invalidReason != null) {
return LicenseState.INVALID;
}
if (license == null) {
return LicenseState.ABSENT;
}
if (!license.isAfterRawExpiry()) {
return LicenseState.ACTIVE;
}
if (!license.isExpired()) {
return LicenseState.GRACE;
}
return LicenseState.EXPIRED;
}
}

View File

@@ -1,117 +0,0 @@
package com.cameleer.server.core.license;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public class LicenseValidator {
private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
private final PublicKey publicKey;
private final String expectedTenantId;
public LicenseValidator(String publicKeyBase64, String expectedTenantId) {
Objects.requireNonNull(expectedTenantId, "expectedTenantId is required");
if (expectedTenantId.isBlank()) {
throw new IllegalArgumentException("expectedTenantId must not be blank");
}
try {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
KeyFactory kf = KeyFactory.getInstance("Ed25519");
this.publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes));
} catch (Exception e) {
throw new IllegalStateException("Failed to load license public key", e);
}
this.expectedTenantId = expectedTenantId;
}
public LicenseInfo validate(String token) {
String[] parts = token.split("\\.", 2);
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid license token format: expected payload.signature");
}
byte[] payloadBytes = Base64.getDecoder().decode(parts[0]);
byte[] signatureBytes = Base64.getDecoder().decode(parts[1]);
try {
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(payloadBytes);
if (!verifier.verify(signatureBytes)) {
throw new SecurityException("License signature verification failed");
}
} catch (SecurityException e) {
throw e;
} catch (Exception e) {
throw new SecurityException("License signature verification failed", e);
}
try {
JsonNode root = objectMapper.readTree(payloadBytes);
String licenseIdStr = textOrThrow(root, "licenseId");
UUID licenseId;
try {
licenseId = UUID.fromString(licenseIdStr);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("licenseId is not a valid UUID: " + licenseIdStr);
}
String tenantId = textOrThrow(root, "tenantId");
if (!tenantId.equals(expectedTenantId)) {
throw new IllegalArgumentException(
"License tenantId '" + tenantId + "' does not match server tenant '" + expectedTenantId + "'");
}
String label = root.has("label") ? root.get("label").asText() : null;
Map<String, Integer> limits = new HashMap<>();
if (root.has("limits")) {
root.get("limits").fields().forEachRemaining(entry ->
limits.put(entry.getKey(), entry.getValue().asInt()));
}
Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now();
if (!root.has("exp")) {
throw new IllegalArgumentException("exp is required");
}
Instant expiresAt = Instant.ofEpochSecond(root.get("exp").asLong());
int gracePeriodDays = root.has("gracePeriodDays") ? root.get("gracePeriodDays").asInt() : 0;
LicenseInfo info = new LicenseInfo(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays);
if (info.isExpired()) {
throw new IllegalArgumentException("License expired at " + expiresAt
+ " (grace period " + gracePeriodDays + " days)");
}
return info;
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("Failed to parse license payload", e);
}
}
private static String textOrThrow(JsonNode root, String field) {
if (!root.has(field) || root.get(field).asText().isBlank()) {
throw new IllegalArgumentException(field + " is required");
}
return root.get(field).asText();
}
}

View File

@@ -1,30 +0,0 @@
package com.cameleer.server.core.license;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class DefaultTierLimitsTest {
@Test
void allDocumentedKeysHaveDefaults() {
for (String key : new String[]{
"max_environments", "max_apps", "max_agents", "max_users",
"max_outbound_connections", "max_alert_rules",
"max_total_cpu_millis", "max_total_memory_mb", "max_total_replicas",
"max_execution_retention_days", "max_log_retention_days",
"max_metric_retention_days", "max_jar_retention_count"
}) {
assertThat(DefaultTierLimits.DEFAULTS).containsKey(key);
}
}
@Test
void specificValues() {
assertThat(DefaultTierLimits.DEFAULTS.get("max_environments")).isEqualTo(1);
assertThat(DefaultTierLimits.DEFAULTS.get("max_apps")).isEqualTo(3);
assertThat(DefaultTierLimits.DEFAULTS.get("max_agents")).isEqualTo(5);
assertThat(DefaultTierLimits.DEFAULTS.get("max_total_cpu_millis")).isEqualTo(2000);
assertThat(DefaultTierLimits.DEFAULTS.get("max_log_retention_days")).isEqualTo(1);
}
}

View File

@@ -0,0 +1,67 @@
package com.cameleer.server.core.license;
import com.cameleer.license.DefaultTierLimits;
import com.cameleer.license.LicenseInfo;
import com.cameleer.license.LicenseState;
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,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;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LicenseInfoTest {
@Test
void requiresLicenseId() {
assertThatThrownBy(() -> new LicenseInfo(
null, "acme", "label",
Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("licenseId");
}
@Test
void requiresTenantId() {
assertThatThrownBy(() -> new LicenseInfo(
UUID.randomUUID(), null, "label",
Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("tenantId");
}
@Test
void emptyTenantIdRejected() {
assertThatThrownBy(() -> new LicenseInfo(
UUID.randomUUID(), " ", "label",
Map.of(), Instant.now(), Instant.now().plusSeconds(60), 0))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void getLimit_returnsDefaultWhenMissing() {
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(), "acme", null,
Map.of("max_apps", 5), Instant.now(),
Instant.now().plusSeconds(60), 0);
assertThat(info.getLimit("max_apps", 99)).isEqualTo(5);
assertThat(info.getLimit("max_users", 99)).isEqualTo(99);
}
@Test
void isExpired_honoursGracePeriod() {
Instant pastByTen = Instant.now().minusSeconds(10 * 86400);
LicenseInfo withinGrace = new LicenseInfo(
UUID.randomUUID(), "acme", null, Map.of(),
Instant.now().minusSeconds(40 * 86400),
pastByTen, 30);
assertThat(withinGrace.isExpired()).isFalse(); // 10 days into a 30-day grace
LicenseInfo pastGrace = new LicenseInfo(
UUID.randomUUID(), "acme", null, Map.of(),
Instant.now().minusSeconds(40 * 86400),
pastByTen, 5);
assertThat(pastGrace.isExpired()).isTrue(); // 10 days is past the 5-day grace
}
}

View File

@@ -1,57 +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 LicenseStateMachineTest {
@Test
void noLicense_isAbsent() {
assertThat(LicenseStateMachine.classify(null, null)).isEqualTo(LicenseState.ABSENT);
}
@Test
void invalidReason_isInvalid() {
assertThat(LicenseStateMachine.classify(null, "signature failed")).isEqualTo(LicenseState.INVALID);
}
@Test
void activeBeforeExp() {
LicenseInfo info = info(Instant.now().plusSeconds(86400), 0);
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.ACTIVE);
}
@Test
void graceWithinGracePeriod() {
LicenseInfo info = info(Instant.now().minusSeconds(86400), 7);
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.GRACE);
}
@Test
void expiredAfterGrace() {
LicenseInfo info = info(Instant.now().minusSeconds(8L * 86400), 7);
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED);
}
@Test
void expiredImmediatelyWithZeroGrace() {
LicenseInfo info = info(Instant.now().minusSeconds(60), 0);
assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED);
}
@Test
void invalidWinsOverPresentLicense() {
LicenseInfo info = info(Instant.now().plusSeconds(86400), 0);
assertThat(LicenseStateMachine.classify(info, "tenant mismatch")).isEqualTo(LicenseState.INVALID);
}
private LicenseInfo info(Instant exp, int graceDays) {
return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(),
Instant.now().minusSeconds(3600), exp, graceDays);
}
}