Installation automation - License key approach
This commit is contained in:
@@ -38,6 +38,15 @@ public class ReactiveCacheService {
|
||||
return redis.delete(cacheKey(namespace, key)).map(deleted -> deleted > 0);
|
||||
}
|
||||
|
||||
public Mono<Long> increment(String namespace, String key, Duration ttl) {
|
||||
String fullKey = cacheKey(namespace, key);
|
||||
return redis.opsForValue()
|
||||
.increment(fullKey)
|
||||
.flatMap(count -> count == 1
|
||||
? redis.expire(fullKey, ttl).thenReturn(count)
|
||||
: Mono.just(count));
|
||||
}
|
||||
|
||||
private String cacheKey(String namespace, String key) {
|
||||
return properties.keyPrefix() + ':' + namespace + ':' + key;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.cygnus.cloud.database;
|
||||
import io.vertx.sqlclient.Pool;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.RowSet;
|
||||
import io.vertx.sqlclient.SqlConnection;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -29,4 +31,37 @@ public class ReactiveDatabaseClient {
|
||||
return Mono.fromCompletionStage(
|
||||
() -> pool.preparedQuery(sql).execute(parameters).toCompletionStage());
|
||||
}
|
||||
|
||||
public Mono<Integer> preparedUpdate(String sql, Tuple parameters) {
|
||||
return preparedQuery(sql, parameters).map(RowSet::rowCount);
|
||||
}
|
||||
|
||||
public <T> Mono<T> inTransaction(Function<SqlConnection, Mono<T>> work) {
|
||||
return Mono.usingWhen(
|
||||
Mono.fromCompletionStage(() -> pool.getConnection().toCompletionStage()),
|
||||
connection -> Mono.fromCompletionStage(
|
||||
() -> connection.begin().toCompletionStage())
|
||||
.flatMap(transaction -> work.apply(connection)
|
||||
.flatMap(result -> Mono.fromCompletionStage(
|
||||
() -> transaction.commit().toCompletionStage())
|
||||
.thenReturn(result))
|
||||
.onErrorResume(error -> Mono.fromCompletionStage(
|
||||
() -> transaction.rollback().toCompletionStage())
|
||||
.onErrorResume(ignored -> Mono.empty())
|
||||
.then(Mono.error(error)))),
|
||||
connection -> Mono.fromCompletionStage(
|
||||
() -> connection.close().toCompletionStage()),
|
||||
(connection, error) -> Mono.fromCompletionStage(
|
||||
() -> connection.close().toCompletionStage()),
|
||||
connection -> Mono.fromCompletionStage(
|
||||
() -> connection.close().toCompletionStage()));
|
||||
}
|
||||
|
||||
public Mono<RowSet<Row>> preparedQuery(
|
||||
SqlConnection connection, String sql, Tuple parameters) {
|
||||
return Mono.fromCompletionStage(
|
||||
() -> connection.preparedQuery(sql)
|
||||
.execute(parameters)
|
||||
.toCompletionStage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ class ClientAssertionValidator {
|
||||
.switchIfEmpty(Mono.error(new MachineAuthenticationException(
|
||||
"Client license is not active")))
|
||||
.map(license -> verify(
|
||||
clientId, signedJwt, claims, installation, license)))
|
||||
clientId, signedJwt, claims, installation, license))
|
||||
.flatMap(principal -> registrations
|
||||
.touchInstallation(
|
||||
installation.installationId(), clock.instant())
|
||||
.thenReturn(principal)))
|
||||
.onErrorMap(
|
||||
exception -> !(exception instanceof MachineAuthenticationException),
|
||||
exception -> new MachineAuthenticationException(
|
||||
|
||||
@@ -30,8 +30,14 @@ public class CloudSecurityConfiguration {
|
||||
.authorizeExchange(exchange -> exchange
|
||||
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||
.pathMatchers("/oauth2/token").permitAll()
|
||||
.pathMatchers(
|
||||
"/api/v1/installations/activation/validate",
|
||||
"/api/v1/installations/register")
|
||||
.permitAll()
|
||||
.pathMatchers("/api/v1/identity/login")
|
||||
.hasAuthority("SCOPE_identity.login")
|
||||
.pathMatchers("/api/v1/admin/**")
|
||||
.hasAuthority("SCOPE_cygnus.admin")
|
||||
.anyExchange().authenticated())
|
||||
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
|
||||
.build();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivationValidationRequest(
|
||||
@NotBlank @Size(max = 40) String clientCode,
|
||||
@NotBlank @Size(max = 80) String licenseKey,
|
||||
@NotNull UUID installationUuid,
|
||||
@NotBlank @Size(max = 40)
|
||||
@Pattern(regexp = "^[A-Za-z0-9._-]+$")
|
||||
String installerVersion) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivationValidationResponse(
|
||||
String activationToken,
|
||||
OffsetDateTime expiresAt,
|
||||
UUID tenantId,
|
||||
String tenantSlug,
|
||||
String packageCode,
|
||||
int maximumInstallations) {
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import com.cygnus.cloud.tenant.repository.ClientAdministrationRepository;
|
||||
import com.cygnus.cloud.tenant.service.LicenseKeyService;
|
||||
import com.cygnus.cloud.tenant.service.InstallationLifecycleService;
|
||||
import com.cygnus.cloud.tenant.service.RegistrationEmailService;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.security.core.Authentication;
|
||||
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 reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin")
|
||||
public class ClientAdministrationController {
|
||||
|
||||
private final ClientAdministrationRepository repository;
|
||||
private final LicenseKeyService licenseKeyService;
|
||||
private final RegistrationEmailService emailService;
|
||||
private final InstallationLifecycleService installationLifecycleService;
|
||||
|
||||
public ClientAdministrationController(
|
||||
ClientAdministrationRepository repository,
|
||||
LicenseKeyService licenseKeyService,
|
||||
RegistrationEmailService emailService,
|
||||
InstallationLifecycleService installationLifecycleService) {
|
||||
this.repository = repository;
|
||||
this.licenseKeyService = licenseKeyService;
|
||||
this.emailService = emailService;
|
||||
this.installationLifecycleService = installationLifecycleService;
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/installations/{installationId}/decommission")
|
||||
public Mono<?> decommission(
|
||||
@PathVariable UUID tenantId,
|
||||
@PathVariable UUID installationId,
|
||||
@Valid @RequestBody RetireInstallationRequest request,
|
||||
Authentication authentication) {
|
||||
return installationLifecycleService.retire(
|
||||
tenantId,
|
||||
installationId,
|
||||
"DECOMMISSIONED",
|
||||
actor(authentication),
|
||||
request.reason());
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/installations/{installationId}/revoke")
|
||||
public Mono<?> revoke(
|
||||
@PathVariable UUID tenantId,
|
||||
@PathVariable UUID installationId,
|
||||
@Valid @RequestBody RetireInstallationRequest request,
|
||||
Authentication authentication) {
|
||||
return installationLifecycleService.retire(
|
||||
tenantId,
|
||||
installationId,
|
||||
"REVOKED",
|
||||
actor(authentication),
|
||||
request.reason());
|
||||
}
|
||||
|
||||
@PostMapping("/client-registrations")
|
||||
public Mono<Map<String, UUID>> registration(
|
||||
@Valid @RequestBody CreateClientRegistrationRequest request,
|
||||
Authentication authentication) {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tuple values = Tuple.tuple()
|
||||
.addUUID(id)
|
||||
.addString(request.clientCode())
|
||||
.addString(request.legalCompanyName())
|
||||
.addString(request.tradeName())
|
||||
.addString(request.pan())
|
||||
.addString(request.cin())
|
||||
.addString(request.gstNumber())
|
||||
.addString(request.billingAddressLine1())
|
||||
.addString(request.billingAddressLine2())
|
||||
.addString(request.billingCity())
|
||||
.addString(request.billingState())
|
||||
.addString(request.billingPostalCode())
|
||||
.addString(request.billingCountry())
|
||||
.addString(request.billingEmail())
|
||||
.addString(request.primaryContactName())
|
||||
.addString(request.primaryContactEmail())
|
||||
.addString(request.primaryContactNumber())
|
||||
.addString(request.alternateContactName())
|
||||
.addString(request.alternateContactEmail())
|
||||
.addString(request.alternateContactNumber())
|
||||
.addLocalDate(request.contractStartDate())
|
||||
.addLocalDate(request.contractEndDate())
|
||||
.addString("ACTIVE")
|
||||
.addString(actor(authentication));
|
||||
return repository.createRegistration(values)
|
||||
.flatMap(created -> created
|
||||
? Mono.just(Map.of("registrationId", id))
|
||||
: Mono.error(new IllegalStateException(
|
||||
"Registration was not created")));
|
||||
}
|
||||
|
||||
@PostMapping("/client-registrations/{registrationId}/tenants")
|
||||
public Mono<Map<String, UUID>> tenant(
|
||||
@PathVariable UUID registrationId,
|
||||
@Valid @RequestBody CreateTenantRequest request) {
|
||||
UUID id = UUID.randomUUID();
|
||||
return repository.createTenant(
|
||||
id, registrationId, request.clientSlug(), request.clientName())
|
||||
.flatMap(created -> created
|
||||
? Mono.just(Map.of("tenantId", id))
|
||||
: Mono.error(new IllegalStateException("Tenant was not created")));
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/licenses")
|
||||
public Mono<Map<String, UUID>> license(
|
||||
@PathVariable UUID tenantId,
|
||||
@Valid @RequestBody CreateLicenseRequest request) {
|
||||
if (!request.validUntil().isAfter(request.validFrom())) {
|
||||
return Mono.error(new IllegalArgumentException(
|
||||
"License end must be after start"));
|
||||
}
|
||||
UUID id = UUID.randomUUID();
|
||||
return repository.createLicense(
|
||||
id,
|
||||
tenantId,
|
||||
request.licenseType(),
|
||||
request.packageCode(),
|
||||
request.validFrom(),
|
||||
request.validUntil(),
|
||||
request.maximumUsers(),
|
||||
request.maximumInstallations())
|
||||
.flatMap(created -> created
|
||||
? Mono.just(Map.of("licenseId", id))
|
||||
: Mono.error(new IllegalStateException("License was not created")));
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/licenses/{licenseId}/activation-key")
|
||||
public Mono<Map<String, String>> activationKey(
|
||||
@PathVariable UUID tenantId,
|
||||
@PathVariable UUID licenseId,
|
||||
@Valid @RequestBody IssueLicenseKeyRequest request,
|
||||
Authentication authentication) {
|
||||
return licenseKeyService.issue(
|
||||
tenantId, licenseId, request.expiresAt(), actor(authentication))
|
||||
.flatMap(issued -> repository
|
||||
.findLicenseDeliveryDetails(tenantId, licenseId)
|
||||
.switchIfEmpty(Mono.error(
|
||||
new IllegalArgumentException("Tenant or license not found")))
|
||||
.flatMap(details -> emailService.sendLicense(
|
||||
request.recipientEmail(),
|
||||
details.clientCode(),
|
||||
details.tenantSlug(),
|
||||
details.packageCode(),
|
||||
details.maximumInstallations(),
|
||||
request.expiresAt(),
|
||||
issued)
|
||||
.onErrorResume(error -> licenseKeyService
|
||||
.revoke(issued.activationKeyId())
|
||||
.then(Mono.error(error)))
|
||||
.thenReturn(Map.of(
|
||||
"activationKeyId",
|
||||
issued.activationKeyId().toString(),
|
||||
"keyHint",
|
||||
issued.keyHint(),
|
||||
"delivery",
|
||||
"EMAIL_SENT"))));
|
||||
}
|
||||
|
||||
private String actor(Authentication authentication) {
|
||||
return authentication == null ? "system" : authentication.getName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateClientRegistrationRequest(
|
||||
@NotBlank @Pattern(regexp = "^[A-Z0-9]+(?:-[A-Z0-9]+)*$")
|
||||
@Size(max = 40) String clientCode,
|
||||
@NotBlank @Size(max = 240) String legalCompanyName,
|
||||
@Size(max = 240) String tradeName,
|
||||
@Size(max = 20) String pan,
|
||||
@Size(max = 30) String cin,
|
||||
@Size(max = 30) String gstNumber,
|
||||
@Size(max = 300) String billingAddressLine1,
|
||||
@Size(max = 300) String billingAddressLine2,
|
||||
@Size(max = 120) String billingCity,
|
||||
@Size(max = 120) String billingState,
|
||||
@Size(max = 20) String billingPostalCode,
|
||||
@NotBlank @Pattern(regexp = "^[A-Z]{2}$") String billingCountry,
|
||||
@Email @Size(max = 254) String billingEmail,
|
||||
@Size(max = 160) String primaryContactName,
|
||||
@Email @Size(max = 254) String primaryContactEmail,
|
||||
@Size(max = 30) String primaryContactNumber,
|
||||
@Size(max = 160) String alternateContactName,
|
||||
@Email @Size(max = 254) String alternateContactEmail,
|
||||
@Size(max = 30) String alternateContactNumber,
|
||||
@NotNull LocalDate contractStartDate,
|
||||
LocalDate contractEndDate) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record CreateLicenseRequest(
|
||||
@NotBlank @Size(max = 30) String licenseType,
|
||||
@NotBlank @Size(max = 50) String packageCode,
|
||||
@NotNull OffsetDateTime validFrom,
|
||||
@NotNull OffsetDateTime validUntil,
|
||||
@Min(1) Integer maximumUsers,
|
||||
@Min(1) @Max(100) int maximumInstallations) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record CreateTenantRequest(
|
||||
@NotBlank @Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
@Size(max = 80) String clientSlug,
|
||||
@NotBlank @Size(max = 200) String clientName) {
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
|
||||
import com.cygnus.cloud.tenant.service.InstallationActivationService;
|
||||
import com.cygnus.cloud.tenant.service.ActivationRateLimiter;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
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 reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/installations")
|
||||
public class InstallationActivationController {
|
||||
|
||||
private final InstallationActivationService activationService;
|
||||
private final ActivationRateLimiter rateLimiter;
|
||||
|
||||
public InstallationActivationController(
|
||||
InstallationActivationService activationService,
|
||||
ActivationRateLimiter rateLimiter) {
|
||||
this.activationService = activationService;
|
||||
this.rateLimiter = rateLimiter;
|
||||
}
|
||||
|
||||
@PostMapping("/activation/validate")
|
||||
public Mono<ActivationValidationResponse> validate(
|
||||
@Valid @RequestBody ActivationValidationRequest request,
|
||||
ServerHttpRequest serverRequest) {
|
||||
String sourceIp = remoteAddress(serverRequest);
|
||||
return rateLimiter.check(sourceIp, request.clientCode())
|
||||
.then(activationService.validateAndCreateSession(
|
||||
request.clientCode(),
|
||||
request.licenseKey(),
|
||||
request.installationUuid(),
|
||||
sourceIp,
|
||||
request.installerVersion()))
|
||||
.map(session -> new ActivationValidationResponse(
|
||||
session.token(),
|
||||
session.expiresAt(),
|
||||
session.tenantId(),
|
||||
session.clientSlug(),
|
||||
session.packageCode(),
|
||||
session.maxInstallations()));
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public Mono<RegisteredInstallation> register(
|
||||
@Valid @RequestBody InstallationRegistrationRequest request) {
|
||||
return activationService.register(
|
||||
request.activationToken(),
|
||||
request.installationCode(),
|
||||
request.installationName(),
|
||||
request.assertionPublicKey(),
|
||||
request.softwareVersion(),
|
||||
request.environment());
|
||||
}
|
||||
|
||||
private String remoteAddress(ServerHttpRequest request) {
|
||||
return request.getRemoteAddress() == null
|
||||
? null
|
||||
: request.getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import com.cygnus.cloud.tenant.service.InstallationActivationException;
|
||||
import com.cygnus.cloud.tenant.service.LicenseKeyException;
|
||||
import com.cygnus.cloud.tenant.service.ActivationRateLimitException;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class InstallationActivationErrorHandler {
|
||||
|
||||
@ExceptionHandler({LicenseKeyException.class, InstallationActivationException.class})
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
Map<String, String> activationFailure() {
|
||||
return Map.of(
|
||||
"code", "INSTALLATION_ACTIVATION_FAILED",
|
||||
"message", "Installation activation could not be completed");
|
||||
}
|
||||
|
||||
@ExceptionHandler(ActivationRateLimitException.class)
|
||||
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
|
||||
Map<String, String> rateLimited() {
|
||||
return Map.of(
|
||||
"code", "INSTALLATION_ACTIVATION_RATE_LIMITED",
|
||||
"message", "Too many activation attempts; retry later");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record InstallationRegistrationRequest(
|
||||
@NotBlank @Size(max = 100) String activationToken,
|
||||
@NotBlank @Size(max = 100)
|
||||
@Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
||||
String installationCode,
|
||||
@NotBlank @Size(max = 160) String installationName,
|
||||
@NotBlank @Size(max = 8192) String assertionPublicKey,
|
||||
@NotBlank @Size(max = 40)
|
||||
@Pattern(regexp = "^[A-Za-z0-9._-]+$")
|
||||
String softwareVersion,
|
||||
@NotBlank @Size(max = 30)
|
||||
@Pattern(regexp = "^[A-Za-z0-9_-]+$")
|
||||
String environment) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record IssueLicenseKeyRequest(
|
||||
@NotNull OffsetDateTime expiresAt,
|
||||
@NotBlank @Email String recipientEmail) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record RetireInstallationRequest(
|
||||
@NotBlank @Size(max = 500) String reason) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public enum ActivationKeyStatus {
|
||||
ACTIVE,
|
||||
LOCKED,
|
||||
REVOKED,
|
||||
EXPIRED
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivationSession(
|
||||
UUID activationSessionId,
|
||||
UUID registrationId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
UUID installationUuid,
|
||||
String token,
|
||||
OffsetDateTime expiresAt,
|
||||
String clientCode,
|
||||
String clientSlug,
|
||||
String packageCode,
|
||||
int maxInstallations) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ClientRegistration(
|
||||
UUID registrationId,
|
||||
String clientCode,
|
||||
String legalCompanyName,
|
||||
String tradeName,
|
||||
String billingEmail,
|
||||
LocalDate contractStartDate,
|
||||
LocalDate contractEndDate,
|
||||
RegistrationStatus status,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt,
|
||||
int version) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record InstallationLifecycleResult(
|
||||
UUID installationId,
|
||||
UUID tenantId,
|
||||
String clientId,
|
||||
String installationCode,
|
||||
String status) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record IssuedLicenseKey(
|
||||
UUID activationKeyId,
|
||||
String licenseKey,
|
||||
String keyHint) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record LicenseActivationContext(
|
||||
LicenseActivationKey activationKey,
|
||||
String clientCode,
|
||||
RegistrationStatus registrationStatus,
|
||||
ClientStatus tenantStatus,
|
||||
String clientSlug,
|
||||
String clientName,
|
||||
String packageCode,
|
||||
String licenseType,
|
||||
LicenseStatus licenseStatus,
|
||||
Instant validFrom,
|
||||
Instant validUntil,
|
||||
int maxInstallations,
|
||||
int consumingInstallations) {
|
||||
|
||||
public boolean hasCapacity() {
|
||||
return consumingInstallations < maxInstallations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record LicenseActivationKey(
|
||||
UUID activationKeyId,
|
||||
UUID registrationId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
String keyHash,
|
||||
String keyHint,
|
||||
ActivationKeyStatus status,
|
||||
OffsetDateTime expiresAt,
|
||||
int failedAttempts,
|
||||
int maximumAttempts,
|
||||
OffsetDateTime lockedUntil,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime lastUsedAt) {
|
||||
|
||||
public boolean canAttemptAt(OffsetDateTime now) {
|
||||
if (expiresAt != null && !expiresAt.isAfter(now)) {
|
||||
return false;
|
||||
}
|
||||
if (status == ActivationKeyStatus.ACTIVE) {
|
||||
return failedAttempts < maximumAttempts;
|
||||
}
|
||||
return status == ActivationKeyStatus.LOCKED
|
||||
&& lockedUntil != null
|
||||
&& !lockedUntil.isAfter(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public record LicenseDeliveryDetails(
|
||||
String clientCode,
|
||||
String tenantSlug,
|
||||
String packageCode,
|
||||
int maximumInstallations) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record RegisteredInstallation(
|
||||
UUID installationId,
|
||||
UUID installationUuid,
|
||||
String clientId,
|
||||
String installationCode,
|
||||
int securityVersion,
|
||||
String status) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public enum RegistrationStatus {
|
||||
DRAFT,
|
||||
ACTIVE,
|
||||
SUSPENDED,
|
||||
TERMINATED
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.cygnus.cloud.tenant.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.tenant.model.LicenseDeliveryDetails;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class ClientAdministrationRepository {
|
||||
|
||||
private static final String INSERT_REGISTRATION = """
|
||||
INSERT INTO identity.client_registration_details (
|
||||
registration_id, client_code, legal_company_name, trade_name,
|
||||
pan, cin, gst_number, billing_address_line1,
|
||||
billing_address_line2, billing_city, billing_state,
|
||||
billing_postal_code, billing_country, billing_email,
|
||||
primary_contact_name, primary_contact_email,
|
||||
primary_contact_number, alternate_contact_name,
|
||||
alternate_contact_email, alternate_contact_number,
|
||||
contract_start_date, contract_end_date, status, created_by)
|
||||
VALUES ($1, upper($2), $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||
$12, upper($13), $14, $15, $16, $17, $18, $19, $20,
|
||||
$21, $22, $23, $24)
|
||||
""";
|
||||
|
||||
private static final String INSERT_TENANT = """
|
||||
INSERT INTO identity.client_account (
|
||||
tenant_id, registration_id, client_slug, client_name,
|
||||
status, security_version)
|
||||
VALUES ($1, $2, $3, $4, 'ACTIVE', 1)
|
||||
""";
|
||||
|
||||
private static final String INSERT_LICENSE = """
|
||||
INSERT INTO identity.client_license (
|
||||
license_id, tenant_id, license_type, package_code,
|
||||
valid_from, valid_until, status, max_users,
|
||||
max_installations)
|
||||
SELECT $1, account.tenant_id, $3, $4, $5, $6, 'ACTIVE', $7, $8
|
||||
FROM identity.client_account account
|
||||
WHERE account.tenant_id = $2
|
||||
AND account.status = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String FIND_LICENSE_DELIVERY_DETAILS = """
|
||||
SELECT registration.client_code,
|
||||
account.client_slug,
|
||||
license.package_code,
|
||||
license.max_installations
|
||||
FROM identity.client_license license
|
||||
JOIN identity.client_account account
|
||||
ON account.tenant_id = license.tenant_id
|
||||
JOIN identity.client_registration_details registration
|
||||
ON registration.registration_id = account.registration_id
|
||||
WHERE license.tenant_id = $1
|
||||
AND license.license_id = $2
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public ClientAdministrationRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Mono<Boolean> createRegistration(Tuple values) {
|
||||
return database.preparedUpdate(INSERT_REGISTRATION, values)
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> createTenant(
|
||||
UUID tenantId,
|
||||
UUID registrationId,
|
||||
String slug,
|
||||
String name) {
|
||||
return database.preparedUpdate(
|
||||
INSERT_TENANT,
|
||||
Tuple.of(tenantId, registrationId, slug, name))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> createLicense(
|
||||
UUID licenseId,
|
||||
UUID tenantId,
|
||||
String licenseType,
|
||||
String packageCode,
|
||||
OffsetDateTime validFrom,
|
||||
OffsetDateTime validUntil,
|
||||
Integer maxUsers,
|
||||
int maxInstallations) {
|
||||
return database.preparedUpdate(
|
||||
INSERT_LICENSE,
|
||||
Tuple.of(
|
||||
licenseId,
|
||||
tenantId,
|
||||
licenseType,
|
||||
packageCode,
|
||||
validFrom,
|
||||
validUntil,
|
||||
maxUsers,
|
||||
maxInstallations))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<LicenseDeliveryDetails> findLicenseDeliveryDetails(
|
||||
UUID tenantId, UUID licenseId) {
|
||||
return database.preparedQuery(
|
||||
FIND_LICENSE_DELIVERY_DETAILS,
|
||||
Tuple.of(tenantId, licenseId))
|
||||
.flatMap(rows -> {
|
||||
var iterator = rows.iterator();
|
||||
if (!iterator.hasNext()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var row = iterator.next();
|
||||
return Mono.just(new LicenseDeliveryDetails(
|
||||
row.getString("client_code"),
|
||||
row.getString("client_slug"),
|
||||
row.getString("package_code"),
|
||||
row.getInteger("max_installations")));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.cygnus.cloud.tenant.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.tenant.model.ActivationSession;
|
||||
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.SqlConnection;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class InstallationActivationRepository {
|
||||
|
||||
private static final String INSERT_SESSION = """
|
||||
INSERT INTO identity.installation_activation_session (
|
||||
activation_session_id, activation_key_id, registration_id,
|
||||
tenant_id, license_id, installation_uuid, token_hash,
|
||||
status, expires_at, source_ip, installer_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'PENDING', $8, $9::inet, $10)
|
||||
""";
|
||||
|
||||
private static final String LOCK_SESSION_AND_LICENSE = """
|
||||
SELECT session.activation_session_id, session.registration_id,
|
||||
session.tenant_id, session.license_id,
|
||||
session.installation_uuid, session.status AS session_status,
|
||||
session.expires_at,
|
||||
registration.client_code, registration.status AS registration_status,
|
||||
account.client_slug, account.status AS tenant_status,
|
||||
license.package_code, license.status AS license_status,
|
||||
license.valid_from, license.valid_until,
|
||||
license.max_installations,
|
||||
(
|
||||
SELECT count(*)::integer
|
||||
FROM identity.client_installation installation
|
||||
WHERE installation.tenant_id = session.tenant_id
|
||||
AND installation.license_id = session.license_id
|
||||
AND installation.status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
|
||||
) AS consuming_installations
|
||||
FROM identity.installation_activation_session session
|
||||
JOIN identity.client_registration_details registration
|
||||
ON registration.registration_id = session.registration_id
|
||||
JOIN identity.client_account account
|
||||
ON account.registration_id = session.registration_id
|
||||
AND account.tenant_id = session.tenant_id
|
||||
JOIN identity.client_license license
|
||||
ON license.tenant_id = session.tenant_id
|
||||
AND license.license_id = session.license_id
|
||||
WHERE session.token_hash = $1
|
||||
FOR UPDATE OF session, license
|
||||
""";
|
||||
|
||||
private static final String INSERT_INSTALLATION = """
|
||||
INSERT INTO identity.client_installation (
|
||||
installation_id, tenant_id, client_id, installation_code,
|
||||
assertion_public_key, allowed_scopes, enabled,
|
||||
security_version, license_id, installation_uuid,
|
||||
installation_name, status, registered_at,
|
||||
software_version, environment)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, true, 1, $7, $8, $9,
|
||||
'ACTIVE', $10, $11, $12)
|
||||
""";
|
||||
|
||||
private static final String CONSUME_SESSION = """
|
||||
UPDATE identity.installation_activation_session
|
||||
SET status = 'CONSUMED', consumed_at = $2
|
||||
WHERE activation_session_id = $1
|
||||
AND status = 'PENDING'
|
||||
""";
|
||||
|
||||
private static final String INSERT_AUDIT = """
|
||||
INSERT INTO identity.installation_audit_event (
|
||||
audit_event_id, registration_id, tenant_id, license_id,
|
||||
installation_id, event_type, actor_type, actor_id,
|
||||
reason, event_data, occurred_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'INSTALLATION_REGISTERED',
|
||||
'INSTALLER', $6, 'License-authorized installation',
|
||||
$7::jsonb, $8)
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public InstallationActivationRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Mono<Boolean> insertSession(
|
||||
UUID sessionId,
|
||||
UUID activationKeyId,
|
||||
UUID registrationId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
UUID installationUuid,
|
||||
String tokenHash,
|
||||
OffsetDateTime expiresAt,
|
||||
String sourceIp,
|
||||
String installerVersion) {
|
||||
Tuple values = Tuple.tuple()
|
||||
.addUUID(sessionId)
|
||||
.addUUID(activationKeyId)
|
||||
.addUUID(registrationId)
|
||||
.addUUID(tenantId)
|
||||
.addUUID(licenseId)
|
||||
.addUUID(installationUuid)
|
||||
.addString(tokenHash)
|
||||
.addOffsetDateTime(expiresAt)
|
||||
.addString(sourceIp)
|
||||
.addString(installerVersion);
|
||||
return database.preparedUpdate(INSERT_SESSION, values).map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<RegisteredInstallation> register(
|
||||
String tokenHash,
|
||||
String installationCode,
|
||||
String installationName,
|
||||
String assertionPublicKey,
|
||||
Set<String> scopes,
|
||||
String softwareVersion,
|
||||
String environment,
|
||||
OffsetDateTime now) {
|
||||
return database.inTransaction(connection -> lockContext(connection, tokenHash)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException(
|
||||
"Activation session is invalid")))
|
||||
.flatMap(context -> validate(context, now))
|
||||
.flatMap(context -> insertInstallation(
|
||||
connection,
|
||||
context,
|
||||
installationCode,
|
||||
installationName,
|
||||
assertionPublicKey,
|
||||
scopes,
|
||||
softwareVersion,
|
||||
environment,
|
||||
now)));
|
||||
}
|
||||
|
||||
private Mono<RegistrationContext> lockContext(
|
||||
SqlConnection connection, String tokenHash) {
|
||||
return database.preparedQuery(
|
||||
connection, LOCK_SESSION_AND_LICENSE, Tuple.of(tokenHash))
|
||||
.flatMap(rows -> {
|
||||
java.util.Iterator<Row> iterator = rows.iterator();
|
||||
return iterator.hasNext()
|
||||
? Mono.just(context(iterator.next()))
|
||||
: Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<RegistrationContext> validate(
|
||||
RegistrationContext context, OffsetDateTime now) {
|
||||
boolean valid = "PENDING".equals(context.sessionStatus)
|
||||
&& context.expiresAt.isAfter(now)
|
||||
&& "ACTIVE".equals(context.registrationStatus)
|
||||
&& "ACTIVE".equals(context.tenantStatus)
|
||||
&& "ACTIVE".equals(context.licenseStatus)
|
||||
&& !now.isBefore(context.validFrom)
|
||||
&& now.isBefore(context.validUntil)
|
||||
&& context.consumingInstallations < context.maxInstallations;
|
||||
return valid
|
||||
? Mono.just(context)
|
||||
: Mono.error(new IllegalStateException(
|
||||
"Activation, license, or installation capacity is invalid"));
|
||||
}
|
||||
|
||||
private Mono<RegisteredInstallation> insertInstallation(
|
||||
SqlConnection connection,
|
||||
RegistrationContext context,
|
||||
String installationCode,
|
||||
String installationName,
|
||||
String assertionPublicKey,
|
||||
Set<String> scopes,
|
||||
String softwareVersion,
|
||||
String environment,
|
||||
OffsetDateTime now) {
|
||||
UUID installationId = UUID.randomUUID();
|
||||
Tuple insert = Tuple.tuple()
|
||||
.addUUID(installationId)
|
||||
.addUUID(context.tenantId)
|
||||
.addString(context.clientSlug)
|
||||
.addString(installationCode)
|
||||
.addString(assertionPublicKey)
|
||||
.addArrayOfString(scopes.toArray(String[]::new))
|
||||
.addUUID(context.licenseId)
|
||||
.addUUID(context.installationUuid)
|
||||
.addString(installationName)
|
||||
.addOffsetDateTime(now)
|
||||
.addString(softwareVersion)
|
||||
.addString(environment);
|
||||
return database.preparedQuery(connection, INSERT_INSTALLATION, insert)
|
||||
.flatMap(rows -> rows.rowCount() == 1
|
||||
? consumeAndAudit(connection, context, installationId, now)
|
||||
: Mono.error(new IllegalStateException(
|
||||
"Installation could not be registered")))
|
||||
.thenReturn(new RegisteredInstallation(
|
||||
installationId,
|
||||
context.installationUuid,
|
||||
context.clientSlug,
|
||||
installationCode,
|
||||
1,
|
||||
"ACTIVE"));
|
||||
}
|
||||
|
||||
private Mono<Void> consumeAndAudit(
|
||||
SqlConnection connection,
|
||||
RegistrationContext context,
|
||||
UUID installationId,
|
||||
OffsetDateTime now) {
|
||||
return database.preparedQuery(
|
||||
connection,
|
||||
CONSUME_SESSION,
|
||||
Tuple.of(context.sessionId, now))
|
||||
.flatMap(rows -> rows.rowCount() == 1
|
||||
? database.preparedQuery(
|
||||
connection,
|
||||
INSERT_AUDIT,
|
||||
Tuple.of(
|
||||
UUID.randomUUID(),
|
||||
context.registrationId,
|
||||
context.tenantId,
|
||||
context.licenseId,
|
||||
installationId,
|
||||
context.installationUuid.toString(),
|
||||
"{}",
|
||||
now))
|
||||
: Mono.error(new IllegalStateException(
|
||||
"Activation session was already consumed")))
|
||||
.then();
|
||||
}
|
||||
|
||||
private RegistrationContext context(Row row) {
|
||||
return new RegistrationContext(
|
||||
row.getUUID("activation_session_id"),
|
||||
row.getUUID("registration_id"),
|
||||
row.getUUID("tenant_id"),
|
||||
row.getUUID("license_id"),
|
||||
row.getUUID("installation_uuid"),
|
||||
row.getString("session_status"),
|
||||
row.getOffsetDateTime("expires_at"),
|
||||
row.getString("client_code"),
|
||||
row.getString("registration_status"),
|
||||
row.getString("client_slug"),
|
||||
row.getString("tenant_status"),
|
||||
row.getString("package_code"),
|
||||
row.getString("license_status"),
|
||||
row.getOffsetDateTime("valid_from"),
|
||||
row.getOffsetDateTime("valid_until"),
|
||||
row.getInteger("max_installations"),
|
||||
row.getInteger("consuming_installations"));
|
||||
}
|
||||
|
||||
private record RegistrationContext(
|
||||
UUID sessionId,
|
||||
UUID registrationId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
UUID installationUuid,
|
||||
String sessionStatus,
|
||||
OffsetDateTime expiresAt,
|
||||
String clientCode,
|
||||
String registrationStatus,
|
||||
String clientSlug,
|
||||
String tenantStatus,
|
||||
String packageCode,
|
||||
String licenseStatus,
|
||||
OffsetDateTime validFrom,
|
||||
OffsetDateTime validUntil,
|
||||
int maxInstallations,
|
||||
int consumingInstallations) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.cygnus.cloud.tenant.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.tenant.model.InstallationLifecycleResult;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class InstallationLifecycleRepository {
|
||||
|
||||
private static final String RETIRE = """
|
||||
WITH retired AS (
|
||||
UPDATE identity.client_installation
|
||||
SET status = $3,
|
||||
enabled = false,
|
||||
security_version = security_version + 1,
|
||||
retired_at = now(),
|
||||
retired_by = $4,
|
||||
retirement_reason = $5
|
||||
WHERE installation_id = $1
|
||||
AND tenant_id = $2
|
||||
AND status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
|
||||
RETURNING installation_id, tenant_id, client_id,
|
||||
installation_code, license_id, status
|
||||
), audited AS (
|
||||
INSERT INTO identity.installation_audit_event (
|
||||
audit_event_id, registration_id, tenant_id, license_id,
|
||||
installation_id, event_type, actor_type, actor_id,
|
||||
reason, event_data)
|
||||
SELECT gen_random_uuid(), account.registration_id, retired.tenant_id,
|
||||
retired.license_id, retired.installation_id, retired.status,
|
||||
'ADMIN', $4, $5, '{}'::jsonb
|
||||
FROM retired
|
||||
JOIN identity.client_account account
|
||||
ON account.tenant_id = retired.tenant_id
|
||||
)
|
||||
SELECT installation_id, tenant_id, client_id, installation_code, status
|
||||
FROM retired
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public InstallationLifecycleRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Mono<InstallationLifecycleResult> retire(
|
||||
UUID tenantId,
|
||||
UUID installationId,
|
||||
String status,
|
||||
String actor,
|
||||
String reason) {
|
||||
return database.preparedQuery(
|
||||
RETIRE,
|
||||
Tuple.of(installationId, tenantId, status, actor, reason))
|
||||
.flatMap(rows -> {
|
||||
var iterator = rows.iterator();
|
||||
if (!iterator.hasNext()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var row = iterator.next();
|
||||
return Mono.just(new InstallationLifecycleResult(
|
||||
row.getUUID("installation_id"),
|
||||
row.getUUID("tenant_id"),
|
||||
row.getString("client_id"),
|
||||
row.getString("installation_code"),
|
||||
row.getString("status")));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.cygnus.cloud.tenant.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.tenant.model.ActivationKeyStatus;
|
||||
import com.cygnus.cloud.tenant.model.ClientStatus;
|
||||
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
|
||||
import com.cygnus.cloud.tenant.model.LicenseActivationKey;
|
||||
import com.cygnus.cloud.tenant.model.LicenseStatus;
|
||||
import com.cygnus.cloud.tenant.model.RegistrationStatus;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class LicenseActivationRepository {
|
||||
|
||||
private static final String FIND_CANDIDATES = """
|
||||
SELECT activation.activation_key_id, activation.registration_id,
|
||||
activation.tenant_id, activation.license_id,
|
||||
activation.key_hash, activation.key_hint, activation.status,
|
||||
activation.expires_at, activation.failed_attempts,
|
||||
activation.maximum_attempts, activation.locked_until,
|
||||
activation.created_at, activation.last_used_at,
|
||||
registration.client_code,
|
||||
registration.status AS registration_status,
|
||||
account.status AS tenant_status,
|
||||
account.client_slug, account.client_name,
|
||||
license.package_code, license.license_type,
|
||||
license.status AS license_status,
|
||||
license.valid_from, license.valid_until,
|
||||
license.max_installations,
|
||||
(
|
||||
SELECT count(*)::integer
|
||||
FROM identity.client_installation installation
|
||||
WHERE installation.tenant_id = activation.tenant_id
|
||||
AND installation.license_id = activation.license_id
|
||||
AND installation.status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
|
||||
) AS consuming_installations
|
||||
FROM identity.license_activation_key activation
|
||||
JOIN identity.client_registration_details registration
|
||||
ON registration.registration_id = activation.registration_id
|
||||
JOIN identity.client_account account
|
||||
ON account.registration_id = activation.registration_id
|
||||
AND account.tenant_id = activation.tenant_id
|
||||
JOIN identity.client_license license
|
||||
ON license.tenant_id = activation.tenant_id
|
||||
AND license.license_id = activation.license_id
|
||||
WHERE upper(registration.client_code) = upper($1)
|
||||
AND activation.key_hint = $2
|
||||
""";
|
||||
|
||||
private static final String INSERT_KEY = """
|
||||
INSERT INTO identity.license_activation_key (
|
||||
activation_key_id, registration_id, tenant_id, license_id,
|
||||
key_hash, key_hint, status, expires_at, created_by)
|
||||
SELECT $1, account.registration_id, account.tenant_id,
|
||||
license.license_id, $4, $5, 'ACTIVE', $6, $7
|
||||
FROM identity.client_account account
|
||||
JOIN identity.client_license license
|
||||
ON license.tenant_id = account.tenant_id
|
||||
WHERE account.tenant_id = $2
|
||||
AND license.license_id = $3
|
||||
AND account.status = 'ACTIVE'
|
||||
AND license.status = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String RECORD_SUCCESS = """
|
||||
UPDATE identity.license_activation_key
|
||||
SET failed_attempts = 0,
|
||||
locked_until = NULL,
|
||||
status = 'ACTIVE',
|
||||
last_used_at = $2
|
||||
WHERE activation_key_id = $1
|
||||
""";
|
||||
|
||||
private static final String RECORD_FAILURE = """
|
||||
UPDATE identity.license_activation_key
|
||||
SET failed_attempts = LEAST(failed_attempts + 1, maximum_attempts),
|
||||
status = CASE
|
||||
WHEN failed_attempts + 1 >= maximum_attempts THEN 'LOCKED'
|
||||
ELSE status
|
||||
END,
|
||||
locked_until = CASE
|
||||
WHEN failed_attempts + 1 >= maximum_attempts THEN $2
|
||||
ELSE locked_until
|
||||
END
|
||||
WHERE activation_key_id = $1
|
||||
""";
|
||||
|
||||
private static final String REVOKE_KEY = """
|
||||
UPDATE identity.license_activation_key
|
||||
SET status = 'REVOKED',
|
||||
locked_until = NULL
|
||||
WHERE activation_key_id = $1
|
||||
AND status IN ('ACTIVE', 'LOCKED')
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public LicenseActivationRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Flux<LicenseActivationContext> findCandidates(
|
||||
String clientCode, String keyHint) {
|
||||
return database.preparedQuery(FIND_CANDIDATES, Tuple.of(clientCode, keyHint))
|
||||
.flatMapMany(Flux::fromIterable)
|
||||
.map(this::context);
|
||||
}
|
||||
|
||||
public Mono<Boolean> insert(
|
||||
UUID activationKeyId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
String keyHash,
|
||||
String keyHint,
|
||||
OffsetDateTime expiresAt,
|
||||
String createdBy) {
|
||||
Tuple values = Tuple.tuple()
|
||||
.addUUID(activationKeyId)
|
||||
.addUUID(tenantId)
|
||||
.addUUID(licenseId)
|
||||
.addString(keyHash)
|
||||
.addString(keyHint)
|
||||
.addOffsetDateTime(expiresAt)
|
||||
.addString(createdBy);
|
||||
return database.preparedUpdate(INSERT_KEY, values).map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> recordSuccess(UUID activationKeyId, OffsetDateTime now) {
|
||||
return database.preparedUpdate(
|
||||
RECORD_SUCCESS, Tuple.of(activationKeyId, now))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> recordFailure(
|
||||
UUID activationKeyId, OffsetDateTime lockedUntil) {
|
||||
return database.preparedUpdate(
|
||||
RECORD_FAILURE, Tuple.of(activationKeyId, lockedUntil))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> revoke(UUID activationKeyId) {
|
||||
return database.preparedUpdate(REVOKE_KEY, Tuple.of(activationKeyId))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
private LicenseActivationContext context(Row row) {
|
||||
LicenseActivationKey key = new LicenseActivationKey(
|
||||
row.getUUID("activation_key_id"),
|
||||
row.getUUID("registration_id"),
|
||||
row.getUUID("tenant_id"),
|
||||
row.getUUID("license_id"),
|
||||
row.getString("key_hash"),
|
||||
row.getString("key_hint"),
|
||||
ActivationKeyStatus.valueOf(row.getString("status")),
|
||||
row.getOffsetDateTime("expires_at"),
|
||||
row.getInteger("failed_attempts"),
|
||||
row.getInteger("maximum_attempts"),
|
||||
row.getOffsetDateTime("locked_until"),
|
||||
row.getOffsetDateTime("created_at"),
|
||||
row.getOffsetDateTime("last_used_at"));
|
||||
return new LicenseActivationContext(
|
||||
key,
|
||||
row.getString("client_code"),
|
||||
RegistrationStatus.valueOf(row.getString("registration_status")),
|
||||
ClientStatus.valueOf(row.getString("tenant_status")),
|
||||
row.getString("client_slug"),
|
||||
row.getString("client_name"),
|
||||
row.getString("package_code"),
|
||||
row.getString("license_type"),
|
||||
LicenseStatus.valueOf(row.getString("license_status")),
|
||||
row.getOffsetDateTime("valid_from").toInstant(),
|
||||
row.getOffsetDateTime("valid_until").toInstant(),
|
||||
row.getInteger("max_installations"),
|
||||
row.getInteger("consuming_installations"));
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,18 @@ public class TenantRegistrationRepository {
|
||||
WHERE installation.client_id = $1
|
||||
AND installation.installation_code = $2
|
||||
AND installation.enabled = true
|
||||
AND installation.status = 'ACTIVE'
|
||||
AND account.status = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String TOUCH_INSTALLATION = """
|
||||
UPDATE identity.client_installation
|
||||
SET last_seen_at = $2,
|
||||
last_authenticated_at = $2
|
||||
WHERE installation_id = $1
|
||||
AND (last_seen_at IS NULL OR last_seen_at < $2 - INTERVAL '5 minutes')
|
||||
""";
|
||||
|
||||
private static final String FIND_CURRENT_LICENSE = """
|
||||
SELECT license_id, tenant_id, license_type, package_code,
|
||||
valid_from, valid_until, status, max_users, max_installations,
|
||||
@@ -82,6 +91,15 @@ public class TenantRegistrationRepository {
|
||||
.flatMap(rows -> first(rows, this::license));
|
||||
}
|
||||
|
||||
public Mono<Void> touchInstallation(UUID installationId, Instant instant) {
|
||||
return database.preparedUpdate(
|
||||
TOUCH_INSTALLATION,
|
||||
Tuple.of(
|
||||
installationId,
|
||||
instant.atOffset(java.time.ZoneOffset.UTC)))
|
||||
.then();
|
||||
}
|
||||
|
||||
private <T> Mono<T> first(
|
||||
Iterable<Row> rows, java.util.function.Function<Row, T> mapper) {
|
||||
java.util.Iterator<Row> iterator = rows.iterator();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
public class ActivationRateLimitException extends RuntimeException {
|
||||
public ActivationRateLimitException() {
|
||||
super("Installation activation rate limit exceeded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.cache.ReactiveCacheService;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class ActivationRateLimiter {
|
||||
|
||||
private static final String NAMESPACE = "installation-activation-rate";
|
||||
private final ReactiveCacheService cache;
|
||||
private final int maximumAttempts;
|
||||
private final Duration window;
|
||||
|
||||
public ActivationRateLimiter(
|
||||
ReactiveCacheService cache,
|
||||
@Value("${cygnus.activation-rate-limit.maximum-attempts:10}")
|
||||
int maximumAttempts,
|
||||
@Value("${cygnus.activation-rate-limit.window:10m}")
|
||||
Duration window) {
|
||||
this.cache = cache;
|
||||
this.maximumAttempts = maximumAttempts;
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
public Mono<Void> check(String sourceIp, String clientCode) {
|
||||
String identity = (sourceIp == null ? "unknown" : sourceIp)
|
||||
+ '|'
|
||||
+ clientCode.toUpperCase(java.util.Locale.ROOT);
|
||||
return cache.increment(NAMESPACE, digest(identity), window)
|
||||
.flatMap(attempts -> attempts <= maximumAttempts
|
||||
? Mono.empty()
|
||||
: Mono.error(new ActivationRateLimitException()));
|
||||
}
|
||||
|
||||
private String digest(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
public class InstallationActivationException extends RuntimeException {
|
||||
|
||||
public InstallationActivationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public InstallationActivationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.ActivationSession;
|
||||
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
|
||||
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
|
||||
import com.cygnus.cloud.tenant.repository.InstallationActivationRepository;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Base64;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class InstallationActivationService {
|
||||
|
||||
private static final Duration SESSION_TTL = Duration.ofMinutes(20);
|
||||
private static final Set<String> INSTALLATION_SCOPES = Set.of("identity.login");
|
||||
|
||||
private final LicenseKeyService licenseKeyService;
|
||||
private final InstallationActivationRepository repository;
|
||||
private final SecureRandom random;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public InstallationActivationService(
|
||||
LicenseKeyService licenseKeyService,
|
||||
InstallationActivationRepository repository,
|
||||
Clock clock) {
|
||||
this(licenseKeyService, repository, clock, new SecureRandom());
|
||||
}
|
||||
|
||||
InstallationActivationService(
|
||||
LicenseKeyService licenseKeyService,
|
||||
InstallationActivationRepository repository,
|
||||
Clock clock,
|
||||
SecureRandom random) {
|
||||
this.licenseKeyService = licenseKeyService;
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
public Mono<ActivationSession> validateAndCreateSession(
|
||||
String clientCode,
|
||||
String licenseKey,
|
||||
UUID installationUuid,
|
||||
String sourceIp,
|
||||
String installerVersion) {
|
||||
return licenseKeyService.validate(clientCode, licenseKey)
|
||||
.flatMap(context -> createSession(
|
||||
context,
|
||||
installationUuid,
|
||||
sourceIp,
|
||||
installerVersion));
|
||||
}
|
||||
|
||||
public Mono<RegisteredInstallation> register(
|
||||
String activationToken,
|
||||
String installationCode,
|
||||
String installationName,
|
||||
String assertionPublicKey,
|
||||
String softwareVersion,
|
||||
String environment) {
|
||||
validatePublicKey(assertionPublicKey);
|
||||
OffsetDateTime now = now();
|
||||
return repository.register(
|
||||
digest(activationToken),
|
||||
installationCode,
|
||||
installationName,
|
||||
assertionPublicKey,
|
||||
INSTALLATION_SCOPES,
|
||||
softwareVersion,
|
||||
environment,
|
||||
now)
|
||||
.onErrorMap(
|
||||
error -> !(error instanceof InstallationActivationException),
|
||||
error -> new InstallationActivationException(
|
||||
"Installation registration failed", error));
|
||||
}
|
||||
|
||||
private Mono<ActivationSession> createSession(
|
||||
LicenseActivationContext context,
|
||||
UUID installationUuid,
|
||||
String sourceIp,
|
||||
String installerVersion) {
|
||||
String token = randomToken();
|
||||
UUID sessionId = UUID.randomUUID();
|
||||
OffsetDateTime expiresAt = now().plus(SESSION_TTL);
|
||||
return repository.insertSession(
|
||||
sessionId,
|
||||
context.activationKey().activationKeyId(),
|
||||
context.activationKey().registrationId(),
|
||||
context.activationKey().tenantId(),
|
||||
context.activationKey().licenseId(),
|
||||
installationUuid,
|
||||
digest(token),
|
||||
expiresAt,
|
||||
sourceIp,
|
||||
installerVersion)
|
||||
.flatMap(inserted -> inserted
|
||||
? Mono.just(new ActivationSession(
|
||||
sessionId,
|
||||
context.activationKey().registrationId(),
|
||||
context.activationKey().tenantId(),
|
||||
context.activationKey().licenseId(),
|
||||
installationUuid,
|
||||
token,
|
||||
expiresAt,
|
||||
context.clientCode(),
|
||||
context.clientSlug(),
|
||||
context.packageCode(),
|
||||
context.maxInstallations()))
|
||||
: Mono.error(new InstallationActivationException(
|
||||
"Activation session could not be created")));
|
||||
}
|
||||
|
||||
private String randomToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
random.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private String digest(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new InstallationActivationException(
|
||||
"Activation token is required");
|
||||
}
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
} catch (Exception exception) {
|
||||
throw new InstallationActivationException(
|
||||
"Activation token could not be processed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePublicKey(String pem) {
|
||||
try {
|
||||
String encoded = pem
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
RSAPublicKey key = (RSAPublicKey) KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
if (key.getModulus().bitLength() < 3072) {
|
||||
throw new InstallationActivationException(
|
||||
"Installation public key must be at least 3072-bit RSA");
|
||||
}
|
||||
} catch (InstallationActivationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new InstallationActivationException(
|
||||
"Installation public key is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private OffsetDateTime now() {
|
||||
return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.InstallationLifecycleResult;
|
||||
import com.cygnus.cloud.tenant.repository.InstallationLifecycleRepository;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class InstallationLifecycleService {
|
||||
|
||||
private final InstallationLifecycleRepository repository;
|
||||
private final TenantRegistrationService registrations;
|
||||
|
||||
public InstallationLifecycleService(
|
||||
InstallationLifecycleRepository repository,
|
||||
TenantRegistrationService registrations) {
|
||||
this.repository = repository;
|
||||
this.registrations = registrations;
|
||||
}
|
||||
|
||||
public Mono<InstallationLifecycleResult> retire(
|
||||
UUID tenantId,
|
||||
UUID installationId,
|
||||
String status,
|
||||
String actor,
|
||||
String reason) {
|
||||
return repository.retire(tenantId, installationId, status, actor, reason)
|
||||
.switchIfEmpty(Mono.error(
|
||||
new IllegalArgumentException("Active installation not found")))
|
||||
.flatMap(result -> registrations
|
||||
.evictInstallation(result.clientId(), result.installationCode())
|
||||
.thenReturn(result));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
public class LicenseKeyException extends RuntimeException {
|
||||
|
||||
public LicenseKeyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.ClientStatus;
|
||||
import com.cygnus.cloud.tenant.model.IssuedLicenseKey;
|
||||
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
|
||||
import com.cygnus.cloud.tenant.model.LicenseStatus;
|
||||
import com.cygnus.cloud.tenant.model.RegistrationStatus;
|
||||
import com.cygnus.cloud.tenant.repository.LicenseActivationRepository;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
@Service
|
||||
public class LicenseKeyService {
|
||||
|
||||
private static final String PREFIX = "CYGNUS";
|
||||
private static final int RANDOM_BYTES = 20;
|
||||
private static final Duration LOCK_DURATION = Duration.ofMinutes(30);
|
||||
|
||||
private final LicenseActivationRepository repository;
|
||||
private final BCryptPasswordEncoder encoder;
|
||||
private final SecureRandom random;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public LicenseKeyService(
|
||||
LicenseActivationRepository repository,
|
||||
Clock clock) {
|
||||
this(repository, clock, new BCryptPasswordEncoder(12), new SecureRandom());
|
||||
}
|
||||
|
||||
LicenseKeyService(
|
||||
LicenseActivationRepository repository,
|
||||
Clock clock,
|
||||
BCryptPasswordEncoder encoder,
|
||||
SecureRandom random) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
this.encoder = encoder;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
public Mono<IssuedLicenseKey> issue(
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
OffsetDateTime expiresAt,
|
||||
String createdBy) {
|
||||
return Mono.fromCallable(this::newPlaintextKey)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.flatMap(plaintext -> hash(plaintext)
|
||||
.flatMap(hash -> {
|
||||
UUID id = UUID.randomUUID();
|
||||
String hint = hint(plaintext);
|
||||
return repository.insert(
|
||||
id,
|
||||
tenantId,
|
||||
licenseId,
|
||||
hash,
|
||||
hint,
|
||||
expiresAt,
|
||||
createdBy)
|
||||
.flatMap(inserted -> inserted
|
||||
? Mono.just(new IssuedLicenseKey(
|
||||
id, plaintext, hint))
|
||||
: Mono.error(new LicenseKeyException(
|
||||
"Active tenant and license were not found")));
|
||||
}));
|
||||
}
|
||||
|
||||
public Mono<LicenseActivationContext> validate(
|
||||
String clientCode, String plaintextKey) {
|
||||
String normalizedCode = normalizeClientCode(clientCode);
|
||||
String normalizedKey = normalizeKey(plaintextKey);
|
||||
OffsetDateTime now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
|
||||
return repository.findCandidates(normalizedCode, hint(normalizedKey))
|
||||
.collectList()
|
||||
.flatMap(candidates -> matchingCandidate(candidates, normalizedKey))
|
||||
.flatMap(candidate -> validateContext(candidate, now))
|
||||
.flatMap(candidate -> repository
|
||||
.recordSuccess(candidate.activationKey().activationKeyId(), now)
|
||||
.thenReturn(candidate));
|
||||
}
|
||||
|
||||
private Mono<LicenseActivationContext> matchingCandidate(
|
||||
List<LicenseActivationContext> candidates, String plaintextKey) {
|
||||
if (candidates.isEmpty()) {
|
||||
return Mono.error(new LicenseKeyException(
|
||||
"The client code or license key is invalid"));
|
||||
}
|
||||
return Flux.fromIterable(candidates)
|
||||
.filterWhen(candidate -> matches(
|
||||
plaintextKey, candidate.activationKey().keyHash()))
|
||||
.next()
|
||||
.switchIfEmpty(Flux.fromIterable(candidates)
|
||||
.flatMap(candidate -> recordRejectedAttempt(
|
||||
candidate.activationKey().activationKeyId()))
|
||||
.then(Mono.error(new LicenseKeyException(
|
||||
"The client code or license key is invalid"))));
|
||||
}
|
||||
|
||||
private Mono<LicenseActivationContext> validateContext(
|
||||
LicenseActivationContext context, OffsetDateTime now) {
|
||||
if (!context.activationKey().canAttemptAt(now)) {
|
||||
return Mono.error(new LicenseKeyException(
|
||||
"The license key is expired, locked, or inactive"));
|
||||
}
|
||||
if (context.registrationStatus() != RegistrationStatus.ACTIVE
|
||||
|| context.tenantStatus() != ClientStatus.ACTIVE) {
|
||||
return Mono.error(new LicenseKeyException(
|
||||
"The client registration or tenant is inactive"));
|
||||
}
|
||||
if (context.licenseStatus() != LicenseStatus.ACTIVE
|
||||
|| clock.instant().isBefore(context.validFrom())
|
||||
|| !clock.instant().isBefore(context.validUntil())) {
|
||||
return Mono.error(new LicenseKeyException("The license is not active"));
|
||||
}
|
||||
if (!context.hasCapacity()) {
|
||||
return Mono.error(new LicenseKeyException(
|
||||
"The installation limit for this tenant has been reached"));
|
||||
}
|
||||
return Mono.just(context);
|
||||
}
|
||||
|
||||
public Mono<Boolean> recordRejectedAttempt(UUID activationKeyId) {
|
||||
OffsetDateTime lockedUntil = OffsetDateTime.ofInstant(
|
||||
clock.instant().plus(LOCK_DURATION), ZoneOffset.UTC);
|
||||
return repository.recordFailure(activationKeyId, lockedUntil);
|
||||
}
|
||||
|
||||
public Mono<Boolean> revoke(UUID activationKeyId) {
|
||||
return repository.revoke(activationKeyId);
|
||||
}
|
||||
|
||||
private Mono<String> hash(String plaintext) {
|
||||
return Mono.fromCallable(() -> encoder.encode(plaintext))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private Mono<Boolean> matches(String plaintext, String hash) {
|
||||
return Mono.fromCallable(() -> encoder.matches(plaintext, hash))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private String newPlaintextKey() {
|
||||
byte[] bytes = new byte[RANDOM_BYTES];
|
||||
random.nextBytes(bytes);
|
||||
String raw = HexFormat.of().formatHex(bytes).toUpperCase(Locale.ROOT);
|
||||
return PREFIX + "-" + groups(raw);
|
||||
}
|
||||
|
||||
private String groups(String value) {
|
||||
return String.join(
|
||||
"-",
|
||||
value.substring(0, 8),
|
||||
value.substring(8, 16),
|
||||
value.substring(16, 24),
|
||||
value.substring(24, 32),
|
||||
value.substring(32, 40));
|
||||
}
|
||||
|
||||
private String hint(String key) {
|
||||
String compact = key.replace("-", "");
|
||||
return compact.substring(Math.max(0, compact.length() - 8));
|
||||
}
|
||||
|
||||
private String normalizeClientCode(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new LicenseKeyException("Client code is required");
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeKey(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new LicenseKeyException("License key is required");
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.IssuedLicenseKey;
|
||||
import java.time.OffsetDateTime;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
@Service
|
||||
public class RegistrationEmailService {
|
||||
|
||||
private final ObjectProvider<JavaMailSender> mailSenderProvider;
|
||||
private final String from;
|
||||
|
||||
public RegistrationEmailService(
|
||||
ObjectProvider<JavaMailSender> mailSenderProvider,
|
||||
@Value("${cygnus.registration-email.from:noreply@cygnus.invalid}")
|
||||
String from) {
|
||||
this.mailSenderProvider = mailSenderProvider;
|
||||
this.from = from;
|
||||
}
|
||||
|
||||
public Mono<Void> sendLicense(
|
||||
String recipient,
|
||||
String clientCode,
|
||||
String tenantSlug,
|
||||
String packageCode,
|
||||
int maximumInstallations,
|
||||
OffsetDateTime validUntil,
|
||||
IssuedLicenseKey issued) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
JavaMailSender mailSender = mailSenderProvider.getIfAvailable();
|
||||
if (mailSender == null) {
|
||||
throw new IllegalStateException(
|
||||
"SMTP is not configured; set spring.mail.host and credentials");
|
||||
}
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(from);
|
||||
message.setTo(recipient);
|
||||
message.setSubject("Cygnus registration and installation license");
|
||||
message.setText("""
|
||||
Your Cygnus tenant is ready.
|
||||
|
||||
Client code: %s
|
||||
Tenant: %s
|
||||
Package: %s
|
||||
Maximum installations: %d
|
||||
License key: %s
|
||||
License key expiry: %s
|
||||
|
||||
Docker and Docker Compose v2 are mandatory. The installation
|
||||
wizard will not continue until both are available and running.
|
||||
Keep this license key confidential and enter it only in the
|
||||
official Cygnus installation wizard.
|
||||
""".formatted(
|
||||
clientCode,
|
||||
tenantSlug,
|
||||
packageCode,
|
||||
maximumInstallations,
|
||||
issued.licenseKey(),
|
||||
validUntil));
|
||||
mailSender.send(message);
|
||||
})
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.then();
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,11 @@ public class TenantRegistrationService {
|
||||
.onErrorReturn(false);
|
||||
}
|
||||
|
||||
public Mono<Void> touchInstallation(UUID installationId, Instant instant) {
|
||||
return repository.touchInstallation(installationId, instant)
|
||||
.onErrorResume(exception -> Mono.empty());
|
||||
}
|
||||
|
||||
private Duration licenseTtl(ClientLicense license) {
|
||||
Duration remaining = Duration.between(clock.instant(), license.validUntil());
|
||||
if (remaining.isNegative() || remaining.isZero()) {
|
||||
|
||||
Reference in New Issue
Block a user