Installation automation - License key approach

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

View File

@@ -38,6 +38,15 @@ public class ReactiveCacheService {
return redis.delete(cacheKey(namespace, key)).map(deleted -> deleted > 0);
}
public Mono<Long> increment(String namespace, String key, Duration ttl) {
String fullKey = cacheKey(namespace, key);
return redis.opsForValue()
.increment(fullKey)
.flatMap(count -> count == 1
? redis.expire(fullKey, ttl).thenReturn(count)
: Mono.just(count));
}
private String cacheKey(String namespace, String key) {
return properties.keyPrefix() + ':' + namespace + ':' + key;
}

View File

@@ -3,7 +3,9 @@ package com.cygnus.cloud.database;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.RowSet;
import io.vertx.sqlclient.SqlConnection;
import io.vertx.sqlclient.Tuple;
import java.util.function.Function;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@@ -29,4 +31,37 @@ public class ReactiveDatabaseClient {
return Mono.fromCompletionStage(
() -> pool.preparedQuery(sql).execute(parameters).toCompletionStage());
}
public Mono<Integer> preparedUpdate(String sql, Tuple parameters) {
return preparedQuery(sql, parameters).map(RowSet::rowCount);
}
public <T> Mono<T> inTransaction(Function<SqlConnection, Mono<T>> work) {
return Mono.usingWhen(
Mono.fromCompletionStage(() -> pool.getConnection().toCompletionStage()),
connection -> Mono.fromCompletionStage(
() -> connection.begin().toCompletionStage())
.flatMap(transaction -> work.apply(connection)
.flatMap(result -> Mono.fromCompletionStage(
() -> transaction.commit().toCompletionStage())
.thenReturn(result))
.onErrorResume(error -> Mono.fromCompletionStage(
() -> transaction.rollback().toCompletionStage())
.onErrorResume(ignored -> Mono.empty())
.then(Mono.error(error)))),
connection -> Mono.fromCompletionStage(
() -> connection.close().toCompletionStage()),
(connection, error) -> Mono.fromCompletionStage(
() -> connection.close().toCompletionStage()),
connection -> Mono.fromCompletionStage(
() -> connection.close().toCompletionStage()));
}
public Mono<RowSet<Row>> preparedQuery(
SqlConnection connection, String sql, Tuple parameters) {
return Mono.fromCompletionStage(
() -> connection.preparedQuery(sql)
.execute(parameters)
.toCompletionStage());
}
}

View File

@@ -57,7 +57,11 @@ class ClientAssertionValidator {
.switchIfEmpty(Mono.error(new MachineAuthenticationException(
"Client license is not active")))
.map(license -> verify(
clientId, signedJwt, claims, installation, license)))
clientId, signedJwt, claims, installation, license))
.flatMap(principal -> registrations
.touchInstallation(
installation.installationId(), clock.instant())
.thenReturn(principal)))
.onErrorMap(
exception -> !(exception instanceof MachineAuthenticationException),
exception -> new MachineAuthenticationException(

View File

@@ -30,8 +30,14 @@ public class CloudSecurityConfiguration {
.authorizeExchange(exchange -> exchange
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
.pathMatchers("/oauth2/token").permitAll()
.pathMatchers(
"/api/v1/installations/activation/validate",
"/api/v1/installations/register")
.permitAll()
.pathMatchers("/api/v1/identity/login")
.hasAuthority("SCOPE_identity.login")
.pathMatchers("/api/v1/admin/**")
.hasAuthority("SCOPE_cygnus.admin")
.anyExchange().authenticated())
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
.build();

View File

@@ -0,0 +1,16 @@
package com.cygnus.cloud.tenant.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.UUID;
public record ActivationValidationRequest(
@NotBlank @Size(max = 40) String clientCode,
@NotBlank @Size(max = 80) String licenseKey,
@NotNull UUID installationUuid,
@NotBlank @Size(max = 40)
@Pattern(regexp = "^[A-Za-z0-9._-]+$")
String installerVersion) {
}

View File

@@ -0,0 +1,13 @@
package com.cygnus.cloud.tenant.api;
import java.time.OffsetDateTime;
import java.util.UUID;
public record ActivationValidationResponse(
String activationToken,
OffsetDateTime expiresAt,
UUID tenantId,
String tenantSlug,
String packageCode,
int maximumInstallations) {
}

View File

@@ -0,0 +1,174 @@
package com.cygnus.cloud.tenant.api;
import com.cygnus.cloud.tenant.repository.ClientAdministrationRepository;
import com.cygnus.cloud.tenant.service.LicenseKeyService;
import com.cygnus.cloud.tenant.service.InstallationLifecycleService;
import com.cygnus.cloud.tenant.service.RegistrationEmailService;
import io.vertx.sqlclient.Tuple;
import jakarta.validation.Valid;
import java.util.Map;
import java.util.UUID;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/v1/admin")
public class ClientAdministrationController {
private final ClientAdministrationRepository repository;
private final LicenseKeyService licenseKeyService;
private final RegistrationEmailService emailService;
private final InstallationLifecycleService installationLifecycleService;
public ClientAdministrationController(
ClientAdministrationRepository repository,
LicenseKeyService licenseKeyService,
RegistrationEmailService emailService,
InstallationLifecycleService installationLifecycleService) {
this.repository = repository;
this.licenseKeyService = licenseKeyService;
this.emailService = emailService;
this.installationLifecycleService = installationLifecycleService;
}
@PostMapping("/tenants/{tenantId}/installations/{installationId}/decommission")
public Mono<?> decommission(
@PathVariable UUID tenantId,
@PathVariable UUID installationId,
@Valid @RequestBody RetireInstallationRequest request,
Authentication authentication) {
return installationLifecycleService.retire(
tenantId,
installationId,
"DECOMMISSIONED",
actor(authentication),
request.reason());
}
@PostMapping("/tenants/{tenantId}/installations/{installationId}/revoke")
public Mono<?> revoke(
@PathVariable UUID tenantId,
@PathVariable UUID installationId,
@Valid @RequestBody RetireInstallationRequest request,
Authentication authentication) {
return installationLifecycleService.retire(
tenantId,
installationId,
"REVOKED",
actor(authentication),
request.reason());
}
@PostMapping("/client-registrations")
public Mono<Map<String, UUID>> registration(
@Valid @RequestBody CreateClientRegistrationRequest request,
Authentication authentication) {
UUID id = UUID.randomUUID();
Tuple values = Tuple.tuple()
.addUUID(id)
.addString(request.clientCode())
.addString(request.legalCompanyName())
.addString(request.tradeName())
.addString(request.pan())
.addString(request.cin())
.addString(request.gstNumber())
.addString(request.billingAddressLine1())
.addString(request.billingAddressLine2())
.addString(request.billingCity())
.addString(request.billingState())
.addString(request.billingPostalCode())
.addString(request.billingCountry())
.addString(request.billingEmail())
.addString(request.primaryContactName())
.addString(request.primaryContactEmail())
.addString(request.primaryContactNumber())
.addString(request.alternateContactName())
.addString(request.alternateContactEmail())
.addString(request.alternateContactNumber())
.addLocalDate(request.contractStartDate())
.addLocalDate(request.contractEndDate())
.addString("ACTIVE")
.addString(actor(authentication));
return repository.createRegistration(values)
.flatMap(created -> created
? Mono.just(Map.of("registrationId", id))
: Mono.error(new IllegalStateException(
"Registration was not created")));
}
@PostMapping("/client-registrations/{registrationId}/tenants")
public Mono<Map<String, UUID>> tenant(
@PathVariable UUID registrationId,
@Valid @RequestBody CreateTenantRequest request) {
UUID id = UUID.randomUUID();
return repository.createTenant(
id, registrationId, request.clientSlug(), request.clientName())
.flatMap(created -> created
? Mono.just(Map.of("tenantId", id))
: Mono.error(new IllegalStateException("Tenant was not created")));
}
@PostMapping("/tenants/{tenantId}/licenses")
public Mono<Map<String, UUID>> license(
@PathVariable UUID tenantId,
@Valid @RequestBody CreateLicenseRequest request) {
if (!request.validUntil().isAfter(request.validFrom())) {
return Mono.error(new IllegalArgumentException(
"License end must be after start"));
}
UUID id = UUID.randomUUID();
return repository.createLicense(
id,
tenantId,
request.licenseType(),
request.packageCode(),
request.validFrom(),
request.validUntil(),
request.maximumUsers(),
request.maximumInstallations())
.flatMap(created -> created
? Mono.just(Map.of("licenseId", id))
: Mono.error(new IllegalStateException("License was not created")));
}
@PostMapping("/tenants/{tenantId}/licenses/{licenseId}/activation-key")
public Mono<Map<String, String>> activationKey(
@PathVariable UUID tenantId,
@PathVariable UUID licenseId,
@Valid @RequestBody IssueLicenseKeyRequest request,
Authentication authentication) {
return licenseKeyService.issue(
tenantId, licenseId, request.expiresAt(), actor(authentication))
.flatMap(issued -> repository
.findLicenseDeliveryDetails(tenantId, licenseId)
.switchIfEmpty(Mono.error(
new IllegalArgumentException("Tenant or license not found")))
.flatMap(details -> emailService.sendLicense(
request.recipientEmail(),
details.clientCode(),
details.tenantSlug(),
details.packageCode(),
details.maximumInstallations(),
request.expiresAt(),
issued)
.onErrorResume(error -> licenseKeyService
.revoke(issued.activationKeyId())
.then(Mono.error(error)))
.thenReturn(Map.of(
"activationKeyId",
issued.activationKeyId().toString(),
"keyHint",
issued.keyHint(),
"delivery",
"EMAIL_SENT"))));
}
private String actor(Authentication authentication) {
return authentication == null ? "system" : authentication.getName();
}
}

View File

@@ -0,0 +1,33 @@
package com.cygnus.cloud.tenant.api;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
public record CreateClientRegistrationRequest(
@NotBlank @Pattern(regexp = "^[A-Z0-9]+(?:-[A-Z0-9]+)*$")
@Size(max = 40) String clientCode,
@NotBlank @Size(max = 240) String legalCompanyName,
@Size(max = 240) String tradeName,
@Size(max = 20) String pan,
@Size(max = 30) String cin,
@Size(max = 30) String gstNumber,
@Size(max = 300) String billingAddressLine1,
@Size(max = 300) String billingAddressLine2,
@Size(max = 120) String billingCity,
@Size(max = 120) String billingState,
@Size(max = 20) String billingPostalCode,
@NotBlank @Pattern(regexp = "^[A-Z]{2}$") String billingCountry,
@Email @Size(max = 254) String billingEmail,
@Size(max = 160) String primaryContactName,
@Email @Size(max = 254) String primaryContactEmail,
@Size(max = 30) String primaryContactNumber,
@Size(max = 160) String alternateContactName,
@Email @Size(max = 254) String alternateContactEmail,
@Size(max = 30) String alternateContactNumber,
@NotNull LocalDate contractStartDate,
LocalDate contractEndDate) {
}

View File

@@ -0,0 +1,17 @@
package com.cygnus.cloud.tenant.api;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.OffsetDateTime;
public record CreateLicenseRequest(
@NotBlank @Size(max = 30) String licenseType,
@NotBlank @Size(max = 50) String packageCode,
@NotNull OffsetDateTime validFrom,
@NotNull OffsetDateTime validUntil,
@Min(1) Integer maximumUsers,
@Min(1) @Max(100) int maximumInstallations) {
}

View File

@@ -0,0 +1,11 @@
package com.cygnus.cloud.tenant.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record CreateTenantRequest(
@NotBlank @Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$")
@Size(max = 80) String clientSlug,
@NotBlank @Size(max = 200) String clientName) {
}

View File

@@ -0,0 +1,66 @@
package com.cygnus.cloud.tenant.api;
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
import com.cygnus.cloud.tenant.service.InstallationActivationService;
import com.cygnus.cloud.tenant.service.ActivationRateLimiter;
import jakarta.validation.Valid;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/v1/installations")
public class InstallationActivationController {
private final InstallationActivationService activationService;
private final ActivationRateLimiter rateLimiter;
public InstallationActivationController(
InstallationActivationService activationService,
ActivationRateLimiter rateLimiter) {
this.activationService = activationService;
this.rateLimiter = rateLimiter;
}
@PostMapping("/activation/validate")
public Mono<ActivationValidationResponse> validate(
@Valid @RequestBody ActivationValidationRequest request,
ServerHttpRequest serverRequest) {
String sourceIp = remoteAddress(serverRequest);
return rateLimiter.check(sourceIp, request.clientCode())
.then(activationService.validateAndCreateSession(
request.clientCode(),
request.licenseKey(),
request.installationUuid(),
sourceIp,
request.installerVersion()))
.map(session -> new ActivationValidationResponse(
session.token(),
session.expiresAt(),
session.tenantId(),
session.clientSlug(),
session.packageCode(),
session.maxInstallations()));
}
@PostMapping("/register")
public Mono<RegisteredInstallation> register(
@Valid @RequestBody InstallationRegistrationRequest request) {
return activationService.register(
request.activationToken(),
request.installationCode(),
request.installationName(),
request.assertionPublicKey(),
request.softwareVersion(),
request.environment());
}
private String remoteAddress(ServerHttpRequest request) {
return request.getRemoteAddress() == null
? null
: request.getRemoteAddress().getAddress().getHostAddress();
}
}

View File

@@ -0,0 +1,30 @@
package com.cygnus.cloud.tenant.api;
import com.cygnus.cloud.tenant.service.InstallationActivationException;
import com.cygnus.cloud.tenant.service.LicenseKeyException;
import com.cygnus.cloud.tenant.service.ActivationRateLimitException;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class InstallationActivationErrorHandler {
@ExceptionHandler({LicenseKeyException.class, InstallationActivationException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
Map<String, String> activationFailure() {
return Map.of(
"code", "INSTALLATION_ACTIVATION_FAILED",
"message", "Installation activation could not be completed");
}
@ExceptionHandler(ActivationRateLimitException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
Map<String, String> rateLimited() {
return Map.of(
"code", "INSTALLATION_ACTIVATION_RATE_LIMITED",
"message", "Too many activation attempts; retry later");
}
}

View File

@@ -0,0 +1,20 @@
package com.cygnus.cloud.tenant.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record InstallationRegistrationRequest(
@NotBlank @Size(max = 100) String activationToken,
@NotBlank @Size(max = 100)
@Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9_-]*$")
String installationCode,
@NotBlank @Size(max = 160) String installationName,
@NotBlank @Size(max = 8192) String assertionPublicKey,
@NotBlank @Size(max = 40)
@Pattern(regexp = "^[A-Za-z0-9._-]+$")
String softwareVersion,
@NotBlank @Size(max = 30)
@Pattern(regexp = "^[A-Za-z0-9_-]+$")
String environment) {
}

View File

@@ -0,0 +1,11 @@
package com.cygnus.cloud.tenant.api;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.OffsetDateTime;
public record IssueLicenseKeyRequest(
@NotNull OffsetDateTime expiresAt,
@NotBlank @Email String recipientEmail) {
}

View File

@@ -0,0 +1,7 @@
package com.cygnus.cloud.tenant.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RetireInstallationRequest(
@NotBlank @Size(max = 500) String reason) {}

View File

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

View File

@@ -0,0 +1,18 @@
package com.cygnus.cloud.tenant.model;
import java.time.OffsetDateTime;
import java.util.UUID;
public record ActivationSession(
UUID activationSessionId,
UUID registrationId,
UUID tenantId,
UUID licenseId,
UUID installationUuid,
String token,
OffsetDateTime expiresAt,
String clientCode,
String clientSlug,
String packageCode,
int maxInstallations) {
}

View File

@@ -0,0 +1,19 @@
package com.cygnus.cloud.tenant.model;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
public record ClientRegistration(
UUID registrationId,
String clientCode,
String legalCompanyName,
String tradeName,
String billingEmail,
LocalDate contractStartDate,
LocalDate contractEndDate,
RegistrationStatus status,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
int version) {
}

View File

@@ -0,0 +1,10 @@
package com.cygnus.cloud.tenant.model;
import java.util.UUID;
public record InstallationLifecycleResult(
UUID installationId,
UUID tenantId,
String clientId,
String installationCode,
String status) {}

View File

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

View File

@@ -0,0 +1,24 @@
package com.cygnus.cloud.tenant.model;
import java.time.Instant;
import java.util.UUID;
public record LicenseActivationContext(
LicenseActivationKey activationKey,
String clientCode,
RegistrationStatus registrationStatus,
ClientStatus tenantStatus,
String clientSlug,
String clientName,
String packageCode,
String licenseType,
LicenseStatus licenseStatus,
Instant validFrom,
Instant validUntil,
int maxInstallations,
int consumingInstallations) {
public boolean hasCapacity() {
return consumingInstallations < maxInstallations;
}
}

View File

@@ -0,0 +1,32 @@
package com.cygnus.cloud.tenant.model;
import java.time.OffsetDateTime;
import java.util.UUID;
public record LicenseActivationKey(
UUID activationKeyId,
UUID registrationId,
UUID tenantId,
UUID licenseId,
String keyHash,
String keyHint,
ActivationKeyStatus status,
OffsetDateTime expiresAt,
int failedAttempts,
int maximumAttempts,
OffsetDateTime lockedUntil,
OffsetDateTime createdAt,
OffsetDateTime lastUsedAt) {
public boolean canAttemptAt(OffsetDateTime now) {
if (expiresAt != null && !expiresAt.isAfter(now)) {
return false;
}
if (status == ActivationKeyStatus.ACTIVE) {
return failedAttempts < maximumAttempts;
}
return status == ActivationKeyStatus.LOCKED
&& lockedUntil != null
&& !lockedUntil.isAfter(now);
}
}

View File

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

View File

@@ -0,0 +1,12 @@
package com.cygnus.cloud.tenant.model;
import java.util.UUID;
public record RegisteredInstallation(
UUID installationId,
UUID installationUuid,
String clientId,
String installationCode,
int securityVersion,
String status) {
}

View File

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

View File

@@ -0,0 +1,124 @@
package com.cygnus.cloud.tenant.repository;
import com.cygnus.cloud.database.ReactiveDatabaseClient;
import com.cygnus.cloud.tenant.model.LicenseDeliveryDetails;
import io.vertx.sqlclient.Tuple;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
@Repository
public class ClientAdministrationRepository {
private static final String INSERT_REGISTRATION = """
INSERT INTO identity.client_registration_details (
registration_id, client_code, legal_company_name, trade_name,
pan, cin, gst_number, billing_address_line1,
billing_address_line2, billing_city, billing_state,
billing_postal_code, billing_country, billing_email,
primary_contact_name, primary_contact_email,
primary_contact_number, alternate_contact_name,
alternate_contact_email, alternate_contact_number,
contract_start_date, contract_end_date, status, created_by)
VALUES ($1, upper($2), $3, $4, $5, $6, $7, $8, $9, $10, $11,
$12, upper($13), $14, $15, $16, $17, $18, $19, $20,
$21, $22, $23, $24)
""";
private static final String INSERT_TENANT = """
INSERT INTO identity.client_account (
tenant_id, registration_id, client_slug, client_name,
status, security_version)
VALUES ($1, $2, $3, $4, 'ACTIVE', 1)
""";
private static final String INSERT_LICENSE = """
INSERT INTO identity.client_license (
license_id, tenant_id, license_type, package_code,
valid_from, valid_until, status, max_users,
max_installations)
SELECT $1, account.tenant_id, $3, $4, $5, $6, 'ACTIVE', $7, $8
FROM identity.client_account account
WHERE account.tenant_id = $2
AND account.status = 'ACTIVE'
""";
private static final String FIND_LICENSE_DELIVERY_DETAILS = """
SELECT registration.client_code,
account.client_slug,
license.package_code,
license.max_installations
FROM identity.client_license license
JOIN identity.client_account account
ON account.tenant_id = license.tenant_id
JOIN identity.client_registration_details registration
ON registration.registration_id = account.registration_id
WHERE license.tenant_id = $1
AND license.license_id = $2
""";
private final ReactiveDatabaseClient database;
public ClientAdministrationRepository(ReactiveDatabaseClient database) {
this.database = database;
}
public Mono<Boolean> createRegistration(Tuple values) {
return database.preparedUpdate(INSERT_REGISTRATION, values)
.map(count -> count == 1);
}
public Mono<Boolean> createTenant(
UUID tenantId,
UUID registrationId,
String slug,
String name) {
return database.preparedUpdate(
INSERT_TENANT,
Tuple.of(tenantId, registrationId, slug, name))
.map(count -> count == 1);
}
public Mono<Boolean> createLicense(
UUID licenseId,
UUID tenantId,
String licenseType,
String packageCode,
OffsetDateTime validFrom,
OffsetDateTime validUntil,
Integer maxUsers,
int maxInstallations) {
return database.preparedUpdate(
INSERT_LICENSE,
Tuple.of(
licenseId,
tenantId,
licenseType,
packageCode,
validFrom,
validUntil,
maxUsers,
maxInstallations))
.map(count -> count == 1);
}
public Mono<LicenseDeliveryDetails> findLicenseDeliveryDetails(
UUID tenantId, UUID licenseId) {
return database.preparedQuery(
FIND_LICENSE_DELIVERY_DETAILS,
Tuple.of(tenantId, licenseId))
.flatMap(rows -> {
var iterator = rows.iterator();
if (!iterator.hasNext()) {
return Mono.empty();
}
var row = iterator.next();
return Mono.just(new LicenseDeliveryDetails(
row.getString("client_code"),
row.getString("client_slug"),
row.getString("package_code"),
row.getInteger("max_installations")));
});
}
}

View File

@@ -0,0 +1,273 @@
package com.cygnus.cloud.tenant.repository;
import com.cygnus.cloud.database.ReactiveDatabaseClient;
import com.cygnus.cloud.tenant.model.ActivationSession;
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.SqlConnection;
import io.vertx.sqlclient.Tuple;
import java.time.OffsetDateTime;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
@Repository
public class InstallationActivationRepository {
private static final String INSERT_SESSION = """
INSERT INTO identity.installation_activation_session (
activation_session_id, activation_key_id, registration_id,
tenant_id, license_id, installation_uuid, token_hash,
status, expires_at, source_ip, installer_version)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'PENDING', $8, $9::inet, $10)
""";
private static final String LOCK_SESSION_AND_LICENSE = """
SELECT session.activation_session_id, session.registration_id,
session.tenant_id, session.license_id,
session.installation_uuid, session.status AS session_status,
session.expires_at,
registration.client_code, registration.status AS registration_status,
account.client_slug, account.status AS tenant_status,
license.package_code, license.status AS license_status,
license.valid_from, license.valid_until,
license.max_installations,
(
SELECT count(*)::integer
FROM identity.client_installation installation
WHERE installation.tenant_id = session.tenant_id
AND installation.license_id = session.license_id
AND installation.status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
) AS consuming_installations
FROM identity.installation_activation_session session
JOIN identity.client_registration_details registration
ON registration.registration_id = session.registration_id
JOIN identity.client_account account
ON account.registration_id = session.registration_id
AND account.tenant_id = session.tenant_id
JOIN identity.client_license license
ON license.tenant_id = session.tenant_id
AND license.license_id = session.license_id
WHERE session.token_hash = $1
FOR UPDATE OF session, license
""";
private static final String INSERT_INSTALLATION = """
INSERT INTO identity.client_installation (
installation_id, tenant_id, client_id, installation_code,
assertion_public_key, allowed_scopes, enabled,
security_version, license_id, installation_uuid,
installation_name, status, registered_at,
software_version, environment)
VALUES ($1, $2, $3, $4, $5, $6, true, 1, $7, $8, $9,
'ACTIVE', $10, $11, $12)
""";
private static final String CONSUME_SESSION = """
UPDATE identity.installation_activation_session
SET status = 'CONSUMED', consumed_at = $2
WHERE activation_session_id = $1
AND status = 'PENDING'
""";
private static final String INSERT_AUDIT = """
INSERT INTO identity.installation_audit_event (
audit_event_id, registration_id, tenant_id, license_id,
installation_id, event_type, actor_type, actor_id,
reason, event_data, occurred_at)
VALUES ($1, $2, $3, $4, $5, 'INSTALLATION_REGISTERED',
'INSTALLER', $6, 'License-authorized installation',
$7::jsonb, $8)
""";
private final ReactiveDatabaseClient database;
public InstallationActivationRepository(ReactiveDatabaseClient database) {
this.database = database;
}
public Mono<Boolean> insertSession(
UUID sessionId,
UUID activationKeyId,
UUID registrationId,
UUID tenantId,
UUID licenseId,
UUID installationUuid,
String tokenHash,
OffsetDateTime expiresAt,
String sourceIp,
String installerVersion) {
Tuple values = Tuple.tuple()
.addUUID(sessionId)
.addUUID(activationKeyId)
.addUUID(registrationId)
.addUUID(tenantId)
.addUUID(licenseId)
.addUUID(installationUuid)
.addString(tokenHash)
.addOffsetDateTime(expiresAt)
.addString(sourceIp)
.addString(installerVersion);
return database.preparedUpdate(INSERT_SESSION, values).map(count -> count == 1);
}
public Mono<RegisteredInstallation> register(
String tokenHash,
String installationCode,
String installationName,
String assertionPublicKey,
Set<String> scopes,
String softwareVersion,
String environment,
OffsetDateTime now) {
return database.inTransaction(connection -> lockContext(connection, tokenHash)
.switchIfEmpty(Mono.error(new IllegalArgumentException(
"Activation session is invalid")))
.flatMap(context -> validate(context, now))
.flatMap(context -> insertInstallation(
connection,
context,
installationCode,
installationName,
assertionPublicKey,
scopes,
softwareVersion,
environment,
now)));
}
private Mono<RegistrationContext> lockContext(
SqlConnection connection, String tokenHash) {
return database.preparedQuery(
connection, LOCK_SESSION_AND_LICENSE, Tuple.of(tokenHash))
.flatMap(rows -> {
java.util.Iterator<Row> iterator = rows.iterator();
return iterator.hasNext()
? Mono.just(context(iterator.next()))
: Mono.empty();
});
}
private Mono<RegistrationContext> validate(
RegistrationContext context, OffsetDateTime now) {
boolean valid = "PENDING".equals(context.sessionStatus)
&& context.expiresAt.isAfter(now)
&& "ACTIVE".equals(context.registrationStatus)
&& "ACTIVE".equals(context.tenantStatus)
&& "ACTIVE".equals(context.licenseStatus)
&& !now.isBefore(context.validFrom)
&& now.isBefore(context.validUntil)
&& context.consumingInstallations < context.maxInstallations;
return valid
? Mono.just(context)
: Mono.error(new IllegalStateException(
"Activation, license, or installation capacity is invalid"));
}
private Mono<RegisteredInstallation> insertInstallation(
SqlConnection connection,
RegistrationContext context,
String installationCode,
String installationName,
String assertionPublicKey,
Set<String> scopes,
String softwareVersion,
String environment,
OffsetDateTime now) {
UUID installationId = UUID.randomUUID();
Tuple insert = Tuple.tuple()
.addUUID(installationId)
.addUUID(context.tenantId)
.addString(context.clientSlug)
.addString(installationCode)
.addString(assertionPublicKey)
.addArrayOfString(scopes.toArray(String[]::new))
.addUUID(context.licenseId)
.addUUID(context.installationUuid)
.addString(installationName)
.addOffsetDateTime(now)
.addString(softwareVersion)
.addString(environment);
return database.preparedQuery(connection, INSERT_INSTALLATION, insert)
.flatMap(rows -> rows.rowCount() == 1
? consumeAndAudit(connection, context, installationId, now)
: Mono.error(new IllegalStateException(
"Installation could not be registered")))
.thenReturn(new RegisteredInstallation(
installationId,
context.installationUuid,
context.clientSlug,
installationCode,
1,
"ACTIVE"));
}
private Mono<Void> consumeAndAudit(
SqlConnection connection,
RegistrationContext context,
UUID installationId,
OffsetDateTime now) {
return database.preparedQuery(
connection,
CONSUME_SESSION,
Tuple.of(context.sessionId, now))
.flatMap(rows -> rows.rowCount() == 1
? database.preparedQuery(
connection,
INSERT_AUDIT,
Tuple.of(
UUID.randomUUID(),
context.registrationId,
context.tenantId,
context.licenseId,
installationId,
context.installationUuid.toString(),
"{}",
now))
: Mono.error(new IllegalStateException(
"Activation session was already consumed")))
.then();
}
private RegistrationContext context(Row row) {
return new RegistrationContext(
row.getUUID("activation_session_id"),
row.getUUID("registration_id"),
row.getUUID("tenant_id"),
row.getUUID("license_id"),
row.getUUID("installation_uuid"),
row.getString("session_status"),
row.getOffsetDateTime("expires_at"),
row.getString("client_code"),
row.getString("registration_status"),
row.getString("client_slug"),
row.getString("tenant_status"),
row.getString("package_code"),
row.getString("license_status"),
row.getOffsetDateTime("valid_from"),
row.getOffsetDateTime("valid_until"),
row.getInteger("max_installations"),
row.getInteger("consuming_installations"));
}
private record RegistrationContext(
UUID sessionId,
UUID registrationId,
UUID tenantId,
UUID licenseId,
UUID installationUuid,
String sessionStatus,
OffsetDateTime expiresAt,
String clientCode,
String registrationStatus,
String clientSlug,
String tenantStatus,
String packageCode,
String licenseStatus,
OffsetDateTime validFrom,
OffsetDateTime validUntil,
int maxInstallations,
int consumingInstallations) {
}
}

View File

@@ -0,0 +1,72 @@
package com.cygnus.cloud.tenant.repository;
import com.cygnus.cloud.database.ReactiveDatabaseClient;
import com.cygnus.cloud.tenant.model.InstallationLifecycleResult;
import io.vertx.sqlclient.Tuple;
import java.util.UUID;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
@Repository
public class InstallationLifecycleRepository {
private static final String RETIRE = """
WITH retired AS (
UPDATE identity.client_installation
SET status = $3,
enabled = false,
security_version = security_version + 1,
retired_at = now(),
retired_by = $4,
retirement_reason = $5
WHERE installation_id = $1
AND tenant_id = $2
AND status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
RETURNING installation_id, tenant_id, client_id,
installation_code, license_id, status
), audited AS (
INSERT INTO identity.installation_audit_event (
audit_event_id, registration_id, tenant_id, license_id,
installation_id, event_type, actor_type, actor_id,
reason, event_data)
SELECT gen_random_uuid(), account.registration_id, retired.tenant_id,
retired.license_id, retired.installation_id, retired.status,
'ADMIN', $4, $5, '{}'::jsonb
FROM retired
JOIN identity.client_account account
ON account.tenant_id = retired.tenant_id
)
SELECT installation_id, tenant_id, client_id, installation_code, status
FROM retired
""";
private final ReactiveDatabaseClient database;
public InstallationLifecycleRepository(ReactiveDatabaseClient database) {
this.database = database;
}
public Mono<InstallationLifecycleResult> retire(
UUID tenantId,
UUID installationId,
String status,
String actor,
String reason) {
return database.preparedQuery(
RETIRE,
Tuple.of(installationId, tenantId, status, actor, reason))
.flatMap(rows -> {
var iterator = rows.iterator();
if (!iterator.hasNext()) {
return Mono.empty();
}
var row = iterator.next();
return Mono.just(new InstallationLifecycleResult(
row.getUUID("installation_id"),
row.getUUID("tenant_id"),
row.getString("client_id"),
row.getString("installation_code"),
row.getString("status")));
});
}
}

View File

@@ -0,0 +1,182 @@
package com.cygnus.cloud.tenant.repository;
import com.cygnus.cloud.database.ReactiveDatabaseClient;
import com.cygnus.cloud.tenant.model.ActivationKeyStatus;
import com.cygnus.cloud.tenant.model.ClientStatus;
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
import com.cygnus.cloud.tenant.model.LicenseActivationKey;
import com.cygnus.cloud.tenant.model.LicenseStatus;
import com.cygnus.cloud.tenant.model.RegistrationStatus;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple;
import java.time.OffsetDateTime;
import java.util.UUID;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Repository
public class LicenseActivationRepository {
private static final String FIND_CANDIDATES = """
SELECT activation.activation_key_id, activation.registration_id,
activation.tenant_id, activation.license_id,
activation.key_hash, activation.key_hint, activation.status,
activation.expires_at, activation.failed_attempts,
activation.maximum_attempts, activation.locked_until,
activation.created_at, activation.last_used_at,
registration.client_code,
registration.status AS registration_status,
account.status AS tenant_status,
account.client_slug, account.client_name,
license.package_code, license.license_type,
license.status AS license_status,
license.valid_from, license.valid_until,
license.max_installations,
(
SELECT count(*)::integer
FROM identity.client_installation installation
WHERE installation.tenant_id = activation.tenant_id
AND installation.license_id = activation.license_id
AND installation.status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
) AS consuming_installations
FROM identity.license_activation_key activation
JOIN identity.client_registration_details registration
ON registration.registration_id = activation.registration_id
JOIN identity.client_account account
ON account.registration_id = activation.registration_id
AND account.tenant_id = activation.tenant_id
JOIN identity.client_license license
ON license.tenant_id = activation.tenant_id
AND license.license_id = activation.license_id
WHERE upper(registration.client_code) = upper($1)
AND activation.key_hint = $2
""";
private static final String INSERT_KEY = """
INSERT INTO identity.license_activation_key (
activation_key_id, registration_id, tenant_id, license_id,
key_hash, key_hint, status, expires_at, created_by)
SELECT $1, account.registration_id, account.tenant_id,
license.license_id, $4, $5, 'ACTIVE', $6, $7
FROM identity.client_account account
JOIN identity.client_license license
ON license.tenant_id = account.tenant_id
WHERE account.tenant_id = $2
AND license.license_id = $3
AND account.status = 'ACTIVE'
AND license.status = 'ACTIVE'
""";
private static final String RECORD_SUCCESS = """
UPDATE identity.license_activation_key
SET failed_attempts = 0,
locked_until = NULL,
status = 'ACTIVE',
last_used_at = $2
WHERE activation_key_id = $1
""";
private static final String RECORD_FAILURE = """
UPDATE identity.license_activation_key
SET failed_attempts = LEAST(failed_attempts + 1, maximum_attempts),
status = CASE
WHEN failed_attempts + 1 >= maximum_attempts THEN 'LOCKED'
ELSE status
END,
locked_until = CASE
WHEN failed_attempts + 1 >= maximum_attempts THEN $2
ELSE locked_until
END
WHERE activation_key_id = $1
""";
private static final String REVOKE_KEY = """
UPDATE identity.license_activation_key
SET status = 'REVOKED',
locked_until = NULL
WHERE activation_key_id = $1
AND status IN ('ACTIVE', 'LOCKED')
""";
private final ReactiveDatabaseClient database;
public LicenseActivationRepository(ReactiveDatabaseClient database) {
this.database = database;
}
public Flux<LicenseActivationContext> findCandidates(
String clientCode, String keyHint) {
return database.preparedQuery(FIND_CANDIDATES, Tuple.of(clientCode, keyHint))
.flatMapMany(Flux::fromIterable)
.map(this::context);
}
public Mono<Boolean> insert(
UUID activationKeyId,
UUID tenantId,
UUID licenseId,
String keyHash,
String keyHint,
OffsetDateTime expiresAt,
String createdBy) {
Tuple values = Tuple.tuple()
.addUUID(activationKeyId)
.addUUID(tenantId)
.addUUID(licenseId)
.addString(keyHash)
.addString(keyHint)
.addOffsetDateTime(expiresAt)
.addString(createdBy);
return database.preparedUpdate(INSERT_KEY, values).map(count -> count == 1);
}
public Mono<Boolean> recordSuccess(UUID activationKeyId, OffsetDateTime now) {
return database.preparedUpdate(
RECORD_SUCCESS, Tuple.of(activationKeyId, now))
.map(count -> count == 1);
}
public Mono<Boolean> recordFailure(
UUID activationKeyId, OffsetDateTime lockedUntil) {
return database.preparedUpdate(
RECORD_FAILURE, Tuple.of(activationKeyId, lockedUntil))
.map(count -> count == 1);
}
public Mono<Boolean> revoke(UUID activationKeyId) {
return database.preparedUpdate(REVOKE_KEY, Tuple.of(activationKeyId))
.map(count -> count == 1);
}
private LicenseActivationContext context(Row row) {
LicenseActivationKey key = new LicenseActivationKey(
row.getUUID("activation_key_id"),
row.getUUID("registration_id"),
row.getUUID("tenant_id"),
row.getUUID("license_id"),
row.getString("key_hash"),
row.getString("key_hint"),
ActivationKeyStatus.valueOf(row.getString("status")),
row.getOffsetDateTime("expires_at"),
row.getInteger("failed_attempts"),
row.getInteger("maximum_attempts"),
row.getOffsetDateTime("locked_until"),
row.getOffsetDateTime("created_at"),
row.getOffsetDateTime("last_used_at"));
return new LicenseActivationContext(
key,
row.getString("client_code"),
RegistrationStatus.valueOf(row.getString("registration_status")),
ClientStatus.valueOf(row.getString("tenant_status")),
row.getString("client_slug"),
row.getString("client_name"),
row.getString("package_code"),
row.getString("license_type"),
LicenseStatus.valueOf(row.getString("license_status")),
row.getOffsetDateTime("valid_from").toInstant(),
row.getOffsetDateTime("valid_until").toInstant(),
row.getInteger("max_installations"),
row.getInteger("consuming_installations"));
}
}

View File

@@ -37,9 +37,18 @@ public class TenantRegistrationRepository {
WHERE installation.client_id = $1
AND installation.installation_code = $2
AND installation.enabled = true
AND installation.status = 'ACTIVE'
AND account.status = 'ACTIVE'
""";
private static final String TOUCH_INSTALLATION = """
UPDATE identity.client_installation
SET last_seen_at = $2,
last_authenticated_at = $2
WHERE installation_id = $1
AND (last_seen_at IS NULL OR last_seen_at < $2 - INTERVAL '5 minutes')
""";
private static final String FIND_CURRENT_LICENSE = """
SELECT license_id, tenant_id, license_type, package_code,
valid_from, valid_until, status, max_users, max_installations,
@@ -82,6 +91,15 @@ public class TenantRegistrationRepository {
.flatMap(rows -> first(rows, this::license));
}
public Mono<Void> touchInstallation(UUID installationId, Instant instant) {
return database.preparedUpdate(
TOUCH_INSTALLATION,
Tuple.of(
installationId,
instant.atOffset(java.time.ZoneOffset.UTC)))
.then();
}
private <T> Mono<T> first(
Iterable<Row> rows, java.util.function.Function<Row, T> mapper) {
java.util.Iterator<Row> iterator = rows.iterator();

View File

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

View File

@@ -0,0 +1,49 @@
package com.cygnus.cloud.tenant.service;
import com.cygnus.cloud.cache.ReactiveCacheService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.HexFormat;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class ActivationRateLimiter {
private static final String NAMESPACE = "installation-activation-rate";
private final ReactiveCacheService cache;
private final int maximumAttempts;
private final Duration window;
public ActivationRateLimiter(
ReactiveCacheService cache,
@Value("${cygnus.activation-rate-limit.maximum-attempts:10}")
int maximumAttempts,
@Value("${cygnus.activation-rate-limit.window:10m}")
Duration window) {
this.cache = cache;
this.maximumAttempts = maximumAttempts;
this.window = window;
}
public Mono<Void> check(String sourceIp, String clientCode) {
String identity = (sourceIp == null ? "unknown" : sourceIp)
+ '|'
+ clientCode.toUpperCase(java.util.Locale.ROOT);
return cache.increment(NAMESPACE, digest(identity), window)
.flatMap(attempts -> attempts <= maximumAttempts
? Mono.empty()
: Mono.error(new ActivationRateLimitException()));
}
private String digest(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}

View File

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

View File

@@ -0,0 +1,173 @@
package com.cygnus.cloud.tenant.service;
import com.cygnus.cloud.tenant.model.ActivationSession;
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
import com.cygnus.cloud.tenant.repository.InstallationActivationRepository;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.Clock;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Set;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class InstallationActivationService {
private static final Duration SESSION_TTL = Duration.ofMinutes(20);
private static final Set<String> INSTALLATION_SCOPES = Set.of("identity.login");
private final LicenseKeyService licenseKeyService;
private final InstallationActivationRepository repository;
private final SecureRandom random;
private final Clock clock;
@Autowired
public InstallationActivationService(
LicenseKeyService licenseKeyService,
InstallationActivationRepository repository,
Clock clock) {
this(licenseKeyService, repository, clock, new SecureRandom());
}
InstallationActivationService(
LicenseKeyService licenseKeyService,
InstallationActivationRepository repository,
Clock clock,
SecureRandom random) {
this.licenseKeyService = licenseKeyService;
this.repository = repository;
this.clock = clock;
this.random = random;
}
public Mono<ActivationSession> validateAndCreateSession(
String clientCode,
String licenseKey,
UUID installationUuid,
String sourceIp,
String installerVersion) {
return licenseKeyService.validate(clientCode, licenseKey)
.flatMap(context -> createSession(
context,
installationUuid,
sourceIp,
installerVersion));
}
public Mono<RegisteredInstallation> register(
String activationToken,
String installationCode,
String installationName,
String assertionPublicKey,
String softwareVersion,
String environment) {
validatePublicKey(assertionPublicKey);
OffsetDateTime now = now();
return repository.register(
digest(activationToken),
installationCode,
installationName,
assertionPublicKey,
INSTALLATION_SCOPES,
softwareVersion,
environment,
now)
.onErrorMap(
error -> !(error instanceof InstallationActivationException),
error -> new InstallationActivationException(
"Installation registration failed", error));
}
private Mono<ActivationSession> createSession(
LicenseActivationContext context,
UUID installationUuid,
String sourceIp,
String installerVersion) {
String token = randomToken();
UUID sessionId = UUID.randomUUID();
OffsetDateTime expiresAt = now().plus(SESSION_TTL);
return repository.insertSession(
sessionId,
context.activationKey().activationKeyId(),
context.activationKey().registrationId(),
context.activationKey().tenantId(),
context.activationKey().licenseId(),
installationUuid,
digest(token),
expiresAt,
sourceIp,
installerVersion)
.flatMap(inserted -> inserted
? Mono.just(new ActivationSession(
sessionId,
context.activationKey().registrationId(),
context.activationKey().tenantId(),
context.activationKey().licenseId(),
installationUuid,
token,
expiresAt,
context.clientCode(),
context.clientSlug(),
context.packageCode(),
context.maxInstallations()))
: Mono.error(new InstallationActivationException(
"Activation session could not be created")));
}
private String randomToken() {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String digest(String value) {
if (value == null || value.isBlank()) {
throw new InstallationActivationException(
"Activation token is required");
}
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
} catch (Exception exception) {
throw new InstallationActivationException(
"Activation token could not be processed", exception);
}
}
private void validatePublicKey(String pem) {
try {
String encoded = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
RSAPublicKey key = (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
if (key.getModulus().bitLength() < 3072) {
throw new InstallationActivationException(
"Installation public key must be at least 3072-bit RSA");
}
} catch (InstallationActivationException exception) {
throw exception;
} catch (Exception exception) {
throw new InstallationActivationException(
"Installation public key is invalid");
}
}
private OffsetDateTime now() {
return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
}
}

View File

@@ -0,0 +1,35 @@
package com.cygnus.cloud.tenant.service;
import com.cygnus.cloud.tenant.model.InstallationLifecycleResult;
import com.cygnus.cloud.tenant.repository.InstallationLifecycleRepository;
import java.util.UUID;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class InstallationLifecycleService {
private final InstallationLifecycleRepository repository;
private final TenantRegistrationService registrations;
public InstallationLifecycleService(
InstallationLifecycleRepository repository,
TenantRegistrationService registrations) {
this.repository = repository;
this.registrations = registrations;
}
public Mono<InstallationLifecycleResult> retire(
UUID tenantId,
UUID installationId,
String status,
String actor,
String reason) {
return repository.retire(tenantId, installationId, status, actor, reason)
.switchIfEmpty(Mono.error(
new IllegalArgumentException("Active installation not found")))
.flatMap(result -> registrations
.evictInstallation(result.clientId(), result.installationCode())
.thenReturn(result));
}
}

View File

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

View File

@@ -0,0 +1,192 @@
package com.cygnus.cloud.tenant.service;
import com.cygnus.cloud.tenant.model.ClientStatus;
import com.cygnus.cloud.tenant.model.IssuedLicenseKey;
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
import com.cygnus.cloud.tenant.model.LicenseStatus;
import com.cygnus.cloud.tenant.model.RegistrationStatus;
import com.cygnus.cloud.tenant.repository.LicenseActivationRepository;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.Locale;
import java.util.List;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@Service
public class LicenseKeyService {
private static final String PREFIX = "CYGNUS";
private static final int RANDOM_BYTES = 20;
private static final Duration LOCK_DURATION = Duration.ofMinutes(30);
private final LicenseActivationRepository repository;
private final BCryptPasswordEncoder encoder;
private final SecureRandom random;
private final Clock clock;
@Autowired
public LicenseKeyService(
LicenseActivationRepository repository,
Clock clock) {
this(repository, clock, new BCryptPasswordEncoder(12), new SecureRandom());
}
LicenseKeyService(
LicenseActivationRepository repository,
Clock clock,
BCryptPasswordEncoder encoder,
SecureRandom random) {
this.repository = repository;
this.clock = clock;
this.encoder = encoder;
this.random = random;
}
public Mono<IssuedLicenseKey> issue(
UUID tenantId,
UUID licenseId,
OffsetDateTime expiresAt,
String createdBy) {
return Mono.fromCallable(this::newPlaintextKey)
.subscribeOn(Schedulers.boundedElastic())
.flatMap(plaintext -> hash(plaintext)
.flatMap(hash -> {
UUID id = UUID.randomUUID();
String hint = hint(plaintext);
return repository.insert(
id,
tenantId,
licenseId,
hash,
hint,
expiresAt,
createdBy)
.flatMap(inserted -> inserted
? Mono.just(new IssuedLicenseKey(
id, plaintext, hint))
: Mono.error(new LicenseKeyException(
"Active tenant and license were not found")));
}));
}
public Mono<LicenseActivationContext> validate(
String clientCode, String plaintextKey) {
String normalizedCode = normalizeClientCode(clientCode);
String normalizedKey = normalizeKey(plaintextKey);
OffsetDateTime now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
return repository.findCandidates(normalizedCode, hint(normalizedKey))
.collectList()
.flatMap(candidates -> matchingCandidate(candidates, normalizedKey))
.flatMap(candidate -> validateContext(candidate, now))
.flatMap(candidate -> repository
.recordSuccess(candidate.activationKey().activationKeyId(), now)
.thenReturn(candidate));
}
private Mono<LicenseActivationContext> matchingCandidate(
List<LicenseActivationContext> candidates, String plaintextKey) {
if (candidates.isEmpty()) {
return Mono.error(new LicenseKeyException(
"The client code or license key is invalid"));
}
return Flux.fromIterable(candidates)
.filterWhen(candidate -> matches(
plaintextKey, candidate.activationKey().keyHash()))
.next()
.switchIfEmpty(Flux.fromIterable(candidates)
.flatMap(candidate -> recordRejectedAttempt(
candidate.activationKey().activationKeyId()))
.then(Mono.error(new LicenseKeyException(
"The client code or license key is invalid"))));
}
private Mono<LicenseActivationContext> validateContext(
LicenseActivationContext context, OffsetDateTime now) {
if (!context.activationKey().canAttemptAt(now)) {
return Mono.error(new LicenseKeyException(
"The license key is expired, locked, or inactive"));
}
if (context.registrationStatus() != RegistrationStatus.ACTIVE
|| context.tenantStatus() != ClientStatus.ACTIVE) {
return Mono.error(new LicenseKeyException(
"The client registration or tenant is inactive"));
}
if (context.licenseStatus() != LicenseStatus.ACTIVE
|| clock.instant().isBefore(context.validFrom())
|| !clock.instant().isBefore(context.validUntil())) {
return Mono.error(new LicenseKeyException("The license is not active"));
}
if (!context.hasCapacity()) {
return Mono.error(new LicenseKeyException(
"The installation limit for this tenant has been reached"));
}
return Mono.just(context);
}
public Mono<Boolean> recordRejectedAttempt(UUID activationKeyId) {
OffsetDateTime lockedUntil = OffsetDateTime.ofInstant(
clock.instant().plus(LOCK_DURATION), ZoneOffset.UTC);
return repository.recordFailure(activationKeyId, lockedUntil);
}
public Mono<Boolean> revoke(UUID activationKeyId) {
return repository.revoke(activationKeyId);
}
private Mono<String> hash(String plaintext) {
return Mono.fromCallable(() -> encoder.encode(plaintext))
.subscribeOn(Schedulers.boundedElastic());
}
private Mono<Boolean> matches(String plaintext, String hash) {
return Mono.fromCallable(() -> encoder.matches(plaintext, hash))
.subscribeOn(Schedulers.boundedElastic());
}
private String newPlaintextKey() {
byte[] bytes = new byte[RANDOM_BYTES];
random.nextBytes(bytes);
String raw = HexFormat.of().formatHex(bytes).toUpperCase(Locale.ROOT);
return PREFIX + "-" + groups(raw);
}
private String groups(String value) {
return String.join(
"-",
value.substring(0, 8),
value.substring(8, 16),
value.substring(16, 24),
value.substring(24, 32),
value.substring(32, 40));
}
private String hint(String key) {
String compact = key.replace("-", "");
return compact.substring(Math.max(0, compact.length() - 8));
}
private String normalizeClientCode(String value) {
if (value == null || value.isBlank()) {
throw new LicenseKeyException("Client code is required");
}
return value.trim().toUpperCase(Locale.ROOT);
}
private String normalizeKey(String value) {
if (value == null || value.isBlank()) {
throw new LicenseKeyException("License key is required");
}
return value.trim().toUpperCase(Locale.ROOT);
}
}

View File

@@ -0,0 +1,71 @@
package com.cygnus.cloud.tenant.service;
import com.cygnus.cloud.tenant.model.IssuedLicenseKey;
import java.time.OffsetDateTime;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
@Service
public class RegistrationEmailService {
private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final String from;
public RegistrationEmailService(
ObjectProvider<JavaMailSender> mailSenderProvider,
@Value("${cygnus.registration-email.from:noreply@cygnus.invalid}")
String from) {
this.mailSenderProvider = mailSenderProvider;
this.from = from;
}
public Mono<Void> sendLicense(
String recipient,
String clientCode,
String tenantSlug,
String packageCode,
int maximumInstallations,
OffsetDateTime validUntil,
IssuedLicenseKey issued) {
return Mono.fromRunnable(() -> {
JavaMailSender mailSender = mailSenderProvider.getIfAvailable();
if (mailSender == null) {
throw new IllegalStateException(
"SMTP is not configured; set spring.mail.host and credentials");
}
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(recipient);
message.setSubject("Cygnus registration and installation license");
message.setText("""
Your Cygnus tenant is ready.
Client code: %s
Tenant: %s
Package: %s
Maximum installations: %d
License key: %s
License key expiry: %s
Docker and Docker Compose v2 are mandatory. The installation
wizard will not continue until both are available and running.
Keep this license key confidential and enter it only in the
official Cygnus installation wizard.
""".formatted(
clientCode,
tenantSlug,
packageCode,
maximumInstallations,
issued.licenseKey(),
validUntil));
mailSender.send(message);
})
.subscribeOn(Schedulers.boundedElastic())
.then();
}
}

View File

@@ -88,6 +88,11 @@ public class TenantRegistrationService {
.onErrorReturn(false);
}
public Mono<Void> touchInstallation(UUID installationId, Instant instant) {
return repository.touchInstallation(installationId, instant)
.onErrorResume(exception -> Mono.empty());
}
private Duration licenseTtl(ClientLicense license) {
Duration remaining = Duration.between(clock.instant(), license.validUntil());
if (remaining.isNegative() || remaining.isZero()) {