Installation automation - License key approach
This commit is contained in:
Binary file not shown.
@@ -12,10 +12,9 @@
|
|||||||
<property name="sun.java.launcher" value="SUN_STANDARD"/>
|
<property name="sun.java.launcher" value="SUN_STANDARD"/>
|
||||||
<property name="user.country" value="US"/>
|
<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.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="http.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
||||||
<property name="jdk.debug" value="release"/>
|
<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="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="sun.cpu.endian" value="little"/>
|
||||||
<property name="user.home" value="/Users/maddy"/>
|
<property name="user.home" value="/Users/maddy"/>
|
||||||
@@ -30,7 +29,7 @@
|
|||||||
<property name="java.vm.specification.vendor" value="Oracle Corporation"/>
|
<property name="java.vm.specification.vendor" value="Oracle Corporation"/>
|
||||||
<property name="java.specification.name" value="Java Platform API Specification"/>
|
<property name="java.specification.name" value="Java Platform API Specification"/>
|
||||||
<property name="apple.awt.application.name" value="ForkedBooter"/>
|
<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="sun.management.compiler" value="HotSpot 64-Bit Tiered Compilers"/>
|
||||||
<property name="ftp.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
<property name="ftp.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
||||||
<property name="java.runtime.version" value="21.0.8+9-LTS"/>
|
<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="socksNonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
||||||
<property name="java.class.version" value="65.0"/>
|
<property name="java.class.version" value="65.0"/>
|
||||||
</properties>
|
</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"/>
|
<testcase name="configurationRequiresMachineCredentials" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.001"/>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
@@ -41,6 +41,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ public class ReactiveCacheService {
|
|||||||
return redis.delete(cacheKey(namespace, key)).map(deleted -> deleted > 0);
|
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) {
|
private String cacheKey(String namespace, String key) {
|
||||||
return properties.keyPrefix() + ':' + namespace + ':' + key;
|
return properties.keyPrefix() + ':' + namespace + ':' + key;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package com.cygnus.cloud.database;
|
|||||||
import io.vertx.sqlclient.Pool;
|
import io.vertx.sqlclient.Pool;
|
||||||
import io.vertx.sqlclient.Row;
|
import io.vertx.sqlclient.Row;
|
||||||
import io.vertx.sqlclient.RowSet;
|
import io.vertx.sqlclient.RowSet;
|
||||||
|
import io.vertx.sqlclient.SqlConnection;
|
||||||
import io.vertx.sqlclient.Tuple;
|
import io.vertx.sqlclient.Tuple;
|
||||||
|
import java.util.function.Function;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -29,4 +31,37 @@ public class ReactiveDatabaseClient {
|
|||||||
return Mono.fromCompletionStage(
|
return Mono.fromCompletionStage(
|
||||||
() -> pool.preparedQuery(sql).execute(parameters).toCompletionStage());
|
() -> 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(
|
.switchIfEmpty(Mono.error(new MachineAuthenticationException(
|
||||||
"Client license is not active")))
|
"Client license is not active")))
|
||||||
.map(license -> verify(
|
.map(license -> verify(
|
||||||
clientId, signedJwt, claims, installation, license)))
|
clientId, signedJwt, claims, installation, license))
|
||||||
|
.flatMap(principal -> registrations
|
||||||
|
.touchInstallation(
|
||||||
|
installation.installationId(), clock.instant())
|
||||||
|
.thenReturn(principal)))
|
||||||
.onErrorMap(
|
.onErrorMap(
|
||||||
exception -> !(exception instanceof MachineAuthenticationException),
|
exception -> !(exception instanceof MachineAuthenticationException),
|
||||||
exception -> new MachineAuthenticationException(
|
exception -> new MachineAuthenticationException(
|
||||||
|
|||||||
@@ -30,8 +30,14 @@ public class CloudSecurityConfiguration {
|
|||||||
.authorizeExchange(exchange -> exchange
|
.authorizeExchange(exchange -> exchange
|
||||||
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
|
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||||
.pathMatchers("/oauth2/token").permitAll()
|
.pathMatchers("/oauth2/token").permitAll()
|
||||||
|
.pathMatchers(
|
||||||
|
"/api/v1/installations/activation/validate",
|
||||||
|
"/api/v1/installations/register")
|
||||||
|
.permitAll()
|
||||||
.pathMatchers("/api/v1/identity/login")
|
.pathMatchers("/api/v1/identity/login")
|
||||||
.hasAuthority("SCOPE_identity.login")
|
.hasAuthority("SCOPE_identity.login")
|
||||||
|
.pathMatchers("/api/v1/admin/**")
|
||||||
|
.hasAuthority("SCOPE_cygnus.admin")
|
||||||
.anyExchange().authenticated())
|
.anyExchange().authenticated())
|
||||||
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
|
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
|
||||||
.build();
|
.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
|
WHERE installation.client_id = $1
|
||||||
AND installation.installation_code = $2
|
AND installation.installation_code = $2
|
||||||
AND installation.enabled = true
|
AND installation.enabled = true
|
||||||
|
AND installation.status = 'ACTIVE'
|
||||||
AND account.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 = """
|
private static final String FIND_CURRENT_LICENSE = """
|
||||||
SELECT license_id, tenant_id, license_type, package_code,
|
SELECT license_id, tenant_id, license_type, package_code,
|
||||||
valid_from, valid_until, status, max_users, max_installations,
|
valid_from, valid_until, status, max_users, max_installations,
|
||||||
@@ -82,6 +91,15 @@ public class TenantRegistrationRepository {
|
|||||||
.flatMap(rows -> first(rows, this::license));
|
.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(
|
private <T> Mono<T> first(
|
||||||
Iterable<Row> rows, java.util.function.Function<Row, T> mapper) {
|
Iterable<Row> rows, java.util.function.Function<Row, T> mapper) {
|
||||||
java.util.Iterator<Row> iterator = rows.iterator();
|
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);
|
.onErrorReturn(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Mono<Void> touchInstallation(UUID installationId, Instant instant) {
|
||||||
|
return repository.touchInstallation(installationId, instant)
|
||||||
|
.onErrorResume(exception -> Mono.empty());
|
||||||
|
}
|
||||||
|
|
||||||
private Duration licenseTtl(ClientLicense license) {
|
private Duration licenseTtl(ClientLicense license) {
|
||||||
Duration remaining = Duration.between(clock.instant(), license.validUntil());
|
Duration remaining = Duration.between(clock.instant(), license.validUntil());
|
||||||
if (remaining.isNegative() || remaining.isZero()) {
|
if (remaining.isNegative() || remaining.isZero()) {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: cygnus-cloud-service
|
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:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST:192.168.0.111}
|
host: ${REDIS_HOST:192.168.0.111}
|
||||||
@@ -43,6 +54,11 @@ cygnus:
|
|||||||
cache:
|
cache:
|
||||||
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
|
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
|
||||||
default-ttl: ${CYGNUS_CACHE_TTL:10m}
|
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:
|
server:
|
||||||
port: ${CYGNUS_CLOUD_PORT:8090}
|
port: ${CYGNUS_CLOUD_PORT:8090}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -108,6 +108,8 @@ class MachineTokenFlowTest {
|
|||||||
OffsetDateTime.now(ZoneOffset.UTC));
|
OffsetDateTime.now(ZoneOffset.UTC));
|
||||||
when(registrations.findCurrentLicense(installation.tenantId(), NOW))
|
when(registrations.findCurrentLicense(installation.tenantId(), NOW))
|
||||||
.thenReturn(Mono.just(license));
|
.thenReturn(Mono.just(license));
|
||||||
|
when(registrations.touchInstallation(installation.installationId(), NOW))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: cygnus-cloud-service
|
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:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST:192.168.0.111}
|
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}
|
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-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}
|
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
|
||||||
clients: {}
|
|
||||||
login-encryption:
|
login-encryption:
|
||||||
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
|
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
|
||||||
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
|
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
|
||||||
@@ -44,6 +54,11 @@ cygnus:
|
|||||||
cache:
|
cache:
|
||||||
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
|
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
|
||||||
default-ttl: ${CYGNUS_CACHE_TTL:10m}
|
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:
|
server:
|
||||||
port: ${CYGNUS_CLOUD_PORT:8090}
|
port: ${CYGNUS_CLOUD_PORT:8090}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user