diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java new file mode 100644 index 00000000..59df6a7a --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseChangedEvent.java @@ -0,0 +1,12 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.core.license.LicenseInfo; +import com.cameleer.server.core.license.LicenseState; + +import java.util.Objects; + +public record LicenseChangedEvent(LicenseState state, LicenseInfo current) { + public LicenseChangedEvent { + Objects.requireNonNull(state); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java new file mode 100644 index 00000000..7d3509af --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseService.java @@ -0,0 +1,133 @@ +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.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; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Single mediation point for license token install / replace / revalidate. + * + *

Audits under {@link AuditCategory#LICENSE}, persists to PostgreSQL via + * {@link LicenseRepository}, mutates the in-memory {@link LicenseGate}, and publishes a + * {@link LicenseChangedEvent} so downstream listeners (retention policy, license metrics, + * etc.) react uniformly to every state change.

+ */ +public class LicenseService { + + private static final Logger log = LoggerFactory.getLogger(LicenseService.class); + + private final String tenantId; + private final LicenseRepository repo; + private final LicenseGate gate; + private final LicenseValidator validator; + private final AuditService audit; + private final ApplicationEventPublisher events; + + public LicenseService(String tenantId, LicenseRepository repo, LicenseGate gate, + LicenseValidator validator, AuditService audit, + ApplicationEventPublisher events) { + this.tenantId = tenantId; + this.repo = repo; + this.gate = gate; + this.validator = validator; + this.audit = audit; + this.events = events; + } + + /** Install a token from any source (env, file, api, db). */ + public LicenseInfo install(String token, String installedBy, String source) { + LicenseInfo info; + try { + info = validator.validate(token); + } catch (Exception e) { + String reason = e.getMessage(); + gate.markInvalid(reason); + Map detail = new LinkedHashMap<>(); + detail.put("reason", reason); + detail.put("source", source); + audit.log(installedBy, "reject_license", AuditCategory.LICENSE, + tenantId, detail, AuditResult.FAILURE, null); + events.publishEvent(new LicenseChangedEvent(gate.getState(), gate.getCurrent())); + throw e instanceof RuntimeException re ? re : new IllegalArgumentException(e); + } + + Optional existing = repo.findByTenantId(tenantId); + Instant now = Instant.now(); + repo.upsert(new LicenseRecord( + tenantId, token, info.licenseId(), + now, installedBy, info.expiresAt(), now)); + gate.load(info); + + Map detail = new LinkedHashMap<>(); + detail.put("licenseId", info.licenseId().toString()); + detail.put("expiresAt", info.expiresAt().toString()); + detail.put("installedBy", installedBy); + detail.put("source", source); + if (existing.isPresent()) { + detail.put("previousLicenseId", existing.get().licenseId().toString()); + audit.log(installedBy, "replace_license", AuditCategory.LICENSE, + info.licenseId().toString(), detail, AuditResult.SUCCESS, null); + } else { + audit.log(installedBy, "install_license", AuditCategory.LICENSE, + info.licenseId().toString(), detail, AuditResult.SUCCESS, null); + } + + events.publishEvent(new LicenseChangedEvent(gate.getState(), info)); + return info; + } + + /** Boot-time load: prefer env/file overrides; falls back to DB; ABSENT if none. */ + public void loadInitial(Optional envToken, Optional fileToken) { + if (envToken.isPresent()) { + try { install(envToken.get(), "system", "env"); return; } + catch (Exception e) { log.error("env-var license rejected: {}", e.getMessage()); } + } + if (fileToken.isPresent()) { + try { install(fileToken.get(), "system", "file"); return; } + catch (Exception e) { log.error("file license rejected: {}", e.getMessage()); } + } + Optional persisted = repo.findByTenantId(tenantId); + if (persisted.isPresent()) { + try { install(persisted.get().token(), persisted.get().installedBy(), "db"); } + catch (Exception e) { log.error("DB license rejected: {}", e.getMessage()); } + } else { + log.info("No license configured - running in default tier"); + events.publishEvent(new LicenseChangedEvent(gate.getState(), null)); + } + } + + /** Re-run validation against the persisted token (daily job). */ + public void revalidate() { + Optional persisted = repo.findByTenantId(tenantId); + if (persisted.isEmpty()) return; + try { + LicenseInfo info = validator.validate(persisted.get().token()); + repo.touchValidated(tenantId, Instant.now()); + gate.load(info); + events.publishEvent(new LicenseChangedEvent(gate.getState(), info)); + } catch (Exception e) { + String reason = e.getMessage(); + gate.markInvalid(reason); + Map detail = new LinkedHashMap<>(); + detail.put("licenseId", persisted.get().licenseId().toString()); + detail.put("reason", reason); + audit.log("system", "revalidate_license", AuditCategory.LICENSE, + persisted.get().licenseId().toString(), detail, AuditResult.FAILURE, null); + events.publishEvent(new LicenseChangedEvent(gate.getState(), null)); + log.error("Revalidation failed: {}", reason); + } + } + + public String getTenantId() { return tenantId; } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java new file mode 100644 index 00000000..7f54da41 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseServiceTest.java @@ -0,0 +1,100 @@ +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.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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LicenseServiceTest { + + LicenseRepository repo; + LicenseGate gate; + AuditService audit; + ApplicationEventPublisher events; + LicenseValidator validator; + LicenseService svc; + + @BeforeEach + void setUp() { + repo = mock(LicenseRepository.class); + gate = new LicenseGate(); + audit = mock(AuditService.class); + events = mock(ApplicationEventPublisher.class); + validator = mock(LicenseValidator.class); + svc = new LicenseService("default", repo, gate, validator, audit, events); + } + + @Test + void install_validToken_persistsAndPublishes() { + LicenseInfo info = new LicenseInfo(UUID.randomUUID(), "default", null, + Map.of("max_apps", 5), Instant.now(), + Instant.now().plusSeconds(86400), 0); + when(validator.validate("tok")).thenReturn(info); + when(repo.findByTenantId("default")).thenReturn(Optional.empty()); + + svc.install("tok", "alice", "api"); + + assertThat(gate.getCurrent()).isEqualTo(info); + assertThat(gate.getState()).isEqualTo(LicenseState.ACTIVE); + verify(repo).upsert(any(LicenseRecord.class)); + verify(events).publishEvent(any(LicenseChangedEvent.class)); + verify(audit).log(eq("alice"), eq("install_license"), eq(AuditCategory.LICENSE), + any(), any(), eq(AuditResult.SUCCESS), isNull()); + } + + @Test + void install_invalidToken_marksGateInvalidAndAudits() { + when(validator.validate("bad")).thenThrow(new SecurityException("signature failed")); + + try { + svc.install("bad", "alice", "api"); + } catch (Exception ignored) {} + + assertThat(gate.getState()).isEqualTo(LicenseState.INVALID); + assertThat(gate.getInvalidReason()).contains("signature failed"); + verify(repo, never()).upsert(any()); + verify(audit).log(eq("alice"), eq("reject_license"), eq(AuditCategory.LICENSE), + any(), any(), eq(AuditResult.FAILURE), isNull()); + } + + @Test + void install_replacingExistingLicense_auditsReplace() { + LicenseInfo old = new LicenseInfo(UUID.randomUUID(), "default", null, + Map.of(), Instant.now(), + Instant.now().plusSeconds(86400), 0); + gate.load(old); + when(repo.findByTenantId("default")).thenReturn(Optional.of( + new LicenseRecord("default", "old", old.licenseId(), + Instant.now(), "system", + Instant.now().plusSeconds(86400), Instant.now()))); + LicenseInfo fresh = new LicenseInfo(UUID.randomUUID(), "default", null, + Map.of(), Instant.now(), + Instant.now().plusSeconds(86400), 0); + when(validator.validate("new")).thenReturn(fresh); + + svc.install("new", "alice", "api"); + + verify(audit).log(eq("alice"), eq("replace_license"), eq(AuditCategory.LICENSE), + any(), any(), eq(AuditResult.SUCCESS), isNull()); + } +}