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:
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license.dto;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record LicensePreset(String tier, Map<String, Integer> limits) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package net.siegeln.cameleer.saas.license.dto;
|
||||||
|
|
||||||
|
public record VerifyLicenseRequest(String token) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/main/java/net/siegeln/cameleer/saas/vendor/VendorLicenseController.java
vendored
Normal file
83
src/main/java/net/siegeln/cameleer/saas/vendor/VendorLicenseController.java
vendored
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package net.siegeln.cameleer.saas.vendor;
|
|||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
|
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.LicenseResponse;
|
||||||
|
import net.siegeln.cameleer.saas.license.dto.MintLicenseRequest;
|
||||||
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
|
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
|
||||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||||
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
|
||||||
@@ -189,12 +191,22 @@ public class VendorTenantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/license")
|
@PostMapping("/{id}/license")
|
||||||
public ResponseEntity<LicenseResponse> renewLicense(@PathVariable UUID id,
|
public ResponseEntity<LicenseBundleResponse> mintLicense(@PathVariable UUID id,
|
||||||
@AuthenticationPrincipal Jwt jwt) {
|
@RequestBody(required = false) MintLicenseRequest request,
|
||||||
|
@AuthenticationPrincipal Jwt jwt) {
|
||||||
UUID actorId = resolveActorId(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 {
|
try {
|
||||||
var license = vendorTenantService.renewLicense(id, actorId);
|
var tenant = vendorTenantService.getById(id)
|
||||||
return ResponseEntity.ok(LicenseResponse.from(license));
|
.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) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
|||||||
import net.siegeln.cameleer.saas.provisioning.TenantDatabaseService;
|
import net.siegeln.cameleer.saas.provisioning.TenantDatabaseService;
|
||||||
import net.siegeln.cameleer.saas.provisioning.TenantDataCleanupService;
|
import net.siegeln.cameleer.saas.provisioning.TenantDataCleanupService;
|
||||||
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
|
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.LicenseEntity;
|
||||||
import net.siegeln.cameleer.saas.license.LicenseService;
|
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.ProvisionResult;
|
||||||
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
|
import net.siegeln.cameleer.saas.provisioning.ServerStatus;
|
||||||
import net.siegeln.cameleer.saas.provisioning.TenantProvisionRequest;
|
import net.siegeln.cameleer.saas.provisioning.TenantProvisionRequest;
|
||||||
@@ -44,6 +47,7 @@ public class VendorTenantService {
|
|||||||
private final TenantService tenantService;
|
private final TenantService tenantService;
|
||||||
private final TenantRepository tenantRepository;
|
private final TenantRepository tenantRepository;
|
||||||
private final LicenseService licenseService;
|
private final LicenseService licenseService;
|
||||||
|
private final SigningKeyService signingKeyService;
|
||||||
private final TenantProvisioner tenantProvisioner;
|
private final TenantProvisioner tenantProvisioner;
|
||||||
private final ServerApiClient serverApiClient;
|
private final ServerApiClient serverApiClient;
|
||||||
private final LogtoManagementClient logtoClient;
|
private final LogtoManagementClient logtoClient;
|
||||||
@@ -57,6 +61,7 @@ public class VendorTenantService {
|
|||||||
public VendorTenantService(TenantService tenantService,
|
public VendorTenantService(TenantService tenantService,
|
||||||
TenantRepository tenantRepository,
|
TenantRepository tenantRepository,
|
||||||
LicenseService licenseService,
|
LicenseService licenseService,
|
||||||
|
SigningKeyService signingKeyService,
|
||||||
TenantProvisioner tenantProvisioner,
|
TenantProvisioner tenantProvisioner,
|
||||||
ServerApiClient serverApiClient,
|
ServerApiClient serverApiClient,
|
||||||
LogtoManagementClient logtoClient,
|
LogtoManagementClient logtoClient,
|
||||||
@@ -69,6 +74,7 @@ public class VendorTenantService {
|
|||||||
this.tenantService = tenantService;
|
this.tenantService = tenantService;
|
||||||
this.tenantRepository = tenantRepository;
|
this.tenantRepository = tenantRepository;
|
||||||
this.licenseService = licenseService;
|
this.licenseService = licenseService;
|
||||||
|
this.signingKeyService = signingKeyService;
|
||||||
this.tenantProvisioner = tenantProvisioner;
|
this.tenantProvisioner = tenantProvisioner;
|
||||||
this.serverApiClient = serverApiClient;
|
this.serverApiClient = serverApiClient;
|
||||||
this.logtoClient = logtoClient;
|
this.logtoClient = logtoClient;
|
||||||
@@ -364,27 +370,71 @@ public class VendorTenantService {
|
|||||||
null, null, "SUCCESS", null);
|
null, null, "SUCCESS", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a license with configurable limits, expiry, grace period, and label.
|
||||||
|
* Optionally pushes to the tenant's server.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public LicenseEntity renewLicense(UUID tenantId, UUID actorId) {
|
public LicenseEntity mintLicense(UUID tenantId, MintLicenseRequest request, UUID actorId) {
|
||||||
TenantEntity tenant = tenantService.getById(tenantId)
|
TenantEntity tenant = tenantService.getById(tenantId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
|
||||||
|
|
||||||
// Revoke current license
|
// Revoke current license
|
||||||
licenseService.revokeLicense(tenantId, actorId);
|
licenseService.revokeLicense(tenantId, actorId);
|
||||||
|
|
||||||
// Generate new license
|
// Resolve limits: use provided limits, or fall back to tier preset
|
||||||
LicenseEntity newLicense = licenseService.generateLicense(tenant, DEFAULT_LICENSE_VALIDITY, actorId);
|
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
|
java.time.Instant expiresAt = request.expiresAt() != null
|
||||||
String endpoint = tenant.getServerEndpoint();
|
? request.expiresAt()
|
||||||
if (endpoint != null && !endpoint.isBlank()) {
|
: java.time.Instant.now().plus(DEFAULT_LICENSE_VALIDITY);
|
||||||
try {
|
|
||||||
serverApiClient.pushLicense(endpoint, newLicense.getToken());
|
int gracePeriodDays = request.gracePeriodDays() != null
|
||||||
} catch (Exception e) {
|
? request.gracePeriodDays()
|
||||||
log.warn("Failed to push renewed license to server for tenant {}: {}", tenant.getSlug(), e.getMessage());
|
: 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;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user