30 Commits

Author SHA1 Message Date
bc00515c1b Edit case issues fixed - Now add case and edit case are working fine 2026-08-02 22:32:54 +05:30
60f9aa05a0 Query 19,20,21,22 migrated - Find same case details in punching screen in case of co-applicant 2026-08-02 14:34:21 +05:30
4705649ae8 Auto Cut Feature Done 2026-08-02 12:52:05 +05:30
5acaffc224 Migrated Query - 24 find punchedrecords 2026-08-02 11:49:39 +05:30
0a1b901b12 More refactoring in js code 2026-08-02 11:09:55 +05:30
440c13cc49 Code Refactored 2026-08-02 10:37:20 +05:30
9b9557105c Removed stale files 2026-08-02 01:09:53 +05:30
af360d7793 Case Punching Feature Done 2026-08-02 00:47:00 +05:30
452e6189e4 Migrated Queries - 4,5 and 70 2026-08-01 23:10:28 +05:30
7e4fb66fc6 Restore installer tests excluded by gitignore 2026-08-01 21:56:23 +05:30
213f560c4a Apply .gitignore 2026-08-01 21:44:11 +05:30
8449578424 Migrated Query 3 - Using Parameterized query 2026-08-01 20:56:12 +05:30
1adcc04efc Punching screen controller created - migrated initview and addcase endpoints 2026-08-01 19:16:24 +05:30
0e5de99f55 Update RedisCachingQueryProvider.java 2026-08-01 16:15:46 +05:30
0dae53017d Query migration to db done - Query persistence in cache is also done 2026-08-01 16:08:52 +05:30
934937feb0 commit 2026-08-01 15:41:35 +05:30
dcb6850306 Fixed PEM Keys issue 2026-08-01 09:51:36 +05:30
f264f90f3b commit 2026-07-27 21:42:45 +05:30
cd11b389e5 Update .gitignore 2026-07-26 23:48:03 +05:30
b3836639bf removed further class files 2026-07-26 23:42:25 +05:30
6f1fb56670 remove classes folder 2026-07-26 23:39:32 +05:30
8ef8bf5d92 Docker installer workflow and licensing flow done - docker container is working fine 2026-07-26 23:38:05 +05:30
d684931bc5 Installer workflow is done - Docker container is working fine 2026-07-26 23:37:23 +05:30
3cf5c83264 Update .gitignore 2026-07-26 20:29:13 +05:30
ebeaaa2629 git ignore added 2026-07-26 20:28:42 +05:30
b84dbc9a56 Update .gitignore 2026-07-26 20:27:56 +05:30
1817d02c31 commit 2026-07-26 19:51:23 +05:30
4afe00e1f8 Installed GUI Integration 2026-07-26 19:48:26 +05:30
60f5450f47 Installation automation - License key approach 2026-07-26 17:02:16 +05:30
d6dc33d9b1 Multi tenant approach - cleanup done 2026-07-26 16:13:47 +05:30
1479 changed files with 14680 additions and 417094 deletions

BIN
.DS_Store vendored

Binary file not shown.

7
.gitignore vendored
View File

@@ -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
View File

@@ -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"
},

View File

@@ -29,18 +29,24 @@ It is valid for one year; the access token obtained with it is short-lived.
For local development, the repository setup script automates prerequisite
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.

View File

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

View File

@@ -0,0 +1,4 @@
package com.cygnus.client.model;
public record CloudQueryResponse(int queryId, String query) {
}

View File

@@ -1,3 +0,0 @@
artifactId=cygnus-cloud-client
groupId=com.cygnus
version=1.0.0-SNAPSHOT

View File

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

View File

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

View File

@@ -1 +0,0 @@
com/cygnus/client/CloudClientPropertiesTest.class

View File

@@ -1 +0,0 @@
/Users/maddy/Projects/matrix/cygnus-cloud-client/src/test/java/com/cygnus/client/CloudClientPropertiesTest.java

View File

@@ -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="&#10;"/>
<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>

View File

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

View File

@@ -53,10 +53,10 @@ on-premises gateway. The client assertion must be:
- bound to the configured client ID, installation ID, and token audience;
- unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`.
The endpoint returns a short-lived RS256 access token carrying `client_id`,
`installation_id`, and the approved scope. The identity endpoint requires the
`identity.login` scope and verifies the same machine binding in the encrypted
login payload.
The endpoint returns a short-lived RS256 access token carrying the client,
installation, tenant, license, security-version, and approved-scope claims.
The identity endpoint requires the `identity.login` scope and verifies the
same machine and tenant binding in the encrypted login payload.
Generate separate cloud key pairs:
@@ -73,26 +73,28 @@ openssl pkey -in config/keys/access-token-private.pem -pubout \
chmod 600 config/keys/*private.pem
```
Configure clients in an external Spring YAML file rather than the packaged
`application.yml`:
## Dynamic tenant, installation, and license registration
```yaml
cygnus:
security:
enabled: true
issuer-uri: https://cloud.example.com
audience: cygnus-cloud-api
token-audience: https://cloud.example.com/oauth2/token
clients:
customer-a:
enabled: true
installation-id: site-01
assertion-public-key: file:/secure/clients/customer-a/public.pem
scopes:
- identity.login
```
Machine clients are no longer configured in a runtime `clients.yml`. The
authoritative records are:
Start with that protected file using
`--spring.config.additional-location=file:/secure/cygnus/clients.yml`.
Never place cloud private keys, customer assertions, or installation private
keys in the repository or container image.
- `identity.client_account`: tenant identity and status;
- `identity.client_installation`: machine identity, assertion public key,
allowed scopes, enabled state, and security version;
- `identity.client_license`: subscription period, package, type, status, and
licensed limits.
Token issuance resolves the installation and active license through a
Redis cache-aside service with PostgreSQL fallback. Cache entries have a
bounded TTL and can be invalidated after administrative changes. Therefore,
new customers, installations, key rotations, scope changes, and license
changes do not require restarting the cloud service.
The login/menu queries are tenant-scoped. Tenant-owned identity tables carry
`tenant_id`; `identity.pages` remains the shared feature catalog while
permissions are assigned per tenant.
Use `scripts/setup-local-communication.sh` to create keys, register or update
the database records, create the initial license, and generate the on-premises
machine assertion. Never place cloud private keys, customer assertions, or
installation private keys in the repository or container image.

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import com.cygnus.cloud.identity.service.LoginRequestReplayService;
import jakarta.validation.Valid;
import java.time.Clock;
import java.time.Duration;
import java.util.UUID;
import org.springframework.util.StringUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -64,12 +65,21 @@ public class CloudLoginController {
return Mono.error(new AuthenticationException("Login request replayed"));
}
return authenticationService.authenticate(
tenantId(machineJwt),
payload.loginId(),
payload.password(),
remoteAddress(serverRequest));
});
}
private UUID tenantId(Jwt jwt) {
try {
return UUID.fromString(jwt.getClaimAsString("tenant_id"));
} catch (RuntimeException exception) {
throw new AuthenticationException("Machine tenant is invalid");
}
}
private void validatePayload(LoginPayload payload) {
if (payload == null
|| !StringUtils.hasText(payload.loginId())

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
package com.cygnus.cloud.query;
public record CloudQuery(int queryId, String query) {
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,13 @@ class AccessTokenIssuer {
.jwtID(UUID.randomUUID().toString())
.claim("client_id", principal.clientId())
.claim("installation_id", principal.installationId())
.claim("installation_uuid",
principal.internalInstallationId().toString())
.claim("tenant_id", principal.tenantId().toString())
.claim("license_id", principal.licenseId().toString())
.claim("license_type", principal.licenseType())
.claim("package_code", principal.packageCode())
.claim("security_version", principal.securityVersion())
.claim("scope", String.join(" ", scopes))
.build();
SignedJWT jwt = new SignedJWT(

View File

@@ -7,29 +7,33 @@ import com.nimbusds.jose.crypto.RSADecrypter;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.cygnus.cloud.tenant.model.ClientInstallation;
import com.cygnus.cloud.tenant.model.ClientLicense;
import com.cygnus.cloud.tenant.service.TenantRegistrationService;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component
class ClientAssertionValidator {
private final CommunicationSecurityProperties properties;
private final TenantRegistrationService registrations;
private final Clock clock;
ClientAssertionValidator(CommunicationSecurityProperties properties, Clock clock) {
ClientAssertionValidator(
CommunicationSecurityProperties properties,
TenantRegistrationService registrations,
Clock clock) {
this.properties = properties;
this.registrations = registrations;
this.clock = clock;
}
MachineClientPrincipal validate(String clientId, String encryptedAssertion) {
CommunicationSecurityProperties.MachineClient client =
properties.clients() == null ? null : properties.clients().get(clientId);
if (client == null || !client.enabled()) {
throw invalid();
}
Mono<MachineClientPrincipal> validate(String clientId, String encryptedAssertion) {
try {
JWEObject jwe = JWEObject.parse(encryptedAssertion);
if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm())
@@ -40,25 +44,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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ package com.cygnus.cloud.security;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@@ -34,33 +33,6 @@ class MachineSecurityConfigurationValidator implements InitializingBean {
requireText("access-token-private-key", properties.accessTokenPrivateKey());
requireText("access-token-public-key", properties.accessTokenPublicKey());
Map<String, CommunicationSecurityProperties.MachineClient> clients =
properties.clients();
if (clients == null || clients.isEmpty()) {
throw invalid("at least one machine client is required");
}
clients.forEach(this::validateClient);
}
private void validateClient(
String clientId,
CommunicationSecurityProperties.MachineClient client) {
requireText("clients.<client-id>", clientId);
if (client == null) {
throw invalid("client '" + clientId + "' has no configuration");
}
requireText(
"clients." + clientId + ".installation-id",
client.installationId());
requireText(
"clients." + clientId + ".assertion-public-key",
client.assertionPublicKey());
if (client.scopes() == null
|| client.scopes().isEmpty()
|| client.scopes().stream().anyMatch(this::isBlank)) {
throw invalid(
"clients." + clientId + ".scopes must contain valid scopes");
}
}
private void requireUri(String name, String value) {

View File

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

View File

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

View File

@@ -0,0 +1,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) {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public enum ActivationKeyStatus {
ACTIVE,
LOCKED,
REVOKED,
EXPIRED
}

View File

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

View File

@@ -0,0 +1,13 @@
package com.cygnus.cloud.tenant.model;
import java.time.OffsetDateTime;
import java.util.UUID;
public record ClientAccount(
UUID tenantId,
String clientSlug,
String clientName,
ClientStatus status,
OffsetDateTime createdAt,
OffsetDateTime updatedAt) {
}

View File

@@ -0,0 +1,17 @@
package com.cygnus.cloud.tenant.model;
import java.time.OffsetDateTime;
import java.util.Set;
import java.util.UUID;
public record ClientInstallation(
UUID installationId,
UUID tenantId,
String clientId,
String installationCode,
String assertionPublicKey,
Set<String> allowedScopes,
boolean enabled,
int securityVersion,
OffsetDateTime lastAuthenticatedAt) {
}

View File

@@ -0,0 +1,24 @@
package com.cygnus.cloud.tenant.model;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.UUID;
public record ClientLicense(
UUID licenseId,
UUID tenantId,
String licenseType,
String packageCode,
Instant validFrom,
Instant validUntil,
LicenseStatus status,
Integer maxUsers,
Integer maxInstallations,
OffsetDateTime updatedAt) {
public boolean isActiveAt(Instant instant) {
return status == LicenseStatus.ACTIVE
&& !instant.isBefore(validFrom)
&& instant.isBefore(validUntil);
}
}

View File

@@ -0,0 +1,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) {
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package com.cygnus.cloud.tenant.model;
import java.util.UUID;
public record IssuedLicenseKey(
UUID activationKeyId,
String licenseKey,
String keyHint) {
}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public record LicenseDeliveryDetails(
String clientCode,
String tenantSlug,
String packageCode,
int maximumInstallations,
String primaryContactEmail) {}

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.model;
public enum RegistrationStatus {
DRAFT,
ACTIVE,
SUSPENDED,
TERMINATED
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package com.cygnus.cloud.tenant.service;
public class ActivationRateLimitException extends RuntimeException {
public ActivationRateLimitException() {
super("Installation activation rate limit exceeded");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package com.cygnus.cloud.tenant.service;
public class LicenseKeyException extends RuntimeException {
public LicenseKeyException(String message) {
super(message);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
BEGIN;
-- DMLP phase 2: introduce the tenant, installation and license source of truth.
-- The fixed legacy tenant is a migration bridge for the identity data copied by
-- 001_identity_login_schema.sql. Replace its migration license through the
-- administration workflow before commercial enforcement is enabled.
CREATE TABLE IF NOT EXISTS identity.client_account (
tenant_id uuid PRIMARY KEY,
client_slug character varying(80) NOT NULL,
client_name character varying(200) NOT NULL,
status character varying(20) NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT uq_identity_client_account_slug UNIQUE (client_slug),
CONSTRAINT ck_identity_client_account_slug
CHECK (client_slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
CONSTRAINT ck_identity_client_account_status
CHECK (status IN ('ACTIVE', 'SUSPENDED', 'CANCELLED'))
);
CREATE TABLE IF NOT EXISTS identity.client_installation (
installation_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
client_id character varying(80) NOT NULL,
installation_code character varying(100) NOT NULL,
assertion_public_key text NOT NULL,
allowed_scopes text[] NOT NULL DEFAULT ARRAY[]::text[],
enabled boolean NOT NULL DEFAULT true,
security_version integer NOT NULL DEFAULT 1,
last_authenticated_at timestamp with time zone,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT fk_identity_installation_tenant
FOREIGN KEY (tenant_id)
REFERENCES identity.client_account (tenant_id),
CONSTRAINT uq_identity_installation_client_code
UNIQUE (client_id, installation_code),
CONSTRAINT ck_identity_installation_client_id
CHECK (client_id ~ '^[A-Za-z0-9][A-Za-z0-9_-]*$'),
CONSTRAINT ck_identity_installation_code
CHECK (installation_code ~ '^[A-Za-z0-9][A-Za-z0-9_-]*$'),
CONSTRAINT ck_identity_installation_security_version
CHECK (security_version > 0)
);
CREATE TABLE IF NOT EXISTS identity.client_license (
license_id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
license_type character varying(30) NOT NULL,
package_code character varying(50) NOT NULL,
valid_from timestamp with time zone NOT NULL,
valid_until timestamp with time zone NOT NULL,
status character varying(20) NOT NULL,
max_users integer,
max_installations integer,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT fk_identity_license_tenant
FOREIGN KEY (tenant_id)
REFERENCES identity.client_account (tenant_id),
CONSTRAINT ck_identity_license_period
CHECK (valid_until > valid_from),
CONSTRAINT ck_identity_license_status
CHECK (status IN ('ACTIVE', 'SUSPENDED', 'EXPIRED', 'CANCELLED')),
CONSTRAINT ck_identity_license_limits
CHECK ((max_users IS NULL OR max_users > 0)
AND (max_installations IS NULL OR max_installations > 0))
);
CREATE INDEX IF NOT EXISTS ix_identity_installation_tenant_enabled
ON identity.client_installation (tenant_id, enabled);
CREATE INDEX IF NOT EXISTS ix_identity_installation_lookup
ON identity.client_installation (client_id, installation_code, enabled);
CREATE INDEX IF NOT EXISTS ix_identity_license_tenant_period
ON identity.client_license (tenant_id, status, valid_from, valid_until);
-- Add tenant ownership without changing any legacy primary-key values.
ALTER TABLE identity.company
ADD COLUMN IF NOT EXISTS tenant_id uuid;
ALTER TABLE identity.company_branch
ADD COLUMN IF NOT EXISTS tenant_id uuid;
ALTER TABLE identity.user_group
ADD COLUMN IF NOT EXISTS tenant_id uuid;
ALTER TABLE identity.app_user
ADD COLUMN IF NOT EXISTS tenant_id uuid;
ALTER TABLE identity.permission
ADD COLUMN IF NOT EXISTS tenant_id uuid;
ALTER TABLE identity.denied_pages
ADD COLUMN IF NOT EXISTS tenant_id uuid;
ALTER TABLE identity.user_loginhistory
ADD COLUMN IF NOT EXISTS tenant_id uuid;
-- Seed a stable bridge tenant for all identity data that already exists.
INSERT INTO identity.client_account
(tenant_id, client_slug, client_name, status)
VALUES ('00000000-0000-4000-8000-000000000001', 'matrix', 'Matrix', 'ACTIVE')
ON CONFLICT (tenant_id) DO UPDATE SET
client_slug = EXCLUDED.client_slug,
client_name = EXCLUDED.client_name,
updated_at = now();
-- A non-expiring migration bridge keeps current users operational. It must be
-- replaced by a commercial license before license administration goes live.
INSERT INTO identity.client_license
(license_id, tenant_id, license_type, package_code,
valid_from, valid_until, status)
VALUES ('00000000-0000-4000-8000-000000000002',
'00000000-0000-4000-8000-000000000001',
'MIGRATION', 'LEGACY_FULL',
'2020-01-01 00:00:00+00', '2099-12-31 23:59:59+00', 'ACTIVE')
ON CONFLICT (license_id) DO NOTHING;
UPDATE identity.company
SET tenant_id = '00000000-0000-4000-8000-000000000001'
WHERE tenant_id IS NULL;
UPDATE identity.company_branch
SET tenant_id = '00000000-0000-4000-8000-000000000001'
WHERE tenant_id IS NULL;
UPDATE identity.user_group
SET tenant_id = '00000000-0000-4000-8000-000000000001'
WHERE tenant_id IS NULL;
UPDATE identity.app_user
SET tenant_id = '00000000-0000-4000-8000-000000000001'
WHERE tenant_id IS NULL;
UPDATE identity.permission
SET tenant_id = '00000000-0000-4000-8000-000000000001'
WHERE tenant_id IS NULL;
UPDATE identity.denied_pages
SET tenant_id = '00000000-0000-4000-8000-000000000001'
WHERE tenant_id IS NULL;
UPDATE identity.user_loginhistory
SET tenant_id = '00000000-0000-4000-8000-000000000001'
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS ix_identity_company_tenant
ON identity.company (tenant_id, company_id);
CREATE INDEX IF NOT EXISTS ix_identity_branch_tenant
ON identity.company_branch (tenant_id, company_id, branch_id);
CREATE INDEX IF NOT EXISTS ix_identity_group_tenant
ON identity.user_group (tenant_id, group_id);
CREATE INDEX IF NOT EXISTS ix_identity_user_tenant_login
ON identity.app_user (tenant_id, upper(loginid));
CREATE INDEX IF NOT EXISTS ix_identity_permission_tenant_group_page
ON identity.permission (tenant_id, group_id, page_id)
WHERE permission <> '000';
CREATE INDEX IF NOT EXISTS ix_identity_denied_tenant_user_page
ON identity.denied_pages (tenant_id, user_id, page_id)
WHERE isdenied = 1;
CREATE INDEX IF NOT EXISTS ix_identity_login_history_tenant_user_time
ON identity.user_loginhistory (tenant_id, user_id, logintime DESC);
COMMIT;

View File

@@ -0,0 +1,231 @@
BEGIN;
-- DMLP phase 3: make tenant ownership mandatory and enforce same-tenant
-- relationships without changing any legacy primary keys.
ALTER TABLE identity.company
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE identity.company_branch
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE identity.user_group
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE identity.app_user
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE identity.permission
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE identity.denied_pages
ALTER COLUMN tenant_id SET NOT NULL;
ALTER TABLE identity.user_loginhistory
ALTER COLUMN tenant_id SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_company_tenant_id
ON identity.company (tenant_id, company_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_branch_tenant_id
ON identity.company_branch (tenant_id, branch_id, company_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_group_tenant_id
ON identity.user_group (tenant_id, group_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_user_tenant_id
ON identity.app_user (tenant_id, user_id);
DO $migration$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_company_tenant'
AND conrelid = 'identity.company'::regclass) THEN
ALTER TABLE identity.company
ADD CONSTRAINT fk_identity_company_tenant
FOREIGN KEY (tenant_id)
REFERENCES identity.client_account (tenant_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_branch_tenant'
AND conrelid = 'identity.company_branch'::regclass) THEN
ALTER TABLE identity.company_branch
ADD CONSTRAINT fk_identity_branch_tenant
FOREIGN KEY (tenant_id)
REFERENCES identity.client_account (tenant_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_branch_company_tenant'
AND conrelid = 'identity.company_branch'::regclass) THEN
ALTER TABLE identity.company_branch
ADD CONSTRAINT fk_identity_branch_company_tenant
FOREIGN KEY (tenant_id, company_id)
REFERENCES identity.company (tenant_id, company_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_group_tenant'
AND conrelid = 'identity.user_group'::regclass) THEN
ALTER TABLE identity.user_group
ADD CONSTRAINT fk_identity_group_tenant
FOREIGN KEY (tenant_id)
REFERENCES identity.client_account (tenant_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_user_tenant'
AND conrelid = 'identity.app_user'::regclass) THEN
ALTER TABLE identity.app_user
ADD CONSTRAINT fk_identity_user_tenant
FOREIGN KEY (tenant_id)
REFERENCES identity.client_account (tenant_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_user_group_tenant'
AND conrelid = 'identity.app_user'::regclass) THEN
ALTER TABLE identity.app_user
ADD CONSTRAINT fk_identity_user_group_tenant
FOREIGN KEY (tenant_id, group_id)
REFERENCES identity.user_group (tenant_id, group_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_user_company_tenant'
AND conrelid = 'identity.app_user'::regclass) THEN
ALTER TABLE identity.app_user
ADD CONSTRAINT fk_identity_user_company_tenant
FOREIGN KEY (tenant_id, company_id)
REFERENCES identity.company (tenant_id, company_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_user_branch_tenant'
AND conrelid = 'identity.app_user'::regclass) THEN
ALTER TABLE identity.app_user
ADD CONSTRAINT fk_identity_user_branch_tenant
FOREIGN KEY (tenant_id, branch_id, company_id)
REFERENCES identity.company_branch (tenant_id, branch_id, company_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_permission_tenant'
AND conrelid = 'identity.permission'::regclass) THEN
ALTER TABLE identity.permission
ADD CONSTRAINT fk_identity_permission_tenant
FOREIGN KEY (tenant_id)
REFERENCES identity.client_account (tenant_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_permission_group_tenant'
AND conrelid = 'identity.permission'::regclass) THEN
ALTER TABLE identity.permission
ADD CONSTRAINT fk_identity_permission_group_tenant
FOREIGN KEY (tenant_id, group_id)
REFERENCES identity.user_group (tenant_id, group_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_permission_page'
AND conrelid = 'identity.permission'::regclass) THEN
ALTER TABLE identity.permission
ADD CONSTRAINT fk_identity_permission_page
FOREIGN KEY (page_id)
REFERENCES identity.pages (page_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_denied_tenant'
AND conrelid = 'identity.denied_pages'::regclass) THEN
ALTER TABLE identity.denied_pages
ADD CONSTRAINT fk_identity_denied_tenant
FOREIGN KEY (tenant_id)
REFERENCES identity.client_account (tenant_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_denied_user_tenant'
AND conrelid = 'identity.denied_pages'::regclass) THEN
ALTER TABLE identity.denied_pages
ADD CONSTRAINT fk_identity_denied_user_tenant
FOREIGN KEY (tenant_id, user_id)
REFERENCES identity.app_user (tenant_id, user_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_denied_page'
AND conrelid = 'identity.denied_pages'::regclass) THEN
ALTER TABLE identity.denied_pages
ADD CONSTRAINT fk_identity_denied_page
FOREIGN KEY (page_id)
REFERENCES identity.pages (page_id)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_identity_login_history_tenant'
AND conrelid = 'identity.user_loginhistory'::regclass) THEN
ALTER TABLE identity.user_loginhistory
ADD CONSTRAINT fk_identity_login_history_tenant
FOREIGN KEY (tenant_id)
REFERENCES identity.client_account (tenant_id)
NOT VALID;
END IF;
END
$migration$;
ALTER TABLE identity.company
VALIDATE CONSTRAINT fk_identity_company_tenant;
ALTER TABLE identity.company_branch
VALIDATE CONSTRAINT fk_identity_branch_tenant;
ALTER TABLE identity.company_branch
VALIDATE CONSTRAINT fk_identity_branch_company_tenant;
ALTER TABLE identity.user_group
VALIDATE CONSTRAINT fk_identity_group_tenant;
ALTER TABLE identity.app_user
VALIDATE CONSTRAINT fk_identity_user_tenant;
ALTER TABLE identity.app_user
VALIDATE CONSTRAINT fk_identity_user_group_tenant;
ALTER TABLE identity.app_user
VALIDATE CONSTRAINT fk_identity_user_company_tenant;
ALTER TABLE identity.app_user
VALIDATE CONSTRAINT fk_identity_user_branch_tenant;
ALTER TABLE identity.permission
VALIDATE CONSTRAINT fk_identity_permission_tenant;
ALTER TABLE identity.permission
VALIDATE CONSTRAINT fk_identity_permission_group_tenant;
ALTER TABLE identity.permission
VALIDATE CONSTRAINT fk_identity_permission_page;
ALTER TABLE identity.denied_pages
VALIDATE CONSTRAINT fk_identity_denied_tenant;
ALTER TABLE identity.denied_pages
VALIDATE CONSTRAINT fk_identity_denied_user_tenant;
ALTER TABLE identity.denied_pages
VALIDATE CONSTRAINT fk_identity_denied_page;
ALTER TABLE identity.user_loginhistory
VALIDATE CONSTRAINT fk_identity_login_history_tenant;
COMMIT;

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
@@ -23,6 +24,8 @@ import reactor.test.StepVerifier;
class IdentityAuthenticationServiceTest {
private static final Instant LOGIN_TIME = Instant.parse("2026-07-23T06:30:00Z");
private static final UUID TENANT_ID =
UUID.fromString("00000000-0000-4000-8000-000000000001");
@Mock
private IdentityRepository repository;
@@ -36,11 +39,17 @@ class IdentityAuthenticationServiceTest {
MenuItem menuItem = new MenuItem(
(short) 10, "Operations", "/ver/operations", (short) 0,
(short) 1, "110", "_parent", null);
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
when(repository.findUsersByLoginId(TENANT_ID, "maddy"))
.thenReturn(Flux.just(user));
when(passwordVerifier.matches("secret", "legacy-value")).thenReturn(true);
when(repository.findMenu((short) 4, (short) 25))
when(repository.findMenu(TENANT_ID, (short) 4, (short) 25))
.thenReturn(Mono.just(List.of(menuItem)));
when(repository.recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25))
when(repository.recordLogin(
TENANT_ID,
"maddy",
LOGIN_TIME,
"127.0.0.1",
(short) 25))
.thenReturn(Mono.just(101L));
IdentityAuthenticationService service = new IdentityAuthenticationService(
@@ -48,7 +57,8 @@ class IdentityAuthenticationServiceTest {
passwordVerifier,
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
StepVerifier.create(service.authenticate(
TENANT_ID, "maddy", "secret", "127.0.0.1"))
.assertNext(result -> {
assertThat(result.loginId()).isEqualTo("maddy");
assertThat(result.companyName()).isEqualTo("Matrix");
@@ -57,20 +67,27 @@ class IdentityAuthenticationServiceTest {
})
.verifyComplete();
verify(repository).recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25);
verify(repository).recordLogin(
TENANT_ID,
"maddy",
LOGIN_TIME,
"127.0.0.1",
(short) 25);
}
@Test
void rejectsInactiveUserWithoutLoadingMenu() {
IdentityUser user = user("maddy", false);
when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user));
when(repository.findUsersByLoginId(TENANT_ID, "maddy"))
.thenReturn(Flux.just(user));
IdentityAuthenticationService service = new IdentityAuthenticationService(
repository,
passwordVerifier,
Clock.fixed(LOGIN_TIME, ZoneOffset.UTC));
StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1"))
StepVerifier.create(service.authenticate(
TENANT_ID, "maddy", "secret", "127.0.0.1"))
.expectError(AuthenticationException.class)
.verify();
}

Some files were not shown because too many files have changed in this diff Show More