Multi tenant approach - cleanup done
This commit is contained in:
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -29,8 +29,7 @@
|
||||
"CYGNUS_ACCESS_TOKEN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/access-token-private.pem",
|
||||
"CYGNUS_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem",
|
||||
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
|
||||
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem",
|
||||
"SPRING_CONFIG_ADDITIONAL_LOCATION": "file:${workspaceFolder}/config/clients.yml"
|
||||
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem"
|
||||
},
|
||||
"shortenCommandLine": "argfile"
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -29,18 +29,24 @@ It is valid for one year; the access token obtained with it is short-lived.
|
||||
|
||||
For local development, the repository setup script automates prerequisite
|
||||
checks, the full Maven verification, directory creation, all three cloud key
|
||||
pairs, the installation key pair, `config/clients.yml`, and the encrypted
|
||||
machine assertion:
|
||||
pairs, the installation key pair, database-backed tenant/install registration,
|
||||
an initial license, and the encrypted machine assertion:
|
||||
|
||||
```bash
|
||||
./scripts/setup-local-communication.sh
|
||||
```
|
||||
|
||||
The script interactively asks for the customer identifier, installation
|
||||
identifier, cloud URL, and whether to run the full verification. Customer and
|
||||
installation identifiers cannot contain spaces; the customer identifier is
|
||||
used for its directory and signing-key filenames. New customers are appended
|
||||
to `config/clients.yml` without replacing existing customers.
|
||||
The script interactively asks for the customer name and slug, installation
|
||||
identifier, cloud URL, database connection, license package/type/duration, and
|
||||
whether to run the full verification. Customer and installation identifiers
|
||||
cannot contain spaces. The slug is the stable tenant key and is used for its
|
||||
directory and signing-key filenames.
|
||||
|
||||
The client account, installation public key, allowed scopes, and license are
|
||||
upserted into PostgreSQL (`identity.client_account`,
|
||||
`identity.client_installation`, and `identity.client_license`). The cloud
|
||||
service resolves this registration dynamically through Redis with PostgreSQL
|
||||
fallback, so adding another customer does not require a cloud restart.
|
||||
|
||||
It preserves existing private keys and assertions. Set
|
||||
`CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be
|
||||
@@ -65,6 +71,6 @@ mvn -pl cygnus-cloud-client exec:java \
|
||||
client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt"
|
||||
```
|
||||
|
||||
Copy only `client-signing-public.pem` into that customer's cloud-side client
|
||||
configuration. Keep the private key and generated assertion on the on-premises
|
||||
server with owner-only filesystem permissions.
|
||||
The setup script stores `client-signing-public.pem` in the installation record
|
||||
used by the cloud. Keep the private key and generated assertion only on the
|
||||
on-premises server with owner-only filesystem permissions.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public enum ClientStatus {
|
||||
ACTIVE,
|
||||
SUSPENDED,
|
||||
CANCELLED
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public enum LicenseStatus {
|
||||
ACTIVE,
|
||||
SUSPENDED,
|
||||
EXPIRED,
|
||||
CANCELLED
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,19 @@ class SpringWebContextTest {
|
||||
|
||||
@Test
|
||||
void applicationContextLoadsControllers() {
|
||||
try (FileSystemXmlApplicationContext context =
|
||||
new FileSystemXmlApplicationContext("build/WebContent/WEB-INF/app-config.xml")) {
|
||||
assertFalse(context.getBeansWithAnnotation(Controller.class).isEmpty());
|
||||
System.setProperty("CYGNUS_CLIENT_ID", "test-client");
|
||||
System.setProperty("CYGNUS_INSTALLATION_ID", "test-installation");
|
||||
System.setProperty("CYGNUS_CLIENT_ASSERTION", "test-assertion");
|
||||
try {
|
||||
try (FileSystemXmlApplicationContext context =
|
||||
new FileSystemXmlApplicationContext(
|
||||
"build/WebContent/WEB-INF/app-config.xml")) {
|
||||
assertFalse(context.getBeansWithAnnotation(Controller.class).isEmpty());
|
||||
}
|
||||
} finally {
|
||||
System.clearProperty("CYGNUS_CLIENT_ID");
|
||||
System.clearProperty("CYGNUS_INSTALLATION_ID");
|
||||
System.clearProperty("CYGNUS_CLIENT_ASSERTION");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,19 @@ INSTALLATION_ID=""
|
||||
CLOUD_BASE_URL=""
|
||||
TOKEN_URL=""
|
||||
CLIENT_DIR=""
|
||||
CLIENT_CONFIG="${CONFIG_DIR}/clients.yml"
|
||||
CLIENT_PRIVATE_KEY=""
|
||||
CLIENT_PUBLIC_KEY=""
|
||||
MACHINE_ASSERTION=""
|
||||
CLIENT_NAME=""
|
||||
DB_HOST_VALUE=""
|
||||
DB_PORT_VALUE=""
|
||||
DB_NAME_VALUE=""
|
||||
DB_USER_VALUE=""
|
||||
DB_PASSWORD_VALUE=""
|
||||
LICENSE_TYPE=""
|
||||
PACKAGE_CODE=""
|
||||
LICENSE_MONTHS=""
|
||||
PSQL_BIN=""
|
||||
SKIP_BUILD_CONFIGURED="${CYGNUS_SETUP_SKIP_BUILD+x}"
|
||||
SKIP_BUILD="${CYGNUS_SETUP_SKIP_BUILD:-false}"
|
||||
FORCE_ASSERTION="${CYGNUS_SETUP_FORCE_ASSERTION:-false}"
|
||||
@@ -77,6 +86,11 @@ validate_identifier() {
|
||||
"${label} must contain no spaces and use only letters, numbers, '-' or '_'."
|
||||
}
|
||||
|
||||
validate_client_slug() {
|
||||
[[ "$1" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || fail \
|
||||
"Customer identifier must use lowercase letters, numbers and single hyphens only."
|
||||
}
|
||||
|
||||
collect_client_inputs() {
|
||||
local default_client="${CYGNUS_SETUP_CLIENT_ID:-customer-a}"
|
||||
local default_installation="${CYGNUS_SETUP_INSTALLATION_ID:-site-01}"
|
||||
@@ -86,7 +100,11 @@ collect_client_inputs() {
|
||||
CLIENT_ID="$(prompt_value \
|
||||
"Customer identifier (no spaces; used in key and directory names)" \
|
||||
"${default_client}")"
|
||||
validate_identifier "Customer identifier" "${CLIENT_ID}"
|
||||
validate_client_slug "${CLIENT_ID}"
|
||||
CLIENT_NAME="$(prompt_value \
|
||||
"Customer display name" \
|
||||
"${CYGNUS_SETUP_CLIENT_NAME:-${CLIENT_ID}}")"
|
||||
[[ -n "${CLIENT_NAME}" ]] || fail "Customer display name is required."
|
||||
|
||||
INSTALLATION_ID="$(prompt_value \
|
||||
"Installation identifier (no spaces)" \
|
||||
@@ -111,11 +129,43 @@ collect_client_inputs() {
|
||||
CLIENT_PUBLIC_KEY="${CLIENT_DIR}/${CLIENT_ID}-signing-public.pem"
|
||||
MACHINE_ASSERTION="${CLIENT_DIR}/${CLIENT_ID}-${INSTALLATION_ID}-assertion.jwt"
|
||||
|
||||
DB_HOST_VALUE="$(prompt_value \
|
||||
"Cloud PostgreSQL host" "${CYGNUS_SETUP_DB_HOST:-${DB_HOST:-localhost}}")"
|
||||
DB_PORT_VALUE="$(prompt_value \
|
||||
"Cloud PostgreSQL port" "${CYGNUS_SETUP_DB_PORT:-${DB_PORT:-5432}}")"
|
||||
DB_NAME_VALUE="$(prompt_value \
|
||||
"Cloud PostgreSQL database" "${CYGNUS_SETUP_DB_NAME:-${DB_NAME:-matrix}}")"
|
||||
DB_USER_VALUE="$(prompt_value \
|
||||
"Cloud PostgreSQL user" "${CYGNUS_SETUP_DB_USER:-${DB_USER:-postgres}}")"
|
||||
DB_PASSWORD_VALUE="${CYGNUS_SETUP_DB_PASSWORD:-${DB_PASSWORD:-}}"
|
||||
if [[ -z "${DB_PASSWORD_VALUE}" ]] && [[ "${NON_INTERACTIVE}" != "true" ]] && [[ -t 0 ]]; then
|
||||
read -r -s -p "Cloud PostgreSQL password: " DB_PASSWORD_VALUE
|
||||
printf '\n'
|
||||
fi
|
||||
[[ -n "${DB_PASSWORD_VALUE}" ]] || fail \
|
||||
"Cloud PostgreSQL password is required through the prompt or CYGNUS_SETUP_DB_PASSWORD."
|
||||
|
||||
LICENSE_TYPE="$(prompt_value \
|
||||
"License type" "${CYGNUS_SETUP_LICENSE_TYPE:-ANNUAL}")"
|
||||
PACKAGE_CODE="$(prompt_value \
|
||||
"License package code" "${CYGNUS_SETUP_PACKAGE_CODE:-FULL}")"
|
||||
LICENSE_MONTHS="$(prompt_value \
|
||||
"License validity in months" "${CYGNUS_SETUP_LICENSE_MONTHS:-12}")"
|
||||
validate_identifier "License type" "${LICENSE_TYPE}"
|
||||
validate_identifier "Package code" "${PACKAGE_CODE}"
|
||||
[[ "${LICENSE_MONTHS}" =~ ^[1-9][0-9]*$ ]] || fail \
|
||||
"License validity must be a positive whole number of months."
|
||||
|
||||
printf '\nProvisioning summary:\n'
|
||||
printf ' Customer: %s\n' "${CLIENT_ID}"
|
||||
printf ' Customer name: %s\n' "${CLIENT_NAME}"
|
||||
printf ' Installation: %s\n' "${INSTALLATION_ID}"
|
||||
printf ' Cloud URL: %s\n' "${CLOUD_BASE_URL}"
|
||||
printf ' Token audience: %s\n' "${TOKEN_URL}"
|
||||
printf ' Database: %s@%s:%s/%s\n' \
|
||||
"${DB_USER_VALUE}" "${DB_HOST_VALUE}" "${DB_PORT_VALUE}" "${DB_NAME_VALUE}"
|
||||
printf ' License: %s / %s / %s month(s)\n' \
|
||||
"${LICENSE_TYPE}" "${PACKAGE_CODE}" "${LICENSE_MONTHS}"
|
||||
printf ' Verify build: %s\n' "$([[ "${SKIP_BUILD}" == "true" ]] && printf no || printf yes)"
|
||||
|
||||
if [[ "$(prompt_yes_no "Continue with these values" "true")" != "true" ]]; then
|
||||
@@ -160,44 +210,78 @@ configure_java_21() {
|
||||
"JDK 21 is required. Current java is: $(java -version 2>&1 | head -n 1)"
|
||||
}
|
||||
|
||||
write_client_configuration() {
|
||||
local public_key_uri="file:${CLIENT_PUBLIC_KEY}"
|
||||
local existing_installation=""
|
||||
register_client_in_database() {
|
||||
local client_id_b64 client_name_b64 installation_b64 public_key_b64
|
||||
local license_type_b64 package_code_b64
|
||||
|
||||
if [[ ! -f "${CLIENT_CONFIG}" ]]; then
|
||||
{
|
||||
printf '%s\n' "cygnus:"
|
||||
printf '%s\n' " security:"
|
||||
printf '%s\n' " clients:"
|
||||
} > "${CLIENT_CONFIG}"
|
||||
elif grep -Fq " ${CLIENT_ID}:" "${CLIENT_CONFIG}"; then
|
||||
existing_installation="$(awk \
|
||||
-v client=" ${CLIENT_ID}:" \
|
||||
'$0 == client { found = 1; next }
|
||||
found && /installation-id:/ {
|
||||
sub(/^.*installation-id:[[:space:]]*/, "");
|
||||
print;
|
||||
exit
|
||||
}
|
||||
found && /^ [^[:space:]]/ { exit }' \
|
||||
"${CLIENT_CONFIG}")"
|
||||
if [[ "${existing_installation}" != "${INSTALLATION_ID}" ]]; then
|
||||
fail "Client '${CLIENT_ID}' already uses installation '${existing_installation}' in ${CLIENT_CONFIG}; requested '${INSTALLATION_ID}'. Use the existing installation ID or provision a different customer identifier."
|
||||
fi
|
||||
printf 'Keeping existing configuration for client: %s\n' "${CLIENT_ID}"
|
||||
return
|
||||
fi
|
||||
client_id_b64="$(printf '%s' "${CLIENT_ID}" | openssl base64 -A)"
|
||||
client_name_b64="$(printf '%s' "${CLIENT_NAME}" | openssl base64 -A)"
|
||||
installation_b64="$(printf '%s' "${INSTALLATION_ID}" | openssl base64 -A)"
|
||||
public_key_b64="$(openssl base64 -A -in "${CLIENT_PUBLIC_KEY}")"
|
||||
license_type_b64="$(printf '%s' "${LICENSE_TYPE}" | openssl base64 -A)"
|
||||
package_code_b64="$(printf '%s' "${PACKAGE_CODE}" | openssl base64 -A)"
|
||||
|
||||
{
|
||||
printf ' %s:\n' "${CLIENT_ID}"
|
||||
printf '%s\n' " enabled: true"
|
||||
printf ' installation-id: %s\n' "${INSTALLATION_ID}"
|
||||
printf ' assertion-public-key: %s\n' "${public_key_uri}"
|
||||
printf '%s\n' " scopes:"
|
||||
printf '%s\n' " - identity.login"
|
||||
} >> "${CLIENT_CONFIG}"
|
||||
chmod 600 "${CLIENT_CONFIG}"
|
||||
printf 'Added client %s to: %s\n' "${CLIENT_ID}" "${CLIENT_CONFIG}"
|
||||
PGPASSWORD="${DB_PASSWORD_VALUE}" "${PSQL_BIN}" \
|
||||
-h "${DB_HOST_VALUE}" \
|
||||
-p "${DB_PORT_VALUE}" \
|
||||
-U "${DB_USER_VALUE}" \
|
||||
-d "${DB_NAME_VALUE}" \
|
||||
-X -v ON_ERROR_STOP=1 \
|
||||
-c "
|
||||
WITH account AS (
|
||||
INSERT INTO identity.client_account
|
||||
(tenant_id, client_slug, client_name, status)
|
||||
VALUES (
|
||||
gen_random_uuid(),
|
||||
convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'),
|
||||
convert_from(decode('${client_name_b64}', 'base64'), 'UTF8'),
|
||||
'ACTIVE')
|
||||
ON CONFLICT (client_slug) DO UPDATE SET
|
||||
client_name = EXCLUDED.client_name,
|
||||
status = 'ACTIVE',
|
||||
updated_at = now()
|
||||
RETURNING tenant_id
|
||||
), installation AS (
|
||||
INSERT INTO identity.client_installation
|
||||
(installation_id, tenant_id, client_id, installation_code,
|
||||
assertion_public_key, allowed_scopes, enabled)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
tenant_id,
|
||||
convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'),
|
||||
convert_from(decode('${installation_b64}', 'base64'), 'UTF8'),
|
||||
convert_from(decode('${public_key_b64}', 'base64'), 'UTF8'),
|
||||
ARRAY['identity.login']::text[],
|
||||
true
|
||||
FROM account
|
||||
ON CONFLICT (client_id, installation_code) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
assertion_public_key = EXCLUDED.assertion_public_key,
|
||||
allowed_scopes = EXCLUDED.allowed_scopes,
|
||||
enabled = true,
|
||||
security_version = identity.client_installation.security_version + 1,
|
||||
updated_at = now()
|
||||
RETURNING tenant_id
|
||||
)
|
||||
INSERT INTO identity.client_license
|
||||
(license_id, tenant_id, license_type, package_code,
|
||||
valid_from, valid_until, status)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
tenant_id,
|
||||
convert_from(decode('${license_type_b64}', 'base64'), 'UTF8'),
|
||||
convert_from(decode('${package_code_b64}', 'base64'), 'UTF8'),
|
||||
now(),
|
||||
now() + make_interval(months => ${LICENSE_MONTHS}),
|
||||
'ACTIVE'
|
||||
FROM installation
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM identity.client_license existing
|
||||
WHERE existing.tenant_id = installation.tenant_id
|
||||
AND existing.status = 'ACTIVE'
|
||||
AND existing.valid_until > now()
|
||||
);"
|
||||
}
|
||||
|
||||
generate_machine_assertion() {
|
||||
@@ -236,6 +320,13 @@ main() {
|
||||
require_command awk
|
||||
require_command openssl
|
||||
require_command mvn
|
||||
if command -v psql >/dev/null 2>&1; then
|
||||
PSQL_BIN="$(command -v psql)"
|
||||
elif [[ -x /Library/PostgreSQL/17/bin/psql ]]; then
|
||||
PSQL_BIN="/Library/PostgreSQL/17/bin/psql"
|
||||
else
|
||||
fail "PostgreSQL psql was not found in PATH or /Library/PostgreSQL/17/bin."
|
||||
fi
|
||||
configure_java_21
|
||||
java -version
|
||||
mvn -version
|
||||
@@ -277,8 +368,8 @@ main() {
|
||||
"${CLIENT_PRIVATE_KEY}" \
|
||||
"${CLIENT_PUBLIC_KEY}"
|
||||
|
||||
log "Writing cloud machine-client configuration"
|
||||
write_client_configuration
|
||||
log "Registering tenant, installation, and license in the cloud database"
|
||||
register_client_in_database
|
||||
|
||||
log "Generating encrypted machine assertion"
|
||||
generate_machine_assertion
|
||||
@@ -288,7 +379,7 @@ main() {
|
||||
"Client ID: ${CLIENT_ID}" \
|
||||
"Installation ID: ${INSTALLATION_ID}" \
|
||||
"Token audience: ${TOKEN_URL}" \
|
||||
"Client config: ${CLIENT_CONFIG}" \
|
||||
"Registration: PostgreSQL identity schema" \
|
||||
"Assertion file: ${MACHINE_ASSERTION}" \
|
||||
"Login public key: ${KEY_DIR}/login-public.pem" \
|
||||
"" \
|
||||
|
||||
Reference in New Issue
Block a user