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 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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user