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_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/access-token-private.pem",
|
||||||
"CYGNUS_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem",
|
"CYGNUS_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem",
|
||||||
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
|
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
|
||||||
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem",
|
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem"
|
||||||
"SPRING_CONFIG_ADDITIONAL_LOCATION": "file:${workspaceFolder}/config/clients.yml"
|
|
||||||
},
|
},
|
||||||
"shortenCommandLine": "argfile"
|
"shortenCommandLine": "argfile"
|
||||||
},
|
},
|
||||||
|
|||||||
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
|
For local development, the repository setup script automates prerequisite
|
||||||
checks, the full Maven verification, directory creation, all three cloud key
|
checks, the full Maven verification, directory creation, all three cloud key
|
||||||
pairs, the installation key pair, `config/clients.yml`, and the encrypted
|
pairs, the installation key pair, database-backed tenant/install registration,
|
||||||
machine assertion:
|
an initial license, and the encrypted machine assertion:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/setup-local-communication.sh
|
./scripts/setup-local-communication.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script interactively asks for the customer identifier, installation
|
The script interactively asks for the customer name and slug, installation
|
||||||
identifier, cloud URL, and whether to run the full verification. Customer and
|
identifier, cloud URL, database connection, license package/type/duration, and
|
||||||
installation identifiers cannot contain spaces; the customer identifier is
|
whether to run the full verification. Customer and installation identifiers
|
||||||
used for its directory and signing-key filenames. New customers are appended
|
cannot contain spaces. The slug is the stable tenant key and is used for its
|
||||||
to `config/clients.yml` without replacing existing customers.
|
directory and signing-key filenames.
|
||||||
|
|
||||||
|
The client account, installation public key, allowed scopes, and license are
|
||||||
|
upserted into PostgreSQL (`identity.client_account`,
|
||||||
|
`identity.client_installation`, and `identity.client_license`). The cloud
|
||||||
|
service resolves this registration dynamically through Redis with PostgreSQL
|
||||||
|
fallback, so adding another customer does not require a cloud restart.
|
||||||
|
|
||||||
It preserves existing private keys and assertions. Set
|
It preserves existing private keys and assertions. Set
|
||||||
`CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be
|
`CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be
|
||||||
@@ -65,6 +71,6 @@ mvn -pl cygnus-cloud-client exec:java \
|
|||||||
client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt"
|
client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt"
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy only `client-signing-public.pem` into that customer's cloud-side client
|
The setup script stores `client-signing-public.pem` in the installation record
|
||||||
configuration. Keep the private key and generated assertion on the on-premises
|
used by the cloud. Keep the private key and generated assertion only on the
|
||||||
server with owner-only filesystem permissions.
|
on-premises server with owner-only filesystem permissions.
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ on-premises gateway. The client assertion must be:
|
|||||||
- bound to the configured client ID, installation ID, and token audience;
|
- bound to the configured client ID, installation ID, and token audience;
|
||||||
- unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`.
|
- unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`.
|
||||||
|
|
||||||
The endpoint returns a short-lived RS256 access token carrying `client_id`,
|
The endpoint returns a short-lived RS256 access token carrying the client,
|
||||||
`installation_id`, and the approved scope. The identity endpoint requires the
|
installation, tenant, license, security-version, and approved-scope claims.
|
||||||
`identity.login` scope and verifies the same machine binding in the encrypted
|
The identity endpoint requires the `identity.login` scope and verifies the
|
||||||
login payload.
|
same machine and tenant binding in the encrypted login payload.
|
||||||
|
|
||||||
Generate separate cloud key pairs:
|
Generate separate cloud key pairs:
|
||||||
|
|
||||||
@@ -73,26 +73,28 @@ openssl pkey -in config/keys/access-token-private.pem -pubout \
|
|||||||
chmod 600 config/keys/*private.pem
|
chmod 600 config/keys/*private.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
Configure clients in an external Spring YAML file rather than the packaged
|
## Dynamic tenant, installation, and license registration
|
||||||
`application.yml`:
|
|
||||||
|
|
||||||
```yaml
|
Machine clients are no longer configured in a runtime `clients.yml`. The
|
||||||
cygnus:
|
authoritative records are:
|
||||||
security:
|
|
||||||
enabled: true
|
|
||||||
issuer-uri: https://cloud.example.com
|
|
||||||
audience: cygnus-cloud-api
|
|
||||||
token-audience: https://cloud.example.com/oauth2/token
|
|
||||||
clients:
|
|
||||||
customer-a:
|
|
||||||
enabled: true
|
|
||||||
installation-id: site-01
|
|
||||||
assertion-public-key: file:/secure/clients/customer-a/public.pem
|
|
||||||
scopes:
|
|
||||||
- identity.login
|
|
||||||
```
|
|
||||||
|
|
||||||
Start with that protected file using
|
- `identity.client_account`: tenant identity and status;
|
||||||
`--spring.config.additional-location=file:/secure/cygnus/clients.yml`.
|
- `identity.client_installation`: machine identity, assertion public key,
|
||||||
Never place cloud private keys, customer assertions, or installation private
|
allowed scopes, enabled state, and security version;
|
||||||
keys in the repository or container image.
|
- `identity.client_license`: subscription period, package, type, status, and
|
||||||
|
licensed limits.
|
||||||
|
|
||||||
|
Token issuance resolves the installation and active license through a
|
||||||
|
Redis cache-aside service with PostgreSQL fallback. Cache entries have a
|
||||||
|
bounded TTL and can be invalidated after administrative changes. Therefore,
|
||||||
|
new customers, installations, key rotations, scope changes, and license
|
||||||
|
changes do not require restarting the cloud service.
|
||||||
|
|
||||||
|
The login/menu queries are tenant-scoped. Tenant-owned identity tables carry
|
||||||
|
`tenant_id`; `identity.pages` remains the shared feature catalog while
|
||||||
|
permissions are assigned per tenant.
|
||||||
|
|
||||||
|
Use `scripts/setup-local-communication.sh` to create keys, register or update
|
||||||
|
the database records, create the initial license, and generate the on-premises
|
||||||
|
machine assertion. Never place cloud private keys, customer assertions, or
|
||||||
|
installation private keys in the repository or container image.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.cygnus.cloud.identity.service.LoginRequestReplayService;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.UUID;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
@@ -64,12 +65,21 @@ public class CloudLoginController {
|
|||||||
return Mono.error(new AuthenticationException("Login request replayed"));
|
return Mono.error(new AuthenticationException("Login request replayed"));
|
||||||
}
|
}
|
||||||
return authenticationService.authenticate(
|
return authenticationService.authenticate(
|
||||||
|
tenantId(machineJwt),
|
||||||
payload.loginId(),
|
payload.loginId(),
|
||||||
payload.password(),
|
payload.password(),
|
||||||
remoteAddress(serverRequest));
|
remoteAddress(serverRequest));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UUID tenantId(Jwt jwt) {
|
||||||
|
try {
|
||||||
|
return UUID.fromString(jwt.getClaimAsString("tenant_id"));
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new AuthenticationException("Machine tenant is invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void validatePayload(LoginPayload payload) {
|
private void validatePayload(LoginPayload payload) {
|
||||||
if (payload == null
|
if (payload == null
|
||||||
|| !StringUtils.hasText(payload.loginId())
|
|| !StringUtils.hasText(payload.loginId())
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -20,25 +21,32 @@ public class IdentityRepository {
|
|||||||
g.name AS group_name, u.branch_id, b.branchname, b.branchcode, b.city,
|
g.name AS group_name, u.branch_id, b.branchname, b.branchcode, b.city,
|
||||||
u.company_id, c.companyname, c.companycode, u.isactive
|
u.company_id, c.companyname, c.companycode, u.isactive
|
||||||
FROM identity.app_user u
|
FROM identity.app_user u
|
||||||
JOIN identity.user_group g ON g.group_id = u.group_id
|
JOIN identity.user_group g
|
||||||
JOIN identity.company c ON c.company_id = u.company_id
|
ON g.tenant_id = u.tenant_id AND g.group_id = u.group_id
|
||||||
|
JOIN identity.company c
|
||||||
|
ON c.tenant_id = u.tenant_id AND c.company_id = u.company_id
|
||||||
JOIN identity.company_branch b
|
JOIN identity.company_branch b
|
||||||
ON b.branch_id = u.branch_id AND b.company_id = u.company_id
|
ON b.tenant_id = u.tenant_id
|
||||||
WHERE upper(u.loginid) = upper($1)
|
AND b.branch_id = u.branch_id
|
||||||
|
AND b.company_id = u.company_id
|
||||||
|
WHERE u.tenant_id = $1
|
||||||
|
AND upper(u.loginid) = upper($2)
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static final String FIND_MENU = """
|
private static final String FIND_MENU = """
|
||||||
SELECT p.page_id, p.menulabel, p.targeturl, p.parentpage, p.pageorder,
|
SELECT p.page_id, p.menulabel, p.targeturl, p.parentpage, p.pageorder,
|
||||||
permissions.permission, p.targetwindow, permissions.requestval
|
permissions.permission, p.targetwindow, permissions.requestval
|
||||||
FROM identity.permission permissions
|
FROM identity.permission permissions
|
||||||
JOIN identity.pages p ON p.page_id = permissions.page_id
|
JOIN identity.pages p ON p.page_id = permissions.page_id
|
||||||
WHERE permissions.group_id = $1
|
WHERE permissions.tenant_id = $1
|
||||||
|
AND permissions.group_id = $2
|
||||||
AND p.isvisible = 1
|
AND p.isvisible = 1
|
||||||
AND permissions.permission <> '000'
|
AND permissions.permission <> '000'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM identity.denied_pages denied
|
FROM identity.denied_pages denied
|
||||||
WHERE denied.user_id = $2
|
WHERE denied.tenant_id = permissions.tenant_id
|
||||||
|
AND denied.user_id = $3
|
||||||
AND denied.page_id = permissions.page_id
|
AND denied.page_id = permissions.page_id
|
||||||
AND denied.isdenied = 1
|
AND denied.isdenied = 1
|
||||||
)
|
)
|
||||||
@@ -47,8 +55,8 @@ public class IdentityRepository {
|
|||||||
|
|
||||||
private static final String RECORD_LOGIN = """
|
private static final String RECORD_LOGIN = """
|
||||||
INSERT INTO identity.user_loginhistory
|
INSERT INTO identity.user_loginhistory
|
||||||
(loginid, logintime, ipaddr, user_id)
|
(tenant_id, loginid, logintime, ipaddr, user_id)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING uid
|
RETURNING uid
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -60,23 +68,34 @@ public class IdentityRepository {
|
|||||||
this.mapper = mapper;
|
this.mapper = mapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Flux<IdentityUser> findUsersByLoginId(String loginId) {
|
public Flux<IdentityUser> findUsersByLoginId(UUID tenantId, String loginId) {
|
||||||
return database.preparedQuery(FIND_USER, Tuple.of(loginId))
|
return database.preparedQuery(FIND_USER, Tuple.of(tenantId, loginId))
|
||||||
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user));
|
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<MenuItem>> findMenu(short groupId, short userId) {
|
public Mono<List<MenuItem>> findMenu(
|
||||||
return database.preparedQuery(FIND_MENU, Tuple.of(groupId, userId))
|
UUID tenantId, short groupId, short userId) {
|
||||||
|
return database.preparedQuery(
|
||||||
|
FIND_MENU, Tuple.of(tenantId, groupId, userId))
|
||||||
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::menuItem))
|
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::menuItem))
|
||||||
.collectList();
|
.collectList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Long> recordLogin(
|
public Mono<Long> recordLogin(
|
||||||
String loginId, Instant loginTime, String remoteAddress, short userId) {
|
UUID tenantId,
|
||||||
|
String loginId,
|
||||||
|
Instant loginTime,
|
||||||
|
String remoteAddress,
|
||||||
|
short userId) {
|
||||||
LocalDateTime databaseTime = LocalDateTime.ofInstant(loginTime, ZoneOffset.UTC);
|
LocalDateTime databaseTime = LocalDateTime.ofInstant(loginTime, ZoneOffset.UTC);
|
||||||
return database.preparedQuery(
|
return database.preparedQuery(
|
||||||
RECORD_LOGIN,
|
RECORD_LOGIN,
|
||||||
Tuple.of(loginId, databaseTime, remoteAddress, userId))
|
Tuple.of(
|
||||||
|
tenantId,
|
||||||
|
loginId,
|
||||||
|
databaseTime,
|
||||||
|
remoteAddress,
|
||||||
|
userId))
|
||||||
.map(rows -> rows.iterator().next().getLong("uid"));
|
.map(rows -> rows.iterator().next().getLong("uid"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.cygnus.cloud.identity.model.IdentityUser;
|
|||||||
import com.cygnus.cloud.identity.repository.IdentityRepository;
|
import com.cygnus.cloud.identity.repository.IdentityRepository;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
@@ -25,8 +26,11 @@ public class IdentityAuthenticationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Mono<AuthenticatedIdentity> authenticate(
|
public Mono<AuthenticatedIdentity> authenticate(
|
||||||
String loginId, String password, String remoteAddress) {
|
UUID tenantId,
|
||||||
return repository.findUsersByLoginId(loginId)
|
String loginId,
|
||||||
|
String password,
|
||||||
|
String remoteAddress) {
|
||||||
|
return repository.findUsersByLoginId(tenantId, loginId)
|
||||||
.collectList()
|
.collectList()
|
||||||
.flatMap(users -> {
|
.flatMap(users -> {
|
||||||
if (users.isEmpty()) {
|
if (users.isEmpty()) {
|
||||||
@@ -49,9 +53,14 @@ public class IdentityAuthenticationService {
|
|||||||
return Mono.error(new AuthenticationException("Invalid credentials"));
|
return Mono.error(new AuthenticationException("Invalid credentials"));
|
||||||
}
|
}
|
||||||
Instant loginTime = clock.instant();
|
Instant loginTime = clock.instant();
|
||||||
return repository.findMenu(user.groupId(), user.userId())
|
return repository.findMenu(
|
||||||
|
tenantId, user.groupId(), user.userId())
|
||||||
.flatMap(menu -> repository.recordLogin(
|
.flatMap(menu -> repository.recordLogin(
|
||||||
user.loginId(), loginTime, remoteAddress, user.userId())
|
tenantId,
|
||||||
|
user.loginId(),
|
||||||
|
loginTime,
|
||||||
|
remoteAddress,
|
||||||
|
user.userId())
|
||||||
.thenReturn(toAuthenticatedIdentity(user, loginTime, menu)));
|
.thenReturn(toAuthenticatedIdentity(user, loginTime, menu)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ class AccessTokenIssuer {
|
|||||||
.jwtID(UUID.randomUUID().toString())
|
.jwtID(UUID.randomUUID().toString())
|
||||||
.claim("client_id", principal.clientId())
|
.claim("client_id", principal.clientId())
|
||||||
.claim("installation_id", principal.installationId())
|
.claim("installation_id", principal.installationId())
|
||||||
|
.claim("installation_uuid",
|
||||||
|
principal.internalInstallationId().toString())
|
||||||
|
.claim("tenant_id", principal.tenantId().toString())
|
||||||
|
.claim("license_id", principal.licenseId().toString())
|
||||||
|
.claim("license_type", principal.licenseType())
|
||||||
|
.claim("package_code", principal.packageCode())
|
||||||
|
.claim("security_version", principal.securityVersion())
|
||||||
.claim("scope", String.join(" ", scopes))
|
.claim("scope", String.join(" ", scopes))
|
||||||
.build();
|
.build();
|
||||||
SignedJWT jwt = new SignedJWT(
|
SignedJWT jwt = new SignedJWT(
|
||||||
|
|||||||
@@ -7,29 +7,33 @@ import com.nimbusds.jose.crypto.RSADecrypter;
|
|||||||
import com.nimbusds.jose.crypto.RSASSAVerifier;
|
import com.nimbusds.jose.crypto.RSASSAVerifier;
|
||||||
import com.nimbusds.jwt.JWTClaimsSet;
|
import com.nimbusds.jwt.JWTClaimsSet;
|
||||||
import com.nimbusds.jwt.SignedJWT;
|
import com.nimbusds.jwt.SignedJWT;
|
||||||
|
import com.cygnus.cloud.tenant.model.ClientInstallation;
|
||||||
|
import com.cygnus.cloud.tenant.model.ClientLicense;
|
||||||
|
import com.cygnus.cloud.tenant.service.TenantRegistrationService;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class ClientAssertionValidator {
|
class ClientAssertionValidator {
|
||||||
|
|
||||||
private final CommunicationSecurityProperties properties;
|
private final CommunicationSecurityProperties properties;
|
||||||
|
private final TenantRegistrationService registrations;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
ClientAssertionValidator(CommunicationSecurityProperties properties, Clock clock) {
|
ClientAssertionValidator(
|
||||||
|
CommunicationSecurityProperties properties,
|
||||||
|
TenantRegistrationService registrations,
|
||||||
|
Clock clock) {
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
|
this.registrations = registrations;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
MachineClientPrincipal validate(String clientId, String encryptedAssertion) {
|
Mono<MachineClientPrincipal> validate(String clientId, String encryptedAssertion) {
|
||||||
CommunicationSecurityProperties.MachineClient client =
|
|
||||||
properties.clients() == null ? null : properties.clients().get(clientId);
|
|
||||||
if (client == null || !client.enabled()) {
|
|
||||||
throw invalid();
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
JWEObject jwe = JWEObject.parse(encryptedAssertion);
|
JWEObject jwe = JWEObject.parse(encryptedAssertion);
|
||||||
if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm())
|
if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm())
|
||||||
@@ -40,25 +44,65 @@ class ClientAssertionValidator {
|
|||||||
PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey())));
|
PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey())));
|
||||||
|
|
||||||
SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString());
|
SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString());
|
||||||
if (!signedJwt.verify(new RSASSAVerifier(
|
JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
|
||||||
PemKeyLoader.publicKey(client.assertionPublicKey())))) {
|
String installationCode = claims.getStringClaim("installation_id");
|
||||||
|
if (installationCode == null || installationCode.isBlank()) {
|
||||||
throw invalid();
|
throw invalid();
|
||||||
}
|
}
|
||||||
|
return registrations.findInstallation(clientId, installationCode)
|
||||||
|
.switchIfEmpty(Mono.error(invalid()))
|
||||||
|
.flatMap(installation -> registrations
|
||||||
|
.findCurrentLicense(installation.tenantId(), clock.instant())
|
||||||
|
.filter(license -> license.isActiveAt(clock.instant()))
|
||||||
|
.switchIfEmpty(Mono.error(new MachineAuthenticationException(
|
||||||
|
"Client license is not active")))
|
||||||
|
.map(license -> verify(
|
||||||
|
clientId, signedJwt, claims, installation, license)))
|
||||||
|
.onErrorMap(
|
||||||
|
exception -> !(exception instanceof MachineAuthenticationException),
|
||||||
|
exception -> new MachineAuthenticationException(
|
||||||
|
"Invalid client assertion", exception));
|
||||||
|
} catch (MachineAuthenticationException exception) {
|
||||||
|
return Mono.error(exception);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
return Mono.error(new MachineAuthenticationException(
|
||||||
|
"Invalid client assertion", exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
|
private MachineClientPrincipal verify(
|
||||||
validateClaims(clientId, client, claims);
|
String clientId,
|
||||||
|
SignedJWT signedJwt,
|
||||||
|
JWTClaimsSet claims,
|
||||||
|
ClientInstallation installation,
|
||||||
|
ClientLicense license) {
|
||||||
|
try {
|
||||||
|
if (!signedJwt.verify(new RSASSAVerifier(
|
||||||
|
PemKeyLoader.publicKey(installation.assertionPublicKey())))) {
|
||||||
|
throw invalid();
|
||||||
|
}
|
||||||
|
validateClaims(clientId, installation, claims);
|
||||||
return new MachineClientPrincipal(
|
return new MachineClientPrincipal(
|
||||||
clientId, client.installationId(), client.scopes());
|
clientId,
|
||||||
|
installation.installationCode(),
|
||||||
|
installation.tenantId(),
|
||||||
|
installation.installationId(),
|
||||||
|
license.licenseId(),
|
||||||
|
license.licenseType(),
|
||||||
|
license.packageCode(),
|
||||||
|
installation.securityVersion(),
|
||||||
|
installation.allowedScopes());
|
||||||
} catch (MachineAuthenticationException exception) {
|
} catch (MachineAuthenticationException exception) {
|
||||||
throw exception;
|
throw exception;
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
throw new MachineAuthenticationException("Invalid client assertion", exception);
|
throw new MachineAuthenticationException(
|
||||||
|
"Invalid client assertion", exception);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateClaims(
|
private void validateClaims(
|
||||||
String clientId,
|
String clientId,
|
||||||
CommunicationSecurityProperties.MachineClient client,
|
ClientInstallation installation,
|
||||||
JWTClaimsSet claims) throws Exception {
|
JWTClaimsSet claims) throws Exception {
|
||||||
Instant now = clock.instant();
|
Instant now = clock.instant();
|
||||||
Date issuedAt = claims.getIssueTime();
|
Date issuedAt = claims.getIssueTime();
|
||||||
@@ -66,7 +110,7 @@ class ClientAssertionValidator {
|
|||||||
if (!clientId.equals(claims.getIssuer())
|
if (!clientId.equals(claims.getIssuer())
|
||||||
|| !clientId.equals(claims.getSubject())
|
|| !clientId.equals(claims.getSubject())
|
||||||
|| !claims.getAudience().contains(properties.tokenAudience())
|
|| !claims.getAudience().contains(properties.tokenAudience())
|
||||||
|| !client.installationId().equals(
|
|| !installation.installationCode().equals(
|
||||||
claims.getStringClaim("installation_id"))
|
claims.getStringClaim("installation_id"))
|
||||||
|| issuedAt == null
|
|| issuedAt == null
|
||||||
|| expiresAt == null
|
|| expiresAt == null
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package com.cygnus.cloud.security;
|
package com.cygnus.cloud.security;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
@@ -16,13 +14,5 @@ public record CommunicationSecurityProperties(
|
|||||||
Duration accessTokenTtl,
|
Duration accessTokenTtl,
|
||||||
String assertionDecryptionPrivateKey,
|
String assertionDecryptionPrivateKey,
|
||||||
String accessTokenPrivateKey,
|
String accessTokenPrivateKey,
|
||||||
String accessTokenPublicKey,
|
String accessTokenPublicKey) {
|
||||||
Map<String, MachineClient> clients) {
|
|
||||||
|
|
||||||
public record MachineClient(
|
|
||||||
boolean enabled,
|
|
||||||
String installationId,
|
|
||||||
String assertionPublicKey,
|
|
||||||
Set<String> scopes) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
package com.cygnus.cloud.security;
|
package com.cygnus.cloud.security;
|
||||||
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
record MachineClientPrincipal(
|
record MachineClientPrincipal(
|
||||||
String clientId,
|
String clientId,
|
||||||
String installationId,
|
String installationId,
|
||||||
|
UUID tenantId,
|
||||||
|
UUID internalInstallationId,
|
||||||
|
UUID licenseId,
|
||||||
|
String licenseType,
|
||||||
|
String packageCode,
|
||||||
|
int securityVersion,
|
||||||
Set<String> allowedScopes) {
|
Set<String> allowedScopes) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.cygnus.cloud.security;
|
|||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
|
||||||
import org.springframework.beans.factory.InitializingBean;
|
import org.springframework.beans.factory.InitializingBean;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -34,33 +33,6 @@ class MachineSecurityConfigurationValidator implements InitializingBean {
|
|||||||
requireText("access-token-private-key", properties.accessTokenPrivateKey());
|
requireText("access-token-private-key", properties.accessTokenPrivateKey());
|
||||||
requireText("access-token-public-key", properties.accessTokenPublicKey());
|
requireText("access-token-public-key", properties.accessTokenPublicKey());
|
||||||
|
|
||||||
Map<String, CommunicationSecurityProperties.MachineClient> clients =
|
|
||||||
properties.clients();
|
|
||||||
if (clients == null || clients.isEmpty()) {
|
|
||||||
throw invalid("at least one machine client is required");
|
|
||||||
}
|
|
||||||
clients.forEach(this::validateClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateClient(
|
|
||||||
String clientId,
|
|
||||||
CommunicationSecurityProperties.MachineClient client) {
|
|
||||||
requireText("clients.<client-id>", clientId);
|
|
||||||
if (client == null) {
|
|
||||||
throw invalid("client '" + clientId + "' has no configuration");
|
|
||||||
}
|
|
||||||
requireText(
|
|
||||||
"clients." + clientId + ".installation-id",
|
|
||||||
client.installationId());
|
|
||||||
requireText(
|
|
||||||
"clients." + clientId + ".assertion-public-key",
|
|
||||||
client.assertionPublicKey());
|
|
||||||
if (client.scopes() == null
|
|
||||||
|| client.scopes().isEmpty()
|
|
||||||
|| client.scopes().stream().anyMatch(this::isBlank)) {
|
|
||||||
throw invalid(
|
|
||||||
"clients." + clientId + ".scopes must contain valid scopes");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireUri(String name, String value) {
|
private void requireUri(String name, String value) {
|
||||||
|
|||||||
@@ -33,23 +33,27 @@ class MachineTokenController {
|
|||||||
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
|
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
|
||||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
Mono<Map<String, Object>> token(ServerWebExchange exchange) {
|
Mono<Map<String, Object>> token(ServerWebExchange exchange) {
|
||||||
return exchange.getFormData().map(this::issueToken);
|
return exchange.getFormData().flatMap(this::issueToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> issueToken(MultiValueMap<String, String> form) {
|
Mono<Map<String, Object>> issueToken(MultiValueMap<String, String> form) {
|
||||||
if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type"))
|
if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type"))
|
||||||
|| !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) {
|
|| !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) {
|
||||||
throw new MachineAuthenticationException("Unsupported token request");
|
throw new MachineAuthenticationException("Unsupported token request");
|
||||||
}
|
}
|
||||||
String clientId = required(form, "client_id");
|
String clientId = required(form, "client_id");
|
||||||
MachineClientPrincipal principal = assertionValidator.validate(
|
|
||||||
clientId, required(form, "client_assertion"));
|
|
||||||
Set<String> requestedScopes = scopes(form.getFirst("scope"));
|
Set<String> requestedScopes = scopes(form.getFirst("scope"));
|
||||||
if (requestedScopes.isEmpty()
|
return assertionValidator.validate(
|
||||||
|| !principal.allowedScopes().containsAll(requestedScopes)) {
|
clientId, required(form, "client_assertion"))
|
||||||
throw new MachineAuthenticationException("Invalid requested scope");
|
.map(principal -> {
|
||||||
}
|
if (requestedScopes.isEmpty()
|
||||||
return tokenIssuer.issue(principal, requestedScopes).asOAuthResponse();
|
|| !principal.allowedScopes().containsAll(requestedScopes)) {
|
||||||
|
throw new MachineAuthenticationException(
|
||||||
|
"Invalid requested scope");
|
||||||
|
}
|
||||||
|
return tokenIssuer.issue(principal, requestedScopes)
|
||||||
|
.asOAuthResponse();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private String required(MultiValueMap<String, String> form, String name) {
|
private String required(MultiValueMap<String, String> form, String name) {
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ final class PemKeyLoader {
|
|||||||
if (location == null || location.isBlank()) {
|
if (location == null || location.isBlank()) {
|
||||||
throw new IllegalArgumentException("RSA key location is not configured");
|
throw new IllegalArgumentException("RSA key location is not configured");
|
||||||
}
|
}
|
||||||
|
if (location.contains("-----BEGIN ")) {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
if (location.startsWith("classpath:")) {
|
if (location.startsWith("classpath:")) {
|
||||||
String resource = location.substring("classpath:".length());
|
String resource = location.substring("classpath:".length());
|
||||||
try (InputStream stream = Thread.currentThread()
|
try (InputStream stream = Thread.currentThread()
|
||||||
|
|||||||
@@ -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}
|
assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem}
|
||||||
access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem}
|
access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem}
|
||||||
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
|
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
|
||||||
clients: {}
|
|
||||||
login-encryption:
|
login-encryption:
|
||||||
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
|
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
|
||||||
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
|
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
|
||||||
|
|||||||
@@ -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.Instant;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
@@ -23,6 +24,8 @@ import reactor.test.StepVerifier;
|
|||||||
class IdentityAuthenticationServiceTest {
|
class IdentityAuthenticationServiceTest {
|
||||||
|
|
||||||
private static final Instant LOGIN_TIME = Instant.parse("2026-07-23T06:30:00Z");
|
private static final Instant LOGIN_TIME = Instant.parse("2026-07-23T06:30:00Z");
|
||||||
|
private static final UUID TENANT_ID =
|
||||||
|
UUID.fromString("00000000-0000-4000-8000-000000000001");
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private IdentityRepository repository;
|
private IdentityRepository repository;
|
||||||
@@ -36,11 +39,17 @@ class IdentityAuthenticationServiceTest {
|
|||||||
MenuItem menuItem = new MenuItem(
|
MenuItem menuItem = new MenuItem(
|
||||||
(short) 10, "Operations", "/ver/operations", (short) 0,
|
(short) 10, "Operations", "/ver/operations", (short) 0,
|
||||||
(short) 1, "110", "_parent", null);
|
(short) 1, "110", "_parent", null);
|
||||||
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
|
when(repository.findUsersByLoginId(TENANT_ID, "maddy"))
|
||||||
|
.thenReturn(Flux.just(user));
|
||||||
when(passwordVerifier.matches("secret", "legacy-value")).thenReturn(true);
|
when(passwordVerifier.matches("secret", "legacy-value")).thenReturn(true);
|
||||||
when(repository.findMenu((short) 4, (short) 25))
|
when(repository.findMenu(TENANT_ID, (short) 4, (short) 25))
|
||||||
.thenReturn(Mono.just(List.of(menuItem)));
|
.thenReturn(Mono.just(List.of(menuItem)));
|
||||||
when(repository.recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25))
|
when(repository.recordLogin(
|
||||||
|
TENANT_ID,
|
||||||
|
"maddy",
|
||||||
|
LOGIN_TIME,
|
||||||
|
"127.0.0.1",
|
||||||
|
(short) 25))
|
||||||
.thenReturn(Mono.just(101L));
|
.thenReturn(Mono.just(101L));
|
||||||
|
|
||||||
IdentityAuthenticationService service = new IdentityAuthenticationService(
|
IdentityAuthenticationService service = new IdentityAuthenticationService(
|
||||||
@@ -48,7 +57,8 @@ class IdentityAuthenticationServiceTest {
|
|||||||
passwordVerifier,
|
passwordVerifier,
|
||||||
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
|
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
|
||||||
|
|
||||||
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
|
StepVerifier.create(service.authenticate(
|
||||||
|
TENANT_ID, "maddy", "secret", "127.0.0.1"))
|
||||||
.assertNext(result -> {
|
.assertNext(result -> {
|
||||||
assertThat(result.loginId()).isEqualTo("maddy");
|
assertThat(result.loginId()).isEqualTo("maddy");
|
||||||
assertThat(result.companyName()).isEqualTo("Matrix");
|
assertThat(result.companyName()).isEqualTo("Matrix");
|
||||||
@@ -57,20 +67,27 @@ class IdentityAuthenticationServiceTest {
|
|||||||
})
|
})
|
||||||
.verifyComplete();
|
.verifyComplete();
|
||||||
|
|
||||||
verify(repository).recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25);
|
verify(repository).recordLogin(
|
||||||
|
TENANT_ID,
|
||||||
|
"maddy",
|
||||||
|
LOGIN_TIME,
|
||||||
|
"127.0.0.1",
|
||||||
|
(short) 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsInactiveUserWithoutLoadingMenu() {
|
void rejectsInactiveUserWithoutLoadingMenu() {
|
||||||
IdentityUser user = user("maddy", false);
|
IdentityUser user = user("maddy", false);
|
||||||
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
|
when(repository.findUsersByLoginId(TENANT_ID, "maddy"))
|
||||||
|
.thenReturn(Flux.just(user));
|
||||||
|
|
||||||
IdentityAuthenticationService service = new IdentityAuthenticationService(
|
IdentityAuthenticationService service = new IdentityAuthenticationService(
|
||||||
repository,
|
repository,
|
||||||
passwordVerifier,
|
passwordVerifier,
|
||||||
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
|
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
|
||||||
|
|
||||||
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
|
StepVerifier.create(service.authenticate(
|
||||||
|
TENANT_ID, "maddy", "secret", "127.0.0.1"))
|
||||||
.expectError(AuthenticationException.class)
|
.expectError(AuthenticationException.class)
|
||||||
.verify();
|
.verify();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ package com.cygnus.cloud.security;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.cygnus.cloud.tenant.model.ClientInstallation;
|
||||||
|
import com.cygnus.cloud.tenant.model.ClientLicense;
|
||||||
|
import com.cygnus.cloud.tenant.model.LicenseStatus;
|
||||||
|
import com.cygnus.cloud.tenant.service.TenantRegistrationService;
|
||||||
import com.nimbusds.jose.EncryptionMethod;
|
import com.nimbusds.jose.EncryptionMethod;
|
||||||
import com.nimbusds.jose.JWEAlgorithm;
|
import com.nimbusds.jose.JWEAlgorithm;
|
||||||
import com.nimbusds.jose.JWEHeader;
|
import com.nimbusds.jose.JWEHeader;
|
||||||
@@ -26,10 +32,12 @@ import java.time.Clock;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
@@ -37,6 +45,7 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.util.LinkedMultiValueMap;
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||||
import org.springframework.web.reactive.function.BodyInserters;
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
class MachineTokenFlowTest {
|
class MachineTokenFlowTest {
|
||||||
|
|
||||||
@@ -53,6 +62,9 @@ class MachineTokenFlowTest {
|
|||||||
private KeyPair clientSigningKeys;
|
private KeyPair clientSigningKeys;
|
||||||
private KeyPair accessTokenKeys;
|
private KeyPair accessTokenKeys;
|
||||||
private CommunicationSecurityProperties properties;
|
private CommunicationSecurityProperties properties;
|
||||||
|
private TenantRegistrationService registrations;
|
||||||
|
private ClientInstallation installation;
|
||||||
|
private ClientLicense license;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() throws Exception {
|
void setUp() throws Exception {
|
||||||
@@ -69,14 +81,33 @@ class MachineTokenFlowTest {
|
|||||||
Duration.ofMinutes(20),
|
Duration.ofMinutes(20),
|
||||||
privatePem("assertion-private.pem", assertionEncryptionKeys),
|
privatePem("assertion-private.pem", assertionEncryptionKeys),
|
||||||
privatePem("access-private.pem", accessTokenKeys),
|
privatePem("access-private.pem", accessTokenKeys),
|
||||||
publicPem("access-public.pem", accessTokenKeys),
|
publicPem("access-public.pem", accessTokenKeys));
|
||||||
Map.of(
|
registrations = mock(TenantRegistrationService.class);
|
||||||
CLIENT_ID,
|
installation = new ClientInstallation(
|
||||||
new CommunicationSecurityProperties.MachineClient(
|
UUID.fromString("10000000-0000-4000-8000-000000000001"),
|
||||||
true,
|
UUID.fromString("00000000-0000-4000-8000-000000000001"),
|
||||||
INSTALLATION_ID,
|
CLIENT_ID,
|
||||||
publicPem("client-public.pem", clientSigningKeys),
|
INSTALLATION_ID,
|
||||||
Set.of("identity.login"))));
|
publicPem("client-public.pem", clientSigningKeys),
|
||||||
|
Set.of("identity.login"),
|
||||||
|
true,
|
||||||
|
1,
|
||||||
|
OffsetDateTime.now(ZoneOffset.UTC));
|
||||||
|
when(registrations.findInstallation(CLIENT_ID, INSTALLATION_ID))
|
||||||
|
.thenReturn(Mono.just(installation));
|
||||||
|
license = new ClientLicense(
|
||||||
|
UUID.fromString("20000000-0000-4000-8000-000000000001"),
|
||||||
|
installation.tenantId(),
|
||||||
|
"ANNUAL",
|
||||||
|
"FULL",
|
||||||
|
NOW.minus(Duration.ofDays(1)),
|
||||||
|
NOW.plus(Duration.ofDays(365)),
|
||||||
|
LicenseStatus.ACTIVE,
|
||||||
|
120,
|
||||||
|
2,
|
||||||
|
OffsetDateTime.now(ZoneOffset.UTC));
|
||||||
|
when(registrations.findCurrentLicense(installation.tenantId(), NOW))
|
||||||
|
.thenReturn(Mono.just(license));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -84,7 +115,7 @@ class MachineTokenFlowTest {
|
|||||||
throws Exception {
|
throws Exception {
|
||||||
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
|
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||||
ClientAssertionValidator validator =
|
ClientAssertionValidator validator =
|
||||||
new ClientAssertionValidator(properties, clock);
|
new ClientAssertionValidator(properties, registrations, clock);
|
||||||
AccessTokenIssuer issuer = new AccessTokenIssuer(properties, clock);
|
AccessTokenIssuer issuer = new AccessTokenIssuer(properties, clock);
|
||||||
MachineTokenController controller =
|
MachineTokenController controller =
|
||||||
new MachineTokenController(validator, issuer);
|
new MachineTokenController(validator, issuer);
|
||||||
@@ -98,7 +129,7 @@ class MachineTokenFlowTest {
|
|||||||
form.add("client_assertion", encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365))));
|
form.add("client_assertion", encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365))));
|
||||||
form.add("scope", "identity.login");
|
form.add("scope", "identity.login");
|
||||||
|
|
||||||
Map<String, Object> response = controller.issueToken(form);
|
Map<String, Object> response = controller.issueToken(form).block();
|
||||||
SignedJWT token = SignedJWT.parse((String) response.get("access_token"));
|
SignedJWT token = SignedJWT.parse((String) response.get("access_token"));
|
||||||
|
|
||||||
assertThat(token.verify(new RSASSAVerifier(
|
assertThat(token.verify(new RSASSAVerifier(
|
||||||
@@ -108,6 +139,12 @@ class MachineTokenFlowTest {
|
|||||||
.isEqualTo(CLIENT_ID);
|
.isEqualTo(CLIENT_ID);
|
||||||
assertThat(token.getJWTClaimsSet().getStringClaim("installation_id"))
|
assertThat(token.getJWTClaimsSet().getStringClaim("installation_id"))
|
||||||
.isEqualTo(INSTALLATION_ID);
|
.isEqualTo(INSTALLATION_ID);
|
||||||
|
assertThat(token.getJWTClaimsSet().getStringClaim("tenant_id"))
|
||||||
|
.isEqualTo(installation.tenantId().toString());
|
||||||
|
assertThat(token.getJWTClaimsSet().getStringClaim("license_id"))
|
||||||
|
.isEqualTo(license.licenseId().toString());
|
||||||
|
assertThat(token.getJWTClaimsSet().getStringClaim("package_code"))
|
||||||
|
.isEqualTo("FULL");
|
||||||
assertThat(token.getJWTClaimsSet().getStringClaim("scope"))
|
assertThat(token.getJWTClaimsSet().getStringClaim("scope"))
|
||||||
.isEqualTo("identity.login");
|
.isEqualTo("identity.login");
|
||||||
assertThat(response)
|
assertThat(response)
|
||||||
@@ -119,21 +156,36 @@ class MachineTokenFlowTest {
|
|||||||
@Test
|
@Test
|
||||||
void rejectsExpiredAssertion() throws Exception {
|
void rejectsExpiredAssertion() throws Exception {
|
||||||
ClientAssertionValidator validator = new ClientAssertionValidator(
|
ClientAssertionValidator validator = new ClientAssertionValidator(
|
||||||
properties, Clock.fixed(NOW, ZoneOffset.UTC));
|
properties, registrations, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||||
|
|
||||||
assertThatThrownBy(() -> validator.validate(
|
assertThatThrownBy(() -> validator.validate(
|
||||||
CLIENT_ID,
|
CLIENT_ID,
|
||||||
encryptedAssertion(
|
encryptedAssertion(
|
||||||
NOW.minus(Duration.ofDays(366)),
|
NOW.minus(Duration.ofDays(366)),
|
||||||
NOW.minusSeconds(1))))
|
NOW.minusSeconds(1))).block())
|
||||||
.isInstanceOf(MachineAuthenticationException.class);
|
.isInstanceOf(MachineAuthenticationException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsClientWithoutAnActiveLicense() throws Exception {
|
||||||
|
when(registrations.findCurrentLicense(installation.tenantId(), NOW))
|
||||||
|
.thenReturn(Mono.empty());
|
||||||
|
ClientAssertionValidator validator = new ClientAssertionValidator(
|
||||||
|
properties, registrations, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> validator.validate(
|
||||||
|
CLIENT_ID,
|
||||||
|
encryptedAssertion(NOW, NOW.plus(Duration.ofDays(1))))
|
||||||
|
.block())
|
||||||
|
.isInstanceOf(MachineAuthenticationException.class)
|
||||||
|
.hasMessage("Client license is not active");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void acceptsOAuthFormEncodedTokenRequestOverHttp() throws Exception {
|
void acceptsOAuthFormEncodedTokenRequestOverHttp() throws Exception {
|
||||||
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
|
Clock clock = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||||
MachineTokenController controller = new MachineTokenController(
|
MachineTokenController controller = new MachineTokenController(
|
||||||
new ClientAssertionValidator(properties, clock),
|
new ClientAssertionValidator(properties, registrations, clock),
|
||||||
new AccessTokenIssuer(properties, clock));
|
new AccessTokenIssuer(properties, clock));
|
||||||
LinkedMultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
LinkedMultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||||
form.add("grant_type", "client_credentials");
|
form.add("grant_type", "client_credentials");
|
||||||
|
|||||||
@@ -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
|
@Test
|
||||||
void applicationContextLoadsControllers() {
|
void applicationContextLoadsControllers() {
|
||||||
try (FileSystemXmlApplicationContext context =
|
System.setProperty("CYGNUS_CLIENT_ID", "test-client");
|
||||||
new FileSystemXmlApplicationContext("build/WebContent/WEB-INF/app-config.xml")) {
|
System.setProperty("CYGNUS_INSTALLATION_ID", "test-installation");
|
||||||
assertFalse(context.getBeansWithAnnotation(Controller.class).isEmpty());
|
System.setProperty("CYGNUS_CLIENT_ASSERTION", "test-assertion");
|
||||||
|
try {
|
||||||
|
try (FileSystemXmlApplicationContext context =
|
||||||
|
new FileSystemXmlApplicationContext(
|
||||||
|
"build/WebContent/WEB-INF/app-config.xml")) {
|
||||||
|
assertFalse(context.getBeansWithAnnotation(Controller.class).isEmpty());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
System.clearProperty("CYGNUS_CLIENT_ID");
|
||||||
|
System.clearProperty("CYGNUS_INSTALLATION_ID");
|
||||||
|
System.clearProperty("CYGNUS_CLIENT_ASSERTION");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,19 @@ INSTALLATION_ID=""
|
|||||||
CLOUD_BASE_URL=""
|
CLOUD_BASE_URL=""
|
||||||
TOKEN_URL=""
|
TOKEN_URL=""
|
||||||
CLIENT_DIR=""
|
CLIENT_DIR=""
|
||||||
CLIENT_CONFIG="${CONFIG_DIR}/clients.yml"
|
|
||||||
CLIENT_PRIVATE_KEY=""
|
CLIENT_PRIVATE_KEY=""
|
||||||
CLIENT_PUBLIC_KEY=""
|
CLIENT_PUBLIC_KEY=""
|
||||||
MACHINE_ASSERTION=""
|
MACHINE_ASSERTION=""
|
||||||
|
CLIENT_NAME=""
|
||||||
|
DB_HOST_VALUE=""
|
||||||
|
DB_PORT_VALUE=""
|
||||||
|
DB_NAME_VALUE=""
|
||||||
|
DB_USER_VALUE=""
|
||||||
|
DB_PASSWORD_VALUE=""
|
||||||
|
LICENSE_TYPE=""
|
||||||
|
PACKAGE_CODE=""
|
||||||
|
LICENSE_MONTHS=""
|
||||||
|
PSQL_BIN=""
|
||||||
SKIP_BUILD_CONFIGURED="${CYGNUS_SETUP_SKIP_BUILD+x}"
|
SKIP_BUILD_CONFIGURED="${CYGNUS_SETUP_SKIP_BUILD+x}"
|
||||||
SKIP_BUILD="${CYGNUS_SETUP_SKIP_BUILD:-false}"
|
SKIP_BUILD="${CYGNUS_SETUP_SKIP_BUILD:-false}"
|
||||||
FORCE_ASSERTION="${CYGNUS_SETUP_FORCE_ASSERTION:-false}"
|
FORCE_ASSERTION="${CYGNUS_SETUP_FORCE_ASSERTION:-false}"
|
||||||
@@ -77,6 +86,11 @@ validate_identifier() {
|
|||||||
"${label} must contain no spaces and use only letters, numbers, '-' or '_'."
|
"${label} must contain no spaces and use only letters, numbers, '-' or '_'."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_client_slug() {
|
||||||
|
[[ "$1" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || fail \
|
||||||
|
"Customer identifier must use lowercase letters, numbers and single hyphens only."
|
||||||
|
}
|
||||||
|
|
||||||
collect_client_inputs() {
|
collect_client_inputs() {
|
||||||
local default_client="${CYGNUS_SETUP_CLIENT_ID:-customer-a}"
|
local default_client="${CYGNUS_SETUP_CLIENT_ID:-customer-a}"
|
||||||
local default_installation="${CYGNUS_SETUP_INSTALLATION_ID:-site-01}"
|
local default_installation="${CYGNUS_SETUP_INSTALLATION_ID:-site-01}"
|
||||||
@@ -86,7 +100,11 @@ collect_client_inputs() {
|
|||||||
CLIENT_ID="$(prompt_value \
|
CLIENT_ID="$(prompt_value \
|
||||||
"Customer identifier (no spaces; used in key and directory names)" \
|
"Customer identifier (no spaces; used in key and directory names)" \
|
||||||
"${default_client}")"
|
"${default_client}")"
|
||||||
validate_identifier "Customer identifier" "${CLIENT_ID}"
|
validate_client_slug "${CLIENT_ID}"
|
||||||
|
CLIENT_NAME="$(prompt_value \
|
||||||
|
"Customer display name" \
|
||||||
|
"${CYGNUS_SETUP_CLIENT_NAME:-${CLIENT_ID}}")"
|
||||||
|
[[ -n "${CLIENT_NAME}" ]] || fail "Customer display name is required."
|
||||||
|
|
||||||
INSTALLATION_ID="$(prompt_value \
|
INSTALLATION_ID="$(prompt_value \
|
||||||
"Installation identifier (no spaces)" \
|
"Installation identifier (no spaces)" \
|
||||||
@@ -111,11 +129,43 @@ collect_client_inputs() {
|
|||||||
CLIENT_PUBLIC_KEY="${CLIENT_DIR}/${CLIENT_ID}-signing-public.pem"
|
CLIENT_PUBLIC_KEY="${CLIENT_DIR}/${CLIENT_ID}-signing-public.pem"
|
||||||
MACHINE_ASSERTION="${CLIENT_DIR}/${CLIENT_ID}-${INSTALLATION_ID}-assertion.jwt"
|
MACHINE_ASSERTION="${CLIENT_DIR}/${CLIENT_ID}-${INSTALLATION_ID}-assertion.jwt"
|
||||||
|
|
||||||
|
DB_HOST_VALUE="$(prompt_value \
|
||||||
|
"Cloud PostgreSQL host" "${CYGNUS_SETUP_DB_HOST:-${DB_HOST:-localhost}}")"
|
||||||
|
DB_PORT_VALUE="$(prompt_value \
|
||||||
|
"Cloud PostgreSQL port" "${CYGNUS_SETUP_DB_PORT:-${DB_PORT:-5432}}")"
|
||||||
|
DB_NAME_VALUE="$(prompt_value \
|
||||||
|
"Cloud PostgreSQL database" "${CYGNUS_SETUP_DB_NAME:-${DB_NAME:-matrix}}")"
|
||||||
|
DB_USER_VALUE="$(prompt_value \
|
||||||
|
"Cloud PostgreSQL user" "${CYGNUS_SETUP_DB_USER:-${DB_USER:-postgres}}")"
|
||||||
|
DB_PASSWORD_VALUE="${CYGNUS_SETUP_DB_PASSWORD:-${DB_PASSWORD:-}}"
|
||||||
|
if [[ -z "${DB_PASSWORD_VALUE}" ]] && [[ "${NON_INTERACTIVE}" != "true" ]] && [[ -t 0 ]]; then
|
||||||
|
read -r -s -p "Cloud PostgreSQL password: " DB_PASSWORD_VALUE
|
||||||
|
printf '\n'
|
||||||
|
fi
|
||||||
|
[[ -n "${DB_PASSWORD_VALUE}" ]] || fail \
|
||||||
|
"Cloud PostgreSQL password is required through the prompt or CYGNUS_SETUP_DB_PASSWORD."
|
||||||
|
|
||||||
|
LICENSE_TYPE="$(prompt_value \
|
||||||
|
"License type" "${CYGNUS_SETUP_LICENSE_TYPE:-ANNUAL}")"
|
||||||
|
PACKAGE_CODE="$(prompt_value \
|
||||||
|
"License package code" "${CYGNUS_SETUP_PACKAGE_CODE:-FULL}")"
|
||||||
|
LICENSE_MONTHS="$(prompt_value \
|
||||||
|
"License validity in months" "${CYGNUS_SETUP_LICENSE_MONTHS:-12}")"
|
||||||
|
validate_identifier "License type" "${LICENSE_TYPE}"
|
||||||
|
validate_identifier "Package code" "${PACKAGE_CODE}"
|
||||||
|
[[ "${LICENSE_MONTHS}" =~ ^[1-9][0-9]*$ ]] || fail \
|
||||||
|
"License validity must be a positive whole number of months."
|
||||||
|
|
||||||
printf '\nProvisioning summary:\n'
|
printf '\nProvisioning summary:\n'
|
||||||
printf ' Customer: %s\n' "${CLIENT_ID}"
|
printf ' Customer: %s\n' "${CLIENT_ID}"
|
||||||
|
printf ' Customer name: %s\n' "${CLIENT_NAME}"
|
||||||
printf ' Installation: %s\n' "${INSTALLATION_ID}"
|
printf ' Installation: %s\n' "${INSTALLATION_ID}"
|
||||||
printf ' Cloud URL: %s\n' "${CLOUD_BASE_URL}"
|
printf ' Cloud URL: %s\n' "${CLOUD_BASE_URL}"
|
||||||
printf ' Token audience: %s\n' "${TOKEN_URL}"
|
printf ' Token audience: %s\n' "${TOKEN_URL}"
|
||||||
|
printf ' Database: %s@%s:%s/%s\n' \
|
||||||
|
"${DB_USER_VALUE}" "${DB_HOST_VALUE}" "${DB_PORT_VALUE}" "${DB_NAME_VALUE}"
|
||||||
|
printf ' License: %s / %s / %s month(s)\n' \
|
||||||
|
"${LICENSE_TYPE}" "${PACKAGE_CODE}" "${LICENSE_MONTHS}"
|
||||||
printf ' Verify build: %s\n' "$([[ "${SKIP_BUILD}" == "true" ]] && printf no || printf yes)"
|
printf ' Verify build: %s\n' "$([[ "${SKIP_BUILD}" == "true" ]] && printf no || printf yes)"
|
||||||
|
|
||||||
if [[ "$(prompt_yes_no "Continue with these values" "true")" != "true" ]]; then
|
if [[ "$(prompt_yes_no "Continue with these values" "true")" != "true" ]]; then
|
||||||
@@ -160,44 +210,78 @@ configure_java_21() {
|
|||||||
"JDK 21 is required. Current java is: $(java -version 2>&1 | head -n 1)"
|
"JDK 21 is required. Current java is: $(java -version 2>&1 | head -n 1)"
|
||||||
}
|
}
|
||||||
|
|
||||||
write_client_configuration() {
|
register_client_in_database() {
|
||||||
local public_key_uri="file:${CLIENT_PUBLIC_KEY}"
|
local client_id_b64 client_name_b64 installation_b64 public_key_b64
|
||||||
local existing_installation=""
|
local license_type_b64 package_code_b64
|
||||||
|
|
||||||
if [[ ! -f "${CLIENT_CONFIG}" ]]; then
|
client_id_b64="$(printf '%s' "${CLIENT_ID}" | openssl base64 -A)"
|
||||||
{
|
client_name_b64="$(printf '%s' "${CLIENT_NAME}" | openssl base64 -A)"
|
||||||
printf '%s\n' "cygnus:"
|
installation_b64="$(printf '%s' "${INSTALLATION_ID}" | openssl base64 -A)"
|
||||||
printf '%s\n' " security:"
|
public_key_b64="$(openssl base64 -A -in "${CLIENT_PUBLIC_KEY}")"
|
||||||
printf '%s\n' " clients:"
|
license_type_b64="$(printf '%s' "${LICENSE_TYPE}" | openssl base64 -A)"
|
||||||
} > "${CLIENT_CONFIG}"
|
package_code_b64="$(printf '%s' "${PACKAGE_CODE}" | openssl base64 -A)"
|
||||||
elif grep -Fq " ${CLIENT_ID}:" "${CLIENT_CONFIG}"; then
|
|
||||||
existing_installation="$(awk \
|
|
||||||
-v client=" ${CLIENT_ID}:" \
|
|
||||||
'$0 == client { found = 1; next }
|
|
||||||
found && /installation-id:/ {
|
|
||||||
sub(/^.*installation-id:[[:space:]]*/, "");
|
|
||||||
print;
|
|
||||||
exit
|
|
||||||
}
|
|
||||||
found && /^ [^[:space:]]/ { exit }' \
|
|
||||||
"${CLIENT_CONFIG}")"
|
|
||||||
if [[ "${existing_installation}" != "${INSTALLATION_ID}" ]]; then
|
|
||||||
fail "Client '${CLIENT_ID}' already uses installation '${existing_installation}' in ${CLIENT_CONFIG}; requested '${INSTALLATION_ID}'. Use the existing installation ID or provision a different customer identifier."
|
|
||||||
fi
|
|
||||||
printf 'Keeping existing configuration for client: %s\n' "${CLIENT_ID}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
PGPASSWORD="${DB_PASSWORD_VALUE}" "${PSQL_BIN}" \
|
||||||
printf ' %s:\n' "${CLIENT_ID}"
|
-h "${DB_HOST_VALUE}" \
|
||||||
printf '%s\n' " enabled: true"
|
-p "${DB_PORT_VALUE}" \
|
||||||
printf ' installation-id: %s\n' "${INSTALLATION_ID}"
|
-U "${DB_USER_VALUE}" \
|
||||||
printf ' assertion-public-key: %s\n' "${public_key_uri}"
|
-d "${DB_NAME_VALUE}" \
|
||||||
printf '%s\n' " scopes:"
|
-X -v ON_ERROR_STOP=1 \
|
||||||
printf '%s\n' " - identity.login"
|
-c "
|
||||||
} >> "${CLIENT_CONFIG}"
|
WITH account AS (
|
||||||
chmod 600 "${CLIENT_CONFIG}"
|
INSERT INTO identity.client_account
|
||||||
printf 'Added client %s to: %s\n' "${CLIENT_ID}" "${CLIENT_CONFIG}"
|
(tenant_id, client_slug, client_name, status)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'),
|
||||||
|
convert_from(decode('${client_name_b64}', 'base64'), 'UTF8'),
|
||||||
|
'ACTIVE')
|
||||||
|
ON CONFLICT (client_slug) DO UPDATE SET
|
||||||
|
client_name = EXCLUDED.client_name,
|
||||||
|
status = 'ACTIVE',
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING tenant_id
|
||||||
|
), installation AS (
|
||||||
|
INSERT INTO identity.client_installation
|
||||||
|
(installation_id, tenant_id, client_id, installation_code,
|
||||||
|
assertion_public_key, allowed_scopes, enabled)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
tenant_id,
|
||||||
|
convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'),
|
||||||
|
convert_from(decode('${installation_b64}', 'base64'), 'UTF8'),
|
||||||
|
convert_from(decode('${public_key_b64}', 'base64'), 'UTF8'),
|
||||||
|
ARRAY['identity.login']::text[],
|
||||||
|
true
|
||||||
|
FROM account
|
||||||
|
ON CONFLICT (client_id, installation_code) DO UPDATE SET
|
||||||
|
tenant_id = EXCLUDED.tenant_id,
|
||||||
|
assertion_public_key = EXCLUDED.assertion_public_key,
|
||||||
|
allowed_scopes = EXCLUDED.allowed_scopes,
|
||||||
|
enabled = true,
|
||||||
|
security_version = identity.client_installation.security_version + 1,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING tenant_id
|
||||||
|
)
|
||||||
|
INSERT INTO identity.client_license
|
||||||
|
(license_id, tenant_id, license_type, package_code,
|
||||||
|
valid_from, valid_until, status)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid(),
|
||||||
|
tenant_id,
|
||||||
|
convert_from(decode('${license_type_b64}', 'base64'), 'UTF8'),
|
||||||
|
convert_from(decode('${package_code_b64}', 'base64'), 'UTF8'),
|
||||||
|
now(),
|
||||||
|
now() + make_interval(months => ${LICENSE_MONTHS}),
|
||||||
|
'ACTIVE'
|
||||||
|
FROM installation
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM identity.client_license existing
|
||||||
|
WHERE existing.tenant_id = installation.tenant_id
|
||||||
|
AND existing.status = 'ACTIVE'
|
||||||
|
AND existing.valid_until > now()
|
||||||
|
);"
|
||||||
}
|
}
|
||||||
|
|
||||||
generate_machine_assertion() {
|
generate_machine_assertion() {
|
||||||
@@ -236,6 +320,13 @@ main() {
|
|||||||
require_command awk
|
require_command awk
|
||||||
require_command openssl
|
require_command openssl
|
||||||
require_command mvn
|
require_command mvn
|
||||||
|
if command -v psql >/dev/null 2>&1; then
|
||||||
|
PSQL_BIN="$(command -v psql)"
|
||||||
|
elif [[ -x /Library/PostgreSQL/17/bin/psql ]]; then
|
||||||
|
PSQL_BIN="/Library/PostgreSQL/17/bin/psql"
|
||||||
|
else
|
||||||
|
fail "PostgreSQL psql was not found in PATH or /Library/PostgreSQL/17/bin."
|
||||||
|
fi
|
||||||
configure_java_21
|
configure_java_21
|
||||||
java -version
|
java -version
|
||||||
mvn -version
|
mvn -version
|
||||||
@@ -277,8 +368,8 @@ main() {
|
|||||||
"${CLIENT_PRIVATE_KEY}" \
|
"${CLIENT_PRIVATE_KEY}" \
|
||||||
"${CLIENT_PUBLIC_KEY}"
|
"${CLIENT_PUBLIC_KEY}"
|
||||||
|
|
||||||
log "Writing cloud machine-client configuration"
|
log "Registering tenant, installation, and license in the cloud database"
|
||||||
write_client_configuration
|
register_client_in_database
|
||||||
|
|
||||||
log "Generating encrypted machine assertion"
|
log "Generating encrypted machine assertion"
|
||||||
generate_machine_assertion
|
generate_machine_assertion
|
||||||
@@ -288,7 +379,7 @@ main() {
|
|||||||
"Client ID: ${CLIENT_ID}" \
|
"Client ID: ${CLIENT_ID}" \
|
||||||
"Installation ID: ${INSTALLATION_ID}" \
|
"Installation ID: ${INSTALLATION_ID}" \
|
||||||
"Token audience: ${TOKEN_URL}" \
|
"Token audience: ${TOKEN_URL}" \
|
||||||
"Client config: ${CLIENT_CONFIG}" \
|
"Registration: PostgreSQL identity schema" \
|
||||||
"Assertion file: ${MACHINE_ASSERTION}" \
|
"Assertion file: ${MACHINE_ASSERTION}" \
|
||||||
"Login public key: ${KEY_DIR}/login-public.pem" \
|
"Login public key: ${KEY_DIR}/login-public.pem" \
|
||||||
"" \
|
"" \
|
||||||
|
|||||||
Reference in New Issue
Block a user