Multi tenant approach - cleanup done

This commit is contained in:
2026-07-26 16:13:47 +05:30
parent a4daf7e204
commit d6dc33d9b1
30 changed files with 1312 additions and 184 deletions

3
.vscode/launch.json vendored
View File

@@ -29,8 +29,7 @@
"CYGNUS_ACCESS_TOKEN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/access-token-private.pem", "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_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem",
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01", "CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem", "CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem"
"SPRING_CONFIG_ADDITIONAL_LOCATION": "file:${workspaceFolder}/config/clients.yml"
}, },
"shortenCommandLine": "argfile" "shortenCommandLine": "argfile"
}, },

View File

@@ -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 For local development, the repository setup script automates prerequisite
checks, the full Maven verification, directory creation, all three cloud key checks, the full Maven verification, directory creation, all three cloud key
pairs, the installation key pair, `config/clients.yml`, and the encrypted pairs, the installation key pair, database-backed tenant/install registration,
machine assertion: an initial license, and the encrypted machine assertion:
```bash ```bash
./scripts/setup-local-communication.sh ./scripts/setup-local-communication.sh
``` ```
The script interactively asks for the customer identifier, installation The script interactively asks for the customer name and slug, installation
identifier, cloud URL, and whether to run the full verification. Customer and identifier, cloud URL, database connection, license package/type/duration, and
installation identifiers cannot contain spaces; the customer identifier is whether to run the full verification. Customer and installation identifiers
used for its directory and signing-key filenames. New customers are appended cannot contain spaces. The slug is the stable tenant key and is used for its
to `config/clients.yml` without replacing existing customers. 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 It preserves existing private keys and assertions. Set
`CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be `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" client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt"
``` ```
Copy only `client-signing-public.pem` into that customer's cloud-side client The setup script stores `client-signing-public.pem` in the installation record
configuration. Keep the private key and generated assertion on the on-premises used by the cloud. Keep the private key and generated assertion only on the
server with owner-only filesystem permissions. on-premises server with owner-only filesystem permissions.

View File

@@ -53,10 +53,10 @@ on-premises gateway. The client assertion must be:
- bound to the configured client ID, installation ID, and token audience; - bound to the configured client ID, installation ID, and token audience;
- unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`. - unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`.
The endpoint returns a short-lived RS256 access token carrying `client_id`, The endpoint returns a short-lived RS256 access token carrying the client,
`installation_id`, and the approved scope. The identity endpoint requires the installation, tenant, license, security-version, and approved-scope claims.
`identity.login` scope and verifies the same machine binding in the encrypted The identity endpoint requires the `identity.login` scope and verifies the
login payload. same machine and tenant binding in the encrypted login payload.
Generate separate cloud key pairs: 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 chmod 600 config/keys/*private.pem
``` ```
Configure clients in an external Spring YAML file rather than the packaged ## Dynamic tenant, installation, and license registration
`application.yml`:
```yaml Machine clients are no longer configured in a runtime `clients.yml`. The
cygnus: authoritative records are:
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
```
Start with that protected file using - `identity.client_account`: tenant identity and status;
`--spring.config.additional-location=file:/secure/cygnus/clients.yml`. - `identity.client_installation`: machine identity, assertion public key,
Never place cloud private keys, customer assertions, or installation private allowed scopes, enabled state, and security version;
keys in the repository or container image. - `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.

View File

@@ -7,6 +7,7 @@ import com.cygnus.cloud.identity.service.LoginRequestReplayService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.util.UUID;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -64,12 +65,21 @@ public class CloudLoginController {
return Mono.error(new AuthenticationException("Login request replayed")); return Mono.error(new AuthenticationException("Login request replayed"));
} }
return authenticationService.authenticate( return authenticationService.authenticate(
tenantId(machineJwt),
payload.loginId(), payload.loginId(),
payload.password(), payload.password(),
remoteAddress(serverRequest)); 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) { private void validatePayload(LoginPayload payload) {
if (payload == null if (payload == null
|| !StringUtils.hasText(payload.loginId()) || !StringUtils.hasText(payload.loginId())

View File

@@ -8,6 +8,7 @@ import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List; import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; 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, g.name AS group_name, u.branch_id, b.branchname, b.branchcode, b.city,
u.company_id, c.companyname, c.companycode, u.isactive u.company_id, c.companyname, c.companycode, u.isactive
FROM identity.app_user u FROM identity.app_user u
JOIN identity.user_group g ON g.group_id = u.group_id JOIN identity.user_group g
JOIN identity.company c ON c.company_id = u.company_id 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 JOIN identity.company_branch b
ON b.branch_id = u.branch_id AND b.company_id = u.company_id ON b.tenant_id = u.tenant_id
WHERE upper(u.loginid) = upper($1) 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 = """ private static final String FIND_MENU = """
SELECT p.page_id, p.menulabel, p.targeturl, p.parentpage, p.pageorder, SELECT p.page_id, p.menulabel, p.targeturl, p.parentpage, p.pageorder,
permissions.permission, p.targetwindow, permissions.requestval 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 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 p.isvisible = 1
AND permissions.permission <> '000' AND permissions.permission <> '000'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 SELECT 1
FROM identity.denied_pages denied 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.page_id = permissions.page_id
AND denied.isdenied = 1 AND denied.isdenied = 1
) )
@@ -47,8 +55,8 @@ public class IdentityRepository {
private static final String RECORD_LOGIN = """ private static final String RECORD_LOGIN = """
INSERT INTO identity.user_loginhistory INSERT INTO identity.user_loginhistory
(loginid, logintime, ipaddr, user_id) (tenant_id, loginid, logintime, ipaddr, user_id)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
RETURNING uid RETURNING uid
"""; """;
@@ -60,23 +68,34 @@ public class IdentityRepository {
this.mapper = mapper; this.mapper = mapper;
} }
public Flux<IdentityUser> findUsersByLoginId(String loginId) { public Flux<IdentityUser> findUsersByLoginId(UUID tenantId, String loginId) {
return database.preparedQuery(FIND_USER, Tuple.of(loginId)) return database.preparedQuery(FIND_USER, Tuple.of(tenantId, loginId))
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user)); .flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user));
} }
public Mono<List<MenuItem>> findMenu(short groupId, short userId) { public Mono<List<MenuItem>> findMenu(
return database.preparedQuery(FIND_MENU, Tuple.of(groupId, userId)) UUID tenantId, short groupId, short userId) {
return database.preparedQuery(
FIND_MENU, Tuple.of(tenantId, groupId, userId))
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::menuItem)) .flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::menuItem))
.collectList(); .collectList();
} }
public Mono<Long> recordLogin( public Mono<Long> 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); LocalDateTime databaseTime = LocalDateTime.ofInstant(loginTime, ZoneOffset.UTC);
return database.preparedQuery( return database.preparedQuery(
RECORD_LOGIN, RECORD_LOGIN,
Tuple.of(loginId, databaseTime, remoteAddress, userId)) Tuple.of(
tenantId,
loginId,
databaseTime,
remoteAddress,
userId))
.map(rows -> rows.iterator().next().getLong("uid")); .map(rows -> rows.iterator().next().getLong("uid"));
} }
} }

View File

@@ -5,6 +5,7 @@ import com.cygnus.cloud.identity.model.IdentityUser;
import com.cygnus.cloud.identity.repository.IdentityRepository; import com.cygnus.cloud.identity.repository.IdentityRepository;
import java.time.Clock; import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.UUID;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -25,8 +26,11 @@ public class IdentityAuthenticationService {
} }
public Mono<AuthenticatedIdentity> authenticate( public Mono<AuthenticatedIdentity> authenticate(
String loginId, String password, String remoteAddress) { UUID tenantId,
return repository.findUsersByLoginId(loginId) String loginId,
String password,
String remoteAddress) {
return repository.findUsersByLoginId(tenantId, loginId)
.collectList() .collectList()
.flatMap(users -> { .flatMap(users -> {
if (users.isEmpty()) { if (users.isEmpty()) {
@@ -49,9 +53,14 @@ public class IdentityAuthenticationService {
return Mono.error(new AuthenticationException("Invalid credentials")); return Mono.error(new AuthenticationException("Invalid credentials"));
} }
Instant loginTime = clock.instant(); Instant loginTime = clock.instant();
return repository.findMenu(user.groupId(), user.userId()) return repository.findMenu(
tenantId, user.groupId(), user.userId())
.flatMap(menu -> repository.recordLogin( .flatMap(menu -> repository.recordLogin(
user.loginId(), loginTime, remoteAddress, user.userId()) tenantId,
user.loginId(),
loginTime,
remoteAddress,
user.userId())
.thenReturn(toAuthenticatedIdentity(user, loginTime, menu))); .thenReturn(toAuthenticatedIdentity(user, loginTime, menu)));
}); });
} }

View File

@@ -36,6 +36,13 @@ class AccessTokenIssuer {
.jwtID(UUID.randomUUID().toString()) .jwtID(UUID.randomUUID().toString())
.claim("client_id", principal.clientId()) .claim("client_id", principal.clientId())
.claim("installation_id", principal.installationId()) .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)) .claim("scope", String.join(" ", scopes))
.build(); .build();
SignedJWT jwt = new SignedJWT( SignedJWT jwt = new SignedJWT(

View File

@@ -7,29 +7,33 @@ import com.nimbusds.jose.crypto.RSADecrypter;
import com.nimbusds.jose.crypto.RSASSAVerifier; import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT; 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.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Date; import java.util.Date;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component @Component
class ClientAssertionValidator { class ClientAssertionValidator {
private final CommunicationSecurityProperties properties; private final CommunicationSecurityProperties properties;
private final TenantRegistrationService registrations;
private final Clock clock; private final Clock clock;
ClientAssertionValidator(CommunicationSecurityProperties properties, Clock clock) { ClientAssertionValidator(
CommunicationSecurityProperties properties,
TenantRegistrationService registrations,
Clock clock) {
this.properties = properties; this.properties = properties;
this.registrations = registrations;
this.clock = clock; this.clock = clock;
} }
MachineClientPrincipal validate(String clientId, String encryptedAssertion) { Mono<MachineClientPrincipal> validate(String clientId, String encryptedAssertion) {
CommunicationSecurityProperties.MachineClient client =
properties.clients() == null ? null : properties.clients().get(clientId);
if (client == null || !client.enabled()) {
throw invalid();
}
try { try {
JWEObject jwe = JWEObject.parse(encryptedAssertion); JWEObject jwe = JWEObject.parse(encryptedAssertion);
if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm()) if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm())
@@ -40,25 +44,65 @@ class ClientAssertionValidator {
PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey()))); PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey())));
SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString()); SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString());
if (!signedJwt.verify(new RSASSAVerifier( JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
PemKeyLoader.publicKey(client.assertionPublicKey())))) { String installationCode = claims.getStringClaim("installation_id");
if (installationCode == null || installationCode.isBlank()) {
throw invalid(); 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(); private MachineClientPrincipal verify(
validateClaims(clientId, client, claims); 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( 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) { } catch (MachineAuthenticationException exception) {
throw exception; throw exception;
} catch (Exception exception) { } catch (Exception exception) {
throw new MachineAuthenticationException("Invalid client assertion", exception); throw new MachineAuthenticationException(
"Invalid client assertion", exception);
} }
} }
private void validateClaims( private void validateClaims(
String clientId, String clientId,
CommunicationSecurityProperties.MachineClient client, ClientInstallation installation,
JWTClaimsSet claims) throws Exception { JWTClaimsSet claims) throws Exception {
Instant now = clock.instant(); Instant now = clock.instant();
Date issuedAt = claims.getIssueTime(); Date issuedAt = claims.getIssueTime();
@@ -66,7 +110,7 @@ class ClientAssertionValidator {
if (!clientId.equals(claims.getIssuer()) if (!clientId.equals(claims.getIssuer())
|| !clientId.equals(claims.getSubject()) || !clientId.equals(claims.getSubject())
|| !claims.getAudience().contains(properties.tokenAudience()) || !claims.getAudience().contains(properties.tokenAudience())
|| !client.installationId().equals( || !installation.installationCode().equals(
claims.getStringClaim("installation_id")) claims.getStringClaim("installation_id"))
|| issuedAt == null || issuedAt == null
|| expiresAt == null || expiresAt == null

View File

@@ -1,8 +1,6 @@
package com.cygnus.cloud.security; package com.cygnus.cloud.security;
import java.time.Duration; import java.time.Duration;
import java.util.Map;
import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -16,13 +14,5 @@ public record CommunicationSecurityProperties(
Duration accessTokenTtl, Duration accessTokenTtl,
String assertionDecryptionPrivateKey, String assertionDecryptionPrivateKey,
String accessTokenPrivateKey, String accessTokenPrivateKey,
String accessTokenPublicKey, String accessTokenPublicKey) {
Map<String, MachineClient> clients) {
public record MachineClient(
boolean enabled,
String installationId,
String assertionPublicKey,
Set<String> scopes) {
}
} }

View File

@@ -1,9 +1,16 @@
package com.cygnus.cloud.security; package com.cygnus.cloud.security;
import java.util.Set; import java.util.Set;
import java.util.UUID;
record MachineClientPrincipal( record MachineClientPrincipal(
String clientId, String clientId,
String installationId, String installationId,
UUID tenantId,
UUID internalInstallationId,
UUID licenseId,
String licenseType,
String packageCode,
int securityVersion,
Set<String> allowedScopes) { Set<String> allowedScopes) {
} }

View File

@@ -2,7 +2,6 @@ package com.cygnus.cloud.security;
import java.net.URI; import java.net.URI;
import java.time.Duration; import java.time.Duration;
import java.util.Map;
import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -34,33 +33,6 @@ class MachineSecurityConfigurationValidator implements InitializingBean {
requireText("access-token-private-key", properties.accessTokenPrivateKey()); requireText("access-token-private-key", properties.accessTokenPrivateKey());
requireText("access-token-public-key", properties.accessTokenPublicKey()); requireText("access-token-public-key", properties.accessTokenPublicKey());
Map<String, CommunicationSecurityProperties.MachineClient> 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.<client-id>", 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) { private void requireUri(String name, String value) {

View File

@@ -33,23 +33,27 @@ class MachineTokenController {
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE) produces = MediaType.APPLICATION_JSON_VALUE)
Mono<Map<String, Object>> token(ServerWebExchange exchange) { Mono<Map<String, Object>> token(ServerWebExchange exchange) {
return exchange.getFormData().map(this::issueToken); return exchange.getFormData().flatMap(this::issueToken);
} }
Map<String, Object> issueToken(MultiValueMap<String, String> form) { Mono<Map<String, Object>> issueToken(MultiValueMap<String, String> form) {
if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type")) if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type"))
|| !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) { || !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) {
throw new MachineAuthenticationException("Unsupported token request"); throw new MachineAuthenticationException("Unsupported token request");
} }
String clientId = required(form, "client_id"); String clientId = required(form, "client_id");
MachineClientPrincipal principal = assertionValidator.validate(
clientId, required(form, "client_assertion"));
Set<String> requestedScopes = scopes(form.getFirst("scope")); Set<String> requestedScopes = scopes(form.getFirst("scope"));
if (requestedScopes.isEmpty() return assertionValidator.validate(
|| !principal.allowedScopes().containsAll(requestedScopes)) { clientId, required(form, "client_assertion"))
throw new MachineAuthenticationException("Invalid requested scope"); .map(principal -> {
} if (requestedScopes.isEmpty()
return tokenIssuer.issue(principal, requestedScopes).asOAuthResponse(); || !principal.allowedScopes().containsAll(requestedScopes)) {
throw new MachineAuthenticationException(
"Invalid requested scope");
}
return tokenIssuer.issue(principal, requestedScopes)
.asOAuthResponse();
});
} }
private String required(MultiValueMap<String, String> form, String name) { private String required(MultiValueMap<String, String> form, String name) {

View File

@@ -66,6 +66,9 @@ final class PemKeyLoader {
if (location == null || location.isBlank()) { if (location == null || location.isBlank()) {
throw new IllegalArgumentException("RSA key location is not configured"); throw new IllegalArgumentException("RSA key location is not configured");
} }
if (location.contains("-----BEGIN ")) {
return location;
}
if (location.startsWith("classpath:")) { if (location.startsWith("classpath:")) {
String resource = location.substring("classpath:".length()); String resource = location.substring("classpath:".length());
try (InputStream stream = Thread.currentThread() try (InputStream stream = Thread.currentThread()

View File

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

View File

@@ -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<String> allowedScopes,
boolean enabled,
int securityVersion,
OffsetDateTime lastAuthenticatedAt) {
}

View File

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

View File

@@ -0,0 +1,7 @@
package com.cygnus.cloud.tenant.model;
public enum ClientStatus {
ACTIVE,
SUSPENDED,
CANCELLED
}

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public enum LicenseStatus {
ACTIVE,
SUSPENDED,
EXPIRED,
CANCELLED
}

View File

@@ -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<ClientAccount> findAccountBySlug(String clientSlug) {
return database.preparedQuery(FIND_ACCOUNT_BY_SLUG, Tuple.of(clientSlug))
.flatMap(rows -> first(rows, this::account));
}
public Mono<ClientInstallation> findInstallation(
String clientId, String installationCode) {
return database.preparedQuery(
FIND_INSTALLATION, Tuple.of(clientId, installationCode))
.flatMap(rows -> first(rows, this::installation));
}
public Mono<ClientLicense> 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 <T> Mono<T> first(
Iterable<Row> rows, java.util.function.Function<Row, T> mapper) {
java.util.Iterator<Row> 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"));
}
}

View File

@@ -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<ClientInstallation> 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<ClientLicense> 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<Boolean> evictInstallation(
String clientId, String installationCode) {
return cache.evict(
INSTALLATION_CACHE,
encoded(clientId) + ':' + encoded(installationCode))
.onErrorReturn(false);
}
public Mono<Boolean> 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<ClientInstallation> decodeInstallationSafely(String value) {
try {
String[] fields = value.split("\\|", -1);
if (fields.length != 9) {
return Mono.empty();
}
Set<String> 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<ClientLicense> 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);
}
}

View File

@@ -36,7 +36,6 @@ cygnus:
assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem} assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem}
access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem} access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem}
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem} access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
clients: {}
login-encryption: login-encryption:
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01} key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem} private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.List; import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
@@ -23,6 +24,8 @@ import reactor.test.StepVerifier;
class IdentityAuthenticationServiceTest { class IdentityAuthenticationServiceTest {
private static final Instant LOGIN_TIME = Instant.parse("2026-07-23T06:30:00Z"); 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 @Mock
private IdentityRepository repository; private IdentityRepository repository;
@@ -36,11 +39,17 @@ class IdentityAuthenticationServiceTest {
MenuItem menuItem = new MenuItem( MenuItem menuItem = new MenuItem(
(short) 10, "Operations", "/ver/operations", (short) 0, (short) 10, "Operations", "/ver/operations", (short) 0,
(short) 1, "110", "_parent", null); (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(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))); .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)); .thenReturn(Mono.just(101L));
IdentityAuthenticationService service = new IdentityAuthenticationService( IdentityAuthenticationService service = new IdentityAuthenticationService(
@@ -48,7 +57,8 @@ class IdentityAuthenticationServiceTest {
passwordVerifier, passwordVerifier,
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC)); 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 -> { .assertNext(result -> {
assertThat(result.loginId()).isEqualTo("maddy"); assertThat(result.loginId()).isEqualTo("maddy");
assertThat(result.companyName()).isEqualTo("Matrix"); assertThat(result.companyName()).isEqualTo("Matrix");
@@ -57,20 +67,27 @@ class IdentityAuthenticationServiceTest {
}) })
.verifyComplete(); .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 @Test
void rejectsInactiveUserWithoutLoadingMenu() { void rejectsInactiveUserWithoutLoadingMenu() {
IdentityUser user = user("maddy", false); 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( IdentityAuthenticationService service = new IdentityAuthenticationService(
repository, repository,
passwordVerifier, passwordVerifier,
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC)); 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) .expectError(AuthenticationException.class)
.verify(); .verify();
} }

View File

@@ -2,7 +2,13 @@ package com.cygnus.cloud.security;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm; import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEHeader; import com.nimbusds.jose.JWEHeader;
@@ -26,10 +32,12 @@ import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.OffsetDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
@@ -37,6 +45,7 @@ import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.BodyInserters;
import reactor.core.publisher.Mono;
class MachineTokenFlowTest { class MachineTokenFlowTest {
@@ -53,6 +62,9 @@ class MachineTokenFlowTest {
private KeyPair clientSigningKeys; private KeyPair clientSigningKeys;
private KeyPair accessTokenKeys; private KeyPair accessTokenKeys;
private CommunicationSecurityProperties properties; private CommunicationSecurityProperties properties;
private TenantRegistrationService registrations;
private ClientInstallation installation;
private ClientLicense license;
@BeforeEach @BeforeEach
void setUp() throws Exception { void setUp() throws Exception {
@@ -69,14 +81,33 @@ class MachineTokenFlowTest {
Duration.ofMinutes(20), Duration.ofMinutes(20),
privatePem("assertion-private.pem", assertionEncryptionKeys), privatePem("assertion-private.pem", assertionEncryptionKeys),
privatePem("access-private.pem", accessTokenKeys), privatePem("access-private.pem", accessTokenKeys),
publicPem("access-public.pem", accessTokenKeys), publicPem("access-public.pem", accessTokenKeys));
Map.of( registrations = mock(TenantRegistrationService.class);
CLIENT_ID, installation = new ClientInstallation(
new CommunicationSecurityProperties.MachineClient( UUID.fromString("10000000-0000-4000-8000-000000000001"),
true, UUID.fromString("00000000-0000-4000-8000-000000000001"),
INSTALLATION_ID, CLIENT_ID,
publicPem("client-public.pem", clientSigningKeys), INSTALLATION_ID,
Set.of("identity.login")))); 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 @Test
@@ -84,7 +115,7 @@ class MachineTokenFlowTest {
throws Exception { throws Exception {
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
ClientAssertionValidator validator = ClientAssertionValidator validator =
new ClientAssertionValidator(properties, clock); new ClientAssertionValidator(properties, registrations, clock);
AccessTokenIssuer issuer = new AccessTokenIssuer(properties, clock); AccessTokenIssuer issuer = new AccessTokenIssuer(properties, clock);
MachineTokenController controller = MachineTokenController controller =
new MachineTokenController(validator, issuer); new MachineTokenController(validator, issuer);
@@ -98,7 +129,7 @@ class MachineTokenFlowTest {
form.add("client_assertion", encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365)))); form.add("client_assertion", encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365))));
form.add("scope", "identity.login"); form.add("scope", "identity.login");
Map<String, Object> response = controller.issueToken(form); Map<String, Object> response = controller.issueToken(form).block();
SignedJWT token = SignedJWT.parse((String) response.get("access_token")); SignedJWT token = SignedJWT.parse((String) response.get("access_token"));
assertThat(token.verify(new RSASSAVerifier( assertThat(token.verify(new RSASSAVerifier(
@@ -108,6 +139,12 @@ class MachineTokenFlowTest {
.isEqualTo(CLIENT_ID); .isEqualTo(CLIENT_ID);
assertThat(token.getJWTClaimsSet().getStringClaim("installation_id")) assertThat(token.getJWTClaimsSet().getStringClaim("installation_id"))
.isEqualTo(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")) assertThat(token.getJWTClaimsSet().getStringClaim("scope"))
.isEqualTo("identity.login"); .isEqualTo("identity.login");
assertThat(response) assertThat(response)
@@ -119,21 +156,36 @@ class MachineTokenFlowTest {
@Test @Test
void rejectsExpiredAssertion() throws Exception { void rejectsExpiredAssertion() throws Exception {
ClientAssertionValidator validator = new ClientAssertionValidator( ClientAssertionValidator validator = new ClientAssertionValidator(
properties, Clock.fixed(NOW, ZoneOffset.UTC)); properties, registrations, Clock.fixed(NOW, ZoneOffset.UTC));
assertThatThrownBy(() -> validator.validate( assertThatThrownBy(() -> validator.validate(
CLIENT_ID, CLIENT_ID,
encryptedAssertion( encryptedAssertion(
NOW.minus(Duration.ofDays(366)), NOW.minus(Duration.ofDays(366)),
NOW.minusSeconds(1)))) NOW.minusSeconds(1))).block())
.isInstanceOf(MachineAuthenticationException.class); .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 @Test
void acceptsOAuthFormEncodedTokenRequestOverHttp() throws Exception { void acceptsOAuthFormEncodedTokenRequestOverHttp() throws Exception {
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
MachineTokenController controller = new MachineTokenController( MachineTokenController controller = new MachineTokenController(
new ClientAssertionValidator(properties, clock), new ClientAssertionValidator(properties, registrations, clock),
new AccessTokenIssuer(properties, clock)); new AccessTokenIssuer(properties, clock));
LinkedMultiValueMap<String, String> form = new LinkedMultiValueMap<>(); LinkedMultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("grant_type", "client_credentials"); form.add("grant_type", "client_credentials");

View File

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

View File

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

View File

@@ -15,9 +15,19 @@ class SpringWebContextTest {
@Test @Test
void applicationContextLoadsControllers() { void applicationContextLoadsControllers() {
try (FileSystemXmlApplicationContext context = System.setProperty("CYGNUS_CLIENT_ID", "test-client");
new FileSystemXmlApplicationContext("build/WebContent/WEB-INF/app-config.xml")) { System.setProperty("CYGNUS_INSTALLATION_ID", "test-installation");
assertFalse(context.getBeansWithAnnotation(Controller.class).isEmpty()); 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");
} }
} }

View File

@@ -13,10 +13,19 @@ INSTALLATION_ID=""
CLOUD_BASE_URL="" CLOUD_BASE_URL=""
TOKEN_URL="" TOKEN_URL=""
CLIENT_DIR="" CLIENT_DIR=""
CLIENT_CONFIG="${CONFIG_DIR}/clients.yml"
CLIENT_PRIVATE_KEY="" CLIENT_PRIVATE_KEY=""
CLIENT_PUBLIC_KEY="" CLIENT_PUBLIC_KEY=""
MACHINE_ASSERTION="" 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_CONFIGURED="${CYGNUS_SETUP_SKIP_BUILD+x}"
SKIP_BUILD="${CYGNUS_SETUP_SKIP_BUILD:-false}" SKIP_BUILD="${CYGNUS_SETUP_SKIP_BUILD:-false}"
FORCE_ASSERTION="${CYGNUS_SETUP_FORCE_ASSERTION:-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 '_'." "${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() { collect_client_inputs() {
local default_client="${CYGNUS_SETUP_CLIENT_ID:-customer-a}" local default_client="${CYGNUS_SETUP_CLIENT_ID:-customer-a}"
local default_installation="${CYGNUS_SETUP_INSTALLATION_ID:-site-01}" local default_installation="${CYGNUS_SETUP_INSTALLATION_ID:-site-01}"
@@ -86,7 +100,11 @@ collect_client_inputs() {
CLIENT_ID="$(prompt_value \ CLIENT_ID="$(prompt_value \
"Customer identifier (no spaces; used in key and directory names)" \ "Customer identifier (no spaces; used in key and directory names)" \
"${default_client}")" "${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_ID="$(prompt_value \
"Installation identifier (no spaces)" \ "Installation identifier (no spaces)" \
@@ -111,11 +129,43 @@ collect_client_inputs() {
CLIENT_PUBLIC_KEY="${CLIENT_DIR}/${CLIENT_ID}-signing-public.pem" CLIENT_PUBLIC_KEY="${CLIENT_DIR}/${CLIENT_ID}-signing-public.pem"
MACHINE_ASSERTION="${CLIENT_DIR}/${CLIENT_ID}-${INSTALLATION_ID}-assertion.jwt" 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 '\nProvisioning summary:\n'
printf ' Customer: %s\n' "${CLIENT_ID}" printf ' Customer: %s\n' "${CLIENT_ID}"
printf ' Customer name: %s\n' "${CLIENT_NAME}"
printf ' Installation: %s\n' "${INSTALLATION_ID}" printf ' Installation: %s\n' "${INSTALLATION_ID}"
printf ' Cloud URL: %s\n' "${CLOUD_BASE_URL}" printf ' Cloud URL: %s\n' "${CLOUD_BASE_URL}"
printf ' Token audience: %s\n' "${TOKEN_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)" printf ' Verify build: %s\n' "$([[ "${SKIP_BUILD}" == "true" ]] && printf no || printf yes)"
if [[ "$(prompt_yes_no "Continue with these values" "true")" != "true" ]]; then 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)" "JDK 21 is required. Current java is: $(java -version 2>&1 | head -n 1)"
} }
write_client_configuration() { register_client_in_database() {
local public_key_uri="file:${CLIENT_PUBLIC_KEY}" local client_id_b64 client_name_b64 installation_b64 public_key_b64
local existing_installation="" local license_type_b64 package_code_b64
if [[ ! -f "${CLIENT_CONFIG}" ]]; then client_id_b64="$(printf '%s' "${CLIENT_ID}" | openssl base64 -A)"
{ client_name_b64="$(printf '%s' "${CLIENT_NAME}" | openssl base64 -A)"
printf '%s\n' "cygnus:" installation_b64="$(printf '%s' "${INSTALLATION_ID}" | openssl base64 -A)"
printf '%s\n' " security:" public_key_b64="$(openssl base64 -A -in "${CLIENT_PUBLIC_KEY}")"
printf '%s\n' " clients:" license_type_b64="$(printf '%s' "${LICENSE_TYPE}" | openssl base64 -A)"
} > "${CLIENT_CONFIG}" package_code_b64="$(printf '%s' "${PACKAGE_CODE}" | openssl base64 -A)"
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
{ PGPASSWORD="${DB_PASSWORD_VALUE}" "${PSQL_BIN}" \
printf ' %s:\n' "${CLIENT_ID}" -h "${DB_HOST_VALUE}" \
printf '%s\n' " enabled: true" -p "${DB_PORT_VALUE}" \
printf ' installation-id: %s\n' "${INSTALLATION_ID}" -U "${DB_USER_VALUE}" \
printf ' assertion-public-key: %s\n' "${public_key_uri}" -d "${DB_NAME_VALUE}" \
printf '%s\n' " scopes:" -X -v ON_ERROR_STOP=1 \
printf '%s\n' " - identity.login" -c "
} >> "${CLIENT_CONFIG}" WITH account AS (
chmod 600 "${CLIENT_CONFIG}" INSERT INTO identity.client_account
printf 'Added client %s to: %s\n' "${CLIENT_ID}" "${CLIENT_CONFIG}" (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() { generate_machine_assertion() {
@@ -236,6 +320,13 @@ main() {
require_command awk require_command awk
require_command openssl require_command openssl
require_command mvn 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 configure_java_21
java -version java -version
mvn -version mvn -version
@@ -277,8 +368,8 @@ main() {
"${CLIENT_PRIVATE_KEY}" \ "${CLIENT_PRIVATE_KEY}" \
"${CLIENT_PUBLIC_KEY}" "${CLIENT_PUBLIC_KEY}"
log "Writing cloud machine-client configuration" log "Registering tenant, installation, and license in the cloud database"
write_client_configuration register_client_in_database
log "Generating encrypted machine assertion" log "Generating encrypted machine assertion"
generate_machine_assertion generate_machine_assertion
@@ -288,7 +379,7 @@ main() {
"Client ID: ${CLIENT_ID}" \ "Client ID: ${CLIENT_ID}" \
"Installation ID: ${INSTALLATION_ID}" \ "Installation ID: ${INSTALLATION_ID}" \
"Token audience: ${TOKEN_URL}" \ "Token audience: ${TOKEN_URL}" \
"Client config: ${CLIENT_CONFIG}" \ "Registration: PostgreSQL identity schema" \
"Assertion file: ${MACHINE_ASSERTION}" \ "Assertion file: ${MACHINE_ASSERTION}" \
"Login public key: ${KEY_DIR}/login-public.pem" \ "Login public key: ${KEY_DIR}/login-public.pem" \
"" \ "" \