diff --git a/.vscode/launch.json b/.vscode/launch.json index c253694..091ef49 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,8 +29,7 @@ "CYGNUS_ACCESS_TOKEN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/access-token-private.pem", "CYGNUS_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem", "CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01", - "CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem", - "SPRING_CONFIG_ADDITIONAL_LOCATION": "file:${workspaceFolder}/config/clients.yml" + "CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem" }, "shortenCommandLine": "argfile" }, diff --git a/Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx b/Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx index e3119de..b27c53e 100644 Binary files a/Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx and b/Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx differ diff --git a/cygnus-cloud-client/README.md b/cygnus-cloud-client/README.md index 7bcb53d..38aef20 100644 --- a/cygnus-cloud-client/README.md +++ b/cygnus-cloud-client/README.md @@ -29,18 +29,24 @@ It is valid for one year; the access token obtained with it is short-lived. For local development, the repository setup script automates prerequisite checks, the full Maven verification, directory creation, all three cloud key -pairs, the installation key pair, `config/clients.yml`, and the encrypted -machine assertion: +pairs, the installation key pair, database-backed tenant/install registration, +an initial license, and the encrypted machine assertion: ```bash ./scripts/setup-local-communication.sh ``` -The script interactively asks for the customer identifier, installation -identifier, cloud URL, and whether to run the full verification. Customer and -installation identifiers cannot contain spaces; the customer identifier is -used for its directory and signing-key filenames. New customers are appended -to `config/clients.yml` without replacing existing customers. +The script interactively asks for the customer name and slug, installation +identifier, cloud URL, database connection, license package/type/duration, and +whether to run the full verification. Customer and installation identifiers +cannot contain spaces. The slug is the stable tenant key and is used for its +directory and signing-key filenames. + +The client account, installation public key, allowed scopes, and license are +upserted into PostgreSQL (`identity.client_account`, +`identity.client_installation`, and `identity.client_license`). The cloud +service resolves this registration dynamically through Redis with PostgreSQL +fallback, so adding another customer does not require a cloud restart. It preserves existing private keys and assertions. Set `CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be @@ -65,6 +71,6 @@ mvn -pl cygnus-cloud-client exec:java \ client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt" ``` -Copy only `client-signing-public.pem` into that customer's cloud-side client -configuration. Keep the private key and generated assertion on the on-premises -server with owner-only filesystem permissions. +The setup script stores `client-signing-public.pem` in the installation record +used by the cloud. Keep the private key and generated assertion only on the +on-premises server with owner-only filesystem permissions. diff --git a/cygnus-cloud-service/README.md b/cygnus-cloud-service/README.md index 4c5028d..8c7388d 100644 --- a/cygnus-cloud-service/README.md +++ b/cygnus-cloud-service/README.md @@ -53,10 +53,10 @@ on-premises gateway. The client assertion must be: - bound to the configured client ID, installation ID, and token audience; - unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`. -The endpoint returns a short-lived RS256 access token carrying `client_id`, -`installation_id`, and the approved scope. The identity endpoint requires the -`identity.login` scope and verifies the same machine binding in the encrypted -login payload. +The endpoint returns a short-lived RS256 access token carrying the client, +installation, tenant, license, security-version, and approved-scope claims. +The identity endpoint requires the `identity.login` scope and verifies the +same machine and tenant binding in the encrypted login payload. Generate separate cloud key pairs: @@ -73,26 +73,28 @@ openssl pkey -in config/keys/access-token-private.pem -pubout \ chmod 600 config/keys/*private.pem ``` -Configure clients in an external Spring YAML file rather than the packaged -`application.yml`: +## Dynamic tenant, installation, and license registration -```yaml -cygnus: - security: - enabled: true - issuer-uri: https://cloud.example.com - audience: cygnus-cloud-api - token-audience: https://cloud.example.com/oauth2/token - clients: - customer-a: - enabled: true - installation-id: site-01 - assertion-public-key: file:/secure/clients/customer-a/public.pem - scopes: - - identity.login -``` +Machine clients are no longer configured in a runtime `clients.yml`. The +authoritative records are: -Start with that protected file using -`--spring.config.additional-location=file:/secure/cygnus/clients.yml`. -Never place cloud private keys, customer assertions, or installation private -keys in the repository or container image. +- `identity.client_account`: tenant identity and status; +- `identity.client_installation`: machine identity, assertion public key, + allowed scopes, enabled state, and security version; +- `identity.client_license`: subscription period, package, type, status, and + licensed limits. + +Token issuance resolves the installation and active license through a +Redis cache-aside service with PostgreSQL fallback. Cache entries have a +bounded TTL and can be invalidated after administrative changes. Therefore, +new customers, installations, key rotations, scope changes, and license +changes do not require restarting the cloud service. + +The login/menu queries are tenant-scoped. Tenant-owned identity tables carry +`tenant_id`; `identity.pages` remains the shared feature catalog while +permissions are assigned per tenant. + +Use `scripts/setup-local-communication.sh` to create keys, register or update +the database records, create the initial license, and generate the on-premises +machine assertion. Never place cloud private keys, customer assertions, or +installation private keys in the repository or container image. diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java index 277e51b..670b728 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java @@ -7,6 +7,7 @@ import com.cygnus.cloud.identity.service.LoginRequestReplayService; import jakarta.validation.Valid; import java.time.Clock; import java.time.Duration; +import java.util.UUID; import org.springframework.util.StringUtils; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -64,12 +65,21 @@ public class CloudLoginController { return Mono.error(new AuthenticationException("Login request replayed")); } return authenticationService.authenticate( + tenantId(machineJwt), payload.loginId(), payload.password(), remoteAddress(serverRequest)); }); } + private UUID tenantId(Jwt jwt) { + try { + return UUID.fromString(jwt.getClaimAsString("tenant_id")); + } catch (RuntimeException exception) { + throw new AuthenticationException("Machine tenant is invalid"); + } + } + private void validatePayload(LoginPayload payload) { if (payload == null || !StringUtils.hasText(payload.loginId()) diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/repository/IdentityRepository.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/repository/IdentityRepository.java index ba6a6e7..0db0597 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/repository/IdentityRepository.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/repository/IdentityRepository.java @@ -8,6 +8,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.UUID; import org.springframework.stereotype.Repository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -20,25 +21,32 @@ public class IdentityRepository { g.name AS group_name, u.branch_id, b.branchname, b.branchcode, b.city, u.company_id, c.companyname, c.companycode, u.isactive FROM identity.app_user u - JOIN identity.user_group g ON g.group_id = u.group_id - JOIN identity.company c ON c.company_id = u.company_id + JOIN identity.user_group g + ON g.tenant_id = u.tenant_id AND g.group_id = u.group_id + JOIN identity.company c + ON c.tenant_id = u.tenant_id AND c.company_id = u.company_id JOIN identity.company_branch b - ON b.branch_id = u.branch_id AND b.company_id = u.company_id - WHERE upper(u.loginid) = upper($1) + ON b.tenant_id = u.tenant_id + AND b.branch_id = u.branch_id + AND b.company_id = u.company_id + WHERE u.tenant_id = $1 + AND upper(u.loginid) = upper($2) """; private static final String FIND_MENU = """ SELECT p.page_id, p.menulabel, p.targeturl, p.parentpage, p.pageorder, permissions.permission, p.targetwindow, permissions.requestval - FROM identity.permission permissions + FROM identity.permission permissions JOIN identity.pages p ON p.page_id = permissions.page_id - WHERE permissions.group_id = $1 + WHERE permissions.tenant_id = $1 + AND permissions.group_id = $2 AND p.isvisible = 1 AND permissions.permission <> '000' AND NOT EXISTS ( SELECT 1 FROM identity.denied_pages denied - WHERE denied.user_id = $2 + WHERE denied.tenant_id = permissions.tenant_id + AND denied.user_id = $3 AND denied.page_id = permissions.page_id AND denied.isdenied = 1 ) @@ -47,8 +55,8 @@ public class IdentityRepository { private static final String RECORD_LOGIN = """ INSERT INTO identity.user_loginhistory - (loginid, logintime, ipaddr, user_id) - VALUES ($1, $2, $3, $4) + (tenant_id, loginid, logintime, ipaddr, user_id) + VALUES ($1, $2, $3, $4, $5) RETURNING uid """; @@ -60,23 +68,34 @@ public class IdentityRepository { this.mapper = mapper; } - public Flux findUsersByLoginId(String loginId) { - return database.preparedQuery(FIND_USER, Tuple.of(loginId)) + public Flux findUsersByLoginId(UUID tenantId, String loginId) { + return database.preparedQuery(FIND_USER, Tuple.of(tenantId, loginId)) .flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user)); } - public Mono> findMenu(short groupId, short userId) { - return database.preparedQuery(FIND_MENU, Tuple.of(groupId, userId)) + public Mono> findMenu( + UUID tenantId, short groupId, short userId) { + return database.preparedQuery( + FIND_MENU, Tuple.of(tenantId, groupId, userId)) .flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::menuItem)) .collectList(); } public Mono recordLogin( - String loginId, Instant loginTime, String remoteAddress, short userId) { + UUID tenantId, + String loginId, + Instant loginTime, + String remoteAddress, + short userId) { LocalDateTime databaseTime = LocalDateTime.ofInstant(loginTime, ZoneOffset.UTC); return database.preparedQuery( RECORD_LOGIN, - Tuple.of(loginId, databaseTime, remoteAddress, userId)) + Tuple.of( + tenantId, + loginId, + databaseTime, + remoteAddress, + userId)) .map(rows -> rows.iterator().next().getLong("uid")); } } diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/service/IdentityAuthenticationService.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/service/IdentityAuthenticationService.java index 3363b03..1eac450 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/service/IdentityAuthenticationService.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/service/IdentityAuthenticationService.java @@ -5,6 +5,7 @@ import com.cygnus.cloud.identity.model.IdentityUser; import com.cygnus.cloud.identity.repository.IdentityRepository; import java.time.Clock; import java.time.Instant; +import java.util.UUID; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; @@ -25,8 +26,11 @@ public class IdentityAuthenticationService { } public Mono authenticate( - String loginId, String password, String remoteAddress) { - return repository.findUsersByLoginId(loginId) + UUID tenantId, + String loginId, + String password, + String remoteAddress) { + return repository.findUsersByLoginId(tenantId, loginId) .collectList() .flatMap(users -> { if (users.isEmpty()) { @@ -49,9 +53,14 @@ public class IdentityAuthenticationService { return Mono.error(new AuthenticationException("Invalid credentials")); } Instant loginTime = clock.instant(); - return repository.findMenu(user.groupId(), user.userId()) + return repository.findMenu( + tenantId, user.groupId(), user.userId()) .flatMap(menu -> repository.recordLogin( - user.loginId(), loginTime, remoteAddress, user.userId()) + tenantId, + user.loginId(), + loginTime, + remoteAddress, + user.userId()) .thenReturn(toAuthenticatedIdentity(user, loginTime, menu))); }); } diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/AccessTokenIssuer.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/AccessTokenIssuer.java index 3a1bcb4..c9c52ce 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/AccessTokenIssuer.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/AccessTokenIssuer.java @@ -36,6 +36,13 @@ class AccessTokenIssuer { .jwtID(UUID.randomUUID().toString()) .claim("client_id", principal.clientId()) .claim("installation_id", principal.installationId()) + .claim("installation_uuid", + principal.internalInstallationId().toString()) + .claim("tenant_id", principal.tenantId().toString()) + .claim("license_id", principal.licenseId().toString()) + .claim("license_type", principal.licenseType()) + .claim("package_code", principal.packageCode()) + .claim("security_version", principal.securityVersion()) .claim("scope", String.join(" ", scopes)) .build(); SignedJWT jwt = new SignedJWT( 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 7b1af5b..5f0b9ff 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 @@ -7,29 +7,33 @@ import com.nimbusds.jose.crypto.RSADecrypter; import com.nimbusds.jose.crypto.RSASSAVerifier; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.model.ClientLicense; +import com.cygnus.cloud.tenant.service.TenantRegistrationService; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Date; import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; @Component class ClientAssertionValidator { private final CommunicationSecurityProperties properties; + private final TenantRegistrationService registrations; private final Clock clock; - ClientAssertionValidator(CommunicationSecurityProperties properties, Clock clock) { + ClientAssertionValidator( + CommunicationSecurityProperties properties, + TenantRegistrationService registrations, + Clock clock) { this.properties = properties; + this.registrations = registrations; this.clock = clock; } - MachineClientPrincipal validate(String clientId, String encryptedAssertion) { - CommunicationSecurityProperties.MachineClient client = - properties.clients() == null ? null : properties.clients().get(clientId); - if (client == null || !client.enabled()) { - throw invalid(); - } + Mono validate(String clientId, String encryptedAssertion) { try { JWEObject jwe = JWEObject.parse(encryptedAssertion); if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm()) @@ -40,25 +44,65 @@ class ClientAssertionValidator { PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey()))); SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString()); - if (!signedJwt.verify(new RSASSAVerifier( - PemKeyLoader.publicKey(client.assertionPublicKey())))) { + JWTClaimsSet claims = signedJwt.getJWTClaimsSet(); + String installationCode = claims.getStringClaim("installation_id"); + if (installationCode == null || installationCode.isBlank()) { throw invalid(); } + return registrations.findInstallation(clientId, installationCode) + .switchIfEmpty(Mono.error(invalid())) + .flatMap(installation -> registrations + .findCurrentLicense(installation.tenantId(), clock.instant()) + .filter(license -> license.isActiveAt(clock.instant())) + .switchIfEmpty(Mono.error(new MachineAuthenticationException( + "Client license is not active"))) + .map(license -> verify( + clientId, signedJwt, claims, installation, license))) + .onErrorMap( + exception -> !(exception instanceof MachineAuthenticationException), + exception -> new MachineAuthenticationException( + "Invalid client assertion", exception)); + } catch (MachineAuthenticationException exception) { + return Mono.error(exception); + } catch (Exception exception) { + return Mono.error(new MachineAuthenticationException( + "Invalid client assertion", exception)); + } + } - JWTClaimsSet claims = signedJwt.getJWTClaimsSet(); - validateClaims(clientId, client, claims); + private MachineClientPrincipal verify( + String clientId, + SignedJWT signedJwt, + JWTClaimsSet claims, + ClientInstallation installation, + ClientLicense license) { + try { + if (!signedJwt.verify(new RSASSAVerifier( + PemKeyLoader.publicKey(installation.assertionPublicKey())))) { + throw invalid(); + } + validateClaims(clientId, installation, claims); return new MachineClientPrincipal( - clientId, client.installationId(), client.scopes()); + clientId, + installation.installationCode(), + installation.tenantId(), + installation.installationId(), + license.licenseId(), + license.licenseType(), + license.packageCode(), + installation.securityVersion(), + installation.allowedScopes()); } catch (MachineAuthenticationException exception) { throw exception; } catch (Exception exception) { - throw new MachineAuthenticationException("Invalid client assertion", exception); + throw new MachineAuthenticationException( + "Invalid client assertion", exception); } } private void validateClaims( String clientId, - CommunicationSecurityProperties.MachineClient client, + ClientInstallation installation, JWTClaimsSet claims) throws Exception { Instant now = clock.instant(); Date issuedAt = claims.getIssueTime(); @@ -66,7 +110,7 @@ class ClientAssertionValidator { if (!clientId.equals(claims.getIssuer()) || !clientId.equals(claims.getSubject()) || !claims.getAudience().contains(properties.tokenAudience()) - || !client.installationId().equals( + || !installation.installationCode().equals( claims.getStringClaim("installation_id")) || issuedAt == null || expiresAt == null diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CommunicationSecurityProperties.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CommunicationSecurityProperties.java index 57acada..55966d9 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CommunicationSecurityProperties.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CommunicationSecurityProperties.java @@ -1,8 +1,6 @@ package com.cygnus.cloud.security; import java.time.Duration; -import java.util.Map; -import java.util.Set; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -16,13 +14,5 @@ public record CommunicationSecurityProperties( Duration accessTokenTtl, String assertionDecryptionPrivateKey, String accessTokenPrivateKey, - String accessTokenPublicKey, - Map clients) { - - public record MachineClient( - boolean enabled, - String installationId, - String assertionPublicKey, - Set scopes) { - } + String accessTokenPublicKey) { } diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineClientPrincipal.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineClientPrincipal.java index 4a2e3d3..0bb2b2f 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineClientPrincipal.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineClientPrincipal.java @@ -1,9 +1,16 @@ package com.cygnus.cloud.security; import java.util.Set; +import java.util.UUID; record MachineClientPrincipal( String clientId, String installationId, + UUID tenantId, + UUID internalInstallationId, + UUID licenseId, + String licenseType, + String packageCode, + int securityVersion, Set allowedScopes) { } diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineSecurityConfigurationValidator.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineSecurityConfigurationValidator.java index 288e0ed..873b47a 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineSecurityConfigurationValidator.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineSecurityConfigurationValidator.java @@ -2,7 +2,6 @@ package com.cygnus.cloud.security; import java.net.URI; import java.time.Duration; -import java.util.Map; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -34,33 +33,6 @@ class MachineSecurityConfigurationValidator implements InitializingBean { requireText("access-token-private-key", properties.accessTokenPrivateKey()); requireText("access-token-public-key", properties.accessTokenPublicKey()); - Map clients = - properties.clients(); - if (clients == null || clients.isEmpty()) { - throw invalid("at least one machine client is required"); - } - clients.forEach(this::validateClient); - } - - private void validateClient( - String clientId, - CommunicationSecurityProperties.MachineClient client) { - requireText("clients.", clientId); - if (client == null) { - throw invalid("client '" + clientId + "' has no configuration"); - } - requireText( - "clients." + clientId + ".installation-id", - client.installationId()); - requireText( - "clients." + clientId + ".assertion-public-key", - client.assertionPublicKey()); - if (client.scopes() == null - || client.scopes().isEmpty() - || client.scopes().stream().anyMatch(this::isBlank)) { - throw invalid( - "clients." + clientId + ".scopes must contain valid scopes"); - } } private void requireUri(String name, String value) { diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineTokenController.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineTokenController.java index beff30a..ac253f5 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineTokenController.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineTokenController.java @@ -33,23 +33,27 @@ class MachineTokenController { consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) Mono> token(ServerWebExchange exchange) { - return exchange.getFormData().map(this::issueToken); + return exchange.getFormData().flatMap(this::issueToken); } - Map issueToken(MultiValueMap form) { + Mono> issueToken(MultiValueMap form) { if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type")) || !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) { throw new MachineAuthenticationException("Unsupported token request"); } String clientId = required(form, "client_id"); - MachineClientPrincipal principal = assertionValidator.validate( - clientId, required(form, "client_assertion")); Set requestedScopes = scopes(form.getFirst("scope")); - if (requestedScopes.isEmpty() - || !principal.allowedScopes().containsAll(requestedScopes)) { - throw new MachineAuthenticationException("Invalid requested scope"); - } - return tokenIssuer.issue(principal, requestedScopes).asOAuthResponse(); + return assertionValidator.validate( + clientId, required(form, "client_assertion")) + .map(principal -> { + if (requestedScopes.isEmpty() + || !principal.allowedScopes().containsAll(requestedScopes)) { + throw new MachineAuthenticationException( + "Invalid requested scope"); + } + return tokenIssuer.issue(principal, requestedScopes) + .asOAuthResponse(); + }); } private String required(MultiValueMap form, String name) { diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/PemKeyLoader.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/PemKeyLoader.java index 3fb607f..e636be2 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/PemKeyLoader.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/PemKeyLoader.java @@ -66,6 +66,9 @@ final class PemKeyLoader { if (location == null || location.isBlank()) { throw new IllegalArgumentException("RSA key location is not configured"); } + if (location.contains("-----BEGIN ")) { + return location; + } if (location.startsWith("classpath:")) { String resource = location.substring("classpath:".length()); try (InputStream stream = Thread.currentThread() diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientAccount.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientAccount.java new file mode 100644 index 0000000..e2e8109 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientAccount.java @@ -0,0 +1,13 @@ +package com.cygnus.cloud.tenant.model; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public record ClientAccount( + UUID tenantId, + String clientSlug, + String clientName, + ClientStatus status, + OffsetDateTime createdAt, + OffsetDateTime updatedAt) { +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientInstallation.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientInstallation.java new file mode 100644 index 0000000..79ea093 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientInstallation.java @@ -0,0 +1,17 @@ +package com.cygnus.cloud.tenant.model; + +import java.time.OffsetDateTime; +import java.util.Set; +import java.util.UUID; + +public record ClientInstallation( + UUID installationId, + UUID tenantId, + String clientId, + String installationCode, + String assertionPublicKey, + Set allowedScopes, + boolean enabled, + int securityVersion, + OffsetDateTime lastAuthenticatedAt) { +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientLicense.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientLicense.java new file mode 100644 index 0000000..b6306dd --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientLicense.java @@ -0,0 +1,24 @@ +package com.cygnus.cloud.tenant.model; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record ClientLicense( + UUID licenseId, + UUID tenantId, + String licenseType, + String packageCode, + Instant validFrom, + Instant validUntil, + LicenseStatus status, + Integer maxUsers, + Integer maxInstallations, + OffsetDateTime updatedAt) { + + public boolean isActiveAt(Instant instant) { + return status == LicenseStatus.ACTIVE + && !instant.isBefore(validFrom) + && instant.isBefore(validUntil); + } +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientStatus.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientStatus.java new file mode 100644 index 0000000..bdac3e0 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientStatus.java @@ -0,0 +1,7 @@ +package com.cygnus.cloud.tenant.model; + +public enum ClientStatus { + ACTIVE, + SUSPENDED, + CANCELLED +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/LicenseStatus.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/LicenseStatus.java new file mode 100644 index 0000000..f01c42f --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/LicenseStatus.java @@ -0,0 +1,8 @@ +package com.cygnus.cloud.tenant.model; + +public enum LicenseStatus { + ACTIVE, + SUSPENDED, + EXPIRED, + CANCELLED +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/repository/TenantRegistrationRepository.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/repository/TenantRegistrationRepository.java new file mode 100644 index 0000000..9ef1bd3 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/repository/TenantRegistrationRepository.java @@ -0,0 +1,131 @@ +package com.cygnus.cloud.tenant.repository; + +import com.cygnus.cloud.database.ReactiveDatabaseClient; +import com.cygnus.cloud.tenant.model.ClientAccount; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.model.ClientLicense; +import com.cygnus.cloud.tenant.model.ClientStatus; +import com.cygnus.cloud.tenant.model.LicenseStatus; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.Tuple; +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Mono; + +@Repository +public class TenantRegistrationRepository { + + private static final String FIND_ACCOUNT_BY_SLUG = """ + SELECT tenant_id, client_slug, client_name, status, created_at, updated_at + FROM identity.client_account + WHERE client_slug = $1 + """; + + private static final String FIND_INSTALLATION = """ + SELECT installation.installation_id, installation.tenant_id, + installation.client_id, installation.installation_code, + installation.assertion_public_key, installation.allowed_scopes, + installation.enabled, installation.security_version, + installation.last_authenticated_at + FROM identity.client_installation installation + JOIN identity.client_account account + ON account.tenant_id = installation.tenant_id + WHERE installation.client_id = $1 + AND installation.installation_code = $2 + AND installation.enabled = true + AND account.status = 'ACTIVE' + """; + + 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, + updated_at + FROM identity.client_license + WHERE tenant_id = $1 + AND valid_from <= $2 + AND valid_until > $2 + ORDER BY + CASE status + WHEN 'ACTIVE' THEN 0 + WHEN 'SUSPENDED' THEN 1 + ELSE 2 + END, + valid_until DESC + LIMIT 1 + """; + + private final ReactiveDatabaseClient database; + + public TenantRegistrationRepository(ReactiveDatabaseClient database) { + this.database = database; + } + + public Mono findAccountBySlug(String clientSlug) { + return database.preparedQuery(FIND_ACCOUNT_BY_SLUG, Tuple.of(clientSlug)) + .flatMap(rows -> first(rows, this::account)); + } + + public Mono findInstallation( + String clientId, String installationCode) { + return database.preparedQuery( + FIND_INSTALLATION, Tuple.of(clientId, installationCode)) + .flatMap(rows -> first(rows, this::installation)); + } + + public Mono findCurrentLicense(UUID tenantId, Instant instant) { + return database.preparedQuery( + FIND_CURRENT_LICENSE, Tuple.of(tenantId, instant.atOffset(java.time.ZoneOffset.UTC))) + .flatMap(rows -> first(rows, this::license)); + } + + private Mono first( + Iterable rows, java.util.function.Function mapper) { + java.util.Iterator iterator = rows.iterator(); + return iterator.hasNext() ? Mono.just(mapper.apply(iterator.next())) : Mono.empty(); + } + + private ClientAccount account(Row row) { + return new ClientAccount( + row.getUUID("tenant_id"), + row.getString("client_slug"), + row.getString("client_name"), + ClientStatus.valueOf(row.getString("status")), + row.getOffsetDateTime("created_at"), + row.getOffsetDateTime("updated_at")); + } + + private ClientInstallation installation(Row row) { + String[] scopes = row.getArrayOfStrings("allowed_scopes"); + return new ClientInstallation( + row.getUUID("installation_id"), + row.getUUID("tenant_id"), + row.getString("client_id"), + row.getString("installation_code"), + row.getString("assertion_public_key"), + scopes == null + ? Set.of() + : java.util.Collections.unmodifiableSet( + new LinkedHashSet<>(Arrays.asList(scopes))), + row.getBoolean("enabled"), + row.getInteger("security_version"), + row.getOffsetDateTime("last_authenticated_at")); + } + + private ClientLicense license(Row row) { + return new ClientLicense( + row.getUUID("license_id"), + row.getUUID("tenant_id"), + row.getString("license_type"), + row.getString("package_code"), + row.getOffsetDateTime("valid_from").toInstant(), + row.getOffsetDateTime("valid_until").toInstant(), + LicenseStatus.valueOf(row.getString("status")), + row.getInteger("max_users"), + row.getInteger("max_installations"), + row.getOffsetDateTime("updated_at")); + } +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/service/TenantRegistrationService.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/service/TenantRegistrationService.java new file mode 100644 index 0000000..8333586 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/service/TenantRegistrationService.java @@ -0,0 +1,201 @@ +package com.cygnus.cloud.tenant.service; + +import com.cygnus.cloud.cache.CacheProperties; +import com.cygnus.cloud.cache.ReactiveCacheService; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.model.ClientLicense; +import com.cygnus.cloud.tenant.model.LicenseStatus; +import com.cygnus.cloud.tenant.repository.TenantRegistrationRepository; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +public class TenantRegistrationService { + + private static final String INSTALLATION_CACHE = "tenant-installation"; + private static final String LICENSE_CACHE = "tenant-license"; + + private final TenantRegistrationRepository repository; + private final ReactiveCacheService cache; + private final CacheProperties cacheProperties; + private final Clock clock; + + public TenantRegistrationService( + TenantRegistrationRepository repository, + ReactiveCacheService cache, + CacheProperties cacheProperties, + Clock clock) { + this.repository = repository; + this.cache = cache; + this.cacheProperties = cacheProperties; + this.clock = clock; + } + + public Mono findInstallation( + String clientId, String installationCode) { + String key = encoded(clientId) + ':' + encoded(installationCode); + return cache.get(INSTALLATION_CACHE, key) + .flatMap(value -> decodeInstallationSafely(value)) + .onErrorResume(exception -> Mono.empty()) + .switchIfEmpty(repository.findInstallation(clientId, installationCode) + .flatMap(installation -> cache + .put( + INSTALLATION_CACHE, + key, + encode(installation)) + .onErrorResume(exception -> Mono.just(false)) + .thenReturn(installation))); + } + + public Mono findCurrentLicense(UUID tenantId, Instant instant) { + String key = tenantId.toString(); + return cache.get(LICENSE_CACHE, key) + .flatMap(value -> decodeLicenseSafely(value)) + .filter(license -> license.isActiveAt(instant)) + .onErrorResume(exception -> Mono.empty()) + .switchIfEmpty(repository.findCurrentLicense(tenantId, instant) + .filter(license -> license.isActiveAt(instant)) + .flatMap(license -> cache + .put( + LICENSE_CACHE, + key, + encode(license), + licenseTtl(license)) + .onErrorResume(exception -> Mono.just(false)) + .thenReturn(license))); + } + + public Mono evictInstallation( + String clientId, String installationCode) { + return cache.evict( + INSTALLATION_CACHE, + encoded(clientId) + ':' + encoded(installationCode)) + .onErrorReturn(false); + } + + public Mono evictLicense(UUID tenantId) { + return cache.evict(LICENSE_CACHE, tenantId.toString()) + .onErrorReturn(false); + } + + private Duration licenseTtl(ClientLicense license) { + Duration remaining = Duration.between(clock.instant(), license.validUntil()); + if (remaining.isNegative() || remaining.isZero()) { + return Duration.ofSeconds(1); + } + return remaining.compareTo(cacheProperties.defaultTtl()) < 0 + ? remaining + : cacheProperties.defaultTtl(); + } + + private String encode(ClientInstallation installation) { + String scopes = installation.allowedScopes().stream() + .sorted() + .map(this::encoded) + .reduce((left, right) -> left + "," + right) + .orElse(""); + return String.join( + "|", + installation.installationId().toString(), + installation.tenantId().toString(), + encoded(installation.clientId()), + encoded(installation.installationCode()), + encoded(installation.assertionPublicKey()), + scopes, + Boolean.toString(installation.enabled()), + Integer.toString(installation.securityVersion()), + installation.lastAuthenticatedAt() == null + ? "" + : installation.lastAuthenticatedAt().toString()); + } + + private Mono decodeInstallationSafely(String value) { + try { + String[] fields = value.split("\\|", -1); + if (fields.length != 9) { + return Mono.empty(); + } + Set scopes = fields[5].isBlank() + ? Set.of() + : Arrays.stream(fields[5].split(",")) + .map(this::decoded) + .collect(java.util.stream.Collectors.toCollection( + LinkedHashSet::new)); + return Mono.just(new ClientInstallation( + UUID.fromString(fields[0]), + UUID.fromString(fields[1]), + decoded(fields[2]), + decoded(fields[3]), + decoded(fields[4]), + Set.copyOf(scopes), + Boolean.parseBoolean(fields[6]), + Integer.parseInt(fields[7]), + fields[8].isBlank() + ? null + : OffsetDateTime.parse(fields[8]))); + } catch (RuntimeException exception) { + return Mono.empty(); + } + } + + private String encode(ClientLicense license) { + return String.join( + "|", + license.licenseId().toString(), + license.tenantId().toString(), + encoded(license.licenseType()), + encoded(license.packageCode()), + license.validFrom().toString(), + license.validUntil().toString(), + license.status().name(), + license.maxUsers() == null ? "" : license.maxUsers().toString(), + license.maxInstallations() == null + ? "" + : license.maxInstallations().toString(), + license.updatedAt().toString()); + } + + private Mono decodeLicenseSafely(String value) { + try { + String[] fields = value.split("\\|", -1); + if (fields.length != 10) { + return Mono.empty(); + } + return Mono.just(new ClientLicense( + UUID.fromString(fields[0]), + UUID.fromString(fields[1]), + decoded(fields[2]), + decoded(fields[3]), + Instant.parse(fields[4]), + Instant.parse(fields[5]), + LicenseStatus.valueOf(fields[6]), + fields[7].isBlank() ? null : Integer.valueOf(fields[7]), + fields[8].isBlank() ? null : Integer.valueOf(fields[8]), + OffsetDateTime.parse(fields[9]))); + } catch (RuntimeException exception) { + return Mono.empty(); + } + } + + private String encoded(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private String decoded(String value) { + return new String( + Base64.getUrlDecoder().decode(value), + StandardCharsets.UTF_8); + } +} diff --git a/cygnus-cloud-service/src/main/resources/application.yml b/cygnus-cloud-service/src/main/resources/application.yml index 0225ebc..0b4dac5 100644 --- a/cygnus-cloud-service/src/main/resources/application.yml +++ b/cygnus-cloud-service/src/main/resources/application.yml @@ -36,7 +36,6 @@ cygnus: assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem} access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem} access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem} - clients: {} login-encryption: key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01} private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem} diff --git a/cygnus-cloud-service/src/main/resources/db/identity/002_multitenant_licensing.sql b/cygnus-cloud-service/src/main/resources/db/identity/002_multitenant_licensing.sql new file mode 100644 index 0000000..99d7db4 --- /dev/null +++ b/cygnus-cloud-service/src/main/resources/db/identity/002_multitenant_licensing.sql @@ -0,0 +1,153 @@ +BEGIN; + +-- DMLP phase 2: introduce the tenant, installation and license source of truth. +-- The fixed legacy tenant is a migration bridge for the identity data copied by +-- 001_identity_login_schema.sql. Replace its migration license through the +-- administration workflow before commercial enforcement is enabled. + +CREATE TABLE IF NOT EXISTS identity.client_account ( + tenant_id uuid PRIMARY KEY, + client_slug character varying(80) NOT NULL, + client_name character varying(200) NOT NULL, + status character varying(20) NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_identity_client_account_slug UNIQUE (client_slug), + CONSTRAINT ck_identity_client_account_slug + CHECK (client_slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'), + CONSTRAINT ck_identity_client_account_status + CHECK (status IN ('ACTIVE', 'SUSPENDED', 'CANCELLED')) +); + +CREATE TABLE IF NOT EXISTS identity.client_installation ( + installation_id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + client_id character varying(80) NOT NULL, + installation_code character varying(100) NOT NULL, + assertion_public_key text NOT NULL, + allowed_scopes text[] NOT NULL DEFAULT ARRAY[]::text[], + enabled boolean NOT NULL DEFAULT true, + security_version integer NOT NULL DEFAULT 1, + last_authenticated_at timestamp with time zone, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_identity_installation_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id), + CONSTRAINT uq_identity_installation_client_code + UNIQUE (client_id, installation_code), + CONSTRAINT ck_identity_installation_client_id + CHECK (client_id ~ '^[A-Za-z0-9][A-Za-z0-9_-]*$'), + CONSTRAINT ck_identity_installation_code + CHECK (installation_code ~ '^[A-Za-z0-9][A-Za-z0-9_-]*$'), + CONSTRAINT ck_identity_installation_security_version + CHECK (security_version > 0) +); + +CREATE TABLE IF NOT EXISTS identity.client_license ( + license_id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + license_type character varying(30) NOT NULL, + package_code character varying(50) NOT NULL, + valid_from timestamp with time zone NOT NULL, + valid_until timestamp with time zone NOT NULL, + status character varying(20) NOT NULL, + max_users integer, + max_installations integer, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_identity_license_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id), + CONSTRAINT ck_identity_license_period + CHECK (valid_until > valid_from), + CONSTRAINT ck_identity_license_status + CHECK (status IN ('ACTIVE', 'SUSPENDED', 'EXPIRED', 'CANCELLED')), + CONSTRAINT ck_identity_license_limits + CHECK ((max_users IS NULL OR max_users > 0) + AND (max_installations IS NULL OR max_installations > 0)) +); + +CREATE INDEX IF NOT EXISTS ix_identity_installation_tenant_enabled + ON identity.client_installation (tenant_id, enabled); +CREATE INDEX IF NOT EXISTS ix_identity_installation_lookup + ON identity.client_installation (client_id, installation_code, enabled); +CREATE INDEX IF NOT EXISTS ix_identity_license_tenant_period + ON identity.client_license (tenant_id, status, valid_from, valid_until); + +-- Add tenant ownership without changing any legacy primary-key values. +ALTER TABLE identity.company + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.company_branch + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.user_group + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.app_user + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.permission + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.denied_pages + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.user_loginhistory + ADD COLUMN IF NOT EXISTS tenant_id uuid; + +-- Seed a stable bridge tenant for all identity data that already exists. +INSERT INTO identity.client_account + (tenant_id, client_slug, client_name, status) +VALUES ('00000000-0000-4000-8000-000000000001', 'matrix', 'Matrix', 'ACTIVE') +ON CONFLICT (tenant_id) DO UPDATE SET + client_slug = EXCLUDED.client_slug, + client_name = EXCLUDED.client_name, + updated_at = now(); + +-- A non-expiring migration bridge keeps current users operational. It must be +-- replaced by a commercial license before license administration goes live. +INSERT INTO identity.client_license + (license_id, tenant_id, license_type, package_code, + valid_from, valid_until, status) +VALUES ('00000000-0000-4000-8000-000000000002', + '00000000-0000-4000-8000-000000000001', + 'MIGRATION', 'LEGACY_FULL', + '2020-01-01 00:00:00+00', '2099-12-31 23:59:59+00', 'ACTIVE') +ON CONFLICT (license_id) DO NOTHING; + +UPDATE identity.company + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.company_branch + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.user_group + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.app_user + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.permission + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.denied_pages + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.user_loginhistory + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; + +CREATE INDEX IF NOT EXISTS ix_identity_company_tenant + ON identity.company (tenant_id, company_id); +CREATE INDEX IF NOT EXISTS ix_identity_branch_tenant + ON identity.company_branch (tenant_id, company_id, branch_id); +CREATE INDEX IF NOT EXISTS ix_identity_group_tenant + ON identity.user_group (tenant_id, group_id); +CREATE INDEX IF NOT EXISTS ix_identity_user_tenant_login + ON identity.app_user (tenant_id, upper(loginid)); +CREATE INDEX IF NOT EXISTS ix_identity_permission_tenant_group_page + ON identity.permission (tenant_id, group_id, page_id) + WHERE permission <> '000'; +CREATE INDEX IF NOT EXISTS ix_identity_denied_tenant_user_page + ON identity.denied_pages (tenant_id, user_id, page_id) + WHERE isdenied = 1; +CREATE INDEX IF NOT EXISTS ix_identity_login_history_tenant_user_time + ON identity.user_loginhistory (tenant_id, user_id, logintime DESC); + +COMMIT; diff --git a/cygnus-cloud-service/src/main/resources/db/identity/003_tenant_isolation_constraints.sql b/cygnus-cloud-service/src/main/resources/db/identity/003_tenant_isolation_constraints.sql new file mode 100644 index 0000000..8bf85f5 --- /dev/null +++ b/cygnus-cloud-service/src/main/resources/db/identity/003_tenant_isolation_constraints.sql @@ -0,0 +1,231 @@ +BEGIN; + +-- DMLP phase 3: make tenant ownership mandatory and enforce same-tenant +-- relationships without changing any legacy primary keys. + +ALTER TABLE identity.company + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.company_branch + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.user_group + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.app_user + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.permission + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.denied_pages + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.user_loginhistory + ALTER COLUMN tenant_id SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_company_tenant_id + ON identity.company (tenant_id, company_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_branch_tenant_id + ON identity.company_branch (tenant_id, branch_id, company_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_group_tenant_id + ON identity.user_group (tenant_id, group_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_user_tenant_id + ON identity.app_user (tenant_id, user_id); + +DO $migration$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_company_tenant' + AND conrelid = 'identity.company'::regclass) THEN + ALTER TABLE identity.company + ADD CONSTRAINT fk_identity_company_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_branch_tenant' + AND conrelid = 'identity.company_branch'::regclass) THEN + ALTER TABLE identity.company_branch + ADD CONSTRAINT fk_identity_branch_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_branch_company_tenant' + AND conrelid = 'identity.company_branch'::regclass) THEN + ALTER TABLE identity.company_branch + ADD CONSTRAINT fk_identity_branch_company_tenant + FOREIGN KEY (tenant_id, company_id) + REFERENCES identity.company (tenant_id, company_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_group_tenant' + AND conrelid = 'identity.user_group'::regclass) THEN + ALTER TABLE identity.user_group + ADD CONSTRAINT fk_identity_group_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_user_tenant' + AND conrelid = 'identity.app_user'::regclass) THEN + ALTER TABLE identity.app_user + ADD CONSTRAINT fk_identity_user_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_user_group_tenant' + AND conrelid = 'identity.app_user'::regclass) THEN + ALTER TABLE identity.app_user + ADD CONSTRAINT fk_identity_user_group_tenant + FOREIGN KEY (tenant_id, group_id) + REFERENCES identity.user_group (tenant_id, group_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_user_company_tenant' + AND conrelid = 'identity.app_user'::regclass) THEN + ALTER TABLE identity.app_user + ADD CONSTRAINT fk_identity_user_company_tenant + FOREIGN KEY (tenant_id, company_id) + REFERENCES identity.company (tenant_id, company_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_user_branch_tenant' + AND conrelid = 'identity.app_user'::regclass) THEN + ALTER TABLE identity.app_user + ADD CONSTRAINT fk_identity_user_branch_tenant + FOREIGN KEY (tenant_id, branch_id, company_id) + REFERENCES identity.company_branch (tenant_id, branch_id, company_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_permission_tenant' + AND conrelid = 'identity.permission'::regclass) THEN + ALTER TABLE identity.permission + ADD CONSTRAINT fk_identity_permission_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_permission_group_tenant' + AND conrelid = 'identity.permission'::regclass) THEN + ALTER TABLE identity.permission + ADD CONSTRAINT fk_identity_permission_group_tenant + FOREIGN KEY (tenant_id, group_id) + REFERENCES identity.user_group (tenant_id, group_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_permission_page' + AND conrelid = 'identity.permission'::regclass) THEN + ALTER TABLE identity.permission + ADD CONSTRAINT fk_identity_permission_page + FOREIGN KEY (page_id) + REFERENCES identity.pages (page_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_denied_tenant' + AND conrelid = 'identity.denied_pages'::regclass) THEN + ALTER TABLE identity.denied_pages + ADD CONSTRAINT fk_identity_denied_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_denied_user_tenant' + AND conrelid = 'identity.denied_pages'::regclass) THEN + ALTER TABLE identity.denied_pages + ADD CONSTRAINT fk_identity_denied_user_tenant + FOREIGN KEY (tenant_id, user_id) + REFERENCES identity.app_user (tenant_id, user_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_denied_page' + AND conrelid = 'identity.denied_pages'::regclass) THEN + ALTER TABLE identity.denied_pages + ADD CONSTRAINT fk_identity_denied_page + FOREIGN KEY (page_id) + REFERENCES identity.pages (page_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_login_history_tenant' + AND conrelid = 'identity.user_loginhistory'::regclass) THEN + ALTER TABLE identity.user_loginhistory + ADD CONSTRAINT fk_identity_login_history_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + +END +$migration$; + +ALTER TABLE identity.company + VALIDATE CONSTRAINT fk_identity_company_tenant; +ALTER TABLE identity.company_branch + VALIDATE CONSTRAINT fk_identity_branch_tenant; +ALTER TABLE identity.company_branch + VALIDATE CONSTRAINT fk_identity_branch_company_tenant; +ALTER TABLE identity.user_group + VALIDATE CONSTRAINT fk_identity_group_tenant; +ALTER TABLE identity.app_user + VALIDATE CONSTRAINT fk_identity_user_tenant; +ALTER TABLE identity.app_user + VALIDATE CONSTRAINT fk_identity_user_group_tenant; +ALTER TABLE identity.app_user + VALIDATE CONSTRAINT fk_identity_user_company_tenant; +ALTER TABLE identity.app_user + VALIDATE CONSTRAINT fk_identity_user_branch_tenant; +ALTER TABLE identity.permission + VALIDATE CONSTRAINT fk_identity_permission_tenant; +ALTER TABLE identity.permission + VALIDATE CONSTRAINT fk_identity_permission_group_tenant; +ALTER TABLE identity.permission + VALIDATE CONSTRAINT fk_identity_permission_page; +ALTER TABLE identity.denied_pages + VALIDATE CONSTRAINT fk_identity_denied_tenant; +ALTER TABLE identity.denied_pages + VALIDATE CONSTRAINT fk_identity_denied_user_tenant; +ALTER TABLE identity.denied_pages + VALIDATE CONSTRAINT fk_identity_denied_page; +ALTER TABLE identity.user_loginhistory + VALIDATE CONSTRAINT fk_identity_login_history_tenant; + +COMMIT; diff --git a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/identity/service/IdentityAuthenticationServiceTest.java b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/identity/service/IdentityAuthenticationServiceTest.java index a0f030b..ee9e44f 100644 --- a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/identity/service/IdentityAuthenticationServiceTest.java +++ b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/identity/service/IdentityAuthenticationServiceTest.java @@ -11,6 +11,7 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -23,6 +24,8 @@ import reactor.test.StepVerifier; class IdentityAuthenticationServiceTest { private static final Instant LOGIN_TIME = Instant.parse("2026-07-23T06:30:00Z"); + private static final UUID TENANT_ID = + UUID.fromString("00000000-0000-4000-8000-000000000001"); @Mock private IdentityRepository repository; @@ -36,11 +39,17 @@ class IdentityAuthenticationServiceTest { MenuItem menuItem = new MenuItem( (short) 10, "Operations", "/ver/operations", (short) 0, (short) 1, "110", "_parent", null); - when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user)); + when(repository.findUsersByLoginId(TENANT_ID, "maddy")) + .thenReturn(Flux.just(user)); when(passwordVerifier.matches("secret", "legacy-value")).thenReturn(true); - when(repository.findMenu((short) 4, (short) 25)) + when(repository.findMenu(TENANT_ID, (short) 4, (short) 25)) .thenReturn(Mono.just(List.of(menuItem))); - when(repository.recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25)) + when(repository.recordLogin( + TENANT_ID, + "maddy", + LOGIN_TIME, + "127.0.0.1", + (short) 25)) .thenReturn(Mono.just(101L)); IdentityAuthenticationService service = new IdentityAuthenticationService( @@ -48,7 +57,8 @@ class IdentityAuthenticationServiceTest { passwordVerifier, Clock.fixed(LOGIN_TIME, ZoneOffset.UTC)); - StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1")) + StepVerifier.create(service.authenticate( + TENANT_ID, "maddy", "secret", "127.0.0.1")) .assertNext(result -> { assertThat(result.loginId()).isEqualTo("maddy"); assertThat(result.companyName()).isEqualTo("Matrix"); @@ -57,20 +67,27 @@ class IdentityAuthenticationServiceTest { }) .verifyComplete(); - verify(repository).recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25); + verify(repository).recordLogin( + TENANT_ID, + "maddy", + LOGIN_TIME, + "127.0.0.1", + (short) 25); } @Test void rejectsInactiveUserWithoutLoadingMenu() { IdentityUser user = user("maddy", false); - when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user)); + when(repository.findUsersByLoginId(TENANT_ID, "maddy")) + .thenReturn(Flux.just(user)); IdentityAuthenticationService service = new IdentityAuthenticationService( repository, passwordVerifier, Clock.fixed(LOGIN_TIME, ZoneOffset.UTC)); - StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1")) + StepVerifier.create(service.authenticate( + TENANT_ID, "maddy", "secret", "127.0.0.1")) .expectError(AuthenticationException.class) .verify(); } diff --git a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/security/MachineTokenFlowTest.java b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/security/MachineTokenFlowTest.java index 3726347..24040da 100644 --- a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/security/MachineTokenFlowTest.java +++ b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/security/MachineTokenFlowTest.java @@ -2,7 +2,13 @@ package com.cygnus.cloud.security; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.model.ClientLicense; +import com.cygnus.cloud.tenant.model.LicenseStatus; +import com.cygnus.cloud.tenant.service.TenantRegistrationService; import com.nimbusds.jose.EncryptionMethod; import com.nimbusds.jose.JWEAlgorithm; import com.nimbusds.jose.JWEHeader; @@ -26,10 +32,12 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.time.OffsetDateTime; import java.util.Base64; import java.util.Date; import java.util.Map; import java.util.Set; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -37,6 +45,7 @@ import org.springframework.http.MediaType; import org.springframework.util.LinkedMultiValueMap; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.BodyInserters; +import reactor.core.publisher.Mono; class MachineTokenFlowTest { @@ -53,6 +62,9 @@ class MachineTokenFlowTest { private KeyPair clientSigningKeys; private KeyPair accessTokenKeys; private CommunicationSecurityProperties properties; + private TenantRegistrationService registrations; + private ClientInstallation installation; + private ClientLicense license; @BeforeEach void setUp() throws Exception { @@ -69,14 +81,33 @@ class MachineTokenFlowTest { Duration.ofMinutes(20), privatePem("assertion-private.pem", assertionEncryptionKeys), privatePem("access-private.pem", accessTokenKeys), - publicPem("access-public.pem", accessTokenKeys), - Map.of( - CLIENT_ID, - new CommunicationSecurityProperties.MachineClient( - true, - INSTALLATION_ID, - publicPem("client-public.pem", clientSigningKeys), - Set.of("identity.login")))); + publicPem("access-public.pem", accessTokenKeys)); + registrations = mock(TenantRegistrationService.class); + installation = new ClientInstallation( + UUID.fromString("10000000-0000-4000-8000-000000000001"), + UUID.fromString("00000000-0000-4000-8000-000000000001"), + CLIENT_ID, + INSTALLATION_ID, + publicPem("client-public.pem", clientSigningKeys), + Set.of("identity.login"), + true, + 1, + OffsetDateTime.now(ZoneOffset.UTC)); + when(registrations.findInstallation(CLIENT_ID, INSTALLATION_ID)) + .thenReturn(Mono.just(installation)); + license = new ClientLicense( + UUID.fromString("20000000-0000-4000-8000-000000000001"), + installation.tenantId(), + "ANNUAL", + "FULL", + NOW.minus(Duration.ofDays(1)), + NOW.plus(Duration.ofDays(365)), + LicenseStatus.ACTIVE, + 120, + 2, + OffsetDateTime.now(ZoneOffset.UTC)); + when(registrations.findCurrentLicense(installation.tenantId(), NOW)) + .thenReturn(Mono.just(license)); } @Test @@ -84,7 +115,7 @@ class MachineTokenFlowTest { throws Exception { Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); ClientAssertionValidator validator = - new ClientAssertionValidator(properties, clock); + new ClientAssertionValidator(properties, registrations, clock); AccessTokenIssuer issuer = new AccessTokenIssuer(properties, clock); MachineTokenController controller = new MachineTokenController(validator, issuer); @@ -98,7 +129,7 @@ class MachineTokenFlowTest { form.add("client_assertion", encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365)))); form.add("scope", "identity.login"); - Map response = controller.issueToken(form); + Map response = controller.issueToken(form).block(); SignedJWT token = SignedJWT.parse((String) response.get("access_token")); assertThat(token.verify(new RSASSAVerifier( @@ -108,6 +139,12 @@ class MachineTokenFlowTest { .isEqualTo(CLIENT_ID); assertThat(token.getJWTClaimsSet().getStringClaim("installation_id")) .isEqualTo(INSTALLATION_ID); + assertThat(token.getJWTClaimsSet().getStringClaim("tenant_id")) + .isEqualTo(installation.tenantId().toString()); + assertThat(token.getJWTClaimsSet().getStringClaim("license_id")) + .isEqualTo(license.licenseId().toString()); + assertThat(token.getJWTClaimsSet().getStringClaim("package_code")) + .isEqualTo("FULL"); assertThat(token.getJWTClaimsSet().getStringClaim("scope")) .isEqualTo("identity.login"); assertThat(response) @@ -119,21 +156,36 @@ class MachineTokenFlowTest { @Test void rejectsExpiredAssertion() throws Exception { ClientAssertionValidator validator = new ClientAssertionValidator( - properties, Clock.fixed(NOW, ZoneOffset.UTC)); + properties, registrations, Clock.fixed(NOW, ZoneOffset.UTC)); assertThatThrownBy(() -> validator.validate( CLIENT_ID, encryptedAssertion( NOW.minus(Duration.ofDays(366)), - NOW.minusSeconds(1)))) + NOW.minusSeconds(1))).block()) .isInstanceOf(MachineAuthenticationException.class); } + @Test + void rejectsClientWithoutAnActiveLicense() throws Exception { + when(registrations.findCurrentLicense(installation.tenantId(), NOW)) + .thenReturn(Mono.empty()); + ClientAssertionValidator validator = new ClientAssertionValidator( + properties, registrations, Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy(() -> validator.validate( + CLIENT_ID, + encryptedAssertion(NOW, NOW.plus(Duration.ofDays(1)))) + .block()) + .isInstanceOf(MachineAuthenticationException.class) + .hasMessage("Client license is not active"); + } + @Test void acceptsOAuthFormEncodedTokenRequestOverHttp() throws Exception { Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); MachineTokenController controller = new MachineTokenController( - new ClientAssertionValidator(properties, clock), + new ClientAssertionValidator(properties, registrations, clock), new AccessTokenIssuer(properties, clock)); LinkedMultiValueMap form = new LinkedMultiValueMap<>(); form.add("grant_type", "client_credentials"); diff --git a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/model/ClientLicenseTest.java b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/model/ClientLicenseTest.java new file mode 100644 index 0000000..d6a179c --- /dev/null +++ b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/model/ClientLicenseTest.java @@ -0,0 +1,45 @@ +package com.cygnus.cloud.tenant.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ClientLicenseTest { + + private static final Instant START = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant END = Instant.parse("2027-01-01T00:00:00Z"); + + @Test + void activeLicenseIsValidOnlyInsideItsConfiguredPeriod() { + ClientLicense license = license(LicenseStatus.ACTIVE); + + assertThat(license.isActiveAt(START)).isTrue(); + assertThat(license.isActiveAt(END.minusSeconds(1))).isTrue(); + assertThat(license.isActiveAt(END)).isFalse(); + assertThat(license.isActiveAt(START.minusSeconds(1))).isFalse(); + } + + @Test + void suspendedLicenseIsNeverActive() { + assertThat(license(LicenseStatus.SUSPENDED) + .isActiveAt(Instant.parse("2026-07-01T00:00:00Z"))) + .isFalse(); + } + + private ClientLicense license(LicenseStatus status) { + return new ClientLicense( + UUID.randomUUID(), + UUID.randomUUID(), + "YEARLY", + "PROFESSIONAL", + START, + END, + status, + 120, + 3, + OffsetDateTime.parse("2026-01-01T00:00:00Z")); + } +} diff --git a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/service/TenantRegistrationServiceTest.java b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/service/TenantRegistrationServiceTest.java new file mode 100644 index 0000000..72fd224 --- /dev/null +++ b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/service/TenantRegistrationServiceTest.java @@ -0,0 +1,57 @@ +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.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.cygnus.cloud.cache.CacheProperties; +import com.cygnus.cloud.cache.ReactiveCacheService; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.repository.TenantRegistrationRepository; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class TenantRegistrationServiceTest { + + @Test + void fallsBackToDatabaseWhenRedisIsUnavailable() { + TenantRegistrationRepository repository = + mock(TenantRegistrationRepository.class); + ReactiveCacheService cache = mock(ReactiveCacheService.class); + ClientInstallation installation = new ClientInstallation( + UUID.randomUUID(), + UUID.randomUUID(), + "matrix", + "site-01", + "public-key", + Set.of("identity.login"), + true, + 1, + null); + when(cache.get(anyString(), anyString())) + .thenReturn(Mono.error(new IllegalStateException("redis unavailable"))); + when(cache.put(anyString(), anyString(), anyString())) + .thenReturn(Mono.error(new IllegalStateException("redis unavailable"))); + when(repository.findInstallation("matrix", "site-01")) + .thenReturn(Mono.just(installation)); + + TenantRegistrationService service = new TenantRegistrationService( + repository, + cache, + new CacheProperties("cygnus", Duration.ofMinutes(10)), + Clock.fixed(Instant.parse("2026-07-26T10:00:00Z"), ZoneOffset.UTC)); + + StepVerifier.create(service.findInstallation("matrix", "site-01")) + .assertNext(result -> assertThat(result).isEqualTo(installation)) + .verifyComplete(); + } +} diff --git a/cygnus-onprem-app/src/test/java/matrix/nimble/SpringWebContextTest.java b/cygnus-onprem-app/src/test/java/matrix/nimble/SpringWebContextTest.java index b2a67d4..105926b 100644 --- a/cygnus-onprem-app/src/test/java/matrix/nimble/SpringWebContextTest.java +++ b/cygnus-onprem-app/src/test/java/matrix/nimble/SpringWebContextTest.java @@ -15,9 +15,19 @@ class SpringWebContextTest { @Test void applicationContextLoadsControllers() { - try (FileSystemXmlApplicationContext context = - new FileSystemXmlApplicationContext("build/WebContent/WEB-INF/app-config.xml")) { - assertFalse(context.getBeansWithAnnotation(Controller.class).isEmpty()); + System.setProperty("CYGNUS_CLIENT_ID", "test-client"); + System.setProperty("CYGNUS_INSTALLATION_ID", "test-installation"); + System.setProperty("CYGNUS_CLIENT_ASSERTION", "test-assertion"); + try { + try (FileSystemXmlApplicationContext context = + new FileSystemXmlApplicationContext( + "build/WebContent/WEB-INF/app-config.xml")) { + assertFalse(context.getBeansWithAnnotation(Controller.class).isEmpty()); + } + } finally { + System.clearProperty("CYGNUS_CLIENT_ID"); + System.clearProperty("CYGNUS_INSTALLATION_ID"); + System.clearProperty("CYGNUS_CLIENT_ASSERTION"); } } diff --git a/scripts/setup-local-communication.sh b/scripts/setup-local-communication.sh index 3d36c96..2a4d143 100644 --- a/scripts/setup-local-communication.sh +++ b/scripts/setup-local-communication.sh @@ -13,10 +13,19 @@ INSTALLATION_ID="" CLOUD_BASE_URL="" TOKEN_URL="" CLIENT_DIR="" -CLIENT_CONFIG="${CONFIG_DIR}/clients.yml" CLIENT_PRIVATE_KEY="" CLIENT_PUBLIC_KEY="" MACHINE_ASSERTION="" +CLIENT_NAME="" +DB_HOST_VALUE="" +DB_PORT_VALUE="" +DB_NAME_VALUE="" +DB_USER_VALUE="" +DB_PASSWORD_VALUE="" +LICENSE_TYPE="" +PACKAGE_CODE="" +LICENSE_MONTHS="" +PSQL_BIN="" SKIP_BUILD_CONFIGURED="${CYGNUS_SETUP_SKIP_BUILD+x}" SKIP_BUILD="${CYGNUS_SETUP_SKIP_BUILD:-false}" FORCE_ASSERTION="${CYGNUS_SETUP_FORCE_ASSERTION:-false}" @@ -77,6 +86,11 @@ validate_identifier() { "${label} must contain no spaces and use only letters, numbers, '-' or '_'." } +validate_client_slug() { + [[ "$1" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || fail \ + "Customer identifier must use lowercase letters, numbers and single hyphens only." +} + collect_client_inputs() { local default_client="${CYGNUS_SETUP_CLIENT_ID:-customer-a}" local default_installation="${CYGNUS_SETUP_INSTALLATION_ID:-site-01}" @@ -86,7 +100,11 @@ collect_client_inputs() { CLIENT_ID="$(prompt_value \ "Customer identifier (no spaces; used in key and directory names)" \ "${default_client}")" - validate_identifier "Customer identifier" "${CLIENT_ID}" + validate_client_slug "${CLIENT_ID}" + CLIENT_NAME="$(prompt_value \ + "Customer display name" \ + "${CYGNUS_SETUP_CLIENT_NAME:-${CLIENT_ID}}")" + [[ -n "${CLIENT_NAME}" ]] || fail "Customer display name is required." INSTALLATION_ID="$(prompt_value \ "Installation identifier (no spaces)" \ @@ -111,11 +129,43 @@ collect_client_inputs() { CLIENT_PUBLIC_KEY="${CLIENT_DIR}/${CLIENT_ID}-signing-public.pem" MACHINE_ASSERTION="${CLIENT_DIR}/${CLIENT_ID}-${INSTALLATION_ID}-assertion.jwt" + DB_HOST_VALUE="$(prompt_value \ + "Cloud PostgreSQL host" "${CYGNUS_SETUP_DB_HOST:-${DB_HOST:-localhost}}")" + DB_PORT_VALUE="$(prompt_value \ + "Cloud PostgreSQL port" "${CYGNUS_SETUP_DB_PORT:-${DB_PORT:-5432}}")" + DB_NAME_VALUE="$(prompt_value \ + "Cloud PostgreSQL database" "${CYGNUS_SETUP_DB_NAME:-${DB_NAME:-matrix}}")" + DB_USER_VALUE="$(prompt_value \ + "Cloud PostgreSQL user" "${CYGNUS_SETUP_DB_USER:-${DB_USER:-postgres}}")" + DB_PASSWORD_VALUE="${CYGNUS_SETUP_DB_PASSWORD:-${DB_PASSWORD:-}}" + if [[ -z "${DB_PASSWORD_VALUE}" ]] && [[ "${NON_INTERACTIVE}" != "true" ]] && [[ -t 0 ]]; then + read -r -s -p "Cloud PostgreSQL password: " DB_PASSWORD_VALUE + printf '\n' + fi + [[ -n "${DB_PASSWORD_VALUE}" ]] || fail \ + "Cloud PostgreSQL password is required through the prompt or CYGNUS_SETUP_DB_PASSWORD." + + LICENSE_TYPE="$(prompt_value \ + "License type" "${CYGNUS_SETUP_LICENSE_TYPE:-ANNUAL}")" + PACKAGE_CODE="$(prompt_value \ + "License package code" "${CYGNUS_SETUP_PACKAGE_CODE:-FULL}")" + LICENSE_MONTHS="$(prompt_value \ + "License validity in months" "${CYGNUS_SETUP_LICENSE_MONTHS:-12}")" + validate_identifier "License type" "${LICENSE_TYPE}" + validate_identifier "Package code" "${PACKAGE_CODE}" + [[ "${LICENSE_MONTHS}" =~ ^[1-9][0-9]*$ ]] || fail \ + "License validity must be a positive whole number of months." + printf '\nProvisioning summary:\n' printf ' Customer: %s\n' "${CLIENT_ID}" + printf ' Customer name: %s\n' "${CLIENT_NAME}" printf ' Installation: %s\n' "${INSTALLATION_ID}" printf ' Cloud URL: %s\n' "${CLOUD_BASE_URL}" printf ' Token audience: %s\n' "${TOKEN_URL}" + printf ' Database: %s@%s:%s/%s\n' \ + "${DB_USER_VALUE}" "${DB_HOST_VALUE}" "${DB_PORT_VALUE}" "${DB_NAME_VALUE}" + printf ' License: %s / %s / %s month(s)\n' \ + "${LICENSE_TYPE}" "${PACKAGE_CODE}" "${LICENSE_MONTHS}" printf ' Verify build: %s\n' "$([[ "${SKIP_BUILD}" == "true" ]] && printf no || printf yes)" if [[ "$(prompt_yes_no "Continue with these values" "true")" != "true" ]]; then @@ -160,44 +210,78 @@ configure_java_21() { "JDK 21 is required. Current java is: $(java -version 2>&1 | head -n 1)" } -write_client_configuration() { - local public_key_uri="file:${CLIENT_PUBLIC_KEY}" - local existing_installation="" +register_client_in_database() { + local client_id_b64 client_name_b64 installation_b64 public_key_b64 + local license_type_b64 package_code_b64 - if [[ ! -f "${CLIENT_CONFIG}" ]]; then - { - printf '%s\n' "cygnus:" - printf '%s\n' " security:" - printf '%s\n' " clients:" - } > "${CLIENT_CONFIG}" - elif grep -Fq " ${CLIENT_ID}:" "${CLIENT_CONFIG}"; then - existing_installation="$(awk \ - -v client=" ${CLIENT_ID}:" \ - '$0 == client { found = 1; next } - found && /installation-id:/ { - sub(/^.*installation-id:[[:space:]]*/, ""); - print; - exit - } - found && /^ [^[:space:]]/ { exit }' \ - "${CLIENT_CONFIG}")" - if [[ "${existing_installation}" != "${INSTALLATION_ID}" ]]; then - fail "Client '${CLIENT_ID}' already uses installation '${existing_installation}' in ${CLIENT_CONFIG}; requested '${INSTALLATION_ID}'. Use the existing installation ID or provision a different customer identifier." - fi - printf 'Keeping existing configuration for client: %s\n' "${CLIENT_ID}" - return - fi + client_id_b64="$(printf '%s' "${CLIENT_ID}" | openssl base64 -A)" + client_name_b64="$(printf '%s' "${CLIENT_NAME}" | openssl base64 -A)" + installation_b64="$(printf '%s' "${INSTALLATION_ID}" | openssl base64 -A)" + public_key_b64="$(openssl base64 -A -in "${CLIENT_PUBLIC_KEY}")" + license_type_b64="$(printf '%s' "${LICENSE_TYPE}" | openssl base64 -A)" + package_code_b64="$(printf '%s' "${PACKAGE_CODE}" | openssl base64 -A)" - { - printf ' %s:\n' "${CLIENT_ID}" - printf '%s\n' " enabled: true" - printf ' installation-id: %s\n' "${INSTALLATION_ID}" - printf ' assertion-public-key: %s\n' "${public_key_uri}" - printf '%s\n' " scopes:" - printf '%s\n' " - identity.login" - } >> "${CLIENT_CONFIG}" - chmod 600 "${CLIENT_CONFIG}" - printf 'Added client %s to: %s\n' "${CLIENT_ID}" "${CLIENT_CONFIG}" + PGPASSWORD="${DB_PASSWORD_VALUE}" "${PSQL_BIN}" \ + -h "${DB_HOST_VALUE}" \ + -p "${DB_PORT_VALUE}" \ + -U "${DB_USER_VALUE}" \ + -d "${DB_NAME_VALUE}" \ + -X -v ON_ERROR_STOP=1 \ + -c " + WITH account AS ( + INSERT INTO identity.client_account + (tenant_id, client_slug, client_name, status) + VALUES ( + gen_random_uuid(), + convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'), + convert_from(decode('${client_name_b64}', 'base64'), 'UTF8'), + 'ACTIVE') + ON CONFLICT (client_slug) DO UPDATE SET + client_name = EXCLUDED.client_name, + status = 'ACTIVE', + updated_at = now() + RETURNING tenant_id + ), installation AS ( + INSERT INTO identity.client_installation + (installation_id, tenant_id, client_id, installation_code, + assertion_public_key, allowed_scopes, enabled) + SELECT + gen_random_uuid(), + tenant_id, + convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'), + convert_from(decode('${installation_b64}', 'base64'), 'UTF8'), + convert_from(decode('${public_key_b64}', 'base64'), 'UTF8'), + ARRAY['identity.login']::text[], + true + FROM account + ON CONFLICT (client_id, installation_code) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + assertion_public_key = EXCLUDED.assertion_public_key, + allowed_scopes = EXCLUDED.allowed_scopes, + enabled = true, + security_version = identity.client_installation.security_version + 1, + updated_at = now() + RETURNING tenant_id + ) + INSERT INTO identity.client_license + (license_id, tenant_id, license_type, package_code, + valid_from, valid_until, status) + SELECT + gen_random_uuid(), + tenant_id, + convert_from(decode('${license_type_b64}', 'base64'), 'UTF8'), + convert_from(decode('${package_code_b64}', 'base64'), 'UTF8'), + now(), + now() + make_interval(months => ${LICENSE_MONTHS}), + 'ACTIVE' + FROM installation + WHERE NOT EXISTS ( + SELECT 1 + FROM identity.client_license existing + WHERE existing.tenant_id = installation.tenant_id + AND existing.status = 'ACTIVE' + AND existing.valid_until > now() + );" } generate_machine_assertion() { @@ -236,6 +320,13 @@ main() { require_command awk require_command openssl require_command mvn + if command -v psql >/dev/null 2>&1; then + PSQL_BIN="$(command -v psql)" + elif [[ -x /Library/PostgreSQL/17/bin/psql ]]; then + PSQL_BIN="/Library/PostgreSQL/17/bin/psql" + else + fail "PostgreSQL psql was not found in PATH or /Library/PostgreSQL/17/bin." + fi configure_java_21 java -version mvn -version @@ -277,8 +368,8 @@ main() { "${CLIENT_PRIVATE_KEY}" \ "${CLIENT_PUBLIC_KEY}" - log "Writing cloud machine-client configuration" - write_client_configuration + log "Registering tenant, installation, and license in the cloud database" + register_client_in_database log "Generating encrypted machine assertion" generate_machine_assertion @@ -288,7 +379,7 @@ main() { "Client ID: ${CLIENT_ID}" \ "Installation ID: ${INSTALLATION_ID}" \ "Token audience: ${TOKEN_URL}" \ - "Client config: ${CLIENT_CONFIG}" \ + "Registration: PostgreSQL identity schema" \ "Assertion file: ${MACHINE_ASSERTION}" \ "Login public key: ${KEY_DIR}/login-public.pem" \ "" \