From 7a8960ca461daee4d2dde6ba97316eee37d3c8f8 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:24:23 +0200 Subject: [PATCH] feat: add vendor license minting, presets, and verify endpoints - POST /vendor/tenants/{id}/license now accepts MintLicenseRequest body with custom limits, expiresAt, gracePeriodDays, label, pushToServer - Returns LicenseBundleResponse with token + public key + tenant slug - GET /vendor/license-presets returns tier preset limits - POST /vendor/license/verify decodes and validates signed tokens - GET /vendor/signing-key/public returns the Ed25519 public key - VendorTenantService.mintLicense() supports configurable minting - Updated portal DTOs to drop features, add label + gracePeriodDays Co-Authored-By: Claude Opus 4.6 (1M context) --- .../license/dto/LicenseBundleResponse.java | 32 +++++++ .../saas/license/dto/LicensePreset.java | 5 ++ .../saas/license/dto/MintLicenseRequest.java | 13 +++ .../license/dto/VerifyLicenseRequest.java | 3 + .../license/dto/VerifyLicenseResponse.java | 20 +++++ .../saas/vendor/VendorLicenseController.java | 83 +++++++++++++++++++ .../saas/vendor/VendorTenantController.java | 20 ++++- .../saas/vendor/VendorTenantService.java | 70 +++++++++++++--- 8 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/vendor/VendorLicenseController.java diff --git a/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java new file mode 100644 index 0000000..2ba907d --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseBundleResponse.java @@ -0,0 +1,32 @@ +package net.siegeln.cameleer.saas.license.dto; + +import net.siegeln.cameleer.saas.license.LicenseEntity; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +public record LicenseBundleResponse( + UUID id, + UUID tenantId, + String tenantSlug, + String tier, + String label, + Map limits, + int gracePeriodDays, + Instant issuedAt, + Instant expiresAt, + String token, + String publicKeyB64, + boolean pushedToServer +) { + public static LicenseBundleResponse from(LicenseEntity e, String tenantSlug, + String publicKeyB64, boolean pushed) { + return new LicenseBundleResponse( + e.getId(), e.getTenantId(), tenantSlug, e.getTier(), + e.getLabel(), e.getLimits(), e.getGracePeriodDays(), + e.getIssuedAt(), e.getExpiresAt(), e.getToken(), + publicKeyB64, pushed + ); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java new file mode 100644 index 0000000..4557c11 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicensePreset.java @@ -0,0 +1,5 @@ +package net.siegeln.cameleer.saas.license.dto; + +import java.util.Map; + +public record LicensePreset(String tier, Map limits) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java b/src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java new file mode 100644 index 0000000..a7799fb --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/dto/MintLicenseRequest.java @@ -0,0 +1,13 @@ +package net.siegeln.cameleer.saas.license.dto; + +import java.time.Instant; +import java.util.Map; + +public record MintLicenseRequest( + String tier, + Map limits, + Instant expiresAt, + Integer gracePeriodDays, + String label, + boolean pushToServer +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java b/src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java new file mode 100644 index 0000000..34ad82a --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseRequest.java @@ -0,0 +1,3 @@ +package net.siegeln.cameleer.saas.license.dto; + +public record VerifyLicenseRequest(String token) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java b/src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java new file mode 100644 index 0000000..d6b4b3b --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/license/dto/VerifyLicenseResponse.java @@ -0,0 +1,20 @@ +package net.siegeln.cameleer.saas.license.dto; + +import java.time.Instant; +import java.util.Map; + +public record VerifyLicenseResponse( + boolean valid, + String state, + String tenantId, + String label, + Map limits, + Instant issuedAt, + Instant expiresAt, + int gracePeriodDays, + String error +) { + public static VerifyLicenseResponse invalid(String error) { + return new VerifyLicenseResponse(false, "INVALID", null, null, null, null, null, 0, error); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorLicenseController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorLicenseController.java new file mode 100644 index 0000000..7ed897b --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorLicenseController.java @@ -0,0 +1,83 @@ +package net.siegeln.cameleer.saas.vendor; + +import com.cameleer.server.core.license.LicenseInfo; +import net.siegeln.cameleer.saas.license.LicenseDefaults; +import net.siegeln.cameleer.saas.license.LicenseService; +import net.siegeln.cameleer.saas.license.dto.LicensePreset; +import net.siegeln.cameleer.saas.license.dto.VerifyLicenseRequest; +import net.siegeln.cameleer.saas.license.dto.VerifyLicenseResponse; +import net.siegeln.cameleer.saas.tenant.Tier; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/vendor") +@PreAuthorize("hasAuthority('SCOPE_platform:admin')") +public class VendorLicenseController { + + private final VendorTenantService vendorTenantService; + private final LicenseService licenseService; + + public VendorLicenseController(VendorTenantService vendorTenantService, + LicenseService licenseService) { + this.vendorTenantService = vendorTenantService; + this.licenseService = licenseService; + } + + @GetMapping("/license-presets") + public ResponseEntity> getPresets() { + List presets = Arrays.stream(Tier.values()) + .map(t -> new LicensePreset(t.name(), LicenseDefaults.limitsForTier(t))) + .toList(); + return ResponseEntity.ok(presets); + } + + @PostMapping("/license/verify") + public ResponseEntity verifyLicense(@RequestBody VerifyLicenseRequest request) { + if (request.token() == null || request.token().isBlank()) { + return ResponseEntity.badRequest().body(VerifyLicenseResponse.invalid("Token is required")); + } + + var result = licenseService.verifyTokenSignature(request.token()); + if (result.isEmpty()) { + return ResponseEntity.ok(VerifyLicenseResponse.invalid("Invalid signature or malformed token")); + } + + LicenseInfo info = result.get(); + String state = computeState(info); + + return ResponseEntity.ok(new VerifyLicenseResponse( + true, state, + info.tenantId(), info.label(), info.limits(), + info.issuedAt(), info.expiresAt(), info.gracePeriodDays(), + null + )); + } + + @GetMapping("/signing-key/public") + public ResponseEntity> getPublicKey() { + return ResponseEntity.ok(Map.of("publicKey", vendorTenantService.getPublicKeyBase64())); + } + + private String computeState(LicenseInfo info) { + Instant now = Instant.now(); + if (now.isBefore(info.expiresAt()) || now.equals(info.expiresAt())) { + return "ACTIVE"; + } + Instant graceEnd = info.expiresAt().plusSeconds((long) info.gracePeriodDays() * 86400); + if (now.isBefore(graceEnd) || now.equals(graceEnd)) { + return "GRACE"; + } + return "EXPIRED"; + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java index 4634112..fda8f3e 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java @@ -2,7 +2,9 @@ package net.siegeln.cameleer.saas.vendor; import jakarta.validation.Valid; import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse; +import net.siegeln.cameleer.saas.license.dto.LicenseBundleResponse; import net.siegeln.cameleer.saas.license.dto.LicenseResponse; +import net.siegeln.cameleer.saas.license.dto.MintLicenseRequest; import net.siegeln.cameleer.saas.provisioning.ServerStatus; import net.siegeln.cameleer.saas.tenant.TenantEntity; import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest; @@ -189,12 +191,22 @@ public class VendorTenantController { } @PostMapping("/{id}/license") - public ResponseEntity renewLicense(@PathVariable UUID id, - @AuthenticationPrincipal Jwt jwt) { + public ResponseEntity mintLicense(@PathVariable UUID id, + @RequestBody(required = false) MintLicenseRequest request, + @AuthenticationPrincipal Jwt jwt) { UUID actorId = resolveActorId(jwt); + // Default to tier-preset, auto-push if no body provided + if (request == null) { + request = new MintLicenseRequest(null, null, null, null, null, true); + } try { - var license = vendorTenantService.renewLicense(id, actorId); - return ResponseEntity.ok(LicenseResponse.from(license)); + var tenant = vendorTenantService.getById(id) + .orElseThrow(() -> new IllegalArgumentException("Tenant not found")); + var license = vendorTenantService.mintLicense(id, request, actorId); + String publicKey = vendorTenantService.getPublicKeyBase64(); + boolean pushed = request.pushToServer() && tenant.getServerEndpoint() != null; + return ResponseEntity.ok(LicenseBundleResponse.from( + license, tenant.getSlug(), publicKey, pushed)); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); } diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java index 05550d0..1fd616a 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java @@ -9,8 +9,11 @@ import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties; import net.siegeln.cameleer.saas.provisioning.TenantDatabaseService; import net.siegeln.cameleer.saas.provisioning.TenantDataCleanupService; import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse; +import net.siegeln.cameleer.saas.license.LicenseDefaults; import net.siegeln.cameleer.saas.license.LicenseEntity; import net.siegeln.cameleer.saas.license.LicenseService; +import net.siegeln.cameleer.saas.license.SigningKeyService; +import net.siegeln.cameleer.saas.license.dto.MintLicenseRequest; import net.siegeln.cameleer.saas.provisioning.ProvisionResult; import net.siegeln.cameleer.saas.provisioning.ServerStatus; import net.siegeln.cameleer.saas.provisioning.TenantProvisionRequest; @@ -44,6 +47,7 @@ public class VendorTenantService { private final TenantService tenantService; private final TenantRepository tenantRepository; private final LicenseService licenseService; + private final SigningKeyService signingKeyService; private final TenantProvisioner tenantProvisioner; private final ServerApiClient serverApiClient; private final LogtoManagementClient logtoClient; @@ -57,6 +61,7 @@ public class VendorTenantService { public VendorTenantService(TenantService tenantService, TenantRepository tenantRepository, LicenseService licenseService, + SigningKeyService signingKeyService, TenantProvisioner tenantProvisioner, ServerApiClient serverApiClient, LogtoManagementClient logtoClient, @@ -69,6 +74,7 @@ public class VendorTenantService { this.tenantService = tenantService; this.tenantRepository = tenantRepository; this.licenseService = licenseService; + this.signingKeyService = signingKeyService; this.tenantProvisioner = tenantProvisioner; this.serverApiClient = serverApiClient; this.logtoClient = logtoClient; @@ -364,27 +370,71 @@ public class VendorTenantService { null, null, "SUCCESS", null); } + /** + * Mint a license with configurable limits, expiry, grace period, and label. + * Optionally pushes to the tenant's server. + */ @Transactional - public LicenseEntity renewLicense(UUID tenantId, UUID actorId) { + public LicenseEntity mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId) { TenantEntity tenant = tenantService.getById(tenantId) .orElseThrow(() -> new IllegalArgumentException("Tenant not found")); // Revoke current license licenseService.revokeLicense(tenantId, actorId); - // Generate new license - LicenseEntity newLicense = licenseService.generateLicense(tenant, DEFAULT_LICENSE_VALIDITY, actorId); + // Resolve limits: use provided limits, or fall back to tier preset + Map limits; + if (request.limits() != null && !request.limits().isEmpty()) { + limits = request.limits(); + } else if (request.tier() != null) { + limits = LicenseDefaults.limitsForTier( + net.siegeln.cameleer.saas.tenant.Tier.valueOf(request.tier())); + } else { + limits = LicenseDefaults.limitsForTier(tenant.getTier()); + } - // Push to server - String endpoint = tenant.getServerEndpoint(); - if (endpoint != null && !endpoint.isBlank()) { - try { - serverApiClient.pushLicense(endpoint, newLicense.getToken()); - } catch (Exception e) { - log.warn("Failed to push renewed license to server for tenant {}: {}", tenant.getSlug(), e.getMessage()); + java.time.Instant expiresAt = request.expiresAt() != null + ? request.expiresAt() + : java.time.Instant.now().plus(DEFAULT_LICENSE_VALIDITY); + + int gracePeriodDays = request.gracePeriodDays() != null + ? request.gracePeriodDays() + : LicenseDefaults.DEFAULT_GRACE_PERIOD_DAYS; + + String label = request.label() != null + ? request.label() + : tenant.getName() + " (" + tenant.getTier().name() + ")"; + + LicenseEntity newLicense = licenseService.generateLicense( + tenant, limits, expiresAt, gracePeriodDays, label, actorId); + + // Push to server if requested and endpoint available + boolean pushed = false; + if (request.pushToServer()) { + String endpoint = tenant.getServerEndpoint(); + if (endpoint != null && !endpoint.isBlank()) { + try { + serverApiClient.pushLicense(endpoint, newLicense.getToken()); + pushed = true; + } catch (Exception e) { + log.warn("Failed to push license to server for tenant {}: {}", tenant.getSlug(), e.getMessage()); + } } } return newLicense; } + + /** + * Backward-compatible renewal using tier presets and auto-push. + */ + @Transactional + public LicenseEntity renewLicense(UUID tenantId, UUID actorId) { + var request = new MintLicenseRequest(null, null, null, null, null, true); + return mintLicense(tenantId, request, actorId); + } + + public String getPublicKeyBase64() { + return signingKeyService.getPublicKeyBase64(); + } }