From b95e80a24a7b81c6597f75bcbc20e6c6a0bf46f6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:16:49 +0200 Subject: [PATCH] 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) --- .../server/app/config/LicenseBeanConfig.java | 125 +++++++++++++----- 1 file changed, 92 insertions(+), 33 deletions(-) diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java index 926c2a52..5d6bb834 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java @@ -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): + * + *
    + *
  1. {@link LicenseGate} — always present, mutated by {@link LicenseService}.
  2. + *
  3. {@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.
  4. + *
  5. {@link LicenseService} — single mediation point for install / replace / revalidate; + * audits + persists + publishes {@code LicenseChangedEvent}.
  6. + *
  7. {@link LicenseBootLoader} — {@code @PostConstruct} drives {@code loadInitial} after the + * Spring context is ready. Resolution order: env var > license file > persisted DB row.
  8. + *
+ */ @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} 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 env = (envToken != null && !envToken.isBlank()) + ? Optional.of(envToken) : Optional.empty(); + Optional 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); + } } }