diff --git a/cygnus-cloud-client/target/cygnus-cloud-client-1.0.0-SNAPSHOT.jar b/cygnus-cloud-client/target/cygnus-cloud-client-1.0.0-SNAPSHOT.jar
index 78e52c2..93a6c8c 100644
Binary files a/cygnus-cloud-client/target/cygnus-cloud-client-1.0.0-SNAPSHOT.jar and b/cygnus-cloud-client/target/cygnus-cloud-client-1.0.0-SNAPSHOT.jar differ
diff --git a/cygnus-cloud-client/target/surefire-reports/TEST-com.cygnus.client.CloudClientPropertiesTest.xml b/cygnus-cloud-client/target/surefire-reports/TEST-com.cygnus.client.CloudClientPropertiesTest.xml
index 42cfb3d..9dc0f3b 100644
--- a/cygnus-cloud-client/target/surefire-reports/TEST-com.cygnus.client.CloudClientPropertiesTest.xml
+++ b/cygnus-cloud-client/target/surefire-reports/TEST-com.cygnus.client.CloudClientPropertiesTest.xml
@@ -12,10 +12,9 @@
-
+
-
@@ -30,7 +29,7 @@
-
+
@@ -59,6 +58,6 @@
-
+
\ No newline at end of file
diff --git a/cygnus-cloud-service/pom.xml b/cygnus-cloud-service/pom.xml
index 862adb2..8d7cace 100644
--- a/cygnus-cloud-service/pom.xml
+++ b/cygnus-cloud-service/pom.xml
@@ -41,6 +41,10 @@
org.springframework.boot
spring-boot-starter-validation
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
org.springframework.boot
spring-boot-starter-security
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/ReactiveCacheService.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/ReactiveCacheService.java
index 8a779bd..7ac67a0 100644
--- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/ReactiveCacheService.java
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/cache/ReactiveCacheService.java
@@ -38,6 +38,15 @@ public class ReactiveCacheService {
return redis.delete(cacheKey(namespace, key)).map(deleted -> deleted > 0);
}
+ public Mono increment(String namespace, String key, Duration ttl) {
+ String fullKey = cacheKey(namespace, key);
+ return redis.opsForValue()
+ .increment(fullKey)
+ .flatMap(count -> count == 1
+ ? redis.expire(fullKey, ttl).thenReturn(count)
+ : Mono.just(count));
+ }
+
private String cacheKey(String namespace, String key) {
return properties.keyPrefix() + ':' + namespace + ':' + key;
}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/database/ReactiveDatabaseClient.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/database/ReactiveDatabaseClient.java
index 48a6823..40e7a4e 100644
--- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/database/ReactiveDatabaseClient.java
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/database/ReactiveDatabaseClient.java
@@ -3,7 +3,9 @@ package com.cygnus.cloud.database;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.RowSet;
+import io.vertx.sqlclient.SqlConnection;
import io.vertx.sqlclient.Tuple;
+import java.util.function.Function;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@@ -29,4 +31,37 @@ public class ReactiveDatabaseClient {
return Mono.fromCompletionStage(
() -> pool.preparedQuery(sql).execute(parameters).toCompletionStage());
}
+
+ public Mono preparedUpdate(String sql, Tuple parameters) {
+ return preparedQuery(sql, parameters).map(RowSet::rowCount);
+ }
+
+ public Mono inTransaction(Function> 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> preparedQuery(
+ SqlConnection connection, String sql, Tuple parameters) {
+ return Mono.fromCompletionStage(
+ () -> connection.preparedQuery(sql)
+ .execute(parameters)
+ .toCompletionStage());
+ }
}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/ClientAssertionValidator.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/ClientAssertionValidator.java
index 5f0b9ff..89d2e66 100644
--- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/ClientAssertionValidator.java
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/ClientAssertionValidator.java
@@ -57,7 +57,11 @@ class ClientAssertionValidator {
.switchIfEmpty(Mono.error(new MachineAuthenticationException(
"Client license is not active")))
.map(license -> verify(
- clientId, signedJwt, claims, installation, license)))
+ clientId, signedJwt, claims, installation, license))
+ .flatMap(principal -> registrations
+ .touchInstallation(
+ installation.installationId(), clock.instant())
+ .thenReturn(principal)))
.onErrorMap(
exception -> !(exception instanceof MachineAuthenticationException),
exception -> new MachineAuthenticationException(
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CloudSecurityConfiguration.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CloudSecurityConfiguration.java
index 45d2fc0..382e350 100644
--- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CloudSecurityConfiguration.java
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CloudSecurityConfiguration.java
@@ -30,8 +30,14 @@ public class CloudSecurityConfiguration {
.authorizeExchange(exchange -> exchange
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
.pathMatchers("/oauth2/token").permitAll()
+ .pathMatchers(
+ "/api/v1/installations/activation/validate",
+ "/api/v1/installations/register")
+ .permitAll()
.pathMatchers("/api/v1/identity/login")
.hasAuthority("SCOPE_identity.login")
+ .pathMatchers("/api/v1/admin/**")
+ .hasAuthority("SCOPE_cygnus.admin")
.anyExchange().authenticated())
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
.build();
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/api/ActivationValidationRequest.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/api/ActivationValidationRequest.java
new file mode 100644
index 0000000..8b6735c
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/api/ActivationValidationRequest.java
@@ -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) {
+}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/api/ActivationValidationResponse.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/api/ActivationValidationResponse.java
new file mode 100644
index 0000000..b28d2fd
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/api/ActivationValidationResponse.java
@@ -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) {
+}
diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/api/ClientAdministrationController.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/api/ClientAdministrationController.java
new file mode 100644
index 0000000..2ffc977
--- /dev/null
+++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/api/ClientAdministrationController.java
@@ -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