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

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

View File

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

View File

@@ -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<IdentityUser> findUsersByLoginId(String loginId) {
return database.preparedQuery(FIND_USER, Tuple.of(loginId))
public Flux<IdentityUser> findUsersByLoginId(UUID tenantId, String loginId) {
return database.preparedQuery(FIND_USER, Tuple.of(tenantId, loginId))
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user));
}
public Mono<List<MenuItem>> findMenu(short groupId, short userId) {
return database.preparedQuery(FIND_MENU, Tuple.of(groupId, userId))
public Mono<List<MenuItem>> 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<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);
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"));
}
}

View File

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

View File

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

View File

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

View File

@@ -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<String, MachineClient> clients) {
public record MachineClient(
boolean enabled,
String installationId,
String assertionPublicKey,
Set<String> scopes) {
}
String accessTokenPublicKey) {
}

View File

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

View File

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

View File

@@ -33,23 +33,27 @@ class MachineTokenController {
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
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"))
|| !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<String> 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<String, String> form, String name) {

View File

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

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}
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}

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

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.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<String, Object> response = controller.issueToken(form);
Map<String, Object> 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<String, String> form = new LinkedMultiValueMap<>();
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();
}
}