feat(license): wire LicenseService into boot order (env > file > DB)
LicenseBootLoader @PostConstruct calls LicenseService.loadInitial, which delegates to install() so env-var/file/DB paths share a single audit + event-publish code path. A missing public key now produces an always-failing validator (constructed with a throwaway keypair so the parent ctor accepts it) so loaded tokens route to INVALID instead of being silently ignored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,48 @@
|
||||
package com.cameleer.server.app.config;
|
||||
|
||||
import com.cameleer.server.app.license.LicenseRepository;
|
||||
import com.cameleer.server.app.license.LicenseService;
|
||||
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 jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* License bean topology (4 beans, in dependency order):
|
||||
*
|
||||
* <ol>
|
||||
* <li>{@link LicenseGate} — always present, mutated by {@link LicenseService}.</li>
|
||||
* <li>{@link LicenseValidator} — always present. When no public key is configured, returns an
|
||||
* always-failing override so any loaded token routes through {@code install()} and is
|
||||
* audited as INVALID rather than silently ignored.</li>
|
||||
* <li>{@link LicenseService} — single mediation point for install / replace / revalidate;
|
||||
* audits + persists + publishes {@code LicenseChangedEvent}.</li>
|
||||
* <li>{@link LicenseBootLoader} — {@code @PostConstruct} drives {@code loadInitial} after the
|
||||
* Spring context is ready. Resolution order: env var > license file > persisted DB row.</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Configuration
|
||||
public class LicenseBeanConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
|
||||
|
||||
@Value("${cameleer.server.tenant.id:default}")
|
||||
private String tenantId;
|
||||
|
||||
@Value("${cameleer.server.license.token:}")
|
||||
private String licenseToken;
|
||||
|
||||
@@ -26,46 +52,79 @@ public class LicenseBeanConfig {
|
||||
@Value("${cameleer.server.license.publickey:}")
|
||||
private String licensePublicKey;
|
||||
|
||||
@Value("${cameleer.server.tenant.id:default}")
|
||||
private String tenantId;
|
||||
|
||||
@Bean
|
||||
public LicenseGate licenseGate() {
|
||||
LicenseGate gate = new LicenseGate();
|
||||
|
||||
String token = resolveLicenseToken();
|
||||
if (token == null || token.isBlank()) {
|
||||
log.info("No license configured — running in open mode (all features enabled)");
|
||||
return gate;
|
||||
}
|
||||
|
||||
if (licensePublicKey == null || licensePublicKey.isBlank()) {
|
||||
log.warn("License token provided but no public key configured (CAMELEER_SERVER_LICENSE_PUBLICKEY). Running in open mode.");
|
||||
return gate;
|
||||
}
|
||||
|
||||
try {
|
||||
LicenseValidator validator = new LicenseValidator(licensePublicKey, tenantId);
|
||||
LicenseInfo info = validator.validate(token);
|
||||
gate.load(info);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to validate license: {}. Running in open mode.", e.getMessage());
|
||||
}
|
||||
|
||||
return gate;
|
||||
return new LicenseGate();
|
||||
}
|
||||
|
||||
private String resolveLicenseToken() {
|
||||
if (licenseToken != null && !licenseToken.isBlank()) {
|
||||
return licenseToken;
|
||||
}
|
||||
if (licenseFile != null && !licenseFile.isBlank()) {
|
||||
@Bean
|
||||
public LicenseValidator licenseValidator() {
|
||||
if (licensePublicKey == null || licensePublicKey.isBlank()) {
|
||||
log.warn("CAMELEER_SERVER_LICENSE_PUBLICKEY not set — all licenses will be rejected as INVALID");
|
||||
// Generate a throwaway, structurally-valid Ed25519 keypair just to satisfy the
|
||||
// parent constructor's X.509 SubjectPublicKeyInfo decode + Ed25519 point validation.
|
||||
// The overridden validate(...) always throws, so the dummy key is never used to
|
||||
// verify anything — it only exists so the bean is constructable in misconfigured
|
||||
// installs and any token that is loaded routes to INVALID via install()'s catch.
|
||||
try {
|
||||
return Files.readString(Path.of(licenseFile)).trim();
|
||||
KeyPair throwaway = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||
String dummyPub = Base64.getEncoder().encodeToString(throwaway.getPublic().getEncoded());
|
||||
return new LicenseValidator(dummyPub, tenantId) {
|
||||
@Override
|
||||
public LicenseInfo validate(String token) {
|
||||
throw new IllegalStateException("license public key not configured");
|
||||
}
|
||||
};
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage());
|
||||
throw new IllegalStateException("Failed to construct fallback license validator", e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return new LicenseValidator(licensePublicKey, tenantId);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public LicenseService licenseService(LicenseRepository repo,
|
||||
LicenseGate gate,
|
||||
LicenseValidator validator,
|
||||
AuditService audit,
|
||||
ApplicationEventPublisher events) {
|
||||
return new LicenseService(tenantId, repo, gate, validator, audit, events);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public LicenseBootLoader licenseBootLoader(LicenseService svc) {
|
||||
return new LicenseBootLoader(svc, licenseToken, licenseFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code @PostConstruct} bridge that converts env-var/file values into the
|
||||
* {@code Optional<String>} pair {@link LicenseService#loadInitial} expects, so
|
||||
* env-var, file, and DB paths share the same audit + event-publish code path.
|
||||
*/
|
||||
public static class LicenseBootLoader {
|
||||
private final LicenseService svc;
|
||||
private final String envToken;
|
||||
private final String filePath;
|
||||
|
||||
public LicenseBootLoader(LicenseService svc, String envToken, String filePath) {
|
||||
this.svc = svc;
|
||||
this.envToken = envToken;
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void load() {
|
||||
Optional<String> env = (envToken != null && !envToken.isBlank())
|
||||
? Optional.of(envToken) : Optional.empty();
|
||||
Optional<String> file = Optional.empty();
|
||||
if (filePath != null && !filePath.isBlank()) {
|
||||
try {
|
||||
file = Optional.of(Files.readString(Path.of(filePath)).trim());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read license file {}: {}", filePath, e.getMessage());
|
||||
}
|
||||
}
|
||||
svc.loadInitial(env, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user