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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 17:24:23 +02:00
parent fdc7187424
commit 7a8960ca46
8 changed files with 232 additions and 14 deletions

View File

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

View File

@@ -0,0 +1,5 @@
package net.siegeln.cameleer.saas.license.dto;
import java.util.Map;
public record LicensePreset(String tier, Map<String, Integer> limits) {}

View File

@@ -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<String, Integer> limits,
Instant expiresAt,
Integer gracePeriodDays,
String label,
boolean pushToServer
) {}

View File

@@ -0,0 +1,3 @@
package net.siegeln.cameleer.saas.license.dto;
public record VerifyLicenseRequest(String token) {}

View File

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

View File

@@ -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<List<LicensePreset>> getPresets() {
List<LicensePreset> presets = Arrays.stream(Tier.values())
.map(t -> new LicensePreset(t.name(), LicenseDefaults.limitsForTier(t)))
.toList();
return ResponseEntity.ok(presets);
}
@PostMapping("/license/verify")
public ResponseEntity<VerifyLicenseResponse> 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<Map<String, String>> 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";
}
}

View File

@@ -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<LicenseResponse> renewLicense(@PathVariable UUID id,
@AuthenticationPrincipal Jwt jwt) {
public ResponseEntity<LicenseBundleResponse> 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();
}

View File

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