Compare commits
30 Commits
a0468d2e6a
...
edp-punchi
| Author | SHA1 | Date | |
|---|---|---|---|
| bc00515c1b | |||
| 60f9aa05a0 | |||
| 4705649ae8 | |||
| 5acaffc224 | |||
| 0a1b901b12 | |||
| 440c13cc49 | |||
| 9b9557105c | |||
| af360d7793 | |||
| 452e6189e4 | |||
| 7e4fb66fc6 | |||
| 213f560c4a | |||
| 8449578424 | |||
| 1adcc04efc | |||
| 0e5de99f55 | |||
| 0dae53017d | |||
| 934937feb0 | |||
| dcb6850306 | |||
| f264f90f3b | |||
| cd11b389e5 | |||
| b3836639bf | |||
| 6f1fb56670 | |||
| 8ef8bf5d92 | |||
| d684931bc5 | |||
| 3cf5c83264 | |||
| ebeaaa2629 | |||
| b84dbc9a56 | |||
| 1817d02c31 | |||
| 4afe00e1f8 | |||
| 60f5450f47 | |||
| d6dc33d9b1 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -28,3 +28,10 @@
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
/cygnus-onprem-app/target
|
||||
/cygnus-cloud-client/target
|
||||
/cygnus-cloud-service/target
|
||||
/cygnus-installer/src/target
|
||||
/cygnus-installer/target
|
||||
/cygnus-onprem-db/target
|
||||
/cygnus-lib/target
|
||||
|
||||
12
.vscode/launch.json
vendored
12
.vscode/launch.json
vendored
@@ -30,7 +30,13 @@
|
||||
"CYGNUS_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem",
|
||||
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
|
||||
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem",
|
||||
"SPRING_CONFIG_ADDITIONAL_LOCATION": "file:${workspaceFolder}/config/clients.yml"
|
||||
"CYGNUS_MAIL_HOST": "smtp.gmail.com",
|
||||
"CYGNUS_MAIL_PORT": "587",
|
||||
"CYGNUS_MAIL_USERNAME": "technobeesolutions@gmail.com",
|
||||
"CYGNUS_MAIL_PASSWORD": "lrideibfakickldg",
|
||||
"CYGNUS_MAIL_SMTP_AUTH": "true",
|
||||
"CYGNUS_MAIL_STARTTLS": "true",
|
||||
"CYGNUS_REGISTRATION_EMAIL_FROM": "technobeesolutions@gmail.com"
|
||||
},
|
||||
"shortenCommandLine": "argfile"
|
||||
},
|
||||
@@ -53,7 +59,9 @@
|
||||
"REDIS_HOST": "103.125.129.116",
|
||||
"REDIS_PORT": "7901",
|
||||
"REDIS_PASSWORD": "M@triXR3d1s@6202",
|
||||
"REDIS_DATABASE": "1",
|
||||
"REDIS_SSL": "false",
|
||||
"CYGNUS_QUERY_CACHE_ENABLED": "false",
|
||||
"CYGNUS_CLOUD_BASE_URL": "http://localhost:8090",
|
||||
"CYGNUS_TOKEN_URL": "http://localhost:8090/oauth2/token",
|
||||
"CYGNUS_CLIENT_ID": "matrix",
|
||||
@@ -61,6 +69,8 @@
|
||||
"CYGNUS_CLIENT_ASSERTION": "file:${workspaceFolder}/config/clients/matrix/matrix-matrix-delhi-cygnus-01-assertion.jwt",
|
||||
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
|
||||
"CYGNUS_LOGIN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/login-public.pem",
|
||||
"CYGNUS_PAYLOAD_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/case-save-private.pem",
|
||||
"CYGNUS_PAYLOAD_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/case-save-public.pem",
|
||||
"CYGNUS_CLOUD_REQUEST_TIMEOUT": "PT10S",
|
||||
"CYGNUS_TOKEN_REFRESH_SKEW": "PT30S"
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -29,18 +29,24 @@ It is valid for one year; the access token obtained with it is short-lived.
|
||||
|
||||
For local development, the repository setup script automates prerequisite
|
||||
checks, the full Maven verification, directory creation, all three cloud key
|
||||
pairs, the installation key pair, `config/clients.yml`, and the encrypted
|
||||
machine assertion:
|
||||
pairs, the installation key pair, database-backed tenant/install registration,
|
||||
an initial license, and the encrypted machine assertion:
|
||||
|
||||
```bash
|
||||
./scripts/setup-local-communication.sh
|
||||
```
|
||||
|
||||
The script interactively asks for the customer identifier, installation
|
||||
identifier, cloud URL, and whether to run the full verification. Customer and
|
||||
installation identifiers cannot contain spaces; the customer identifier is
|
||||
used for its directory and signing-key filenames. New customers are appended
|
||||
to `config/clients.yml` without replacing existing customers.
|
||||
The script interactively asks for the customer name and slug, installation
|
||||
identifier, cloud URL, database connection, license package/type/duration, and
|
||||
whether to run the full verification. Customer and installation identifiers
|
||||
cannot contain spaces. The slug is the stable tenant key and is used for its
|
||||
directory and signing-key filenames.
|
||||
|
||||
The client account, installation public key, allowed scopes, and license are
|
||||
upserted into PostgreSQL (`identity.client_account`,
|
||||
`identity.client_installation`, and `identity.client_license`). The cloud
|
||||
service resolves this registration dynamically through Redis with PostgreSQL
|
||||
fallback, so adding another customer does not require a cloud restart.
|
||||
|
||||
It preserves existing private keys and assertions. Set
|
||||
`CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be
|
||||
@@ -65,6 +71,6 @@ mvn -pl cygnus-cloud-client exec:java \
|
||||
client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt"
|
||||
```
|
||||
|
||||
Copy only `client-signing-public.pem` into that customer's cloud-side client
|
||||
configuration. Keep the private key and generated assertion on the on-premises
|
||||
server with owner-only filesystem permissions.
|
||||
The setup script stores `client-signing-public.pem` in the installation record
|
||||
used by the cloud. Keep the private key and generated assertion only on the
|
||||
on-premises server with owner-only filesystem permissions.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cygnus.client;
|
||||
|
||||
import com.cygnus.client.model.CloudIdentitySession;
|
||||
import com.cygnus.client.model.CloudQueryResponse;
|
||||
import com.cygnus.client.model.LoginPayload;
|
||||
import com.cygnus.client.security.LoginEnvelopeEncryptor;
|
||||
import com.cygnus.client.security.MachineTokenProvider;
|
||||
@@ -50,4 +51,26 @@ public class CloudIdentityClient {
|
||||
.bodyToMono(CloudIdentitySession.class))
|
||||
.timeout(properties.requestTimeout());
|
||||
}
|
||||
|
||||
public Mono<CloudQueryResponse> fetchQuery(int queryId) {
|
||||
return tokenProvider.accessToken()
|
||||
.flatMap(token -> webClient.get()
|
||||
.uri(properties.baseUri().resolve("/api/v1/queries/" + queryId))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.retrieve()
|
||||
.bodyToMono(CloudQueryResponse.class))
|
||||
.timeout(properties.requestTimeout());
|
||||
}
|
||||
|
||||
public Mono<CloudQueryResponse> fetchQuery(String queryKey) {
|
||||
return tokenProvider.accessToken()
|
||||
.flatMap(token -> webClient.get()
|
||||
.uri(properties.baseUri().resolve("/api/v1/queries/key/" + queryKey))
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.retrieve()
|
||||
.bodyToMono(CloudQueryResponse.class))
|
||||
.timeout(properties.requestTimeout());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.cygnus.client.model;
|
||||
|
||||
public record CloudQueryResponse(int queryId, String query) {
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
artifactId=cygnus-cloud-client
|
||||
groupId=com.cygnus
|
||||
version=1.0.0-SNAPSHOT
|
||||
@@ -1,14 +0,0 @@
|
||||
com/cygnus/client/security/MachineTokenProvider.class
|
||||
com/cygnus/client/security/OAuthMachineTokenProvider$TokenResponse.class
|
||||
com/cygnus/client/provisioning/MachineAssertionGenerator.class
|
||||
com/cygnus/client/CloudClientProperties.class
|
||||
com/cygnus/client/security/LoginEnvelopeEncryptor.class
|
||||
com/cygnus/client/model/LoginPayload.class
|
||||
com/cygnus/client/model/CloudIdentitySession.class
|
||||
com/cygnus/client/security/OAuthMachineTokenProvider.class
|
||||
com/cygnus/client/security/OAuthMachineTokenProvider$CachedToken.class
|
||||
com/cygnus/client/CloudClientFactory.class
|
||||
com/cygnus/client/model/CloudMenuItem.class
|
||||
com/cygnus/client/model/EncryptedLoginRequest.class
|
||||
com/cygnus/client/security/CloudClientException.class
|
||||
com/cygnus/client/CloudIdentityClient.class
|
||||
@@ -1,12 +0,0 @@
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientFactory.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudClientProperties.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/CloudIdentityClient.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudIdentitySession.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/CloudMenuItem.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/EncryptedLoginRequest.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/model/LoginPayload.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/provisioning/MachineAssertionGenerator.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/CloudClientException.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/LoginEnvelopeEncryptor.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/MachineTokenProvider.java
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/main/java/com/cygnus/client/security/OAuthMachineTokenProvider.java
|
||||
@@ -1 +0,0 @@
|
||||
com/cygnus/client/CloudClientPropertiesTest.class
|
||||
@@ -1 +0,0 @@
|
||||
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/test/java/com/cygnus/client/CloudClientPropertiesTest.java
|
||||
@@ -1,64 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report.xsd" version="3.0.2" name="com.cygnus.client.CloudClientPropertiesTest" time="0.016" tests="2" errors="0" skipped="0" failures="0">
|
||||
<properties>
|
||||
<property name="java.specification.version" value="21"/>
|
||||
<property name="sun.jnu.encoding" value="UTF-8"/>
|
||||
<property name="java.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/test-classes:/Users/maddy/Projects/matrix/cygnus-cloud-client/target/classes:/Users/maddy/.m2/repository/org/springframework/spring-webflux/6.2.19/spring-webflux-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-beans/6.2.19/spring-beans-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-core/6.2.19/spring-core-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-jcl/6.2.19/spring-jcl-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-web/6.2.19/spring-web-6.2.19.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-observation/1.15.12/micrometer-observation-1.15.12.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-commons/1.15.12/micrometer-commons-1.15.12.jar:/Users/maddy/.m2/repository/io/projectreactor/reactor-core/3.7.19/reactor-core-3.7.19.jar:/Users/maddy/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-http/1.2.8/reactor-netty-http-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http/4.1.122.Final/netty-codec-http-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-common/4.1.122.Final/netty-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-buffer/4.1.122.Final/netty-buffer-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport/4.1.122.Final/netty-transport-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec/4.1.122.Final/netty-codec-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-handler/4.1.122.Final/netty-handler-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http2/4.1.122.Final/netty-codec-http2-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns/4.1.122.Final/netty-resolver-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver/4.1.122.Final/netty-resolver-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-dns/4.1.122.Final/netty-codec-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-native-macos/4.1.122.Final/netty-resolver-dns-native-macos-4.1.122.Final-osx-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-classes-macos/4.1.122.Final/netty-resolver-dns-classes-macos-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-epoll/4.1.122.Final/netty-transport-native-epoll-4.1.122.Final-linux-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-unix-common/4.1.122.Final/netty-transport-native-unix-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-classes-epoll/4.1.122.Final/netty-transport-classes-epoll-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-core/1.2.8/reactor-netty-core-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-handler-proxy/4.1.122.Final/netty-handler-proxy-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-socks/4.1.122.Final/netty-codec-socks-4.1.122.Final.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.18.6/jackson-databind-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.18.6/jackson-annotations-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.18.6/jackson-core-2.18.6.jar:/Users/maddy/.m2/repository/com/nimbusds/nimbus-jose-jwt/10.4/nimbus-jose-jwt-10.4.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter/5.12.2/junit-jupiter-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.12.2/junit-jupiter-api-5.12.2.jar:/Users/maddy/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-commons/1.12.2/junit-platform-commons-1.12.2.jar:/Users/maddy/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.12.2/junit-jupiter-params-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.12.2/junit-jupiter-engine-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-engine/1.12.2/junit-platform-engine-1.12.2.jar:"/>
|
||||
<property name="java.vm.vendor" value="Microsoft"/>
|
||||
<property name="sun.arch.data.model" value="64"/>
|
||||
<property name="java.vendor.url" value="https://www.microsoft.com"/>
|
||||
<property name="os.name" value="Mac OS X"/>
|
||||
<property name="java.vm.specification.version" value="21"/>
|
||||
<property name="sun.java.launcher" value="SUN_STANDARD"/>
|
||||
<property name="user.country" value="US"/>
|
||||
<property name="sun.boot.library.path" value="/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home/lib"/>
|
||||
<property name="sun.java.command" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260724215520397_3.jar /Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire 2026-07-24T21-55-20_356-jvmRun1 surefire-20260724215520397_1tmp surefire_0-20260724215520397_2tmp"/>
|
||||
<property name="http.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
||||
<property name="jdk.debug" value="release"/>
|
||||
<property name="test" value="CloudClientPropertiesTest"/>
|
||||
<property name="surefire.test.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/test-classes:/Users/maddy/Projects/matrix/cygnus-cloud-client/target/classes:/Users/maddy/.m2/repository/org/springframework/spring-webflux/6.2.19/spring-webflux-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-beans/6.2.19/spring-beans-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-core/6.2.19/spring-core-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-jcl/6.2.19/spring-jcl-6.2.19.jar:/Users/maddy/.m2/repository/org/springframework/spring-web/6.2.19/spring-web-6.2.19.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-observation/1.15.12/micrometer-observation-1.15.12.jar:/Users/maddy/.m2/repository/io/micrometer/micrometer-commons/1.15.12/micrometer-commons-1.15.12.jar:/Users/maddy/.m2/repository/io/projectreactor/reactor-core/3.7.19/reactor-core-3.7.19.jar:/Users/maddy/.m2/repository/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-http/1.2.8/reactor-netty-http-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http/4.1.122.Final/netty-codec-http-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-common/4.1.122.Final/netty-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-buffer/4.1.122.Final/netty-buffer-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport/4.1.122.Final/netty-transport-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec/4.1.122.Final/netty-codec-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-handler/4.1.122.Final/netty-handler-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-http2/4.1.122.Final/netty-codec-http2-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns/4.1.122.Final/netty-resolver-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver/4.1.122.Final/netty-resolver-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-dns/4.1.122.Final/netty-codec-dns-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-native-macos/4.1.122.Final/netty-resolver-dns-native-macos-4.1.122.Final-osx-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-resolver-dns-classes-macos/4.1.122.Final/netty-resolver-dns-classes-macos-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-epoll/4.1.122.Final/netty-transport-native-epoll-4.1.122.Final-linux-x86_64.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-native-unix-common/4.1.122.Final/netty-transport-native-unix-common-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-transport-classes-epoll/4.1.122.Final/netty-transport-classes-epoll-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/projectreactor/netty/reactor-netty-core/1.2.8/reactor-netty-core-1.2.8.jar:/Users/maddy/.m2/repository/io/netty/netty-handler-proxy/4.1.122.Final/netty-handler-proxy-4.1.122.Final.jar:/Users/maddy/.m2/repository/io/netty/netty-codec-socks/4.1.122.Final/netty-codec-socks-4.1.122.Final.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.18.6/jackson-databind-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-annotations/2.18.6/jackson-annotations-2.18.6.jar:/Users/maddy/.m2/repository/com/fasterxml/jackson/core/jackson-core/2.18.6/jackson-core-2.18.6.jar:/Users/maddy/.m2/repository/com/nimbusds/nimbus-jose-jwt/10.4/nimbus-jose-jwt-10.4.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter/5.12.2/junit-jupiter-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-api/5.12.2/junit-jupiter-api-5.12.2.jar:/Users/maddy/.m2/repository/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-commons/1.12.2/junit-platform-commons-1.12.2.jar:/Users/maddy/.m2/repository/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-params/5.12.2/junit-jupiter-params-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/jupiter/junit-jupiter-engine/5.12.2/junit-jupiter-engine-5.12.2.jar:/Users/maddy/.m2/repository/org/junit/platform/junit-platform-engine/1.12.2/junit-platform-engine-1.12.2.jar:"/>
|
||||
<property name="sun.cpu.endian" value="little"/>
|
||||
<property name="user.home" value="/Users/maddy"/>
|
||||
<property name="user.language" value="en"/>
|
||||
<property name="java.specification.vendor" value="Oracle Corporation"/>
|
||||
<property name="java.version.date" value="2025-07-15"/>
|
||||
<property name="java.home" value="/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home"/>
|
||||
<property name="file.separator" value="/"/>
|
||||
<property name="basedir" value="/Users/maddy/Projects/matrix/cygnus-cloud-client"/>
|
||||
<property name="java.vm.compressedOopsMode" value="Zero based"/>
|
||||
<property name="line.separator" value=" "/>
|
||||
<property name="java.vm.specification.vendor" value="Oracle Corporation"/>
|
||||
<property name="java.specification.name" value="Java Platform API Specification"/>
|
||||
<property name="apple.awt.application.name" value="ForkedBooter"/>
|
||||
<property name="surefire.real.class.path" value="/Users/maddy/Projects/matrix/cygnus-cloud-client/target/surefire/surefirebooter-20260724215520397_3.jar"/>
|
||||
<property name="sun.management.compiler" value="HotSpot 64-Bit Tiered Compilers"/>
|
||||
<property name="ftp.nonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
||||
<property name="java.runtime.version" value="21.0.8+9-LTS"/>
|
||||
<property name="user.name" value="maddy"/>
|
||||
<property name="stdout.encoding" value="UTF-8"/>
|
||||
<property name="path.separator" value=":"/>
|
||||
<property name="os.version" value="26.5.2"/>
|
||||
<property name="java.runtime.name" value="OpenJDK Runtime Environment"/>
|
||||
<property name="file.encoding" value="UTF-8"/>
|
||||
<property name="java.vm.name" value="OpenJDK 64-Bit Server VM"/>
|
||||
<property name="java.vendor.version" value="Microsoft-11933201"/>
|
||||
<property name="localRepository" value="/Users/maddy/.m2/repository"/>
|
||||
<property name="java.vendor.url.bug" value="https://github.com/microsoft/openjdk/issues"/>
|
||||
<property name="java.io.tmpdir" value="/var/folders/1l/36214rdn79755j30lcnmgsqh0000gn/T/"/>
|
||||
<property name="java.version" value="21.0.8"/>
|
||||
<property name="user.dir" value="/Users/maddy/Projects/matrix/cygnus-cloud-client"/>
|
||||
<property name="os.arch" value="aarch64"/>
|
||||
<property name="java.vm.specification.name" value="Java Virtual Machine Specification"/>
|
||||
<property name="native.encoding" value="UTF-8"/>
|
||||
<property name="java.library.path" value="/Users/maddy/Library/Java/Extensions:/Library/Java/Extensions:/Network/Library/Java/Extensions:/System/Library/Java/Extensions:/usr/lib/java:."/>
|
||||
<property name="java.vm.info" value="mixed mode, sharing"/>
|
||||
<property name="stderr.encoding" value="UTF-8"/>
|
||||
<property name="java.vendor" value="Microsoft"/>
|
||||
<property name="java.vm.version" value="21.0.8+9-LTS"/>
|
||||
<property name="sun.io.unicode.encoding" value="UnicodeBig"/>
|
||||
<property name="socksNonProxyHosts" value="local|*.local|169.254/16|*.169.254/16"/>
|
||||
<property name="java.class.version" value="65.0"/>
|
||||
</properties>
|
||||
<testcase name="configurationAcceptsCompleteMachineIdentity" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.007"/>
|
||||
<testcase name="configurationRequiresMachineCredentials" classname="com.cygnus.client.CloudClientPropertiesTest" time="0.001"/>
|
||||
</testsuite>
|
||||
@@ -1,4 +0,0 @@
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.cygnus.client.CloudClientPropertiesTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.016 s -- in com.cygnus.client.CloudClientPropertiesTest
|
||||
Binary file not shown.
@@ -53,10 +53,10 @@ on-premises gateway. The client assertion must be:
|
||||
- bound to the configured client ID, installation ID, and token audience;
|
||||
- unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`.
|
||||
|
||||
The endpoint returns a short-lived RS256 access token carrying `client_id`,
|
||||
`installation_id`, and the approved scope. The identity endpoint requires the
|
||||
`identity.login` scope and verifies the same machine binding in the encrypted
|
||||
login payload.
|
||||
The endpoint returns a short-lived RS256 access token carrying the client,
|
||||
installation, tenant, license, security-version, and approved-scope claims.
|
||||
The identity endpoint requires the `identity.login` scope and verifies the
|
||||
same machine and tenant binding in the encrypted login payload.
|
||||
|
||||
Generate separate cloud key pairs:
|
||||
|
||||
@@ -73,26 +73,28 @@ openssl pkey -in config/keys/access-token-private.pem -pubout \
|
||||
chmod 600 config/keys/*private.pem
|
||||
```
|
||||
|
||||
Configure clients in an external Spring YAML file rather than the packaged
|
||||
`application.yml`:
|
||||
## Dynamic tenant, installation, and license registration
|
||||
|
||||
```yaml
|
||||
cygnus:
|
||||
security:
|
||||
enabled: true
|
||||
issuer-uri: https://cloud.example.com
|
||||
audience: cygnus-cloud-api
|
||||
token-audience: https://cloud.example.com/oauth2/token
|
||||
clients:
|
||||
customer-a:
|
||||
enabled: true
|
||||
installation-id: site-01
|
||||
assertion-public-key: file:/secure/clients/customer-a/public.pem
|
||||
scopes:
|
||||
- identity.login
|
||||
```
|
||||
Machine clients are no longer configured in a runtime `clients.yml`. The
|
||||
authoritative records are:
|
||||
|
||||
Start with that protected file using
|
||||
`--spring.config.additional-location=file:/secure/cygnus/clients.yml`.
|
||||
Never place cloud private keys, customer assertions, or installation private
|
||||
keys in the repository or container image.
|
||||
- `identity.client_account`: tenant identity and status;
|
||||
- `identity.client_installation`: machine identity, assertion public key,
|
||||
allowed scopes, enabled state, and security version;
|
||||
- `identity.client_license`: subscription period, package, type, status, and
|
||||
licensed limits.
|
||||
|
||||
Token issuance resolves the installation and active license through a
|
||||
Redis cache-aside service with PostgreSQL fallback. Cache entries have a
|
||||
bounded TTL and can be invalidated after administrative changes. Therefore,
|
||||
new customers, installations, key rotations, scope changes, and license
|
||||
changes do not require restarting the cloud service.
|
||||
|
||||
The login/menu queries are tenant-scoped. Tenant-owned identity tables carry
|
||||
`tenant_id`; `identity.pages` remains the shared feature catalog while
|
||||
permissions are assigned per tenant.
|
||||
|
||||
Use `scripts/setup-local-communication.sh` to create keys, register or update
|
||||
the database records, create the initial license, and generate the on-premises
|
||||
machine assertion. Never place cloud private keys, customer assertions, or
|
||||
installation private keys in the repository or container image.
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
|
||||
@@ -38,6 +38,15 @@ public class ReactiveCacheService {
|
||||
return redis.delete(cacheKey(namespace, key)).map(deleted -> deleted > 0);
|
||||
}
|
||||
|
||||
public Mono<Long> increment(String namespace, String key, Duration ttl) {
|
||||
String fullKey = cacheKey(namespace, key);
|
||||
return redis.opsForValue()
|
||||
.increment(fullKey)
|
||||
.flatMap(count -> count == 1
|
||||
? redis.expire(fullKey, ttl).thenReturn(count)
|
||||
: Mono.just(count));
|
||||
}
|
||||
|
||||
private String cacheKey(String namespace, String key) {
|
||||
return properties.keyPrefix() + ':' + namespace + ':' + key;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.cygnus.cloud.database;
|
||||
import io.vertx.sqlclient.Pool;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.RowSet;
|
||||
import io.vertx.sqlclient.SqlConnection;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -29,4 +31,37 @@ public class ReactiveDatabaseClient {
|
||||
return Mono.fromCompletionStage(
|
||||
() -> pool.preparedQuery(sql).execute(parameters).toCompletionStage());
|
||||
}
|
||||
|
||||
public Mono<Integer> preparedUpdate(String sql, Tuple parameters) {
|
||||
return preparedQuery(sql, parameters).map(RowSet::rowCount);
|
||||
}
|
||||
|
||||
public <T> Mono<T> inTransaction(Function<SqlConnection, Mono<T>> work) {
|
||||
return Mono.usingWhen(
|
||||
Mono.fromCompletionStage(() -> pool.getConnection().toCompletionStage()),
|
||||
connection -> Mono.fromCompletionStage(
|
||||
() -> connection.begin().toCompletionStage())
|
||||
.flatMap(transaction -> work.apply(connection)
|
||||
.flatMap(result -> Mono.fromCompletionStage(
|
||||
() -> transaction.commit().toCompletionStage())
|
||||
.thenReturn(result))
|
||||
.onErrorResume(error -> Mono.fromCompletionStage(
|
||||
() -> transaction.rollback().toCompletionStage())
|
||||
.onErrorResume(ignored -> Mono.empty())
|
||||
.then(Mono.error(error)))),
|
||||
connection -> Mono.fromCompletionStage(
|
||||
() -> connection.close().toCompletionStage()),
|
||||
(connection, error) -> Mono.fromCompletionStage(
|
||||
() -> connection.close().toCompletionStage()),
|
||||
connection -> Mono.fromCompletionStage(
|
||||
() -> connection.close().toCompletionStage()));
|
||||
}
|
||||
|
||||
public Mono<RowSet<Row>> preparedQuery(
|
||||
SqlConnection connection, String sql, Tuple parameters) {
|
||||
return Mono.fromCompletionStage(
|
||||
() -> connection.preparedQuery(sql)
|
||||
.execute(parameters)
|
||||
.toCompletionStage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.cygnus.cloud.identity.service.LoginRequestReplayService;
|
||||
import jakarta.validation.Valid;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
@@ -64,12 +65,21 @@ public class CloudLoginController {
|
||||
return Mono.error(new AuthenticationException("Login request replayed"));
|
||||
}
|
||||
return authenticationService.authenticate(
|
||||
tenantId(machineJwt),
|
||||
payload.loginId(),
|
||||
payload.password(),
|
||||
remoteAddress(serverRequest));
|
||||
});
|
||||
}
|
||||
|
||||
private UUID tenantId(Jwt jwt) {
|
||||
try {
|
||||
return UUID.fromString(jwt.getClaimAsString("tenant_id"));
|
||||
} catch (RuntimeException exception) {
|
||||
throw new AuthenticationException("Machine tenant is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePayload(LoginPayload payload) {
|
||||
if (payload == null
|
||||
|| !StringUtils.hasText(payload.loginId())
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.cygnus.cloud.identity.api;
|
||||
|
||||
import com.cygnus.cloud.identity.service.AuthenticationException;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
@@ -9,10 +11,12 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class IdentityErrorHandler {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(IdentityErrorHandler.class);
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
Map<String, String> authenticationFailure() {
|
||||
Map<String, String> authenticationFailure(AuthenticationException exception) {
|
||||
LOGGER.warn("Identity authentication rejected: {}", exception.getMessage());
|
||||
return Map.of("code", "AUTHENTICATION_FAILED", "message", "Authentication failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
@@ -20,25 +21,32 @@ public class IdentityRepository {
|
||||
g.name AS group_name, u.branch_id, b.branchname, b.branchcode, b.city,
|
||||
u.company_id, c.companyname, c.companycode, u.isactive
|
||||
FROM identity.app_user u
|
||||
JOIN identity.user_group g ON g.group_id = u.group_id
|
||||
JOIN identity.company c ON c.company_id = u.company_id
|
||||
JOIN identity.user_group g
|
||||
ON g.tenant_id = u.tenant_id AND g.group_id = u.group_id
|
||||
JOIN identity.company c
|
||||
ON c.tenant_id = u.tenant_id AND c.company_id = u.company_id
|
||||
JOIN identity.company_branch b
|
||||
ON b.branch_id = u.branch_id AND b.company_id = u.company_id
|
||||
WHERE upper(u.loginid) = upper($1)
|
||||
ON b.tenant_id = u.tenant_id
|
||||
AND b.branch_id = u.branch_id
|
||||
AND b.company_id = u.company_id
|
||||
WHERE u.tenant_id = $1
|
||||
AND upper(u.loginid) = upper($2)
|
||||
""";
|
||||
|
||||
private static final String FIND_MENU = """
|
||||
SELECT p.page_id, p.menulabel, p.targeturl, p.parentpage, p.pageorder,
|
||||
permissions.permission, p.targetwindow, permissions.requestval
|
||||
FROM identity.permission permissions
|
||||
FROM identity.permission permissions
|
||||
JOIN identity.pages p ON p.page_id = permissions.page_id
|
||||
WHERE permissions.group_id = $1
|
||||
WHERE permissions.tenant_id = $1
|
||||
AND permissions.group_id = $2
|
||||
AND p.isvisible = 1
|
||||
AND permissions.permission <> '000'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM identity.denied_pages denied
|
||||
WHERE denied.user_id = $2
|
||||
WHERE denied.tenant_id = permissions.tenant_id
|
||||
AND denied.user_id = $3
|
||||
AND denied.page_id = permissions.page_id
|
||||
AND denied.isdenied = 1
|
||||
)
|
||||
@@ -47,8 +55,8 @@ public class IdentityRepository {
|
||||
|
||||
private static final String RECORD_LOGIN = """
|
||||
INSERT INTO identity.user_loginhistory
|
||||
(loginid, logintime, ipaddr, user_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
(tenant_id, loginid, logintime, ipaddr, user_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING uid
|
||||
""";
|
||||
|
||||
@@ -60,23 +68,34 @@ public class IdentityRepository {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public Flux<IdentityUser> findUsersByLoginId(String loginId) {
|
||||
return database.preparedQuery(FIND_USER, Tuple.of(loginId))
|
||||
public Flux<IdentityUser> findUsersByLoginId(UUID tenantId, String loginId) {
|
||||
return database.preparedQuery(FIND_USER, Tuple.of(tenantId, loginId))
|
||||
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user));
|
||||
}
|
||||
|
||||
public Mono<List<MenuItem>> findMenu(short groupId, short userId) {
|
||||
return database.preparedQuery(FIND_MENU, Tuple.of(groupId, userId))
|
||||
public Mono<List<MenuItem>> findMenu(
|
||||
UUID tenantId, short groupId, short userId) {
|
||||
return database.preparedQuery(
|
||||
FIND_MENU, Tuple.of(tenantId, groupId, userId))
|
||||
.flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::menuItem))
|
||||
.collectList();
|
||||
}
|
||||
|
||||
public Mono<Long> recordLogin(
|
||||
String loginId, Instant loginTime, String remoteAddress, short userId) {
|
||||
UUID tenantId,
|
||||
String loginId,
|
||||
Instant loginTime,
|
||||
String remoteAddress,
|
||||
short userId) {
|
||||
LocalDateTime databaseTime = LocalDateTime.ofInstant(loginTime, ZoneOffset.UTC);
|
||||
return database.preparedQuery(
|
||||
RECORD_LOGIN,
|
||||
Tuple.of(loginId, databaseTime, remoteAddress, userId))
|
||||
Tuple.of(
|
||||
tenantId,
|
||||
loginId,
|
||||
databaseTime,
|
||||
remoteAddress,
|
||||
userId))
|
||||
.map(rows -> rows.iterator().next().getLong("uid"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.cygnus.cloud.identity.model.IdentityUser;
|
||||
import com.cygnus.cloud.identity.repository.IdentityRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@@ -25,8 +26,11 @@ public class IdentityAuthenticationService {
|
||||
}
|
||||
|
||||
public Mono<AuthenticatedIdentity> authenticate(
|
||||
String loginId, String password, String remoteAddress) {
|
||||
return repository.findUsersByLoginId(loginId)
|
||||
UUID tenantId,
|
||||
String loginId,
|
||||
String password,
|
||||
String remoteAddress) {
|
||||
return repository.findUsersByLoginId(tenantId, loginId)
|
||||
.collectList()
|
||||
.flatMap(users -> {
|
||||
if (users.isEmpty()) {
|
||||
@@ -49,9 +53,14 @@ public class IdentityAuthenticationService {
|
||||
return Mono.error(new AuthenticationException("Invalid credentials"));
|
||||
}
|
||||
Instant loginTime = clock.instant();
|
||||
return repository.findMenu(user.groupId(), user.userId())
|
||||
return repository.findMenu(
|
||||
tenantId, user.groupId(), user.userId())
|
||||
.flatMap(menu -> repository.recordLogin(
|
||||
user.loginId(), loginTime, remoteAddress, user.userId())
|
||||
tenantId,
|
||||
user.loginId(),
|
||||
loginTime,
|
||||
remoteAddress,
|
||||
user.userId())
|
||||
.thenReturn(toAuthenticatedIdentity(user, loginTime, menu)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
public record CloudQuery(int queryId, String query) {
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/queries")
|
||||
public class CloudQueryController {
|
||||
|
||||
private final QueryCatalogRepository repository;
|
||||
|
||||
public CloudQueryController(QueryCatalogRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@GetMapping("/{queryId}")
|
||||
public Mono<CloudQuery> query(
|
||||
@PathVariable @Min(1) int queryId) {
|
||||
return repository.findEnabled(queryId)
|
||||
.switchIfEmpty(Mono.error(new QueryNotFoundException(queryId)));
|
||||
}
|
||||
|
||||
@GetMapping("/key/{queryKey}")
|
||||
public Mono<CloudQuery> queryByKey(@PathVariable String queryKey) {
|
||||
return repository.findEnabled(queryKey)
|
||||
.switchIfEmpty(Mono.error(new QueryNotFoundException(queryKey)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class QueryCatalogRepository {
|
||||
|
||||
private static final String INITIALIZE = """
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
CREATE SEQUENCE IF NOT EXISTS platform.application_query_id_seq;
|
||||
CREATE TABLE IF NOT EXISTS platform.application_query (
|
||||
query_id integer PRIMARY KEY DEFAULT nextval('platform.application_query_id_seq'),
|
||||
query_key varchar(100),
|
||||
query_text text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
CONSTRAINT ck_platform_application_query_id
|
||||
CHECK (query_id > 0),
|
||||
CONSTRAINT ck_platform_application_query_text
|
||||
CHECK (length(btrim(query_text)) > 0)
|
||||
);
|
||||
ALTER TABLE platform.application_query
|
||||
ADD COLUMN IF NOT EXISTS query_key varchar(100);
|
||||
ALTER TABLE platform.application_query ALTER COLUMN query_id
|
||||
SET DEFAULT nextval('platform.application_query_id_seq');
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_platform_application_query_key
|
||||
ON platform.application_query (query_key) WHERE query_key IS NOT NULL;
|
||||
SELECT setval('platform.application_query_id_seq',
|
||||
greatest(coalesce((SELECT max(query_id) FROM platform.application_query), 0) + 1, 1), false)
|
||||
""";
|
||||
private static final String FIND = """
|
||||
SELECT query_id, query_text
|
||||
FROM platform.application_query
|
||||
WHERE query_id = $1
|
||||
AND enabled = true
|
||||
""";
|
||||
private static final String FIND_BY_KEY = """
|
||||
SELECT query_id, query_text
|
||||
FROM platform.application_query
|
||||
WHERE query_key = $1
|
||||
AND enabled = true
|
||||
""";
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public QueryCatalogRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Mono<Void> initialize() {
|
||||
return database.query(INITIALIZE).then();
|
||||
}
|
||||
|
||||
public Mono<CloudQuery> findEnabled(int queryId) {
|
||||
return database.preparedQuery(FIND, Tuple.of(queryId))
|
||||
.flatMapMany(rows -> reactor.core.publisher.Flux.fromIterable(rows))
|
||||
.next()
|
||||
.map(row -> new CloudQuery(
|
||||
row.getInteger("query_id"), row.getString("query_text")));
|
||||
}
|
||||
|
||||
public Mono<CloudQuery> findEnabled(String queryKey) {
|
||||
return database.preparedQuery(FIND_BY_KEY, Tuple.of(queryKey))
|
||||
.flatMapMany(rows -> reactor.core.publisher.Flux.fromIterable(rows))
|
||||
.next()
|
||||
.map(row -> new CloudQuery(row.getInteger("query_id"), row.getString("query_text")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class QueryCatalogSchemaInitializer implements ApplicationRunner {
|
||||
|
||||
private final QueryCatalogRepository repository;
|
||||
|
||||
public QueryCatalogSchemaInitializer(QueryCatalogRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments arguments) {
|
||||
repository.initialize()
|
||||
.block(Duration.ofMinutes(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cygnus.cloud.query;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public class QueryNotFoundException extends RuntimeException {
|
||||
|
||||
public QueryNotFoundException(int queryId) {
|
||||
super("Query was not found: " + queryId);
|
||||
}
|
||||
|
||||
public QueryNotFoundException(String queryKey) {
|
||||
super("Query was not found: " + queryKey);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@ class AccessTokenIssuer {
|
||||
.jwtID(UUID.randomUUID().toString())
|
||||
.claim("client_id", principal.clientId())
|
||||
.claim("installation_id", principal.installationId())
|
||||
.claim("installation_uuid",
|
||||
principal.internalInstallationId().toString())
|
||||
.claim("tenant_id", principal.tenantId().toString())
|
||||
.claim("license_id", principal.licenseId().toString())
|
||||
.claim("license_type", principal.licenseType())
|
||||
.claim("package_code", principal.packageCode())
|
||||
.claim("security_version", principal.securityVersion())
|
||||
.claim("scope", String.join(" ", scopes))
|
||||
.build();
|
||||
SignedJWT jwt = new SignedJWT(
|
||||
|
||||
@@ -7,29 +7,33 @@ import com.nimbusds.jose.crypto.RSADecrypter;
|
||||
import com.nimbusds.jose.crypto.RSASSAVerifier;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import com.cygnus.cloud.tenant.model.ClientInstallation;
|
||||
import com.cygnus.cloud.tenant.model.ClientLicense;
|
||||
import com.cygnus.cloud.tenant.service.TenantRegistrationService;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Component
|
||||
class ClientAssertionValidator {
|
||||
|
||||
private final CommunicationSecurityProperties properties;
|
||||
private final TenantRegistrationService registrations;
|
||||
private final Clock clock;
|
||||
|
||||
ClientAssertionValidator(CommunicationSecurityProperties properties, Clock clock) {
|
||||
ClientAssertionValidator(
|
||||
CommunicationSecurityProperties properties,
|
||||
TenantRegistrationService registrations,
|
||||
Clock clock) {
|
||||
this.properties = properties;
|
||||
this.registrations = registrations;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
MachineClientPrincipal validate(String clientId, String encryptedAssertion) {
|
||||
CommunicationSecurityProperties.MachineClient client =
|
||||
properties.clients() == null ? null : properties.clients().get(clientId);
|
||||
if (client == null || !client.enabled()) {
|
||||
throw invalid();
|
||||
}
|
||||
Mono<MachineClientPrincipal> validate(String clientId, String encryptedAssertion) {
|
||||
try {
|
||||
JWEObject jwe = JWEObject.parse(encryptedAssertion);
|
||||
if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm())
|
||||
@@ -40,25 +44,69 @@ class ClientAssertionValidator {
|
||||
PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey())));
|
||||
|
||||
SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString());
|
||||
if (!signedJwt.verify(new RSASSAVerifier(
|
||||
PemKeyLoader.publicKey(client.assertionPublicKey())))) {
|
||||
JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
|
||||
String installationCode = claims.getStringClaim("installation_id");
|
||||
if (installationCode == null || installationCode.isBlank()) {
|
||||
throw invalid();
|
||||
}
|
||||
return registrations.findInstallation(clientId, installationCode)
|
||||
.switchIfEmpty(Mono.error(invalid()))
|
||||
.flatMap(installation -> registrations
|
||||
.findCurrentLicense(installation.tenantId(), clock.instant())
|
||||
.filter(license -> license.isActiveAt(clock.instant()))
|
||||
.switchIfEmpty(Mono.error(new MachineAuthenticationException(
|
||||
"Client license is not active")))
|
||||
.map(license -> verify(
|
||||
clientId, signedJwt, claims, installation, license))
|
||||
.flatMap(principal -> registrations
|
||||
.touchInstallation(
|
||||
installation.installationId(), clock.instant())
|
||||
.thenReturn(principal)))
|
||||
.onErrorMap(
|
||||
exception -> !(exception instanceof MachineAuthenticationException),
|
||||
exception -> new MachineAuthenticationException(
|
||||
"Invalid client assertion", exception));
|
||||
} catch (MachineAuthenticationException exception) {
|
||||
return Mono.error(exception);
|
||||
} catch (Exception exception) {
|
||||
return Mono.error(new MachineAuthenticationException(
|
||||
"Invalid client assertion", exception));
|
||||
}
|
||||
}
|
||||
|
||||
JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
|
||||
validateClaims(clientId, client, claims);
|
||||
private MachineClientPrincipal verify(
|
||||
String clientId,
|
||||
SignedJWT signedJwt,
|
||||
JWTClaimsSet claims,
|
||||
ClientInstallation installation,
|
||||
ClientLicense license) {
|
||||
try {
|
||||
if (!signedJwt.verify(new RSASSAVerifier(
|
||||
PemKeyLoader.publicKey(installation.assertionPublicKey())))) {
|
||||
throw invalid();
|
||||
}
|
||||
validateClaims(clientId, installation, claims);
|
||||
return new MachineClientPrincipal(
|
||||
clientId, client.installationId(), client.scopes());
|
||||
clientId,
|
||||
installation.installationCode(),
|
||||
installation.tenantId(),
|
||||
installation.installationId(),
|
||||
license.licenseId(),
|
||||
license.licenseType(),
|
||||
license.packageCode(),
|
||||
installation.securityVersion(),
|
||||
installation.allowedScopes());
|
||||
} catch (MachineAuthenticationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new MachineAuthenticationException("Invalid client assertion", exception);
|
||||
throw new MachineAuthenticationException(
|
||||
"Invalid client assertion", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateClaims(
|
||||
String clientId,
|
||||
CommunicationSecurityProperties.MachineClient client,
|
||||
ClientInstallation installation,
|
||||
JWTClaimsSet claims) throws Exception {
|
||||
Instant now = clock.instant();
|
||||
Date issuedAt = claims.getIssueTime();
|
||||
@@ -66,7 +114,7 @@ class ClientAssertionValidator {
|
||||
if (!clientId.equals(claims.getIssuer())
|
||||
|| !clientId.equals(claims.getSubject())
|
||||
|| !claims.getAudience().contains(properties.tokenAudience())
|
||||
|| !client.installationId().equals(
|
||||
|| !installation.installationCode().equals(
|
||||
claims.getStringClaim("installation_id"))
|
||||
|| issuedAt == null
|
||||
|| expiresAt == null
|
||||
|
||||
@@ -30,8 +30,16 @@ public class CloudSecurityConfiguration {
|
||||
.authorizeExchange(exchange -> exchange
|
||||
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||
.pathMatchers("/oauth2/token").permitAll()
|
||||
.pathMatchers(
|
||||
"/api/v1/installations/activation/validate",
|
||||
"/api/v1/installations/register")
|
||||
.permitAll()
|
||||
.pathMatchers("/api/v1/identity/login")
|
||||
.hasAuthority("SCOPE_identity.login")
|
||||
.pathMatchers("/api/v1/queries/**")
|
||||
.hasAuthority("SCOPE_identity.login")
|
||||
.pathMatchers("/api/v1/admin/**")
|
||||
.hasAuthority("SCOPE_cygnus.admin")
|
||||
.anyExchange().authenticated())
|
||||
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
|
||||
.build();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@@ -16,13 +14,5 @@ public record CommunicationSecurityProperties(
|
||||
Duration accessTokenTtl,
|
||||
String assertionDecryptionPrivateKey,
|
||||
String accessTokenPrivateKey,
|
||||
String accessTokenPublicKey,
|
||||
Map<String, MachineClient> clients) {
|
||||
|
||||
public record MachineClient(
|
||||
boolean enabled,
|
||||
String installationId,
|
||||
String assertionPublicKey,
|
||||
Set<String> scopes) {
|
||||
}
|
||||
String accessTokenPublicKey) {
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package com.cygnus.cloud.security;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
record MachineClientPrincipal(
|
||||
String clientId,
|
||||
String installationId,
|
||||
UUID tenantId,
|
||||
UUID internalInstallationId,
|
||||
UUID licenseId,
|
||||
String licenseType,
|
||||
String packageCode,
|
||||
int securityVersion,
|
||||
Set<String> allowedScopes) {
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.cygnus.cloud.security;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -34,33 +33,6 @@ class MachineSecurityConfigurationValidator implements InitializingBean {
|
||||
requireText("access-token-private-key", properties.accessTokenPrivateKey());
|
||||
requireText("access-token-public-key", properties.accessTokenPublicKey());
|
||||
|
||||
Map<String, CommunicationSecurityProperties.MachineClient> clients =
|
||||
properties.clients();
|
||||
if (clients == null || clients.isEmpty()) {
|
||||
throw invalid("at least one machine client is required");
|
||||
}
|
||||
clients.forEach(this::validateClient);
|
||||
}
|
||||
|
||||
private void validateClient(
|
||||
String clientId,
|
||||
CommunicationSecurityProperties.MachineClient client) {
|
||||
requireText("clients.<client-id>", clientId);
|
||||
if (client == null) {
|
||||
throw invalid("client '" + clientId + "' has no configuration");
|
||||
}
|
||||
requireText(
|
||||
"clients." + clientId + ".installation-id",
|
||||
client.installationId());
|
||||
requireText(
|
||||
"clients." + clientId + ".assertion-public-key",
|
||||
client.assertionPublicKey());
|
||||
if (client.scopes() == null
|
||||
|| client.scopes().isEmpty()
|
||||
|| client.scopes().stream().anyMatch(this::isBlank)) {
|
||||
throw invalid(
|
||||
"clients." + clientId + ".scopes must contain valid scopes");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireUri(String name, String value) {
|
||||
|
||||
@@ -33,23 +33,27 @@ class MachineTokenController {
|
||||
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
Mono<Map<String, Object>> token(ServerWebExchange exchange) {
|
||||
return exchange.getFormData().map(this::issueToken);
|
||||
return exchange.getFormData().flatMap(this::issueToken);
|
||||
}
|
||||
|
||||
Map<String, Object> issueToken(MultiValueMap<String, String> form) {
|
||||
Mono<Map<String, Object>> issueToken(MultiValueMap<String, String> form) {
|
||||
if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type"))
|
||||
|| !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) {
|
||||
throw new MachineAuthenticationException("Unsupported token request");
|
||||
}
|
||||
String clientId = required(form, "client_id");
|
||||
MachineClientPrincipal principal = assertionValidator.validate(
|
||||
clientId, required(form, "client_assertion"));
|
||||
Set<String> requestedScopes = scopes(form.getFirst("scope"));
|
||||
if (requestedScopes.isEmpty()
|
||||
|| !principal.allowedScopes().containsAll(requestedScopes)) {
|
||||
throw new MachineAuthenticationException("Invalid requested scope");
|
||||
}
|
||||
return tokenIssuer.issue(principal, requestedScopes).asOAuthResponse();
|
||||
return assertionValidator.validate(
|
||||
clientId, required(form, "client_assertion"))
|
||||
.map(principal -> {
|
||||
if (requestedScopes.isEmpty()
|
||||
|| !principal.allowedScopes().containsAll(requestedScopes)) {
|
||||
throw new MachineAuthenticationException(
|
||||
"Invalid requested scope");
|
||||
}
|
||||
return tokenIssuer.issue(principal, requestedScopes)
|
||||
.asOAuthResponse();
|
||||
});
|
||||
}
|
||||
|
||||
private String required(MultiValueMap<String, String> form, String name) {
|
||||
|
||||
@@ -66,6 +66,9 @@ final class PemKeyLoader {
|
||||
if (location == null || location.isBlank()) {
|
||||
throw new IllegalArgumentException("RSA key location is not configured");
|
||||
}
|
||||
if (location.contains("-----BEGIN ")) {
|
||||
return location;
|
||||
}
|
||||
if (location.startsWith("classpath:")) {
|
||||
String resource = location.substring("classpath:".length());
|
||||
try (InputStream stream = Thread.currentThread()
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivationValidationRequest(
|
||||
@NotBlank @Size(max = 40) String clientCode,
|
||||
@NotBlank @Size(max = 80) String licenseKey,
|
||||
@NotNull UUID installationUuid,
|
||||
@NotBlank @Size(max = 40)
|
||||
@Pattern(regexp = "^[A-Za-z0-9._-]+$")
|
||||
String installerVersion) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivationValidationResponse(
|
||||
String activationToken,
|
||||
OffsetDateTime expiresAt,
|
||||
UUID tenantId,
|
||||
String tenantSlug,
|
||||
String packageCode,
|
||||
int maximumInstallations) {
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import com.cygnus.cloud.tenant.repository.ClientAdministrationRepository;
|
||||
import com.cygnus.cloud.tenant.service.LicenseKeyService;
|
||||
import com.cygnus.cloud.tenant.service.InstallationLifecycleService;
|
||||
import com.cygnus.cloud.tenant.service.RegistrationEmailService;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin")
|
||||
public class ClientAdministrationController {
|
||||
|
||||
private final ClientAdministrationRepository repository;
|
||||
private final LicenseKeyService licenseKeyService;
|
||||
private final RegistrationEmailService emailService;
|
||||
private final InstallationLifecycleService installationLifecycleService;
|
||||
|
||||
public ClientAdministrationController(
|
||||
ClientAdministrationRepository repository,
|
||||
LicenseKeyService licenseKeyService,
|
||||
RegistrationEmailService emailService,
|
||||
InstallationLifecycleService installationLifecycleService) {
|
||||
this.repository = repository;
|
||||
this.licenseKeyService = licenseKeyService;
|
||||
this.emailService = emailService;
|
||||
this.installationLifecycleService = installationLifecycleService;
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/installations/{installationId}/decommission")
|
||||
public Mono<?> decommission(
|
||||
@PathVariable("tenantId") UUID tenantId,
|
||||
@PathVariable("installationId") UUID installationId,
|
||||
@Valid @RequestBody RetireInstallationRequest request,
|
||||
Authentication authentication) {
|
||||
return installationLifecycleService.retire(
|
||||
tenantId,
|
||||
installationId,
|
||||
"DECOMMISSIONED",
|
||||
actor(authentication),
|
||||
request.reason());
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/installations/{installationId}/revoke")
|
||||
public Mono<?> revoke(
|
||||
@PathVariable("tenantId") UUID tenantId,
|
||||
@PathVariable("installationId") UUID installationId,
|
||||
@Valid @RequestBody RetireInstallationRequest request,
|
||||
Authentication authentication) {
|
||||
return installationLifecycleService.retire(
|
||||
tenantId,
|
||||
installationId,
|
||||
"REVOKED",
|
||||
actor(authentication),
|
||||
request.reason());
|
||||
}
|
||||
|
||||
@PostMapping("/client-registrations")
|
||||
public Mono<Map<String, UUID>> registration(
|
||||
@Valid @RequestBody CreateClientRegistrationRequest request,
|
||||
Authentication authentication) {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tuple values = Tuple.tuple()
|
||||
.addUUID(id)
|
||||
.addString(request.clientCode())
|
||||
.addString(request.legalCompanyName())
|
||||
.addString(request.tradeName())
|
||||
.addString(request.pan())
|
||||
.addString(request.cin())
|
||||
.addString(request.gstNumber())
|
||||
.addString(request.billingAddressLine1())
|
||||
.addString(request.billingAddressLine2())
|
||||
.addString(request.billingCity())
|
||||
.addString(request.billingState())
|
||||
.addString(request.billingPostalCode())
|
||||
.addString(request.billingCountry())
|
||||
.addString(request.billingEmail())
|
||||
.addString(request.primaryContactName())
|
||||
.addString(request.primaryContactEmail())
|
||||
.addString(request.primaryContactNumber())
|
||||
.addString(request.alternateContactName())
|
||||
.addString(request.alternateContactEmail())
|
||||
.addString(request.alternateContactNumber())
|
||||
.addLocalDate(request.contractStartDate())
|
||||
.addLocalDate(request.contractEndDate())
|
||||
.addString("ACTIVE")
|
||||
.addString(actor(authentication));
|
||||
return repository.createRegistration(values)
|
||||
.flatMap(created -> created
|
||||
? Mono.just(Map.of("registrationId", id))
|
||||
: Mono.error(new IllegalStateException(
|
||||
"Registration was not created")));
|
||||
}
|
||||
|
||||
@PostMapping("/client-registrations/{registrationId}/tenants")
|
||||
public Mono<Map<String, UUID>> tenant(
|
||||
@PathVariable("registrationId") UUID registrationId,
|
||||
@Valid @RequestBody CreateTenantRequest request) {
|
||||
UUID id = UUID.randomUUID();
|
||||
return repository.createTenant(
|
||||
id, registrationId, request.clientSlug(), request.clientName())
|
||||
.flatMap(created -> created
|
||||
? Mono.just(Map.of("tenantId", id))
|
||||
: Mono.error(new IllegalStateException("Tenant was not created")));
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/licenses")
|
||||
public Mono<Map<String, UUID>> license(
|
||||
@PathVariable("tenantId") UUID tenantId,
|
||||
@Valid @RequestBody CreateLicenseRequest request) {
|
||||
if (!request.validUntil().isAfter(request.validFrom())) {
|
||||
return Mono.error(new IllegalArgumentException(
|
||||
"License end must be after start"));
|
||||
}
|
||||
UUID id = UUID.randomUUID();
|
||||
return repository.createLicense(
|
||||
id,
|
||||
tenantId,
|
||||
request.licenseType(),
|
||||
request.packageCode(),
|
||||
request.validFrom(),
|
||||
request.validUntil(),
|
||||
request.maximumUsers(),
|
||||
request.maximumInstallations())
|
||||
.flatMap(created -> created
|
||||
? Mono.just(Map.of("licenseId", id))
|
||||
: Mono.error(new IllegalStateException("License was not created")));
|
||||
}
|
||||
|
||||
@PostMapping("/tenants/{tenantId}/licenses/{licenseId}/activation-key")
|
||||
public Mono<Map<String, String>> activationKey(
|
||||
@PathVariable("tenantId") UUID tenantId,
|
||||
@PathVariable("licenseId") UUID licenseId,
|
||||
@Valid @RequestBody IssueLicenseKeyRequest request,
|
||||
Authentication authentication) {
|
||||
return licenseKeyService.issue(
|
||||
tenantId, licenseId, request.expiresAt(), actor(authentication))
|
||||
.flatMap(issued -> repository
|
||||
.findLicenseDeliveryDetails(tenantId, licenseId)
|
||||
.switchIfEmpty(Mono.error(
|
||||
new IllegalArgumentException("Tenant or license not found")))
|
||||
.flatMap(details -> emailService.sendLicense(
|
||||
details.primaryContactEmail(),
|
||||
details.clientCode(),
|
||||
details.tenantSlug(),
|
||||
details.packageCode(),
|
||||
details.maximumInstallations(),
|
||||
request.expiresAt(),
|
||||
issued)
|
||||
.onErrorResume(error -> licenseKeyService
|
||||
.revoke(issued.activationKeyId())
|
||||
.then(Mono.error(error)))
|
||||
.thenReturn(Map.of(
|
||||
"activationKeyId",
|
||||
issued.activationKeyId().toString(),
|
||||
"keyHint",
|
||||
issued.keyHint(),
|
||||
"delivery",
|
||||
"EMAIL_SENT"))));
|
||||
}
|
||||
|
||||
private String actor(Authentication authentication) {
|
||||
return authentication == null ? "system" : authentication.getName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record CreateClientRegistrationRequest(
|
||||
@NotBlank @Pattern(regexp = "^[A-Z0-9]+(?:-[A-Z0-9]+)*$")
|
||||
@Size(max = 40) String clientCode,
|
||||
@NotBlank @Size(max = 240) String legalCompanyName,
|
||||
@Size(max = 240) String tradeName,
|
||||
@Size(max = 20) String pan,
|
||||
@Size(max = 30) String cin,
|
||||
@Size(max = 30) String gstNumber,
|
||||
@Size(max = 300) String billingAddressLine1,
|
||||
@Size(max = 300) String billingAddressLine2,
|
||||
@Size(max = 120) String billingCity,
|
||||
@Size(max = 120) String billingState,
|
||||
@Size(max = 20) String billingPostalCode,
|
||||
@NotBlank @Pattern(regexp = "^[A-Z]{2}$") String billingCountry,
|
||||
@Email @Size(max = 254) String billingEmail,
|
||||
@Size(max = 160) String primaryContactName,
|
||||
@Email @Size(max = 254) String primaryContactEmail,
|
||||
@Size(max = 30) String primaryContactNumber,
|
||||
@Size(max = 160) String alternateContactName,
|
||||
@Email @Size(max = 254) String alternateContactEmail,
|
||||
@Size(max = 30) String alternateContactNumber,
|
||||
@NotNull LocalDate contractStartDate,
|
||||
LocalDate contractEndDate) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record CreateLicenseRequest(
|
||||
@NotBlank @Size(max = 30) String licenseType,
|
||||
@NotBlank @Size(max = 50) String packageCode,
|
||||
@NotNull OffsetDateTime validFrom,
|
||||
@NotNull OffsetDateTime validUntil,
|
||||
@Min(1) Integer maximumUsers,
|
||||
@Min(1) @Max(100) int maximumInstallations) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record CreateTenantRequest(
|
||||
@NotBlank @Pattern(regexp = "^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
@Size(max = 80) String clientSlug,
|
||||
@NotBlank @Size(max = 200) String clientName) {
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
|
||||
import com.cygnus.cloud.tenant.service.InstallationActivationService;
|
||||
import com.cygnus.cloud.tenant.service.ActivationRateLimiter;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/installations")
|
||||
public class InstallationActivationController {
|
||||
|
||||
private final InstallationActivationService activationService;
|
||||
private final ActivationRateLimiter rateLimiter;
|
||||
|
||||
public InstallationActivationController(
|
||||
InstallationActivationService activationService,
|
||||
ActivationRateLimiter rateLimiter) {
|
||||
this.activationService = activationService;
|
||||
this.rateLimiter = rateLimiter;
|
||||
}
|
||||
|
||||
@PostMapping("/activation/validate")
|
||||
public Mono<ActivationValidationResponse> validate(
|
||||
@Valid @RequestBody ActivationValidationRequest request,
|
||||
ServerHttpRequest serverRequest) {
|
||||
String sourceIp = remoteAddress(serverRequest);
|
||||
return rateLimiter.check(sourceIp, request.clientCode())
|
||||
.then(activationService.validateAndCreateSession(
|
||||
request.clientCode(),
|
||||
request.licenseKey(),
|
||||
request.installationUuid(),
|
||||
sourceIp,
|
||||
request.installerVersion()))
|
||||
.map(session -> new ActivationValidationResponse(
|
||||
session.token(),
|
||||
session.expiresAt(),
|
||||
session.tenantId(),
|
||||
session.clientSlug(),
|
||||
session.packageCode(),
|
||||
session.maxInstallations()));
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public Mono<RegisteredInstallation> register(
|
||||
@Valid @RequestBody InstallationRegistrationRequest request) {
|
||||
return activationService.register(
|
||||
request.activationToken(),
|
||||
request.installationCode(),
|
||||
request.installationName(),
|
||||
request.assertionPublicKey(),
|
||||
request.softwareVersion(),
|
||||
request.environment());
|
||||
}
|
||||
|
||||
private String remoteAddress(ServerHttpRequest request) {
|
||||
return request.getRemoteAddress() == null
|
||||
? null
|
||||
: request.getRemoteAddress().getAddress().getHostAddress();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import com.cygnus.cloud.tenant.service.InstallationActivationException;
|
||||
import com.cygnus.cloud.tenant.service.InstallationCodeConflictException;
|
||||
import com.cygnus.cloud.tenant.service.LicenseKeyException;
|
||||
import com.cygnus.cloud.tenant.service.ActivationRateLimitException;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class InstallationActivationErrorHandler {
|
||||
|
||||
@ExceptionHandler(InstallationCodeConflictException.class)
|
||||
@ResponseStatus(HttpStatus.CONFLICT)
|
||||
Map<String, String> installationCodeConflict(
|
||||
InstallationCodeConflictException exception) {
|
||||
return Map.of(
|
||||
"code", "INSTALLATION_CODE_ALREADY_EXISTS",
|
||||
"message", exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({LicenseKeyException.class, InstallationActivationException.class})
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
Map<String, String> activationFailure() {
|
||||
return Map.of(
|
||||
"code", "INSTALLATION_ACTIVATION_FAILED",
|
||||
"message", "Installation activation could not be completed");
|
||||
}
|
||||
|
||||
@ExceptionHandler(ActivationRateLimitException.class)
|
||||
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
|
||||
Map<String, String> rateLimited() {
|
||||
return Map.of(
|
||||
"code", "INSTALLATION_ACTIVATION_RATE_LIMITED",
|
||||
"message", "Too many activation attempts; retry later");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record InstallationRegistrationRequest(
|
||||
@NotBlank @Size(max = 100) String activationToken,
|
||||
@NotBlank @Size(max = 100)
|
||||
@Pattern(regexp = "^[A-Za-z0-9][A-Za-z0-9_-]*$")
|
||||
String installationCode,
|
||||
@NotBlank @Size(max = 160) String installationName,
|
||||
@NotBlank @Size(max = 8192) String assertionPublicKey,
|
||||
@NotBlank @Size(max = 40)
|
||||
@Pattern(regexp = "^[A-Za-z0-9._-]+$")
|
||||
String softwareVersion,
|
||||
@NotBlank @Size(max = 30)
|
||||
@Pattern(regexp = "^[A-Za-z0-9_-]+$")
|
||||
String environment) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public record IssueLicenseKeyRequest(
|
||||
@NotNull OffsetDateTime expiresAt) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.cloud.tenant.api;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record RetireInstallationRequest(
|
||||
@NotBlank @Size(max = 500) String reason) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public enum ActivationKeyStatus {
|
||||
ACTIVE,
|
||||
LOCKED,
|
||||
REVOKED,
|
||||
EXPIRED
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ActivationSession(
|
||||
UUID activationSessionId,
|
||||
UUID registrationId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
UUID installationUuid,
|
||||
String token,
|
||||
OffsetDateTime expiresAt,
|
||||
String clientCode,
|
||||
String clientSlug,
|
||||
String packageCode,
|
||||
int maxInstallations) {
|
||||
}
|
||||
@@ -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,19 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ClientRegistration(
|
||||
UUID registrationId,
|
||||
String clientCode,
|
||||
String legalCompanyName,
|
||||
String tradeName,
|
||||
String billingEmail,
|
||||
LocalDate contractStartDate,
|
||||
LocalDate contractEndDate,
|
||||
RegistrationStatus status,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt,
|
||||
int version) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public enum ClientStatus {
|
||||
ACTIVE,
|
||||
SUSPENDED,
|
||||
CANCELLED
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record InstallationLifecycleResult(
|
||||
UUID installationId,
|
||||
UUID tenantId,
|
||||
String clientId,
|
||||
String installationCode,
|
||||
String status) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record IssuedLicenseKey(
|
||||
UUID activationKeyId,
|
||||
String licenseKey,
|
||||
String keyHint) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record LicenseActivationContext(
|
||||
LicenseActivationKey activationKey,
|
||||
String clientCode,
|
||||
RegistrationStatus registrationStatus,
|
||||
ClientStatus tenantStatus,
|
||||
String clientSlug,
|
||||
String clientName,
|
||||
String packageCode,
|
||||
String licenseType,
|
||||
LicenseStatus licenseStatus,
|
||||
Instant validFrom,
|
||||
Instant validUntil,
|
||||
int maxInstallations,
|
||||
int consumingInstallations) {
|
||||
|
||||
public boolean hasCapacity() {
|
||||
return consumingInstallations < maxInstallations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record LicenseActivationKey(
|
||||
UUID activationKeyId,
|
||||
UUID registrationId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
String keyHash,
|
||||
String keyHint,
|
||||
ActivationKeyStatus status,
|
||||
OffsetDateTime expiresAt,
|
||||
int failedAttempts,
|
||||
int maximumAttempts,
|
||||
OffsetDateTime lockedUntil,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime lastUsedAt) {
|
||||
|
||||
public boolean canAttemptAt(OffsetDateTime now) {
|
||||
if (expiresAt != null && !expiresAt.isAfter(now)) {
|
||||
return false;
|
||||
}
|
||||
if (status == ActivationKeyStatus.ACTIVE) {
|
||||
return failedAttempts < maximumAttempts;
|
||||
}
|
||||
return status == ActivationKeyStatus.LOCKED
|
||||
&& lockedUntil != null
|
||||
&& !lockedUntil.isAfter(now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public record LicenseDeliveryDetails(
|
||||
String clientCode,
|
||||
String tenantSlug,
|
||||
String packageCode,
|
||||
int maximumInstallations,
|
||||
String primaryContactEmail) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public enum LicenseStatus {
|
||||
ACTIVE,
|
||||
SUSPENDED,
|
||||
EXPIRED,
|
||||
CANCELLED
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record RegisteredInstallation(
|
||||
UUID installationId,
|
||||
UUID installationUuid,
|
||||
String clientId,
|
||||
String installationCode,
|
||||
int securityVersion,
|
||||
String status) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.model;
|
||||
|
||||
public enum RegistrationStatus {
|
||||
DRAFT,
|
||||
ACTIVE,
|
||||
SUSPENDED,
|
||||
TERMINATED
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.cygnus.cloud.tenant.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.tenant.model.LicenseDeliveryDetails;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class ClientAdministrationRepository {
|
||||
|
||||
private static final String INSERT_REGISTRATION = """
|
||||
INSERT INTO identity.client_registration_details (
|
||||
registration_id, client_code, legal_company_name, trade_name,
|
||||
pan, cin, gst_number, billing_address_line1,
|
||||
billing_address_line2, billing_city, billing_state,
|
||||
billing_postal_code, billing_country, billing_email,
|
||||
primary_contact_name, primary_contact_email,
|
||||
primary_contact_number, alternate_contact_name,
|
||||
alternate_contact_email, alternate_contact_number,
|
||||
contract_start_date, contract_end_date, status, created_by)
|
||||
VALUES ($1, upper($2), $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||
$12, upper($13), $14, $15, $16, $17, $18, $19, $20,
|
||||
$21, $22, $23, $24)
|
||||
""";
|
||||
|
||||
private static final String INSERT_TENANT = """
|
||||
INSERT INTO identity.client_account (
|
||||
tenant_id, registration_id, client_slug, client_name,
|
||||
status, security_version)
|
||||
VALUES ($1, $2, $3, $4, 'ACTIVE', 1)
|
||||
""";
|
||||
|
||||
private static final String INSERT_LICENSE = """
|
||||
INSERT INTO identity.client_license (
|
||||
license_id, tenant_id, license_type, package_code,
|
||||
valid_from, valid_until, status, max_users,
|
||||
max_installations)
|
||||
SELECT $1, account.tenant_id, $3, $4, $5, $6, 'ACTIVE', $7, $8
|
||||
FROM identity.client_account account
|
||||
WHERE account.tenant_id = $2
|
||||
AND account.status = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String FIND_LICENSE_DELIVERY_DETAILS = """
|
||||
SELECT registration.client_code,
|
||||
registration.primary_contact_email,
|
||||
account.client_slug,
|
||||
license.package_code,
|
||||
license.max_installations
|
||||
FROM identity.client_license license
|
||||
JOIN identity.client_account account
|
||||
ON account.tenant_id = license.tenant_id
|
||||
JOIN identity.client_registration_details registration
|
||||
ON registration.registration_id = account.registration_id
|
||||
WHERE license.tenant_id = $1
|
||||
AND license.license_id = $2
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public ClientAdministrationRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Mono<Boolean> createRegistration(Tuple values) {
|
||||
return database.preparedUpdate(INSERT_REGISTRATION, values)
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> createTenant(
|
||||
UUID tenantId,
|
||||
UUID registrationId,
|
||||
String slug,
|
||||
String name) {
|
||||
return database.preparedUpdate(
|
||||
INSERT_TENANT,
|
||||
Tuple.of(tenantId, registrationId, slug, name))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> createLicense(
|
||||
UUID licenseId,
|
||||
UUID tenantId,
|
||||
String licenseType,
|
||||
String packageCode,
|
||||
OffsetDateTime validFrom,
|
||||
OffsetDateTime validUntil,
|
||||
Integer maxUsers,
|
||||
int maxInstallations) {
|
||||
return database.preparedUpdate(
|
||||
INSERT_LICENSE,
|
||||
Tuple.of(
|
||||
licenseId,
|
||||
tenantId,
|
||||
licenseType,
|
||||
packageCode,
|
||||
validFrom,
|
||||
validUntil,
|
||||
maxUsers,
|
||||
maxInstallations))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<LicenseDeliveryDetails> findLicenseDeliveryDetails(
|
||||
UUID tenantId, UUID licenseId) {
|
||||
return database.preparedQuery(
|
||||
FIND_LICENSE_DELIVERY_DETAILS,
|
||||
Tuple.of(tenantId, licenseId))
|
||||
.flatMap(rows -> {
|
||||
var iterator = rows.iterator();
|
||||
if (!iterator.hasNext()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var row = iterator.next();
|
||||
return Mono.just(new LicenseDeliveryDetails(
|
||||
row.getString("client_code"),
|
||||
row.getString("client_slug"),
|
||||
row.getString("package_code"),
|
||||
row.getInteger("max_installations"),
|
||||
row.getString("primary_contact_email")));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.cygnus.cloud.tenant.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.tenant.model.ActivationSession;
|
||||
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.SqlConnection;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class InstallationActivationRepository {
|
||||
|
||||
private static final String INSERT_SESSION = """
|
||||
INSERT INTO identity.installation_activation_session (
|
||||
activation_session_id, activation_key_id, registration_id,
|
||||
tenant_id, license_id, installation_uuid, token_hash,
|
||||
status, expires_at, source_ip, installer_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'PENDING', $8,
|
||||
CAST($9 AS text)::inet, $10)
|
||||
""";
|
||||
|
||||
private static final String LOCK_SESSION_AND_LICENSE = """
|
||||
SELECT session.activation_session_id, session.registration_id,
|
||||
session.tenant_id, session.license_id,
|
||||
session.installation_uuid, session.status AS session_status,
|
||||
session.expires_at,
|
||||
registration.client_code, registration.status AS registration_status,
|
||||
account.client_slug, account.status AS tenant_status,
|
||||
license.package_code, license.status AS license_status,
|
||||
license.valid_from, license.valid_until,
|
||||
license.max_installations,
|
||||
(
|
||||
SELECT count(*)::integer
|
||||
FROM identity.client_installation installation
|
||||
WHERE installation.tenant_id = session.tenant_id
|
||||
AND installation.license_id = session.license_id
|
||||
AND installation.status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
|
||||
) AS consuming_installations
|
||||
FROM identity.installation_activation_session session
|
||||
JOIN identity.client_registration_details registration
|
||||
ON registration.registration_id = session.registration_id
|
||||
JOIN identity.client_account account
|
||||
ON account.registration_id = session.registration_id
|
||||
AND account.tenant_id = session.tenant_id
|
||||
JOIN identity.client_license license
|
||||
ON license.tenant_id = session.tenant_id
|
||||
AND license.license_id = session.license_id
|
||||
WHERE session.token_hash = $1
|
||||
FOR UPDATE OF session, license
|
||||
""";
|
||||
|
||||
private static final String INSERT_INSTALLATION = """
|
||||
INSERT INTO identity.client_installation (
|
||||
installation_id, tenant_id, client_id, installation_code,
|
||||
assertion_public_key, allowed_scopes, enabled,
|
||||
security_version, license_id, installation_uuid,
|
||||
installation_name, status, registered_at,
|
||||
software_version, environment)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, true, 1, $7, $8, $9,
|
||||
'ACTIVE', $10, $11, $12)
|
||||
""";
|
||||
|
||||
private static final String CONSUME_SESSION = """
|
||||
UPDATE identity.installation_activation_session
|
||||
SET status = 'CONSUMED', consumed_at = $2
|
||||
WHERE activation_session_id = $1
|
||||
AND status = 'PENDING'
|
||||
""";
|
||||
|
||||
private static final String INSERT_AUDIT = """
|
||||
INSERT INTO identity.installation_audit_event (
|
||||
audit_event_id, registration_id, tenant_id, license_id,
|
||||
installation_id, event_type, actor_type, actor_id,
|
||||
reason, event_data, occurred_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'INSTALLATION_REGISTERED',
|
||||
'INSTALLER', $6, 'License-authorized installation',
|
||||
$7::jsonb, $8)
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public InstallationActivationRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Mono<Boolean> insertSession(
|
||||
UUID sessionId,
|
||||
UUID activationKeyId,
|
||||
UUID registrationId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
UUID installationUuid,
|
||||
String tokenHash,
|
||||
OffsetDateTime expiresAt,
|
||||
String sourceIp,
|
||||
String installerVersion) {
|
||||
Tuple values = Tuple.tuple()
|
||||
.addUUID(sessionId)
|
||||
.addUUID(activationKeyId)
|
||||
.addUUID(registrationId)
|
||||
.addUUID(tenantId)
|
||||
.addUUID(licenseId)
|
||||
.addUUID(installationUuid)
|
||||
.addString(tokenHash)
|
||||
.addOffsetDateTime(expiresAt)
|
||||
.addString(sourceIp)
|
||||
.addString(installerVersion);
|
||||
return database.preparedUpdate(INSERT_SESSION, values).map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<RegisteredInstallation> register(
|
||||
String tokenHash,
|
||||
String installationCode,
|
||||
String installationName,
|
||||
String assertionPublicKey,
|
||||
Set<String> scopes,
|
||||
String softwareVersion,
|
||||
String environment,
|
||||
OffsetDateTime now) {
|
||||
return database.inTransaction(connection -> lockContext(connection, tokenHash)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException(
|
||||
"Activation session is invalid")))
|
||||
.flatMap(context -> validate(context, now))
|
||||
.flatMap(context -> insertInstallation(
|
||||
connection,
|
||||
context,
|
||||
installationCode,
|
||||
installationName,
|
||||
assertionPublicKey,
|
||||
scopes,
|
||||
softwareVersion,
|
||||
environment,
|
||||
now)));
|
||||
}
|
||||
|
||||
private Mono<RegistrationContext> lockContext(
|
||||
SqlConnection connection, String tokenHash) {
|
||||
return database.preparedQuery(
|
||||
connection, LOCK_SESSION_AND_LICENSE, Tuple.of(tokenHash))
|
||||
.flatMap(rows -> {
|
||||
java.util.Iterator<Row> iterator = rows.iterator();
|
||||
return iterator.hasNext()
|
||||
? Mono.just(context(iterator.next()))
|
||||
: Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<RegistrationContext> validate(
|
||||
RegistrationContext context, OffsetDateTime now) {
|
||||
boolean valid = "PENDING".equals(context.sessionStatus)
|
||||
&& context.expiresAt.isAfter(now)
|
||||
&& "ACTIVE".equals(context.registrationStatus)
|
||||
&& "ACTIVE".equals(context.tenantStatus)
|
||||
&& "ACTIVE".equals(context.licenseStatus)
|
||||
&& !now.isBefore(context.validFrom)
|
||||
&& now.isBefore(context.validUntil)
|
||||
&& context.consumingInstallations < context.maxInstallations;
|
||||
return valid
|
||||
? Mono.just(context)
|
||||
: Mono.error(new IllegalStateException(
|
||||
"Activation, license, or installation capacity is invalid"));
|
||||
}
|
||||
|
||||
private Mono<RegisteredInstallation> insertInstallation(
|
||||
SqlConnection connection,
|
||||
RegistrationContext context,
|
||||
String installationCode,
|
||||
String installationName,
|
||||
String assertionPublicKey,
|
||||
Set<String> scopes,
|
||||
String softwareVersion,
|
||||
String environment,
|
||||
OffsetDateTime now) {
|
||||
UUID installationId = UUID.randomUUID();
|
||||
Tuple insert = Tuple.tuple()
|
||||
.addUUID(installationId)
|
||||
.addUUID(context.tenantId)
|
||||
.addString(context.clientSlug)
|
||||
.addString(installationCode)
|
||||
.addString(assertionPublicKey)
|
||||
.addArrayOfString(scopes.toArray(String[]::new))
|
||||
.addUUID(context.licenseId)
|
||||
.addUUID(context.installationUuid)
|
||||
.addString(installationName)
|
||||
.addOffsetDateTime(now)
|
||||
.addString(softwareVersion)
|
||||
.addString(environment);
|
||||
return database.preparedQuery(connection, INSERT_INSTALLATION, insert)
|
||||
.flatMap(rows -> rows.rowCount() == 1
|
||||
? consumeAndAudit(connection, context, installationId, now)
|
||||
: Mono.error(new IllegalStateException(
|
||||
"Installation could not be registered")))
|
||||
.thenReturn(new RegisteredInstallation(
|
||||
installationId,
|
||||
context.installationUuid,
|
||||
context.clientSlug,
|
||||
installationCode,
|
||||
1,
|
||||
"ACTIVE"));
|
||||
}
|
||||
|
||||
private Mono<Void> consumeAndAudit(
|
||||
SqlConnection connection,
|
||||
RegistrationContext context,
|
||||
UUID installationId,
|
||||
OffsetDateTime now) {
|
||||
return database.preparedQuery(
|
||||
connection,
|
||||
CONSUME_SESSION,
|
||||
Tuple.of(context.sessionId, now))
|
||||
.flatMap(rows -> rows.rowCount() == 1
|
||||
? database.preparedQuery(
|
||||
connection,
|
||||
INSERT_AUDIT,
|
||||
Tuple.of(
|
||||
UUID.randomUUID(),
|
||||
context.registrationId,
|
||||
context.tenantId,
|
||||
context.licenseId,
|
||||
installationId,
|
||||
context.installationUuid.toString(),
|
||||
"{}",
|
||||
now))
|
||||
: Mono.error(new IllegalStateException(
|
||||
"Activation session was already consumed")))
|
||||
.then();
|
||||
}
|
||||
|
||||
private RegistrationContext context(Row row) {
|
||||
return new RegistrationContext(
|
||||
row.getUUID("activation_session_id"),
|
||||
row.getUUID("registration_id"),
|
||||
row.getUUID("tenant_id"),
|
||||
row.getUUID("license_id"),
|
||||
row.getUUID("installation_uuid"),
|
||||
row.getString("session_status"),
|
||||
row.getOffsetDateTime("expires_at"),
|
||||
row.getString("client_code"),
|
||||
row.getString("registration_status"),
|
||||
row.getString("client_slug"),
|
||||
row.getString("tenant_status"),
|
||||
row.getString("package_code"),
|
||||
row.getString("license_status"),
|
||||
row.getOffsetDateTime("valid_from"),
|
||||
row.getOffsetDateTime("valid_until"),
|
||||
row.getInteger("max_installations"),
|
||||
row.getInteger("consuming_installations"));
|
||||
}
|
||||
|
||||
private record RegistrationContext(
|
||||
UUID sessionId,
|
||||
UUID registrationId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
UUID installationUuid,
|
||||
String sessionStatus,
|
||||
OffsetDateTime expiresAt,
|
||||
String clientCode,
|
||||
String registrationStatus,
|
||||
String clientSlug,
|
||||
String tenantStatus,
|
||||
String packageCode,
|
||||
String licenseStatus,
|
||||
OffsetDateTime validFrom,
|
||||
OffsetDateTime validUntil,
|
||||
int maxInstallations,
|
||||
int consumingInstallations) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.cygnus.cloud.tenant.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.tenant.model.InstallationLifecycleResult;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class InstallationLifecycleRepository {
|
||||
|
||||
private static final String RETIRE = """
|
||||
WITH retired AS (
|
||||
UPDATE identity.client_installation
|
||||
SET status = $3,
|
||||
enabled = false,
|
||||
security_version = security_version + 1,
|
||||
retired_at = now(),
|
||||
retired_by = $4,
|
||||
retirement_reason = $5
|
||||
WHERE installation_id = $1
|
||||
AND tenant_id = $2
|
||||
AND status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
|
||||
RETURNING installation_id, tenant_id, client_id,
|
||||
installation_code, license_id, status
|
||||
), audited AS (
|
||||
INSERT INTO identity.installation_audit_event (
|
||||
audit_event_id, registration_id, tenant_id, license_id,
|
||||
installation_id, event_type, actor_type, actor_id,
|
||||
reason, event_data)
|
||||
SELECT gen_random_uuid(), account.registration_id, retired.tenant_id,
|
||||
retired.license_id, retired.installation_id, retired.status,
|
||||
'ADMIN', $4, $5, '{}'::jsonb
|
||||
FROM retired
|
||||
JOIN identity.client_account account
|
||||
ON account.tenant_id = retired.tenant_id
|
||||
)
|
||||
SELECT installation_id, tenant_id, client_id, installation_code, status
|
||||
FROM retired
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public InstallationLifecycleRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Mono<InstallationLifecycleResult> retire(
|
||||
UUID tenantId,
|
||||
UUID installationId,
|
||||
String status,
|
||||
String actor,
|
||||
String reason) {
|
||||
return database.preparedQuery(
|
||||
RETIRE,
|
||||
Tuple.of(installationId, tenantId, status, actor, reason))
|
||||
.flatMap(rows -> {
|
||||
var iterator = rows.iterator();
|
||||
if (!iterator.hasNext()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var row = iterator.next();
|
||||
return Mono.just(new InstallationLifecycleResult(
|
||||
row.getUUID("installation_id"),
|
||||
row.getUUID("tenant_id"),
|
||||
row.getString("client_id"),
|
||||
row.getString("installation_code"),
|
||||
row.getString("status")));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.cygnus.cloud.tenant.repository;
|
||||
|
||||
import com.cygnus.cloud.database.ReactiveDatabaseClient;
|
||||
import com.cygnus.cloud.tenant.model.ActivationKeyStatus;
|
||||
import com.cygnus.cloud.tenant.model.ClientStatus;
|
||||
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
|
||||
import com.cygnus.cloud.tenant.model.LicenseActivationKey;
|
||||
import com.cygnus.cloud.tenant.model.LicenseStatus;
|
||||
import com.cygnus.cloud.tenant.model.RegistrationStatus;
|
||||
import io.vertx.sqlclient.Row;
|
||||
import io.vertx.sqlclient.Tuple;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Repository
|
||||
public class LicenseActivationRepository {
|
||||
|
||||
private static final String FIND_CANDIDATES = """
|
||||
SELECT activation.activation_key_id, activation.registration_id,
|
||||
activation.tenant_id, activation.license_id,
|
||||
activation.key_hash, activation.key_hint, activation.status,
|
||||
activation.expires_at, activation.failed_attempts,
|
||||
activation.maximum_attempts, activation.locked_until,
|
||||
activation.created_at, activation.last_used_at,
|
||||
registration.client_code,
|
||||
registration.status AS registration_status,
|
||||
account.status AS tenant_status,
|
||||
account.client_slug, account.client_name,
|
||||
license.package_code, license.license_type,
|
||||
license.status AS license_status,
|
||||
license.valid_from, license.valid_until,
|
||||
license.max_installations,
|
||||
(
|
||||
SELECT count(*)::integer
|
||||
FROM identity.client_installation installation
|
||||
WHERE installation.tenant_id = activation.tenant_id
|
||||
AND installation.license_id = activation.license_id
|
||||
AND installation.status IN ('PENDING', 'ACTIVE', 'SUSPENDED')
|
||||
) AS consuming_installations
|
||||
FROM identity.license_activation_key activation
|
||||
JOIN identity.client_registration_details registration
|
||||
ON registration.registration_id = activation.registration_id
|
||||
JOIN identity.client_account account
|
||||
ON account.registration_id = activation.registration_id
|
||||
AND account.tenant_id = activation.tenant_id
|
||||
JOIN identity.client_license license
|
||||
ON license.tenant_id = activation.tenant_id
|
||||
AND license.license_id = activation.license_id
|
||||
WHERE upper(registration.client_code) = upper($1)
|
||||
AND activation.key_hint = $2
|
||||
""";
|
||||
|
||||
private static final String INSERT_KEY = """
|
||||
INSERT INTO identity.license_activation_key (
|
||||
activation_key_id, registration_id, tenant_id, license_id,
|
||||
key_hash, key_hint, status, expires_at, created_by)
|
||||
SELECT $1, account.registration_id, account.tenant_id,
|
||||
license.license_id, $4, $5, 'ACTIVE', $6, $7
|
||||
FROM identity.client_account account
|
||||
JOIN identity.client_license license
|
||||
ON license.tenant_id = account.tenant_id
|
||||
WHERE account.tenant_id = $2
|
||||
AND license.license_id = $3
|
||||
AND account.status = 'ACTIVE'
|
||||
AND license.status = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String RECORD_SUCCESS = """
|
||||
UPDATE identity.license_activation_key
|
||||
SET failed_attempts = 0,
|
||||
locked_until = NULL,
|
||||
status = 'ACTIVE',
|
||||
last_used_at = $2
|
||||
WHERE activation_key_id = $1
|
||||
""";
|
||||
|
||||
private static final String RECORD_FAILURE = """
|
||||
UPDATE identity.license_activation_key
|
||||
SET failed_attempts = LEAST(failed_attempts + 1, maximum_attempts),
|
||||
status = CASE
|
||||
WHEN failed_attempts + 1 >= maximum_attempts THEN 'LOCKED'
|
||||
ELSE status
|
||||
END,
|
||||
locked_until = CASE
|
||||
WHEN failed_attempts + 1 >= maximum_attempts THEN $2
|
||||
ELSE locked_until
|
||||
END
|
||||
WHERE activation_key_id = $1
|
||||
""";
|
||||
|
||||
private static final String REVOKE_KEY = """
|
||||
UPDATE identity.license_activation_key
|
||||
SET status = 'REVOKED',
|
||||
locked_until = NULL,
|
||||
revoked_at = now(),
|
||||
revoked_by = 'system',
|
||||
revocation_reason = 'Activation-key delivery failed'
|
||||
WHERE activation_key_id = $1
|
||||
AND status IN ('ACTIVE', 'LOCKED')
|
||||
""";
|
||||
|
||||
private final ReactiveDatabaseClient database;
|
||||
|
||||
public LicenseActivationRepository(ReactiveDatabaseClient database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public Flux<LicenseActivationContext> findCandidates(
|
||||
String clientCode, String keyHint) {
|
||||
return database.preparedQuery(FIND_CANDIDATES, Tuple.of(clientCode, keyHint))
|
||||
.flatMapMany(Flux::fromIterable)
|
||||
.map(this::context);
|
||||
}
|
||||
|
||||
public Mono<Boolean> insert(
|
||||
UUID activationKeyId,
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
String keyHash,
|
||||
String keyHint,
|
||||
OffsetDateTime expiresAt,
|
||||
String createdBy) {
|
||||
Tuple values = Tuple.tuple()
|
||||
.addUUID(activationKeyId)
|
||||
.addUUID(tenantId)
|
||||
.addUUID(licenseId)
|
||||
.addString(keyHash)
|
||||
.addString(keyHint)
|
||||
.addOffsetDateTime(expiresAt)
|
||||
.addString(createdBy);
|
||||
return database.preparedUpdate(INSERT_KEY, values).map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> recordSuccess(UUID activationKeyId, OffsetDateTime now) {
|
||||
return database.preparedUpdate(
|
||||
RECORD_SUCCESS, Tuple.of(activationKeyId, now))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> recordFailure(
|
||||
UUID activationKeyId, OffsetDateTime lockedUntil) {
|
||||
return database.preparedUpdate(
|
||||
RECORD_FAILURE, Tuple.of(activationKeyId, lockedUntil))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
public Mono<Boolean> revoke(UUID activationKeyId) {
|
||||
return database.preparedUpdate(REVOKE_KEY, Tuple.of(activationKeyId))
|
||||
.map(count -> count == 1);
|
||||
}
|
||||
|
||||
private LicenseActivationContext context(Row row) {
|
||||
LicenseActivationKey key = new LicenseActivationKey(
|
||||
row.getUUID("activation_key_id"),
|
||||
row.getUUID("registration_id"),
|
||||
row.getUUID("tenant_id"),
|
||||
row.getUUID("license_id"),
|
||||
row.getString("key_hash"),
|
||||
row.getString("key_hint"),
|
||||
ActivationKeyStatus.valueOf(row.getString("status")),
|
||||
row.getOffsetDateTime("expires_at"),
|
||||
row.getInteger("failed_attempts"),
|
||||
row.getInteger("maximum_attempts"),
|
||||
row.getOffsetDateTime("locked_until"),
|
||||
row.getOffsetDateTime("created_at"),
|
||||
row.getOffsetDateTime("last_used_at"));
|
||||
return new LicenseActivationContext(
|
||||
key,
|
||||
row.getString("client_code"),
|
||||
RegistrationStatus.valueOf(row.getString("registration_status")),
|
||||
ClientStatus.valueOf(row.getString("tenant_status")),
|
||||
row.getString("client_slug"),
|
||||
row.getString("client_name"),
|
||||
row.getString("package_code"),
|
||||
row.getString("license_type"),
|
||||
LicenseStatus.valueOf(row.getString("license_status")),
|
||||
row.getOffsetDateTime("valid_from").toInstant(),
|
||||
row.getOffsetDateTime("valid_until").toInstant(),
|
||||
row.getInteger("max_installations"),
|
||||
row.getInteger("consuming_installations"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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 installation.status = 'ACTIVE'
|
||||
AND account.status = 'ACTIVE'
|
||||
""";
|
||||
|
||||
private static final String TOUCH_INSTALLATION = """
|
||||
UPDATE identity.client_installation
|
||||
SET last_seen_at = $2,
|
||||
last_authenticated_at = $2
|
||||
WHERE installation_id = $1
|
||||
AND (last_seen_at IS NULL OR last_seen_at < $2 - INTERVAL '5 minutes')
|
||||
""";
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
public Mono<Void> touchInstallation(UUID installationId, Instant instant) {
|
||||
return database.preparedUpdate(
|
||||
TOUCH_INSTALLATION,
|
||||
Tuple.of(
|
||||
installationId,
|
||||
instant.atOffset(java.time.ZoneOffset.UTC)))
|
||||
.then();
|
||||
}
|
||||
|
||||
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,7 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
public class ActivationRateLimitException extends RuntimeException {
|
||||
public ActivationRateLimitException() {
|
||||
super("Installation activation rate limit exceeded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.cache.ReactiveCacheService;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class ActivationRateLimiter {
|
||||
|
||||
private static final String NAMESPACE = "installation-activation-rate";
|
||||
private final ReactiveCacheService cache;
|
||||
private final int maximumAttempts;
|
||||
private final Duration window;
|
||||
|
||||
public ActivationRateLimiter(
|
||||
ReactiveCacheService cache,
|
||||
@Value("${cygnus.activation-rate-limit.maximum-attempts:10}")
|
||||
int maximumAttempts,
|
||||
@Value("${cygnus.activation-rate-limit.window:10m}")
|
||||
Duration window) {
|
||||
this.cache = cache;
|
||||
this.maximumAttempts = maximumAttempts;
|
||||
this.window = window;
|
||||
}
|
||||
|
||||
public Mono<Void> check(String sourceIp, String clientCode) {
|
||||
String identity = (sourceIp == null ? "unknown" : sourceIp)
|
||||
+ '|'
|
||||
+ clientCode.toUpperCase(java.util.Locale.ROOT);
|
||||
return cache.increment(NAMESPACE, digest(identity), window)
|
||||
.flatMap(attempts -> attempts <= maximumAttempts
|
||||
? Mono.empty()
|
||||
: Mono.error(new ActivationRateLimitException()));
|
||||
}
|
||||
|
||||
private String digest(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
public class InstallationActivationException extends RuntimeException {
|
||||
|
||||
public InstallationActivationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public InstallationActivationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.ActivationSession;
|
||||
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
|
||||
import com.cygnus.cloud.tenant.model.RegisteredInstallation;
|
||||
import com.cygnus.cloud.tenant.repository.InstallationActivationRepository;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Base64;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import io.vertx.pgclient.PgException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class InstallationActivationService {
|
||||
|
||||
private static final Duration SESSION_TTL = Duration.ofMinutes(20);
|
||||
private static final Set<String> INSTALLATION_SCOPES = Set.of("identity.login");
|
||||
|
||||
private final LicenseKeyService licenseKeyService;
|
||||
private final InstallationActivationRepository repository;
|
||||
private final SecureRandom random;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public InstallationActivationService(
|
||||
LicenseKeyService licenseKeyService,
|
||||
InstallationActivationRepository repository,
|
||||
Clock clock) {
|
||||
this(licenseKeyService, repository, clock, new SecureRandom());
|
||||
}
|
||||
|
||||
InstallationActivationService(
|
||||
LicenseKeyService licenseKeyService,
|
||||
InstallationActivationRepository repository,
|
||||
Clock clock,
|
||||
SecureRandom random) {
|
||||
this.licenseKeyService = licenseKeyService;
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
public Mono<ActivationSession> validateAndCreateSession(
|
||||
String clientCode,
|
||||
String licenseKey,
|
||||
UUID installationUuid,
|
||||
String sourceIp,
|
||||
String installerVersion) {
|
||||
return licenseKeyService.validate(clientCode, licenseKey)
|
||||
.flatMap(context -> createSession(
|
||||
context,
|
||||
installationUuid,
|
||||
sourceIp,
|
||||
installerVersion));
|
||||
}
|
||||
|
||||
public Mono<RegisteredInstallation> register(
|
||||
String activationToken,
|
||||
String installationCode,
|
||||
String installationName,
|
||||
String assertionPublicKey,
|
||||
String softwareVersion,
|
||||
String environment) {
|
||||
validatePublicKey(assertionPublicKey);
|
||||
OffsetDateTime now = now();
|
||||
return repository.register(
|
||||
digest(activationToken),
|
||||
installationCode,
|
||||
installationName,
|
||||
assertionPublicKey,
|
||||
INSTALLATION_SCOPES,
|
||||
softwareVersion,
|
||||
environment,
|
||||
now)
|
||||
.onErrorMap(
|
||||
error -> error instanceof PgException pgException
|
||||
&& "23505".equals(pgException.getSqlState()),
|
||||
error -> new InstallationCodeConflictException(
|
||||
installationCode))
|
||||
.onErrorMap(
|
||||
error -> !(error instanceof InstallationActivationException),
|
||||
error -> new InstallationActivationException(
|
||||
"Installation registration failed", error));
|
||||
}
|
||||
|
||||
private Mono<ActivationSession> createSession(
|
||||
LicenseActivationContext context,
|
||||
UUID installationUuid,
|
||||
String sourceIp,
|
||||
String installerVersion) {
|
||||
String token = randomToken();
|
||||
UUID sessionId = UUID.randomUUID();
|
||||
OffsetDateTime expiresAt = now().plus(SESSION_TTL);
|
||||
return repository.insertSession(
|
||||
sessionId,
|
||||
context.activationKey().activationKeyId(),
|
||||
context.activationKey().registrationId(),
|
||||
context.activationKey().tenantId(),
|
||||
context.activationKey().licenseId(),
|
||||
installationUuid,
|
||||
digest(token),
|
||||
expiresAt,
|
||||
sourceIp,
|
||||
installerVersion)
|
||||
.flatMap(inserted -> inserted
|
||||
? Mono.just(new ActivationSession(
|
||||
sessionId,
|
||||
context.activationKey().registrationId(),
|
||||
context.activationKey().tenantId(),
|
||||
context.activationKey().licenseId(),
|
||||
installationUuid,
|
||||
token,
|
||||
expiresAt,
|
||||
context.clientCode(),
|
||||
context.clientSlug(),
|
||||
context.packageCode(),
|
||||
context.maxInstallations()))
|
||||
: Mono.error(new InstallationActivationException(
|
||||
"Activation session could not be created")));
|
||||
}
|
||||
|
||||
private String randomToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
random.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private String digest(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new InstallationActivationException(
|
||||
"Activation token is required");
|
||||
}
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
} catch (Exception exception) {
|
||||
throw new InstallationActivationException(
|
||||
"Activation token could not be processed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePublicKey(String pem) {
|
||||
try {
|
||||
String encoded = pem
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
RSAPublicKey key = (RSAPublicKey) KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(
|
||||
Base64.getDecoder().decode(encoded)));
|
||||
if (key.getModulus().bitLength() < 3072) {
|
||||
throw new InstallationActivationException(
|
||||
"Installation public key must be at least 3072-bit RSA");
|
||||
}
|
||||
} catch (InstallationActivationException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new InstallationActivationException(
|
||||
"Installation public key is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private OffsetDateTime now() {
|
||||
return OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
public class InstallationCodeConflictException
|
||||
extends InstallationActivationException {
|
||||
|
||||
public InstallationCodeConflictException(String installationCode) {
|
||||
super("Installation code already exists: " + installationCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.InstallationLifecycleResult;
|
||||
import com.cygnus.cloud.tenant.repository.InstallationLifecycleRepository;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
@Service
|
||||
public class InstallationLifecycleService {
|
||||
|
||||
private final InstallationLifecycleRepository repository;
|
||||
private final TenantRegistrationService registrations;
|
||||
|
||||
public InstallationLifecycleService(
|
||||
InstallationLifecycleRepository repository,
|
||||
TenantRegistrationService registrations) {
|
||||
this.repository = repository;
|
||||
this.registrations = registrations;
|
||||
}
|
||||
|
||||
public Mono<InstallationLifecycleResult> retire(
|
||||
UUID tenantId,
|
||||
UUID installationId,
|
||||
String status,
|
||||
String actor,
|
||||
String reason) {
|
||||
return repository.retire(tenantId, installationId, status, actor, reason)
|
||||
.switchIfEmpty(Mono.error(
|
||||
new IllegalArgumentException("Active installation not found")))
|
||||
.flatMap(result -> registrations
|
||||
.evictInstallation(result.clientId(), result.installationCode())
|
||||
.thenReturn(result));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
public class LicenseKeyException extends RuntimeException {
|
||||
|
||||
public LicenseKeyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.ClientStatus;
|
||||
import com.cygnus.cloud.tenant.model.IssuedLicenseKey;
|
||||
import com.cygnus.cloud.tenant.model.LicenseActivationContext;
|
||||
import com.cygnus.cloud.tenant.model.LicenseStatus;
|
||||
import com.cygnus.cloud.tenant.model.RegistrationStatus;
|
||||
import com.cygnus.cloud.tenant.repository.LicenseActivationRepository;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
@Service
|
||||
public class LicenseKeyService {
|
||||
|
||||
private static final String PREFIX = "CYGNUS";
|
||||
private static final int RANDOM_BYTES = 20;
|
||||
private static final Duration LOCK_DURATION = Duration.ofMinutes(30);
|
||||
|
||||
private final LicenseActivationRepository repository;
|
||||
private final BCryptPasswordEncoder encoder;
|
||||
private final SecureRandom random;
|
||||
private final Clock clock;
|
||||
|
||||
@Autowired
|
||||
public LicenseKeyService(
|
||||
LicenseActivationRepository repository,
|
||||
Clock clock) {
|
||||
this(repository, clock, new BCryptPasswordEncoder(12), new SecureRandom());
|
||||
}
|
||||
|
||||
LicenseKeyService(
|
||||
LicenseActivationRepository repository,
|
||||
Clock clock,
|
||||
BCryptPasswordEncoder encoder,
|
||||
SecureRandom random) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
this.encoder = encoder;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
public Mono<IssuedLicenseKey> issue(
|
||||
UUID tenantId,
|
||||
UUID licenseId,
|
||||
OffsetDateTime expiresAt,
|
||||
String createdBy) {
|
||||
return Mono.fromCallable(this::newPlaintextKey)
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.flatMap(plaintext -> hash(plaintext)
|
||||
.flatMap(hash -> {
|
||||
UUID id = UUID.randomUUID();
|
||||
String hint = hint(plaintext);
|
||||
return repository.insert(
|
||||
id,
|
||||
tenantId,
|
||||
licenseId,
|
||||
hash,
|
||||
hint,
|
||||
expiresAt,
|
||||
createdBy)
|
||||
.flatMap(inserted -> inserted
|
||||
? Mono.just(new IssuedLicenseKey(
|
||||
id, plaintext, hint))
|
||||
: Mono.error(new LicenseKeyException(
|
||||
"Active tenant and license were not found")));
|
||||
}));
|
||||
}
|
||||
|
||||
public Mono<LicenseActivationContext> validate(
|
||||
String clientCode, String plaintextKey) {
|
||||
String normalizedCode = normalizeClientCode(clientCode);
|
||||
String normalizedKey = normalizeKey(plaintextKey);
|
||||
OffsetDateTime now = OffsetDateTime.ofInstant(clock.instant(), ZoneOffset.UTC);
|
||||
|
||||
return repository.findCandidates(normalizedCode, hint(normalizedKey))
|
||||
.collectList()
|
||||
.flatMap(candidates -> matchingCandidate(candidates, normalizedKey))
|
||||
.flatMap(candidate -> validateContext(candidate, now))
|
||||
.flatMap(candidate -> repository
|
||||
.recordSuccess(candidate.activationKey().activationKeyId(), now)
|
||||
.thenReturn(candidate));
|
||||
}
|
||||
|
||||
private Mono<LicenseActivationContext> matchingCandidate(
|
||||
List<LicenseActivationContext> candidates, String plaintextKey) {
|
||||
if (candidates.isEmpty()) {
|
||||
return Mono.error(new LicenseKeyException(
|
||||
"The client code or license key is invalid"));
|
||||
}
|
||||
return Flux.fromIterable(candidates)
|
||||
.filterWhen(candidate -> matches(
|
||||
plaintextKey, candidate.activationKey().keyHash()))
|
||||
.next()
|
||||
.switchIfEmpty(Flux.fromIterable(candidates)
|
||||
.flatMap(candidate -> recordRejectedAttempt(
|
||||
candidate.activationKey().activationKeyId()))
|
||||
.then(Mono.error(new LicenseKeyException(
|
||||
"The client code or license key is invalid"))));
|
||||
}
|
||||
|
||||
private Mono<LicenseActivationContext> validateContext(
|
||||
LicenseActivationContext context, OffsetDateTime now) {
|
||||
if (!context.activationKey().canAttemptAt(now)) {
|
||||
return Mono.error(new LicenseKeyException(
|
||||
"The license key is expired, locked, or inactive"));
|
||||
}
|
||||
if (context.registrationStatus() != RegistrationStatus.ACTIVE
|
||||
|| context.tenantStatus() != ClientStatus.ACTIVE) {
|
||||
return Mono.error(new LicenseKeyException(
|
||||
"The client registration or tenant is inactive"));
|
||||
}
|
||||
if (context.licenseStatus() != LicenseStatus.ACTIVE
|
||||
|| clock.instant().isBefore(context.validFrom())
|
||||
|| !clock.instant().isBefore(context.validUntil())) {
|
||||
return Mono.error(new LicenseKeyException("The license is not active"));
|
||||
}
|
||||
if (!context.hasCapacity()) {
|
||||
return Mono.error(new LicenseKeyException(
|
||||
"The installation limit for this tenant has been reached"));
|
||||
}
|
||||
return Mono.just(context);
|
||||
}
|
||||
|
||||
public Mono<Boolean> recordRejectedAttempt(UUID activationKeyId) {
|
||||
OffsetDateTime lockedUntil = OffsetDateTime.ofInstant(
|
||||
clock.instant().plus(LOCK_DURATION), ZoneOffset.UTC);
|
||||
return repository.recordFailure(activationKeyId, lockedUntil);
|
||||
}
|
||||
|
||||
public Mono<Boolean> revoke(UUID activationKeyId) {
|
||||
return repository.revoke(activationKeyId);
|
||||
}
|
||||
|
||||
private Mono<String> hash(String plaintext) {
|
||||
return Mono.fromCallable(() -> encoder.encode(plaintext))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private Mono<Boolean> matches(String plaintext, String hash) {
|
||||
return Mono.fromCallable(() -> encoder.matches(plaintext, hash))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
private String newPlaintextKey() {
|
||||
byte[] bytes = new byte[RANDOM_BYTES];
|
||||
random.nextBytes(bytes);
|
||||
String raw = HexFormat.of().formatHex(bytes).toUpperCase(Locale.ROOT);
|
||||
return PREFIX + "-" + groups(raw);
|
||||
}
|
||||
|
||||
private String groups(String value) {
|
||||
return String.join(
|
||||
"-",
|
||||
value.substring(0, 8),
|
||||
value.substring(8, 16),
|
||||
value.substring(16, 24),
|
||||
value.substring(24, 32),
|
||||
value.substring(32, 40));
|
||||
}
|
||||
|
||||
private String hint(String key) {
|
||||
String compact = key.replace("-", "");
|
||||
return compact.substring(Math.max(0, compact.length() - 8));
|
||||
}
|
||||
|
||||
private String normalizeClientCode(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new LicenseKeyException("Client code is required");
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeKey(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new LicenseKeyException("License key is required");
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.cygnus.cloud.tenant.service;
|
||||
|
||||
import com.cygnus.cloud.tenant.model.IssuedLicenseKey;
|
||||
import java.time.OffsetDateTime;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
@Service
|
||||
public class RegistrationEmailService {
|
||||
|
||||
private final ObjectProvider<JavaMailSender> mailSenderProvider;
|
||||
private final String from;
|
||||
|
||||
public RegistrationEmailService(
|
||||
ObjectProvider<JavaMailSender> mailSenderProvider,
|
||||
@Value("${cygnus.registration-email.from:noreply@cygnus.invalid}")
|
||||
String from) {
|
||||
this.mailSenderProvider = mailSenderProvider;
|
||||
this.from = from;
|
||||
}
|
||||
|
||||
public Mono<Void> sendLicense(
|
||||
String recipient,
|
||||
String clientCode,
|
||||
String tenantSlug,
|
||||
String packageCode,
|
||||
int maximumInstallations,
|
||||
OffsetDateTime validUntil,
|
||||
IssuedLicenseKey issued) {
|
||||
return Mono.fromRunnable(() -> {
|
||||
if (recipient == null || recipient.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"The client registration has no primary contact email");
|
||||
}
|
||||
JavaMailSender mailSender = mailSenderProvider.getIfAvailable();
|
||||
if (mailSender == null) {
|
||||
throw new IllegalStateException(
|
||||
"SMTP is not configured; set spring.mail.host and credentials");
|
||||
}
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(from);
|
||||
message.setTo(recipient);
|
||||
message.setSubject("Cygnus registration and installation license");
|
||||
message.setText("""
|
||||
Your Cygnus tenant is ready.
|
||||
|
||||
Client code: %s
|
||||
Tenant: %s
|
||||
Package: %s
|
||||
Maximum installations: %d
|
||||
License key: %s
|
||||
License key expiry: %s
|
||||
|
||||
Docker and Docker Compose v2 are mandatory. The installation
|
||||
wizard will not continue until both are available and running.
|
||||
Keep this license key confidential and enter it only in the
|
||||
official Cygnus installation wizard.
|
||||
""".formatted(
|
||||
clientCode,
|
||||
tenantSlug,
|
||||
packageCode,
|
||||
maximumInstallations,
|
||||
issued.licenseKey(),
|
||||
validUntil));
|
||||
mailSender.send(message);
|
||||
})
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.then();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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);
|
||||
}
|
||||
|
||||
public Mono<Void> touchInstallation(UUID installationId, Instant instant) {
|
||||
return repository.touchInstallation(installationId, instant)
|
||||
.onErrorResume(exception -> Mono.empty());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
spring:
|
||||
application:
|
||||
name: cygnus-cloud-service
|
||||
mail:
|
||||
host: ${CYGNUS_MAIL_HOST:}
|
||||
port: ${CYGNUS_MAIL_PORT:587}
|
||||
username: ${CYGNUS_MAIL_USERNAME:}
|
||||
password: ${CYGNUS_MAIL_PASSWORD:}
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: ${CYGNUS_MAIL_SMTP_AUTH:true}
|
||||
starttls:
|
||||
enable: ${CYGNUS_MAIL_STARTTLS:true}
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:192.168.0.111}
|
||||
@@ -36,7 +47,6 @@ cygnus:
|
||||
assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem}
|
||||
access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem}
|
||||
access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem}
|
||||
clients: {}
|
||||
login-encryption:
|
||||
key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01}
|
||||
private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem}
|
||||
@@ -44,6 +54,11 @@ cygnus:
|
||||
cache:
|
||||
key-prefix: ${CYGNUS_CACHE_PREFIX:cygnus}
|
||||
default-ttl: ${CYGNUS_CACHE_TTL:10m}
|
||||
registration-email:
|
||||
from: ${CYGNUS_REGISTRATION_EMAIL_FROM:noreply@cygnus.invalid}
|
||||
activation-rate-limit:
|
||||
maximum-attempts: ${CYGNUS_ACTIVATION_MAX_ATTEMPTS:10}
|
||||
window: ${CYGNUS_ACTIVATION_RATE_WINDOW:10m}
|
||||
|
||||
server:
|
||||
port: ${CYGNUS_CLOUD_PORT:8090}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,369 @@
|
||||
BEGIN;
|
||||
|
||||
-- Client registration and controlled installation activation.
|
||||
-- This migration is additive and preserves all existing tenant, license, and
|
||||
-- installation identifiers. Plaintext license/activation keys must never be
|
||||
-- stored in these tables.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.client_registration_details (
|
||||
registration_id uuid PRIMARY KEY,
|
||||
client_code character varying(40) NOT NULL,
|
||||
legal_company_name character varying(240) NOT NULL,
|
||||
trade_name character varying(240),
|
||||
pan character varying(20),
|
||||
cin character varying(30),
|
||||
gst_number character varying(30),
|
||||
billing_address_line1 character varying(300),
|
||||
billing_address_line2 character varying(300),
|
||||
billing_city character varying(120),
|
||||
billing_state character varying(120),
|
||||
billing_postal_code character varying(20),
|
||||
billing_country character varying(2) NOT NULL DEFAULT 'IN',
|
||||
billing_email character varying(254),
|
||||
primary_contact_name character varying(160),
|
||||
primary_contact_email character varying(254),
|
||||
primary_contact_number character varying(30),
|
||||
alternate_contact_name character varying(160),
|
||||
alternate_contact_email character varying(254),
|
||||
alternate_contact_number character varying(30),
|
||||
contract_start_date date,
|
||||
contract_end_date date,
|
||||
status character varying(20) NOT NULL DEFAULT 'DRAFT',
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
created_by character varying(120) NOT NULL,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
CONSTRAINT ck_identity_registration_code
|
||||
CHECK (client_code ~ '^[A-Z0-9]+(?:-[A-Z0-9]+)*$'),
|
||||
CONSTRAINT ck_identity_registration_country
|
||||
CHECK (billing_country ~ '^[A-Z]{2}$'),
|
||||
CONSTRAINT ck_identity_registration_period
|
||||
CHECK (contract_end_date IS NULL
|
||||
OR contract_start_date IS NULL
|
||||
OR contract_end_date >= contract_start_date),
|
||||
CONSTRAINT ck_identity_registration_status
|
||||
CHECK (status IN ('DRAFT', 'ACTIVE', 'SUSPENDED', 'TERMINATED')),
|
||||
CONSTRAINT ck_identity_registration_version
|
||||
CHECK (version > 0)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_registration_client_code_ci
|
||||
ON identity.client_registration_details (upper(client_code));
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_registration_status_contract
|
||||
ON identity.client_registration_details (status, contract_end_date);
|
||||
|
||||
ALTER TABLE identity.client_account
|
||||
ADD COLUMN IF NOT EXISTS registration_id uuid;
|
||||
ALTER TABLE identity.client_account
|
||||
ADD COLUMN IF NOT EXISTS security_version integer NOT NULL DEFAULT 1;
|
||||
|
||||
-- Stable bridge registration for the tenant introduced by migration 002.
|
||||
INSERT INTO identity.client_registration_details (
|
||||
registration_id, client_code, legal_company_name, trade_name,
|
||||
status, contract_start_date, created_by)
|
||||
VALUES ('00000000-0000-4000-8000-000000000010',
|
||||
'MATRIX', 'Matrix', 'Matrix', 'ACTIVE', DATE '2020-01-01', 'migration-004')
|
||||
ON CONFLICT (registration_id) DO UPDATE SET
|
||||
legal_company_name = EXCLUDED.legal_company_name,
|
||||
updated_at = now();
|
||||
|
||||
UPDATE identity.client_account
|
||||
SET registration_id = '00000000-0000-4000-8000-000000000010'
|
||||
WHERE registration_id IS NULL;
|
||||
|
||||
ALTER TABLE identity.client_account
|
||||
ALTER COLUMN registration_id SET NOT NULL;
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_identity_client_account_registration'
|
||||
AND conrelid = 'identity.client_account'::regclass) THEN
|
||||
ALTER TABLE identity.client_account
|
||||
ADD CONSTRAINT fk_identity_client_account_registration
|
||||
FOREIGN KEY (registration_id)
|
||||
REFERENCES identity.client_registration_details (registration_id)
|
||||
NOT VALID;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_identity_client_account_security_version'
|
||||
AND conrelid = 'identity.client_account'::regclass) THEN
|
||||
ALTER TABLE identity.client_account
|
||||
ADD CONSTRAINT ck_identity_client_account_security_version
|
||||
CHECK (security_version > 0) NOT VALID;
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
ALTER TABLE identity.client_account
|
||||
VALIDATE CONSTRAINT fk_identity_client_account_registration;
|
||||
ALTER TABLE identity.client_account
|
||||
VALIDATE CONSTRAINT ck_identity_client_account_security_version;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_client_account_registration
|
||||
ON identity.client_account (registration_id, status);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_client_account_registration_tenant
|
||||
ON identity.client_account (registration_id, tenant_id);
|
||||
|
||||
UPDATE identity.client_license
|
||||
SET max_installations = 1
|
||||
WHERE max_installations IS NULL;
|
||||
ALTER TABLE identity.client_license
|
||||
ALTER COLUMN max_installations SET DEFAULT 1;
|
||||
ALTER TABLE identity.client_license
|
||||
ALTER COLUMN max_installations SET NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_license_tenant_id
|
||||
ON identity.client_license (tenant_id, license_id);
|
||||
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS license_id uuid;
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS installation_uuid uuid;
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS installation_name character varying(160);
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS status character varying(24);
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS registered_at timestamp with time zone;
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS last_seen_at timestamp with time zone;
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS software_version character varying(40);
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS environment character varying(30);
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS retired_at timestamp with time zone;
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS retired_by character varying(120);
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD COLUMN IF NOT EXISTS retirement_reason character varying(500);
|
||||
|
||||
UPDATE identity.client_installation installation
|
||||
SET license_id = (
|
||||
SELECT license.license_id
|
||||
FROM identity.client_license license
|
||||
WHERE license.tenant_id = installation.tenant_id
|
||||
ORDER BY CASE license.status WHEN 'ACTIVE' THEN 0 ELSE 1 END,
|
||||
license.valid_until DESC
|
||||
LIMIT 1)
|
||||
WHERE installation.license_id IS NULL;
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM identity.client_installation
|
||||
WHERE license_id IS NULL) THEN
|
||||
RAISE EXCEPTION
|
||||
'Migration 004 cannot associate every existing installation with a license';
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
UPDATE identity.client_installation
|
||||
SET installation_uuid = installation_id
|
||||
WHERE installation_uuid IS NULL;
|
||||
UPDATE identity.client_installation
|
||||
SET installation_name = installation_code
|
||||
WHERE installation_name IS NULL;
|
||||
UPDATE identity.client_installation
|
||||
SET status = CASE WHEN enabled THEN 'ACTIVE' ELSE 'SUSPENDED' END
|
||||
WHERE status IS NULL;
|
||||
UPDATE identity.client_installation
|
||||
SET registered_at = created_at
|
||||
WHERE registered_at IS NULL;
|
||||
|
||||
ALTER TABLE identity.client_installation
|
||||
ALTER COLUMN license_id SET NOT NULL;
|
||||
ALTER TABLE identity.client_installation
|
||||
ALTER COLUMN installation_uuid SET NOT NULL;
|
||||
ALTER TABLE identity.client_installation
|
||||
ALTER COLUMN installation_name SET NOT NULL;
|
||||
ALTER TABLE identity.client_installation
|
||||
ALTER COLUMN status SET NOT NULL;
|
||||
ALTER TABLE identity.client_installation
|
||||
ALTER COLUMN status SET DEFAULT 'PENDING';
|
||||
ALTER TABLE identity.client_installation
|
||||
ALTER COLUMN registered_at SET NOT NULL;
|
||||
ALTER TABLE identity.client_installation
|
||||
ALTER COLUMN registered_at SET DEFAULT now();
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fk_identity_installation_tenant_license'
|
||||
AND conrelid = 'identity.client_installation'::regclass) THEN
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD CONSTRAINT fk_identity_installation_tenant_license
|
||||
FOREIGN KEY (tenant_id, license_id)
|
||||
REFERENCES identity.client_license (tenant_id, license_id)
|
||||
NOT VALID;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_identity_installation_status'
|
||||
AND conrelid = 'identity.client_installation'::regclass) THEN
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD CONSTRAINT ck_identity_installation_status
|
||||
CHECK (status IN (
|
||||
'PENDING', 'ACTIVE', 'SUSPENDED', 'DECOMMISSIONED', 'REVOKED', 'FAILED'
|
||||
)) NOT VALID;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_identity_installation_retirement'
|
||||
AND conrelid = 'identity.client_installation'::regclass) THEN
|
||||
ALTER TABLE identity.client_installation
|
||||
ADD CONSTRAINT ck_identity_installation_retirement
|
||||
CHECK (
|
||||
(status NOT IN ('DECOMMISSIONED', 'REVOKED')
|
||||
AND retired_at IS NULL)
|
||||
OR
|
||||
(status IN ('DECOMMISSIONED', 'REVOKED')
|
||||
AND retired_at IS NOT NULL
|
||||
AND retirement_reason IS NOT NULL)
|
||||
) NOT VALID;
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
|
||||
ALTER TABLE identity.client_installation
|
||||
VALIDATE CONSTRAINT fk_identity_installation_tenant_license;
|
||||
ALTER TABLE identity.client_installation
|
||||
VALIDATE CONSTRAINT ck_identity_installation_status;
|
||||
ALTER TABLE identity.client_installation
|
||||
VALIDATE CONSTRAINT ck_identity_installation_retirement;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_installation_uuid
|
||||
ON identity.client_installation (installation_uuid);
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_installation_capacity
|
||||
ON identity.client_installation (tenant_id, license_id, status)
|
||||
WHERE status IN ('PENDING', 'ACTIVE', 'SUSPENDED');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.license_activation_key (
|
||||
activation_key_id uuid PRIMARY KEY,
|
||||
registration_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
license_id uuid NOT NULL,
|
||||
key_hash character varying(255) NOT NULL,
|
||||
key_hint character varying(16) NOT NULL,
|
||||
status character varying(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
expires_at timestamp with time zone,
|
||||
failed_attempts integer NOT NULL DEFAULT 0,
|
||||
maximum_attempts integer NOT NULL DEFAULT 10,
|
||||
locked_until timestamp with time zone,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
last_used_at timestamp with time zone,
|
||||
created_by character varying(120) NOT NULL,
|
||||
revoked_at timestamp with time zone,
|
||||
revoked_by character varying(120),
|
||||
revocation_reason character varying(500),
|
||||
CONSTRAINT fk_identity_activation_key_registration
|
||||
FOREIGN KEY (registration_id)
|
||||
REFERENCES identity.client_registration_details (registration_id),
|
||||
CONSTRAINT fk_identity_activation_key_registration_tenant
|
||||
FOREIGN KEY (registration_id, tenant_id)
|
||||
REFERENCES identity.client_account (registration_id, tenant_id),
|
||||
CONSTRAINT fk_identity_activation_key_tenant_license
|
||||
FOREIGN KEY (tenant_id, license_id)
|
||||
REFERENCES identity.client_license (tenant_id, license_id),
|
||||
CONSTRAINT uq_identity_activation_key_hash UNIQUE (key_hash),
|
||||
CONSTRAINT ck_identity_activation_key_status
|
||||
CHECK (status IN ('ACTIVE', 'LOCKED', 'REVOKED', 'EXPIRED')),
|
||||
CONSTRAINT ck_identity_activation_key_attempts
|
||||
CHECK (failed_attempts >= 0
|
||||
AND maximum_attempts > 0
|
||||
AND failed_attempts <= maximum_attempts),
|
||||
CONSTRAINT ck_identity_activation_key_revocation
|
||||
CHECK ((status <> 'REVOKED' AND revoked_at IS NULL)
|
||||
OR (status = 'REVOKED'
|
||||
AND revoked_at IS NOT NULL
|
||||
AND revocation_reason IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_activation_key_validation
|
||||
ON identity.license_activation_key
|
||||
(registration_id, tenant_id, license_id, status, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.installation_activation_session (
|
||||
activation_session_id uuid PRIMARY KEY,
|
||||
activation_key_id uuid NOT NULL,
|
||||
registration_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
license_id uuid NOT NULL,
|
||||
installation_uuid uuid NOT NULL,
|
||||
token_hash character varying(255) NOT NULL,
|
||||
status character varying(20) NOT NULL DEFAULT 'PENDING',
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
consumed_at timestamp with time zone,
|
||||
source_ip inet,
|
||||
installer_version character varying(40),
|
||||
CONSTRAINT fk_identity_activation_session_key
|
||||
FOREIGN KEY (activation_key_id)
|
||||
REFERENCES identity.license_activation_key (activation_key_id),
|
||||
CONSTRAINT fk_identity_activation_session_registration
|
||||
FOREIGN KEY (registration_id)
|
||||
REFERENCES identity.client_registration_details (registration_id),
|
||||
CONSTRAINT fk_identity_activation_session_registration_tenant
|
||||
FOREIGN KEY (registration_id, tenant_id)
|
||||
REFERENCES identity.client_account (registration_id, tenant_id),
|
||||
CONSTRAINT fk_identity_activation_session_tenant_license
|
||||
FOREIGN KEY (tenant_id, license_id)
|
||||
REFERENCES identity.client_license (tenant_id, license_id),
|
||||
CONSTRAINT uq_identity_activation_session_token UNIQUE (token_hash),
|
||||
CONSTRAINT uq_identity_activation_session_uuid UNIQUE (installation_uuid),
|
||||
CONSTRAINT ck_identity_activation_session_status
|
||||
CHECK (status IN ('PENDING', 'CONSUMED', 'EXPIRED', 'REVOKED')),
|
||||
CONSTRAINT ck_identity_activation_session_period
|
||||
CHECK (expires_at > created_at),
|
||||
CONSTRAINT ck_identity_activation_session_consumed
|
||||
CHECK ((status <> 'CONSUMED' AND consumed_at IS NULL)
|
||||
OR (status = 'CONSUMED' AND consumed_at IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_activation_session_pending
|
||||
ON identity.installation_activation_session (tenant_id, expires_at)
|
||||
WHERE status = 'PENDING';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identity.installation_audit_event (
|
||||
audit_event_id uuid PRIMARY KEY,
|
||||
registration_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
license_id uuid,
|
||||
installation_id uuid,
|
||||
event_type character varying(50) NOT NULL,
|
||||
actor_type character varying(30) NOT NULL,
|
||||
actor_id character varying(160) NOT NULL,
|
||||
reason character varying(500),
|
||||
event_data jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
occurred_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT fk_identity_installation_audit_registration
|
||||
FOREIGN KEY (registration_id)
|
||||
REFERENCES identity.client_registration_details (registration_id),
|
||||
CONSTRAINT fk_identity_installation_audit_registration_tenant
|
||||
FOREIGN KEY (registration_id, tenant_id)
|
||||
REFERENCES identity.client_account (registration_id, tenant_id),
|
||||
CONSTRAINT fk_identity_installation_audit_tenant_license
|
||||
FOREIGN KEY (tenant_id, license_id)
|
||||
REFERENCES identity.client_license (tenant_id, license_id),
|
||||
CONSTRAINT fk_identity_installation_audit_installation
|
||||
FOREIGN KEY (installation_id)
|
||||
REFERENCES identity.client_installation (installation_id),
|
||||
CONSTRAINT ck_identity_installation_audit_actor
|
||||
CHECK (actor_type IN ('ADMIN', 'INSTALLER', 'SYSTEM', 'CLIENT'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_installation_audit_tenant_time
|
||||
ON identity.installation_audit_event (tenant_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_identity_installation_audit_installation_time
|
||||
ON identity.installation_audit_event (installation_id, occurred_at DESC)
|
||||
WHERE installation_id IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
CREATE SEQUENCE IF NOT EXISTS platform.application_query_id_seq;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.application_query (
|
||||
query_id integer PRIMARY KEY DEFAULT nextval('platform.application_query_id_seq'),
|
||||
query_key varchar(100),
|
||||
query_text text NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
CONSTRAINT ck_platform_application_query_id
|
||||
CHECK (query_id > 0),
|
||||
CONSTRAINT ck_platform_application_query_text
|
||||
CHECK (length(btrim(query_text)) > 0)
|
||||
);
|
||||
ALTER TABLE platform.application_query
|
||||
ADD COLUMN IF NOT EXISTS query_key varchar(100);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_platform_application_query_key
|
||||
ON platform.application_query (query_key) WHERE query_key IS NOT NULL;
|
||||
ALTER TABLE platform.application_query ALTER COLUMN query_id
|
||||
SET DEFAULT nextval('platform.application_query_id_seq');
|
||||
SELECT setval('platform.application_query_id_seq',
|
||||
greatest(coalesce((SELECT max(query_id) FROM platform.application_query), 0) + 1, 1), false);
|
||||
@@ -0,0 +1,25 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
current_type text;
|
||||
BEGIN
|
||||
SELECT data_type
|
||||
INTO current_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'platform'
|
||||
AND table_name = 'application_query'
|
||||
AND column_name = 'query_id';
|
||||
|
||||
IF current_type IN ('character varying', 'text') THEN
|
||||
ALTER TABLE platform.application_query
|
||||
DROP CONSTRAINT IF EXISTS ck_platform_application_query_id;
|
||||
|
||||
ALTER TABLE platform.application_query
|
||||
ALTER COLUMN query_id TYPE integer
|
||||
USING regexp_replace(query_id, '^Query', '')::integer;
|
||||
|
||||
ALTER TABLE platform.application_query
|
||||
ADD CONSTRAINT ck_platform_application_query_id
|
||||
CHECK (query_id > 0);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Stable query key; query_id is generated by PostgreSQL.
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO platform.application_query(query_key, query_text, enabled, created_at, updated_at, ismigrated)
|
||||
VALUES ('APPLICATION_SAVE',
|
||||
'procedure!C0L!select * from public.save_application_details(?::jsonb,?::smallint,?::smallint,?::smallint)',
|
||||
true, clock_timestamp(), clock_timestamp(), true)
|
||||
ON CONFLICT (query_key) WHERE query_key IS NOT NULL DO UPDATE SET
|
||||
query_text=excluded.query_text, enabled=true, updated_at=clock_timestamp(), ismigrated=true;
|
||||
|
||||
COMMIT;
|
||||
@@ -11,6 +11,7 @@ import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
@@ -23,6 +24,8 @@ import reactor.test.StepVerifier;
|
||||
class IdentityAuthenticationServiceTest {
|
||||
|
||||
private static final Instant LOGIN_TIME = Instant.parse("2026-07-23T06:30:00Z");
|
||||
private static final UUID TENANT_ID =
|
||||
UUID.fromString("00000000-0000-4000-8000-000000000001");
|
||||
|
||||
@Mock
|
||||
private IdentityRepository repository;
|
||||
@@ -36,11 +39,17 @@ class IdentityAuthenticationServiceTest {
|
||||
MenuItem menuItem = new MenuItem(
|
||||
(short) 10, "Operations", "/ver/operations", (short) 0,
|
||||
(short) 1, "110", "_parent", null);
|
||||
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
|
||||
when(repository.findUsersByLoginId(TENANT_ID, "maddy"))
|
||||
.thenReturn(Flux.just(user));
|
||||
when(passwordVerifier.matches("secret", "legacy-value")).thenReturn(true);
|
||||
when(repository.findMenu((short) 4, (short) 25))
|
||||
when(repository.findMenu(TENANT_ID, (short) 4, (short) 25))
|
||||
.thenReturn(Mono.just(List.of(menuItem)));
|
||||
when(repository.recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25))
|
||||
when(repository.recordLogin(
|
||||
TENANT_ID,
|
||||
"maddy",
|
||||
LOGIN_TIME,
|
||||
"127.0.0.1",
|
||||
(short) 25))
|
||||
.thenReturn(Mono.just(101L));
|
||||
|
||||
IdentityAuthenticationService service = new IdentityAuthenticationService(
|
||||
@@ -48,7 +57,8 @@ class IdentityAuthenticationServiceTest {
|
||||
passwordVerifier,
|
||||
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
|
||||
|
||||
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
|
||||
StepVerifier.create(service.authenticate(
|
||||
TENANT_ID, "maddy", "secret", "127.0.0.1"))
|
||||
.assertNext(result -> {
|
||||
assertThat(result.loginId()).isEqualTo("maddy");
|
||||
assertThat(result.companyName()).isEqualTo("Matrix");
|
||||
@@ -57,20 +67,27 @@ class IdentityAuthenticationServiceTest {
|
||||
})
|
||||
.verifyComplete();
|
||||
|
||||
verify(repository).recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25);
|
||||
verify(repository).recordLogin(
|
||||
TENANT_ID,
|
||||
"maddy",
|
||||
LOGIN_TIME,
|
||||
"127.0.0.1",
|
||||
(short) 25);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInactiveUserWithoutLoadingMenu() {
|
||||
IdentityUser user = user("maddy", false);
|
||||
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
|
||||
when(repository.findUsersByLoginId(TENANT_ID, "maddy"))
|
||||
.thenReturn(Flux.just(user));
|
||||
|
||||
IdentityAuthenticationService service = new IdentityAuthenticationService(
|
||||
repository,
|
||||
passwordVerifier,
|
||||
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
|
||||
|
||||
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
|
||||
StepVerifier.create(service.authenticate(
|
||||
TENANT_ID, "maddy", "secret", "127.0.0.1"))
|
||||
.expectError(AuthenticationException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user