diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java new file mode 100644 index 0000000..733d85a --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java @@ -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 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 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); + }; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java new file mode 100644 index 0000000..9e7f8b6 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java @@ -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 getActiveLicense(UUID tenantId) { + return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId); + } + + public Optional> 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 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 features, + Map limits, Instant issuedAt, Instant expiresAt) { + try { + String header = base64UrlEncode(objectMapper.writeValueAsBytes( + Map.of("alg", "EdDSA", "typ", "JWT", "kid", "license"))); + + Map 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); + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java new file mode 100644 index 0000000..f726914 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java @@ -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); + } +}