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:
@@ -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 ->
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
176
src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java
vendored
Normal file
176
src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantController.java
vendored
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
217
src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java
vendored
Normal file
217
src/main/java/net/siegeln/cameleer/saas/vendor/VendorTenantService.java
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user