feat(license): LicenseService + LicenseChangedEvent

Single mediation point for token install/replace/revalidate. Audits
under AuditCategory.LICENSE, persists to PG, mutates the LicenseGate,
and publishes LicenseChangedEvent so downstream listeners
(RetentionPolicyApplier, LicenseMetrics) react uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 11:11:48 +02:00
parent 2f75b2865b
commit 6fbcf10ee4
3 changed files with 245 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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.
*
* <p>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.</p>
*/
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<String, Object> 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<LicenseRecord> 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<String, Object> 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<String> envToken, Optional<String> 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<LicenseRecord> 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<LicenseRecord> 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<String, Object> 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; }
}

View File

@@ -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());
}
}