feat: add license service with Ed25519 JWT signing and verification
Generates tier-aware license tokens with features/limits per tier. Verifies signature and expiry. Audit logged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class LicenseDefaults {
|
||||||
|
|
||||||
|
private LicenseDefaults() {}
|
||||||
|
|
||||||
|
public static Map<String, Object> featuresForTier(Tier tier) {
|
||||||
|
return switch (tier) {
|
||||||
|
case LOW -> Map.of(
|
||||||
|
"topology", true, "lineage", false,
|
||||||
|
"correlation", false, "debugger", false, "replay", false);
|
||||||
|
case MID -> Map.of(
|
||||||
|
"topology", true, "lineage", true,
|
||||||
|
"correlation", true, "debugger", false, "replay", false);
|
||||||
|
case HIGH -> Map.of(
|
||||||
|
"topology", true, "lineage", true,
|
||||||
|
"correlation", true, "debugger", true, "replay", true);
|
||||||
|
case BUSINESS -> Map.of(
|
||||||
|
"topology", true, "lineage", true,
|
||||||
|
"correlation", true, "debugger", true, "replay", true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Map<String, Object> limitsForTier(Tier tier) {
|
||||||
|
return switch (tier) {
|
||||||
|
case LOW -> Map.of(
|
||||||
|
"max_agents", 3, "retention_days", 7,
|
||||||
|
"max_environments", 1);
|
||||||
|
case MID -> Map.of(
|
||||||
|
"max_agents", 10, "retention_days", 30,
|
||||||
|
"max_environments", 2);
|
||||||
|
case HIGH -> Map.of(
|
||||||
|
"max_agents", 50, "retention_days", 90,
|
||||||
|
"max_environments", -1);
|
||||||
|
case BUSINESS -> Map.of(
|
||||||
|
"max_agents", -1, "retention_days", 365,
|
||||||
|
"max_environments", -1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||||
|
import net.siegeln.cameleer.saas.config.JwtConfig;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class LicenseService {
|
||||||
|
|
||||||
|
private final LicenseRepository licenseRepository;
|
||||||
|
private final JwtConfig jwtConfig;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public LicenseService(LicenseRepository licenseRepository, JwtConfig jwtConfig, AuditService auditService) {
|
||||||
|
this.licenseRepository = licenseRepository;
|
||||||
|
this.jwtConfig = jwtConfig;
|
||||||
|
this.auditService = auditService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) {
|
||||||
|
var features = LicenseDefaults.featuresForTier(tenant.getTier());
|
||||||
|
var limits = LicenseDefaults.limitsForTier(tenant.getTier());
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant expiresAt = now.plus(validity);
|
||||||
|
|
||||||
|
String token = signLicenseJwt(tenant.getId(), tenant.getTier().name(), features, limits, now, expiresAt);
|
||||||
|
|
||||||
|
var entity = new LicenseEntity();
|
||||||
|
entity.setTenantId(tenant.getId());
|
||||||
|
entity.setTier(tenant.getTier().name());
|
||||||
|
entity.setFeatures(features);
|
||||||
|
entity.setLimits(limits);
|
||||||
|
entity.setIssuedAt(now);
|
||||||
|
entity.setExpiresAt(expiresAt);
|
||||||
|
entity.setToken(token);
|
||||||
|
|
||||||
|
var saved = licenseRepository.save(entity);
|
||||||
|
|
||||||
|
auditService.log(actorId, null, tenant.getId(),
|
||||||
|
AuditAction.LICENSE_GENERATE, saved.getId().toString(),
|
||||||
|
null, null, "SUCCESS", null);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<LicenseEntity> getActiveLicense(UUID tenantId) {
|
||||||
|
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Map<String, Object>> verifyLicenseToken(String token) {
|
||||||
|
try {
|
||||||
|
String[] parts = token.split("\\.");
|
||||||
|
if (parts.length != 3) return Optional.empty();
|
||||||
|
|
||||||
|
String signingInput = parts[0] + "." + parts[1];
|
||||||
|
byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]);
|
||||||
|
|
||||||
|
Signature sig = Signature.getInstance("Ed25519");
|
||||||
|
sig.initVerify(jwtConfig.getPublicKey());
|
||||||
|
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
if (!sig.verify(signatureBytes)) return Optional.empty();
|
||||||
|
|
||||||
|
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
|
||||||
|
Map<String, Object> payload = objectMapper.readValue(payloadBytes, new TypeReference<>() {});
|
||||||
|
|
||||||
|
long exp = ((Number) payload.get("exp")).longValue();
|
||||||
|
if (Instant.now().getEpochSecond() >= exp) return Optional.empty();
|
||||||
|
|
||||||
|
return Optional.of(payload);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String signLicenseJwt(UUID tenantId, String tier, Map<String, Object> features,
|
||||||
|
Map<String, Object> limits, Instant issuedAt, Instant expiresAt) {
|
||||||
|
try {
|
||||||
|
String header = base64UrlEncode(objectMapper.writeValueAsBytes(
|
||||||
|
Map.of("alg", "EdDSA", "typ", "JWT", "kid", "license")));
|
||||||
|
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
|
payload.put("tenant_id", tenantId.toString());
|
||||||
|
payload.put("tier", tier);
|
||||||
|
payload.put("features", features);
|
||||||
|
payload.put("limits", limits);
|
||||||
|
payload.put("iat", issuedAt.getEpochSecond());
|
||||||
|
payload.put("exp", expiresAt.getEpochSecond());
|
||||||
|
|
||||||
|
String payloadEncoded = base64UrlEncode(objectMapper.writeValueAsBytes(payload));
|
||||||
|
String signingInput = header + "." + payloadEncoded;
|
||||||
|
|
||||||
|
Signature sig = Signature.getInstance("Ed25519");
|
||||||
|
sig.initSign(jwtConfig.getPrivateKey());
|
||||||
|
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
String signature = base64UrlEncode(sig.sign());
|
||||||
|
|
||||||
|
return signingInput + "." + signature;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to sign license JWT", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String base64UrlEncode(byte[] data) {
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license;
|
||||||
|
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||||
|
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||||
|
import net.siegeln.cameleer.saas.config.JwtConfig;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
|
import net.siegeln.cameleer.saas.tenant.TenantStatus;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class LicenseServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private LicenseRepository licenseRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private AuditService auditService;
|
||||||
|
|
||||||
|
private JwtConfig jwtConfig;
|
||||||
|
private LicenseService licenseService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
jwtConfig = new JwtConfig();
|
||||||
|
jwtConfig.init(); // generates ephemeral keys for testing
|
||||||
|
licenseService = new LicenseService(licenseRepository, jwtConfig, auditService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TenantEntity createTenant(Tier tier) {
|
||||||
|
var tenant = new TenantEntity();
|
||||||
|
tenant.setName("Test Tenant");
|
||||||
|
tenant.setSlug("test");
|
||||||
|
tenant.setTier(tier);
|
||||||
|
tenant.setStatus(TenantStatus.ACTIVE);
|
||||||
|
try {
|
||||||
|
var idField = TenantEntity.class.getDeclaredField("id");
|
||||||
|
idField.setAccessible(true);
|
||||||
|
idField.set(tenant, UUID.randomUUID());
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LicenseEntity withGeneratedId(LicenseEntity entity) {
|
||||||
|
try {
|
||||||
|
var idField = LicenseEntity.class.getDeclaredField("id");
|
||||||
|
idField.setAccessible(true);
|
||||||
|
idField.set(entity, UUID.randomUUID());
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateLicense_producesValidSignedToken() {
|
||||||
|
var tenant = createTenant(Tier.MID);
|
||||||
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
|
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(license.getToken()).isNotBlank();
|
||||||
|
assertThat(license.getToken().split("\\.")).hasSize(3);
|
||||||
|
assertThat(license.getTier()).isEqualTo("MID");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateLicense_setsCorrectFeaturesForTier() {
|
||||||
|
var tenant = createTenant(Tier.HIGH);
|
||||||
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
|
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(license.getFeatures()).containsEntry("debugger", true);
|
||||||
|
assertThat(license.getFeatures()).containsEntry("replay", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateLicense_setsCorrectLimitsForTier() {
|
||||||
|
var tenant = createTenant(Tier.LOW);
|
||||||
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
|
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(license.getLimits()).containsEntry("max_agents", 3);
|
||||||
|
assertThat(license.getLimits()).containsEntry("retention_days", 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyLicenseToken_validTokenReturnsPayload() {
|
||||||
|
var tenant = createTenant(Tier.MID);
|
||||||
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
|
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||||
|
var payload = licenseService.verifyLicenseToken(license.getToken());
|
||||||
|
|
||||||
|
assertThat(payload).isPresent();
|
||||||
|
assertThat(payload.get().get("tier")).isEqualTo("MID");
|
||||||
|
assertThat(payload.get().get("tenant_id")).isEqualTo(tenant.getId().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyLicenseToken_tamperedTokenReturnsEmpty() {
|
||||||
|
var tenant = createTenant(Tier.MID);
|
||||||
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
|
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||||
|
String tampered = license.getToken() + "x";
|
||||||
|
|
||||||
|
var payload = licenseService.verifyLicenseToken(tampered);
|
||||||
|
|
||||||
|
assertThat(payload).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateLicense_logsAuditEvent() {
|
||||||
|
var tenant = createTenant(Tier.LOW);
|
||||||
|
var actorId = UUID.randomUUID();
|
||||||
|
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||||
|
|
||||||
|
licenseService.generateLicense(tenant, Duration.ofDays(30), actorId);
|
||||||
|
|
||||||
|
var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
|
||||||
|
verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
|
||||||
|
assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.LICENSE_GENERATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user