From d6dc33d9b176033da4ce27034363dbfc6306673e Mon Sep 17 00:00:00 2001 From: Narayanan Madaswamy Date: Sun, 26 Jul 2026 16:13:47 +0530 Subject: [PATCH] Multi tenant approach - cleanup done --- .vscode/launch.json | 3 +- ...Prem_to_Cloud_Authentication_Workflow.docx | Bin 10974 -> 12920 bytes cygnus-cloud-client/README.md | 26 +- cygnus-cloud-service/README.md | 52 ++-- .../identity/api/CloudLoginController.java | 10 + .../repository/IdentityRepository.java | 49 ++-- .../IdentityAuthenticationService.java | 17 +- .../cloud/security/AccessTokenIssuer.java | 7 + .../security/ClientAssertionValidator.java | 74 ++++-- .../CommunicationSecurityProperties.java | 12 +- .../security/MachineClientPrincipal.java | 7 + ...MachineSecurityConfigurationValidator.java | 28 --- .../security/MachineTokenController.java | 22 +- .../cygnus/cloud/security/PemKeyLoader.java | 3 + .../cloud/tenant/model/ClientAccount.java | 13 + .../tenant/model/ClientInstallation.java | 17 ++ .../cloud/tenant/model/ClientLicense.java | 24 ++ .../cloud/tenant/model/ClientStatus.java | 7 + .../cloud/tenant/model/LicenseStatus.java | 8 + .../TenantRegistrationRepository.java | 131 ++++++++++ .../service/TenantRegistrationService.java | 201 +++++++++++++++ .../src/main/resources/application.yml | 1 - .../db/identity/002_multitenant_licensing.sql | 153 ++++++++++++ .../003_tenant_isolation_constraints.sql | 231 ++++++++++++++++++ .../IdentityAuthenticationServiceTest.java | 31 ++- .../cloud/security/MachineTokenFlowTest.java | 78 +++++- .../cloud/tenant/model/ClientLicenseTest.java | 45 ++++ .../TenantRegistrationServiceTest.java | 57 +++++ .../matrix/nimble/SpringWebContextTest.java | 16 +- scripts/setup-local-communication.sh | 173 +++++++++---- 30 files changed, 1312 insertions(+), 184 deletions(-) create mode 100644 cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientAccount.java create mode 100644 cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientInstallation.java create mode 100644 cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientLicense.java create mode 100644 cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientStatus.java create mode 100644 cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/LicenseStatus.java create mode 100644 cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/repository/TenantRegistrationRepository.java create mode 100644 cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/service/TenantRegistrationService.java create mode 100644 cygnus-cloud-service/src/main/resources/db/identity/002_multitenant_licensing.sql create mode 100644 cygnus-cloud-service/src/main/resources/db/identity/003_tenant_isolation_constraints.sql create mode 100644 cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/model/ClientLicenseTest.java create mode 100644 cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/service/TenantRegistrationServiceTest.java diff --git a/.vscode/launch.json b/.vscode/launch.json index c253694..091ef49 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -29,8 +29,7 @@ "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", - "SPRING_CONFIG_ADDITIONAL_LOCATION": "file:${workspaceFolder}/config/clients.yml" + "CYGNUS_LOGIN_PRIVATE_KEY": "file:${workspaceFolder}/config/keys/login-private.pem" }, "shortenCommandLine": "argfile" }, diff --git a/Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx b/Cygnus_OnPrem_to_Cloud_Authentication_Workflow.docx index e3119de8c5b85ae04fad210ef5fbbe01534c1f93..b27c53e2e794fb113e0726ad928bb3b982ddbc93 100644 GIT binary patch delta 10622 zcmY+qMO2+l*R_iiWaIAc?oO}}+}+(F5ZrFu-60U%-Q6X)LvVN51b6tKw{gb!PIcy} z8f#W(c2=#rwltp1k(K43VXz<|Aih9^2G%1RLPCWGHrhj?LSlplzUW6wF0gjfKtQb0 zLqecKBsdZy*Be`=DO!a?Nr_lp2mzHJBQ%R3EJuEeGpLR;i-hwB5kRg1w)Eoz6@& z91DrEzkS_%e_nBtC-)#0nm-*hYH7`^8Trw;l2DnT0hN*>&-M)WR>0HCwmW@oWBbn)BtDBX>k~gGe#xX{=u(<-|+H80I7Q`88A41w;$q zK`NyLpv2AVpF%&02*@^l8o&vN&Z`)sUK39lSj&bBfL?CK#Ij_V8zY6DUOWZl5t$8X zE^p=ID4Cl_Q~BaF(4z_|NFkXE{1c$bhY3>+P)*%}d8eM2-$o~_Jaq1E;`#bZIZz9< z!zhvg=Avm>#eC|f3tXKPrD5$*4o3uSVpS@jpNe4KTn|b!9Ir1`ie~sr&(J?d+?kDB z#l_9%qPT_+x^WB9@KQl6Kksmd)INlL_k@zYW8h!YXe6y`X2~U#dSX z^oHD{=saWhHu|>Ipo(A`CVq5Z;jlNWbt$R`cTz!mIpGtc5KWX>e)JY!(Dh3uva=un z%YMneq9;y$x#%e9=~TaJOYE%iT92xfUCtNwYrrM7?)!Dr*++;ET-`Vl@!gKb+$;9m z*0sNwV^4%wNYkOMVN1%!20u~8jZe!o-kS!iH1*oJR zQGAPxujd5bd9w661APjkWqIzIekKHn|J6)mj37m>kXL$xZg4^pcr~_vPxV6wfisBr zSD#Cv(au0Xiwn%+5dC8m-BEM`(|dWFq)1iLUoqr$;*w`!CQt|azx#qlm*Rb-;r;;U zg(oEx@UH3?xc13^PHP+9SOfKtU7NJ58zQF69Tk!QlHeLV!!(5l&0pmHyVmt?iKva%8l;Tt@-Bta%U5$`C zJ7)d-FKAA-K)6m4X}(ObeH&=@_DaWop6N{#gL^dB{TN|*%hOo60?`waG>1r6pYuIg zB!oSz+wF+uctFLKKoXP!%v2kayi^=iFA9Vrd-|uo8DkToxx_)MxmI(XaTfBRVgfr? za8`SQNfD}4K~m4fz(Z6py*+2BqA1!6s6NYLA>!hBj@YHEp$8sb4M???5eulO#cC3D zjhkof<`xe2V4#4g0M`?g)=w6XBBIfl(Q}RXByp>!6yg2qQxJ;044Fg>*jAKy#F%Q# zKoS`cV(f>_47qSWe)M@s>Dr*jlfX-CB8Pisndh%@70vF`>I11_Q$O35KG8~2G#17G zMap1upS!?T`LT)Y1sEDL8QEQ99KU5x9W!!4%y4o;3NvB$A>$5(JVa5HLT6A*6~sUi z+02E@Ow>^q5cQf+DTEPUW8g@;L$8-ez0x;_FgzOd&bAXwR8_03#>1%?P4dxPI39e* zi*HL`uSI?gokt?8fI2v1($UQ*hQvzt)l8xKkw#rQJI98=22`!zn1<0rL{_oLx{?gB zI~^IOIb3yuC?A`RI$3J*TrKj*xomnNd|5k(GgM>006FZ=& zFx2j_U={XTt5C&7%~KDR*iW$`ilB(xiK|Mx!ls`|Fr*!m9AQYMy}*TT(ME{mRQU9K zz6ouMyh-E}0NU*bUu*%Nx9m|he*W}*bqg_`;f0~*>>F-s>9#hZhU@zRTxj_IpNNyR z1G#Ig@ru*KhaS>=1BNPD2Uq3wpYdMTsWHv2GP1WE3GpodtOTOKmngmRocd_#JZ|OY zQGA!bkEb2j;U|qtaZEyVe;{G?bB&jTi4x4aWw>^r0!5pQs;QH+ld}iE)kBhGEXbog zUFaaLb6Ih?Fcq2b;k~x2cv)phbn#oY7O6g?zkFqo&bZ)&U4xm+i2S}`WUcAs%J$$< zoy;eeR7Rv(W_%1P(|DC6ndoIPuW*G)g3{q-O6-t$x|y-ca7P^Yx;F?TA>TKyb6d}^ zmTeVf55%RFD1>oD%`aBz@m%X>5T;7GK!uybr4bW|WO?v?@yfB161AEjStxmkIy!_q zeq$51VpPS!KRFMkgZqGA{x2(ax}}MrBGI#lB|cCR{l(Aeb2o%M$xbdj*>0^N%5pp8 z!(*|mg8AyN1D5T%e~yX185c5LQ)j%oR(g5V0Y}b!vt0D~&beh;GgEgMye2-ClcQ!1 zzINXXcU%Z$>zuS@cLrX-XV3U(>C+$Jv$G8YQy#X2j=WzM6?kzKPCN1G$9p4G=Cvi@ z9G7L`i@q$A?d&gw#u>2tBL?SI7Kpd(GS35kD>-(H2jlEF$Lev~`Q|7|U#`;nTf_Ef z0QG>x_3_e=cZqmz6a4t)%a$vrrzJLEBjFV%ohM;iD^arZJ-;@O{4p#W#whR>(ahDS zP-`~92PuZoFwiY0FIVB0gE8}mJc`)ZcnPSTm-exC?~T3YhkO6~^%OSiwWZfp+^@wO zo?)jDnG4rNqfrwAm#gm}Np5%p^ASNyU>nkI7c}J|bsJSWHQ!b5B@&UYCCZKZ%elsF zEJsDO)r2Mb)Zw7$XA`zF%Q9{uTd zqWdZ({5Bfp1)Lp>_e8CYnR4FfNz(f8VO{(4mOx?8=4>?m zRShTb$slU!ekxU+V2>TCF1l*qI4HC_v0X_UW@ZNB-YsW_P+T`|#{I}<#rFH&_dW3& zkOGl{{DZUvdckNdDR0P5@!V|SgpE}an?Sj2G+sz7{|i4-dD^B-PX6SjQm>(z@FsoE z(?o^hWZKmaY@(6PthY3Mk_xFqYUsu!#2dLtAn%Ir17wZ#i;yV%5n-?;=Q zR!;aN@*2sJaf5?F>=iQ;Vp)i~oyc;;zlo48qwLG1X^th6$hcpI(t@r_)^&Bg1943v zlwT>#VqqK`tn;OQvqL!swdQ8v--sI-(rM`X3wME>no!P57N!e;5`Qr(`a`EX7;v7N z7$iYCLXqZ)+~p3RU&?WYSt{12_7PS80h2sugER?Q-K62grjaWDhwOQeK9VAAYUoC^ z4SICoqpV835*gjZxvzQir>2KqJHALgvUbhVARebJ${gCJzJ?RL%wnK32bWs{(N`E&kKK4D#h3<7@EC_u1yj_z z;LRj`nw0LWrue0ME-%WApCUqCi*_ za&m+Uh{oB)w48S89<`C6+`pn8kle+5px`mT9@}#jHk6V|Bl^ZcN>n0-6LU}Qd?6vy zdO7W|-yXibec-_2@AO?Dy-0~o>QDKa1DnWNRf-;~w2aDR##_?{Pw!;}CF;MxufEHU zOAQOOdNK8Y7!*TK{YQw7AP$emx zh_>ST38AEXWWiT)o}5Yg9=84i>~(tI!U{yy-h6S`3lM#{c-o8Yr$if z%y>3*Hs2u9QE}hMm^UO7yj97{%EpOz4h&dp{d@wX5TY}w_Cf5ZCM}CNZAXj;7iG8(Ia);{UiC^qwVJfpSn=@2z|ls7wNT2fAYV7u);uaZ z>^rBsDfi;FhbjKA^N+VPGDad6cEhh!DMKfL^*S6p!W zsxaJ3Y%lSzs_!G#>$5(}X^R{Ex^>0h%aLI&_ltFn^kAdr&f9-sM;@s!uL_Yb#As>( zEIjpEYALBo3Zxr1af|%8mER7;%ko)!>JNdA1Vs3xr z$gCStI}`*>sv-JVgFT}q_O{QtJ5V<1cilb>rS%sHY3o0&U}?sxqw1(JCNG_Q{!LEE z9hVyrJa|fnpwZsoc_=dzH(8L0pA*jk^i*mcs&&8HBn=+=q*kw?Fv9XPPo=V|$WYcZ zL~cr1|Pd?t`JP8gUae5aChxzD?k&`!KclH=UXB^Te164h&oGbp$K8s5vgF|J2w^m6G zDGCqs#?;dgH;3||;mZnKS#>hPpyDH$=B-$6o=srtc}fbb+A(ql@i}NKnE7WtHdeW}-?N9Rt`s!ZtB;rBy}(Uo}bg zTNsQa6y7cE5%Pu*U+2?P!?`{2hw^6#>jB>`{e}#Q-AEQ!CS_ElhA)K&K4yC6i9MFEJaJ0^=;J6_Kc;z* zYk;W$tpbCR4@(Xy4V6ohsoWDZ=hd1r3a>4hq1tc3dSav(lgn_nLhQM)&frnIhr7XV zP-kzFUk@^0EF8ky88*`kRfjjp+MW`R4^k)Sv}Ot(N-GM6EP|WxB(y+W(L@Zg_#s=d=lHY$bF zoAU)QW4S?_d~{!}PV;`2HnYLTYYP7oIkq_D#r>n%{U^K9zOM+hhr9Zv3j5R_r??$# zn~7^9sA;^{D&|Lk8;Kq5EwgzojTOTHQ5s=autC? zBBnMuFcvygcHn$HTl(=c>oeLB*BSmyRZT>J8Jc*7zm#GWNTJ4%Wzz(@H73~KlA>VR zof_|CA$=a6JZC~9ZZpD`7z%g45vR4Q9q@X&K_2WyAilR(K7)&>NF-E7hz$-t8_e+UACu-F25>J?q&rZOf| z4;Z@NG&q`mfiy@MEgA%s^oHtbwv8w)T0=diCCWu%mmOPWjiF~6M`o3Z3WHx;vGh{? z74OluCg(pE&2MpuuaPgXnfjW)Cn+dLU|UJx#J#h;u#E!d5)DZUkra@`^ic>rwp-x? z{N~i1Kf=zl)*o0&f~Sd))796l>b@BaaH@4eOMdTm6B5?UTe7I1UOh=r8!FJ z*o?OE;478IF~PiCS5&FDrjq{s1wMIOyuS(&c5J{Va3OFJ`LlxIy>vt-P(?wjT#OrP z0{z2Sqt6(~#_;~KP$v&j!y)^N!(h*;PTsc6dSe~dRKi81e-G7)`p0lMM06M6z*p?3 z9B8+<-qvA-MEpMET)f~s#m{G)+LTYHa%YoS8({Y%aZRVNEPlx17EW{ie)*!F^A2UY z=Yg*T5BjBra=;Aaz-zM3V*LBIBy?&Gdf$}&CzAE z5<$sX$2qN}6e)he|Z9QTZ zGE4_Zh%4w>nK&rnaP8N#fC@vpk(DFVphOj5P)}65rJN!cBHX_9J8_C*d5DrvZ#?;F zzGs6jy&oZ$Tga|IysLu5UceQR)lD&^f`9732@VOxa-L&IcC2RXFi=v&#!;{mD^MF> zb;G^D_G54lNcU9yx|sFjfSLt8-B@4Be3cd8@zJ0OGA1k~cmbxl|coS=^Rl4oj< zA!nWMXDhH$7{@2&zAI*b7nZZ+h9;?l32k=kuLR={FWp##5PlW7MzL`yc zmSm?gzgxZf*zBlL>8}*7oi_~DxYIS`kP4%nwf*I4KYZ}7ul_X{O!jO7;q{L6adH%N zqwFOqo${FWEy;JK)h<)*lp8}ZS~<>SXqa1!rY3e@c5y!gUw9JK{fCMr;~A$zR?!yt zzNQH1#&U3;&mB=pwzD@#IL;<9=6h!VQ_B4;TZ7CmL`S2yO=AsBu;|ftX+hM3S6QI- zik`$9YtyDe_wgSN=ch4uMcv3p;%!;*8GeqX*MZ&=A8y>hjE3H?u8!N}V`lN<5$Qi6 z<=9FxnP!%fn-i*fdyy#ujr>ylDqT>2896daZM}K zYtJ_%qWO%%)9MePE(f=(w{7>AV|-wOdR($Rd2(zpeet{|zH@2QTKpa(&@%%ki)6nR z&7@a}5;zR@E3)g!Oo-y<4Neg!5XT#IxwT3)^&=O`!#P8PjMKJ0zDF01=(bL6uP|=mgkJnDU;$=?)OS zm^mWxme#Y;;kaWUm)?^gw6XIAA+_XdHO0_B(;WXEuar3GJxJe;?a=t};gW8+`hDdc z>_7kZfbqqWz}1I=)P58nqOD-)uKaXe049WC#D zezbe^o%bvHbj`MMSTImBrt_lTu${<7{FPkJBc`~|nVE-($7bcMgv?#YIIF%_3Bb3! zsm9e+7;5>UVnzeXrX3V`K3Xq1SWZxXs~3cFwzZuMQARaMeLGW2N>@k|4Xp{e#ToR? zx@Q$4^U9iJWXfD^6vnY|*0XH7b<&qDVKKK{!|uhJy)g{-v#>Ed>3^QSwrT3p1h{-e z-)}R-X#T;vc{gbj#rJ)W^*LDIlsHw7zX9w!|gC1 zAl%{xfzS2W@tV_Ps}P5Ok6`$7kX+tF4oNJ2mjnQAzT6vp!r@!Yrc{PVM^WYy$a_Q* zluScFx3IL5R&UIrXoFs}W3SPdTFvK5NW8xy+}zv&^_E(`*TG0Qm?cJ@cNnz?jx!F8FU!xSwy88C=>J=A~POyBrJi)(%=d zIA>cPPX0yWX?c47lMC~YKwfOwncd!GlL$D3b@>97CSy3c#=+TI$wb$6EI(xWJ8Rp; zNxHKIi(_F(brLR*HldQVk3*k`@ZivmEu^#+p53ToIR7gVCG4h5sJly=$zX*}PU!)p z7rlBxc|-8ViieX;mvYTEt)`1c-`^7CzlX5M3W6xn=w+sN8|wZ2X=FkCRwBpY7)Jo! z3Gsw*EhiUq+kLnOlS{_e0w?npi!gs%IA^$qDx+AL3LBE>?H6=u9IE3zW`(z?`y*J zXT-2p@vN=3A(N+P`Et9!dvNA-yoP=ZraXCzG|OV_GXebJ)#C-^OJ)!CS*KHgE_mJU z<6F8*#SO0XgWI#xqXSOp(=pmd==$w~u&?J8r05P!cxNi%k&t-qr_;!J`VjDC8y3Ft z?=MWNWvkkj+22!-=pGa39-KATr@k@yV*^G)KmI2A3WnsSS9bOZw|18gx^vz*9}nJ9 z^Gl=E^o6T;C_r_RwJ$7N35|4Seezq;x^qT~1reh@K-tZTu$Y|JfDIJxEo|>aQ^DR< zOL`WsDZ*L%h26szou<^6_&R`--JGGSJ0FeErDxu;e)VAjqGRT$|A%FxWvkrux^5-w zB1zIs6&&aLTxlD8=jLqF>Jcel34)(V{XVOu-M*;(XzAve!qK{AwD&6CSICeIKCD#d zZcq3ZCtof|xV+lZ|VC5{8y+DH}~ne^z-?-lyq0r#Av+h=ZDxcj%X&Q0vj+F znVo$tnwrcjgRTE#@a#lxeu44tdSw}g=~2k=GsR&lsditZ(E9cr4||O&=6>I0yG_j} zV*DeZPVc9^Y?Ml$Of%&WesATbs!2MSwnC7yon+_qOpn8wHwf64SJT5CEqc8s=@v+r zIZHOs9Y>Ho6pObQdB4%Aku=IGO6a≪|f>lwzrl*FCyuh1Uu=TGutJ~! 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 diff --git a/cygnus-cloud-client/README.md b/cygnus-cloud-client/README.md index 7bcb53d..38aef20 100644 --- a/cygnus-cloud-client/README.md +++ b/cygnus-cloud-client/README.md @@ -29,18 +29,24 @@ It is valid for one year; the access token obtained with it is short-lived. For local development, the repository setup script automates prerequisite checks, the full Maven verification, directory creation, all three cloud key -pairs, the installation key pair, `config/clients.yml`, and the encrypted -machine assertion: +pairs, the installation key pair, database-backed tenant/install registration, +an initial license, and the encrypted machine assertion: ```bash ./scripts/setup-local-communication.sh ``` -The script interactively asks for the customer identifier, installation -identifier, cloud URL, and whether to run the full verification. Customer and -installation identifiers cannot contain spaces; the customer identifier is -used for its directory and signing-key filenames. New customers are appended -to `config/clients.yml` without replacing existing customers. +The script interactively asks for the customer name and slug, installation +identifier, cloud URL, database connection, license package/type/duration, and +whether to run the full verification. Customer and installation identifiers +cannot contain spaces. The slug is the stable tenant key and is used for its +directory and signing-key filenames. + +The client account, installation public key, allowed scopes, and license are +upserted into PostgreSQL (`identity.client_account`, +`identity.client_installation`, and `identity.client_license`). The cloud +service resolves this registration dynamically through Redis with PostgreSQL +fallback, so adding another customer does not require a cloud restart. It preserves existing private keys and assertions. Set `CYGNUS_SETUP_FORCE_ASSERTION=true` only when the assertion needs to be @@ -65,6 +71,6 @@ mvn -pl cygnus-cloud-client exec:java \ client-signing-private.pem cloud-assertion-public.pem machine-assertion.jwt" ``` -Copy only `client-signing-public.pem` into that customer's cloud-side client -configuration. Keep the private key and generated assertion on the on-premises -server with owner-only filesystem permissions. +The setup script stores `client-signing-public.pem` in the installation record +used by the cloud. Keep the private key and generated assertion only on the +on-premises server with owner-only filesystem permissions. diff --git a/cygnus-cloud-service/README.md b/cygnus-cloud-service/README.md index 4c5028d..8c7388d 100644 --- a/cygnus-cloud-service/README.md +++ b/cygnus-cloud-service/README.md @@ -53,10 +53,10 @@ on-premises gateway. The client assertion must be: - bound to the configured client ID, installation ID, and token audience; - unexpired and no longer-lived than `CYGNUS_ASSERTION_TTL`. -The endpoint returns a short-lived RS256 access token carrying `client_id`, -`installation_id`, and the approved scope. The identity endpoint requires the -`identity.login` scope and verifies the same machine binding in the encrypted -login payload. +The endpoint returns a short-lived RS256 access token carrying the client, +installation, tenant, license, security-version, and approved-scope claims. +The identity endpoint requires the `identity.login` scope and verifies the +same machine and tenant binding in the encrypted login payload. Generate separate cloud key pairs: @@ -73,26 +73,28 @@ openssl pkey -in config/keys/access-token-private.pem -pubout \ chmod 600 config/keys/*private.pem ``` -Configure clients in an external Spring YAML file rather than the packaged -`application.yml`: +## Dynamic tenant, installation, and license registration -```yaml -cygnus: - security: - enabled: true - issuer-uri: https://cloud.example.com - audience: cygnus-cloud-api - token-audience: https://cloud.example.com/oauth2/token - clients: - customer-a: - enabled: true - installation-id: site-01 - assertion-public-key: file:/secure/clients/customer-a/public.pem - scopes: - - identity.login -``` +Machine clients are no longer configured in a runtime `clients.yml`. The +authoritative records are: -Start with that protected file using -`--spring.config.additional-location=file:/secure/cygnus/clients.yml`. -Never place cloud private keys, customer assertions, or installation private -keys in the repository or container image. +- `identity.client_account`: tenant identity and status; +- `identity.client_installation`: machine identity, assertion public key, + allowed scopes, enabled state, and security version; +- `identity.client_license`: subscription period, package, type, status, and + licensed limits. + +Token issuance resolves the installation and active license through a +Redis cache-aside service with PostgreSQL fallback. Cache entries have a +bounded TTL and can be invalidated after administrative changes. Therefore, +new customers, installations, key rotations, scope changes, and license +changes do not require restarting the cloud service. + +The login/menu queries are tenant-scoped. Tenant-owned identity tables carry +`tenant_id`; `identity.pages` remains the shared feature catalog while +permissions are assigned per tenant. + +Use `scripts/setup-local-communication.sh` to create keys, register or update +the database records, create the initial license, and generate the on-premises +machine assertion. Never place cloud private keys, customer assertions, or +installation private keys in the repository or container image. diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java index 277e51b..670b728 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/api/CloudLoginController.java @@ -7,6 +7,7 @@ import com.cygnus.cloud.identity.service.LoginRequestReplayService; import jakarta.validation.Valid; import java.time.Clock; import java.time.Duration; +import java.util.UUID; import org.springframework.util.StringUtils; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -64,12 +65,21 @@ public class CloudLoginController { return Mono.error(new AuthenticationException("Login request replayed")); } return authenticationService.authenticate( + tenantId(machineJwt), payload.loginId(), payload.password(), remoteAddress(serverRequest)); }); } + private UUID tenantId(Jwt jwt) { + try { + return UUID.fromString(jwt.getClaimAsString("tenant_id")); + } catch (RuntimeException exception) { + throw new AuthenticationException("Machine tenant is invalid"); + } + } + private void validatePayload(LoginPayload payload) { if (payload == null || !StringUtils.hasText(payload.loginId()) diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/repository/IdentityRepository.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/repository/IdentityRepository.java index ba6a6e7..0db0597 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/repository/IdentityRepository.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/repository/IdentityRepository.java @@ -8,6 +8,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.UUID; import org.springframework.stereotype.Repository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -20,25 +21,32 @@ public class IdentityRepository { g.name AS group_name, u.branch_id, b.branchname, b.branchcode, b.city, u.company_id, c.companyname, c.companycode, u.isactive FROM identity.app_user u - JOIN identity.user_group g ON g.group_id = u.group_id - JOIN identity.company c ON c.company_id = u.company_id + JOIN identity.user_group g + ON g.tenant_id = u.tenant_id AND g.group_id = u.group_id + JOIN identity.company c + ON c.tenant_id = u.tenant_id AND c.company_id = u.company_id JOIN identity.company_branch b - ON b.branch_id = u.branch_id AND b.company_id = u.company_id - WHERE upper(u.loginid) = upper($1) + ON b.tenant_id = u.tenant_id + AND b.branch_id = u.branch_id + AND b.company_id = u.company_id + WHERE u.tenant_id = $1 + AND upper(u.loginid) = upper($2) """; private static final String FIND_MENU = """ SELECT p.page_id, p.menulabel, p.targeturl, p.parentpage, p.pageorder, permissions.permission, p.targetwindow, permissions.requestval - FROM identity.permission permissions + FROM identity.permission permissions JOIN identity.pages p ON p.page_id = permissions.page_id - WHERE permissions.group_id = $1 + WHERE permissions.tenant_id = $1 + AND permissions.group_id = $2 AND p.isvisible = 1 AND permissions.permission <> '000' AND NOT EXISTS ( SELECT 1 FROM identity.denied_pages denied - WHERE denied.user_id = $2 + WHERE denied.tenant_id = permissions.tenant_id + AND denied.user_id = $3 AND denied.page_id = permissions.page_id AND denied.isdenied = 1 ) @@ -47,8 +55,8 @@ public class IdentityRepository { private static final String RECORD_LOGIN = """ INSERT INTO identity.user_loginhistory - (loginid, logintime, ipaddr, user_id) - VALUES ($1, $2, $3, $4) + (tenant_id, loginid, logintime, ipaddr, user_id) + VALUES ($1, $2, $3, $4, $5) RETURNING uid """; @@ -60,23 +68,34 @@ public class IdentityRepository { this.mapper = mapper; } - public Flux findUsersByLoginId(String loginId) { - return database.preparedQuery(FIND_USER, Tuple.of(loginId)) + public Flux findUsersByLoginId(UUID tenantId, String loginId) { + return database.preparedQuery(FIND_USER, Tuple.of(tenantId, loginId)) .flatMapMany(rows -> Flux.fromIterable(rows).map(mapper::user)); } - public Mono> findMenu(short groupId, short userId) { - return database.preparedQuery(FIND_MENU, Tuple.of(groupId, userId)) + public Mono> 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 recordLogin( - String loginId, Instant loginTime, String remoteAddress, short userId) { + UUID tenantId, + String loginId, + Instant loginTime, + String remoteAddress, + short userId) { LocalDateTime databaseTime = LocalDateTime.ofInstant(loginTime, ZoneOffset.UTC); return database.preparedQuery( RECORD_LOGIN, - Tuple.of(loginId, databaseTime, remoteAddress, userId)) + Tuple.of( + tenantId, + loginId, + databaseTime, + remoteAddress, + userId)) .map(rows -> rows.iterator().next().getLong("uid")); } } diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/service/IdentityAuthenticationService.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/service/IdentityAuthenticationService.java index 3363b03..1eac450 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/service/IdentityAuthenticationService.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/identity/service/IdentityAuthenticationService.java @@ -5,6 +5,7 @@ import com.cygnus.cloud.identity.model.IdentityUser; import com.cygnus.cloud.identity.repository.IdentityRepository; import java.time.Clock; import java.time.Instant; +import java.util.UUID; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; @@ -25,8 +26,11 @@ public class IdentityAuthenticationService { } public Mono authenticate( - String loginId, String password, String remoteAddress) { - return repository.findUsersByLoginId(loginId) + UUID tenantId, + String loginId, + String password, + String remoteAddress) { + return repository.findUsersByLoginId(tenantId, loginId) .collectList() .flatMap(users -> { if (users.isEmpty()) { @@ -49,9 +53,14 @@ public class IdentityAuthenticationService { return Mono.error(new AuthenticationException("Invalid credentials")); } Instant loginTime = clock.instant(); - return repository.findMenu(user.groupId(), user.userId()) + return repository.findMenu( + tenantId, user.groupId(), user.userId()) .flatMap(menu -> repository.recordLogin( - user.loginId(), loginTime, remoteAddress, user.userId()) + tenantId, + user.loginId(), + loginTime, + remoteAddress, + user.userId()) .thenReturn(toAuthenticatedIdentity(user, loginTime, menu))); }); } diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/AccessTokenIssuer.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/AccessTokenIssuer.java index 3a1bcb4..c9c52ce 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/AccessTokenIssuer.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/AccessTokenIssuer.java @@ -36,6 +36,13 @@ class AccessTokenIssuer { .jwtID(UUID.randomUUID().toString()) .claim("client_id", principal.clientId()) .claim("installation_id", principal.installationId()) + .claim("installation_uuid", + principal.internalInstallationId().toString()) + .claim("tenant_id", principal.tenantId().toString()) + .claim("license_id", principal.licenseId().toString()) + .claim("license_type", principal.licenseType()) + .claim("package_code", principal.packageCode()) + .claim("security_version", principal.securityVersion()) .claim("scope", String.join(" ", scopes)) .build(); SignedJWT jwt = new SignedJWT( diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/ClientAssertionValidator.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/ClientAssertionValidator.java index 7b1af5b..5f0b9ff 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/ClientAssertionValidator.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/ClientAssertionValidator.java @@ -7,29 +7,33 @@ import com.nimbusds.jose.crypto.RSADecrypter; import com.nimbusds.jose.crypto.RSASSAVerifier; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.model.ClientLicense; +import com.cygnus.cloud.tenant.service.TenantRegistrationService; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.Date; import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; @Component class ClientAssertionValidator { private final CommunicationSecurityProperties properties; + private final TenantRegistrationService registrations; private final Clock clock; - ClientAssertionValidator(CommunicationSecurityProperties properties, Clock clock) { + ClientAssertionValidator( + CommunicationSecurityProperties properties, + TenantRegistrationService registrations, + Clock clock) { this.properties = properties; + this.registrations = registrations; this.clock = clock; } - MachineClientPrincipal validate(String clientId, String encryptedAssertion) { - CommunicationSecurityProperties.MachineClient client = - properties.clients() == null ? null : properties.clients().get(clientId); - if (client == null || !client.enabled()) { - throw invalid(); - } + Mono validate(String clientId, String encryptedAssertion) { try { JWEObject jwe = JWEObject.parse(encryptedAssertion); if (!JWEAlgorithm.RSA_OAEP_256.equals(jwe.getHeader().getAlgorithm()) @@ -40,25 +44,65 @@ class ClientAssertionValidator { PemKeyLoader.privateKey(properties.assertionDecryptionPrivateKey()))); SignedJWT signedJwt = SignedJWT.parse(jwe.getPayload().toString()); - if (!signedJwt.verify(new RSASSAVerifier( - PemKeyLoader.publicKey(client.assertionPublicKey())))) { + JWTClaimsSet claims = signedJwt.getJWTClaimsSet(); + String installationCode = claims.getStringClaim("installation_id"); + if (installationCode == null || installationCode.isBlank()) { throw invalid(); } + return registrations.findInstallation(clientId, installationCode) + .switchIfEmpty(Mono.error(invalid())) + .flatMap(installation -> registrations + .findCurrentLicense(installation.tenantId(), clock.instant()) + .filter(license -> license.isActiveAt(clock.instant())) + .switchIfEmpty(Mono.error(new MachineAuthenticationException( + "Client license is not active"))) + .map(license -> verify( + clientId, signedJwt, claims, installation, license))) + .onErrorMap( + exception -> !(exception instanceof MachineAuthenticationException), + exception -> new MachineAuthenticationException( + "Invalid client assertion", exception)); + } catch (MachineAuthenticationException exception) { + return Mono.error(exception); + } catch (Exception exception) { + return Mono.error(new MachineAuthenticationException( + "Invalid client assertion", exception)); + } + } - JWTClaimsSet claims = signedJwt.getJWTClaimsSet(); - validateClaims(clientId, client, claims); + private MachineClientPrincipal verify( + String clientId, + SignedJWT signedJwt, + JWTClaimsSet claims, + ClientInstallation installation, + ClientLicense license) { + try { + if (!signedJwt.verify(new RSASSAVerifier( + PemKeyLoader.publicKey(installation.assertionPublicKey())))) { + throw invalid(); + } + validateClaims(clientId, installation, claims); return new MachineClientPrincipal( - clientId, client.installationId(), client.scopes()); + clientId, + installation.installationCode(), + installation.tenantId(), + installation.installationId(), + license.licenseId(), + license.licenseType(), + license.packageCode(), + installation.securityVersion(), + installation.allowedScopes()); } catch (MachineAuthenticationException exception) { throw exception; } catch (Exception exception) { - throw new MachineAuthenticationException("Invalid client assertion", exception); + throw new MachineAuthenticationException( + "Invalid client assertion", exception); } } private void validateClaims( String clientId, - CommunicationSecurityProperties.MachineClient client, + ClientInstallation installation, JWTClaimsSet claims) throws Exception { Instant now = clock.instant(); Date issuedAt = claims.getIssueTime(); @@ -66,7 +110,7 @@ class ClientAssertionValidator { if (!clientId.equals(claims.getIssuer()) || !clientId.equals(claims.getSubject()) || !claims.getAudience().contains(properties.tokenAudience()) - || !client.installationId().equals( + || !installation.installationCode().equals( claims.getStringClaim("installation_id")) || issuedAt == null || expiresAt == null diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CommunicationSecurityProperties.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CommunicationSecurityProperties.java index 57acada..55966d9 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CommunicationSecurityProperties.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/CommunicationSecurityProperties.java @@ -1,8 +1,6 @@ package com.cygnus.cloud.security; import java.time.Duration; -import java.util.Map; -import java.util.Set; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -16,13 +14,5 @@ public record CommunicationSecurityProperties( Duration accessTokenTtl, String assertionDecryptionPrivateKey, String accessTokenPrivateKey, - String accessTokenPublicKey, - Map clients) { - - public record MachineClient( - boolean enabled, - String installationId, - String assertionPublicKey, - Set scopes) { - } + String accessTokenPublicKey) { } diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineClientPrincipal.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineClientPrincipal.java index 4a2e3d3..0bb2b2f 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineClientPrincipal.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineClientPrincipal.java @@ -1,9 +1,16 @@ package com.cygnus.cloud.security; import java.util.Set; +import java.util.UUID; record MachineClientPrincipal( String clientId, String installationId, + UUID tenantId, + UUID internalInstallationId, + UUID licenseId, + String licenseType, + String packageCode, + int securityVersion, Set allowedScopes) { } diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineSecurityConfigurationValidator.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineSecurityConfigurationValidator.java index 288e0ed..873b47a 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineSecurityConfigurationValidator.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineSecurityConfigurationValidator.java @@ -2,7 +2,6 @@ package com.cygnus.cloud.security; import java.net.URI; import java.time.Duration; -import java.util.Map; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; @@ -34,33 +33,6 @@ class MachineSecurityConfigurationValidator implements InitializingBean { requireText("access-token-private-key", properties.accessTokenPrivateKey()); requireText("access-token-public-key", properties.accessTokenPublicKey()); - Map clients = - properties.clients(); - if (clients == null || clients.isEmpty()) { - throw invalid("at least one machine client is required"); - } - clients.forEach(this::validateClient); - } - - private void validateClient( - String clientId, - CommunicationSecurityProperties.MachineClient client) { - requireText("clients.", clientId); - if (client == null) { - throw invalid("client '" + clientId + "' has no configuration"); - } - requireText( - "clients." + clientId + ".installation-id", - client.installationId()); - requireText( - "clients." + clientId + ".assertion-public-key", - client.assertionPublicKey()); - if (client.scopes() == null - || client.scopes().isEmpty() - || client.scopes().stream().anyMatch(this::isBlank)) { - throw invalid( - "clients." + clientId + ".scopes must contain valid scopes"); - } } private void requireUri(String name, String value) { diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineTokenController.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineTokenController.java index beff30a..ac253f5 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineTokenController.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/MachineTokenController.java @@ -33,23 +33,27 @@ class MachineTokenController { consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) Mono> token(ServerWebExchange exchange) { - return exchange.getFormData().map(this::issueToken); + return exchange.getFormData().flatMap(this::issueToken); } - Map issueToken(MultiValueMap form) { + Mono> issueToken(MultiValueMap form) { if (!CLIENT_CREDENTIALS.equals(form.getFirst("grant_type")) || !ASSERTION_TYPE.equals(form.getFirst("client_assertion_type"))) { throw new MachineAuthenticationException("Unsupported token request"); } String clientId = required(form, "client_id"); - MachineClientPrincipal principal = assertionValidator.validate( - clientId, required(form, "client_assertion")); Set requestedScopes = scopes(form.getFirst("scope")); - if (requestedScopes.isEmpty() - || !principal.allowedScopes().containsAll(requestedScopes)) { - throw new MachineAuthenticationException("Invalid requested scope"); - } - return tokenIssuer.issue(principal, requestedScopes).asOAuthResponse(); + return assertionValidator.validate( + clientId, required(form, "client_assertion")) + .map(principal -> { + if (requestedScopes.isEmpty() + || !principal.allowedScopes().containsAll(requestedScopes)) { + throw new MachineAuthenticationException( + "Invalid requested scope"); + } + return tokenIssuer.issue(principal, requestedScopes) + .asOAuthResponse(); + }); } private String required(MultiValueMap form, String name) { diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/PemKeyLoader.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/PemKeyLoader.java index 3fb607f..e636be2 100644 --- a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/PemKeyLoader.java +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/security/PemKeyLoader.java @@ -66,6 +66,9 @@ final class PemKeyLoader { if (location == null || location.isBlank()) { throw new IllegalArgumentException("RSA key location is not configured"); } + if (location.contains("-----BEGIN ")) { + return location; + } if (location.startsWith("classpath:")) { String resource = location.substring("classpath:".length()); try (InputStream stream = Thread.currentThread() diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientAccount.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientAccount.java new file mode 100644 index 0000000..e2e8109 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientAccount.java @@ -0,0 +1,13 @@ +package com.cygnus.cloud.tenant.model; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public record ClientAccount( + UUID tenantId, + String clientSlug, + String clientName, + ClientStatus status, + OffsetDateTime createdAt, + OffsetDateTime updatedAt) { +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientInstallation.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientInstallation.java new file mode 100644 index 0000000..79ea093 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientInstallation.java @@ -0,0 +1,17 @@ +package com.cygnus.cloud.tenant.model; + +import java.time.OffsetDateTime; +import java.util.Set; +import java.util.UUID; + +public record ClientInstallation( + UUID installationId, + UUID tenantId, + String clientId, + String installationCode, + String assertionPublicKey, + Set allowedScopes, + boolean enabled, + int securityVersion, + OffsetDateTime lastAuthenticatedAt) { +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientLicense.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientLicense.java new file mode 100644 index 0000000..b6306dd --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientLicense.java @@ -0,0 +1,24 @@ +package com.cygnus.cloud.tenant.model; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.UUID; + +public record ClientLicense( + UUID licenseId, + UUID tenantId, + String licenseType, + String packageCode, + Instant validFrom, + Instant validUntil, + LicenseStatus status, + Integer maxUsers, + Integer maxInstallations, + OffsetDateTime updatedAt) { + + public boolean isActiveAt(Instant instant) { + return status == LicenseStatus.ACTIVE + && !instant.isBefore(validFrom) + && instant.isBefore(validUntil); + } +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientStatus.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientStatus.java new file mode 100644 index 0000000..bdac3e0 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/ClientStatus.java @@ -0,0 +1,7 @@ +package com.cygnus.cloud.tenant.model; + +public enum ClientStatus { + ACTIVE, + SUSPENDED, + CANCELLED +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/LicenseStatus.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/LicenseStatus.java new file mode 100644 index 0000000..f01c42f --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/model/LicenseStatus.java @@ -0,0 +1,8 @@ +package com.cygnus.cloud.tenant.model; + +public enum LicenseStatus { + ACTIVE, + SUSPENDED, + EXPIRED, + CANCELLED +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/repository/TenantRegistrationRepository.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/repository/TenantRegistrationRepository.java new file mode 100644 index 0000000..9ef1bd3 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/repository/TenantRegistrationRepository.java @@ -0,0 +1,131 @@ +package com.cygnus.cloud.tenant.repository; + +import com.cygnus.cloud.database.ReactiveDatabaseClient; +import com.cygnus.cloud.tenant.model.ClientAccount; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.model.ClientLicense; +import com.cygnus.cloud.tenant.model.ClientStatus; +import com.cygnus.cloud.tenant.model.LicenseStatus; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.Tuple; +import java.time.Instant; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Mono; + +@Repository +public class TenantRegistrationRepository { + + private static final String FIND_ACCOUNT_BY_SLUG = """ + SELECT tenant_id, client_slug, client_name, status, created_at, updated_at + FROM identity.client_account + WHERE client_slug = $1 + """; + + private static final String FIND_INSTALLATION = """ + SELECT installation.installation_id, installation.tenant_id, + installation.client_id, installation.installation_code, + installation.assertion_public_key, installation.allowed_scopes, + installation.enabled, installation.security_version, + installation.last_authenticated_at + FROM identity.client_installation installation + JOIN identity.client_account account + ON account.tenant_id = installation.tenant_id + WHERE installation.client_id = $1 + AND installation.installation_code = $2 + AND installation.enabled = true + AND account.status = 'ACTIVE' + """; + + private static final String FIND_CURRENT_LICENSE = """ + SELECT license_id, tenant_id, license_type, package_code, + valid_from, valid_until, status, max_users, max_installations, + updated_at + FROM identity.client_license + WHERE tenant_id = $1 + AND valid_from <= $2 + AND valid_until > $2 + ORDER BY + CASE status + WHEN 'ACTIVE' THEN 0 + WHEN 'SUSPENDED' THEN 1 + ELSE 2 + END, + valid_until DESC + LIMIT 1 + """; + + private final ReactiveDatabaseClient database; + + public TenantRegistrationRepository(ReactiveDatabaseClient database) { + this.database = database; + } + + public Mono findAccountBySlug(String clientSlug) { + return database.preparedQuery(FIND_ACCOUNT_BY_SLUG, Tuple.of(clientSlug)) + .flatMap(rows -> first(rows, this::account)); + } + + public Mono findInstallation( + String clientId, String installationCode) { + return database.preparedQuery( + FIND_INSTALLATION, Tuple.of(clientId, installationCode)) + .flatMap(rows -> first(rows, this::installation)); + } + + public Mono findCurrentLicense(UUID tenantId, Instant instant) { + return database.preparedQuery( + FIND_CURRENT_LICENSE, Tuple.of(tenantId, instant.atOffset(java.time.ZoneOffset.UTC))) + .flatMap(rows -> first(rows, this::license)); + } + + private Mono first( + Iterable rows, java.util.function.Function mapper) { + java.util.Iterator iterator = rows.iterator(); + return iterator.hasNext() ? Mono.just(mapper.apply(iterator.next())) : Mono.empty(); + } + + private ClientAccount account(Row row) { + return new ClientAccount( + row.getUUID("tenant_id"), + row.getString("client_slug"), + row.getString("client_name"), + ClientStatus.valueOf(row.getString("status")), + row.getOffsetDateTime("created_at"), + row.getOffsetDateTime("updated_at")); + } + + private ClientInstallation installation(Row row) { + String[] scopes = row.getArrayOfStrings("allowed_scopes"); + return new ClientInstallation( + row.getUUID("installation_id"), + row.getUUID("tenant_id"), + row.getString("client_id"), + row.getString("installation_code"), + row.getString("assertion_public_key"), + scopes == null + ? Set.of() + : java.util.Collections.unmodifiableSet( + new LinkedHashSet<>(Arrays.asList(scopes))), + row.getBoolean("enabled"), + row.getInteger("security_version"), + row.getOffsetDateTime("last_authenticated_at")); + } + + private ClientLicense license(Row row) { + return new ClientLicense( + row.getUUID("license_id"), + row.getUUID("tenant_id"), + row.getString("license_type"), + row.getString("package_code"), + row.getOffsetDateTime("valid_from").toInstant(), + row.getOffsetDateTime("valid_until").toInstant(), + LicenseStatus.valueOf(row.getString("status")), + row.getInteger("max_users"), + row.getInteger("max_installations"), + row.getOffsetDateTime("updated_at")); + } +} diff --git a/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/service/TenantRegistrationService.java b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/service/TenantRegistrationService.java new file mode 100644 index 0000000..8333586 --- /dev/null +++ b/cygnus-cloud-service/src/main/java/com/cygnus/cloud/tenant/service/TenantRegistrationService.java @@ -0,0 +1,201 @@ +package com.cygnus.cloud.tenant.service; + +import com.cygnus.cloud.cache.CacheProperties; +import com.cygnus.cloud.cache.ReactiveCacheService; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.model.ClientLicense; +import com.cygnus.cloud.tenant.model.LicenseStatus; +import com.cygnus.cloud.tenant.repository.TenantRegistrationRepository; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +public class TenantRegistrationService { + + private static final String INSTALLATION_CACHE = "tenant-installation"; + private static final String LICENSE_CACHE = "tenant-license"; + + private final TenantRegistrationRepository repository; + private final ReactiveCacheService cache; + private final CacheProperties cacheProperties; + private final Clock clock; + + public TenantRegistrationService( + TenantRegistrationRepository repository, + ReactiveCacheService cache, + CacheProperties cacheProperties, + Clock clock) { + this.repository = repository; + this.cache = cache; + this.cacheProperties = cacheProperties; + this.clock = clock; + } + + public Mono findInstallation( + String clientId, String installationCode) { + String key = encoded(clientId) + ':' + encoded(installationCode); + return cache.get(INSTALLATION_CACHE, key) + .flatMap(value -> decodeInstallationSafely(value)) + .onErrorResume(exception -> Mono.empty()) + .switchIfEmpty(repository.findInstallation(clientId, installationCode) + .flatMap(installation -> cache + .put( + INSTALLATION_CACHE, + key, + encode(installation)) + .onErrorResume(exception -> Mono.just(false)) + .thenReturn(installation))); + } + + public Mono findCurrentLicense(UUID tenantId, Instant instant) { + String key = tenantId.toString(); + return cache.get(LICENSE_CACHE, key) + .flatMap(value -> decodeLicenseSafely(value)) + .filter(license -> license.isActiveAt(instant)) + .onErrorResume(exception -> Mono.empty()) + .switchIfEmpty(repository.findCurrentLicense(tenantId, instant) + .filter(license -> license.isActiveAt(instant)) + .flatMap(license -> cache + .put( + LICENSE_CACHE, + key, + encode(license), + licenseTtl(license)) + .onErrorResume(exception -> Mono.just(false)) + .thenReturn(license))); + } + + public Mono evictInstallation( + String clientId, String installationCode) { + return cache.evict( + INSTALLATION_CACHE, + encoded(clientId) + ':' + encoded(installationCode)) + .onErrorReturn(false); + } + + public Mono evictLicense(UUID tenantId) { + return cache.evict(LICENSE_CACHE, tenantId.toString()) + .onErrorReturn(false); + } + + private Duration licenseTtl(ClientLicense license) { + Duration remaining = Duration.between(clock.instant(), license.validUntil()); + if (remaining.isNegative() || remaining.isZero()) { + return Duration.ofSeconds(1); + } + return remaining.compareTo(cacheProperties.defaultTtl()) < 0 + ? remaining + : cacheProperties.defaultTtl(); + } + + private String encode(ClientInstallation installation) { + String scopes = installation.allowedScopes().stream() + .sorted() + .map(this::encoded) + .reduce((left, right) -> left + "," + right) + .orElse(""); + return String.join( + "|", + installation.installationId().toString(), + installation.tenantId().toString(), + encoded(installation.clientId()), + encoded(installation.installationCode()), + encoded(installation.assertionPublicKey()), + scopes, + Boolean.toString(installation.enabled()), + Integer.toString(installation.securityVersion()), + installation.lastAuthenticatedAt() == null + ? "" + : installation.lastAuthenticatedAt().toString()); + } + + private Mono decodeInstallationSafely(String value) { + try { + String[] fields = value.split("\\|", -1); + if (fields.length != 9) { + return Mono.empty(); + } + Set scopes = fields[5].isBlank() + ? Set.of() + : Arrays.stream(fields[5].split(",")) + .map(this::decoded) + .collect(java.util.stream.Collectors.toCollection( + LinkedHashSet::new)); + return Mono.just(new ClientInstallation( + UUID.fromString(fields[0]), + UUID.fromString(fields[1]), + decoded(fields[2]), + decoded(fields[3]), + decoded(fields[4]), + Set.copyOf(scopes), + Boolean.parseBoolean(fields[6]), + Integer.parseInt(fields[7]), + fields[8].isBlank() + ? null + : OffsetDateTime.parse(fields[8]))); + } catch (RuntimeException exception) { + return Mono.empty(); + } + } + + private String encode(ClientLicense license) { + return String.join( + "|", + license.licenseId().toString(), + license.tenantId().toString(), + encoded(license.licenseType()), + encoded(license.packageCode()), + license.validFrom().toString(), + license.validUntil().toString(), + license.status().name(), + license.maxUsers() == null ? "" : license.maxUsers().toString(), + license.maxInstallations() == null + ? "" + : license.maxInstallations().toString(), + license.updatedAt().toString()); + } + + private Mono decodeLicenseSafely(String value) { + try { + String[] fields = value.split("\\|", -1); + if (fields.length != 10) { + return Mono.empty(); + } + return Mono.just(new ClientLicense( + UUID.fromString(fields[0]), + UUID.fromString(fields[1]), + decoded(fields[2]), + decoded(fields[3]), + Instant.parse(fields[4]), + Instant.parse(fields[5]), + LicenseStatus.valueOf(fields[6]), + fields[7].isBlank() ? null : Integer.valueOf(fields[7]), + fields[8].isBlank() ? null : Integer.valueOf(fields[8]), + OffsetDateTime.parse(fields[9]))); + } catch (RuntimeException exception) { + return Mono.empty(); + } + } + + private String encoded(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private String decoded(String value) { + return new String( + Base64.getUrlDecoder().decode(value), + StandardCharsets.UTF_8); + } +} diff --git a/cygnus-cloud-service/src/main/resources/application.yml b/cygnus-cloud-service/src/main/resources/application.yml index 0225ebc..0b4dac5 100644 --- a/cygnus-cloud-service/src/main/resources/application.yml +++ b/cygnus-cloud-service/src/main/resources/application.yml @@ -36,7 +36,6 @@ cygnus: assertion-decryption-private-key: ${CYGNUS_ASSERTION_DECRYPTION_PRIVATE_KEY:file:./config/keys/assertion-decryption-private.pem} access-token-private-key: ${CYGNUS_ACCESS_TOKEN_PRIVATE_KEY:file:./config/keys/access-token-private.pem} access-token-public-key: ${CYGNUS_ACCESS_TOKEN_PUBLIC_KEY:file:./config/keys/access-token-public.pem} - clients: {} login-encryption: key-id: ${CYGNUS_LOGIN_KEY_ID:cygnus-login-2026-01} private-key-location: ${CYGNUS_LOGIN_PRIVATE_KEY:file:./config/keys/login-private.pem} diff --git a/cygnus-cloud-service/src/main/resources/db/identity/002_multitenant_licensing.sql b/cygnus-cloud-service/src/main/resources/db/identity/002_multitenant_licensing.sql new file mode 100644 index 0000000..99d7db4 --- /dev/null +++ b/cygnus-cloud-service/src/main/resources/db/identity/002_multitenant_licensing.sql @@ -0,0 +1,153 @@ +BEGIN; + +-- DMLP phase 2: introduce the tenant, installation and license source of truth. +-- The fixed legacy tenant is a migration bridge for the identity data copied by +-- 001_identity_login_schema.sql. Replace its migration license through the +-- administration workflow before commercial enforcement is enabled. + +CREATE TABLE IF NOT EXISTS identity.client_account ( + tenant_id uuid PRIMARY KEY, + client_slug character varying(80) NOT NULL, + client_name character varying(200) NOT NULL, + status character varying(20) NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_identity_client_account_slug UNIQUE (client_slug), + CONSTRAINT ck_identity_client_account_slug + CHECK (client_slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'), + CONSTRAINT ck_identity_client_account_status + CHECK (status IN ('ACTIVE', 'SUSPENDED', 'CANCELLED')) +); + +CREATE TABLE IF NOT EXISTS identity.client_installation ( + installation_id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + client_id character varying(80) NOT NULL, + installation_code character varying(100) NOT NULL, + assertion_public_key text NOT NULL, + allowed_scopes text[] NOT NULL DEFAULT ARRAY[]::text[], + enabled boolean NOT NULL DEFAULT true, + security_version integer NOT NULL DEFAULT 1, + last_authenticated_at timestamp with time zone, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_identity_installation_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id), + CONSTRAINT uq_identity_installation_client_code + UNIQUE (client_id, installation_code), + CONSTRAINT ck_identity_installation_client_id + CHECK (client_id ~ '^[A-Za-z0-9][A-Za-z0-9_-]*$'), + CONSTRAINT ck_identity_installation_code + CHECK (installation_code ~ '^[A-Za-z0-9][A-Za-z0-9_-]*$'), + CONSTRAINT ck_identity_installation_security_version + CHECK (security_version > 0) +); + +CREATE TABLE IF NOT EXISTS identity.client_license ( + license_id uuid PRIMARY KEY, + tenant_id uuid NOT NULL, + license_type character varying(30) NOT NULL, + package_code character varying(50) NOT NULL, + valid_from timestamp with time zone NOT NULL, + valid_until timestamp with time zone NOT NULL, + status character varying(20) NOT NULL, + max_users integer, + max_installations integer, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_identity_license_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id), + CONSTRAINT ck_identity_license_period + CHECK (valid_until > valid_from), + CONSTRAINT ck_identity_license_status + CHECK (status IN ('ACTIVE', 'SUSPENDED', 'EXPIRED', 'CANCELLED')), + CONSTRAINT ck_identity_license_limits + CHECK ((max_users IS NULL OR max_users > 0) + AND (max_installations IS NULL OR max_installations > 0)) +); + +CREATE INDEX IF NOT EXISTS ix_identity_installation_tenant_enabled + ON identity.client_installation (tenant_id, enabled); +CREATE INDEX IF NOT EXISTS ix_identity_installation_lookup + ON identity.client_installation (client_id, installation_code, enabled); +CREATE INDEX IF NOT EXISTS ix_identity_license_tenant_period + ON identity.client_license (tenant_id, status, valid_from, valid_until); + +-- Add tenant ownership without changing any legacy primary-key values. +ALTER TABLE identity.company + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.company_branch + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.user_group + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.app_user + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.permission + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.denied_pages + ADD COLUMN IF NOT EXISTS tenant_id uuid; +ALTER TABLE identity.user_loginhistory + ADD COLUMN IF NOT EXISTS tenant_id uuid; + +-- Seed a stable bridge tenant for all identity data that already exists. +INSERT INTO identity.client_account + (tenant_id, client_slug, client_name, status) +VALUES ('00000000-0000-4000-8000-000000000001', 'matrix', 'Matrix', 'ACTIVE') +ON CONFLICT (tenant_id) DO UPDATE SET + client_slug = EXCLUDED.client_slug, + client_name = EXCLUDED.client_name, + updated_at = now(); + +-- A non-expiring migration bridge keeps current users operational. It must be +-- replaced by a commercial license before license administration goes live. +INSERT INTO identity.client_license + (license_id, tenant_id, license_type, package_code, + valid_from, valid_until, status) +VALUES ('00000000-0000-4000-8000-000000000002', + '00000000-0000-4000-8000-000000000001', + 'MIGRATION', 'LEGACY_FULL', + '2020-01-01 00:00:00+00', '2099-12-31 23:59:59+00', 'ACTIVE') +ON CONFLICT (license_id) DO NOTHING; + +UPDATE identity.company + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.company_branch + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.user_group + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.app_user + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.permission + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.denied_pages + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; +UPDATE identity.user_loginhistory + SET tenant_id = '00000000-0000-4000-8000-000000000001' + WHERE tenant_id IS NULL; + +CREATE INDEX IF NOT EXISTS ix_identity_company_tenant + ON identity.company (tenant_id, company_id); +CREATE INDEX IF NOT EXISTS ix_identity_branch_tenant + ON identity.company_branch (tenant_id, company_id, branch_id); +CREATE INDEX IF NOT EXISTS ix_identity_group_tenant + ON identity.user_group (tenant_id, group_id); +CREATE INDEX IF NOT EXISTS ix_identity_user_tenant_login + ON identity.app_user (tenant_id, upper(loginid)); +CREATE INDEX IF NOT EXISTS ix_identity_permission_tenant_group_page + ON identity.permission (tenant_id, group_id, page_id) + WHERE permission <> '000'; +CREATE INDEX IF NOT EXISTS ix_identity_denied_tenant_user_page + ON identity.denied_pages (tenant_id, user_id, page_id) + WHERE isdenied = 1; +CREATE INDEX IF NOT EXISTS ix_identity_login_history_tenant_user_time + ON identity.user_loginhistory (tenant_id, user_id, logintime DESC); + +COMMIT; diff --git a/cygnus-cloud-service/src/main/resources/db/identity/003_tenant_isolation_constraints.sql b/cygnus-cloud-service/src/main/resources/db/identity/003_tenant_isolation_constraints.sql new file mode 100644 index 0000000..8bf85f5 --- /dev/null +++ b/cygnus-cloud-service/src/main/resources/db/identity/003_tenant_isolation_constraints.sql @@ -0,0 +1,231 @@ +BEGIN; + +-- DMLP phase 3: make tenant ownership mandatory and enforce same-tenant +-- relationships without changing any legacy primary keys. + +ALTER TABLE identity.company + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.company_branch + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.user_group + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.app_user + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.permission + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.denied_pages + ALTER COLUMN tenant_id SET NOT NULL; +ALTER TABLE identity.user_loginhistory + ALTER COLUMN tenant_id SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_company_tenant_id + ON identity.company (tenant_id, company_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_branch_tenant_id + ON identity.company_branch (tenant_id, branch_id, company_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_group_tenant_id + ON identity.user_group (tenant_id, group_id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_identity_user_tenant_id + ON identity.app_user (tenant_id, user_id); + +DO $migration$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_company_tenant' + AND conrelid = 'identity.company'::regclass) THEN + ALTER TABLE identity.company + ADD CONSTRAINT fk_identity_company_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_branch_tenant' + AND conrelid = 'identity.company_branch'::regclass) THEN + ALTER TABLE identity.company_branch + ADD CONSTRAINT fk_identity_branch_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_branch_company_tenant' + AND conrelid = 'identity.company_branch'::regclass) THEN + ALTER TABLE identity.company_branch + ADD CONSTRAINT fk_identity_branch_company_tenant + FOREIGN KEY (tenant_id, company_id) + REFERENCES identity.company (tenant_id, company_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_group_tenant' + AND conrelid = 'identity.user_group'::regclass) THEN + ALTER TABLE identity.user_group + ADD CONSTRAINT fk_identity_group_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_user_tenant' + AND conrelid = 'identity.app_user'::regclass) THEN + ALTER TABLE identity.app_user + ADD CONSTRAINT fk_identity_user_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_user_group_tenant' + AND conrelid = 'identity.app_user'::regclass) THEN + ALTER TABLE identity.app_user + ADD CONSTRAINT fk_identity_user_group_tenant + FOREIGN KEY (tenant_id, group_id) + REFERENCES identity.user_group (tenant_id, group_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_user_company_tenant' + AND conrelid = 'identity.app_user'::regclass) THEN + ALTER TABLE identity.app_user + ADD CONSTRAINT fk_identity_user_company_tenant + FOREIGN KEY (tenant_id, company_id) + REFERENCES identity.company (tenant_id, company_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_user_branch_tenant' + AND conrelid = 'identity.app_user'::regclass) THEN + ALTER TABLE identity.app_user + ADD CONSTRAINT fk_identity_user_branch_tenant + FOREIGN KEY (tenant_id, branch_id, company_id) + REFERENCES identity.company_branch (tenant_id, branch_id, company_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_permission_tenant' + AND conrelid = 'identity.permission'::regclass) THEN + ALTER TABLE identity.permission + ADD CONSTRAINT fk_identity_permission_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_permission_group_tenant' + AND conrelid = 'identity.permission'::regclass) THEN + ALTER TABLE identity.permission + ADD CONSTRAINT fk_identity_permission_group_tenant + FOREIGN KEY (tenant_id, group_id) + REFERENCES identity.user_group (tenant_id, group_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_permission_page' + AND conrelid = 'identity.permission'::regclass) THEN + ALTER TABLE identity.permission + ADD CONSTRAINT fk_identity_permission_page + FOREIGN KEY (page_id) + REFERENCES identity.pages (page_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_denied_tenant' + AND conrelid = 'identity.denied_pages'::regclass) THEN + ALTER TABLE identity.denied_pages + ADD CONSTRAINT fk_identity_denied_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_denied_user_tenant' + AND conrelid = 'identity.denied_pages'::regclass) THEN + ALTER TABLE identity.denied_pages + ADD CONSTRAINT fk_identity_denied_user_tenant + FOREIGN KEY (tenant_id, user_id) + REFERENCES identity.app_user (tenant_id, user_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_denied_page' + AND conrelid = 'identity.denied_pages'::regclass) THEN + ALTER TABLE identity.denied_pages + ADD CONSTRAINT fk_identity_denied_page + FOREIGN KEY (page_id) + REFERENCES identity.pages (page_id) + NOT VALID; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_identity_login_history_tenant' + AND conrelid = 'identity.user_loginhistory'::regclass) THEN + ALTER TABLE identity.user_loginhistory + ADD CONSTRAINT fk_identity_login_history_tenant + FOREIGN KEY (tenant_id) + REFERENCES identity.client_account (tenant_id) + NOT VALID; + END IF; + +END +$migration$; + +ALTER TABLE identity.company + VALIDATE CONSTRAINT fk_identity_company_tenant; +ALTER TABLE identity.company_branch + VALIDATE CONSTRAINT fk_identity_branch_tenant; +ALTER TABLE identity.company_branch + VALIDATE CONSTRAINT fk_identity_branch_company_tenant; +ALTER TABLE identity.user_group + VALIDATE CONSTRAINT fk_identity_group_tenant; +ALTER TABLE identity.app_user + VALIDATE CONSTRAINT fk_identity_user_tenant; +ALTER TABLE identity.app_user + VALIDATE CONSTRAINT fk_identity_user_group_tenant; +ALTER TABLE identity.app_user + VALIDATE CONSTRAINT fk_identity_user_company_tenant; +ALTER TABLE identity.app_user + VALIDATE CONSTRAINT fk_identity_user_branch_tenant; +ALTER TABLE identity.permission + VALIDATE CONSTRAINT fk_identity_permission_tenant; +ALTER TABLE identity.permission + VALIDATE CONSTRAINT fk_identity_permission_group_tenant; +ALTER TABLE identity.permission + VALIDATE CONSTRAINT fk_identity_permission_page; +ALTER TABLE identity.denied_pages + VALIDATE CONSTRAINT fk_identity_denied_tenant; +ALTER TABLE identity.denied_pages + VALIDATE CONSTRAINT fk_identity_denied_user_tenant; +ALTER TABLE identity.denied_pages + VALIDATE CONSTRAINT fk_identity_denied_page; +ALTER TABLE identity.user_loginhistory + VALIDATE CONSTRAINT fk_identity_login_history_tenant; + +COMMIT; diff --git a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/identity/service/IdentityAuthenticationServiceTest.java b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/identity/service/IdentityAuthenticationServiceTest.java index a0f030b..ee9e44f 100644 --- a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/identity/service/IdentityAuthenticationServiceTest.java +++ b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/identity/service/IdentityAuthenticationServiceTest.java @@ -11,6 +11,7 @@ import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -23,6 +24,8 @@ import reactor.test.StepVerifier; class IdentityAuthenticationServiceTest { private static final Instant LOGIN_TIME = Instant.parse("2026-07-23T06:30:00Z"); + private static final UUID TENANT_ID = + UUID.fromString("00000000-0000-4000-8000-000000000001"); @Mock private IdentityRepository repository; @@ -36,11 +39,17 @@ class IdentityAuthenticationServiceTest { MenuItem menuItem = new MenuItem( (short) 10, "Operations", "/ver/operations", (short) 0, (short) 1, "110", "_parent", null); - when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user)); + when(repository.findUsersByLoginId(TENANT_ID, "maddy")) + .thenReturn(Flux.just(user)); when(passwordVerifier.matches("secret", "legacy-value")).thenReturn(true); - when(repository.findMenu((short) 4, (short) 25)) + when(repository.findMenu(TENANT_ID, (short) 4, (short) 25)) .thenReturn(Mono.just(List.of(menuItem))); - when(repository.recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25)) + when(repository.recordLogin( + TENANT_ID, + "maddy", + LOGIN_TIME, + "127.0.0.1", + (short) 25)) .thenReturn(Mono.just(101L)); IdentityAuthenticationService service = new IdentityAuthenticationService( @@ -48,7 +57,8 @@ class IdentityAuthenticationServiceTest { passwordVerifier, Clock.fixed(LOGIN_TIME, ZoneOffset.UTC)); - StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1")) + StepVerifier.create(service.authenticate( + TENANT_ID, "maddy", "secret", "127.0.0.1")) .assertNext(result -> { assertThat(result.loginId()).isEqualTo("maddy"); assertThat(result.companyName()).isEqualTo("Matrix"); @@ -57,20 +67,27 @@ class IdentityAuthenticationServiceTest { }) .verifyComplete(); - verify(repository).recordLogin("maddy", LOGIN_TIME, "127.0.0.1", (short) 25); + verify(repository).recordLogin( + TENANT_ID, + "maddy", + LOGIN_TIME, + "127.0.0.1", + (short) 25); } @Test void rejectsInactiveUserWithoutLoadingMenu() { IdentityUser user = user("maddy", false); - when(repository.findUsersByLoginId("maddy")).thenReturn(Flux.just(user)); + when(repository.findUsersByLoginId(TENANT_ID, "maddy")) + .thenReturn(Flux.just(user)); IdentityAuthenticationService service = new IdentityAuthenticationService( repository, passwordVerifier, Clock.fixed(LOGIN_TIME, ZoneOffset.UTC)); - StepVerifier.create(service.authenticate("maddy", "secret", "127.0.0.1")) + StepVerifier.create(service.authenticate( + TENANT_ID, "maddy", "secret", "127.0.0.1")) .expectError(AuthenticationException.class) .verify(); } diff --git a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/security/MachineTokenFlowTest.java b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/security/MachineTokenFlowTest.java index 3726347..24040da 100644 --- a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/security/MachineTokenFlowTest.java +++ b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/security/MachineTokenFlowTest.java @@ -2,7 +2,13 @@ package com.cygnus.cloud.security; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.model.ClientLicense; +import com.cygnus.cloud.tenant.model.LicenseStatus; +import com.cygnus.cloud.tenant.service.TenantRegistrationService; import com.nimbusds.jose.EncryptionMethod; import com.nimbusds.jose.JWEAlgorithm; import com.nimbusds.jose.JWEHeader; @@ -26,10 +32,12 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.time.OffsetDateTime; import java.util.Base64; import java.util.Date; import java.util.Map; import java.util.Set; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -37,6 +45,7 @@ import org.springframework.http.MediaType; import org.springframework.util.LinkedMultiValueMap; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.BodyInserters; +import reactor.core.publisher.Mono; class MachineTokenFlowTest { @@ -53,6 +62,9 @@ class MachineTokenFlowTest { private KeyPair clientSigningKeys; private KeyPair accessTokenKeys; private CommunicationSecurityProperties properties; + private TenantRegistrationService registrations; + private ClientInstallation installation; + private ClientLicense license; @BeforeEach void setUp() throws Exception { @@ -69,14 +81,33 @@ class MachineTokenFlowTest { Duration.ofMinutes(20), privatePem("assertion-private.pem", assertionEncryptionKeys), privatePem("access-private.pem", accessTokenKeys), - publicPem("access-public.pem", accessTokenKeys), - Map.of( - CLIENT_ID, - new CommunicationSecurityProperties.MachineClient( - true, - INSTALLATION_ID, - publicPem("client-public.pem", clientSigningKeys), - Set.of("identity.login")))); + publicPem("access-public.pem", accessTokenKeys)); + registrations = mock(TenantRegistrationService.class); + installation = new ClientInstallation( + UUID.fromString("10000000-0000-4000-8000-000000000001"), + UUID.fromString("00000000-0000-4000-8000-000000000001"), + CLIENT_ID, + INSTALLATION_ID, + publicPem("client-public.pem", clientSigningKeys), + Set.of("identity.login"), + true, + 1, + OffsetDateTime.now(ZoneOffset.UTC)); + when(registrations.findInstallation(CLIENT_ID, INSTALLATION_ID)) + .thenReturn(Mono.just(installation)); + license = new ClientLicense( + UUID.fromString("20000000-0000-4000-8000-000000000001"), + installation.tenantId(), + "ANNUAL", + "FULL", + NOW.minus(Duration.ofDays(1)), + NOW.plus(Duration.ofDays(365)), + LicenseStatus.ACTIVE, + 120, + 2, + OffsetDateTime.now(ZoneOffset.UTC)); + when(registrations.findCurrentLicense(installation.tenantId(), NOW)) + .thenReturn(Mono.just(license)); } @Test @@ -84,7 +115,7 @@ class MachineTokenFlowTest { throws Exception { Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); ClientAssertionValidator validator = - new ClientAssertionValidator(properties, clock); + new ClientAssertionValidator(properties, registrations, clock); AccessTokenIssuer issuer = new AccessTokenIssuer(properties, clock); MachineTokenController controller = new MachineTokenController(validator, issuer); @@ -98,7 +129,7 @@ class MachineTokenFlowTest { form.add("client_assertion", encryptedAssertion(NOW, NOW.plus(Duration.ofDays(365)))); form.add("scope", "identity.login"); - Map response = controller.issueToken(form); + Map response = controller.issueToken(form).block(); SignedJWT token = SignedJWT.parse((String) response.get("access_token")); assertThat(token.verify(new RSASSAVerifier( @@ -108,6 +139,12 @@ class MachineTokenFlowTest { .isEqualTo(CLIENT_ID); assertThat(token.getJWTClaimsSet().getStringClaim("installation_id")) .isEqualTo(INSTALLATION_ID); + assertThat(token.getJWTClaimsSet().getStringClaim("tenant_id")) + .isEqualTo(installation.tenantId().toString()); + assertThat(token.getJWTClaimsSet().getStringClaim("license_id")) + .isEqualTo(license.licenseId().toString()); + assertThat(token.getJWTClaimsSet().getStringClaim("package_code")) + .isEqualTo("FULL"); assertThat(token.getJWTClaimsSet().getStringClaim("scope")) .isEqualTo("identity.login"); assertThat(response) @@ -119,21 +156,36 @@ class MachineTokenFlowTest { @Test void rejectsExpiredAssertion() throws Exception { ClientAssertionValidator validator = new ClientAssertionValidator( - properties, Clock.fixed(NOW, ZoneOffset.UTC)); + properties, registrations, Clock.fixed(NOW, ZoneOffset.UTC)); assertThatThrownBy(() -> validator.validate( CLIENT_ID, encryptedAssertion( NOW.minus(Duration.ofDays(366)), - NOW.minusSeconds(1)))) + NOW.minusSeconds(1))).block()) .isInstanceOf(MachineAuthenticationException.class); } + @Test + void rejectsClientWithoutAnActiveLicense() throws Exception { + when(registrations.findCurrentLicense(installation.tenantId(), NOW)) + .thenReturn(Mono.empty()); + ClientAssertionValidator validator = new ClientAssertionValidator( + properties, registrations, Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThatThrownBy(() -> validator.validate( + CLIENT_ID, + encryptedAssertion(NOW, NOW.plus(Duration.ofDays(1)))) + .block()) + .isInstanceOf(MachineAuthenticationException.class) + .hasMessage("Client license is not active"); + } + @Test void acceptsOAuthFormEncodedTokenRequestOverHttp() throws Exception { Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); MachineTokenController controller = new MachineTokenController( - new ClientAssertionValidator(properties, clock), + new ClientAssertionValidator(properties, registrations, clock), new AccessTokenIssuer(properties, clock)); LinkedMultiValueMap form = new LinkedMultiValueMap<>(); form.add("grant_type", "client_credentials"); diff --git a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/model/ClientLicenseTest.java b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/model/ClientLicenseTest.java new file mode 100644 index 0000000..d6a179c --- /dev/null +++ b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/model/ClientLicenseTest.java @@ -0,0 +1,45 @@ +package com.cygnus.cloud.tenant.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ClientLicenseTest { + + private static final Instant START = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant END = Instant.parse("2027-01-01T00:00:00Z"); + + @Test + void activeLicenseIsValidOnlyInsideItsConfiguredPeriod() { + ClientLicense license = license(LicenseStatus.ACTIVE); + + assertThat(license.isActiveAt(START)).isTrue(); + assertThat(license.isActiveAt(END.minusSeconds(1))).isTrue(); + assertThat(license.isActiveAt(END)).isFalse(); + assertThat(license.isActiveAt(START.minusSeconds(1))).isFalse(); + } + + @Test + void suspendedLicenseIsNeverActive() { + assertThat(license(LicenseStatus.SUSPENDED) + .isActiveAt(Instant.parse("2026-07-01T00:00:00Z"))) + .isFalse(); + } + + private ClientLicense license(LicenseStatus status) { + return new ClientLicense( + UUID.randomUUID(), + UUID.randomUUID(), + "YEARLY", + "PROFESSIONAL", + START, + END, + status, + 120, + 3, + OffsetDateTime.parse("2026-01-01T00:00:00Z")); + } +} diff --git a/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/service/TenantRegistrationServiceTest.java b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/service/TenantRegistrationServiceTest.java new file mode 100644 index 0000000..72fd224 --- /dev/null +++ b/cygnus-cloud-service/src/test/java/com/cygnus/cloud/tenant/service/TenantRegistrationServiceTest.java @@ -0,0 +1,57 @@ +package com.cygnus.cloud.tenant.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.cygnus.cloud.cache.CacheProperties; +import com.cygnus.cloud.cache.ReactiveCacheService; +import com.cygnus.cloud.tenant.model.ClientInstallation; +import com.cygnus.cloud.tenant.repository.TenantRegistrationRepository; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +class TenantRegistrationServiceTest { + + @Test + void fallsBackToDatabaseWhenRedisIsUnavailable() { + TenantRegistrationRepository repository = + mock(TenantRegistrationRepository.class); + ReactiveCacheService cache = mock(ReactiveCacheService.class); + ClientInstallation installation = new ClientInstallation( + UUID.randomUUID(), + UUID.randomUUID(), + "matrix", + "site-01", + "public-key", + Set.of("identity.login"), + true, + 1, + null); + when(cache.get(anyString(), anyString())) + .thenReturn(Mono.error(new IllegalStateException("redis unavailable"))); + when(cache.put(anyString(), anyString(), anyString())) + .thenReturn(Mono.error(new IllegalStateException("redis unavailable"))); + when(repository.findInstallation("matrix", "site-01")) + .thenReturn(Mono.just(installation)); + + TenantRegistrationService service = new TenantRegistrationService( + repository, + cache, + new CacheProperties("cygnus", Duration.ofMinutes(10)), + Clock.fixed(Instant.parse("2026-07-26T10:00:00Z"), ZoneOffset.UTC)); + + StepVerifier.create(service.findInstallation("matrix", "site-01")) + .assertNext(result -> assertThat(result).isEqualTo(installation)) + .verifyComplete(); + } +} diff --git a/cygnus-onprem-app/src/test/java/matrix/nimble/SpringWebContextTest.java b/cygnus-onprem-app/src/test/java/matrix/nimble/SpringWebContextTest.java index b2a67d4..105926b 100644 --- a/cygnus-onprem-app/src/test/java/matrix/nimble/SpringWebContextTest.java +++ b/cygnus-onprem-app/src/test/java/matrix/nimble/SpringWebContextTest.java @@ -15,9 +15,19 @@ class SpringWebContextTest { @Test void applicationContextLoadsControllers() { - try (FileSystemXmlApplicationContext context = - new FileSystemXmlApplicationContext("build/WebContent/WEB-INF/app-config.xml")) { - assertFalse(context.getBeansWithAnnotation(Controller.class).isEmpty()); + System.setProperty("CYGNUS_CLIENT_ID", "test-client"); + System.setProperty("CYGNUS_INSTALLATION_ID", "test-installation"); + System.setProperty("CYGNUS_CLIENT_ASSERTION", "test-assertion"); + try { + try (FileSystemXmlApplicationContext context = + new FileSystemXmlApplicationContext( + "build/WebContent/WEB-INF/app-config.xml")) { + assertFalse(context.getBeansWithAnnotation(Controller.class).isEmpty()); + } + } finally { + System.clearProperty("CYGNUS_CLIENT_ID"); + System.clearProperty("CYGNUS_INSTALLATION_ID"); + System.clearProperty("CYGNUS_CLIENT_ASSERTION"); } } diff --git a/scripts/setup-local-communication.sh b/scripts/setup-local-communication.sh index 3d36c96..2a4d143 100644 --- a/scripts/setup-local-communication.sh +++ b/scripts/setup-local-communication.sh @@ -13,10 +13,19 @@ INSTALLATION_ID="" CLOUD_BASE_URL="" TOKEN_URL="" CLIENT_DIR="" -CLIENT_CONFIG="${CONFIG_DIR}/clients.yml" CLIENT_PRIVATE_KEY="" CLIENT_PUBLIC_KEY="" MACHINE_ASSERTION="" +CLIENT_NAME="" +DB_HOST_VALUE="" +DB_PORT_VALUE="" +DB_NAME_VALUE="" +DB_USER_VALUE="" +DB_PASSWORD_VALUE="" +LICENSE_TYPE="" +PACKAGE_CODE="" +LICENSE_MONTHS="" +PSQL_BIN="" SKIP_BUILD_CONFIGURED="${CYGNUS_SETUP_SKIP_BUILD+x}" SKIP_BUILD="${CYGNUS_SETUP_SKIP_BUILD:-false}" FORCE_ASSERTION="${CYGNUS_SETUP_FORCE_ASSERTION:-false}" @@ -77,6 +86,11 @@ validate_identifier() { "${label} must contain no spaces and use only letters, numbers, '-' or '_'." } +validate_client_slug() { + [[ "$1" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] || fail \ + "Customer identifier must use lowercase letters, numbers and single hyphens only." +} + collect_client_inputs() { local default_client="${CYGNUS_SETUP_CLIENT_ID:-customer-a}" local default_installation="${CYGNUS_SETUP_INSTALLATION_ID:-site-01}" @@ -86,7 +100,11 @@ collect_client_inputs() { CLIENT_ID="$(prompt_value \ "Customer identifier (no spaces; used in key and directory names)" \ "${default_client}")" - validate_identifier "Customer identifier" "${CLIENT_ID}" + validate_client_slug "${CLIENT_ID}" + CLIENT_NAME="$(prompt_value \ + "Customer display name" \ + "${CYGNUS_SETUP_CLIENT_NAME:-${CLIENT_ID}}")" + [[ -n "${CLIENT_NAME}" ]] || fail "Customer display name is required." INSTALLATION_ID="$(prompt_value \ "Installation identifier (no spaces)" \ @@ -111,11 +129,43 @@ collect_client_inputs() { CLIENT_PUBLIC_KEY="${CLIENT_DIR}/${CLIENT_ID}-signing-public.pem" MACHINE_ASSERTION="${CLIENT_DIR}/${CLIENT_ID}-${INSTALLATION_ID}-assertion.jwt" + DB_HOST_VALUE="$(prompt_value \ + "Cloud PostgreSQL host" "${CYGNUS_SETUP_DB_HOST:-${DB_HOST:-localhost}}")" + DB_PORT_VALUE="$(prompt_value \ + "Cloud PostgreSQL port" "${CYGNUS_SETUP_DB_PORT:-${DB_PORT:-5432}}")" + DB_NAME_VALUE="$(prompt_value \ + "Cloud PostgreSQL database" "${CYGNUS_SETUP_DB_NAME:-${DB_NAME:-matrix}}")" + DB_USER_VALUE="$(prompt_value \ + "Cloud PostgreSQL user" "${CYGNUS_SETUP_DB_USER:-${DB_USER:-postgres}}")" + DB_PASSWORD_VALUE="${CYGNUS_SETUP_DB_PASSWORD:-${DB_PASSWORD:-}}" + if [[ -z "${DB_PASSWORD_VALUE}" ]] && [[ "${NON_INTERACTIVE}" != "true" ]] && [[ -t 0 ]]; then + read -r -s -p "Cloud PostgreSQL password: " DB_PASSWORD_VALUE + printf '\n' + fi + [[ -n "${DB_PASSWORD_VALUE}" ]] || fail \ + "Cloud PostgreSQL password is required through the prompt or CYGNUS_SETUP_DB_PASSWORD." + + LICENSE_TYPE="$(prompt_value \ + "License type" "${CYGNUS_SETUP_LICENSE_TYPE:-ANNUAL}")" + PACKAGE_CODE="$(prompt_value \ + "License package code" "${CYGNUS_SETUP_PACKAGE_CODE:-FULL}")" + LICENSE_MONTHS="$(prompt_value \ + "License validity in months" "${CYGNUS_SETUP_LICENSE_MONTHS:-12}")" + validate_identifier "License type" "${LICENSE_TYPE}" + validate_identifier "Package code" "${PACKAGE_CODE}" + [[ "${LICENSE_MONTHS}" =~ ^[1-9][0-9]*$ ]] || fail \ + "License validity must be a positive whole number of months." + printf '\nProvisioning summary:\n' printf ' Customer: %s\n' "${CLIENT_ID}" + printf ' Customer name: %s\n' "${CLIENT_NAME}" printf ' Installation: %s\n' "${INSTALLATION_ID}" printf ' Cloud URL: %s\n' "${CLOUD_BASE_URL}" printf ' Token audience: %s\n' "${TOKEN_URL}" + printf ' Database: %s@%s:%s/%s\n' \ + "${DB_USER_VALUE}" "${DB_HOST_VALUE}" "${DB_PORT_VALUE}" "${DB_NAME_VALUE}" + printf ' License: %s / %s / %s month(s)\n' \ + "${LICENSE_TYPE}" "${PACKAGE_CODE}" "${LICENSE_MONTHS}" printf ' Verify build: %s\n' "$([[ "${SKIP_BUILD}" == "true" ]] && printf no || printf yes)" if [[ "$(prompt_yes_no "Continue with these values" "true")" != "true" ]]; then @@ -160,44 +210,78 @@ configure_java_21() { "JDK 21 is required. Current java is: $(java -version 2>&1 | head -n 1)" } -write_client_configuration() { - local public_key_uri="file:${CLIENT_PUBLIC_KEY}" - local existing_installation="" +register_client_in_database() { + local client_id_b64 client_name_b64 installation_b64 public_key_b64 + local license_type_b64 package_code_b64 - if [[ ! -f "${CLIENT_CONFIG}" ]]; then - { - printf '%s\n' "cygnus:" - printf '%s\n' " security:" - printf '%s\n' " clients:" - } > "${CLIENT_CONFIG}" - elif grep -Fq " ${CLIENT_ID}:" "${CLIENT_CONFIG}"; then - existing_installation="$(awk \ - -v client=" ${CLIENT_ID}:" \ - '$0 == client { found = 1; next } - found && /installation-id:/ { - sub(/^.*installation-id:[[:space:]]*/, ""); - print; - exit - } - found && /^ [^[:space:]]/ { exit }' \ - "${CLIENT_CONFIG}")" - if [[ "${existing_installation}" != "${INSTALLATION_ID}" ]]; then - fail "Client '${CLIENT_ID}' already uses installation '${existing_installation}' in ${CLIENT_CONFIG}; requested '${INSTALLATION_ID}'. Use the existing installation ID or provision a different customer identifier." - fi - printf 'Keeping existing configuration for client: %s\n' "${CLIENT_ID}" - return - fi + client_id_b64="$(printf '%s' "${CLIENT_ID}" | openssl base64 -A)" + client_name_b64="$(printf '%s' "${CLIENT_NAME}" | openssl base64 -A)" + installation_b64="$(printf '%s' "${INSTALLATION_ID}" | openssl base64 -A)" + public_key_b64="$(openssl base64 -A -in "${CLIENT_PUBLIC_KEY}")" + license_type_b64="$(printf '%s' "${LICENSE_TYPE}" | openssl base64 -A)" + package_code_b64="$(printf '%s' "${PACKAGE_CODE}" | openssl base64 -A)" - { - printf ' %s:\n' "${CLIENT_ID}" - printf '%s\n' " enabled: true" - printf ' installation-id: %s\n' "${INSTALLATION_ID}" - printf ' assertion-public-key: %s\n' "${public_key_uri}" - printf '%s\n' " scopes:" - printf '%s\n' " - identity.login" - } >> "${CLIENT_CONFIG}" - chmod 600 "${CLIENT_CONFIG}" - printf 'Added client %s to: %s\n' "${CLIENT_ID}" "${CLIENT_CONFIG}" + PGPASSWORD="${DB_PASSWORD_VALUE}" "${PSQL_BIN}" \ + -h "${DB_HOST_VALUE}" \ + -p "${DB_PORT_VALUE}" \ + -U "${DB_USER_VALUE}" \ + -d "${DB_NAME_VALUE}" \ + -X -v ON_ERROR_STOP=1 \ + -c " + WITH account AS ( + INSERT INTO identity.client_account + (tenant_id, client_slug, client_name, status) + VALUES ( + gen_random_uuid(), + convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'), + convert_from(decode('${client_name_b64}', 'base64'), 'UTF8'), + 'ACTIVE') + ON CONFLICT (client_slug) DO UPDATE SET + client_name = EXCLUDED.client_name, + status = 'ACTIVE', + updated_at = now() + RETURNING tenant_id + ), installation AS ( + INSERT INTO identity.client_installation + (installation_id, tenant_id, client_id, installation_code, + assertion_public_key, allowed_scopes, enabled) + SELECT + gen_random_uuid(), + tenant_id, + convert_from(decode('${client_id_b64}', 'base64'), 'UTF8'), + convert_from(decode('${installation_b64}', 'base64'), 'UTF8'), + convert_from(decode('${public_key_b64}', 'base64'), 'UTF8'), + ARRAY['identity.login']::text[], + true + FROM account + ON CONFLICT (client_id, installation_code) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + assertion_public_key = EXCLUDED.assertion_public_key, + allowed_scopes = EXCLUDED.allowed_scopes, + enabled = true, + security_version = identity.client_installation.security_version + 1, + updated_at = now() + RETURNING tenant_id + ) + INSERT INTO identity.client_license + (license_id, tenant_id, license_type, package_code, + valid_from, valid_until, status) + SELECT + gen_random_uuid(), + tenant_id, + convert_from(decode('${license_type_b64}', 'base64'), 'UTF8'), + convert_from(decode('${package_code_b64}', 'base64'), 'UTF8'), + now(), + now() + make_interval(months => ${LICENSE_MONTHS}), + 'ACTIVE' + FROM installation + WHERE NOT EXISTS ( + SELECT 1 + FROM identity.client_license existing + WHERE existing.tenant_id = installation.tenant_id + AND existing.status = 'ACTIVE' + AND existing.valid_until > now() + );" } generate_machine_assertion() { @@ -236,6 +320,13 @@ main() { require_command awk require_command openssl require_command mvn + if command -v psql >/dev/null 2>&1; then + PSQL_BIN="$(command -v psql)" + elif [[ -x /Library/PostgreSQL/17/bin/psql ]]; then + PSQL_BIN="/Library/PostgreSQL/17/bin/psql" + else + fail "PostgreSQL psql was not found in PATH or /Library/PostgreSQL/17/bin." + fi configure_java_21 java -version mvn -version @@ -277,8 +368,8 @@ main() { "${CLIENT_PRIVATE_KEY}" \ "${CLIENT_PUBLIC_KEY}" - log "Writing cloud machine-client configuration" - write_client_configuration + log "Registering tenant, installation, and license in the cloud database" + register_client_in_database log "Generating encrypted machine assertion" generate_machine_assertion @@ -288,7 +379,7 @@ main() { "Client ID: ${CLIENT_ID}" \ "Installation ID: ${INSTALLATION_ID}" \ "Token audience: ${TOKEN_URL}" \ - "Client config: ${CLIENT_CONFIG}" \ + "Registration: PostgreSQL identity schema" \ "Assertion file: ${MACHINE_ASSERTION}" \ "Login public key: ${KEY_DIR}/login-public.pem" \ "" \