From 127834ce4d86dacb758d9cba1ddaee4f0dd42571 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:46:52 +0200 Subject: [PATCH] feat: vendor tenant API with provisioning, suspend, delete Adds VendorTenantService orchestrating full tenant lifecycle (create, provision, license push, activate, suspend, delete, renew license), VendorTenantController at /api/vendor/tenants with platform:admin guard, LicenseResponse.from() factory, SecurityConfig vendor/tenant path rules, and TenantIsolationInterceptor bypasses for vendor and tenant portal paths. Co-Authored-By: Claude Sonnet 4.6 --- .../cameleer/saas/config/SecurityConfig.java | 2 + .../config/TenantIsolationInterceptor.java | 12 + .../saas/license/dto/LicenseResponse.java | 13 +- .../saas/vendor/VendorTenantController.java | 176 ++++++++++++++ .../saas/vendor/VendorTenantService.java | 217 ++++++++++++++++++ 5 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java create mode 100644 src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java index d2bd847..7c0ef64 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java @@ -46,6 +46,8 @@ public class SecurityConfig { .requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license", "/admin/**").permitAll() .requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll() + .requestMatchers("/api/vendor/**").hasAuthority("SCOPE_platform:admin") + .requestMatchers("/api/tenant/**").authenticated() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> diff --git a/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java b/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java index 8808781..18019d4 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/TenantIsolationInterceptor.java @@ -34,6 +34,18 @@ public class TenantIsolationInterceptor implements HandlerInterceptor { var authentication = SecurityContextHolder.getContext().getAuthentication(); if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) return true; + String path = request.getRequestURI(); + + // Vendor endpoints: platform:admin already enforced by Spring Security + if (path.startsWith("/platform/api/vendor/")) { + return true; + } + + // Tenant portal endpoints: tenant resolved from JWT org context (no path variable) + if (path.startsWith("/platform/api/tenant/")) { + return TenantContext.getTenantId() != null; + } + // 1. Resolve: JWT organization_id -> TenantContext Jwt jwt = jwtAuth.getToken(); String orgId = jwt.getClaimAsString("organization_id"); diff --git a/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java index f737692..2dfa2eb 100644 --- a/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java +++ b/src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java @@ -1,5 +1,7 @@ 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; @@ -13,4 +15,13 @@ public record LicenseResponse( Instant issuedAt, Instant expiresAt, String token -) {} +) { + public static LicenseResponse from(LicenseEntity e) { + return new LicenseResponse( + e.getId(), e.getTenantId(), e.getTier(), + e.getFeatures(), e.getLimits(), + e.getIssuedAt(), e.getExpiresAt(), + e.getToken() + ); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java new file mode 100644 index 0000000..f2d55c6 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java @@ -0,0 +1,176 @@ +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.LicenseResponse; +import net.siegeln.cameleer.saas.provisioning.ServerStatus; +import net.siegeln.cameleer.saas.tenant.TenantEntity; +import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest; +import net.siegeln.cameleer.saas.tenant.dto.TenantResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/vendor/tenants") +@PreAuthorize("hasAuthority('SCOPE_platform:admin')") +public class VendorTenantController { + + private final VendorTenantService vendorTenantService; + + public VendorTenantController(VendorTenantService vendorTenantService) { + this.vendorTenantService = vendorTenantService; + } + + // --- Response types --- + + public record VendorTenantSummary( + UUID id, + String name, + String slug, + String tier, + String status, + String serverState, + String licenseExpiry, + String provisionError + ) {} + + public record VendorTenantDetail( + TenantResponse tenant, + String serverState, + boolean serverHealthy, + String serverStatus, + LicenseResponse license + ) {} + + // --- Endpoints --- + + @GetMapping + public ResponseEntity> listAll() { + List summaries = vendorTenantService.listAll().stream() + .map(tenant -> { + ServerStatus status = vendorTenantService.getServerStatus(tenant); + String licenseExpiry = vendorTenantService + .getLicenseForTenant(tenant.getId()) + .map(l -> l.getExpiresAt() != null ? l.getExpiresAt().toString() : null) + .orElse(null); + return new VendorTenantSummary( + tenant.getId(), tenant.getName(), tenant.getSlug(), + tenant.getTier().name(), tenant.getStatus().name(), + status.state().name(), licenseExpiry, tenant.getProvisionError() + ); + }) + .toList(); + return ResponseEntity.ok(summaries); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateTenantRequest request, + @AuthenticationPrincipal Jwt jwt) { + UUID actorId = resolveActorId(jwt); + try { + TenantEntity tenant = vendorTenantService.createAndProvision(request, actorId); + return ResponseEntity.status(HttpStatus.CREATED).body(TenantResponse.from(tenant)); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable UUID id) { + return vendorTenantService.getById(id) + .map(tenant -> { + ServerStatus serverStatus = vendorTenantService.getServerStatus(tenant); + ServerHealthResponse health = vendorTenantService.getServerHealth(tenant); + LicenseResponse license = vendorTenantService + .getLicenseForTenant(id) + .map(LicenseResponse::from) + .orElse(null); + return ResponseEntity.ok(new VendorTenantDetail( + TenantResponse.from(tenant), + serverStatus.state().name(), + health.healthy(), + health.status(), + license + )); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/{id}/suspend") + public ResponseEntity suspend(@PathVariable UUID id, + @AuthenticationPrincipal Jwt jwt) { + UUID actorId = resolveActorId(jwt); + try { + TenantEntity tenant = vendorTenantService.suspend(id, actorId); + return ResponseEntity.ok(TenantResponse.from(tenant)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping("/{id}/activate") + public ResponseEntity activate(@PathVariable UUID id, + @AuthenticationPrincipal Jwt jwt) { + UUID actorId = resolveActorId(jwt); + try { + TenantEntity tenant = vendorTenantService.activate(id, actorId); + return ResponseEntity.ok(TenantResponse.from(tenant)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id, + @AuthenticationPrincipal Jwt jwt) { + UUID actorId = resolveActorId(jwt); + try { + vendorTenantService.delete(id, actorId); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping("/{id}/license") + public ResponseEntity renewLicense(@PathVariable UUID id, + @AuthenticationPrincipal Jwt jwt) { + UUID actorId = resolveActorId(jwt); + try { + var license = vendorTenantService.renewLicense(id, actorId); + return ResponseEntity.ok(LicenseResponse.from(license)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/{id}/health") + public ResponseEntity health(@PathVariable UUID id) { + return vendorTenantService.getById(id) + .map(tenant -> ResponseEntity.ok(vendorTenantService.getServerHealth(tenant))) + .orElse(ResponseEntity.notFound().build()); + } + + // --- Helpers --- + + private UUID resolveActorId(Jwt jwt) { + try { + return UUID.fromString(jwt.getSubject()); + } catch (Exception e) { + return UUID.nameUUIDFromBytes(jwt.getSubject().getBytes()); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java new file mode 100644 index 0000000..edeacd8 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java @@ -0,0 +1,217 @@ +package net.siegeln.cameleer.saas.vendor; + +import net.siegeln.cameleer.saas.audit.AuditAction; +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.identity.LogtoManagementClient; +import net.siegeln.cameleer.saas.identity.ServerApiClient; +import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse; +import net.siegeln.cameleer.saas.license.LicenseEntity; +import net.siegeln.cameleer.saas.license.LicenseService; +import net.siegeln.cameleer.saas.provisioning.ProvisionResult; +import net.siegeln.cameleer.saas.provisioning.ServerStatus; +import net.siegeln.cameleer.saas.provisioning.TenantProvisionRequest; +import net.siegeln.cameleer.saas.provisioning.TenantProvisioner; +import net.siegeln.cameleer.saas.tenant.TenantEntity; +import net.siegeln.cameleer.saas.tenant.TenantRepository; +import net.siegeln.cameleer.saas.tenant.TenantService; +import net.siegeln.cameleer.saas.tenant.TenantStatus; +import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class VendorTenantService { + + private static final Logger log = LoggerFactory.getLogger(VendorTenantService.class); + private static final Duration DEFAULT_LICENSE_VALIDITY = Duration.ofDays(365); + + private final TenantService tenantService; + private final TenantRepository tenantRepository; + private final LicenseService licenseService; + private final TenantProvisioner tenantProvisioner; + private final ServerApiClient serverApiClient; + private final LogtoManagementClient logtoClient; + private final AuditService auditService; + + public VendorTenantService(TenantService tenantService, + TenantRepository tenantRepository, + LicenseService licenseService, + TenantProvisioner tenantProvisioner, + ServerApiClient serverApiClient, + LogtoManagementClient logtoClient, + AuditService auditService) { + this.tenantService = tenantService; + this.tenantRepository = tenantRepository; + this.licenseService = licenseService; + this.tenantProvisioner = tenantProvisioner; + this.serverApiClient = serverApiClient; + this.logtoClient = logtoClient; + this.auditService = auditService; + } + + @Transactional + public TenantEntity createAndProvision(CreateTenantRequest request, UUID actorId) { + // 1. Create tenant record (sets status = PROVISIONING) + TenantEntity tenant = tenantService.create(request, actorId); + + // 2. Generate license + LicenseEntity license = licenseService.generateLicense(tenant, DEFAULT_LICENSE_VALIDITY, actorId); + + // 3. Provision server if provisioner is available + if (tenantProvisioner.isAvailable()) { + var provisionRequest = new TenantProvisionRequest( + tenant.getId(), tenant.getSlug(), + tenant.getTier().name(), license.getToken()); + ProvisionResult result = tenantProvisioner.provision(provisionRequest); + if (result.success()) { + tenant.setServerEndpoint(result.serverEndpoint()); + tenant.setProvisionError(null); + tenant.setStatus(TenantStatus.ACTIVE); + tenant = tenantRepository.save(tenant); + + // Push license to newly provisioned server + try { + serverApiClient.pushLicense(result.serverEndpoint(), license.getToken()); + } catch (Exception e) { + log.warn("License push failed for tenant {}: {}", tenant.getSlug(), e.getMessage()); + } + } else { + tenant.setProvisionError(result.error()); + tenant.setStatus(TenantStatus.PROVISIONING); + tenant = tenantRepository.save(tenant); + log.error("Provisioning failed for tenant {}: {}", tenant.getSlug(), result.error()); + } + } + + auditService.log(actorId, null, tenant.getId(), + AuditAction.TENANT_CREATE, "provision:" + tenant.getSlug(), + null, null, "SUCCESS", null); + + return tenant; + } + + public List listAll() { + return tenantService.findAll(); + } + + public Optional getById(UUID id) { + return tenantService.getById(id); + } + + public Optional getLicenseForTenant(UUID tenantId) { + return licenseService.getActiveLicense(tenantId); + } + + public ServerStatus getServerStatus(TenantEntity tenant) { + if (!tenantProvisioner.isAvailable()) { + return ServerStatus.notFound(); + } + return tenantProvisioner.getStatus(tenant.getSlug()); + } + + public ServerHealthResponse getServerHealth(TenantEntity tenant) { + String endpoint = tenant.getServerEndpoint(); + if (endpoint == null || endpoint.isBlank()) { + return new ServerHealthResponse(false, "NO_ENDPOINT"); + } + return serverApiClient.getHealth(endpoint); + } + + @Transactional + public TenantEntity suspend(UUID tenantId, UUID actorId) { + TenantEntity tenant = tenantService.getById(tenantId) + .orElseThrow(() -> new IllegalArgumentException("Tenant not found")); + + if (tenantProvisioner.isAvailable()) { + try { + tenantProvisioner.stop(tenant.getSlug()); + } catch (Exception e) { + log.warn("Failed to stop containers for tenant {}: {}", tenant.getSlug(), e.getMessage()); + } + } + + return tenantService.suspend(tenantId, actorId); + } + + @Transactional + public TenantEntity activate(UUID tenantId, UUID actorId) { + TenantEntity tenant = tenantService.getById(tenantId) + .orElseThrow(() -> new IllegalArgumentException("Tenant not found")); + + if (tenantProvisioner.isAvailable()) { + try { + tenantProvisioner.start(tenant.getSlug()); + } catch (Exception e) { + log.warn("Failed to start containers for tenant {}: {}", tenant.getSlug(), e.getMessage()); + } + } + + return tenantService.activate(tenantId, actorId); + } + + @Transactional + public void delete(UUID tenantId, UUID actorId) { + TenantEntity tenant = tenantService.getById(tenantId) + .orElseThrow(() -> new IllegalArgumentException("Tenant not found")); + + // Remove containers + if (tenantProvisioner.isAvailable()) { + try { + tenantProvisioner.remove(tenant.getSlug()); + } catch (Exception e) { + log.warn("Failed to remove containers for tenant {}: {}", tenant.getSlug(), e.getMessage()); + } + } + + // Revoke license + licenseService.revokeLicense(tenantId, actorId); + + // Delete Logto org + if (logtoClient.isAvailable() && tenant.getLogtoOrgId() != null) { + try { + logtoClient.deleteOrganization(tenant.getLogtoOrgId()); + } catch (Exception e) { + log.warn("Failed to delete Logto org for tenant {}: {}", tenant.getSlug(), e.getMessage()); + } + } + + // Soft-delete + tenant.setStatus(TenantStatus.DELETED); + tenantRepository.save(tenant); + + auditService.log(actorId, null, tenantId, + AuditAction.TENANT_DELETE, tenant.getSlug(), + null, null, "SUCCESS", null); + } + + @Transactional + public LicenseEntity renewLicense(UUID tenantId, 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); + + // 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()); + } + } + + return newLicense; + } +}