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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user