Installation automation - License key approach

This commit is contained in:
2026-07-26 17:02:16 +05:30
parent d6dc33d9b1
commit 60f5450f47
385 changed files with 4557 additions and 2228 deletions

View File

@@ -12,10 +12,9 @@
<property name="sun.java.launcher" value="SUN_STANDARD"/>
<property name="user.country" value="US"/>
<property name="sun.boot.library.path" value="/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home/lib"/>
<property name="sun.java.command" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260724215520397_3.jar /Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire 2026-07-24T21-55-20_356-jvmRun1 surefire-20260724215520397_1tmp surefire_0-20260724215520397_2tmp"/>
<property name="sun.java.command" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260726165419825_10.jar /Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire 2026-07-26T16-54-14_594-jvmRun1 surefire-20260726165419825_8tmp surefire_1-20260726165419825_9tmp"/>
<property name="http.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
<property name="jdk.debug" value="release"/>
<property name="test" value="CloudClientPropertiesTest"/>
<property name="surefire.test.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/test-classes:/Users/maddy/Projects/matrix/cygnus-cloud-client/target/classes:/Users/maddy/.m2/repository/org/springframework/spring-webflux/6.2.19/spring-webflux-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-beans/6.2.19/spring-beans-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-core/6.2.19/spring-core-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-jcl/6.2.19/spring-jcl-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-web/6.2.19/spring-web-6.2.19.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-observation/1.15.12/micrometer-observation-1.15.12.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-commons/1.15.12/micrometer-commons-1.15.12.jar:/Users/maddy/.m2/repository/io/projectreactor/reactor-core/3.7.19/reactor-core-3.7.19.jar:/Users/maddy/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-http/1.2.8/reactor-netty-http-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http/4.1.122.Final/netty-codec-http-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-common/4.1.122.Final/netty-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-buffer/4.1.122.Final/netty-buffer-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport/4.1.122.Final/netty-transport-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec/4.1.122.Final/netty-codec-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-handler/4.1.122.Final/netty-handler-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http2/4.1.122.Final/netty-codec-http2-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns/4.1.122.Final/netty-resolver-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver/4.1.122.Final/netty-resolver-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-dns/4.1.122.Final/netty-codec-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-native-macos/4.1.122.Final/netty-resolver-dns-native-macos-4.1.122.Final-osx-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-classes-macos/4.1.122.Final/netty-resolver-dns-classes-macos-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-epoll/4.1.122.Final/netty-transport-native-epoll-4.1.122.Final-linux-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-unix-common/4.1.122.Final/netty-transport-native-unix-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-classes-epoll/4.1.122.Final/netty-transport-classes-epoll-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-core/1.2.8/reactor-netty-core-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-handler-proxy/4.1.122.Final/netty-handler-proxy-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-socks/4.1.122.Final/netty-codec-socks-4.1.122.Final.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.18.6/jackson-databind-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.18.6/jackson-annotations-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.18.6/jackson-core-2.18.6.jar:/Users/maddy/.m2/repository/com/nimbusds/nimbus-jose-jwt/10.4/nimbus-jose-jwt-10.4.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter/5.12.2/junit-jupiter-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.12.2/junit-jupiter-api-5.12.2.jar:/Users/maddy/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-commons/1.12.2/junit-platform-commons-1.12.2.jar:/Users/maddy/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.12.2/junit-jupiter-params-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.12.2/junit-jupiter-engine-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-engine/1.12.2/junit-platform-engine-1.12.2.jar:"/>
<property name="sun.cpu.endian" value="little"/>
<property name="user.home" value="/Users/maddy"/>
@@ -30,7 +29,7 @@
<property name="java.vm.specification.vendor" value="Oracle Corporation"/>
<property name="java.specification.name" value="Java Platform API Specification"/>
<property name="apple.awt.application.name" value="ForkedBooter"/>
<property name="surefire.real.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260724215520397_3.jar"/>
<property name="surefire.real.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260726165419825_10.jar"/>
<property name="sun.management.compiler" value="HotSpot 64-Bit Tiered Compilers"/>
<property name="ftp.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
<property name="java.runtime.version" value="21.0.8+9-LTS"/>
@@ -59,6 +58,6 @@
<property name="socksNonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
<property name="java.class.version" value="65.0"/>
</properties>
<testcase name="configurationAcceptsCompleteMachineIdentity" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.007"/>
<testcase name="configurationAcceptsCompleteMachineIdentity" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.006"/>
<testcase name="configurationRequiresMachineCredentials" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.001"/>
</testsuite>

View File

@@ -41,6 +41,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

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

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

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

View File

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

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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) {}

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public enum ActivationKeyStatus {
ACTIVE,
LOCKED,
REVOKED,
EXPIRED
}

View File

@@ -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) {
}

View File

@@ -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) {
}

View File

@@ -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) {}

View File

@@ -0,0 +1,9 @@
package com.cygnus.cloud.tenant.model;
import java.util.UUID;
public record IssuedLicenseKey(
UUID activationKeyId,
String licenseKey,
String keyHint) {
}

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.cygnus.cloud.tenant.model;
public record LicenseDeliveryDetails(
String clientCode,
String tenantSlug,
String packageCode,
int maximumInstallations) {}

View File

@@ -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) {
}

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public enum RegistrationStatus {
DRAFT,
ACTIVE,
SUSPENDED,
TERMINATED
}

View File

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

View File

@@ -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) {
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.cygnus.cloud.tenant.service;
public class ActivationRateLimitException extends RuntimeException {
public ActivationRateLimitException() {
super("Installation activation rate limit exceeded");
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.service;
public class LicenseKeyException extends RuntimeException {
public LicenseKeyException(String message) {
super(message);
}
}

View File

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

View File

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

View File

@@ -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()) {

View File

@@ -1,6 +1,17 @@
spring:
application:
name: cygnus-cloud-service
mail:
host: ${CYGNUS_MAIL_HOST:}
port: ${CYGNUS_MAIL_PORT:587}
username: ${CYGNUS_MAIL_USERNAME:}
password: ${CYGNUS_MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: ${CYGNUS_MAIL_SMTP_AUTH:true}
starttls:
enable: ${CYGNUS_MAIL_STARTTLS:true}
data:
redis:
host: ${REDIS_HOST:192.168.0.111}
@@ -43,6 +54,11 @@ cygnus:
cache:
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
default-ttl: ${CYGNUS_CACHE_TTL:10m}
registration-email:
from: ${CYGNUS_REGISTRATION_EMAIL_FROM:noreply@cygnus.invalid}
activation-rate-limit:
maximum-attempts: ${CYGNUS_ACTIVATION_MAX_ATTEMPTS:10}
window: ${CYGNUS_ACTIVATION_RATE_WINDOW:10m}
server:
port: ${CYGNUS_CLOUD_PORT:8090}

View File

@@ -0,0 +1,369 @@
BEGIN;
-- Client registration and controlled installation activation.
-- This migration is additive and preserves all existing tenant, license, and
-- installation identifiers. Plaintext license/activation keys must never be
-- stored in these tables.
CREATE TABLE IF NOT EXISTS identity.client_registration_details (
registration_id uuid PRIMARY KEY,
client_code character varying(40) NOT NULL,
legal_company_name character varying(240) NOT NULL,
trade_name character varying(240),
pan character varying(20),
cin character varying(30),
gst_number character varying(30),
billing_address_line1 character varying(300),
billing_address_line2 character varying(300),
billing_city character varying(120),
billing_state character varying(120),
billing_postal_code character varying(20),
billing_country character varying(2) NOT NULL DEFAULT 'IN',
billing_email character varying(254),
primary_contact_name character varying(160),
primary_contact_email character varying(254),
primary_contact_number character varying(30),
alternate_contact_name character varying(160),
alternate_contact_email character varying(254),
alternate_contact_number character varying(30),
contract_start_date date,
contract_end_date date,
status character varying(20) NOT NULL DEFAULT 'DRAFT',
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
created_by character varying(120) NOT NULL,
version integer NOT NULL DEFAULT 1,
CONSTRAINT ck_identity_registration_code
CHECK (client_code ~ '^[A-Z0-9]+(?:-[A-Z0-9]+)*$'),
CONSTRAINT ck_identity_registration_country
CHECK (billing_country ~ '^[A-Z]{2}$'),
CONSTRAINT ck_identity_registration_period
CHECK (contract_end_date IS NULL
OR contract_start_date IS NULL
OR contract_end_date >= contract_start_date),
CONSTRAINT ck_identity_registration_status
CHECK (status IN ('DRAFT', 'ACTIVE', 'SUSPENDED', 'TERMINATED')),
CONSTRAINT ck_identity_registration_version
CHECK (version > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_registration_client_code_ci
ON identity.client_registration_details (upper(client_code));
CREATE INDEX IF NOT EXISTS ix_identity_registration_status_contract
ON identity.client_registration_details (status, contract_end_date);
ALTER TABLE identity.client_account
ADD COLUMN IF NOT EXISTS registration_id uuid;
ALTER TABLE identity.client_account
ADD COLUMN IF NOT EXISTS security_version integer NOT NULL DEFAULT 1;
-- Stable bridge registration for the tenant introduced by migration 002.
INSERT INTO identity.client_registration_details (
registration_id, client_code, legal_company_name, trade_name,
status, contract_start_date, created_by)
VALUES ('00000000-0000-4000-8000-000000000010',
'MATRIX', 'Matrix', 'Matrix', 'ACTIVE', DATE '2020-01-01', 'migration-004')
ON CONFLICT (registration_id) DO UPDATE SET
legal_company_name = EXCLUDED.legal_company_name,
updated_at = now();
UPDATE identity.client_account
SET registration_id = '00000000-0000-4000-8000-000000000010'
WHERE registration_id IS NULL;
ALTER TABLE identity.client_account
ALTER COLUMN registration_id SET NOT NULL;
DO $migration$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_client_account_registration'
AND conrelid = 'identity.client_account'::regclass) THEN
ALTER TABLE identity.client_account
ADD CONSTRAINT fk_identity_client_account_registration
FOREIGN KEY (registration_id)
REFERENCES identity.client_registration_details (registration_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_identity_client_account_security_version'
AND conrelid = 'identity.client_account'::regclass) THEN
ALTER TABLE identity.client_account
ADD CONSTRAINT ck_identity_client_account_security_version
CHECK (security_version > 0) NOT VALID;
END IF;
END
$migration$;
ALTER TABLE identity.client_account
VALIDATE CONSTRAINT fk_identity_client_account_registration;
ALTER TABLE identity.client_account
VALIDATE CONSTRAINT ck_identity_client_account_security_version;
CREATE INDEX IF NOT EXISTS ix_identity_client_account_registration
ON identity.client_account (registration_id, status);
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_client_account_registration_tenant
ON identity.client_account (registration_id, tenant_id);
UPDATE identity.client_license
SET max_installations = 1
WHERE max_installations IS NULL;
ALTER TABLE identity.client_license
ALTER COLUMN max_installations SET DEFAULT 1;
ALTER TABLE identity.client_license
ALTER COLUMN max_installations SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_license_tenant_id
ON identity.client_license (tenant_id, license_id);
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS license_id uuid;
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS installation_uuid uuid;
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS installation_name character varying(160);
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS status character varying(24);
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS registered_at timestamp with time zone;
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS last_seen_at timestamp with time zone;
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS software_version character varying(40);
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS environment character varying(30);
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS retired_at timestamp with time zone;
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS retired_by character varying(120);
ALTER TABLE identity.client_installation
ADD COLUMN IF NOT EXISTS retirement_reason character varying(500);
UPDATE identity.client_installation installation
SET license_id = (
SELECT license.license_id
FROM identity.client_license license
WHERE license.tenant_id = installation.tenant_id
ORDER BY CASE license.status WHEN 'ACTIVE' THEN 0 ELSE 1 END,
license.valid_until DESC
LIMIT 1)
WHERE installation.license_id IS NULL;
DO $migration$
BEGIN
IF EXISTS (
SELECT 1
FROM identity.client_installation
WHERE license_id IS NULL) THEN
RAISE EXCEPTION
'Migration 004 cannot associate every existing installation with a license';
END IF;
END
$migration$;
UPDATE identity.client_installation
SET installation_uuid = installation_id
WHERE installation_uuid IS NULL;
UPDATE identity.client_installation
SET installation_name = installation_code
WHERE installation_name IS NULL;
UPDATE identity.client_installation
SET status = CASE WHEN enabled THEN 'ACTIVE' ELSE 'SUSPENDED' END
WHERE status IS NULL;
UPDATE identity.client_installation
SET registered_at = created_at
WHERE registered_at IS NULL;
ALTER TABLE identity.client_installation
ALTER COLUMN license_id SET NOT NULL;
ALTER TABLE identity.client_installation
ALTER COLUMN installation_uuid SET NOT NULL;
ALTER TABLE identity.client_installation
ALTER COLUMN installation_name SET NOT NULL;
ALTER TABLE identity.client_installation
ALTER COLUMN status SET NOT NULL;
ALTER TABLE identity.client_installation
ALTER COLUMN status SET DEFAULT 'PENDING';
ALTER TABLE identity.client_installation
ALTER COLUMN registered_at SET NOT NULL;
ALTER TABLE identity.client_installation
ALTER COLUMN registered_at SET DEFAULT now();
DO $migration$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_installation_tenant_license'
AND conrelid = 'identity.client_installation'::regclass) THEN
ALTER TABLE identity.client_installation
ADD CONSTRAINT fk_identity_installation_tenant_license
FOREIGN KEY (tenant_id, license_id)
REFERENCES identity.client_license (tenant_id, license_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_identity_installation_status'
AND conrelid = 'identity.client_installation'::regclass) THEN
ALTER TABLE identity.client_installation
ADD CONSTRAINT ck_identity_installation_status
CHECK (status IN (
'PENDING', 'ACTIVE', 'SUSPENDED', 'DECOMMISSIONED', 'REVOKED', 'FAILED'
)) NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_identity_installation_retirement'
AND conrelid = 'identity.client_installation'::regclass) THEN
ALTER TABLE identity.client_installation
ADD CONSTRAINT ck_identity_installation_retirement
CHECK (
(status NOT IN ('DECOMMISSIONED', 'REVOKED')
AND retired_at IS NULL)
OR
(status IN ('DECOMMISSIONED', 'REVOKED')
AND retired_at IS NOT NULL
AND retirement_reason IS NOT NULL)
) NOT VALID;
END IF;
END
$migration$;
ALTER TABLE identity.client_installation
VALIDATE CONSTRAINT fk_identity_installation_tenant_license;
ALTER TABLE identity.client_installation
VALIDATE CONSTRAINT ck_identity_installation_status;
ALTER TABLE identity.client_installation
VALIDATE CONSTRAINT ck_identity_installation_retirement;
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_installation_uuid
ON identity.client_installation (installation_uuid);
CREATE INDEX IF NOT EXISTS ix_identity_installation_capacity
ON identity.client_installation (tenant_id, license_id, status)
WHERE status IN ('PENDING', 'ACTIVE', 'SUSPENDED');
CREATE TABLE IF NOT EXISTS identity.license_activation_key (
activation_key_id uuid PRIMARY KEY,
registration_id uuid NOT NULL,
tenant_id uuid NOT NULL,
license_id uuid NOT NULL,
key_hash character varying(255) NOT NULL,
key_hint character varying(16) NOT NULL,
status character varying(20) NOT NULL DEFAULT 'ACTIVE',
expires_at timestamp with time zone,
failed_attempts integer NOT NULL DEFAULT 0,
maximum_attempts integer NOT NULL DEFAULT 10,
locked_until timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
last_used_at timestamp with time zone,
created_by character varying(120) NOT NULL,
revoked_at timestamp with time zone,
revoked_by character varying(120),
revocation_reason character varying(500),
CONSTRAINT fk_identity_activation_key_registration
FOREIGN KEY (registration_id)
REFERENCES identity.client_registration_details (registration_id),
CONSTRAINT fk_identity_activation_key_registration_tenant
FOREIGN KEY (registration_id, tenant_id)
REFERENCES identity.client_account (registration_id, tenant_id),
CONSTRAINT fk_identity_activation_key_tenant_license
FOREIGN KEY (tenant_id, license_id)
REFERENCES identity.client_license (tenant_id, license_id),
CONSTRAINT uq_identity_activation_key_hash UNIQUE (key_hash),
CONSTRAINT ck_identity_activation_key_status
CHECK (status IN ('ACTIVE', 'LOCKED', 'REVOKED', 'EXPIRED')),
CONSTRAINT ck_identity_activation_key_attempts
CHECK (failed_attempts >= 0
AND maximum_attempts > 0
AND failed_attempts <= maximum_attempts),
CONSTRAINT ck_identity_activation_key_revocation
CHECK ((status <> 'REVOKED' AND revoked_at IS NULL)
OR (status = 'REVOKED'
AND revoked_at IS NOT NULL
AND revocation_reason IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS ix_identity_activation_key_validation
ON identity.license_activation_key
(registration_id, tenant_id, license_id, status, expires_at);
CREATE TABLE IF NOT EXISTS identity.installation_activation_session (
activation_session_id uuid PRIMARY KEY,
activation_key_id uuid NOT NULL,
registration_id uuid NOT NULL,
tenant_id uuid NOT NULL,
license_id uuid NOT NULL,
installation_uuid uuid NOT NULL,
token_hash character varying(255) NOT NULL,
status character varying(20) NOT NULL DEFAULT 'PENDING',
expires_at timestamp with time zone NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now(),
consumed_at timestamp with time zone,
source_ip inet,
installer_version character varying(40),
CONSTRAINT fk_identity_activation_session_key
FOREIGN KEY (activation_key_id)
REFERENCES identity.license_activation_key (activation_key_id),
CONSTRAINT fk_identity_activation_session_registration
FOREIGN KEY (registration_id)
REFERENCES identity.client_registration_details (registration_id),
CONSTRAINT fk_identity_activation_session_registration_tenant
FOREIGN KEY (registration_id, tenant_id)
REFERENCES identity.client_account (registration_id, tenant_id),
CONSTRAINT fk_identity_activation_session_tenant_license
FOREIGN KEY (tenant_id, license_id)
REFERENCES identity.client_license (tenant_id, license_id),
CONSTRAINT uq_identity_activation_session_token UNIQUE (token_hash),
CONSTRAINT uq_identity_activation_session_uuid UNIQUE (installation_uuid),
CONSTRAINT ck_identity_activation_session_status
CHECK (status IN ('PENDING', 'CONSUMED', 'EXPIRED', 'REVOKED')),
CONSTRAINT ck_identity_activation_session_period
CHECK (expires_at > created_at),
CONSTRAINT ck_identity_activation_session_consumed
CHECK ((status <> 'CONSUMED' AND consumed_at IS NULL)
OR (status = 'CONSUMED' AND consumed_at IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS ix_identity_activation_session_pending
ON identity.installation_activation_session (tenant_id, expires_at)
WHERE status = 'PENDING';
CREATE TABLE IF NOT EXISTS identity.installation_audit_event (
audit_event_id uuid PRIMARY KEY,
registration_id uuid NOT NULL,
tenant_id uuid NOT NULL,
license_id uuid,
installation_id uuid,
event_type character varying(50) NOT NULL,
actor_type character varying(30) NOT NULL,
actor_id character varying(160) NOT NULL,
reason character varying(500),
event_data jsonb NOT NULL DEFAULT '{}'::jsonb,
occurred_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT fk_identity_installation_audit_registration
FOREIGN KEY (registration_id)
REFERENCES identity.client_registration_details (registration_id),
CONSTRAINT fk_identity_installation_audit_registration_tenant
FOREIGN KEY (registration_id, tenant_id)
REFERENCES identity.client_account (registration_id, tenant_id),
CONSTRAINT fk_identity_installation_audit_tenant_license
FOREIGN KEY (tenant_id, license_id)
REFERENCES identity.client_license (tenant_id, license_id),
CONSTRAINT fk_identity_installation_audit_installation
FOREIGN KEY (installation_id)
REFERENCES identity.client_installation (installation_id),
CONSTRAINT ck_identity_installation_audit_actor
CHECK (actor_type IN ('ADMIN', 'INSTALLER', 'SYSTEM', 'CLIENT'))
);
CREATE INDEX IF NOT EXISTS ix_identity_installation_audit_tenant_time
ON identity.installation_audit_event (tenant_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS ix_identity_installation_audit_installation_time
ON identity.installation_audit_event (installation_id, occurred_at DESC)
WHERE installation_id IS NOT NULL;
COMMIT;

View File

@@ -108,6 +108,8 @@ class MachineTokenFlowTest {
OffsetDateTime.now(ZoneOffset.UTC));
when(registrations.findCurrentLicense(installation.tenantId(), NOW))
.thenReturn(Mono.just(license));
when(registrations.touchInstallation(installation.installationId(), NOW))
.thenReturn(Mono.empty());
}
@Test

View File

@@ -0,0 +1,38 @@
package com.cygnus.cloud.tenant.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.cygnus.cloud.cache.ReactiveCacheService;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
class ActivationRateLimiterTest {
@Test
void permitsAttemptsWithinLimit() {
ReactiveCacheService cache = mock(ReactiveCacheService.class);
when(cache.increment(anyString(), anyString(), any(Duration.class)))
.thenReturn(Mono.just(10L));
ActivationRateLimiter limiter =
new ActivationRateLimiter(cache, 10, Duration.ofMinutes(10));
assertDoesNotThrow(() -> limiter.check("127.0.0.1", "ACME").block());
}
@Test
void blocksAttemptsBeyondLimit() {
ReactiveCacheService cache = mock(ReactiveCacheService.class);
when(cache.increment(anyString(), anyString(), any(Duration.class)))
.thenReturn(Mono.just(11L));
ActivationRateLimiter limiter =
new ActivationRateLimiter(cache, 10, Duration.ofMinutes(10));
assertThrows(
ActivationRateLimitException.class,
() -> limiter.check("127.0.0.1", "ACME").block());
}
}

View File

@@ -0,0 +1,154 @@
package com.cygnus.cloud.tenant.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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 com.cygnus.cloud.tenant.repository.LicenseActivationRepository;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
class LicenseKeyServiceTest {
private static final Instant NOW = Instant.parse("2026-07-26T10:00:00Z");
private LicenseActivationRepository repository;
private LicenseKeyService service;
private BCryptPasswordEncoder encoder;
@BeforeEach
void setUp() {
repository = mock(LicenseActivationRepository.class);
encoder = new BCryptPasswordEncoder(4);
service = new LicenseKeyService(
repository,
Clock.fixed(NOW, ZoneOffset.UTC),
encoder,
new SecureRandom(new byte[] {1, 2, 3, 4}));
}
@Test
void issuesReadableKeyButPersistsOnlyHash() {
when(repository.insert(
any(), any(), any(), anyString(), anyString(), any(), anyString()))
.thenReturn(Mono.just(true));
StepVerifier.create(service.issue(
UUID.randomUUID(),
UUID.randomUUID(),
OffsetDateTime.parse("2026-08-26T10:00:00Z"),
"admin"))
.assertNext(issued -> {
assertThat(issued.licenseKey())
.matches("CYGNUS-[A-F0-9]{8}(?:-[A-F0-9]{8}){4}");
assertThat(issued.keyHint()).hasSize(8);
})
.verifyComplete();
verify(repository).insert(
any(), any(), any(), anyString(), anyString(), any(), eq("admin"));
}
@Test
void validatesActiveKeyWhenInstallationCapacityExists() {
String plaintext = "CYGNUS-01234567-89ABCDEF-01234567-89ABCDEF-01234567";
LicenseActivationContext context = context(
plaintext, LicenseStatus.ACTIVE, 2, 1);
when(repository.findCandidates("MATRIX", "01234567"))
.thenReturn(Flux.just(context));
when(repository.recordSuccess(any(), any())).thenReturn(Mono.just(true));
StepVerifier.create(service.validate("matrix", plaintext))
.expectNext(context)
.verifyComplete();
}
@Test
void rejectsValidKeyWhenCapacityIsExhausted() {
String plaintext = "CYGNUS-01234567-89ABCDEF-01234567-89ABCDEF-01234567";
when(repository.findCandidates("MATRIX", "01234567"))
.thenReturn(Flux.just(context(
plaintext, LicenseStatus.ACTIVE, 2, 2)));
StepVerifier.create(service.validate("matrix", plaintext))
.expectErrorMatches(error -> error instanceof LicenseKeyException
&& error.getMessage().contains("installation limit"))
.verify();
}
@Test
void recordsRejectedAttemptWhenHintMatchesButHashDoesNot() {
String correct = "CYGNUS-AAAAAAAA-AAAAAAAA-AAAAAAAA-AAAAAAAA-01234567";
String wrong = "CYGNUS-BBBBBBBB-BBBBBBBB-BBBBBBBB-BBBBBBBB-01234567";
LicenseActivationContext context = context(
correct, LicenseStatus.ACTIVE, 2, 0);
when(repository.findCandidates("MATRIX", "01234567"))
.thenReturn(Flux.just(context));
when(repository.recordFailure(any(), any())).thenReturn(Mono.just(true));
StepVerifier.create(service.validate("MATRIX", wrong))
.expectError(LicenseKeyException.class)
.verify();
verify(repository).recordFailure(
eq(context.activationKey().activationKeyId()), any());
}
private LicenseActivationContext context(
String plaintext,
LicenseStatus licenseStatus,
int maximum,
int consuming) {
UUID registrationId = UUID.randomUUID();
UUID tenantId = UUID.randomUUID();
UUID licenseId = UUID.randomUUID();
LicenseActivationKey key = new LicenseActivationKey(
UUID.randomUUID(),
registrationId,
tenantId,
licenseId,
encoder.encode(plaintext),
"01234567",
ActivationKeyStatus.ACTIVE,
OffsetDateTime.parse("2026-08-26T10:00:00Z"),
0,
10,
null,
OffsetDateTime.parse("2026-07-01T10:00:00Z"),
null);
return new LicenseActivationContext(
key,
"MATRIX",
RegistrationStatus.ACTIVE,
ClientStatus.ACTIVE,
"matrix",
"Matrix",
"PROFESSIONAL",
"YEARLY",
licenseStatus,
NOW.minusSeconds(60),
NOW.plusSeconds(3600),
maximum,
consuming);
}
}

View File

@@ -1,6 +1,17 @@
spring:
application:
name: cygnus-cloud-service
mail:
host: ${CYGNUS_MAIL_HOST:}
port: ${CYGNUS_MAIL_PORT:587}
username: ${CYGNUS_MAIL_USERNAME:}
password: ${CYGNUS_MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: ${CYGNUS_MAIL_SMTP_AUTH:true}
starttls:
enable: ${CYGNUS_MAIL_STARTTLS:true}
data:
redis:
host: ${REDIS_HOST:192.168.0.111}
@@ -36,7 +47,6 @@ cygnus:
assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem}
access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem}
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
clients: {}
login-encryption:
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
@@ -44,6 +54,11 @@ cygnus:
cache:
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
default-ttl: ${CYGNUS_CACHE_TTL:10m}
registration-email:
from: ${CYGNUS_REGISTRATION_EMAIL_FROM:noreply@cygnus.invalid}
activation-rate-limit:
maximum-attempts: ${CYGNUS_ACTIVATION_MAX_ATTEMPTS:10}
window: ${CYGNUS_ACTIVATION_RATE_WINDOW:10m}
server:
port: ${CYGNUS_CLOUD_PORT:8090}

Some files were not shown because too many files have changed in this diff Show More