43 Commits

Author SHA1 Message Date
86df5b9bc7 Merge branch 'dedupe_feature' into development 2026-08-12 13:37:08 +05:30
61bea5fb6e Company options values, portfolio options values migration done 2026-08-12 09:38:18 +05:30
c835a9deb0 Punching, Dedupe and CutOff Features Done 2026-08-12 07:46:24 +05:30
b862a3f686 Fixed Installer Issue 2026-08-08 08:05:29 +05:30
5139f30e86 Merge branch 'edp-punching-feature' into development 2026-08-02 22:39:12 +05:30
e964c74f96 Update .gitignore 2026-08-02 22:38:46 +05:30
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
421911e41b Merge branch 'query-provider-approach' into development 2026-08-01 16:16:03 +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
ebe8e5d574 Update .gitignore 2026-07-27 21:43:04 +05:30
f264f90f3b commit 2026-07-27 21:42:45 +05:30
ba820e428e Merge branch 'project-installer-license-workflow' into development 2026-07-26 23:48:21 +05:30
cd11b389e5 Update .gitignore 2026-07-26 23:48:03 +05:30
6a91eab764 removed class files 2026-07-26 23:44:20 +05:30
5b8280cbc3 Merge branch 'project-installer-license-workflow' into development 2026-07-26 23:43:30 +05:30
1481c84770 Update .gitignore 2026-07-26 23:42:59 +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
a8aa61336f Update .gitignore 2026-07-26 23:39:08 +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
1573 changed files with 20330 additions and 420581 deletions

BIN
.DS_Store vendored

Binary file not shown.

8
.gitignore vendored
View File

@@ -1,5 +1,4 @@
/target/ /target/
/config/
!.mvn/wrapper/maven-wrapper.jar !.mvn/wrapper/maven-wrapper.jar
### STS ### ### STS ###
@@ -28,3 +27,10 @@
/dist/ /dist/
/nbdist/ /nbdist/
/.nb-gradle/ /.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_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem",
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01", "CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem", "CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem",
"SPRING_CONFIG_ADDITIONAL_LOCATION": "file:${workspaceFolder}/config/clients.yml" "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" "shortenCommandLine": "argfile"
}, },
@@ -53,7 +59,9 @@
"REDIS_HOST": "103.125.129.116", "REDIS_HOST": "103.125.129.116",
"REDIS_PORT": "7901", "REDIS_PORT": "7901",
"REDIS_PASSWORD": "M@triXR3d1s@6202", "REDIS_PASSWORD": "M@triXR3d1s@6202",
"REDIS_DATABASE": "1",
"REDIS_SSL": "false", "REDIS_SSL": "false",
"CYGNUS_QUERY_CACHE_ENABLED": "false",
"CYGNUS_CLOUD_BASE_URL": "http://localhost:8090", "CYGNUS_CLOUD_BASE_URL": "http://localhost:8090",
"CYGNUS_TOKEN_URL": "http://localhost:8090/oauth2/token", "CYGNUS_TOKEN_URL": "http://localhost:8090/oauth2/token",
"CYGNUS_CLIENT_ID": "matrix", "CYGNUS_CLIENT_ID": "matrix",
@@ -61,6 +69,8 @@
"CYGNUS_CLIENT_ASSERTION": "file:${workspaceFolder}/config/clients/matrix/matrix-matrix-delhi-cygnus-01-assertion.jwt", "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_KEY_ID": "cygnus-login-2026-01",
"CYGNUS_LOGIN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/login-public.pem", "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_CLOUD_REQUEST_TIMEOUT": "PT10S",
"CYGNUS_TOKEN_REFRESH_SKEW": "PT30S" "CYGNUS_TOKEN_REFRESH_SKEW": "PT30S"
}, },

View File

@@ -0,0 +1 @@
eyJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAtMjU2In0.rf8bViJPb5bRJHFamVoCftE0TLA1Gz7JyGnfcjtKEx3KiUL4SSbgYSUQlsKhCt8VJMWE--UpgbkM0Bv3pFVsMF_uh693MhFstEy_A6S3MODjoUV_bMLa_Zr1yH2Jwzud4LSyw-ctJ7G-vO8S_I6YML0HV5kNbobdFmMfXFLGKssVmM_Km0m57ZgSjj3zRii0cKpmdwwSbWoJC2y519TpKhIja0OZAoN1oB3IVKvERlFYCQYUpQmcRkzULGrlSoLIm9iqbRX4qMGhGISwVS6JdVtn9WjpSjF3xkUxN_QsVuYKYFL_kEKWSNepIcfoip7Ag9Qw_S1RZA_KO908MgrzGRbs2UIpxY9sAM8yZ-k1NTk0swV836s5pdDIP3PHovcd6iR8mqWBPt7mq7ES98vNeCZxqPLB_b1JuNfKsbLPdRIvEk2DTjZHeEi-yA1yl5uMi_1f4la6mgy65u_jLL_Ow28uZtj-8yQ3dR4lF3K7tkxQaB4LQiH5nl6lXEndpoPe.Cpu6VoHf2ptCue_L.uH-Pq1WZN-x_c71Iyx8X1QJa1JZRp9sYta9iUOmQ4W8x4Qh2wYm-_tpr8FEw56MLvIb8IH_gHVVLAJgXTmCZhyo26vJlPCtnMMPYFDrPRzTJv9Ewg7RMpsAh_kXFFK4UJ6Tgt_r7Tg_bnVOTKXodUjl3wVdMowZRX_ua-gKwLU-2LDWdqIRmuFDSOkMb1sFBZLPyjWHg_rThs-RwVa4YJdDi74s-L0WX0xRPXzeovu1-xvOgZ0xYf0ScbsFQgvK-JwAxOWsB1WKeHRqnnx5-apK6qtzhpiJ30c7ukPlrg2nieuJvkXOVuKBKXLVRLSViOH4HU76DD96btDUA0Cg2kEURpepucX0VWPyB7K3xb9v1AT42TUkW9wZzdmIs9saRo-ItuWzlCezvapLIsbqmYCs_rW2J3KgqXrUyy2mptn32sqaqQZnlNVu4wwboPCCrbl2MG5CYlYKrwaOHNg611j8sCDkIptm_UmPFnOnzvQYwURuDSgzswTIV5bTd_58yxag2yWwiMdGLMDbxxSUPQ6a4EbuDkqwmGBTSLiG7SaSFchSg6o9EflqK6k-u_rlixCxmfg1A6sa7nloW17dOxDzGKtmn9lBoaskL97nNrhP6ptftak7j4TIZulz1FUOSVoR8kXK8RNQMj-b6rg_MdSO9Ecq5hZsza1SzjkzKZvV-tpP6Wl6-rszCH6g3DIQAWN5aWdUkwuya1HqDDyVPbUOVQPsp4nhVC24QmnlbcqFwQdoCrikYKyX3Snc0H6_K4z9c6nURAdj6THwHmA9BfHtJniI_rEIvkmBk3yndi-H5eAjtxIoYSFQrSTWEFhgvk6bAKoR_xb_cHgzKOxYLBiO_FifkjkFDOlkVBuYnZEp3oRePDKgMDfL5Oxjuie4u-8yrH52Cd32yQzbvGplaXLLeEDf3i71dcz4LkO5DknScWG3G_SbKBpjlse-GKe3XYqRXeRIYLpyiX1MXph0b-Q.dvHU75ZR8wJFxoSacfkthw

View File

@@ -0,0 +1,40 @@
-----BEGIN PRIVATE KEY-----
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
M/D4ZjXsfrW//BMF6+w2Bh4=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAloJtGMOLcTNWe1IgdrT8
uYucI1Vs02WjyUPZ7Po51iC67QmGdG0InzlBJYdkYtTz8eda+qArZMNGB0ChFs/7
xKPKASkMvVcrm4IYbth0DEIkXWAL7YXsWd8qdS+KsnxuC6TqvuA9Xq/PX4RmtAq4
bl0/1T2HZjJs53/b73l5NehcseZU129w+5lBqDwW5xh+1VnXNbc96za7BFfQSnfg
9WwaWSpo8Y57XATnV7s46yT8gNKM29INHJXs4aIrQ9tcLnTbDz9nhGceNgec3AhO
zc4TyCPugUiG9WD4ehsQr+7Cp30RqT5l9ZPvoBTpAdHLZ1r/jB+qY6+WkY6EsOJW
2xg9bUYoHXjMiFdi8OLXC4NTAPUquFOaIMfjxGaYQc8NoDMFiq3qdTWi2ndCOoxX
yFxYRP5pYs/oA46UAHZsCHaspjvl9jfR/abCALBtsFLwpLA9qqBws5ojXFH//UoX
E7dXnJziCFQNyJOhXtIhbQhtb3wJc6WSLtDrRpFtaJudAgMBAAE=
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,40 @@
-----BEGIN PRIVATE KEY-----
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
m/lDtrY4EpMy9FSQD1zb+qE=
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAvyfuM67QxEonEvC2Vf21
P+fxo406Z4C+zNzxH/8tCPhMH94Sklt10WXqSfQFfZbhJPVYoZL8m0NDI7ozI7lI
MPAhAVfcFOzO6VqpFGrzMgMqmnWLrxRsL3y1rjB3LnE84W+ATcS/mlk6rwbCfrSo
RRlY3i79ol8h36jhhaHT2/iGGO3uvqAQK334lQEOlqr+YL4Kkr/GMOm86bE4PcZS
JgCTAnlCQC7TUu6jGWuocmI1nNwLXPjQvXgc+HKtJEqFGf+a0fty9fLgaR2yIzlM
9i31r3kjeKzqVe2DeLTbttd8bVWU4/KmGg8rEchtwC8wR4inuDUjwJ3FwmdBrvDp
Ga1AM8yofdbs73Lv2EgkvRLvlgaWiX2P6KdlKGHeaDj9v0YWJbiFM4rc9y5hD3lG
2unhpvP/49OFXVBT2/pm86LmxSlwsun3G2LKNOXI646jhdVHQkIVcBiHTHYQOe9/
xLqx3ZiEM7witbzuJnxIkbj/8OFgH+WDUmTQVXtbz7vdAgMBAAE=
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,40 @@
-----BEGIN PRIVATE KEY-----
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
RqsZZ1cLu0TCOoNBkcSr79a6
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAtZc750+cxwDcG/lvpEHm
3fxFgp5X5df90s51R4ShKpV6dmDRYXnVil9GmXrJf/aGAqrsf6ZUvZWJrAJ2OO2X
Wt8tcqajnaWdM33BrH/QztTJkoGHZM1xLY5EpRaWo1LaG2gNGsaxG0ON9T1emKi7
XDEcI9Lmc/mhP6SuuUnvGwq1Z433vqGodorkU+T2jCfflhwfenvBcOk4GJZoJFlY
sjy9Dtlha70MJpdVFWffxs4UBhhkjckb7GbDTVxa3xvz1NEbkY2+oh/tJmuIuupA
7A6TnfYP1YPXPMRQFtSPaKMOeKW5J5IorvgQKr4C74HR9iBojJN2oJ6/BjfAaz7P
8kywLj2uo9gJemOm1wz8HZyZsz482GHa0wqpUcFfOIr0oTMiJtvLn/FSYVb5xvNg
3xWiGPMju32Z49XTX7VxTDsIjGx5wtDd4G8ILS3yUlKw0yufHacmy8Fn+Plk+Xtz
Bbz6rq4frV93/3mfVMPGgBDx8mLjVJovPBnXKBSX+SdbAgMBAAE=
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,40 @@
-----BEGIN PRIVATE KEY-----
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
5PO2cnRk24OsUqAswTBT2qjd
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAzpOwECKVW0KdZwlVJtW7
tkqqa1wY1J+HhuFf/kbjmJv8fwFcGj+uyy2s5cXzgckNHz3Cv4Ki8VnQakbn83KR
TNIuMvYgwnaNY35KvVKU5R8KPJ7wV8yEUyhRyjtauaY9TaVUH0MStD3izcZ7Pfej
XJvxRtLjQ9SX/K+joHs3mkWyWDNYm9u6ecLBoPxW6BpAXiUOWnSpc0IO2njGbvnY
I7zs5tYdFjVtDTf4TOSIDXeCXc7nNUhf/yQVAEKvGmr64SihZc+11T5AXJy1eVEj
Fwj7hWqcZxhgukacOoU8oUORO0O0H1HIS8ICXHBgKL17Jq+furEInlU4Pfp1tkA7
dYh9nXQ91+hmRtTSXA1BpzWdIzC3fQqnwTsD6cyIDsPdRB9iSJTywFPbGywMIbh4
wIpUOS3s83DBtpxj2qHw7zc5Q99BP4Dd38kl9ejfdYuFYRmbzrq+BZR7ic5/LM7+
8iExYGpz3sFCr6qJAlCDK4th6TCl2ZjzgpTOF3pnC1I7AgMBAAE=
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,40 @@
-----BEGIN PRIVATE KEY-----
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
VkpSw8QqBfhHSViEh12BQZa2
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA2g0IbNxsNIZ6fKaGTHTQ
pUFfgJVq3x0SZ458xAQDL0l1PZ+lnMtvS3/ZZpbF99bLuqyzUuHwi+1BJ/Nled61
oK8RdJKfUPiQUshXfvWT4/sLsYO6H+v2qw2jBv6kx3h+teDiuHMbw6ikUECr5u1k
qkjb+ls3aijtWplmN25HRhwFB/l6/0xAhEQNA36EoNb+ufsDR/2aRQYGQ6qegynp
8bUt7JDf3hW1y76UCKlKteFfIOkp4zwY0YYq/NewgKlze08LBrX+zy5xr5Vn8nE0
ZtWY8vbNC33Sf6g5D1ZlA+HvquplAWS/rPymj+IN79FeXNegmyCxF/ZafsneaUBn
GpSgXYbyASpA+OkqUqJcBEeTI4rfRZPiyx3ztaibg+rgOEM/DUV5le2WyujnsQ75
IG4SrVu/XTmF08MN8/hD1+ydBVnhVa7bE4WctqgA1T/5BP7ivAB03a1BJHpKFRh/
Q/pO5ONIK8lNGG6aImLPmtJijEvOL0VsUdp+oO87VnTFAgMBAAE=
-----END PUBLIC KEY-----

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 For local development, the repository setup script automates prerequisite
checks, the full Maven verification, directory creation, all three cloud key checks, the full Maven verification, directory creation, all three cloud key
pairs, the installation key pair, `config/clients.yml`, and the encrypted pairs, the installation key pair, database-backed tenant/install registration,
machine assertion: an initial license, and the encrypted machine assertion:
```bash ```bash
./scripts/setup-local-communication.sh ./scripts/setup-local-communication.sh
``` ```
The script interactively asks for the customer identifier, installation The script interactively asks for the customer name and slug, installation
identifier, cloud URL, and whether to run the full verification. Customer and identifier, cloud URL, database connection, license package/type/duration, and
installation identifiers cannot contain spaces; the customer identifier is whether to run the full verification. Customer and installation identifiers
used for its directory and signing-key filenames. New customers are appended cannot contain spaces. The slug is the stable tenant key and is used for its
to `config/clients.yml` without replacing existing customers. directory and signing-key filenames.
The client account, installation public key, allowed scopes, and license are
upserted into PostgreSQL (`identity.client_account`,
`identity.client_installation`, and `identity.client_license`). The cloud
service resolves this registration dynamically through Redis with PostgreSQL
fallback, so adding another customer does not require a cloud restart.
It preserves existing private keys and assertions. Set It preserves existing private keys and assertions. Set
`CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be `CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be
@@ -65,6 +71,6 @@ mvn -pl cygnus-cloud-client exec:java \
client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt" client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt"
``` ```
Copy only `client-signing-public.pem` into that customer's cloud-side client The setup script stores `client-signing-public.pem` in the installation record
configuration. Keep the private key and generated assertion on the on-premises used by the cloud. Keep the private key and generated assertion only on the
server with owner-only filesystem permissions. on-premises server with owner-only filesystem permissions.

View File

@@ -1,10 +1,15 @@
package com.cygnus.client; package com.cygnus.client;
import com.cygnus.client.model.CloudIdentitySession; import com.cygnus.client.model.CloudIdentitySession;
import com.cygnus.client.model.CloudDataItem;
import com.cygnus.client.model.CloudDataRequest;
import com.cygnus.client.model.CloudQueryResponse;
import com.cygnus.client.model.LoginPayload; import com.cygnus.client.model.LoginPayload;
import com.cygnus.client.security.LoginEnvelopeEncryptor; import com.cygnus.client.security.LoginEnvelopeEncryptor;
import com.cygnus.client.security.MachineTokenProvider; import com.cygnus.client.security.MachineTokenProvider;
import java.time.Clock; import java.time.Clock;
import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -50,4 +55,40 @@ public class CloudIdentityClient {
.bodyToMono(CloudIdentitySession.class)) .bodyToMono(CloudIdentitySession.class))
.timeout(properties.requestTimeout()); .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());
}
public Mono<List<CloudDataItem>> fetchData(String scope, Map<String, Object> data) {
return tokenProvider.accessToken()
.flatMap(token -> webClient.post()
.uri(properties.baseUri().resolve("/api/v1/platform/data"))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.bodyValue(new CloudDataRequest(scope, Map.copyOf(data)))
.retrieve()
.bodyToFlux(CloudDataItem.class)
.collectList())
.timeout(properties.requestTimeout());
}
} }

View File

@@ -0,0 +1,4 @@
package com.cygnus.client.model;
public record CloudDataItem(Object value, String label, String group) {
}

View File

@@ -0,0 +1,6 @@
package com.cygnus.client.model;
import java.util.Map;
public record CloudDataRequest(String scope, Map<String, Object> data) {
}

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

View File

@@ -41,6 +41,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId> <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); 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) { private String cacheKey(String namespace, String key) {
return properties.keyPrefix() + ':' + namespace + ':' + 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.Pool;
import io.vertx.sqlclient.Row; import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.RowSet; import io.vertx.sqlclient.RowSet;
import io.vertx.sqlclient.SqlConnection;
import io.vertx.sqlclient.Tuple; import io.vertx.sqlclient.Tuple;
import java.util.function.Function;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -29,4 +31,37 @@ public class ReactiveDatabaseClient {
return Mono.fromCompletionStage( return Mono.fromCompletionStage(
() -> pool.preparedQuery(sql).execute(parameters).toCompletionStage()); () -> 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 jakarta.validation.Valid;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.util.UUID;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -64,12 +65,21 @@ public class CloudLoginController {
return Mono.error(new AuthenticationException("Login request replayed")); return Mono.error(new AuthenticationException("Login request replayed"));
} }
return authenticationService.authenticate( return authenticationService.authenticate(
tenantId(machineJwt),
payload.loginId(), payload.loginId(),
payload.password(), payload.password(),
remoteAddress(serverRequest)); remoteAddress(serverRequest));
}); });
} }
private UUID tenantId(Jwt jwt) {
try {
return UUID.fromString(jwt.getClaimAsString("tenant_id"));
} catch (RuntimeException exception) {
throw new AuthenticationException("Machine tenant is invalid");
}
}
private void validatePayload(LoginPayload payload) { private void validatePayload(LoginPayload payload) {
if (payload == null if (payload == null
|| !StringUtils.hasText(payload.loginId()) || !StringUtils.hasText(payload.loginId())

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
package com.cygnus.cloud.platform.api;
import com.cygnus.cloud.platform.service.ScopedDataException;
import com.cygnus.cloud.platform.service.ScopedDataService;
import jakarta.validation.Valid;
import java.util.Map;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.ExceptionHandler;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/api/v1/platform/data")
public class ScopedDataController {
private static final Logger LOGGER = LoggerFactory.getLogger(ScopedDataController.class);
private final ScopedDataService service;
public ScopedDataController(ScopedDataService service) {
this.service = service;
}
@PostMapping
public Flux<ScopedDataItem> data(
@AuthenticationPrincipal Jwt machineJwt,
@Valid @RequestBody ScopedDataRequest request) {
if (machineJwt == null) throw new ScopedDataException("Machine authentication required");
try {
return service.fetch(UUID.fromString(machineJwt.getClaimAsString("tenant_id")), request);
} catch (IllegalArgumentException exception) {
throw new ScopedDataException("Machine tenant is invalid");
}
}
@ExceptionHandler(ScopedDataException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
Map<String, String> invalidRequest(ScopedDataException exception) {
LOGGER.warn("Scoped platform-data request rejected: {}", exception.getMessage());
return Map.of("code", "PLATFORM_DATA_INVALID", "message", exception.getMessage());
}
}

View File

@@ -0,0 +1,7 @@
package com.cygnus.cloud.platform.api;
public record ScopedDataItem(
Object value,
String label,
String group) {
}

View File

@@ -0,0 +1,10 @@
package com.cygnus.cloud.platform.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.Map;
public record ScopedDataRequest(
@NotBlank String scope,
@NotNull Map<String, Object> data) {
}

View File

@@ -0,0 +1,79 @@
package com.cygnus.cloud.platform.repository;
import com.cygnus.cloud.database.ReactiveDatabaseClient;
import com.cygnus.cloud.platform.api.ScopedDataItem;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
@Repository
public class ScopedDataRepository {
private static final String COMPANY_OPTIONS = """
SELECT ov.opt_value AS value,
ov.val_description AS label,
lower(o.description) || '.' AS group_name
FROM platform.company_options_mapping m
JOIN platform.options o ON o.option_id = m.option_id
JOIN platform.options_values ov ON ov.opt_value_id = m.opt_value_id
JOIN identity.company c ON c.company_id = m.company_id
WHERE c.tenant_id = $1
AND m.company_id = $2
AND o.isactive = 1
AND ov.isactive = 1
AND o.description = ANY($3::text[])
ORDER BY o.description, ov.val_description
""";
private static final String PORTFOLIO_OPTIONS = """
SELECT ov.opt_value AS value,
ov.val_description AS label,
lower(o.description) || '.' AS group_name
FROM platform.options_mapping m
JOIN platform.options o ON o.option_id = m.option_id
JOIN platform.options_values ov ON ov.opt_value_id = m.opt_value_id
JOIN identity.company c ON c.company_id = m.company_id
JOIN platform.company_branch_portfolios cbp
ON cbp.company_id = m.company_id
AND cbp.portfolio_reference = m.portfolio_reference
WHERE c.tenant_id = $1
AND m.company_id = $2
AND cbp.company_branch_id = $3
AND m.portfolio_reference = $4
AND o.isactive = 1
AND ov.isactive = 1
AND o.description = ANY($5::text[])
ORDER BY o.description, ov.val_description
""";
private final ReactiveDatabaseClient database;
public ScopedDataRepository(ReactiveDatabaseClient database) {
this.database = database;
}
public Flux<ScopedDataItem> companyOptions(
UUID tenantId, short companyId, List<String> descriptions) {
return rows(COMPANY_OPTIONS, Tuple.of(tenantId, companyId, descriptions.toArray(String[]::new)));
}
public Flux<ScopedDataItem> portfolioOptions(
UUID tenantId, short companyId, short branchId, UUID portfolioReference,
List<String> descriptions) {
return rows(PORTFOLIO_OPTIONS,
Tuple.of(tenantId, companyId, branchId, portfolioReference,
descriptions.toArray(String[]::new)));
}
private Flux<ScopedDataItem> rows(String sql, Tuple parameters) {
return database.preparedQuery(sql, parameters)
.flatMapMany(result -> Flux.fromIterable(result))
.map(this::item);
}
private ScopedDataItem item(Row row) {
return new ScopedDataItem(
row.getValue("value"), row.getString("label"), row.getString("group_name"));
}
}

View File

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

View File

@@ -0,0 +1,74 @@
package com.cygnus.cloud.platform.service;
import com.cygnus.cloud.platform.api.ScopedDataItem;
import com.cygnus.cloud.platform.api.ScopedDataRequest;
import com.cygnus.cloud.platform.repository.ScopedDataRepository;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
public class ScopedDataService {
public static final String COMPANY_OPTIONS = "company-options";
public static final String PORTFOLIO_OPTIONS = "portfolio-options";
private final ScopedDataRepository repository;
public ScopedDataService(ScopedDataRepository repository) {
this.repository = repository;
}
public Flux<ScopedDataItem> fetch(UUID tenantId, ScopedDataRequest request) {
Map<String, Object> data = request.data();
short companyId = shortValue(data, "companyId");
List<String> descriptions = descriptions(data);
return switch (request.scope().trim().toLowerCase(Locale.ROOT)) {
case COMPANY_OPTIONS -> repository.companyOptions(tenantId, companyId, descriptions);
case PORTFOLIO_OPTIONS -> repository.portfolioOptions(
tenantId, companyId, shortValue(data, "branchId"),
uuidValue(data, "portfolioReference"), descriptions);
default -> Flux.error(new ScopedDataException("Unsupported data scope"));
};
}
private UUID uuidValue(Map<String, Object> data, String name) {
try {
return UUID.fromString(String.valueOf(data.get(name)));
} catch (RuntimeException exception) {
throw new ScopedDataException(name + " must be a valid UUID");
}
}
private short shortValue(Map<String, Object> data, String name) {
Object value = data.get(name);
try {
short result = value instanceof Number number
? number.shortValue() : Short.parseShort(String.valueOf(value));
if (result <= 0) throw new NumberFormatException();
return result;
} catch (RuntimeException exception) {
throw new ScopedDataException(name + " must be a positive number");
}
}
private List<String> descriptions(Map<String, Object> data) {
Object value = data.get("descriptions");
if (!(value instanceof List<?> values)) {
throw new ScopedDataException("descriptions must be a non-empty list");
}
List<String> result = values.stream()
.map(String::valueOf)
.map(String::trim)
.filter(text -> !text.isEmpty())
.map(text -> text.toUpperCase(Locale.ROOT))
.distinct()
.toList();
if (result.isEmpty() || result.size() > 20) {
throw new ScopedDataException("descriptions must contain between 1 and 20 values");
}
return result;
}
}

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()) .jwtID(UUID.randomUUID().toString())
.claim("client_id", principal.clientId()) .claim("client_id", principal.clientId())
.claim("installation_id", principal.installationId()) .claim("installation_id", principal.installationId())
.claim("installation_uuid",
principal.internalInstallationId().toString())
.claim("tenant_id", principal.tenantId().toString())
.claim("license_id", principal.licenseId().toString())
.claim("license_type", principal.licenseType())
.claim("package_code", principal.packageCode())
.claim("security_version", principal.securityVersion())
.claim("scope", String.join(" ", scopes)) .claim("scope", String.join(" ", scopes))
.build(); .build();
SignedJWT jwt = new SignedJWT( SignedJWT jwt = new SignedJWT(

View File

@@ -7,29 +7,33 @@ import com.nimbusds.jose.crypto.RSADecrypter;
import com.nimbusds.jose.crypto.RSASSAVerifier; import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT; import com.nimbusds.jwt.SignedJWT;
import com.cygnus.cloud.tenant.model.ClientInstallation;
import com.cygnus.cloud.tenant.model.ClientLicense;
import com.cygnus.cloud.tenant.service.TenantRegistrationService;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Date; import java.util.Date;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component @Component
class ClientAssertionValidator { class ClientAssertionValidator {
private final CommunicationSecurityProperties properties; private final CommunicationSecurityProperties properties;
private final TenantRegistrationService registrations;
private final Clock clock; private final Clock clock;
ClientAssertionValidator(CommunicationSecurityProperties properties, Clock clock) { ClientAssertionValidator(
CommunicationSecurityProperties properties,
TenantRegistrationService registrations,
Clock clock) {
this.properties = properties; this.properties = properties;
this.registrations = registrations;
this.clock = clock; this.clock = clock;
} }
MachineClientPrincipal validate(String clientId, String encryptedAssertion) { Mono<MachineClientPrincipal> validate(String clientId, String encryptedAssertion) {
CommunicationSecurityProperties.MachineClient client =
properties.clients() == null ? null : properties.clients().get(clientId);
if (client == null || !client.enabled()) {
throw invalid();
}
try { try {
JWEObject jwe = JWEObject.parse(encryptedAssertion); JWEObject jwe = JWEObject.parse(encryptedAssertion);
if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm()) if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm())
@@ -40,25 +44,69 @@ class ClientAssertionValidator {
PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey()))); PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey())));
SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString()); SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString());
if (!signedJwt.verify(new RSASSAVerifier( JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
PemKeyLoader.publicKey(client.assertionPublicKey())))) { String installationCode = claims.getStringClaim("installation_id");
if (installationCode == null || installationCode.isBlank()) {
throw invalid(); throw invalid();
} }
return registrations.findInstallation(clientId, installationCode)
.switchIfEmpty(Mono.error(invalid()))
.flatMap(installation -> registrations
.findCurrentLicense(installation.tenantId(), clock.instant())
.filter(license -> license.isActiveAt(clock.instant()))
.switchIfEmpty(Mono.error(new MachineAuthenticationException(
"Client license is not active")))
.map(license -> verify(
clientId, signedJwt, claims, installation, license))
.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(); private MachineClientPrincipal verify(
validateClaims(clientId, client, claims); String clientId,
SignedJWT signedJwt,
JWTClaimsSet claims,
ClientInstallation installation,
ClientLicense license) {
try {
if (!signedJwt.verify(new RSASSAVerifier(
PemKeyLoader.publicKey(installation.assertionPublicKey())))) {
throw invalid();
}
validateClaims(clientId, installation, claims);
return new MachineClientPrincipal( return new MachineClientPrincipal(
clientId, client.installationId(), client.scopes()); clientId,
installation.installationCode(),
installation.tenantId(),
installation.installationId(),
license.licenseId(),
license.licenseType(),
license.packageCode(),
installation.securityVersion(),
installation.allowedScopes());
} catch (MachineAuthenticationException exception) { } catch (MachineAuthenticationException exception) {
throw exception; throw exception;
} catch (Exception exception) { } catch (Exception exception) {
throw new MachineAuthenticationException("Invalid client assertion", exception); throw new MachineAuthenticationException(
"Invalid client assertion", exception);
} }
} }
private void validateClaims( private void validateClaims(
String clientId, String clientId,
CommunicationSecurityProperties.MachineClient client, ClientInstallation installation,
JWTClaimsSet claims) throws Exception { JWTClaimsSet claims) throws Exception {
Instant now = clock.instant(); Instant now = clock.instant();
Date issuedAt = claims.getIssueTime(); Date issuedAt = claims.getIssueTime();
@@ -66,7 +114,7 @@ class ClientAssertionValidator {
if (!clientId.equals(claims.getIssuer()) if (!clientId.equals(claims.getIssuer())
|| !clientId.equals(claims.getSubject()) || !clientId.equals(claims.getSubject())
|| !claims.getAudience().contains(properties.tokenAudience()) || !claims.getAudience().contains(properties.tokenAudience())
|| !client.installationId().equals( || !installation.installationCode().equals(
claims.getStringClaim("installation_id")) claims.getStringClaim("installation_id"))
|| issuedAt == null || issuedAt == null
|| expiresAt == null || expiresAt == null

View File

@@ -30,8 +30,18 @@ public class CloudSecurityConfiguration {
.authorizeExchange(exchange -> exchange .authorizeExchange(exchange -> exchange
.pathMatchers("/actuator/health", "/actuator/info").permitAll() .pathMatchers("/actuator/health", "/actuator/info").permitAll()
.pathMatchers("/oauth2/token").permitAll() .pathMatchers("/oauth2/token").permitAll()
.pathMatchers(
"/api/v1/installations/activation/validate",
"/api/v1/installations/register")
.permitAll()
.pathMatchers("/api/v1/identity/login") .pathMatchers("/api/v1/identity/login")
.hasAuthority("SCOPE_identity.login") .hasAuthority("SCOPE_identity.login")
.pathMatchers("/api/v1/queries/**")
.hasAuthority("SCOPE_identity.login")
.pathMatchers("/api/v1/platform/data")
.hasAuthority("SCOPE_identity.login")
.pathMatchers("/api/v1/admin/**")
.hasAuthority("SCOPE_cygnus.admin")
.anyExchange().authenticated()) .anyExchange().authenticated())
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults())) .oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
.build(); .build();

View File

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

View File

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

View File

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

View File

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

View File

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

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

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