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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 21:46:52 +02:00
parent 6bdb02ff5a
commit 127834ce4d
5 changed files with 419 additions and 1 deletions

View File

@@ -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 ->

View File

@@ -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");

View File

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

View File

@@ -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<List<VendorTenantSummary>> listAll() {
List<VendorTenantSummary> 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<TenantResponse> 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<VendorTenantDetail> 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<TenantResponse> 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<TenantResponse> 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<Void> 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<LicenseResponse> 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<ServerHealthResponse> 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());
}
}
}

View File

@@ -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<TenantEntity> listAll() {
return tenantService.findAll();
}
public Optional<TenantEntity> getById(UUID id) {
return tenantService.getById(id);
}
public Optional<LicenseEntity> 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;
}
}