refactor(license): extract cameleer-license-api module from server-core
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}.
|
||||
*/
|
||||
|
||||
@@ -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}.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user