41 Commits

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

BIN
.DS_Store vendored Normal file

Binary file not shown.

15
.gitignore vendored
View File

@@ -18,8 +18,19 @@
### NetBeans ###
/nbproject/private/
/build/
/build/classes/*
!/build/WebContent/
!/build/WebContent/**
/build/WebContent/ReportsBackup/
/build/WebContent/WEB-INF/lib/
/nbbuild/
/dist/
/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

87
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,87 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "java",
"name": "Cygnus Cloud Service (JDK 21)",
"request": "launch",
"mainClass": "com.cygnus.cloud.CygnusCloudServiceApplication",
"projectName": "cygnus-cloud-service",
"cwd": "${workspaceFolder}/cygnus-cloud-service",
"console": "integratedTerminal",
"env": {
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home",
"DB_HOST": "103.125.129.116",
"DB_PORT": "5333",
"DB_NAME": "matrix",
"DB_USER": "postgres",
"DB_PASSWORD": "M@triXPostgr3s@6202",
"DB_SSL": "false",
"REDIS_HOST": "103.125.129.116",
"REDIS_PORT": "7901",
"REDIS_PASSWORD": "M@triXR3d1s@6202",
"CYGNUS_CLOUD_PORT": "8090",
"CYGNUS_SECURITY_ENABLED": "true",
"CYGNUS_JWT_ISSUER_URI": "http://localhost:8090",
"CYGNUS_JWT_AUDIENCE": "cygnus-cloud-api",
"CYGNUS_TOKEN_AUDIENCE": "http://localhost:8090/oauth2/token",
"CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/assertion-decryption-private.pem",
"CYGNUS_ACCESS_TOKEN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/access-token-private.pem",
"CYGNUS_ACCESS_TOKEN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/access-token-public.pem",
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
"CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem",
"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"
},
{
"type": "java",
"name": "Cygnus On-Prem - Tomcat 10 (JDK 21)",
"request": "launch",
"mainClass": "matrix.nimble.EmbeddedTomcatServer",
"cwd": "${workspaceFolder}/cygnus-onprem-app",
"console": "integratedTerminal",
"preLaunchTask": "cygnus: dev compile",
"classPaths": [
"$Test"
],
"env": {
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home",
"MATRIX_DB_URL": "jdbc:postgresql://103.125.129.116:5333/matrix",
"MATRIX_DB_USERNAME": "postgres",
"MATRIX_DB_PASSWORD": "M@triXPostgr3s@6202",
"REDIS_HOST": "103.125.129.116",
"REDIS_PORT": "7901",
"REDIS_PASSWORD": "M@triXR3d1s@6202",
"REDIS_DATABASE": "1",
"REDIS_SSL": "false",
"CYGNUS_QUERY_CACHE_ENABLED": "false",
"CYGNUS_CLOUD_BASE_URL": "http://localhost:8090",
"CYGNUS_TOKEN_URL": "http://localhost:8090/oauth2/token",
"CYGNUS_CLIENT_ID": "matrix",
"CYGNUS_INSTALLATION_ID": "matrix-delhi-cygnus-01",
"CYGNUS_CLIENT_ASSERTION": "file:${workspaceFolder}/config/clients/matrix/matrix-matrix-delhi-cygnus-01-assertion.jwt",
"CYGNUS_LOGIN_KEY_ID": "cygnus-login-2026-01",
"CYGNUS_LOGIN_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/login-public.pem",
"CYGNUS_PAYLOAD_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/case-save-private.pem",
"CYGNUS_PAYLOAD_PUBLIC_KEY": "file:${workspaceFolder}/config/keys/case-save-public.pem",
"CYGNUS_CLOUD_REQUEST_TIMEOUT": "PT10S",
"CYGNUS_TOKEN_REFRESH_SKEW": "PT30S"
},
"vmArgs": "-Dserver.port=8080 -Djava.awt.headless=true -Dmatrix.webapp=${workspaceFolder}/cygnus-onprem-app/build/WebContent -Dmatrix.classes=${workspaceFolder}/cygnus-onprem-app/target/classes",
"shortenCommandLine": "argfile"
},
{
"type": "java",
"name": "Current Java File",
"request": "launch",
"mainClass": "${file}"
}
]
}

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"java.configuration.updateBuildConfiguration": "automatic",
"java.compile.nullAnalysis.mode": "automatic"
}

24
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,24 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "cygnus: dev compile",
"type": "shell",
"command": "mvn",
"args": [
"test-compile",
"-DskipTests"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"JAVA_HOME": "/Users/maddy/Library/Java/JavaVirtualMachines/ms-21.0.8/Contents/Home"
}
},
"group": "build",
"problemMatcher": [
"$javac"
]
}
]
}

View File

@@ -0,0 +1,359 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="3720" viewBox="0 0 1920 3720" role="img" aria-labelledby="title description">
<title id="title">Cygnus on-premises to cloud authentication architecture overview</title>
<desc id="description">Complete architecture, request flow, sequence diagram, security layers, code navigation, data access, session mapping, and failure behavior for Cygnus cloud identity authentication.</desc>
<defs>
<linearGradient id="pageBg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#eaf2f8"/>
<stop offset="1" stop-color="#d9e7f1"/>
</linearGradient>
<linearGradient id="heroBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#173b5f"/>
<stop offset="1" stop-color="#28679b"/>
</linearGradient>
<linearGradient id="cloudBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#e7f2fb"/>
<stop offset="1" stop-color="#d2e7f7"/>
</linearGradient>
<linearGradient id="onpremBg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#eef8f2"/>
<stop offset="1" stop-color="#dcefe3"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="5" stdDeviation="8" flood-color="#1c3448" flood-opacity=".16"/>
</filter>
<marker id="arrowBlue" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L12,6 L0,12 z" fill="#28679b"/>
</marker>
<marker id="arrowGreen" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L12,6 L0,12 z" fill="#26845b"/>
</marker>
<marker id="arrowOrange" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L12,6 L0,12 z" fill="#d47b17"/>
</marker>
<style>
text { font-family: Inter, "Segoe UI", Arial, sans-serif; fill: #22303d; }
.hero-title { font-size: 46px; font-weight: 750; fill: #fff; letter-spacing: .2px; }
.hero-sub { font-size: 22px; fill: #d7eafb; }
.section-title { font-size: 30px; font-weight: 750; fill: #173b5f; }
.section-sub { font-size: 17px; fill: #536777; }
.box-title { font-size: 20px; font-weight: 750; }
.box-sub { font-size: 14px; fill: #5a6f7f; }
.body { font-size: 16px; }
.small { font-size: 14px; }
.tiny { font-size: 12px; }
.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 13px; }
.white { fill: #fff; }
.muted { fill: #64798a; }
.blue { fill: #28679b; }
.green { fill: #26845b; }
.orange { fill: #c76c0f; }
.red { fill: #b74646; }
.bold { font-weight: 700; }
.card { fill: #fff; stroke: #b8cad8; stroke-width: 1.4; rx: 12; filter: url(#shadow); }
.soft-card { fill: #f7fafc; stroke: #c5d5e1; stroke-width: 1.2; rx: 10; }
.onprem { fill: url(#onpremBg); stroke: #83b99b; stroke-width: 1.5; rx: 12; }
.cloud { fill: url(#cloudBg); stroke: #81b2d5; stroke-width: 1.5; rx: 12; }
.security { fill: #fff6e7; stroke: #e2ae62; stroke-width: 1.4; rx: 10; }
.database { fill: #f2ecfa; stroke: #a991c6; stroke-width: 1.4; rx: 10; }
.line-blue { stroke: #28679b; stroke-width: 3; fill: none; marker-end: url(#arrowBlue); }
.line-green { stroke: #26845b; stroke-width: 3; fill: none; marker-end: url(#arrowGreen); }
.line-orange { stroke: #d47b17; stroke-width: 3; fill: none; marker-end: url(#arrowOrange); }
.dash { stroke-dasharray: 8 7; }
.lane { stroke: #9bb0bf; stroke-width: 1.5; stroke-dasharray: 6 6; }
.step-circle { fill: #28679b; stroke: #fff; stroke-width: 3; }
.step-number { font-size: 14px; font-weight: 750; fill: #fff; text-anchor: middle; dominant-baseline: central; }
.chip { fill: #e5eff7; stroke: #aec4d5; stroke-width: 1; rx: 14; }
</style>
</defs>
<rect width="1920" height="3720" fill="url(#pageBg)"/>
<!-- Hero -->
<rect x="50" y="42" width="1820" height="190" rx="20" fill="url(#heroBg)" filter="url(#shadow)"/>
<text x="105" y="112" class="hero-title">Cygnus Architecture Overview</text>
<text x="105" y="157" class="hero-sub">Secure on-premises → cloud identity authentication while operational and applicant data remain on customer infrastructure</text>
<rect x="105" y="180" width="212" height="30" class="chip" opacity=".22"/>
<text x="123" y="201" class="small white bold">JDK 21 · Spring MVC</text>
<rect x="330" y="180" width="245" height="30" class="chip" opacity=".22"/>
<text x="348" y="201" class="small white bold">Spring WebFlux client</text>
<rect x="588" y="180" width="280" height="30" class="chip" opacity=".22"/>
<text x="606" y="201" class="small white bold">Spring Boot cloud service</text>
<rect x="881" y="180" width="260" height="30" class="chip" opacity=".22"/>
<text x="899" y="201" class="small white bold">PostgreSQL + Vert.x</text>
<rect x="1154" y="180" width="212" height="30" class="chip" opacity=".22"/>
<text x="1172" y="201" class="small white bold">Redis replay guard</text>
<!-- Architecture overview -->
<text x="70" y="292" class="section-title">1 · Architecture overview</text>
<text x="70" y="322" class="section-sub">The browser-facing application remains on-premises. Authentication and menu ownership are delegated to the cloud through a reusable client.</text>
<rect x="70" y="352" width="1780" height="490" class="card"/>
<rect x="95" y="377" width="1050" height="440" class="onprem"/>
<text x="125" y="414" class="box-title green">CUSTOMER PREMISES</text>
<text x="125" y="439" class="box-sub">Applicant PII, punching, operations, reporting/MIS, JSP UI and HTTP session</text>
<rect x="130" y="480" width="210" height="126" class="soft-card"/>
<text x="235" y="520" class="box-title" text-anchor="middle">Browser</text>
<text x="235" y="550" class="small" text-anchor="middle">Login form / JSP UI</text>
<text x="235" y="576" class="tiny muted" text-anchor="middle">POST /ver/authenticatelogin</text>
<rect x="430" y="455" width="300" height="178" class="soft-card"/>
<text x="580" y="495" class="box-title" text-anchor="middle">cygnus-onprem-app</text>
<text x="580" y="524" class="small" text-anchor="middle">Spring MVC + embedded Tomcat</text>
<text x="580" y="552" class="tiny muted" text-anchor="middle">SessionController</text>
<text x="580" y="575" class="tiny muted" text-anchor="middle">CloudAuthenticationGateway</text>
<text x="580" y="598" class="tiny muted" text-anchor="middle">CloudSessionMapper</text>
<rect x="820" y="455" width="290" height="178" class="security"/>
<text x="965" y="495" class="box-title" text-anchor="middle">cygnus-cloud-client</text>
<text x="965" y="524" class="small" text-anchor="middle">Reusable WebClient gateway</text>
<text x="965" y="552" class="tiny muted" text-anchor="middle">OAuthMachineTokenProvider</text>
<text x="965" y="575" class="tiny muted" text-anchor="middle">LoginEnvelopeEncryptor</text>
<text x="965" y="598" class="tiny muted" text-anchor="middle">CloudIdentityClient</text>
<path d="M340 543 H420" class="line-green"/>
<text x="380" y="528" class="tiny green" text-anchor="middle">credentials</text>
<path d="M730 543 H810" class="line-green"/>
<text x="770" y="528" class="tiny green" text-anchor="middle">delegate</text>
<rect x="130" y="676" width="980" height="102" class="soft-card"/>
<text x="155" y="711" class="box-title">Existing on-premises application behavior retained</text>
<text x="155" y="741" class="small">Cloud response → legacy SessionValues + menu HTML → existing authorization, controllers, JSP pages and operational database access.</text>
<rect x="1190" y="377" width="635" height="440" class="cloud"/>
<text x="1220" y="414" class="box-title blue">CYGNUS CLOUD</text>
<text x="1220" y="439" class="box-sub">Vendor-controlled machine authorization, identity, permissions and menu data</text>
<rect x="1225" y="470" width="260" height="140" class="security"/>
<text x="1355" y="507" class="box-title" text-anchor="middle">OAuth endpoint</text>
<text x="1355" y="536" class="small" text-anchor="middle">POST /oauth2/token</text>
<text x="1355" y="564" class="tiny muted" text-anchor="middle">Validate nested machine assertion</text>
<text x="1355" y="586" class="tiny muted" text-anchor="middle">Issue short-lived RS256 JWT</text>
<rect x="1525" y="470" width="265" height="140" class="cloud"/>
<text x="1657" y="507" class="box-title" text-anchor="middle">Identity API</text>
<text x="1657" y="536" class="small" text-anchor="middle">POST /api/v1/identity/login</text>
<text x="1657" y="564" class="tiny muted" text-anchor="middle">Decrypt login envelope</text>
<text x="1657" y="586" class="tiny muted" text-anchor="middle">Authenticate and load menu</text>
<rect x="1225" y="660" width="260" height="110" class="database"/>
<text x="1355" y="699" class="box-title" text-anchor="middle">Redis</text>
<text x="1355" y="728" class="small" text-anchor="middle">Nonce replay protection</text>
<text x="1355" y="751" class="tiny muted" text-anchor="middle">Atomic claim + short TTL</text>
<rect x="1525" y="660" width="265" height="110" class="database"/>
<text x="1657" y="699" class="box-title" text-anchor="middle">PostgreSQL</text>
<text x="1657" y="728" class="small" text-anchor="middle">matrix.identity schema</text>
<text x="1657" y="751" class="tiny muted" text-anchor="middle">Vert.x prepared queries</text>
<path d="M1110 520 C1160 520 1160 520 1215 520" class="line-blue"/>
<text x="1162" y="504" class="tiny blue" text-anchor="middle">machine assertion</text>
<path d="M1110 585 C1320 625 1420 570 1515 540" class="line-blue"/>
<text x="1320" y="605" class="tiny blue" text-anchor="middle">Bearer JWT + encrypted login</text>
<path d="M1657 610 V650" class="line-blue"/>
<path d="M1600 610 C1515 640 1450 670 1490 710" class="line-blue"/>
<!-- Responsibility split -->
<text x="70" y="912" class="section-title">2 · Responsibility and data boundary</text>
<text x="70" y="942" class="section-sub">The split is deliberate: identity and non-PI control data can be cloud-managed without transferring loan-applicant PII.</text>
<rect x="70" y="972" width="855" height="265" class="card"/>
<text x="105" y="1014" class="box-title green">Remains on customer premises</text>
<text x="115" y="1054" class="body">• Punching and applicant intake</text>
<text x="115" y="1086" class="body">• Operational allocation, scanning, telecalling and reports</text>
<text x="115" y="1118" class="body">• Applicant PII, addresses, documents, photos and verification content</text>
<text x="115" y="1150" class="body">• MIS generation and existing JSP/HTTP-session application behavior</text>
<text x="115" y="1182" class="body">• Existing operational SQL, functions and views unrelated to cloud identity</text>
<rect x="945" y="972" width="905" height="265" class="card"/>
<text x="980" y="1014" class="box-title blue">Managed by Cygnus cloud</text>
<text x="990" y="1054" class="body">• Customer installation identity and machine authorization</text>
<text x="990" y="1086" class="body">• Application users, groups, companies and branches required at login</text>
<text x="990" y="1118" class="body">• Page permissions, denied pages and menu structure</text>
<text x="990" y="1150" class="body">• Authentication audit entry and cloud access-token issuance</text>
<text x="990" y="1182" class="body">• Redis nonce state used to reject replayed login envelopes</text>
<!-- Full runtime sequence -->
<text x="70" y="1307" class="section-title">3 · Full authentication sequence</text>
<text x="70" y="1337" class="section-sub">Every arrow below is part of one browser login. The access token is cached and reused until shortly before expiry.</text>
<rect x="70" y="1367" width="1780" height="1040" class="card"/>
<!-- Lane headers -->
<rect x="90" y="1390" width="220" height="62" class="soft-card"/>
<rect x="385" y="1390" width="245" height="62" class="onprem"/>
<rect x="705" y="1390" width="245" height="62" class="security"/>
<rect x="1025" y="1390" width="230" height="62" class="security"/>
<rect x="1330" y="1390" width="240" height="62" class="cloud"/>
<rect x="1640" y="1390" width="185" height="62" class="database"/>
<text x="200" y="1428" class="box-title" text-anchor="middle">Browser</text>
<text x="507" y="1428" class="box-title" text-anchor="middle">On-prem MVC</text>
<text x="827" y="1428" class="box-title" text-anchor="middle">Cloud client</text>
<text x="1140" y="1428" class="box-title" text-anchor="middle">OAuth service</text>
<text x="1450" y="1428" class="box-title" text-anchor="middle">Identity service</text>
<text x="1732" y="1428" class="box-title" text-anchor="middle">Redis / DB</text>
<line x1="200" y1="1452" x2="200" y2="2365" class="lane"/>
<line x1="507" y1="1452" x2="507" y2="2365" class="lane"/>
<line x1="827" y1="1452" x2="827" y2="2365" class="lane"/>
<line x1="1140" y1="1452" x2="1140" y2="2365" class="lane"/>
<line x1="1450" y1="1452" x2="1450" y2="2365" class="lane"/>
<line x1="1732" y1="1452" x2="1732" y2="2365" class="lane"/>
<!-- Sequence messages -->
<circle cx="95" cy="1500" r="15" class="step-circle"/><text x="95" y="1500" class="step-number">1</text>
<path d="M200 1500 H497" class="line-green"/>
<text x="348" y="1483" class="small bold" text-anchor="middle">POST login ID + password</text>
<text x="348" y="1523" class="tiny muted" text-anchor="middle">/ver/authenticatelogin</text>
<circle cx="95" cy="1575" r="15" class="step-circle"/><text x="95" y="1575" class="step-number">2</text>
<path d="M507 1575 H817" class="line-green"/>
<text x="662" y="1558" class="small bold" text-anchor="middle">CloudAuthenticationGateway.authenticate()</text>
<text x="662" y="1598" class="tiny muted" text-anchor="middle">Synchronous MVC → reactive client boundary</text>
<circle cx="95" cy="1650" r="15" class="step-circle"/><text x="95" y="1650" class="step-number">3</text>
<path d="M827 1650 H1130" class="line-orange"/>
<text x="978" y="1633" class="small bold" text-anchor="middle">POST machine assertion</text>
<text x="978" y="1673" class="tiny muted" text-anchor="middle">Only when cached access token is absent/expiring</text>
<circle cx="95" cy="1725" r="15" class="step-circle"/><text x="95" y="1725" class="step-number">4</text>
<path d="M1140 1725 H1440" class="line-orange"/>
<text x="1290" y="1708" class="small bold" text-anchor="middle">Decrypt JWE + verify signed inner JWT</text>
<text x="1290" y="1748" class="tiny muted" text-anchor="middle">Validate client ID, installation, audience and expiry</text>
<circle cx="95" cy="1800" r="15" class="step-circle"/><text x="95" y="1800" class="step-number">5</text>
<path d="M1140 1800 H837" class="line-orange"/>
<text x="988" y="1783" class="small bold" text-anchor="middle">Return short-lived RS256 access JWT</text>
<text x="988" y="1823" class="tiny muted" text-anchor="middle">Claims: client_id, installation_id, scope, issuer, audience, expiry</text>
<circle cx="95" cy="1885" r="15" class="step-circle"/><text x="95" y="1885" class="step-number">6</text>
<path d="M827 1885 H1440" class="line-blue"/>
<text x="1133" y="1868" class="small bold" text-anchor="middle">Bearer JWT + encrypted login envelope</text>
<text x="1133" y="1908" class="tiny muted" text-anchor="middle">AES-256-GCM payload; AES key wrapped by RSA-OAEP-256</text>
<circle cx="95" cy="1970" r="15" class="step-circle"/><text x="95" y="1970" class="step-number">7</text>
<path d="M1450 1970 H1722" class="line-blue"/>
<text x="1586" y="1953" class="small bold" text-anchor="middle">Atomically claim nonce in Redis</text>
<text x="1586" y="1993" class="tiny muted" text-anchor="middle">Reject duplicate; apply short TTL</text>
<circle cx="95" cy="2045" r="15" class="step-circle"/><text x="95" y="2045" class="step-number">8</text>
<path d="M1450 2045 H1722" class="line-blue"/>
<text x="1586" y="2028" class="small bold" text-anchor="middle">Vert.x prepared SQL queries</text>
<text x="1586" y="2068" class="tiny muted" text-anchor="middle">User → password check → menu → login audit</text>
<circle cx="95" cy="2120" r="15" class="step-circle"/><text x="95" y="2120" class="step-number">9</text>
<path d="M1450 2120 H837" class="line-blue"/>
<text x="1143" y="2103" class="small bold" text-anchor="middle">Authenticated identity + structured menu</text>
<text x="1143" y="2143" class="tiny muted" text-anchor="middle">User, group, company, branch, permissions and login time</text>
<circle cx="95" cy="2195" r="15" class="step-circle"/><text x="95" y="2195" class="step-number">10</text>
<path d="M827 2195 H517" class="line-green"/>
<text x="672" y="2178" class="small bold" text-anchor="middle">Map cloud response to legacy SessionValues</text>
<text x="672" y="2218" class="tiny muted" text-anchor="middle">Render menu HTML and preserve existing JSP expectations</text>
<circle cx="95" cy="2270" r="15" class="step-circle"/><text x="95" y="2270" class="step-number">11</text>
<path d="M507 2270 H210" class="line-green"/>
<text x="358" y="2253" class="small bold" text-anchor="middle">Store HTTP session and return home</text>
<text x="358" y="2293" class="tiny muted" text-anchor="middle">Existing authorization and UI flow continues</text>
<rect x="118" y="2330" width="1684" height="52" class="security"/>
<text x="960" y="2362" class="small bold" text-anchor="middle">Cloud identity is mandatory: there is no local password fallback. A cloud outage returns a controlled temporary-unavailable login message.</text>
<!-- Security model -->
<text x="70" y="2477" class="section-title">4 · Security model</text>
<text x="70" y="2507" class="section-sub">Machine identity, transport security, application-layer encryption and replay protection are separate layers.</text>
<rect x="70" y="2537" width="1780" height="410" class="card"/>
<rect x="95" y="2570" width="405" height="330" class="security"/>
<text x="125" y="2610" class="box-title orange">A · Machine assertion</text>
<text x="125" y="2645" class="small">1. Inner JWT signed by installation private key</text>
<text x="125" y="2674" class="small">2. Identifies client + installation</text>
<text x="125" y="2703" class="small">3. Nested inside RSA-OAEP-256 JWE</text>
<text x="125" y="2732" class="small">4. Cloud decrypts with assertion private key</text>
<text x="125" y="2761" class="small">5. Signature checked using registered client key</text>
<text x="125" y="2790" class="small">6. Assertion may be long-lived and is used only</text>
<text x="143" y="2816" class="small">to obtain short-lived access tokens</text>
<rect x="125" y="2845" width="335" height="34" class="chip"/>
<text x="292" y="2868" class="tiny bold" text-anchor="middle">Customer-specific asymmetric identity</text>
<rect x="530" y="2570" width="405" height="330" class="security"/>
<text x="560" y="2610" class="box-title orange">B · Access token</text>
<text x="560" y="2645" class="small">• RS256 signed by the cloud</text>
<text x="560" y="2674" class="small">• Short expiry; cached until refresh skew</text>
<text x="560" y="2703" class="small">• Bound to client ID and installation ID</text>
<text x="560" y="2732" class="small">• Carries identity.login scope</text>
<text x="560" y="2761" class="small">• Issuer and audience validated</text>
<text x="560" y="2790" class="small">• Required by Spring Security before the</text>
<text x="578" y="2816" class="small">cloud identity controller executes</text>
<rect x="560" y="2845" width="335" height="34" class="chip"/>
<text x="727" y="2868" class="tiny bold" text-anchor="middle">Machine authorization, not user session</text>
<rect x="965" y="2570" width="405" height="330" class="security"/>
<text x="995" y="2610" class="box-title orange">C · Login envelope</text>
<text x="995" y="2645" class="small">• Fresh random AES-256 key per request</text>
<text x="995" y="2674" class="small">• Credentials encrypted with AES-GCM</text>
<text x="995" y="2703" class="small">• AES key encrypted by cloud RSA key</text>
<text x="995" y="2732" class="small">• GCM detects ciphertext tampering</text>
<text x="995" y="2761" class="small">• Payload contains nonce and timestamp</text>
<text x="995" y="2790" class="small">• Client/installation values must match</text>
<text x="1013" y="2816" class="small">the authenticated access-token claims</text>
<rect x="995" y="2845" width="335" height="34" class="chip"/>
<text x="1162" y="2868" class="tiny bold" text-anchor="middle">Application encryption in addition to TLS</text>
<rect x="1400" y="2570" width="405" height="330" class="security"/>
<text x="1430" y="2610" class="box-title orange">D · Replay and transport</text>
<text x="1430" y="2645" class="small">• Redis SET-if-absent claims each nonce</text>
<text x="1430" y="2674" class="small">• Duplicate nonce is rejected</text>
<text x="1430" y="2703" class="small">• Timestamp freshness window enforced</text>
<text x="1430" y="2732" class="small">• HTTPS is mandatory outside local dev</text>
<text x="1430" y="2761" class="small">• Credentials and tokens are not logged</text>
<text x="1430" y="2790" class="small">• Private keys and assertions require</text>
<text x="1448" y="2816" class="small">owner-only storage and rotation controls</text>
<rect x="1430" y="2845" width="335" height="34" class="chip"/>
<text x="1597" y="2868" class="tiny bold" text-anchor="middle">Captured requests cannot be reused</text>
<!-- Cloud login internals -->
<text x="70" y="3017" class="section-title">5 · Cloud login internals and session compatibility</text>
<text x="70" y="3047" class="section-sub">Prepared SQL retrieves only the identity data needed to preserve the existing SessionValues contract.</text>
<rect x="70" y="3077" width="1095" height="500" class="card"/>
<text x="105" y="3120" class="box-title blue">Cloud identity processing</text>
<circle cx="125" cy="3170" r="14" class="step-circle"/><text x="125" y="3170" class="step-number">1</text>
<text x="155" y="3176" class="body">Decrypt payload and validate machine binding, timestamp and nonce.</text>
<circle cx="125" cy="3225" r="14" class="step-circle"/><text x="125" y="3225" class="step-number">2</text>
<text x="155" y="3231" class="body">Load active user by case-insensitive login ID from identity.app_user.</text>
<circle cx="125" cy="3280" r="14" class="step-circle"/><text x="125" y="3280" class="step-number">3</text>
<text x="155" y="3286" class="body">Verify password using the compatible legacy password verifier.</text>
<circle cx="125" cy="3335" r="14" class="step-circle"/><text x="125" y="3335" class="step-number">4</text>
<text x="155" y="3341" class="body">Load group, company and branch details.</text>
<circle cx="125" cy="3390" r="14" class="step-circle"/><text x="125" y="3390" class="step-number">5</text>
<text x="155" y="3396" class="body">Load visible permitted pages and exclude explicitly denied pages.</text>
<circle cx="125" cy="3445" r="14" class="step-circle"/><text x="125" y="3445" class="step-number">6</text>
<text x="155" y="3451" class="body">Insert cloud login audit row and return structured identity/menu response.</text>
<rect x="105" y="3490" width="1025" height="58" class="database"/>
<text x="617" y="3514" class="small bold" text-anchor="middle">Vert.x prepared queries keep values separate from SQL text</text>
<text x="617" y="3538" class="tiny muted" text-anchor="middle">Tables: identity.app_user · user_group · company · company_branch · permission · pages · denied_pages · user_loginhistory</text>
<rect x="1195" y="3077" width="655" height="500" class="card"/>
<text x="1230" y="3120" class="box-title green">Legacy compatibility mapping</text>
<text x="1230" y="3158" class="small bold">CloudSessionMapper populates:</text>
<text x="1250" y="3192" class="small">• User ID, login ID and display name</text>
<text x="1250" y="3222" class="small">• Group ID and group name</text>
<text x="1250" y="3252" class="small">• Company ID, name and code</text>
<text x="1250" y="3282" class="small">• Branch ID, name, code and location</text>
<text x="1250" y="3312" class="small">• Login timestamp in the legacy format</text>
<text x="1250" y="3342" class="small">• Menu HTML rendered from cloud menu records</text>
<rect x="1230" y="3380" width="585" height="116" class="onprem"/>
<text x="1522" y="3414" class="small bold" text-anchor="middle">No local password query or fallback remains</text>
<text x="1522" y="3443" class="small" text-anchor="middle">Existing JSP controllers keep reading SessionValues</text>
<text x="1522" y="3472" class="small" text-anchor="middle">Operational authorization continues without UI rewrites</text>
<text x="1230" y="3535" class="tiny muted">Logout currently invalidates the local HTTP session. A cloud logout/audit API is the future option if logout timestamps are required.</text>
<!-- Code navigation and failures -->
<text x="70" y="3647" class="section-title">6 · Code navigation</text>
<text x="70" y="3677" class="section-sub">Primary implementation points: SessionController → CloudAuthenticationGateway → OAuthMachineTokenProvider / LoginEnvelopeEncryptor → CloudLoginController → IdentityAuthenticationService → IdentityRepository → CloudSessionMapper.</text>
<!-- Footer strip overlays final area intentionally within document bounds -->
<rect x="50" y="3695" width="1820" height="1" fill="#9eb5c5"/>
</svg>

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

186
README.md
View File

@@ -1,93 +1,93 @@
# Matrix
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://12.168.10.68/nimblev1/matrix.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](http://12.168.10.68/nimblev1/matrix/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
# Matrix
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://12.168.10.68/nimblev1/matrix.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](http://12.168.10.68/nimblev1/matrix/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

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

@@ -0,0 +1,76 @@
# Cygnus cloud client
This module is the reusable on-premises gateway client for Cygnus cloud APIs.
It obtains a short-lived machine access token, encrypts login credentials with
the cloud login public key, and calls the cloud identity API over WebFlux.
## On-premises identity configuration
Cloud login is required by the on-premises application. Configure these as
environment variables or JVM system properties:
| Setting | Purpose |
| --- | --- |
| `CYGNUS_CLOUD_BASE_URL` | Cloud gateway/API base URL |
| `CYGNUS_TOKEN_URL` | OAuth 2.0 machine-token endpoint |
| `CYGNUS_CLIENT_ID` | Provisioned customer/client identifier |
| `CYGNUS_INSTALLATION_ID` | Unique on-premises installation identifier |
| `CYGNUS_CLIENT_ASSERTION` | Provisioned encrypted assertion or `file:/secure/path/assertion.jwt` |
| `CYGNUS_LOGIN_KEY_ID` | Cloud login encryption-key identifier |
| `CYGNUS_LOGIN_PUBLIC_KEY` | X.509 RSA public key location |
| `CYGNUS_CLOUD_REQUEST_TIMEOUT` | Request timeout, for example `PT10S` |
Do not store private keys, client assertions, passwords, or production URLs in
source control.
## Provision a machine assertion
The assertion is a signed JWT nested inside an RSA-OAEP-256/AES-256-GCM JWE.
It is valid for one year; the access token obtained with it is short-lived.
For local development, the repository setup script automates prerequisite
checks, the full Maven verification, directory creation, all three cloud key
pairs, the installation key pair, database-backed tenant/install registration,
an initial license, and the encrypted machine assertion:
```bash
./scripts/setup-local-communication.sh
```
The script interactively asks for the customer name and slug, installation
identifier, cloud URL, database connection, license package/type/duration, and
whether to run the full verification. Customer and installation identifiers
cannot contain spaces. The slug is the stable tenant key and is used for its
directory and signing-key filenames.
The client account, installation public key, allowed scopes, and license are
upserted into PostgreSQL (`identity.client_account`,
`identity.client_installation`, and `identity.client_license`). The cloud
service resolves this registration dynamically through Redis with PostgreSQL
fallback, so adding another customer does not require a cloud restart.
It preserves existing private keys and assertions. Set
`CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be
regenerated. The manual provisioning commands follow.
Generate the installation signing key:
```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
-out client-signing-private.pem
openssl pkey -in client-signing-private.pem -pubout \
-out client-signing-public.pem
```
Generate the assertion after the cloud assertion-encryption public key has
been securely delivered:
```bash
mvn -pl cygnus-cloud-client exec:java \
-Dexec.mainClass=com.cygnus.client.provisioning.MachineAssertionGenerator \
-Dexec.args="customer-a site-01 https://cloud.example.com/oauth2/token \
client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt"
```
The setup script stores `client-signing-public.pem` in the installation record
used by the cloud. Keep the private key and generated assertion only on the
on-premises server with owner-only filesystem permissions.

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.cygnus</groupId>
<artifactId>cygnus-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cygnus-cloud-client</artifactId>
<packaging>jar</packaging>
<name>Cygnus Cloud Client</name>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
<version>${spring.version}</version>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty-http</artifactId>
<version>${reactor.netty.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>${nimbus.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,25 @@
package com.cygnus.client;
import com.cygnus.client.security.LoginEnvelopeEncryptor;
import com.cygnus.client.security.MachineTokenProvider;
import com.cygnus.client.security.OAuthMachineTokenProvider;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock;
import org.springframework.web.reactive.function.client.WebClient;
public final class CloudClientFactory {
private CloudClientFactory() {
}
public static CloudIdentityClient create(
CloudClientProperties properties, ObjectMapper objectMapper, Clock clock) {
WebClient webClient = WebClient.builder().build();
MachineTokenProvider tokenProvider =
new OAuthMachineTokenProvider(webClient, properties, clock);
LoginEnvelopeEncryptor encryptor =
new LoginEnvelopeEncryptor(properties, objectMapper);
return new CloudIdentityClient(
webClient, tokenProvider, encryptor, properties, clock);
}
}

View File

@@ -0,0 +1,71 @@
package com.cygnus.client;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
public record CloudClientProperties(
URI baseUri,
URI tokenUri,
String clientId,
String installationId,
String clientAssertion,
String loginKeyId,
String loginPublicKeyLocation,
Duration requestTimeout,
Duration tokenRefreshSkew) {
public static CloudClientProperties fromSystem() {
CloudClientProperties properties = new CloudClientProperties(
URI.create(value("CYGNUS_CLOUD_BASE_URL", "http://localhost:8090")),
URI.create(value(
"CYGNUS_TOKEN_URL",
"http://localhost:8090/oauth2/token")),
value("CYGNUS_CLIENT_ID", ""),
value("CYGNUS_INSTALLATION_ID", ""),
value("CYGNUS_CLIENT_ASSERTION", ""),
value("CYGNUS_LOGIN_KEY_ID", "cygnus-login-2026-01"),
value(
"CYGNUS_LOGIN_PUBLIC_KEY",
"file:./config/keys/login-public.pem"),
Duration.parse(value("CYGNUS_CLOUD_REQUEST_TIMEOUT", "PT10S")),
Duration.parse(value("CYGNUS_TOKEN_REFRESH_SKEW", "PT30S")));
properties.validate();
return properties;
}
public void validate() {
List<String> missing = new ArrayList<>();
require(clientId, "CYGNUS_CLIENT_ID", missing);
require(installationId, "CYGNUS_INSTALLATION_ID", missing);
require(clientAssertion, "CYGNUS_CLIENT_ASSERTION", missing);
require(loginKeyId, "CYGNUS_LOGIN_KEY_ID", missing);
require(loginPublicKeyLocation, "CYGNUS_LOGIN_PUBLIC_KEY", missing);
if (!missing.isEmpty()) {
throw new IllegalStateException(
"Cloud identity requires these settings: "
+ String.join(", ", missing));
}
if (requestTimeout.isZero() || requestTimeout.isNegative()) {
throw new IllegalStateException(
"CYGNUS_CLOUD_REQUEST_TIMEOUT must be greater than zero");
}
}
private static void require(String value, String name, List<String> missing) {
if (value == null || value.isBlank()) {
missing.add(name);
}
}
private static String value(String name, String defaultValue) {
String systemValue = System.getProperty(name);
if (systemValue != null) {
return systemValue;
}
String environmentValue = System.getenv(name);
return environmentValue == null ? defaultValue : environmentValue;
}
}

View File

@@ -0,0 +1,94 @@
package com.cygnus.client;
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.security.LoginEnvelopeEncryptor;
import com.cygnus.client.security.MachineTokenProvider;
import java.time.Clock;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
public class CloudIdentityClient {
private final WebClient webClient;
private final MachineTokenProvider tokenProvider;
private final LoginEnvelopeEncryptor encryptor;
private final CloudClientProperties properties;
private final Clock clock;
public CloudIdentityClient(
WebClient webClient,
MachineTokenProvider tokenProvider,
LoginEnvelopeEncryptor encryptor,
CloudClientProperties properties,
Clock clock) {
this.webClient = webClient;
this.tokenProvider = tokenProvider;
this.encryptor = encryptor;
this.properties = properties;
this.clock = clock;
}
public Mono<CloudIdentitySession> authenticate(String loginId, String password) {
LoginPayload payload = new LoginPayload(
loginId,
password,
properties.clientId(),
properties.installationId(),
UUID.randomUUID().toString(),
clock.instant());
return tokenProvider.accessToken()
.flatMap(token -> webClient.post()
.uri(properties.baseUri().resolve("/api/v1/identity/login"))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(encryptor.encrypt(payload))
.retrieve()
.bodyToMono(CloudIdentitySession.class))
.timeout(properties.requestTimeout());
}
public Mono<CloudQueryResponse> fetchQuery(int queryId) {
return tokenProvider.accessToken()
.flatMap(token -> webClient.get()
.uri(properties.baseUri().resolve("/api/v1/queries/" + queryId))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(CloudQueryResponse.class))
.timeout(properties.requestTimeout());
}
public Mono<CloudQueryResponse> fetchQuery(String queryKey) {
return tokenProvider.accessToken()
.flatMap(token -> webClient.get()
.uri(properties.baseUri().resolve("/api/v1/queries/key/" + queryKey))
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(CloudQueryResponse.class))
.timeout(properties.requestTimeout());
}
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,21 @@
package com.cygnus.client.model;
import java.time.Instant;
import java.util.List;
public record CloudIdentitySession(
short userId,
String loginId,
String displayName,
short groupId,
String groupName,
short branchId,
String branchName,
String branchCode,
String branchLocation,
short companyId,
String companyName,
String companyCode,
Instant loginTime,
List<CloudMenuItem> menu) {
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.client.model;
public record CloudMenuItem(
short pageId,
String label,
String targetUrl,
short parentPage,
short pageOrder,
String permission,
String targetWindow,
String requestValue) {
}

View File

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

View File

@@ -0,0 +1,8 @@
package com.cygnus.client.model;
public record EncryptedLoginRequest(
String keyId,
String encryptedKey,
String initializationVector,
String encryptedPayload) {
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.client.model;
import java.time.Instant;
public record LoginPayload(
String loginId,
String password,
String clientId,
String installationId,
String nonce,
Instant issuedAt) {
}

View File

@@ -0,0 +1,99 @@
package com.cygnus.client.provisioning;
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEHeader;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.RSAEncrypter;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.KeyFactory;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Date;
import java.util.Set;
public final class MachineAssertionGenerator {
private MachineAssertionGenerator() {
}
public static void main(String[] args) throws Exception {
if (args.length != 6) {
throw new IllegalArgumentException(
"Expected: <client-id> <installation-id> <token-audience> "
+ "<client-signing-private.pem> "
+ "<cloud-assertion-public.pem> <output-file>");
}
Instant issuedAt = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(args[0])
.subject(args[0])
.audience(args[2])
.issueTime(Date.from(issuedAt))
.expirationTime(Date.from(issuedAt.plus(365, ChronoUnit.DAYS)))
.claim("installation_id", args[1])
.build();
SignedJWT signed = new SignedJWT(
new JWSHeader(JWSAlgorithm.RS256), claims);
signed.sign(new RSASSASigner(privateKey(Path.of(args[3]))));
JWEObject encrypted = new JWEObject(
new JWEHeader(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM),
new Payload(signed.serialize()));
encrypted.encrypt(new RSAEncrypter(publicKey(Path.of(args[4]))));
Path output = Path.of(args[5]).toAbsolutePath();
Files.writeString(
output,
encrypted.serialize(),
StandardCharsets.US_ASCII,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
try {
Files.setPosixFilePermissions(
output,
Set.of(
java.nio.file.attribute.PosixFilePermission.OWNER_READ,
java.nio.file.attribute.PosixFilePermission.OWNER_WRITE));
} catch (UnsupportedOperationException ignored) {
// Non-POSIX platforms must secure this file through their native ACL.
}
System.out.println("Machine assertion written to " + output);
}
private static RSAPrivateKey privateKey(Path path) throws Exception {
String encoded = pem(path, "PRIVATE KEY");
return (RSAPrivateKey) KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
}
private static RSAPublicKey publicKey(Path path) throws Exception {
String encoded = pem(path, "PUBLIC KEY");
return (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
}
private static String pem(Path path, String type) throws Exception {
return Files.readString(path, StandardCharsets.US_ASCII)
.replace("-----BEGIN " + type + "-----", "")
.replace("-----END " + type + "-----", "")
.replaceAll("\\s", "");
}
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.client.security;
public class CloudClientException extends RuntimeException {
public CloudClientException(String message) {
super(message);
}
public CloudClientException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,120 @@
package com.cygnus.client.security;
import com.cygnus.client.CloudClientProperties;
import com.cygnus.client.model.EncryptedLoginRequest;
import com.cygnus.client.model.LoginPayload;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
public class LoginEnvelopeEncryptor {
private static final OAEPParameterSpec OAEP_SHA_256 = new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT);
private final CloudClientProperties properties;
private final ObjectMapper objectMapper;
private final SecureRandom secureRandom;
private volatile PublicKey publicKey;
public LoginEnvelopeEncryptor(
CloudClientProperties properties, ObjectMapper objectMapper) {
this(properties, objectMapper, new SecureRandom());
}
LoginEnvelopeEncryptor(
CloudClientProperties properties,
ObjectMapper objectMapper,
SecureRandom secureRandom) {
this.properties = properties;
this.objectMapper = objectMapper;
this.secureRandom = secureRandom;
}
public EncryptedLoginRequest encrypt(LoginPayload payload) {
try {
KeyGenerator generator = KeyGenerator.getInstance("AES");
generator.init(256, secureRandom);
SecretKey aesKey = generator.generateKey();
byte[] initializationVector = new byte[12];
secureRandom.nextBytes(initializationVector);
Cipher payloadCipher = Cipher.getInstance("AES/GCM/NoPadding");
payloadCipher.init(
Cipher.ENCRYPT_MODE,
aesKey,
new GCMParameterSpec(128, initializationVector));
payloadCipher.updateAAD(
properties.loginKeyId().getBytes(StandardCharsets.UTF_8));
byte[] encryptedPayload =
payloadCipher.doFinal(objectMapper.writeValueAsBytes(payload));
Cipher keyCipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
keyCipher.init(Cipher.ENCRYPT_MODE, publicKey(), OAEP_SHA_256);
byte[] encryptedKey = keyCipher.doFinal(aesKey.getEncoded());
Base64.Encoder base64 = Base64.getEncoder();
return new EncryptedLoginRequest(
properties.loginKeyId(),
base64.encodeToString(encryptedKey),
base64.encodeToString(initializationVector),
base64.encodeToString(encryptedPayload));
} catch (Exception exception) {
throw new CloudClientException("Unable to encrypt cloud login request", exception);
}
}
private PublicKey publicKey() throws Exception {
PublicKey loaded = publicKey;
if (loaded != null) {
return loaded;
}
synchronized (this) {
if (publicKey == null) {
String pem;
String location = properties.loginPublicKeyLocation();
if (location.startsWith("classpath:")) {
String resourceName = location.substring("classpath:".length());
try (InputStream stream = Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream(resourceName)) {
if (stream == null) {
throw new IllegalArgumentException(
"Public key resource not found: " + resourceName);
}
pem = new String(stream.readAllBytes(), StandardCharsets.US_ASCII);
}
} else {
String fileName =
location.startsWith("file:") ? location.substring(5) : location;
pem = Files.readString(Path.of(fileName), StandardCharsets.US_ASCII);
}
String encoded = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
publicKey = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
}
return publicKey;
}
}
}

View File

@@ -0,0 +1,7 @@
package com.cygnus.client.security;
import reactor.core.publisher.Mono;
public interface MachineTokenProvider {
Mono<String> accessToken();
}

View File

@@ -0,0 +1,102 @@
package com.cygnus.client.security;
import com.cygnus.client.CloudClientProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Clock;
import java.time.Instant;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
public class OAuthMachineTokenProvider implements MachineTokenProvider {
private static final String ASSERTION_TYPE =
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
private final WebClient webClient;
private final CloudClientProperties properties;
private final Clock clock;
private final AtomicReference<CachedToken> cachedToken = new AtomicReference<>();
private volatile Mono<String> tokenRequest;
public OAuthMachineTokenProvider(
WebClient webClient, CloudClientProperties properties, Clock clock) {
this.webClient = webClient;
this.properties = properties;
this.clock = clock;
}
@Override
public Mono<String> accessToken() {
CachedToken current = cachedToken.get();
if (current != null
&& clock.instant().isBefore(
current.expiresAt().minus(properties.tokenRefreshSkew()))) {
return Mono.just(current.value());
}
return refreshToken();
}
private synchronized Mono<String> refreshToken() {
CachedToken current = cachedToken.get();
if (current != null
&& clock.instant().isBefore(
current.expiresAt().minus(properties.tokenRefreshSkew()))) {
return Mono.just(current.value());
}
if (tokenRequest == null) {
tokenRequest = webClient.post()
.uri(properties.tokenUri())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData("grant_type", "client_credentials")
.with("client_id", properties.clientId())
.with("client_assertion_type", ASSERTION_TYPE)
.with("client_assertion", clientAssertion())
.with("scope", "identity.login"))
.retrieve()
.bodyToMono(TokenResponse.class)
.map(response -> {
cachedToken.set(new CachedToken(
response.accessToken(),
clock.instant().plusSeconds(response.expiresIn())));
return response.accessToken();
})
.doFinally(signal -> clearInFlight())
.cache();
}
return tokenRequest;
}
private synchronized void clearInFlight() {
tokenRequest = null;
}
private String clientAssertion() {
String configured = properties.clientAssertion();
if (!configured.startsWith("file:")) {
return configured;
}
try {
return Files.readString(
Path.of(configured.substring("file:".length())),
StandardCharsets.US_ASCII)
.trim();
} catch (Exception exception) {
throw new IllegalStateException("Unable to read machine client assertion", exception);
}
}
private record CachedToken(String value, Instant expiresAt) {
}
private record TokenResponse(
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") long expiresIn,
@JsonProperty("token_type") String tokenType) {
}
}

View File

@@ -0,0 +1,42 @@
package com.cygnus.client;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.net.URI;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class CloudClientPropertiesTest {
@Test
void configurationRequiresMachineCredentials() {
CloudClientProperties properties = properties("", "", "");
assertThrows(IllegalStateException.class, properties::validate);
}
@Test
void configurationAcceptsCompleteMachineIdentity() {
CloudClientProperties properties =
properties("customer-a", "site-01", "signed-assertion");
assertDoesNotThrow(properties::validate);
}
private CloudClientProperties properties(
String clientId,
String installationId,
String assertion) {
return new CloudClientProperties(
URI.create("https://cloud.example.test"),
URI.create("https://identity.example.test/oauth2/token"),
clientId,
installationId,
assertion,
"login-key-01",
"file:./login-public.pem",
Duration.ofSeconds(10),
Duration.ofSeconds(30));
}
}

View File

@@ -0,0 +1,100 @@
# Cygnus cloud service
## Identity login API
`POST /api/v1/identity/login` requires a valid machine JWT with the
`identity.login` scope. The JWT must carry `client_id` and `installation_id`;
both must equal the values inside the encrypted payload.
The request uses a hybrid encrypted envelope:
```json
{
"keyId": "cygnus-login-2026-01",
"encryptedKey": "base64 RSA-OAEP-SHA256 encrypted AES key",
"initializationVector": "base64 12-byte AES-GCM IV",
"encryptedPayload": "base64 AES-GCM ciphertext and tag"
}
```
The AES-GCM additional authenticated data is the UTF-8 `keyId`. The decrypted
JSON is:
```json
{
"loginId": "user",
"password": "password",
"clientId": "client-id-from-jwt",
"installationId": "installation-id-from-jwt",
"nonce": "unique-random-value",
"issuedAt": "2026-07-23T06:30:00Z"
}
```
Configure the PKCS#8 RSA private key with
`CYGNUS_LOGIN_PRIVATE_KEY=file:/secure/path/login-private.pem`. Keep this key
outside the source tree and container image. The corresponding public key is
distributed to the on-prem gateway.
The database bootstrap is
`src/main/resources/db/identity/001_identity_login_schema.sql`. It is
transactional and idempotent; it copies login/menu data from `matrix.public`
to `matrix.identity`. It is intended for initial migration and controlled
development refreshes. Do not run it after `identity` becomes the production
system of record because its upserts intentionally refresh rows from `public`.
## Machine token endpoint
`POST /oauth2/token` implements the client-credentials flow used by the
on-premises gateway. The client assertion must be:
- an inner RS256 JWT signed with the installation private key;
- encrypted as RSA-OAEP-256 plus AES-256-GCM using the cloud assertion key;
- bound to the configured client ID, installation ID, and token audience;
- unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`.
The endpoint returns a short-lived RS256 access token carrying the client,
installation, tenant, license, security-version, and approved-scope claims.
The identity endpoint requires the `identity.login` scope and verifies the
same machine and tenant binding in the encrypted login payload.
Generate separate cloud key pairs:
```bash
mkdir -p config/keys
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
-out config/keys/assertion-decryption-private.pem
openssl pkey -in config/keys/assertion-decryption-private.pem -pubout \
-out config/keys/assertion-decryption-public.pem
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
-out config/keys/access-token-private.pem
openssl pkey -in config/keys/access-token-private.pem -pubout \
-out config/keys/access-token-public.pem
chmod 600 config/keys/*private.pem
```
## Dynamic tenant, installation, and license registration
Machine clients are no longer configured in a runtime `clients.yml`. The
authoritative records are:
- `identity.client_account`: tenant identity and status;
- `identity.client_installation`: machine identity, assertion public key,
allowed scopes, enabled state, and security version;
- `identity.client_license`: subscription period, package, type, status, and
licensed limits.
Token issuance resolves the installation and active license through a
Redis cache-aside service with PostgreSQL fallback. Cache entries have a
bounded TTL and can be invalidated after administrative changes. Therefore,
new customers, installations, key rotations, scope changes, and license
changes do not require restarting the cloud service.
The login/menu queries are tenant-scoped. Tenant-owned identity tables carry
`tenant_id`; `identity.pages` remains the shared feature catalog while
permissions are assigned per tenant.
Use `scripts/setup-local-communication.sh` to create keys, register or update
the database records, create the initial license, and generate the on-premises
machine assertion. Never place cloud private keys, customer assertions, or
installation private keys in the repository or container image.

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.cygnus</groupId>
<artifactId>cygnus-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>cygnus-cloud-service</artifactId>
<packaging>jar</packaging>
<name>Cygnus Cloud Service</name>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-pg-client</artifactId>
<version>${vertx.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,14 @@
package com.cygnus.cloud;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class CygnusCloudServiceApplication {
public static void main(String[] args) {
SpringApplication.run(CygnusCloudServiceApplication.class, args);
}
}

View File

@@ -0,0 +1,9 @@
package com.cygnus.cloud.cache;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("cygnus.cache")
public record CacheProperties(String keyPrefix, Duration defaultTtl) {
}

View File

@@ -0,0 +1,53 @@
package com.cygnus.cloud.cache;
import java.time.Duration;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class ReactiveCacheService {
private final ReactiveStringRedisTemplate redis;
private final CacheProperties properties;
public ReactiveCacheService(ReactiveStringRedisTemplate redis, CacheProperties properties) {
this.redis = redis;
this.properties = properties;
}
public Mono<String> get(String namespace, String key) {
return redis.opsForValue().get(cacheKey(namespace, key));
}
public Mono<Boolean> put(String namespace, String key, String value) {
return put(namespace, key, value, properties.defaultTtl());
}
public Mono<Boolean> put(String namespace, String key, String value, Duration ttl) {
return redis.opsForValue().set(cacheKey(namespace, key), value, ttl);
}
public Mono<Boolean> putIfAbsent(String namespace, String key, String value, Duration ttl) {
return redis.opsForValue().setIfAbsent(cacheKey(namespace, key), value, ttl);
}
public Mono<Boolean> evict(String namespace, String key) {
return redis.delete(cacheKey(namespace, key)).map(deleted -> deleted > 0);
}
public Mono<Long> increment(String namespace, String key, Duration ttl) {
String fullKey = cacheKey(namespace, key);
return redis.opsForValue()
.increment(fullKey)
.flatMap(count -> count == 1
? redis.expire(fullKey, ttl).thenReturn(count)
: Mono.just(count));
}
private String cacheKey(String namespace, String key) {
return properties.keyPrefix() + ':' + namespace + ':' + key;
}
}

View File

@@ -0,0 +1,18 @@
package com.cygnus.cloud.database;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("cygnus.database")
public record DatabaseProperties(
String host,
int port,
String database,
String username,
String password,
boolean ssl,
Duration connectTimeout,
int poolSize,
int poolWaitQueueSize) {
}

View File

@@ -0,0 +1,67 @@
package com.cygnus.cloud.database;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.RowSet;
import io.vertx.sqlclient.SqlConnection;
import io.vertx.sqlclient.Tuple;
import java.util.function.Function;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
/**
* Reactor boundary around the Vert.x PostgreSQL pool. Feature repositories
* should use parameterized SQL and pass values through {@link Tuple}.
*/
@Service
public class ReactiveDatabaseClient {
private final Pool pool;
public ReactiveDatabaseClient(Pool pool) {
this.pool = pool;
}
public Mono<RowSet<Row>> query(String sql) {
return Mono.fromCompletionStage(() -> pool.query(sql).execute().toCompletionStage());
}
public Mono<RowSet<Row>> preparedQuery(String sql, Tuple parameters) {
return Mono.fromCompletionStage(
() -> pool.preparedQuery(sql).execute(parameters).toCompletionStage());
}
public Mono<Integer> preparedUpdate(String sql, Tuple parameters) {
return preparedQuery(sql, parameters).map(RowSet::rowCount);
}
public <T> Mono<T> inTransaction(Function<SqlConnection, Mono<T>> work) {
return Mono.usingWhen(
Mono.fromCompletionStage(() -> pool.getConnection().toCompletionStage()),
connection -> Mono.fromCompletionStage(
() -> connection.begin().toCompletionStage())
.flatMap(transaction -> work.apply(connection)
.flatMap(result -> Mono.fromCompletionStage(
() -> transaction.commit().toCompletionStage())
.thenReturn(result))
.onErrorResume(error -> Mono.fromCompletionStage(
() -> transaction.rollback().toCompletionStage())
.onErrorResume(ignored -> Mono.empty())
.then(Mono.error(error)))),
connection -> Mono.fromCompletionStage(
() -> connection.close().toCompletionStage()),
(connection, error) -> Mono.fromCompletionStage(
() -> connection.close().toCompletionStage()),
connection -> Mono.fromCompletionStage(
() -> connection.close().toCompletionStage()));
}
public Mono<RowSet<Row>> preparedQuery(
SqlConnection connection, String sql, Tuple parameters) {
return Mono.fromCompletionStage(
() -> connection.preparedQuery(sql)
.execute(parameters)
.toCompletionStage());
}
}

View File

@@ -0,0 +1,44 @@
package com.cygnus.cloud.database;
import io.vertx.core.Vertx;
import io.vertx.pgclient.PgConnectOptions;
import io.vertx.pgclient.PgBuilder;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class VertxDatabaseConfiguration {
@Bean(destroyMethod = "close")
Vertx vertx() {
return Vertx.vertx();
}
@Bean(destroyMethod = "close")
Pool postgresPool(Vertx vertx, DatabaseProperties properties) {
PgConnectOptions connection = new PgConnectOptions()
.setHost(properties.host())
.setPort(properties.port())
.setDatabase(properties.database())
.setUser(properties.username())
.setPassword(properties.password())
.setSslMode(properties.ssl()
? io.vertx.pgclient.SslMode.REQUIRE
: io.vertx.pgclient.SslMode.DISABLE);
PoolOptions pool = new PoolOptions()
.setMaxSize(properties.poolSize())
.setMaxWaitQueueSize(properties.poolWaitQueueSize())
.setConnectionTimeout(Math.toIntExact(properties.connectTimeout().toMillis()))
.setConnectionTimeoutUnit(TimeUnit.MILLISECONDS);
return PgBuilder.pool()
.using(vertx)
.connectingTo(connection)
.with(pool)
.build();
}
}

View File

@@ -0,0 +1,116 @@
package com.cygnus.cloud.identity.api;
import com.cygnus.cloud.identity.model.AuthenticatedIdentity;
import com.cygnus.cloud.identity.service.AuthenticationException;
import com.cygnus.cloud.identity.service.IdentityAuthenticationService;
import com.cygnus.cloud.identity.service.LoginRequestReplayService;
import jakarta.validation.Valid;
import java.time.Clock;
import java.time.Duration;
import java.util.UUID;
import org.springframework.util.StringUtils;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
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/identity")
public class CloudLoginController {
private final RsaLoginPayloadDecryptor decryptor;
private final IdentityAuthenticationService authenticationService;
private final LoginRequestReplayService replayService;
private final LoginEncryptionProperties encryptionProperties;
private final Clock clock;
public CloudLoginController(
RsaLoginPayloadDecryptor decryptor,
IdentityAuthenticationService authenticationService,
LoginRequestReplayService replayService,
LoginEncryptionProperties encryptionProperties,
Clock clock) {
this.decryptor = decryptor;
this.authenticationService = authenticationService;
this.replayService = replayService;
this.encryptionProperties = encryptionProperties;
this.clock = clock;
}
@PostMapping("/login")
public Mono<AuthenticatedIdentity> login(
@AuthenticationPrincipal Jwt machineJwt,
@Valid @RequestBody EncryptedLoginRequest request,
ServerHttpRequest serverRequest) {
if (machineJwt == null) {
return Mono.error(new AuthenticationException("Machine authentication required"));
}
LoginPayload payload = decryptor.decrypt(request);
validatePayload(payload);
validateMachineBinding(machineJwt, payload);
validateFreshness(payload);
return replayService
.claim(
payload.installationId(),
payload.nonce(),
encryptionProperties.payloadTtl())
.flatMap(claimed -> {
if (!claimed) {
return Mono.error(new AuthenticationException("Login request replayed"));
}
return authenticationService.authenticate(
tenantId(machineJwt),
payload.loginId(),
payload.password(),
remoteAddress(serverRequest));
});
}
private UUID tenantId(Jwt jwt) {
try {
return UUID.fromString(jwt.getClaimAsString("tenant_id"));
} catch (RuntimeException exception) {
throw new AuthenticationException("Machine tenant is invalid");
}
}
private void validatePayload(LoginPayload payload) {
if (payload == null
|| !StringUtils.hasText(payload.loginId())
|| !StringUtils.hasText(payload.password())
|| !StringUtils.hasText(payload.clientId())
|| !StringUtils.hasText(payload.installationId())
|| !StringUtils.hasText(payload.nonce())
|| payload.issuedAt() == null) {
throw new AuthenticationException("Invalid login payload");
}
}
private void validateMachineBinding(Jwt jwt, LoginPayload payload) {
String authenticatedClient = jwt.getClaimAsString("client_id");
String authenticatedInstallation = jwt.getClaimAsString("installation_id");
if (!payload.clientId().equals(authenticatedClient)
|| !payload.installationId().equals(authenticatedInstallation)) {
throw new AuthenticationException("Machine identity mismatch");
}
}
private void validateFreshness(LoginPayload payload) {
Duration age = Duration.between(payload.issuedAt(), clock.instant()).abs();
if (age.compareTo(encryptionProperties.payloadTtl()) > 0) {
throw new AuthenticationException("Login request expired");
}
}
private String remoteAddress(ServerHttpRequest request) {
return request.getRemoteAddress() == null
? null
: request.getRemoteAddress().getAddress().getHostAddress();
}
}

View File

@@ -0,0 +1,10 @@
package com.cygnus.cloud.identity.api;
import jakarta.validation.constraints.NotBlank;
public record EncryptedLoginRequest(
@NotBlank String keyId,
@NotBlank String encryptedKey,
@NotBlank String initializationVector,
@NotBlank String encryptedPayload) {
}

View File

@@ -0,0 +1,22 @@
package com.cygnus.cloud.identity.api;
import com.cygnus.cloud.identity.service.AuthenticationException;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class IdentityErrorHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(IdentityErrorHandler.class);
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
Map<String, String> authenticationFailure(AuthenticationException exception) {
LOGGER.warn("Identity authentication rejected: {}", exception.getMessage());
return Map.of("code", "AUTHENTICATION_FAILED", "message", "Authentication failed");
}
}

View File

@@ -0,0 +1,11 @@
package com.cygnus.cloud.identity.api;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("cygnus.login-encryption")
public record LoginEncryptionProperties(
String keyId,
String privateKeyLocation,
Duration payloadTtl) {
}

View File

@@ -0,0 +1,14 @@
package com.cygnus.cloud.identity.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
public record LoginPayload(
@NotBlank String loginId,
@NotBlank String password,
@NotBlank String clientId,
@NotBlank String installationId,
@NotBlank String nonce,
@NotNull Instant issuedAt) {
}

View File

@@ -0,0 +1,92 @@
package com.cygnus.cloud.identity.api;
import com.cygnus.cloud.identity.service.AuthenticationException;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;
@Component
public class RsaLoginPayloadDecryptor {
private static final OAEPParameterSpec OAEP_SHA_256 = new OAEPParameterSpec(
"SHA-256",
"MGF1",
MGF1ParameterSpec.SHA256,
PSource.PSpecified.DEFAULT);
private final LoginEncryptionProperties properties;
private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader;
private volatile PrivateKey privateKey;
public RsaLoginPayloadDecryptor(
LoginEncryptionProperties properties,
ObjectMapper objectMapper,
ResourceLoader resourceLoader) {
this.properties = properties;
this.objectMapper = objectMapper;
this.resourceLoader = resourceLoader;
}
public LoginPayload decrypt(EncryptedLoginRequest request) {
if (!properties.keyId().equals(request.keyId())) {
throw new AuthenticationException("Unsupported encryption key");
}
try {
Cipher keyCipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
keyCipher.init(Cipher.DECRYPT_MODE, privateKey(), OAEP_SHA_256);
byte[] aesKey =
keyCipher.doFinal(Base64.getDecoder().decode(request.encryptedKey()));
Cipher payloadCipher = Cipher.getInstance("AES/GCM/NoPadding");
payloadCipher.init(
Cipher.DECRYPT_MODE,
new SecretKeySpec(aesKey, "AES"),
new GCMParameterSpec(
128,
Base64.getDecoder().decode(request.initializationVector())));
payloadCipher.updateAAD(request.keyId().getBytes(StandardCharsets.UTF_8));
byte[] plaintext = payloadCipher.doFinal(
Base64.getDecoder().decode(request.encryptedPayload()));
return objectMapper.readValue(plaintext, LoginPayload.class);
} catch (AuthenticationException exception) {
throw exception;
} catch (Exception exception) {
throw new AuthenticationException("Invalid encrypted login request");
}
}
private PrivateKey privateKey() throws Exception {
PrivateKey loaded = privateKey;
if (loaded != null) {
return loaded;
}
synchronized (this) {
if (privateKey == null) {
Resource resource =
resourceLoader.getResource(properties.privateKeyLocation());
String pem = resource.getContentAsString(StandardCharsets.US_ASCII);
String encoded = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(encoded);
privateKey = KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
}
return privateKey;
}
}
}

View File

@@ -0,0 +1,21 @@
package com.cygnus.cloud.identity.model;
import java.time.Instant;
import java.util.List;
public record AuthenticatedIdentity(
short userId,
String loginId,
String displayName,
short groupId,
String groupName,
short branchId,
String branchName,
String branchCode,
String branchLocation,
short companyId,
String companyName,
String companyCode,
Instant loginTime,
List<MenuItem> menu) {
}

View File

@@ -0,0 +1,18 @@
package com.cygnus.cloud.identity.model;
public record IdentityUser(
short userId,
String loginId,
String displayName,
String legacyPassword,
short groupId,
String groupName,
short branchId,
String branchName,
String branchCode,
String branchLocation,
short companyId,
String companyName,
String companyCode,
boolean active) {
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.cloud.identity.model;
public record MenuItem(
short pageId,
String label,
String targetUrl,
short parentPage,
short pageOrder,
String permission,
String targetWindow,
String requestValue) {
}

View File

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

View File

@@ -0,0 +1,40 @@
package com.cygnus.cloud.identity.repository;
import com.cygnus.cloud.identity.model.IdentityUser;
import com.cygnus.cloud.identity.model.MenuItem;
import io.vertx.sqlclient.Row;
import org.springframework.stereotype.Component;
@Component
public class IdentityRowMapper {
IdentityUser user(Row row) {
return new IdentityUser(
row.getShort("user_id"),
row.getString("loginid"),
row.getString("displayname"),
row.getString("loginpassword"),
row.getShort("group_id"),
row.getString("group_name"),
row.getShort("branch_id"),
row.getString("branchname"),
row.getString("branchcode"),
row.getString("city"),
row.getShort("company_id"),
row.getString("companyname"),
row.getString("companycode"),
row.getShort("isactive") == 1);
}
MenuItem menuItem(Row row) {
return new MenuItem(
row.getShort("page_id"),
row.getString("menulabel"),
row.getString("targeturl"),
row.getShort("parentpage"),
row.getShort("pageorder"),
row.getString("permission").trim(),
row.getString("targetwindow"),
row.getString("requestval"));
}
}

View File

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

View File

@@ -0,0 +1,88 @@
package com.cygnus.cloud.identity.service;
import com.cygnus.cloud.identity.model.AuthenticatedIdentity;
import com.cygnus.cloud.identity.model.IdentityUser;
import com.cygnus.cloud.identity.repository.IdentityRepository;
import java.time.Clock;
import java.time.Instant;
import java.util.UUID;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class IdentityAuthenticationService {
private final IdentityRepository repository;
private final LegacyPasswordVerifier passwordVerifier;
private final Clock clock;
public IdentityAuthenticationService(
IdentityRepository repository,
LegacyPasswordVerifier passwordVerifier,
Clock clock) {
this.repository = repository;
this.passwordVerifier = passwordVerifier;
this.clock = clock;
}
public Mono<AuthenticatedIdentity> authenticate(
UUID tenantId,
String loginId,
String password,
String remoteAddress) {
return repository.findUsersByLoginId(tenantId, loginId)
.collectList()
.flatMap(users -> {
if (users.isEmpty()) {
return Mono.error(new AuthenticationException("Invalid credentials"));
}
java.util.List<IdentityUser> exactMatches = users.stream()
.filter(user -> user.loginId().equals(loginId))
.toList();
IdentityUser user;
if (exactMatches.size() == 1) {
user = exactMatches.getFirst();
} else if (users.size() == 1) {
user = users.getFirst();
} else {
return Mono.error(
new AuthenticationException("Ambiguous login identity"));
}
if (!user.active()
|| !passwordVerifier.matches(password, user.legacyPassword())) {
return Mono.error(new AuthenticationException("Invalid credentials"));
}
Instant loginTime = clock.instant();
return repository.findMenu(
tenantId, user.groupId(), user.userId())
.flatMap(menu -> repository.recordLogin(
tenantId,
user.loginId(),
loginTime,
remoteAddress,
user.userId())
.thenReturn(toAuthenticatedIdentity(user, loginTime, menu)));
});
}
private AuthenticatedIdentity toAuthenticatedIdentity(
IdentityUser user,
Instant loginTime,
java.util.List<com.cygnus.cloud.identity.model.MenuItem> menu) {
return new AuthenticatedIdentity(
user.userId(),
user.loginId(),
user.displayName(),
user.groupId(),
user.groupName(),
user.branchId(),
user.branchName(),
user.branchCode(),
user.branchLocation(),
user.companyId(),
user.companyName(),
user.companyCode(),
loginTime,
menu);
}
}

View File

@@ -0,0 +1,35 @@
package com.cygnus.cloud.identity.service;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Component;
/**
* Temporary compatibility verifier for passwords created by the legacy app.
* New passwords must not be written with this algorithm.
*/
@Component
public class LegacyPasswordVerifier {
private static final byte[] LEGACY_KEY =
"ThisIsASecretKey".getBytes(StandardCharsets.US_ASCII);
public boolean matches(String rawPassword, String storedPassword) {
if (rawPassword == null || storedPassword == null) {
return false;
}
try {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(LEGACY_KEY, "AES"));
byte[] encrypted = cipher.doFinal(rawPassword.getBytes(StandardCharsets.UTF_8));
byte[] expected = Base64.getMimeDecoder().decode(storedPassword);
return MessageDigest.isEqual(encrypted, expected);
} catch (GeneralSecurityException | IllegalArgumentException exception) {
return false;
}
}
}

View File

@@ -0,0 +1,23 @@
package com.cygnus.cloud.identity.service;
import com.cygnus.cloud.cache.ReactiveCacheService;
import java.time.Duration;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class LoginRequestReplayService {
private static final String NAMESPACE = "login-nonce";
private final ReactiveCacheService cache;
public LoginRequestReplayService(ReactiveCacheService cache) {
this.cache = cache;
}
public Mono<Boolean> claim(String installationId, String nonce, Duration ttl) {
return cache.putIfAbsent(
NAMESPACE, installationId + ':' + nonce, "used", ttl);
}
}

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

@@ -0,0 +1,64 @@
package com.cygnus.cloud.security;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.time.Clock;
import java.time.Instant;
import java.util.Date;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
class AccessTokenIssuer {
private final CommunicationSecurityProperties properties;
private final Clock clock;
AccessTokenIssuer(CommunicationSecurityProperties properties, Clock clock) {
this.properties = properties;
this.clock = clock;
}
TokenResponse issue(MachineClientPrincipal principal, Set<String> scopes) {
try {
Instant issuedAt = clock.instant();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(properties.issuerUri())
.subject(principal.clientId())
.audience(properties.audience())
.issueTime(Date.from(issuedAt))
.expirationTime(Date.from(expiresAt))
.jwtID(UUID.randomUUID().toString())
.claim("client_id", principal.clientId())
.claim("installation_id", principal.installationId())
.claim("installation_uuid",
principal.internalInstallationId().toString())
.claim("tenant_id", principal.tenantId().toString())
.claim("license_id", principal.licenseId().toString())
.claim("license_type", principal.licenseType())
.claim("package_code", principal.packageCode())
.claim("security_version", principal.securityVersion())
.claim("scope", String.join(" ", scopes))
.build();
SignedJWT jwt = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256)
.keyID("cygnus-access-token")
.build(),
claims);
jwt.sign(new RSASSASigner(
PemKeyLoader.privateKey(properties.accessTokenPrivateKey())));
return new TokenResponse(
jwt.serialize(),
"Bearer",
properties.accessTokenTtl().toSeconds(),
String.join(" ", scopes));
} catch (Exception exception) {
throw new IllegalStateException("Unable to issue access token", exception);
}
}
}

View File

@@ -0,0 +1,25 @@
package com.cygnus.cloud.security;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error INVALID_AUDIENCE =
new OAuth2Error("invalid_token", "Required token audience is missing", null);
private final String audience;
AudienceValidator(String audience) {
this.audience = audience;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
return jwt.getAudience().contains(audience)
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(INVALID_AUDIENCE);
}
}

View File

@@ -0,0 +1,135 @@
package com.cygnus.cloud.security;
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.crypto.RSADecrypter;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.cygnus.cloud.tenant.model.ClientInstallation;
import com.cygnus.cloud.tenant.model.ClientLicense;
import com.cygnus.cloud.tenant.service.TenantRegistrationService;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@Component
class ClientAssertionValidator {
private final CommunicationSecurityProperties properties;
private final TenantRegistrationService registrations;
private final Clock clock;
ClientAssertionValidator(
CommunicationSecurityProperties properties,
TenantRegistrationService registrations,
Clock clock) {
this.properties = properties;
this.registrations = registrations;
this.clock = clock;
}
Mono<MachineClientPrincipal> validate(String clientId, String encryptedAssertion) {
try {
JWEObject jwe = JWEObject.parse(encryptedAssertion);
if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm())
|| !EncryptionMethod.A256GCM.equals(jwe.getHeader().getEncryptionMethod())) {
throw invalid();
}
jwe.decrypt(new RSADecrypter(
PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey())));
SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString());
JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
String installationCode = claims.getStringClaim("installation_id");
if (installationCode == null || installationCode.isBlank()) {
throw invalid();
}
return registrations.findInstallation(clientId, installationCode)
.switchIfEmpty(Mono.error(invalid()))
.flatMap(installation -> registrations
.findCurrentLicense(installation.tenantId(), clock.instant())
.filter(license -> license.isActiveAt(clock.instant()))
.switchIfEmpty(Mono.error(new MachineAuthenticationException(
"Client license is not active")))
.map(license -> verify(
clientId, signedJwt, claims, installation, license))
.flatMap(principal -> registrations
.touchInstallation(
installation.installationId(), clock.instant())
.thenReturn(principal)))
.onErrorMap(
exception -> !(exception instanceof MachineAuthenticationException),
exception -> new MachineAuthenticationException(
"Invalid client assertion", exception));
} catch (MachineAuthenticationException exception) {
return Mono.error(exception);
} catch (Exception exception) {
return Mono.error(new MachineAuthenticationException(
"Invalid client assertion", exception));
}
}
private MachineClientPrincipal verify(
String clientId,
SignedJWT signedJwt,
JWTClaimsSet claims,
ClientInstallation installation,
ClientLicense license) {
try {
if (!signedJwt.verify(new RSASSAVerifier(
PemKeyLoader.publicKey(installation.assertionPublicKey())))) {
throw invalid();
}
validateClaims(clientId, installation, claims);
return new MachineClientPrincipal(
clientId,
installation.installationCode(),
installation.tenantId(),
installation.installationId(),
license.licenseId(),
license.licenseType(),
license.packageCode(),
installation.securityVersion(),
installation.allowedScopes());
} catch (MachineAuthenticationException exception) {
throw exception;
} catch (Exception exception) {
throw new MachineAuthenticationException(
"Invalid client assertion", exception);
}
}
private void validateClaims(
String clientId,
ClientInstallation installation,
JWTClaimsSet claims) throws Exception {
Instant now = clock.instant();
Date issuedAt = claims.getIssueTime();
Date expiresAt = claims.getExpirationTime();
if (!clientId.equals(claims.getIssuer())
|| !clientId.equals(claims.getSubject())
|| !claims.getAudience().contains(properties.tokenAudience())
|| !installation.installationCode().equals(
claims.getStringClaim("installation_id"))
|| issuedAt == null
|| expiresAt == null
|| now.isBefore(issuedAt.toInstant().minusSeconds(60))
|| !now.isBefore(expiresAt.toInstant())) {
throw invalid();
}
Duration lifetime = Duration.between(
issuedAt.toInstant(), expiresAt.toInstant());
if (lifetime.isNegative() || lifetime.compareTo(properties.assertionTtl()) > 0) {
throw invalid();
}
}
private MachineAuthenticationException invalid() {
return new MachineAuthenticationException("Invalid client assertion");
}
}

View File

@@ -0,0 +1,61 @@
package com.cygnus.cloud.security;
import static org.springframework.security.config.Customizer.withDefaults;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
@Configuration
public class CloudSecurityConfiguration {
@Bean
SecurityWebFilterChain cloudSecurityFilterChain(
ServerHttpSecurity http,
CommunicationSecurityProperties properties) {
http.csrf(ServerHttpSecurity.CsrfSpec::disable);
if (!properties.enabled()) {
return http.authorizeExchange(exchange -> exchange.anyExchange().permitAll()).build();
}
return http
.authorizeExchange(exchange -> exchange
.pathMatchers("/actuator/health", "/actuator/info").permitAll()
.pathMatchers("/oauth2/token").permitAll()
.pathMatchers(
"/api/v1/installations/activation/validate",
"/api/v1/installations/register")
.permitAll()
.pathMatchers("/api/v1/identity/login")
.hasAuthority("SCOPE_identity.login")
.pathMatchers("/api/v1/queries/**")
.hasAuthority("SCOPE_identity.login")
.pathMatchers("/api/v1/platform/data")
.hasAuthority("SCOPE_identity.login")
.pathMatchers("/api/v1/admin/**")
.hasAuthority("SCOPE_cygnus.admin")
.anyExchange().authenticated())
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
.build();
}
@Bean
@ConditionalOnProperty(name = "cygnus.security.enabled", havingValue = "true")
ReactiveJwtDecoder reactiveJwtDecoder(CommunicationSecurityProperties properties) {
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withPublicKey(PemKeyLoader.publicKey(properties.accessTokenPublicKey()))
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<Jwt>(
JwtValidators.createDefaultWithIssuer(properties.issuerUri()),
new AudienceValidator(properties.audience())));
return decoder;
}
}

View File

@@ -0,0 +1,18 @@
package com.cygnus.cloud.security;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("cygnus.security")
public record CommunicationSecurityProperties(
boolean enabled,
String issuerUri,
String audience,
String tokenAudience,
Duration assertionTtl,
Duration accessTokenTtl,
String assertionDecryptionPrivateKey,
String accessTokenPrivateKey,
String accessTokenPublicKey) {
}

View File

@@ -0,0 +1,24 @@
package com.cygnus.cloud.security;
import java.time.Duration;
import com.cygnus.cloud.cache.ReactiveCacheService;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@Service
public class JwtReplayProtectionService {
private static final String NAMESPACE = "jwt-jti";
private final ReactiveCacheService cache;
public JwtReplayProtectionService(ReactiveCacheService cache) {
this.cache = cache;
}
public Mono<Boolean> claim(String jwtId, Duration remainingLifetime) {
return cache.putIfAbsent(NAMESPACE, jwtId, "used", remainingLifetime);
}
}

View File

@@ -0,0 +1,12 @@
package com.cygnus.cloud.security;
final class MachineAuthenticationException extends RuntimeException {
MachineAuthenticationException(String message) {
super(message);
}
MachineAuthenticationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

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

View File

@@ -0,0 +1,70 @@
package com.cygnus.cloud.security;
import java.net.URI;
import java.time.Duration;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Component
@ConditionalOnProperty(
prefix = "cygnus.security",
name = "enabled",
havingValue = "true")
class MachineSecurityConfigurationValidator implements InitializingBean {
private final CommunicationSecurityProperties properties;
MachineSecurityConfigurationValidator(
CommunicationSecurityProperties properties) {
this.properties = properties;
}
@Override
public void afterPropertiesSet() {
requireUri("issuer-uri", properties.issuerUri());
requireText("audience", properties.audience());
requireUri("token-audience", properties.tokenAudience());
requirePositive("assertion-ttl", properties.assertionTtl());
requirePositive("access-token-ttl", properties.accessTokenTtl());
requireText(
"assertion-decryption-private-key",
properties.assertionDecryptionPrivateKey());
requireText("access-token-private-key", properties.accessTokenPrivateKey());
requireText("access-token-public-key", properties.accessTokenPublicKey());
}
private void requireUri(String name, String value) {
requireText(name, value);
try {
URI uri = URI.create(value);
if (!uri.isAbsolute()) {
throw invalid(name + " must be an absolute URI");
}
} catch (IllegalArgumentException exception) {
throw invalid(name + " must be a valid absolute URI");
}
}
private void requirePositive(String name, Duration value) {
if (value == null || value.isZero() || value.isNegative()) {
throw invalid(name + " must be positive");
}
}
private void requireText(String name, String value) {
if (isBlank(value)) {
throw invalid(name + " is required");
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private IllegalStateException invalid(String detail) {
return new IllegalStateException(
"Invalid cygnus.security configuration: " + detail);
}
}

View File

@@ -0,0 +1,73 @@
package com.cygnus.cloud.security;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.springframework.http.MediaType;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@RestController
class MachineTokenController {
private static final String CLIENT_CREDENTIALS = "client_credentials";
private static final String ASSERTION_TYPE =
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
private final ClientAssertionValidator assertionValidator;
private final AccessTokenIssuer tokenIssuer;
MachineTokenController(
ClientAssertionValidator assertionValidator,
AccessTokenIssuer tokenIssuer) {
this.assertionValidator = assertionValidator;
this.tokenIssuer = tokenIssuer;
}
@PostMapping(
value = "/oauth2/token",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
Mono<Map<String, Object>> token(ServerWebExchange exchange) {
return exchange.getFormData().flatMap(this::issueToken);
}
Mono<Map<String, Object>> issueToken(MultiValueMap<String, String> form) {
if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type"))
|| !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) {
throw new MachineAuthenticationException("Unsupported token request");
}
String clientId = required(form, "client_id");
Set<String> requestedScopes = scopes(form.getFirst("scope"));
return assertionValidator.validate(
clientId, required(form, "client_assertion"))
.map(principal -> {
if (requestedScopes.isEmpty()
|| !principal.allowedScopes().containsAll(requestedScopes)) {
throw new MachineAuthenticationException(
"Invalid requested scope");
}
return tokenIssuer.issue(principal, requestedScopes)
.asOAuthResponse();
});
}
private String required(MultiValueMap<String, String> form, String name) {
String value = form.getFirst(name);
if (value == null || value.isBlank()) {
throw new MachineAuthenticationException("Invalid token request");
}
return value;
}
private Set<String> scopes(String value) {
if (value == null || value.isBlank()) {
return Set.of();
}
return new LinkedHashSet<>(Arrays.asList(value.trim().split("\\s+")));
}
}

View File

@@ -0,0 +1,17 @@
package com.cygnus.cloud.security;
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(assignableTypes = MachineTokenController.class)
class MachineTokenErrorHandler {
@ExceptionHandler(MachineAuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
Map<String, String> invalidClient() {
return Map.of("error", "invalid_client");
}
}

View File

@@ -0,0 +1,86 @@
package com.cygnus.cloud.security;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.concurrent.ConcurrentHashMap;
final class PemKeyLoader {
private static final ConcurrentHashMap<String, RSAPrivateKey> PRIVATE_KEYS =
new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, RSAPublicKey> PUBLIC_KEYS =
new ConcurrentHashMap<>();
private PemKeyLoader() {
}
static RSAPrivateKey privateKey(String location) {
return PRIVATE_KEYS.computeIfAbsent(location, PemKeyLoader::loadPrivateKey);
}
private static RSAPrivateKey loadPrivateKey(String location) {
try {
String encoded = read(location)
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
PrivateKey key = KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
return (RSAPrivateKey) key;
} catch (Exception exception) {
throw new IllegalStateException("Unable to load RSA private key", exception);
}
}
static RSAPublicKey publicKey(String location) {
return PUBLIC_KEYS.computeIfAbsent(location, PemKeyLoader::loadPublicKey);
}
private static RSAPublicKey loadPublicKey(String location) {
try {
String encoded = read(location)
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
PublicKey key = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(
Base64.getDecoder().decode(encoded)));
return (RSAPublicKey) key;
} catch (Exception exception) {
throw new IllegalStateException("Unable to load RSA public key", exception);
}
}
private static String read(String location) throws Exception {
if (location == null || location.isBlank()) {
throw new IllegalArgumentException("RSA key location is not configured");
}
if (location.contains("-----BEGIN ")) {
return location;
}
if (location.startsWith("classpath:")) {
String resource = location.substring("classpath:".length());
try (InputStream stream = Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream(resource)) {
if (stream == null) {
throw new IllegalArgumentException("Key resource not found");
}
return new String(stream.readAllBytes(), StandardCharsets.US_ASCII);
}
}
String file = location.startsWith("file:") ? location.substring(5) : location;
return Files.readString(Path.of(file), StandardCharsets.US_ASCII);
}
}

View File

@@ -0,0 +1,20 @@
package com.cygnus.cloud.security;
import java.util.LinkedHashMap;
import java.util.Map;
record TokenResponse(
String accessToken,
String tokenType,
long expiresIn,
String scope) {
Map<String, Object> asOAuthResponse() {
Map<String, Object> response = new LinkedHashMap<>();
response.put("access_token", accessToken);
response.put("token_type", tokenType);
response.put("expires_in", expiresIn);
response.put("scope", scope);
return response;
}
}

View File

@@ -0,0 +1,15 @@
package com.cygnus.cloud.system;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SystemConfiguration {
@Bean
Clock systemClock() {
return Clock.systemUTC();
}
}

View File

@@ -0,0 +1,29 @@
package com.cygnus.cloud.system;
import java.time.Clock;
import java.time.Instant;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/v1/system")
public class SystemInfoController {
private final Clock clock;
public SystemInfoController(Clock clock) {
this.clock = clock;
}
@GetMapping("/info")
public Mono<SystemInfoResponse> info() {
return Mono.just(new SystemInfoResponse(
"cygnus-cloud-service",
"1.0.0-SNAPSHOT",
Instant.now(clock)));
}
}

View File

@@ -0,0 +1,6 @@
package com.cygnus.cloud.system;
import java.time.Instant;
public record SystemInfoResponse(String service, String version, Instant timestamp) {
}

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

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