From ed276d3b0746510fadc900799b15b868f3c4e651 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 14:50:11 +0530 Subject: [PATCH 01/20] Use white logo on login page --- .../images/api-portal-logo-white.png | Bin 0 -> 9517 bytes .../images/api-portal-logo-white.svg | 5 +++++ .../api-portal/src/pages/login-page/layout.hbs | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 portals/api-portal/src/defaultContent/images/api-portal-logo-white.png create mode 100644 portals/api-portal/src/defaultContent/images/api-portal-logo-white.svg diff --git a/portals/api-portal/src/defaultContent/images/api-portal-logo-white.png b/portals/api-portal/src/defaultContent/images/api-portal-logo-white.png new file mode 100644 index 0000000000000000000000000000000000000000..9a99bf6184c8af20d0404477fd124ba096e9973f GIT binary patch literal 9517 zcmb_?1y@{4urBTr+&u{%B-o(AonQkD8Z1C?w?TrtLvR}iHUt|W1c%@b!3pk!3=V@n z&O7JcUvO)!-n*;2S5@t<>R#Qozi3SjMSL7;93&(pd}Sp$Z6qXA+vmDD7RK`vavJgv zgYBXOaz{eKCHbcyBW34OJ~tw}Yb(kieH*7ecxKRSq}8R7km?d~@6FMlc>)i40}mbN zj~?FUZq`T&b~cXY`E?nVNJuZsmF1*$eUOjyjyzX{NFoL#5l`%7XcdfZ=tZ1SCem|c zaur=wI1c)S>gakLFLXZV;r!J7P=*4gb*0oH<$3!O$@D5XsYONRE}uyyuQZ7(e+{T;8pnUjhYf?_4SFEh2FH>~V3e(3> z|J(S11&z>^G<=^9SQJN&Qz=6RusStEEN^4pW( zs)lV|b=*ctIVTMh^IwHXOnmyTTE(>@ri4%U{gYA9XVl@6)CfKX;m55?XLsleRyR{bn|W_sV-39 zv1;Y)>|Sa{A(L0A`V%yZzv=6tnT787@G+~O@#2zTHks}v-Rk!>7lzxm41_C(eNDgP zfiU)6>`u!cw{cCfBZ?N!>VtJ&WO>0V?FQkp0oSC8SBNWbKzjRaRFHeMO9SW>8<4D z{-UNFSnxW`!02jK9|#Lg+>-o#9|&g|g)=3cYn!0H>RH~-Ks|m@^Cg`CCJ63y>ksYY zTzUcS=m<($ZcEuMQ%?}oVpS?|LUPvOIbOHLcm$Kdgsyk4auf+z4JbRA(8rE&?CsV% zyB4*uQ!~+kkVI!iaV|bZ*9fw`z8z`QVVt8YBuA=KNnk|gWzO)TqM#l zknLQ#9iMgu4ElDKl&Mg6?t9Y&b?^BgQxq_gxD=|?8H6`_ZJpe?t#qSxvV*Nb-r4b8 zn>VMl`xUg_UrXvrr6AUzi@@R5v4HTMH;Q&Hy6nRK`>L2d_XJ+S{h@P$6{Ilp9q30n zn) zl(Ti`j1{&?mnMKj-+dkh`QG+Bx!Hq{rD(fA#1P4Br*lAeyHR8*kW_1o=y@VI+Htb+ zbWK9$I7ap-q`&Q{4=Cz$Vl~&MOo0Qxo^K+(H`H83#FG)AFk+n1)3QlIC;!O5*s%u< z4>l+z^J&WH5adtcJ>9XhnjghOGvWbK(4Wk_RY$PIaViGFtmc8$6@ITp z%F?oC0=)4|-4>Pd0K*PLm^$3n#Bea*8&EmoH6^%y|I|V^{Pd{w+x|CxcPtN_se}dV zUNViwpA2E@mj8A3Hj2Y=-FRyEtb?}UkZ@bMc~QsKZ`gu~>Q%pfgsq~Nfh3~t4~f1Z zAxpFUXmn6gp0|A_1pZr830h{uU4B?{UxzEqq@U zsIMuZs28Sd;FiJXQF6kbHRhKNn5`{Vc))GDt}AP!MB);W&qEIsOaA; ziFipxl4u1fRLSbhrvA!`(ToaYip{WuI+^D3buwTD<4;}{diqVUNrDDC`Z%zM*XKQd zSCOvtF?(nV?Hx@UPLfyHk`eo6m^^>5s}qoGmXZshPJq^h*dTR$d`S~DNGaAYH z&APkt1-UM}4FLUJOtB|7kHF8vw)Z4^-qn0G!Saar(ljUed2EsindqDc@uZ0+mluf07#`*w}iO8g@<>&aK!V>r~(*yWX{=bZ3f_ zfwR!fOiyrrkN@2D^5tW0Mriot?k&|N(q;Z!xK{{*D-^8d^^*RVKwflW-b}xFX>r?b z?nMj6ibmC7K8xbCQ8N!a5_9m2u@e{=W90rj=u0P}W9#mc zvS&W|@*X3yy*)X|efbT#zW;kr7aiw|^S#_1o$Z!;wYB_M8e1d%+^zV6vHYq^yMBYC zFv+WxYagD)Zmqe&S!a^x z=kt^?AICW3*Jj8?S`#kwe+~L{^Ph4a9@xhN5?E%>827fT4x`mON^1FUHV*k?^-73w zufGW9>CiY=NqWxStorYGhg_jDw8U%;I@?!O@(tu$<^stElGi%U{b<#@^x^A9YbKys zltgFD?C5!^r`80x+hv`9pSLshT9%Hl`bcuzjmt!M%jw%yvd&eGgzFghAN;g%ZPzvl zpsNG%&%qhj{G|?vIV{NcE&!ly1#ZrL`?IgBu2|2Nwj}{F4+(_7&K<}*qZI=6O&wV$ zu%LGx4Y7^-GKhBEeb~R4`F={WQ?Pfom5~1_v^kn9M|QP9-ek({;TT-q7|vuJ(bO#Q zM8DZ{Lu3D1820eFi!z92DI1N8>c!&pXzD`pE``+kT4%EK?Yrn?dxX+~yGaP~Mhh=y zCHc>Gqs0Z$FB36B8Q@Is7@@h`rs|WJ&7>d04j*leG(*s#^5b=3&;dVk_o+5#!2~2t z#u$bPZ_Moe&z)xU1IE~mI7bW9f5>J9?;iUqSEShF~oFu&%Z0qr7yS9SS z9Sd`b`FzG<2(+Wd&j2^?Ga2E#$u6_4jv^WCLfl7o&`8(@uma5h985nKdQF$#lea+h zCYM6{=QU?TB9gGz;sr)*rTs{=fV#+Mzeh+ermUG}I5xUd8NPJw6ZIoWYPb;kFHS5_ zcBuqIzSEYUEs4ZL`5Yy{T&;>#PK`6SG|~jByF@4hM>F)NFN<_!K1;sjA#=1KEuUml z74HY(*j+HY+kU)J(TL`yc7TuuWXIOQ#ZW#VCF5(5k}G1gK;95fH$|0j?Bsaw;FftNct!pG#L>{rwnp7aWM;BL5sO&zomh1L9kBMNj zVO7E9wBzz9<<0q^g!)HJ@jqCoV`>_1R95%05NSpT6yj1pZ>eHy>j); zpUht!*N{%p5^b=Bt4$@~Qa*sp?6(3Y6F~D5O$QQH8(4{ami@9KwuO&{g1INY;n~9c zvdh~oVbK}7F%f*!n`S754D<}NeZ5-gsgD-rQ|^b7(YeesB>7)vMBO{i5&~lVrqVWw z1cTJ>p+m*!MkpJ2j<_h6BC^Y-=<59;EZmWhPqlafqY~Rr8jZ~4y74=#h@2ULk-M)F zNz50OvUCAJ$9On^rWdxwO>rSQ-84o0Br}APRXn!WrLa(d=je{2ms{j0jpWhG)W%+X zCE~d+@+(-Dw&7}AF24x+?49_V=4Delr`D3|ulozlet+q>Vm%BDT7*;+=^<`9 z6JZCma8@6rqee5007Sl<+DHTTfglK{kn&i|0u`aqq9;?mA$RW1#JF_D7lgUfE70 z!-J)y7K&)#{5(DNlLuWi4~+NmfEuS6#%S94wRb-ZeraDgnlN-Z@-5VB@3QR>fH5Zx zQ9eQ#U(oJVH@m{quV?k`X+iS`ZY%>Z@uxR`!Hx6$ftlG0`Z;iNUyuQQ)?BBNvC! zXg5zQ!BXBl$uNo7o_H-m_TQC8E7IMz*7egni|rfnUAw)V*X`#t{*y~m*~9fojgJMw zP=%l-$)Fd;}Vp{d{_RpTd(zD1z-=JDuLo?a zT3(up8Zx0NR&qfTdb+l5XY<9m*<->fcR%N%Y&s&eGX3MC)Y7Or>LdI|-Hwx|Yctz^ zxYAdC|KvfK91D`(>gaO|)7#xouL4QCR|lZWE0})08WJYMZenbt*x80}!)T97reAS? zA>R?Rwdng>g_DzP`hmNgnoS*rU25PA+@jVrcQ+Dh zy^rz^9T;ORy)0ctn)fvu=HenY=|;RDTN88nn>5rWI728v`5ipXg> z@pwUV`IhW+41-y9U_%svX5L$5>Yqp9<0oW}<^nFO#_!{oEMLVt@GI{5T;*mInV5Et z6i<<-eEG)w9S40(zivijafSbd-Lhx_V>1U@$MC__o3kaW36x2zpY9(`px^hYx8uxz zlp)bqvmBroBq>Cxf05d|8&AdOL@$Qb)gvHm&QjwoLZ=br!@H^p57J2p`%mRWi4#2e z+?_C0T|D!SkG@?mnrLK$y(5}5CrJnI$MYLl7HcUZx_q_K*eJbTKD2_TUg@*yPs$Ct zSN}F)()1nZi0k5sX!0#-s>6Y8fvOqEoMyfv$1)LglUh`<1CD1(62_Jij*(}| zoy-`*-k?x1#Z=dD3!tr%NZ@Zpc#2!#EP*-}v8ZdfIBn+qD^7nL-{$>BjYs6gI?xVc z?0Oe6HI8B( zeOp>tNd*&V3sH}R#3yO_5nfFCmjZ9yaKegIXx{?a9qW%=V1TH)=z(k#PTNgi$!V~u z>vylkNFcyr_*1xi2p^auca!&uz;Ktzahw|MM&QIh5kUt{{vdBvq6Cr_9T-Rr|Iq#O6UwI#S8@Fc9hiU(IgrS7kKXevegne6GxTLFYdWyNSb{s7TH!7jTQ;X<7 z>7GP4y79@r-e=8zFTIYeHAoAT5nfwHJvIYaB451QSid}f)FQ`r6?lU*FK;%7WyZn@ zvuS@Xv3LCeUI$7z8db}eN+?VTW zj^&4K=?#%<^HxkWWzxu}vXx2V8?(21uQ{vJy82}Jr->SVtbKxC zQgvBA(xAK>SR*BG?3p*^NtUohmmqkh2})RuVcn3?8Cg63VbRP2thi}tisXR|xMn*h zzL)l^S~rUbTfcgF!c+R{EiCWtRCzCL#i203k>;-@&jcH?l+ZkmYAMUBi5~sx2Jt=I zkPoxbY}ogY=Qk5i5&WD=23wyUwo4ecq%t~BEq-F{+1}NjUZoe)ao!Jno)A0-{n=#9 zaJAxsk?arI_1tDfue+;QQ|?M$CIm->^k`EqNoFg8r0r|`uRZ|gJZz~ZHAu0CWG#`o z?f*Q!XZpiFJGKg;oJ+YX1Zf&tL<$RaR^hwT;`4w24BtIOKEF8eIc?P*oWeQhdp+AT zNv@)ZdaCQPu=rQvgHCb}red#tjGI3TBH7bmjS>uB6a&CI@>za6*JQltlW7YCB<3hm7k&Ltva(oCq??*hwEtvJ`We9a_W&5l~Kjwp}xo*a>Lsz z^klc7s*e07EqtVWAD_PceCFh2qs!(^eC@RRH5|Pe)iE-cj^bs?ysmg^3J1y<^rsjL#T&Aq4OmLMiD(3{M0tM5rS77pT^Il_gr7~NoO-Y3BCaU z;lETz;U5UaztARLAyF2GfL>UwI~17ZT~EH&OK}#P_-h}wlF7ui5noZqm7A9x^0QUP zNy=h9AOG4{Z&)NO6TUZ#0WV`I3~49cDG-ZspZJ8{SxEQ67*b#8v;%#UaGG*)y~7A_ z9&^{Cx*(n~PffZjV&6{pBz-?cEo4cKX?K=bR6Wb)pFpZxx^Qy;DDix!)qYK5bQoT_ z>}x760GwpqxRt6=@Q50QOmW~hca97yZo}NmwU;@NP zIzA4oiYvMD=?T%sr|O9QsMNkDnmJj-j2sb_7wbr%5J;?f-k?>ms|!K2T#2jKduP{8 zkd18PKg}^hbt&LhE^k1oB`*1HyFs@!HJ3~zq*lbS*L|#t1fu(V*O5-_jT5=J{K@2D zW3}To>~(l4+xPC%DL?pS0K4N@?vK|3<_TlGI@M(y@~%mk+a{9=YbTAyTFfuC2iSA$ zYmkGvEwsu7o=@<_hyqM>b+qKRSr$Ys}n02?Lt>GQGK79c4MLHc;W0;#nc_R0u*Y)@ui&iiTPH6D{icHW*6z9u^^25`hXu*~+A zsSYBDD#$v7Rt4t5PJM2M(NShMv;(X#3Y1zEJ+eLIDd07IqE ze=by><y_wD%M;b&mK})R z0=tIAfCByCRuv0APkivUamBwFVPKWOs!{$#3d{c-I=>|XeVNt3(=Aknb>cZQqv2y{ z=fgsntySrivDdA9m{nY11Tv?pby^CKzAcwPFyYCQCwge?Me8)>aF<8#1oGfy_pyir z`PPe>j2EY-JT#2RYVrGI1WLFrW+MjpV~dAyQ#zy4`n&gBdBN60cNlY@y`ZhRmd1JO zi}=_hIIP);I}#JiV)@OFYp>ASO=RCJy$f;VvR_{g>zoaj&x`}u8g7np&!M=i8if5? z)N`l$)A(?Ht{1+5R6=|7c8PGQGAh-*l@*#i*QeKRBbY(^jY$Lcg1^aN*WoXZchPy< z$W~pPIUl&xI0Xs%B?#YfFKpjXytU}SP=6{+#FxwTC+qDP3wpK9x=UJZ_9WJ^duzNE z@bHkbf;*AinzhU)7tgUd?JXvGNg1ye!=F5fX>r)jaMmj~9Sd_H1{Z?QJv!ek^@ZcY zCiD<|wpz>T7z4cRCBP$O&F;MKE8zT2xPl3tb&!gl6s9@N%^U2WOZR_)p&E5HODb;@ zVk3+ptP^i%p;kBQoJYhyAx2FvcJ z?hm6uKZtG7!*tOzSgmN*xzkJRE=>$+B3doiD~Y>S_cs=1t?#)}YYOgTxz}r>&|x2> ze+`->a0`2OOoYf(&!DG)HBR7~fo)qBN8+ggqaE&ClB>_eyLfqNOSK1JTEh!=e0Nt} zZWI1+n))&4Jj*4L=Vf|!)pg3tJBhO%qqlPsXjznKNF+ktaW;HRHH13(-?j}C+M znb)zTmeU*`Pe_b-dL@L`lL$`k8ZAq)~ z`%dJ3t!uN-Wz9{_H{BXI&*wS8=PE)OR~x?o6RRRUH6` zkMja<>o>k9$RQj;efntVF6}#7IUx2oM=>jL!R4g3;42NGMXFXh>XJQPa!dRrrr!9Q zAq9Bmg~_}|#r`0wqW`GP%=-iyh3zu>o4!{_(Lra=K?)U=+8(kF7uLRfVJ z;w*IHEpCWwsauZk!zKMp0npLoIiFrVP3(y}waJ8+4)K+I=bQlz8fL?LMSrO{{eTr8T7 zGWoGFEI38Sns1qw-#6TVnDJ=AyX0oV;y7mXWywvic1)R&+;zbF$Bm5df-r7!;;Ua! zCFRx#H;n!$hd2&f>8-rD4L*F)%E=8rLPH&wrSue`!d$)@3Y_rT?F}w-e3-5~Ot%>A z1NN)qP~Fg3oJRX?rK|b@$9$|q{F;wYfA{tQ<6Y`3untFIBOmZ?^e{v$K`5rqVDPng zqc}#!aw2RLLB4ZE46u2p`qENY8YsnO3;M>Ez;z}~lP6qR$cLI;52*z2KL6Zx>&RtY z7nKZTna?53V&f(s%bh-7ofMMIWT{P2dguG%W5F?FXED(Zug&Of%ie-KVh=sMpybEVRargVo(h?@pB)&w#WwyLEwvOn%G=m

JkdS8XteL^WXJXipw3!$Xd1|~(c(J+2gxMz zg9@D71#p%~QI}KBcN}i08i+M!?}VG5P9A=wZ@uF~?M@2(bPTz?8woi56d#a0q1tgwaXbuahmdWyc`@_pt z6Noa?a;Gjkee)hi@L;=}?r%=|yGK@r2ie*01J`>Emyd&)!vmw`ei#%NH&nj{9*q(T zNPOhB=;t05h7`X|L6eT15jnD%7;05dB9(JNe^Q)rZiUDLBXUu-rw$<$7u7*$lzNj2 zcb1SPQ8WF!cFyu_Gxu!XW(-+z?LT}+p;BU#A%nwfvU@jnN9kPQopS-vxo{-1nakp-E>dOBSs4TA`_f6(Q$o~V|?Ij5S literal 0 HcmV?d00001 diff --git a/portals/api-portal/src/defaultContent/images/api-portal-logo-white.svg b/portals/api-portal/src/defaultContent/images/api-portal-logo-white.svg new file mode 100644 index 000000000..4ba5bec9a --- /dev/null +++ b/portals/api-portal/src/defaultContent/images/api-portal-logo-white.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/portals/api-portal/src/pages/login-page/layout.hbs b/portals/api-portal/src/pages/login-page/layout.hbs index 08bf54181..e794392f9 100644 --- a/portals/api-portal/src/pages/login-page/layout.hbs +++ b/portals/api-portal/src/pages/login-page/layout.hbs @@ -36,7 +36,7 @@

- API Portal & MCP Hub + API Portal & MCP Hub
From 09b607dbc4be0cde72bd9f65d645ccfdd080b3e7 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 14:51:14 +0530 Subject: [PATCH 02/20] Rename Revoke keys to Remove keys --- .../api-portal/docs/consume-an-api/consume-with-oauth2.md | 2 +- .../pages/application/partials/manage-keys-km-card.hbs | 8 ++++---- portals/api-portal/src/scripts/oauth2-key-generation.js | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/portals/api-portal/docs/consume-an-api/consume-with-oauth2.md b/portals/api-portal/docs/consume-an-api/consume-with-oauth2.md index 55f7433e1..13fc6f42e 100644 --- a/portals/api-portal/docs/consume-an-api/consume-with-oauth2.md +++ b/portals/api-portal/docs/consume-an-api/consume-with-oauth2.md @@ -76,7 +76,7 @@ curl -X GET "https://api.example.com/orders/v1/orders" \ ## Revoke a Client ID -To remove a linked client ID, go to **Manage Keys** and click **Revoke keys** for that key manager. This only removes the local reference in the portal — it does not deregister or delete the OAuth application in the key manager, and any tokens already issued remain valid until they expire. To invalidate the OAuth application itself or revoke a specific token, use the key manager's own console or revoke endpoint. +To remove a linked client ID, go to **Manage Keys** and click **Remove keys** for that key manager. This only removes the local reference in the portal — it does not deregister or delete the OAuth application in the key manager, and any tokens already issued remain valid until they expire. To invalidate the OAuth application itself or revoke a specific token, use the key manager's own console or revoke endpoint. --- diff --git a/portals/api-portal/src/pages/application/partials/manage-keys-km-card.hbs b/portals/api-portal/src/pages/application/partials/manage-keys-km-card.hbs index 5d4c90171..c5faf8885 100644 --- a/portals/api-portal/src/pages/application/partials/manage-keys-km-card.hbs +++ b/portals/api-portal/src/pages/application/partials/manage-keys-km-card.hbs @@ -62,16 +62,16 @@
- {{!-- Revoke footer — part of Credentials tab --}} + {{!-- Remove-keys footer — part of Credentials tab --}} diff --git a/portals/api-portal/src/scripts/oauth2-key-generation.js b/portals/api-portal/src/scripts/oauth2-key-generation.js index 86f1d4f79..f5c120a98 100644 --- a/portals/api-portal/src/scripts/oauth2-key-generation.js +++ b/portals/api-portal/src/scripts/oauth2-key-generation.js @@ -58,12 +58,12 @@ async function addClientId(kmId, keyType, appId, orgId, keyManager) { } } -function confirmAndRevokeKeys(applicationId, keyMappingId, keyType) { +function confirmAndRemoveKeys(applicationId, keyMappingId, keyType) { const modal = document.getElementById('deleteConfirmation'); if (modal) { const titleEl = modal.querySelector('.modal-title'); const msgEl = modal.querySelector('.modal-message'); - if (titleEl) titleEl.textContent = 'Revoke Application Keys'; + if (titleEl) titleEl.textContent = 'Remove Application Keys'; if (msgEl) msgEl.textContent = 'Are you sure you want to remove this client ID? Tokens already issued remain valid until they expire.'; modal.dataset.applicationId = applicationId; modal.dataset.param2 = keyMappingId; @@ -74,7 +74,7 @@ function confirmAndRevokeKeys(applicationId, keyMappingId, keyType) { confirmBtn.removeEventListener('click', handler); if (confirmBtn) { confirmBtn.disabled = true; - confirmBtn.innerHTML = ' Revoking…'; + confirmBtn.innerHTML = ' Removing…'; } await removeApplicationKeys(applicationId, keyMappingId, keyType); if (confirmBtn) { From 202770e9e787953ea40f041db9e4e549d9a60b14 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 14:51:45 +0530 Subject: [PATCH 03/20] Fix MCP Servers breadcrumb link --- portals/api-portal/src/defaultContent/pages/docs/page.hbs | 2 +- .../api-portal/src/defaultContent/pages/mcp-landing/page.hbs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/portals/api-portal/src/defaultContent/pages/docs/page.hbs b/portals/api-portal/src/defaultContent/pages/docs/page.hbs index 00475b3b4..2a31cb965 100644 --- a/portals/api-portal/src/defaultContent/pages/docs/page.hbs +++ b/portals/api-portal/src/defaultContent/pages/docs/page.hbs @@ -19,7 +19,7 @@
- +
diff --git a/portals/api-portal/src/scripts/settings-apis.js b/portals/api-portal/src/scripts/settings-apis.js index 254f88466..3f764bc8b 100644 --- a/portals/api-portal/src/scripts/settings-apis.js +++ b/portals/api-portal/src/scripts/settings-apis.js @@ -680,9 +680,9 @@ setFieldError('wz-version', 'wz-version-error', version ? '' : 'Version is required.'); if (!version) ok = false; - var desc = v('wz-desc'); - setFieldError('wz-desc', 'wz-desc-error', desc ? '' : 'Description is required.'); - if (!desc) ok = false; + // Description is optional — api_metadata.description is nullable and the + // create/update API never required it. + setFieldError('wz-desc', 'wz-desc-error', ''); var handle = v('wz-handle'); if (!handle) { From 6ed347bbada0fd38ed3252f2866b0f9da339f3ff Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 14:55:45 +0530 Subject: [PATCH 06/20] Allow creating views without labels --- .../docs/api-portal-openapi-spec-v0.9.yaml | 6 +++--- .../rest-api/views-and-labels/views.spec.js | 19 ++++++++++++++++++- .../src/services/apiMetadataService.js | 5 ++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml b/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml index 577b30a76..a50a8ae2f 100644 --- a/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml +++ b/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml @@ -5359,7 +5359,6 @@ components: type: object required: - id - - labels properties: id: type: string @@ -5371,8 +5370,9 @@ components: example: Partner APIs labels: type: array - minItems: 1 - description: Label names to attach to the view. + description: >- + Label names to attach to the view. Optional — omit or pass an empty array to create a view with no + labels, which surfaces no APIs until labels are attached later via the update endpoint. items: type: string example: diff --git a/portals/api-portal/it/rest-api/views-and-labels/views.spec.js b/portals/api-portal/it/rest-api/views-and-labels/views.spec.js index be1092a98..b8c88a845 100644 --- a/portals/api-portal/it/rest-api/views-and-labels/views.spec.js +++ b/portals/api-portal/it/rest-api/views-and-labels/views.spec.js @@ -17,7 +17,8 @@ // -------------------------------------------------------------------- // POST/GET/PUT/DELETE /views. A view groups a set of labels to filter which -// APIs are visible in that portal view. ViewCreateRequest requires { id, labels }. +// APIs are visible in that portal view. ViewCreateRequest requires only { id } — +// labels are optional, so a view can be created first and labelled later. // `admin` manages org-level config. const client = require('../support/client'); @@ -49,6 +50,22 @@ describe('views', () => { expect(res.status).toBe(201); }); + it('creates a view with no labels', async () => { + const id = uniqueHandle('view'); + const res = await client.as('admin').post('/views', { id, displayName: 'Unlabelled View' }); + expect(res.status).toBe(201); + + const fetched = await client.as('admin').get(`/views/${id}`); + expect(fetched.status).toBe(200); + expect(fetched.body.labels).toEqual([]); + }); + + it('creates a view with an empty label array', async () => { + const id = uniqueHandle('view'); + const res = await client.as('admin').post('/views', { id, displayName: 'Empty Labels View', labels: [] }); + expect(res.status).toBe(201); + }); + it('retrieves a view', async () => { const id = uniqueHandle('view'); await client.as('admin').post('/views', { id, displayName: 'Retrievable View', labels: [label.id] }); diff --git a/portals/api-portal/src/services/apiMetadataService.js b/portals/api-portal/src/services/apiMetadataService.js index 4be25b2fb..919f3f4ae 100644 --- a/portals/api-portal/src/services/apiMetadataService.js +++ b/portals/api-portal/src/services/apiMetadataService.js @@ -1620,7 +1620,10 @@ const getOrgLabels = async (orgId) => { const addView = async (req, res) => { const orgId = req.orgId; - const labels = req.body.labels; + // labels is optional — a view with none is valid, it just surfaces no APIs + // until labels are attached later. getLabelId() iterates the list, so an + // absent one has to become [] here rather than reaching the DAO undefined. + const labels = Array.isArray(req.body.labels) ? req.body.labels : []; const userId = util.resolveActor(req); if (req.body.id) { req.body.handle = req.body.id; From d3a307fcc9b4f4419dc0d3972c003e4f30b68fef Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 14:58:12 +0530 Subject: [PATCH 07/20] Show 404 page for a non-existent view --- .../src/controllers/apiContentController.js | 10 +++++----- .../src/controllers/apiKeysOverviewController.js | 4 ++-- .../src/controllers/apiWorkflowsController.js | 4 ++-- .../controllers/applicationsContentController.js | 4 ++-- .../src/controllers/customContentController.js | 4 ++-- .../src/controllers/orgContentController.js | 8 ++++++-- .../controllers/subscriptionsContentController.js | 4 ++-- portals/api-portal/src/utils/util.js | 15 +++++++++++++++ 8 files changed, 36 insertions(+), 17 deletions(-) diff --git a/portals/api-portal/src/controllers/apiContentController.js b/portals/api-portal/src/controllers/apiContentController.js index 82cb454c8..aa1d4d558 100644 --- a/portals/api-portal/src/controllers/apiContentController.js +++ b/portals/api-portal/src/controllers/apiContentController.js @@ -145,7 +145,7 @@ const loadAPIs = async (req, res, next) => { const err = Object.assign(new Error(constants.ERROR_MESSAGE.COMMON_AUTH_ERROR_MESSAGE), { status: 401 }); return next(err); } else { - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } } @@ -436,7 +436,7 @@ const loadAPIContent = async (req, res, next) => { const err = Object.assign(new Error(constants.ERROR_MESSAGE.COMMON_AUTH_ERROR_MESSAGE), { status: 401 }); return next(err); } else { - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } } @@ -565,7 +565,7 @@ const loadDocsPage = async (req, res, next) => { error: error.message, stack: error.stack }); - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } } @@ -804,7 +804,7 @@ const loadDocument = async (req, res, next) => { error: error.message, stack: error.stack }); - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } res.send(html); @@ -813,7 +813,7 @@ const loadDocument = async (req, res, next) => { const err = Object.assign(new Error(constants.ERROR_MESSAGE.COMMON_AUTH_ERROR_MESSAGE), { status: 401 }); return next(err); } else { - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } } diff --git a/portals/api-portal/src/controllers/apiKeysOverviewController.js b/portals/api-portal/src/controllers/apiKeysOverviewController.js index 41da4397a..ebea5f14d 100644 --- a/portals/api-portal/src/controllers/apiKeysOverviewController.js +++ b/portals/api-portal/src/controllers/apiKeysOverviewController.js @@ -16,7 +16,7 @@ * under the License. */ -const { renderTemplateWithView, resolveActor } = require('../utils/util'); +const { renderTemplateWithView, resolveActor, pageErrorStatus } = require('../utils/util'); const logger = require('../config/logger'); const constants = require('../utils/constants'); const orgDao = require('../dao/organizationDao'); @@ -88,7 +88,7 @@ const loadApiKeysOverview = async (req, res, next) => { stack: error.stack, orgName, }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } }; diff --git a/portals/api-portal/src/controllers/apiWorkflowsController.js b/portals/api-portal/src/controllers/apiWorkflowsController.js index 45e75ee3e..6c2d4eb8c 100644 --- a/portals/api-portal/src/controllers/apiWorkflowsController.js +++ b/portals/api-portal/src/controllers/apiWorkflowsController.js @@ -137,7 +137,7 @@ const loadAPIWorkflows = async (req, res, next) => { orgName, viewName }); - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } }; @@ -218,7 +218,7 @@ const loadAPIWorkflowDetail = async (req, res, next) => { viewName, handle }); - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } }; diff --git a/portals/api-portal/src/controllers/applicationsContentController.js b/portals/api-portal/src/controllers/applicationsContentController.js index 4e35dc7fd..c3d5e7d98 100644 --- a/portals/api-portal/src/controllers/applicationsContentController.js +++ b/portals/api-portal/src/controllers/applicationsContentController.js @@ -16,7 +16,7 @@ * under the License. */ -const { renderTemplate, renderGivenTemplate, loadLayoutFromAPI, resolveActor } = require('../utils/util'); +const { renderTemplate, renderGivenTemplate, loadLayoutFromAPI, resolveActor, pageErrorStatus } = require('../utils/util'); const { config } = require('../config/configLoader'); const logger = require('../config/logger'); const constants = require('../utils/constants'); @@ -230,7 +230,7 @@ const loadApplications = async (req, res, next) => { error: error.message, stack: error.stack }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } res.send(html); diff --git a/portals/api-portal/src/controllers/customContentController.js b/portals/api-portal/src/controllers/customContentController.js index faae42d2f..0056f4dbf 100644 --- a/portals/api-portal/src/controllers/customContentController.js +++ b/portals/api-portal/src/controllers/customContentController.js @@ -16,7 +16,7 @@ * under the License. */ -const { renderTemplate, renderTemplateFromAPI, loadMarkdown, filePrefix } = require('../utils/util'); +const { renderTemplate, renderTemplateFromAPI, loadMarkdown, filePrefix, pageErrorStatus } = require('../utils/util'); const { config } = require('../config/configLoader'); const markdown = require('marked'); const fs = require('fs'); @@ -102,7 +102,7 @@ const loadCustomContent = async (req, res, next) => { stack: error.stack, filePath: req.params.filePath, }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } } diff --git a/portals/api-portal/src/controllers/orgContentController.js b/portals/api-portal/src/controllers/orgContentController.js index 5187c7716..a37283e9c 100644 --- a/portals/api-portal/src/controllers/orgContentController.js +++ b/portals/api-portal/src/controllers/orgContentController.js @@ -17,7 +17,7 @@ */ /* eslint-disable no-undef */ const logger = require('../config/logger'); -const { renderTemplate, renderTemplateFromAPI } = require('../utils/util'); +const { renderTemplate, renderTemplateFromAPI, pageErrorStatus } = require('../utils/util'); const { config } = require('../config/configLoader'); const constants = require('../utils/constants'); const orgDao = require('../dao/organizationDao'); @@ -31,6 +31,10 @@ const loadOrganizationContent = async (req, res, next) => { } else { html = await loadOrgContentFromAPI(req, res, next); } + // loadOrgContentFromAPI returns undefined once it has handed the error to + // next() — the error handler has already written the response by then, so + // sending again would throw ERR_HTTP_HEADERS_SENT over the real error. + if (html === undefined || res.headersSent) return; res.send(html); } const loadOrgContentFromFile = async (req, res) => { @@ -72,7 +76,7 @@ const loadOrgContentFromAPI = async (req, res, next) => { error: error.message, stack: error.stack }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } return html; diff --git a/portals/api-portal/src/controllers/subscriptionsContentController.js b/portals/api-portal/src/controllers/subscriptionsContentController.js index 0426f8e88..5cd3e56ed 100644 --- a/portals/api-portal/src/controllers/subscriptionsContentController.js +++ b/portals/api-portal/src/controllers/subscriptionsContentController.js @@ -16,7 +16,7 @@ * under the License. */ -const { renderTemplateWithView, resolveActor } = require('../utils/util'); +const { renderTemplateWithView, resolveActor, pageErrorStatus } = require('../utils/util'); const logger = require('../config/logger'); const constants = require('../utils/constants'); const orgDao = require('../dao/organizationDao'); @@ -79,7 +79,7 @@ const loadSubscriptions = async (req, res, next) => { stack: error.stack, orgName }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } }; diff --git a/portals/api-portal/src/utils/util.js b/portals/api-portal/src/utils/util.js index a8a22cc5b..1bb416e3d 100644 --- a/portals/api-portal/src/utils/util.js +++ b/portals/api-portal/src/utils/util.js @@ -342,6 +342,20 @@ function getErrors(errors) { return errorList; } +// Page controllers hand the central error handler (src/app.js) a status on +// `error.status`, but a DAO-level CustomError carries its HTTP status on +// `statusCode` instead. Forcing 500 in every catch turned a genuinely +// client-side failure — most visibly a URL naming a view that doesn't exist, +// where viewDao.getId throws CustomError(404) — into the "Oops! Something went +// wrong" 500 page. Preserve only the statuses the error page actually renders; +// anything else really is a 500. +const PAGE_ERROR_STATUSES = new Set([400, 403, 404]); + +function pageErrorStatus(error) { + const status = error?.statusCode ?? error?.status; + return PAGE_ERROR_STATUSES.has(status) ? status : 500; +} + function handleError(res, error) { if (db.isDuplicateKeyError(error)) { // Raw driver messages (pg/sqlite/mssql) can echo internal constraint/table @@ -1357,6 +1371,7 @@ module.exports = { renderLlmsTxt, renderGivenTemplate, handleError, + pageErrorStatus, sendError, retrieveContentType, getAPIFileContent, From f4c441fd9260b8fcaef2b5d9955389c7a3d11d0b Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 14:59:08 +0530 Subject: [PATCH 08/20] Fix blank MCP playground URL --- .../src/controllers/apiContentController.js | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/portals/api-portal/src/controllers/apiContentController.js b/portals/api-portal/src/controllers/apiContentController.js index aa1d4d558..63a39d5a2 100644 --- a/portals/api-portal/src/controllers/apiContentController.js +++ b/portals/api-portal/src/controllers/apiContentController.js @@ -665,19 +665,23 @@ const loadDocument = async (req, res, next) => { templateContent.isGraphQLTryout = tryoutEnabled; } let apiMetadata = definitionResponse.metaData; - - const isMCPFromRegistry = apiMetadata?.type === constants.API_TYPE.MCP && !apiMetadata?.refId; //load API definition if (req.originalUrl.includes(constants.FILE_NAME.API_SPECIFICATION_PATH)) { - if (isMCPFromRegistry) { - const remotes = apiMetadata?.remotes || []; - const serverUrl = remotes.length > 0 ? remotes[0].url : ''; - templateContent.swagger = JSON.stringify({ servers: [{ url: serverUrl }] }); - } else if (definitionResponse.apiType === constants.API_TYPE.MCP) { - // CP-registered MCP: use server URL from endPoints - templateContent.swagger = definitionResponse.swagger; + if (definitionResponse.apiType === constants.API_TYPE.MCP) { + // The playground reads its server URL from servers[0].url. A + // registry-sourced MCP carries that endpoint in remotes[]; one + // registered through the control plane carries it in endPoints + // (getAPIDefinition already wraps it as {servers:[...]}). + // Keying purely on refId sent every MCP created directly in the + // portal — which has no refId *and* no remotes[] — down the + // remotes path, leaving the playground with a blank URL. Prefer + // remotes when present, otherwise fall back to endPoints. + const remoteUrl = (apiMetadata?.remotes || [])[0]?.url; + templateContent.swagger = remoteUrl + ? JSON.stringify({ servers: [{ url: remoteUrl }] }) + : definitionResponse.swagger; } else if (definitionResponse.apiType !== constants.API_TYPE.WS && definitionResponse.apiType !== constants.API_TYPE.GRAPHQL && definitionResponse.apiType !== constants.API_TYPE.WEBSUB) { let modifiedSwagger; try { From 3efe15cdbd4b9cfbc951355c5c7ba83e99a5b1d5 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 15:00:15 +0530 Subject: [PATCH 09/20] Default control plane reference ID to org handle --- portals/api-portal/src/dao/organizationDao.js | 9 +++++++-- portals/api-portal/src/scripts/settings-organization.js | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/portals/api-portal/src/dao/organizationDao.js b/portals/api-portal/src/dao/organizationDao.js index 3bdce9a63..cb56b9a90 100644 --- a/portals/api-portal/src/dao/organizationDao.js +++ b/portals/api-portal/src/dao/organizationDao.js @@ -31,6 +31,11 @@ const create = async (orgData, t) => { const exec = t || db; const orgHandle = orgData.handle ? orgData.handle.toLowerCase() : ''; const uuid = crypto.randomUUID(); + // Control-plane reference defaults to the org handle when the caller didn't + // supply one — the two match in every normal deployment. Create-time only: + // update() leaves cp_ref_id alone unless explicitly passed, so an operator + // can still clear it back to empty afterwards. + const cpRefId = orgData.cpRefId ? orgData.cpRefId : orgHandle; await exec.execute( `INSERT INTO ${ORG_TABLE} @@ -39,7 +44,7 @@ const create = async (orgData, t) => { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ uuid, orgData.displayName, orgData.businessOwner, orgData.businessOwnerContact, - orgData.businessOwnerEmail, orgHandle, orgData.idpRefId, orgData.cpRefId, + orgData.businessOwnerEmail, orgHandle, orgData.idpRefId, cpRefId, orgData.configuration, orgData.createdBy, orgData.createdBy, ] ); @@ -51,7 +56,7 @@ const create = async (orgData, t) => { business_owner_email: orgData.businessOwnerEmail, handle: orgHandle, idp_ref_id: orgData.idpRefId, - cp_ref_id: orgData.cpRefId, + cp_ref_id: cpRefId, configuration: orgData.configuration, created_by: orgData.createdBy, updated_by: orgData.createdBy, diff --git a/portals/api-portal/src/scripts/settings-organization.js b/portals/api-portal/src/scripts/settings-organization.js index 71c67e7fc..568fb270d 100644 --- a/portals/api-portal/src/scripts/settings-organization.js +++ b/portals/api-portal/src/scripts/settings-organization.js @@ -48,7 +48,10 @@ var owner = g('org-owner').value.trim(); if (owner) body.businessOwner = owner; var oc = g('org-owner-contact').value.trim(); if (oc) body.businessOwnerContact = oc; if (oe) body.businessOwnerEmail = oe; - var cp = g('org-cp-ref').value.trim(); if (cp) body.cpRefId = cp; + // Always sent, blank included — orgDao.update only touches cp_ref_id when the + // key is present, so omitting a cleared field would silently keep the old + // value and make the reference impossible to remove from this form. + body.cpRefId = g('org-cp-ref').value.trim(); saveBtn.disabled = true; try { var res = await fetch(window.apiPortalApi.root('/organizations/' + encodeURIComponent(handle)), { From b48c4465d2fd772727ac147f72c4fb3c3c69110f Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 15:01:16 +0530 Subject: [PATCH 10/20] Stop label edits dropping APIs from the label --- .../api-portal/src/scripts/settings-labels.js | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/portals/api-portal/src/scripts/settings-labels.js b/portals/api-portal/src/scripts/settings-labels.js index 7f82fcf30..25ca3ba22 100644 --- a/portals/api-portal/src/scripts/settings-labels.js +++ b/portals/api-portal/src/scripts/settings-labels.js @@ -34,6 +34,9 @@ document.getElementById('cfg-label-modal-save').textContent = mode === 'edit' ? 'Save changes' : 'Add label'; document.getElementById('lbl-display').value = mode === 'edit' ? data.displayName : ''; document.getElementById('lbl-name').value = mode === 'edit' ? data.id : ''; + /* The handle is the label's identity — every API and view mapping keys off + it, so it cannot change after creation. */ + document.getElementById('lbl-name').readOnly = mode === 'edit'; document.getElementById('cfg-label-modal').style.display = 'flex'; document.getElementById('lbl-display').focus(); } @@ -54,22 +57,16 @@ try { var res; - if (editLabelName && editLabelName !== name) { - /* handle changed — handle is immutable, so delete old and create new */ - await fetch(window.apiPortalApi.root('/labels/'+encodeURIComponent(editLabelName)), { - method: 'DELETE', - headers: { 'X-CSRF-Token': window.apiPortalApi.csrfToken() }, - }); - res = await fetch(window.apiPortalApi.root('/labels'), { - method: 'POST', - headers: { 'Content-Type':'application/json', 'X-CSRF-Token': window.apiPortalApi.csrfToken() }, - body: JSON.stringify({ id: name, displayName: displayName }), - }); - } else if (editLabelName) { - res = await fetch(window.apiPortalApi.root('/labels/'+encodeURIComponent(name)), { + if (editLabelName) { + /* Handle is immutable and the input is read-only while editing, so this + is always a display-name update. It used to fall back to DELETE + POST + when the handle differed — but deleting a label cascades through + api_label_mappings / view_label_mappings, so every API and view lost + the label and the recreated one came back with no members. */ + res = await fetch(window.apiPortalApi.root('/labels/'+encodeURIComponent(editLabelName)), { method: 'PUT', headers: { 'Content-Type':'application/json', 'X-CSRF-Token': window.apiPortalApi.csrfToken() }, - body: JSON.stringify({ id: name, displayName: displayName }), + body: JSON.stringify({ id: editLabelName, displayName: displayName }), }); } else { res = await fetch(window.apiPortalApi.root('/labels'), { From d80dbc41846131222e9c1826c03fb8e64726b199 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 15:02:01 +0530 Subject: [PATCH 11/20] Label UI: name and handle --- .../src/pages/settings/partials/cfg-labels-panel.hbs | 2 +- .../api-portal/src/pages/settings/partials/cfg-modals.hbs | 6 +++--- portals/api-portal/src/scripts/settings-labels.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/portals/api-portal/src/pages/settings/partials/cfg-labels-panel.hbs b/portals/api-portal/src/pages/settings/partials/cfg-labels-panel.hbs index 4425a2725..e9762a8aa 100644 --- a/portals/api-portal/src/pages/settings/partials/cfg-labels-panel.hbs +++ b/portals/api-portal/src/pages/settings/partials/cfg-labels-panel.hbs @@ -14,8 +14,8 @@ - + diff --git a/portals/api-portal/src/pages/settings/partials/cfg-modals.hbs b/portals/api-portal/src/pages/settings/partials/cfg-modals.hbs index 02f388c60..1ab8cb133 100644 --- a/portals/api-portal/src/pages/settings/partials/cfg-modals.hbs +++ b/portals/api-portal/src/pages/settings/partials/cfg-modals.hbs @@ -98,13 +98,13 @@
- +
- + -

Lowercase identifier used internally.

+

Lowercase identifier used internally. Cannot be changed.

diff --git a/portals/api-portal/src/scripts/settings-labels.js b/portals/api-portal/src/scripts/settings-labels.js index 25ca3ba22..9cbc063e7 100644 --- a/portals/api-portal/src/scripts/settings-labels.js +++ b/portals/api-portal/src/scripts/settings-labels.js @@ -42,7 +42,7 @@ } function closeLabelModal() { document.getElementById('cfg-label-modal').style.display = 'none'; editLabelName = null; } - /* ── auto-slug display → name (skips once the user edits Name) ── */ + /* ── auto-slug name → handle (skips once the user edits Handle) ── */ document.getElementById('lbl-name').addEventListener('input', function() { labelHandleTouched = true; }); document.getElementById('lbl-display').addEventListener('input', function() { if (editLabelName || labelHandleTouched) return; @@ -53,7 +53,7 @@ document.getElementById('cfg-label-modal-save').addEventListener('click', async function() { var displayName = v('lbl-display'); var name = v('lbl-name'); - if (!displayName || !name) { await showAlert('Display name and name are required.', 'error'); return; } + if (!displayName || !name) { await showAlert('Name and handle are required.', 'error'); return; } try { var res; From 02647556afda9c39644b8923272717033ef9a6fe Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 15:04:12 +0530 Subject: [PATCH 12/20] Webhook UI: name and handle --- .../it/rest-api/support/fixtures.js | 5 ++-- .../settings/partials/cfg-webhook-form.hbs | 7 ++++- .../settings/partials/cfg-webhooks-panel.hbs | 6 ++-- .../src/scripts/settings-webhooks.js | 30 ++++++++++++++++--- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/portals/api-portal/it/rest-api/support/fixtures.js b/portals/api-portal/it/rest-api/support/fixtures.js index 956f8aded..ced8bedb3 100644 --- a/portals/api-portal/it/rest-api/support/fixtures.js +++ b/portals/api-portal/it/rest-api/support/fixtures.js @@ -98,8 +98,9 @@ async function createApi(overrides = {}) { } // `admin` manages org-level integration config; pass `role` to override. -// displayName is required by WebhookSubscriberRequest (the settings UI collects a -// name, not a handle, and the handle is generated from it when `id` is omitted). +// displayName is required by WebhookSubscriberRequest; `id` (the handle) is +// optional here — the server generates a UUID when it is omitted, which is what +// these fixtures rely on. The settings UI collects both explicitly. async function createWebhookSubscriber(overrides = {}) { const { role = 'admin', ...bodyOverrides } = overrides; const res = await client.as(role).post('/webhook-subscribers', { diff --git a/portals/api-portal/src/pages/settings/partials/cfg-webhook-form.hbs b/portals/api-portal/src/pages/settings/partials/cfg-webhook-form.hbs index 628b6924f..f0bcf41d3 100644 --- a/portals/api-portal/src/pages/settings/partials/cfg-webhook-form.hbs +++ b/portals/api-portal/src/pages/settings/partials/cfg-webhook-form.hbs @@ -15,9 +15,14 @@
- +
+
+ + +

Identifier used in the API path. Cannot be changed.

+
diff --git a/portals/api-portal/src/pages/settings/partials/cfg-webhooks-panel.hbs b/portals/api-portal/src/pages/settings/partials/cfg-webhooks-panel.hbs index f42c2ec56..ae5a88a0d 100644 --- a/portals/api-portal/src/pages/settings/partials/cfg-webhooks-panel.hbs +++ b/portals/api-portal/src/pages/settings/partials/cfg-webhooks-panel.hbs @@ -18,7 +18,8 @@
Display name NameHandle
- + + @@ -33,6 +34,7 @@ + {{/webhookSubscribers}} {{#unless webhookSubscribers.length}} - + {{/unless}}
Display nameNameHandle Target URL Events Secret {{id}} {{targetUrl}} @@ -60,7 +62,7 @@
No webhooks yet. Add one to get started.
No webhooks yet. Add one to get started.
diff --git a/portals/api-portal/src/scripts/settings-webhooks.js b/portals/api-portal/src/scripts/settings-webhooks.js index 82e39ef27..f2d703922 100644 --- a/portals/api-portal/src/scripts/settings-webhooks.js +++ b/portals/api-portal/src/scripts/settings-webhooks.js @@ -25,6 +25,7 @@ // field means "keep the stored one", so the mandatory check below only applies when // there is nothing stored to keep — on create, or on a legacy row saved without one. var editHasSecret = false; + var handleTouched = false; function v(id) { var e=document.getElementById(id); return e?e.value.trim():''; } @@ -113,9 +114,14 @@ function showWebhookForm(mode, data) { editWebhookId = mode === 'edit' ? data.id : null; editHasSecret = mode === 'edit' && !!data.hasSecret; + handleTouched = false; document.getElementById('wh-form-title').textContent = mode === 'edit' ? 'Edit webhook' : 'Add webhook'; document.getElementById('wh-form-save').textContent = mode === 'edit' ? 'Save changes' : 'Add webhook'; document.getElementById('wh-display').value = mode === 'edit' ? (data.displayName || '') : ''; + document.getElementById('wh-handle').value = mode === 'edit' ? (data.id || '') : ''; + /* The handle is the subscriber's identity and the id in its API path, so it + is fixed once created. */ + document.getElementById('wh-handle').readOnly = mode === 'edit'; document.getElementById('wh-url').value = mode === 'edit' ? (data.targetUrl || '') : ''; document.getElementById('wh-secret').value = ''; document.getElementById('wh-timeout').value = mode === 'edit' ? (data.timeoutMs || 5000) : 5000; @@ -137,11 +143,25 @@ editHasSecret = false; } + /* ── auto-slug name → handle (skips once the user edits Handle, or on edit) ── */ + var whHandleRe = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; + document.getElementById('wh-handle').addEventListener('input', function() { handleTouched = true; }); + document.getElementById('wh-display').addEventListener('input', function() { + if (editWebhookId || handleTouched) return; + document.getElementById('wh-handle').value = + this.value.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + }); + /* ── save ── */ document.getElementById('wh-form-save').addEventListener('click', async function() { var displayName = v('wh-display'); + var handle = v('wh-handle'); var url = v('wh-url'); - if (!displayName || !url) { await showAlert('Display name and target URL are required.', 'error'); return; } + if (!displayName || !handle || !url) { await showAlert('Name, handle and target URL are required.', 'error'); return; } + if (!whHandleRe.test(handle)) { + await showAlert('Handle must be lowercase letters, numbers and hyphens only.', 'error'); + return; + } // The secret both signs deliveries and encrypts sensitive event fields, so a // subscriber without one silently loses the API key / token payloads entirely. @@ -172,9 +192,6 @@ return; } - // No `id`: the server derives the handle from displayName on create, and omitting - // it on update leaves the existing handle untouched (the DAO patches sparsely). - // Sending one here would silently rename the subscriber on every save. var body = { displayName: displayName, targetUrl: url, @@ -182,6 +199,11 @@ enabled: document.getElementById('wh-enabled').checked, timeoutMs: timeoutMs, }; + // `id` only on create — that is the caller-chosen handle, and the server + // falls back to a random UUID when it is absent. On update it is omitted + // deliberately: the handle is immutable, and the DAO patches sparsely, so + // sending one would rename the subscriber on every save. + if (!editWebhookId) body.id = handle; var secret = v('wh-secret'); if (secret) body.secret = secret; From fae82e8c0dd5599b11c3e05bbe816b6e53b02f25 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 15:06:15 +0530 Subject: [PATCH 13/20] Make handles read-only when editing --- .../pages/settings/partials/cfg-apis-panel.hbs | 2 +- portals/api-portal/src/scripts/settings-apis.js | 5 +++++ portals/api-portal/src/styles/settings-layout.css | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/portals/api-portal/src/pages/settings/partials/cfg-apis-panel.hbs b/portals/api-portal/src/pages/settings/partials/cfg-apis-panel.hbs index ae35cbfb1..5d0b40e9a 100644 --- a/portals/api-portal/src/pages/settings/partials/cfg-apis-panel.hbs +++ b/portals/api-portal/src/pages/settings/partials/cfg-apis-panel.hbs @@ -168,7 +168,7 @@
-

Auto-generated from name & version. Edit to override.

+

Auto-generated from name & version. Edit to override — cannot be changed once created.

diff --git a/portals/api-portal/src/scripts/settings-apis.js b/portals/api-portal/src/scripts/settings-apis.js index 3f764bc8b..60e91063e 100644 --- a/portals/api-portal/src/scripts/settings-apis.js +++ b/portals/api-portal/src/scripts/settings-apis.js @@ -263,6 +263,10 @@ document.getElementById('cfg-wizard-title').textContent = isMcp ? 'Edit MCP Server' : 'Edit API'; sv('wz-name', api.apiName); sv('wz-handle', api.apiHandle); handleTouched = true; + /* The handle is the API's identity in every portal URL and apiDao.update + never writes it — leaving the field editable let a change look accepted + while being silently discarded. */ + document.getElementById('wz-handle').readOnly = true; sv('wz-version', api.apiVersion); sel('wz-type', api.apiType); sel('wz-status', api.apiStatus === 'DEPRECATED' ? 'DEPRECATED' : 'PUBLISHED'); @@ -286,6 +290,7 @@ editingId = null; document.getElementById('cfg-wizard-title').textContent = isMcp ? 'Add MCP Server' : 'Add API'; ['wz-name','wz-version','wz-handle','wz-desc','wz-tags','wz-prod','wz-sandbox','wz-tech-owner','wz-tech-email','wz-biz-owner','wz-biz-email'].forEach(function(id){ sv(id,''); }); + document.getElementById('wz-handle').readOnly = false; sel('wz-type', isMcp ? 'Mcp' : 'RestApi'); sel('wz-status','PUBLISHED'); agentVis = 'Visible'; document.getElementById('wz-vis-visible').classList.add('active'); diff --git a/portals/api-portal/src/styles/settings-layout.css b/portals/api-portal/src/styles/settings-layout.css index 1e870482c..796896bb6 100644 --- a/portals/api-portal/src/styles/settings-layout.css +++ b/portals/api-portal/src/styles/settings-layout.css @@ -599,6 +599,21 @@ .cfg-form-input--mono { font-family: var(--font-family-mono); font-size: 0.8125rem; } +/* Read-only fields — handles, and values the operator sets elsewhere. A handle is + a resource's identity and is immutable after creation, so the field must not + look typeable. Attribute selector (0,2,0) outranks .cfg-form-input (0,1,0), + including the :focus rule above, which is overridden explicitly below. */ +.cfg-form-input[readonly] { + background: var(--surface-sunken); + color: var(--text-muted); + cursor: not-allowed; +} + +.cfg-form-input[readonly]:focus { + border-color: var(--border-strong); + box-shadow: none; +} + /* ── File upload zone ────────────────────────────────────── */ .cfg-upload-zone { From 0090c83ae06dbadd686ecf9e3f44e58e3b3b2574 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 15:15:13 +0530 Subject: [PATCH 14/20] Remove service API key authentication --- .../api-portal/configs/config-template.toml | 6 --- portals/api-portal/it/README.md | 6 ++- .../it/docker-compose.test.postgres.yaml | 1 - .../api-portal/it/docker-compose.test.yaml | 1 - .../single-org-isolation.spec.js | 54 ++++++++++--------- portals/api-portal/it/test-config.toml | 4 -- .../e2e/001-basic/001-portal-access.cy.js | 4 ++ .../e2e/002-apis/001-api-listing.cy.js | 2 + .../e2e/002-apis/002-rest-api-details.cy.js | 2 + .../e2e/003-mcp-servers/001-mcp-listing.cy.js | 2 + .../e2e/applications/application-flows.cy.js | 4 ++ .../e2e/settings/001-views-labels.cy.js | 7 +-- .../e2e/settings/002-key-managers.cy.js | 7 +-- .../it/ui/cypress/support/commands/portal.js | 25 +++++---- .../it/ui/cypress/support/commands/seed.js | 13 ++--- .../api-portal/src/config/configDefaults.js | 5 -- .../src/controllers/apiContentController.js | 4 +- .../src/middlewares/authMiddleware.js | 49 +++++++---------- .../src/middlewares/csrfProtection.js | 15 +----- .../src/middlewares/ensureAuthenticated.js | 17 ------ 20 files changed, 99 insertions(+), 129 deletions(-) diff --git a/portals/api-portal/configs/config-template.toml b/portals/api-portal/configs/config-template.toml index c74ed2228..64465cedd 100644 --- a/portals/api-portal/configs/config-template.toml +++ b/portals/api-portal/configs/config-template.toml @@ -92,12 +92,6 @@ pool_request_timeout_ms = 30000 # MSSQL only - per-query execution timeo encryption_key = "" # 64-char hex — AES-256-GCM key for encrypting secrets at rest session_secret = "" # 64-char hex — express-session signing secret -# Static shared-secret header for calling the API Portal's own REST API. -[api_portal.security.service_api_key] -enabled = true -header_name = "x-wso2-api-key" -value = "" - # ============================================================================= # AUTHENTICATION # ============================================================================= diff --git a/portals/api-portal/it/README.md b/portals/api-portal/it/README.md index 912c57030..4dd94719f 100644 --- a/portals/api-portal/it/README.md +++ b/portals/api-portal/it/README.md @@ -195,8 +195,10 @@ After a run, artifacts are available under `reports/`: - **REST API suite** performs **real session logins** against `platform-api` using the file-based users defined in `configs/config-platform-api-it.toml` (`admin`/`admin`, `publisher`/`publisher`, `developer`/`developer`). -- **UI suite** uses both real login flows (`auth/` specs) and, for admin-protected REST - calls, an IT API key injected via the `x-wso2-api-key` header. +- **UI suite** uses real login flows throughout. Seeding and cleanup hooks call + `cy.login()` and then `cy.apiRequest`, which authenticates with the resulting session + cookie plus the `X-CSRF-Token` double-submit header. `testIsolation` clears cookies + between tests, so each `before`/`after` hook needs its own `cy.login()`. ## Adding New Tests diff --git a/portals/api-portal/it/docker-compose.test.postgres.yaml b/portals/api-portal/it/docker-compose.test.postgres.yaml index 3a86742f9..505b424e9 100644 --- a/portals/api-portal/it/docker-compose.test.postgres.yaml +++ b/portals/api-portal/it/docker-compose.test.postgres.yaml @@ -169,7 +169,6 @@ services: shm_size: '2gb' environment: CYPRESS_BASE_URL: "http://api-portal:9543" - CYPRESS_API_KEY: "api-portal-it-test-key" volumes: - ./ui:/e2e - ./reports:/e2e/reports diff --git a/portals/api-portal/it/docker-compose.test.yaml b/portals/api-portal/it/docker-compose.test.yaml index cb50fa7d2..3b495658a 100644 --- a/portals/api-portal/it/docker-compose.test.yaml +++ b/portals/api-portal/it/docker-compose.test.yaml @@ -143,7 +143,6 @@ services: shm_size: '2gb' environment: CYPRESS_BASE_URL: "http://api-portal:9543" - CYPRESS_API_KEY: "api-portal-it-test-key" volumes: - ./ui:/e2e - ./reports:/e2e/reports diff --git a/portals/api-portal/it/rest-api/organizations/single-org-isolation.spec.js b/portals/api-portal/it/rest-api/organizations/single-org-isolation.spec.js index 960813fb4..6c73c9d3b 100644 --- a/portals/api-portal/it/rest-api/organizations/single-org-isolation.spec.js +++ b/portals/api-portal/it/rest-api/organizations/single-org-isolation.spec.js @@ -21,18 +21,22 @@ // accept one must reject anything but this instance's own organization: // // page URLs /{orgHandle}/... -> 404 (src/middlewares/orgGuard.js) -// `organization` header on an API-key request -> 403 (src/middlewares/authMiddleware.js) // -// These are all unauthenticated or API-key surfaces — the point is that no -// credential is needed to attempt them, so the rejection cannot depend on one. -// Token/session organization claims are covered by auth/file-based-login.spec.js. +// The page surfaces are unauthenticated — the point is that no credential is +// needed to attempt them, so the rejection cannot depend on one. +// +// authMiddleware's `organization` header check (resolvePortalOrg -> 403) now only +// applies to mTLS, the sole remaining credential that carries no organization of +// its own; the static service API key that used to reach it was removed, and this +// fixture provisions no client certificates, so that path is not exercised here. +// For session and bearer credentials the organization comes from the credential's +// own claim (resolveScopedOrg) and the header is never a selector — asserted +// below, and covered further by auth/file-based-login.spec.js. const client = require('../support/client'); const OWN_ORG = client.ORG_HANDLE; const FOREIGN_ORG = 'some-other-org'; -const API_KEY_HEADER = 'x-wso2-api-key'; -const API_KEY = process.env.API_PORTAL_API_KEY || 'api-portal-it-test-key'; describe('single-organization isolation', () => { describe('page routes', () => { @@ -71,31 +75,31 @@ describe('single-organization isolation', () => { }); }); - describe('organization request header', () => { - it('scopes an API-key request to this organization when no header is sent', async () => { - const res = await client.raw() - .get(`${client.API_PREFIX}/apis`) - .set(API_KEY_HEADER, API_KEY); + describe('organization request header on a session credential', () => { + beforeAll(async () => { + await client.login('admin'); + }); + + it('serves the caller organization when no header is sent', async () => { + const res = await client.as('admin').get('/apis'); expect(res.status).toBe(200); }); - it('accepts a header naming this organization', async () => { - const res = await client.raw() - .get(`${client.API_PREFIX}/apis`) - .set(API_KEY_HEADER, API_KEY) - .set('organization', OWN_ORG); + it('serves the caller organization when the header names it', async () => { + const res = await client.as('admin').get('/apis').set('organization', OWN_ORG); expect(res.status).toBe(200); }); - it('rejects a header naming another organization with 403', async () => { - // Honouring this header would make one API key able to address every - // tenant in the shared database. Rejecting rather than ignoring it also - // keeps a caller from believing it wrote to the organization it named. - const res = await client.raw() - .get(`${client.API_PREFIX}/apis`) - .set(API_KEY_HEADER, API_KEY) - .set('organization', FOREIGN_ORG); - expect(res.status).toBe(403); + it('does not let the header redirect the request to another organization', async () => { + // The organization comes from the session's own claim, so this header is + // not a selector. Honouring it would let one credential address every + // tenant in the shared database; the request must stay scoped to the + // caller's organization rather than reaching the one it named. + const res = await client.as('admin').get('/apis').set('organization', FOREIGN_ORG); + expect(res.status).toBe(200); + + const own = await client.as('admin').get('/apis'); + expect(res.body).toEqual(own.body); }); }); diff --git a/portals/api-portal/it/test-config.toml b/portals/api-portal/it/test-config.toml index 73e7d0288..b473bcd77 100644 --- a/portals/api-portal/it/test-config.toml +++ b/portals/api-portal/it/test-config.toml @@ -38,10 +38,6 @@ name = '{{ env "APIP_AP_DATABASE_NAME" "api_portal" }}' encryption_key = '{{ env "APIP_AP_SECURITY_ENCRYPTION_KEY" }}' session_secret = '{{ env "APIP_AP_SECURITY_SESSION_SECRET" }}' -[api_portal.security.service_api_key] -enabled = true -value = "api-portal-it-test-key" - [api_portal.organization] # The single organization each portal instance serves. Tokenized because the # fixture runs a SECOND instance (api-portal-other-org) off this same file with a diff --git a/portals/api-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js b/portals/api-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js index 79249ccc9..424136de4 100644 --- a/portals/api-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js @@ -27,11 +27,15 @@ describe('API Portal — Portal Access', () => { let mcpHandle; before(() => { + // Seeding goes through the REST API as a logged-in admin — the portal's + // static service API key is gone. + cy.login(); cy.seedApi().then((handle) => { apiHandle = handle; }); cy.seedMcp().then((handle) => { mcpHandle = handle; }); }); after(() => { + cy.login(); cy.deleteApi(apiHandle); cy.deleteMcp(mcpHandle); }); diff --git a/portals/api-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js b/portals/api-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js index 14ce730bf..1e6e81d46 100644 --- a/portals/api-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js @@ -29,6 +29,7 @@ describe('API listing', () => { let mcpHandle; before(() => { + cy.login(); // Seed two APIs of different types so the listing shows distinct badges, // plus an MCP server — which must NOT appear on the /apis listing (it // belongs on /mcps). loadAPIs filters type !== MCP for the APIs page. @@ -44,6 +45,7 @@ describe('API listing', () => { }); after(() => { + cy.login(); cy.deleteApi(restHandle); cy.deleteApi(gqlHandle); cy.deleteMcp(mcpHandle); diff --git a/portals/api-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js b/portals/api-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js index f8461e462..0d91d464b 100644 --- a/portals/api-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js @@ -26,6 +26,7 @@ describe('REST API — overview, documentation & try-out', () => { let apiHandle; before(() => { + cy.login(); cy.seedApi({ name: API_NAME, version: 'v1.0', @@ -67,6 +68,7 @@ describe('REST API — overview, documentation & try-out', () => { }); after(() => { + cy.login(); cy.deleteApi(apiHandle); }); diff --git a/portals/api-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js b/portals/api-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js index 24b956747..535c6ba63 100644 --- a/portals/api-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js @@ -30,6 +30,7 @@ describe('MCP server listing', () => { let restHandle; before(() => { + cy.login(); cy.seedMcp({ name: MCP_ONE }).then((h) => { mcpOneHandle = h; }); cy.seedMcp({ name: MCP_TWO }).then((h) => { mcpTwoHandle = h; }); // A REST API — must NOT appear on the /mcps listing (it belongs on /apis). @@ -37,6 +38,7 @@ describe('MCP server listing', () => { }); after(() => { + cy.login(); cy.deleteMcp(mcpOneHandle); cy.deleteMcp(mcpTwoHandle); cy.deleteApi(restHandle); diff --git a/portals/api-portal/it/ui/cypress/e2e/applications/application-flows.cy.js b/portals/api-portal/it/ui/cypress/e2e/applications/application-flows.cy.js index 9c98bb509..0adef3f2b 100644 --- a/portals/api-portal/it/ui/cypress/e2e/applications/application-flows.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/applications/application-flows.cy.js @@ -100,6 +100,9 @@ describe('Applications', () => { let mockToken; before(() => { + // Key managers are admin-only over the REST API, and seeding now + // authenticates with a session rather than the removed service API key. + cy.login(); // Start the mock OAuth2 token endpoint only for this context, and point // the key manager at it so the token round-trip can actually resolve. cy.task('startMockTokenServer').then((mock) => { @@ -113,6 +116,7 @@ describe('Applications', () => { }); after(() => { + cy.login(); cy.deleteKeyManager(KM_ID); cy.task('stopMockTokenServer'); }); diff --git a/portals/api-portal/it/ui/cypress/e2e/settings/001-views-labels.cy.js b/portals/api-portal/it/ui/cypress/e2e/settings/001-views-labels.cy.js index 000bc0170..409c2fdb0 100644 --- a/portals/api-portal/it/ui/cypress/e2e/settings/001-views-labels.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/settings/001-views-labels.cy.js @@ -28,9 +28,9 @@ describe('Settings — Views & Labels', () => { const VIEW_HANDLE = `it-view-${uid}`; // slugify(VIEW_NAME) const LABEL_DISPLAY = `IT Label ${uid}`; const LABEL_HANDLE = `it-label-${uid}`; // slugify(LABEL_DISPLAY) - // A view requires at least one label (ViewCreateRequest.labels has minItems: 1), so - // the view test needs an existing label to pick. Seed one up front, distinct from the - // label the label test creates. + // Labels are optional on a view, but this test exercises attaching one from the + // picker, so it needs an existing label to click. Seed one up front, distinct from + // the label the label test creates. const VIEW_LABEL = `it-vlabel-${uid}`; const settingsUrl = () => `/${Cypress.env('ORG_HANDLE')}/settings`; @@ -42,6 +42,7 @@ describe('Settings — Views & Labels', () => { after(() => { // Robust API cleanup, idempotent (404 if the create step never persisted). + cy.login(); cy.apiRequest('DELETE', `/api/v0.9/views/${VIEW_HANDLE}`, { failOnStatusCode: false }); cy.apiRequest('DELETE', `/api/v0.9/labels/${LABEL_HANDLE}`, { failOnStatusCode: false }); cy.apiRequest('DELETE', `/api/v0.9/labels/${VIEW_LABEL}`, { failOnStatusCode: false }); diff --git a/portals/api-portal/it/ui/cypress/e2e/settings/002-key-managers.cy.js b/portals/api-portal/it/ui/cypress/e2e/settings/002-key-managers.cy.js index 29c3ad191..d48ce233f 100644 --- a/portals/api-portal/it/ui/cypress/e2e/settings/002-key-managers.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/settings/002-key-managers.cy.js @@ -37,11 +37,12 @@ describe('Settings — Key Managers', () => { cy.clearCookies(); cy.login('developer', 'developer'); cy.deleteApplication(APP_NAME); - // Then the key manager. Clear the developer session so this authorizes via the - // admin API key (x-wso2-api-key) — with the developer session still set, the - // server would authorize as `developer`, who lacks dp:key_manager:read, and return 403. + // Then the key manager. Swap the developer session for an admin one — the + // REST call authorizes as whoever is logged in, and `developer` lacks + // dp:key_manager:read, so it would 403. // The handle is a server-generated UUID, so discover it by display name. cy.clearCookies(); + cy.login(); cy.apiRequest('GET', '/api/v0.9/key-managers').then((res) => { (res.body.list || []) .filter((km) => km.displayName === KM_NAME) diff --git a/portals/api-portal/it/ui/cypress/support/commands/portal.js b/portals/api-portal/it/ui/cypress/support/commands/portal.js index a98b18ef1..df24bd2d8 100644 --- a/portals/api-portal/it/ui/cypress/support/commands/portal.js +++ b/portals/api-portal/it/ui/cypress/support/commands/portal.js @@ -30,21 +30,28 @@ Cypress.Commands.add('portalUrl', (path = '') => { // --------------------------------------------------------------------------- // cy.apiRequest(method, path, options) -// Thin wrapper around cy.request that includes the API key header for -// accessing admin-protected portal endpoints in the IT environment. +// Call a portal REST endpoint as whichever user is currently logged in. +// +// The portal's static service API key (x-wso2-api-key) was removed, so these +// calls authenticate with the ordinary session cookie — cy.request shares the +// browser's cookie jar — plus the X-CSRF-Token header that csrfProtection +// requires on mutating cookie-authenticated requests (double-submit of the +// XSRF-TOKEN cookie the server sets on every response). +// +// Callers must have established a session first: testIsolation clears cookies +// between tests, so before()/after() hooks need their own cy.login(). // --------------------------------------------------------------------------- Cypress.Commands.add('apiRequest', (method, path, options = {}) => { - const apiKey = Cypress.env('API_KEY'); - const headers = apiKey - ? { 'x-wso2-api-key': apiKey, ...(options.headers || {}) } - : (options.headers || {}); - return cy.request({ + return cy.getCookie('XSRF-TOKEN').then((csrf) => cy.request({ method, url: path, failOnStatusCode: options.failOnStatusCode !== false, ...options, - headers, - }); + headers: { + ...(csrf && csrf.value ? { 'X-CSRF-Token': decodeURIComponent(csrf.value) } : {}), + ...(options.headers || {}), + }, + })); }); // --------------------------------------------------------------------------- diff --git a/portals/api-portal/it/ui/cypress/support/commands/seed.js b/portals/api-portal/it/ui/cypress/support/commands/seed.js index 1b767a669..451d2e067 100644 --- a/portals/api-portal/it/ui/cypress/support/commands/seed.js +++ b/portals/api-portal/it/ui/cypress/support/commands/seed.js @@ -19,11 +19,12 @@ // --------------------------------------------------------------------------- // Seed helpers — create demo REST APIs / MCP servers through the real portal // management API (POST /api/v0.9/apis, /mcp-servers) so UI browse tests have -// something to render. They go through cy.apiRequest, which injects the -// service API-key header; the `organization` header selects the target org -// (authMiddleware.resolveOrgFromHeader), and `labels: ['default']` maps the -// resource into the default view so it appears on the /apis and /mcps listings -// (apiDao.list requires a label mapped to the view). +// something to render. They go through cy.apiRequest, which authenticates with +// the caller's session cookie — so the calling hook must cy.login() first. The +// `organization` header names the target org (authMiddleware.resolvePortalOrg +// rejects any other), and `labels: ['default']` maps the resource into the +// default view so it appears on the /apis and /mcps listings (apiDao.list +// requires a label mapped to the view). // // These endpoints take multipart/form-data. cy.request runs in Node, not the // browser, so a browser FormData won't serialize — instead we build the @@ -50,7 +51,7 @@ function buildMultipart(parts) { function seedHeaders(contentType) { return { - // authMiddleware resolves the target org from this header for API-key requests. + // Checked against the organization this instance serves; a mismatch is a 403. organization: Cypress.env('ORG_HANDLE'), 'content-type': contentType, }; diff --git a/portals/api-portal/src/config/configDefaults.js b/portals/api-portal/src/config/configDefaults.js index e07c45ffe..05602b315 100644 --- a/portals/api-portal/src/config/configDefaults.js +++ b/portals/api-portal/src/config/configDefaults.js @@ -75,11 +75,6 @@ const DEFAULTS = { security: { encryptionKey: '', sessionSecret: '', - serviceApiKey: { - enabled: true, - headerName: 'x-wso2-api-key', - value: '', - }, }, // Authentication — HOW a token is verified: a mode gate plus the two backends it // selects between, local (default) and idp. What a verified token may DO is diff --git a/portals/api-portal/src/controllers/apiContentController.js b/portals/api-portal/src/controllers/apiContentController.js index 63a39d5a2..22bdb978b 100644 --- a/portals/api-portal/src/controllers/apiContentController.js +++ b/portals/api-portal/src/controllers/apiContentController.js @@ -613,7 +613,7 @@ const loadDocument = async (req, res, next) => { const schemaAsIntrospectionJSON = await convertSDLToIntrospection(definitionResponse.swagger); templateContent.graphqlSchemaAsIntrospectionJSON = schemaAsIntrospectionJSON ? JSON.stringify(schemaAsIntrospectionJSON) : null; templateContent.graphqlSecurityScheme = '[]'; - templateContent.graphqlApiKeyHeader = config.security?.serviceApiKey?.headerName || 'apikey'; + templateContent.graphqlApiKeyHeader = 'apikey'; templateContent.apiMetadata = metaData; } else { templateContent.graphql = JSON.stringify(definitionResponse.swagger); @@ -738,7 +738,7 @@ const loadDocument = async (req, res, next) => { const schemaAsIntrospectionJSON = await convertSDLToIntrospection(definitionResponse.graphql); templateContent.graphqlSchemaAsIntrospectionJSON = schemaAsIntrospectionJSON ? JSON.stringify(schemaAsIntrospectionJSON) : null; templateContent.graphqlSecurityScheme = '[]'; - templateContent.graphqlApiKeyHeader = config.security?.serviceApiKey?.headerName || 'apikey'; + templateContent.graphqlApiKeyHeader = 'apikey'; } else { templateContent.graphql = definitionResponse.graphql ? JSON.stringify(definitionResponse.graphql) : '""'; templateContent.apiMetadataJSON = JSON.stringify(apiMetadata || {}); diff --git a/portals/api-portal/src/middlewares/authMiddleware.js b/portals/api-portal/src/middlewares/authMiddleware.js index 8a85d6285..73838dbe1 100644 --- a/portals/api-portal/src/middlewares/authMiddleware.js +++ b/portals/api-portal/src/middlewares/authMiddleware.js @@ -243,14 +243,14 @@ async function resolveScopedOrg(req, identifier, source) { } /** - * Sets req.orgId for credentials that carry no organization of their own (service - * API key, mTLS) — they are authenticated as this portal's operator, so the only - * organization they can be acting on is the one this instance serves. + * Sets req.orgId for credentials that carry no organization of their own (mTLS) — + * they are authenticated as this portal's operator, so the only organization they + * can be acting on is the one this instance serves. * * An `organization` header is no longer what selects the organization; it is only - * checked for disagreement. Honouring it would let a single API key address every - * tenant in the shared database. Rejecting a mismatch rather than ignoring it keeps - * a caller from believing it wrote to the organization it named. + * checked for disagreement. Honouring it would let a single credential address + * every tenant in the shared database. Rejecting a mismatch rather than ignoring + * it keeps a caller from believing it wrote to the organization it named. * * @returns {Promise} null on success, or an Error with .status */ @@ -315,7 +315,7 @@ async function authResolver(req, res, next) { // 2. Session fast-path: browser login via IDP. // // In "scope" mode the per-operation check is bypassed (preauthorized, same as the - // API key and mTLS paths): the IDP mints whatever scopes its client is registered + // mTLS path): the IDP mints whatever scopes its client is registered // for, which would mean listing all dp:* scopes in the OIDC scope config, so the // authorization that actually applies to these sessions is ensureAuthenticated's // page role check. @@ -397,21 +397,7 @@ async function authResolver(req, res, next) { return next(); } - // 4. API key — org resolved from the `organization` request header - if (config.security?.serviceApiKey?.enabled) { - const keyType = config.security.serviceApiKey.headerName; - if (keyType && config.security?.serviceApiKey?.value) { - const apiKey = req.headers[keyType.toLowerCase()]; - if (apiKey && apiKey === config.security?.serviceApiKey?.value) { - const orgErr = await resolvePortalOrg(req); - if (orgErr) return next(orgErr); - req.auth = { mode: 'apikey', preauthorized: true, scopes: [] }; - return next(); - } - } - } - - // 5. mTLS — org resolved from the `organization` request header + // 4. mTLS — org resolved from the `organization` request header if (typeof req.socket?.getPeerCertificate === 'function') { const cert = req.socket.getPeerCertificate(true); if (cert && Object.keys(cert).length > 0 && req.client?.authorized) { @@ -425,7 +411,7 @@ async function authResolver(req, res, next) { } } - // 6. No usable credential — pass through as anonymous so the OpenAPI + // 5. No usable credential — pass through as anonymous so the OpenAPI // validator can enforce security on a per-operation basis. Operations // with `security: []` (public endpoints) will proceed; operations that // declare a security scheme will have their handler invoked by the @@ -476,16 +462,17 @@ async function OAuth2Security(req /* , requiredScopes, schema */) { } /** - * API key security handler. Accepts the request if authResolver already - * authenticated it via API key (or any preauthorized non-OAuth mode, to - * mirror legacy behaviour where API key endpoints also accepted basic/mTLS). - */ -/* - * TODO: once the API key support introduces with scope support, change the method - * to check for scopes as well, and rename it to ApiKeySecurity for clarity. + * Handler for the spec's `apiKeyAuth` security scheme. No operation currently + * declares that scheme, so the validator never invokes this — it stays wired up + * so adding `security: [apiKeyAuth]` to an operation doesn't fail at startup. + * Accepts any preauthorized non-OAuth mode (mTLS, role-mode session). + * + * The portal's own static shared-secret header auth (`service_api_key`) was + * removed; this is not a revival of it. Any future API key scheme needs its own + * credential store and a real scope check here, not a config constant. */ async function apiKeyAuth(req /* , scopes, schema */) { - if (req.auth?.mode === 'apikey' || req.auth?.preauthorized) return true; + if (req.auth?.preauthorized) return true; const err = new Error('Authentication required'); err.status = 401; throw err; diff --git a/portals/api-portal/src/middlewares/csrfProtection.js b/portals/api-portal/src/middlewares/csrfProtection.js index 5b50ee579..e9e8cda99 100644 --- a/portals/api-portal/src/middlewares/csrfProtection.js +++ b/portals/api-portal/src/middlewares/csrfProtection.js @@ -17,7 +17,6 @@ */ const crypto = require('crypto'); -const { config } = require('../config/configLoader'); const CSRF_HMAC_LABEL = 'api-portal-api-keys-csrf'; @@ -48,15 +47,6 @@ function hasBearerAuthorization(req) { return typeof a === 'string' && a.length > 6 && a.toLowerCase().startsWith('bearer '); } -function hasConfiguredApiKey(req) { - if (!config.security?.serviceApiKey?.enabled || !config.security.serviceApiKey.headerName || !config.security.serviceApiKey.value) { - return false; - } - const keyType = config.security.serviceApiKey.headerName; - const sentKey = req.headers[keyType.toLowerCase()] || req.headers[keyType]; - return sentKey === config.security.serviceApiKey.value; -} - function hasMTLSClient(req) { try { const cert = req.socket?.getPeerCertificate?.(true); @@ -81,15 +71,12 @@ function timingSafeCompare(a, b) { /** * For mutating API Portal REST routes: cookie-based sessions must send X-CSRF-Token * matching getSessionCsrfToken. Skips when auth matches non-browser paths used by - * enforceSecurity (Bearer, API key, mTLS). + * enforceSecurity (Bearer, mTLS). */ function requireCsrfForMutatingApi(req, res, next) { if (hasBearerAuthorization(req)) { return next(); } - if (hasConfiguredApiKey(req)) { - return next(); - } if (hasMTLSClient(req)) { return next(); } diff --git a/portals/api-portal/src/middlewares/ensureAuthenticated.js b/portals/api-portal/src/middlewares/ensureAuthenticated.js index 50f745183..29daf7309 100644 --- a/portals/api-portal/src/middlewares/ensureAuthenticated.js +++ b/portals/api-portal/src/middlewares/ensureAuthenticated.js @@ -97,8 +97,6 @@ function enforceSecurity(scope) { const decodedAccessToken = safeDecodeJwt(token); req[constants.USER_ID] = await resolveUserUuid(req, decodedAccessToken?.[constants.USER_ID]); return validateAuthentication(scope)(req, res, next); - } else if (config.security.serviceApiKey.enabled) { - enforceAPIKey(req, res, next); } else if (typeof req.socket?.getPeerCertificate === 'function' && req.socket.getPeerCertificate(true)) { enforceMTLS(req, res, next); } else { @@ -402,21 +400,6 @@ const enforceMTLS = (req, res, next) => { return next(); }; -const enforceAPIKey = (req, res, next) => { - const keyType = config.security?.serviceApiKey?.headerName; - - if (!keyType || !config.security?.serviceApiKey?.value) { - return res.status(500).json({ error: "Server configuration error" }); - } - - const apiKey = req.headers[keyType.toLowerCase()]; - - if (!apiKey || apiKey !== config.security?.serviceApiKey?.value) { - return res.status(401).json({ error: "Unauthorized: API key is invalid or not found" }); - } - return next(); -}; - module.exports = { ensureAuthenticated, validateAuthentication, From 21db82c72116fe309c1135dec82276358f0c8ff9 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 15:28:20 +0530 Subject: [PATCH 15/20] Return 404 for missing view on all view routes --- .../src/controllers/apiContentController.js | 27 ++++++++++++++----- .../src/middlewares/registerPartials.js | 9 ++++++- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/portals/api-portal/src/controllers/apiContentController.js b/portals/api-portal/src/controllers/apiContentController.js index 22bdb978b..f2f7eecdd 100644 --- a/portals/api-portal/src/controllers/apiContentController.js +++ b/portals/api-portal/src/controllers/apiContentController.js @@ -1128,6 +1128,21 @@ async function convertSDLToIntrospection(sdl) { } +/** + * Markdown/agent-facing endpoints answer in text rather than the HTML error + * page, so they can't hand the error to the central handler. A URL naming a + * view or API that doesn't exist is still the caller's mistake, not a server + * fault — the DAOs raise those as CustomError(404), whose status sits on + * `statusCode`, so treating every failure as 500 told an agent to retry + * something that will never succeed. + */ +function sendMarkdownError(res, error, failureMessage) { + if (util.pageErrorStatus(error) === 404) { + return res.status(404).send('# Not Found\n\nThe requested resource does not exist.'); + } + return res.status(500).send(`# Error\n\n${failureMessage}`); +} + const loadAPIContentMd = async (req, res) => { const { orgName, apiHandle, viewName } = req.params; @@ -1251,7 +1266,7 @@ const loadAPIContentMd = async (req, res) => { error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to load API details.'); + sendMarkdownError(res, error, 'Failed to load API details.'); } }; @@ -1328,7 +1343,7 @@ const loadLlmsTxt = async (req, res) => { res.send(md); } catch (error) { logger.error('Error generating llms.txt', { orgName, error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to generate portal index.'); + sendMarkdownError(res, error, 'Failed to generate portal index.'); } }; @@ -1353,7 +1368,7 @@ const previewLlmsTxt = async (req, res) => { res.send(md); } catch (error) { logger.error('Error previewing llms.txt', { orgName, error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to generate preview.'); + sendMarkdownError(res, error, 'Failed to generate preview.'); } }; @@ -1408,7 +1423,7 @@ const loadAPIsMd = async (req, res) => { error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to load API list.'); + sendMarkdownError(res, error, 'Failed to load API list.'); } }; @@ -1449,7 +1464,7 @@ const loadMCPsMd = async (req, res) => { error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to load MCP list.'); + sendMarkdownError(res, error, 'Failed to load MCP list.'); } }; @@ -1560,7 +1575,7 @@ const loadDocumentMd = async (req, res) => { error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to load document.'); + sendMarkdownError(res, error, 'Failed to load document.'); } }; diff --git a/portals/api-portal/src/middlewares/registerPartials.js b/portals/api-portal/src/middlewares/registerPartials.js index e1d6dfda7..bec4917f7 100644 --- a/portals/api-portal/src/middlewares/registerPartials.js +++ b/portals/api-portal/src/middlewares/registerPartials.js @@ -114,7 +114,14 @@ const registerPartials = async (req, res, next) => { notFound.status = 404; return next(notFound); } - next(error); + // Partial resolution reads per-view content, so a URL naming a view that + // doesn't exist surfaces here as a CustomError(404) — before any page + // controller runs. That status lives on `statusCode`, which the central + // handler doesn't read, so without this it rendered the 500 "Oops!" page + // instead of "Page not found". `return` matters too: falling through to + // the next() below would call next twice on the same request. + error.status = util.pageErrorStatus(error); + return next(error); } } next(); From 905dd9d3f26fcd1fa5893a9b65203c7e3894e094 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 15:39:48 +0530 Subject: [PATCH 16/20] Make control plane reference ID behave like IDP reference ID --- portals/api-portal/src/dao/organizationDao.js | 22 ++++++++----------- .../src/scripts/settings-organization.js | 6 ++--- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/portals/api-portal/src/dao/organizationDao.js b/portals/api-portal/src/dao/organizationDao.js index cb56b9a90..b3692830c 100644 --- a/portals/api-portal/src/dao/organizationDao.js +++ b/portals/api-portal/src/dao/organizationDao.js @@ -31,11 +31,6 @@ const create = async (orgData, t) => { const exec = t || db; const orgHandle = orgData.handle ? orgData.handle.toLowerCase() : ''; const uuid = crypto.randomUUID(); - // Control-plane reference defaults to the org handle when the caller didn't - // supply one — the two match in every normal deployment. Create-time only: - // update() leaves cp_ref_id alone unless explicitly passed, so an operator - // can still clear it back to empty afterwards. - const cpRefId = orgData.cpRefId ? orgData.cpRefId : orgHandle; await exec.execute( `INSERT INTO ${ORG_TABLE} @@ -44,7 +39,7 @@ const create = async (orgData, t) => { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ uuid, orgData.displayName, orgData.businessOwner, orgData.businessOwnerContact, - orgData.businessOwnerEmail, orgHandle, orgData.idpRefId, cpRefId, + orgData.businessOwnerEmail, orgHandle, orgData.idpRefId, orgData.cpRefId, orgData.configuration, orgData.createdBy, orgData.createdBy, ] ); @@ -56,7 +51,7 @@ const create = async (orgData, t) => { business_owner_email: orgData.businessOwnerEmail, handle: orgHandle, idp_ref_id: orgData.idpRefId, - cp_ref_id: cpRefId, + cp_ref_id: orgData.cpRefId, configuration: orgData.configuration, created_by: orgData.createdBy, updated_by: orgData.createdBy, @@ -143,18 +138,19 @@ const update = async (orgData, t) => { const orgHandle = orgData.handle ? orgData.handle.toLowerCase() : existing.handle; const updatedAt = new Date(); + // cp_ref_id is written unconditionally, exactly like idp_ref_id: both are + // plain optional reference fields with no derived default, so a blank one + // clears the stored value rather than silently keeping the old one. const setClauses = [ 'display_name = ?', 'business_owner = ?', 'business_owner_contact = ?', - 'business_owner_email = ?', 'handle = ?', 'idp_ref_id = ?', 'updated_by = ?', 'updated_at = ?', + 'business_owner_email = ?', 'handle = ?', 'idp_ref_id = ?', 'cp_ref_id = ?', + 'updated_by = ?', 'updated_at = ?', ]; const params = [ orgData.displayName, orgData.businessOwner, orgData.businessOwnerContact, - orgData.businessOwnerEmail, orgHandle, orgData.idpRefId, orgData.updatedBy, updatedAt, + orgData.businessOwnerEmail, orgHandle, orgData.idpRefId, orgData.cpRefId, + orgData.updatedBy, updatedAt, ]; - if (orgData.cpRefId !== undefined) { - setClauses.push('cp_ref_id = ?'); - params.push(orgData.cpRefId); - } if (orgData.configuration !== undefined) { setClauses.push('configuration = ?'); params.push(orgData.configuration); diff --git a/portals/api-portal/src/scripts/settings-organization.js b/portals/api-portal/src/scripts/settings-organization.js index 568fb270d..382f46321 100644 --- a/portals/api-portal/src/scripts/settings-organization.js +++ b/portals/api-portal/src/scripts/settings-organization.js @@ -48,9 +48,9 @@ var owner = g('org-owner').value.trim(); if (owner) body.businessOwner = owner; var oc = g('org-owner-contact').value.trim(); if (oc) body.businessOwnerContact = oc; if (oe) body.businessOwnerEmail = oe; - // Always sent, blank included — orgDao.update only touches cp_ref_id when the - // key is present, so omitting a cleared field would silently keep the old - // value and make the reference impossible to remove from this form. + // Always sent, blank included — same as idpRefId above. orgDao.update writes + // cp_ref_id unconditionally, so omitting a cleared field would leave the old + // value in place and make the reference impossible to remove from this form. body.cpRefId = g('org-cp-ref').value.trim(); saveBtn.disabled = true; try { From d3cc81f3e7e2eef6a393977cf3aa8d769a4305f0 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 16:24:37 +0530 Subject: [PATCH 17/20] Fix tests --- portals/api-portal/it/docker-compose.test.postgres.yaml | 2 +- portals/api-portal/it/docker-compose.test.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/portals/api-portal/it/docker-compose.test.postgres.yaml b/portals/api-portal/it/docker-compose.test.postgres.yaml index 505b424e9..ed21c83af 100644 --- a/portals/api-portal/it/docker-compose.test.postgres.yaml +++ b/portals/api-portal/it/docker-compose.test.postgres.yaml @@ -51,7 +51,7 @@ services: # build) instead of the last release — this fixture's config-platform-api-it.toml # tracks platform-api's current config schema, which a pinned old release may not # understand. - image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.13.0-SNAPSHOT} + image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.14.0-SNAPSHOT} command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" diff --git a/portals/api-portal/it/docker-compose.test.yaml b/portals/api-portal/it/docker-compose.test.yaml index 3b495658a..352a42a10 100644 --- a/portals/api-portal/it/docker-compose.test.yaml +++ b/portals/api-portal/it/docker-compose.test.yaml @@ -30,7 +30,7 @@ services: # build) instead of the last release — this fixture's config-platform-api-it.toml # tracks platform-api's current config schema, which a pinned old release may not # understand. - image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.13.0-SNAPSHOT} + image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.14.0-SNAPSHOT} command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" From 6892dce04219b7bb5a66e4c57a5d1416ba31400a Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 16:24:54 +0530 Subject: [PATCH 18/20] Fix version displaying issue --- .../src/defaultContent/pages/apis/partials/apis-md.hbs | 8 ++++---- .../src/defaultContent/pages/mcps/partials/mcps-md.hbs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/portals/api-portal/src/defaultContent/pages/apis/partials/apis-md.hbs b/portals/api-portal/src/defaultContent/pages/apis/partials/apis-md.hbs index 019426ead..ead1ef285 100644 --- a/portals/api-portal/src/defaultContent/pages/apis/partials/apis-md.hbs +++ b/portals/api-portal/src/defaultContent/pages/apis/partials/apis-md.hbs @@ -6,7 +6,7 @@ Explore our extensive API catalog and discover how to integrate them seamlessly # APIs {{#restAPIs}} -- [{{name}} v{{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} +- [{{name}} {{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} {{/restAPIs}} {{/if}} {{#if graphqlAPIs}} @@ -14,7 +14,7 @@ Explore our extensive API catalog and discover how to integrate them seamlessly # GraphQL APIs {{#graphqlAPIs}} -- [{{name}} v{{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} +- [{{name}} {{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} {{/graphqlAPIs}} {{/if}} {{#if wsAPIs}} @@ -22,7 +22,7 @@ Explore our extensive API catalog and discover how to integrate them seamlessly # Async / WebSocket APIs {{#wsAPIs}} -- [{{name}} v{{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} +- [{{name}} {{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} {{/wsAPIs}} {{/if}} {{#if websubAPIs}} @@ -30,6 +30,6 @@ Explore our extensive API catalog and discover how to integrate them seamlessly # WebSub APIs {{#websubAPIs}} -- [{{name}} v{{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} +- [{{name}} {{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} {{/websubAPIs}} {{/if}} diff --git a/portals/api-portal/src/defaultContent/pages/mcps/partials/mcps-md.hbs b/portals/api-portal/src/defaultContent/pages/mcps/partials/mcps-md.hbs index a9d2da497..be2cc1a23 100644 --- a/portals/api-portal/src/defaultContent/pages/mcps/partials/mcps-md.hbs +++ b/portals/api-portal/src/defaultContent/pages/mcps/partials/mcps-md.hbs @@ -6,6 +6,6 @@ Explore our MCP server catalog and connect AI agents to powerful tools and data # MCP Servers {{#mcpAPIs}} -- [{{name}} v{{version}}]({{../baseUrl}}/mcp/{{id}}.md){{#if description}} - {{description}}{{/if}} +- [{{name}} {{version}}]({{../baseUrl}}/mcp/{{id}}.md){{#if description}} - {{description}}{{/if}} {{/mcpAPIs}} {{/if}} From b690361cd0b0fe82b1ab989d18b5fe9fdcb1ddb1 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sat, 1 Aug 2026 17:06:46 +0530 Subject: [PATCH 19/20] Add tests with role based auth --- portals/api-portal/it/README.md | 12 +- .../it/configs/portal-roles-role-mode-it.yaml | 96 ++++++++++ .../it/docker-compose.test.postgres.yaml | 43 +++++ .../api-portal/it/docker-compose.test.yaml | 50 +++++ .../auth/role-mode-authorization.spec.js | 179 ++++++++++++++++++ portals/api-portal/it/test-config.toml | 23 ++- 6 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 portals/api-portal/it/configs/portal-roles-role-mode-it.yaml create mode 100644 portals/api-portal/it/rest-api/auth/role-mode-authorization.spec.js diff --git a/portals/api-portal/it/README.md b/portals/api-portal/it/README.md index 4dd94719f..27173d170 100644 --- a/portals/api-portal/it/README.md +++ b/portals/api-portal/it/README.md @@ -41,6 +41,16 @@ Each suite can run against either **SQLite** (default, no external DB) or **Post organization and refuses a login carrying any other one's — a check the matched pair above can never reach. Started only for the `test-rest-api*` targets (as a `rest-api-tests` dependency), and driven by `rest-api/auth/foreign-org-login.spec.js`. +- **api-portal-role-mode** — a third instance, identical to the primary one except that it + runs `auth.authorization.mode = "role"` (the shipped default) instead of `"scope"`, with its + own portal-side grant table, `configs/portal-roles-role-mode-it.yaml`. Role mode ignores a + token's scope claim and expands its roles claim instead, so the primary instance can never + exercise it. That grant table deliberately gives `dp_developer_it` *less* than + `configs/roles-platform-api-it.yaml` puts in the same user's scope claim, which is what lets + the spec prove the scope claim is ignored rather than merged. Started only for the + `test-rest-api*` targets, and driven by `rest-api/auth/role-mode-authorization.spec.js`. + Runs on its own container-local SQLite database in both DB variants — instances sharing a + database steal each other's webhook deliveries, and each has a different encryption key. - **Jest + Supertest** — REST API test framework. - **Cypress** — UI E2E test framework (headless Electron). - **SQLite / PostgreSQL** — SQLite by default; the `-postgres` targets swap in a Postgres service. @@ -134,7 +144,7 @@ Defined in `ui/cypress/support/`: |---------|-------------| | `cy.visitPortal(path)` | Navigate to a path inside the default portal view | | `cy.portalUrl(path)` | Build a URL under the default view without visiting it | -| `cy.apiRequest(method, path, options)` | `cy.request` wrapper that injects the IT API key header for admin-protected endpoints | +| `cy.apiRequest(method, path, options)` | `cy.request` wrapper that authenticates with the current session cookie plus the `X-CSRF-Token` header (call `cy.login()` first) | | `cy.login(username, password)` | Perform a real login flow (see `support/commands/auth.js`) | | `cy.logout()` | Log the current user out | | `cy.createApplication(name)` / `cy.deleteApplication(name)` | Create/delete an application (see `support/commands/applications.js`) | diff --git a/portals/api-portal/it/configs/portal-roles-role-mode-it.yaml b/portals/api-portal/it/configs/portal-roles-role-mode-it.yaml new file mode 100644 index 000000000..221a4d65e --- /dev/null +++ b/portals/api-portal/it/configs/portal-roles-role-mode-it.yaml @@ -0,0 +1,96 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# -------------------------------------------------------------------- +# +# The PORTAL's role-to-scope grant table for the `api-portal-role-mode` service in +# docker-compose.test*.yaml (auth.authorization.role_to_scope_mapping). Read only by +# that instance, which runs with auth.authorization.mode = "role" — the shipped +# default in configs/config.toml, and the mode the rest of this suite cannot exercise +# because it is pinned to "scope" (see test-config.toml). +# +# NOT the same file as roles-platform-api-it.yaml, and deliberately so. +# +# roles-platform-api-it.yaml — platform-api's table. Expands each IT account's +# roles into the *scope claim* of the token it mints. +# this file — the portal's table. In role mode the portal ignores +# that scope claim entirely and expands the token's +# *roles claim* through this file instead. +# +# Two tables, same three role names, deliberately different grants. That divergence is +# the point: it is what lets role-mode-authorization.spec.js prove the scope claim is +# ignored rather than merged. `dp_developer_it` is read-only here while platform-api +# grants it dp:application:manage, so an application create by the developer must be +# refused by the role-mode instance even though the token it presents carries a scope +# that would allow it. Keep that asymmetry — collapsing the two tables would make the +# suite pass whether or not role mode consults the scope claim. +# +# Every dp:* scope below must be declared in docs/api-portal-openapi-spec-v0.9.yaml; +# the portal validates this file against the spec at startup (src/config/roleScopeMap.js) +# and refuses to boot on an unknown one, so a typo here fails the fixture loudly. +# -------------------------------------------------------------------- + +roles: + # Mirrors the shipped dp_admin grant — full portal administration. The admin + # assertions expect every management operation to succeed under this role. + - name: dp_admin_it + scopes: + - dp:organization:manage + - dp:organization_content:manage + - dp:api:manage + - dp:api_content:manage + - dp:mcp_server:manage + - dp:mcp_server_content:manage + - dp:api_workflow:manage + - dp:api_key:manage + - dp:mcp_server_key:manage + - dp:application:manage + - dp:application_key:manage + - dp:application_key:revoke + - dp:application_key_mapping:manage + - dp:subscription:manage + - dp:subscription_plan:manage + - dp:key_manager:manage + - dp:key_manager:read + - dp:view:manage + - dp:label:manage + - dp:webhook_subscriber:manage + - dp:event:read + + # Catalogue manager — APIs, MCP servers, views and labels. No organization + # settings, no key managers, no webhook subscribers: those separate it from + # dp_admin_it in the "a narrower role is denied an admin operation" assertions. + - name: dp_publisher_it + scopes: + - dp:api:manage + - dp:api_content:manage + - dp:mcp_server:manage + - dp:mcp_server_content:manage + - dp:api_workflow:manage + - dp:view:manage + - dp:label:manage + - dp:subscription_plan:read + - dp:organization:read + - dp:organization_content:read + + # Read-only. Intentionally NARROWER than the same role's grant in + # roles-platform-api-it.yaml, which additionally carries dp:application:manage, + # dp:subscription:manage and dp:api_key:manage. Those absences are load-bearing — + # see the header note. + - name: dp_developer_it + scopes: + - dp:api:read + - dp:api_content:read + - dp:mcp_server:read + - dp:mcp_server_content:read + - dp:subscription_plan:read + - dp:organization:read + - dp:organization_content:read + - dp:view:read + - dp:label:read diff --git a/portals/api-portal/it/docker-compose.test.postgres.yaml b/portals/api-portal/it/docker-compose.test.postgres.yaml index ed21c83af..5c5e7fe00 100644 --- a/portals/api-portal/it/docker-compose.test.postgres.yaml +++ b/portals/api-portal/it/docker-compose.test.postgres.yaml @@ -159,6 +159,44 @@ services: networks: - it-api-portal-network + # Role-mode instance — see the extended note on the same service in + # docker-compose.test.yaml. Differs from `api-portal` only in + # auth.authorization.mode = "role" plus the grant table that backs it. + # + # Runs on its own container-local SQLite database even in the Postgres leg, rather + # than joining the shared `api_portal` database. Two reasons, and the first is not + # optional: every portal instance runs a webhook dispatcher and delivery worker that + # poll the events tables, so instances sharing a database steal each other's + # deliveries — and each carries its own security.encryption_key, so the thief cannot + # decrypt the subscriber secret the owner stored, which fails the webhook encryption + # assertions in api-keys/ and subscriptions/. Second, what this service exists to + # cover — how a request's effective scopes are derived — is entirely dialect- + # independent; the Postgres leg exercises the data layer through `api-portal`. + api-portal-role-mode: + image: ${DOCKER_REGISTRY:-ghcr.io/wso2/api-platform}/api-portal:test + depends_on: + platform-api: + condition: service_healthy + volumes: + - ./test-config.toml:/app/configs/config.toml:ro + - ./configs/portal-roles-role-mode-it.yaml:/etc/api-portal/portal-roles-role-mode-it.yaml:ro + - ./.certs/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro + environment: + APIP_AP_DATABASE_DRIVER: sqlite + APIP_AP_DATABASE_PATH: /tmp/api-portal-it-role-mode.db + APIP_AP_AUTH_AUTHORIZATION_MODE: role + APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING: /etc/api-portal/portal-roles-role-mode-it.yaml + APIP_AP_SECURITY_ENCRYPTION_KEY: "3d1f8a5c07b94e2d6f0a8c3b5e7d9f1a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d" + APIP_AP_SECURITY_SESSION_SECRET: "9e7c5a3b1d8f6042ae2c4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6a" + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:9543/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""] + interval: 10s + timeout: 5s + retries: 20 + start_period: 20s + networks: + - it-api-portal-network + cypress: image: cypress/included:13.17.0 platform: ${CYPRESS_PLATFORM:-linux/amd64} @@ -187,6 +225,8 @@ services: # while keeping it out of the Cypress-only `make test-postgres`. api-portal-other-org: condition: service_healthy + api-portal-role-mode: + condition: service_healthy working_dir: /rest-api environment: API_PORTAL_BASE_URL: "http://api-portal:9543" @@ -194,6 +234,9 @@ services: # tokens for. auth/foreign-org-login.spec.js skips itself when this is unset. API_PORTAL_OTHER_ORG_BASE_URL: "http://api-portal-other-org:9543" API_PORTAL_OTHER_ORG_HANDLE: "other-org" + # The third instance, running auth.authorization.mode = "role". + # auth/role-mode-authorization.spec.js skips itself when this is unset. + API_PORTAL_ROLE_MODE_BASE_URL: "http://api-portal-role-mode:9543" API_PORTAL_ORG_HANDLE: "default" API_PORTAL_ADMIN_USERNAME: "admin" API_PORTAL_ADMIN_PASSWORD: "admin" diff --git a/portals/api-portal/it/docker-compose.test.yaml b/portals/api-portal/it/docker-compose.test.yaml index 352a42a10..240825628 100644 --- a/portals/api-portal/it/docker-compose.test.yaml +++ b/portals/api-portal/it/docker-compose.test.yaml @@ -133,6 +133,51 @@ services: networks: - it-api-portal-network + # Third portal instance, differing from `api-portal` in exactly one dimension: + # auth.authorization.mode = "role" instead of "scope". That is the shipped default + # (configs/config.toml), so without this service the suite covered only the mode + # most deployments do NOT run. + # + # In role mode the portal ignores the token's scope claim and expands its roles + # claim through its OWN grant table — mounted here from + # configs/portal-roles-role-mode-it.yaml, which defines the three dp_*_it roles the + # image's shipped table does not. Same organization and same platform-api as the + # primary instance, so the tokens are identical; only the portal's interpretation of + # them differs, which is what makes the comparison in + # rest-api/auth/role-mode-authorization.spec.js meaningful. + # + # Its own container-local SQLite database, deliberately NOT the shared volume. + # Every portal instance runs a webhook dispatcher and delivery worker that poll the + # events tables, so two instances sharing a database race for each other's + # deliveries — and since each carries its own security.encryption_key, whichever one + # wins cannot necessarily decrypt the subscriber secret the other stored. That + # breaks the webhook encryption assertions in api-keys/ and subscriptions/. Isolating + # the storage is what keeps this instance invisible to the rest of the suite. + api-portal-role-mode: + image: ${DOCKER_REGISTRY:-ghcr.io/wso2/api-platform}/api-portal:test + depends_on: + platform-api: + condition: service_healthy + environment: + APIP_AP_DATABASE_DRIVER: sqlite + APIP_AP_DATABASE_PATH: /tmp/api-portal-it-role-mode.db + APIP_AP_AUTH_AUTHORIZATION_MODE: role + APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING: /etc/api-portal/portal-roles-role-mode-it.yaml + APIP_AP_SECURITY_ENCRYPTION_KEY: "3d1f8a5c07b94e2d6f0a8c3b5e7d9f1a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d" + APIP_AP_SECURITY_SESSION_SECRET: "9e7c5a3b1d8f6042ae2c4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6a" + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:9543/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""] + interval: 10s + timeout: 5s + retries: 20 + start_period: 20s + volumes: + - ./test-config.toml:/app/configs/config.toml:ro + - ./configs/portal-roles-role-mode-it.yaml:/etc/api-portal/portal-roles-role-mode-it.yaml:ro + - ./.certs/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro + networks: + - it-api-portal-network + cypress: image: cypress/included:13.17.0 platform: ${CYPRESS_PLATFORM:-linux/amd64} @@ -161,6 +206,8 @@ services: # keeping it out of the Cypress-only `make test`. api-portal-other-org: condition: service_healthy + api-portal-role-mode: + condition: service_healthy working_dir: /rest-api environment: API_PORTAL_BASE_URL: "http://api-portal:9543" @@ -168,6 +215,9 @@ services: # tokens for. auth/foreign-org-login.spec.js skips itself when this is unset. API_PORTAL_OTHER_ORG_BASE_URL: "http://api-portal-other-org:9543" API_PORTAL_OTHER_ORG_HANDLE: "other-org" + # The third instance, running auth.authorization.mode = "role". + # auth/role-mode-authorization.spec.js skips itself when this is unset. + API_PORTAL_ROLE_MODE_BASE_URL: "http://api-portal-role-mode:9543" # Real session login (config-platform-api-it.toml) — password == username # for all three, matching the admin/admin convention documented in # configs/config-platform-api-template.toml. diff --git a/portals/api-portal/it/rest-api/auth/role-mode-authorization.spec.js b/portals/api-portal/it/rest-api/auth/role-mode-authorization.spec.js new file mode 100644 index 000000000..9d9707b2a --- /dev/null +++ b/portals/api-portal/it/rest-api/auth/role-mode-authorization.spec.js @@ -0,0 +1,179 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// auth.authorization.mode = "role", against the `api-portal-role-mode` service in +// docker-compose.test*.yaml. +// +// Role mode is the SHIPPED DEFAULT (configs/config.toml) but the rest of this suite +// runs in scope mode, because the primary instance authorizes against the dp:* scope +// claim platform-api mints. So every assertion here covers a code path — the +// isRoleMode() branch of effectiveScopes in src/middlewares/authorization.js, backed +// by src/config/roleScopeMap.js — that nothing else in the suite executes. +// +// The two modes differ in exactly one thing: where a request's effective scopes come +// from. Scope mode reads the token's `scope` claim. Role mode ignores it entirely and +// expands the token's `roles` claim through the PORTAL's own grant table. This fixture +// is built so that difference is observable rather than assumed: +// +// platform-api's table (configs/roles-platform-api-it.yaml) +// dp_developer_it -> ... dp:application:manage, dp:subscription:manage ... +// the portal's table (configs/portal-roles-role-mode-it.yaml) +// dp_developer_it -> read-only, no application scopes at all +// +// One identical token, two different answers. An application create by `developer` +// must succeed on the scope-mode instance and be refused on the role-mode one — and +// the refusal proves the scope claim was not consulted, since that same token carries +// a scope which would permit it. `asserts the token really does carry the scope` +// below pins that premise, so a fixture drift that removed the scope from the token +// would fail loudly instead of making the interesting assertion vacuous. + +const supertest = require('supertest'); +const { CookieAccessInfo } = require('cookiejar'); +const client = require('../support/client'); + +const ROLE_MODE_BASE_URL = process.env.API_PORTAL_ROLE_MODE_BASE_URL; +const ORG = client.ORG_HANDLE; + +// Skipped rather than failed when the fixture isn't running (e.g. a hand-rolled +// `docker compose up api-portal rest-api-tests`), matching foreign-org-login.spec.js. +// Both CI matrix legs define the variable. +const describeRoleMode = ROLE_MODE_BASE_URL ? describe : describe.skip; + +// Logs into the role-mode instance and returns an agent plus its CSRF token. +// Deliberately not client.login(): that helper is bound to the primary instance's +// BASE_URL, and the whole point here is to drive a different portal with the same +// credentials. +async function loginTo(baseUrl, username, password) { + const agent = supertest.agent(baseUrl); + const res = await agent + .post(`/${ORG}/views/default/login`) + .type('form') + .send({ username, password }) + .redirects(0); + if (res.status !== 302 || /error=/.test(res.headers.location || '')) { + throw new Error(`Login failed for '${username}': ${res.status} ${res.headers.location || ''}`); + } + // handleLocalLogin regenerates the session mid-request, after the CSRF-cookie + // middleware already ran — so the token on the login response belongs to the + // discarded session. One throwaway authenticated GET refreshes it. Same dance as + // support/client.js. + await agent.get(`${client.API_PREFIX}/organizations/${ORG}`); + const jar = agent.jar || agent._jar; + const xsrf = jar?.getCookies(CookieAccessInfo.All).find((c) => c.name === 'XSRF-TOKEN')?.value; + return { agent, xsrf }; +} + +const post = ({ agent, xsrf }, path, body) => { + const req = agent.post(`${client.API_PREFIX}${path}`); + return (xsrf ? req.set('X-CSRF-Token', xsrf) : req).send(body); +}; + +const uniq = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +describeRoleMode('authorization mode = role', () => { + let admin; + let developer; + + beforeAll(async () => { + admin = await loginTo(ROLE_MODE_BASE_URL, 'admin', 'admin'); + developer = await loginTo(ROLE_MODE_BASE_URL, 'developer', 'developer'); + }); + + describe('the fixture itself', () => { + it('serves the organization, proving the role-mode instance is up and seeded', async () => { + // Baseline. A failure here means the instance is misconfigured — most + // likely the grant table failed startup validation — not that an + // authorization decision below misfired. + const res = await supertest(ROLE_MODE_BASE_URL).get(`/${ORG}/views/default`); + expect(res.status).toBe(200); + }); + + it('mints a token carrying both a roles claim and a scope claim', async () => { + // The premise the whole file rests on. Role mode needs the roles claim to + // expand; the "scope claim is ignored" assertions need the scope claim to + // be present and permissive. Read straight from platform-api so a portal + // bug can't mask a fixture problem. + const res = await client.raw() + .post(`/${ORG}/views/default/login`) + .type('form') + .send({ username: 'developer', password: 'developer' }) + .redirects(0); + expect(res.status).toBe(302); + expect(res.headers.location).not.toContain('error='); + }); + }); + + describe('a role expands to the scopes its grant table entry lists', () => { + it('lets dp_admin_it create a label (dp:label:manage)', async () => { + const id = uniq('rolemode-label'); + const res = await post(admin, '/labels', { id, displayName: 'Role mode label' }); + expect(res.status).toBe(201); + }); + + it('lets dp_developer_it read the API catalogue (dp:api:read)', async () => { + // The counterweight to the denials below. Without it they would also pass + // if role mode were broken outright and denied every request — this is + // what shows the grant table is being applied rather than ignored. + const res = await developer.agent.get(`${client.API_PREFIX}/apis`); + expect(res.status).toBe(200); + }); + }); + + describe('a role is denied what its grant table entry omits', () => { + it('refuses a label create by dp_developer_it (no dp:label:manage)', async () => { + const id = uniq('rolemode-denied-label'); + const res = await post(developer, '/labels', { id, displayName: 'Should not exist' }); + expect(res.status).toBe(403); + }); + + it('refuses a key-manager read by dp_developer_it (no dp:key_manager:read)', async () => { + const res = await developer.agent.get(`${client.API_PREFIX}/key-managers`); + expect(res.status).toBe(403); + }); + }); + + describe("the token's own scope claim is ignored", () => { + // The security property role mode exists to provide: a caller must not be able + // to widen a role's grant by obtaining extra scope values from their issuer. + // effectiveScopes() drops tokenScopes entirely in role mode rather than merging. + + it('asserts the token really does carry the scope being ignored', async () => { + // Guards the assertion below from going vacuous. `developer`'s token is + // minted with dp:application:create/manage by platform-api's table; the + // scope-mode instance therefore allows the create. If this ever stops + // being true the next test would pass for the wrong reason. + await client.login('developer'); + const res = await client.as('developer').post('/applications', { + displayName: uniq('scopemode-app'), + description: 'Created on the scope-mode instance', + }); + expect(res.status).toBe(201); + }); + + it('refuses the same create on the role-mode instance', async () => { + // Same credentials, same platform-api, same token shape — only the + // portal's interpretation differs. The scope claim would allow this; + // dp_developer_it's portal-side grant does not. + const res = await post(developer, '/applications', { + displayName: uniq('rolemode-app'), + description: 'Must be refused — role grants no application scope', + }); + expect(res.status).toBe(403); + }); + }); +}); diff --git a/portals/api-portal/it/test-config.toml b/portals/api-portal/it/test-config.toml index b473bcd77..a290f4abf 100644 --- a/portals/api-portal/it/test-config.toml +++ b/portals/api-portal/it/test-config.toml @@ -47,14 +47,25 @@ handle = '{{ env "APIP_AP_ORGANIZATION_HANDLE" "default" }}' display_name = '{{ env "APIP_AP_ORGANIZATION_DISPLAY_NAME" "Default" }}' auto_create_subscription_plans = true -# The IT suite authorizes against the dp:* scopes the Platform API sidecar mints into -# each token's scope claim — its own grant table (configs/roles-platform-api-it.yaml) +# Most of the suite authorizes against the dp:* scopes the Platform API sidecar mints +# into each token's scope claim — its own grant table (configs/roles-platform-api-it.yaml) # is where the three IT accounts' privileges are defined. That is scope mode by -# definition, so it is pinned here rather than inheriting the "role" default: role mode -# would expand the dp_*_it role names against the PORTAL's grant table, which does not -# define them, and every REST assertion would 403. +# definition, so it is the default here rather than the shipped "role": role mode would +# expand the dp_*_it role names against the PORTAL's grant table, and the table baked +# into the image does not define them, so every REST assertion would 403. +# +# Tokenized, not hardcoded, because the fixture also runs a THIRD portal instance +# (api-portal-role-mode) off this same file with mode = "role" and a grant table that +# does define those roles (configs/portal-roles-role-mode-it.yaml) — role mode is the +# shipped default in configs/config.toml, so leaving it unexercised meant the suite +# never covered the configuration most deployments actually run. Driven by +# rest-api/auth/role-mode-authorization.spec.js. [api_portal.auth.authorization] -mode = "scope" +mode = '{{ env "APIP_AP_AUTH_AUTHORIZATION_MODE" "scope" }}' +# Only read in role mode. The default points at the copy baked into the image so the +# scope-mode instances resolve a real path; the role-mode instance overrides it with +# its mounted IT table. +role_to_scope_mapping = '{{ env "APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING" "./resources/role-to-scope-mapping.yaml" }}' [api_portal.auth.local] # File-based (local) auth against the Platform API sidecar. Host is identical in From d2aa71e1a7a2a25d822a1688b8d0782323aaf649 Mon Sep 17 00:00:00 2001 From: Piumal Rathnayake Date: Sun, 2 Aug 2026 21:56:26 +0530 Subject: [PATCH 20/20] Fix tests --- .../workflows/devportal-integration-test.yml | 16 +- portals/api-portal/it/Makefile | 49 +++- portals/api-portal/it/README.md | 52 +++- .../it/configs/config-platform-api-it.toml | 14 + .../it/configs/portal-roles-it.yaml | 248 ++++++++++++++++++ .../it/configs/portal-roles-role-mode-it.yaml | 96 ------- .../it/configs/roles-platform-api-it.yaml | 37 +++ .../it/docker-compose.test.postgres.yaml | 54 +--- .../api-portal/it/docker-compose.test.yaml | 69 ++--- .../rest-api/auth/authorization-mode.spec.js | 130 +++++++++ .../rest-api/auth/grant-table-parity.spec.js | 83 ++++++ .../auth/role-mode-authorization.spec.js | 179 ------------- .../api-portal/it/rest-api/package-lock.json | 57 +++- portals/api-portal/it/rest-api/package.json | 1 + .../api-portal/it/rest-api/support/client.js | 11 + .../rest-api/views-and-labels/views.spec.js | 6 + .../src/controllers/apiContentController.js | 11 +- .../controllers/customContentController.js | 15 +- .../src/middlewares/authMiddleware.js | 10 +- .../partials/manage-keys-km-card.hbs | 3 +- .../src/scripts/oauth2-key-generation.js | 8 +- .../src/services/apiMetadataService.js | 42 +-- .../api-portal/src/styles/settings-layout.css | 6 +- 23 files changed, 759 insertions(+), 438 deletions(-) create mode 100644 portals/api-portal/it/configs/portal-roles-it.yaml delete mode 100644 portals/api-portal/it/configs/portal-roles-role-mode-it.yaml create mode 100644 portals/api-portal/it/rest-api/auth/authorization-mode.spec.js create mode 100644 portals/api-portal/it/rest-api/auth/grant-table-parity.spec.js delete mode 100644 portals/api-portal/it/rest-api/auth/role-mode-authorization.spec.js diff --git a/.github/workflows/devportal-integration-test.yml b/.github/workflows/devportal-integration-test.yml index 83167bbae..d9692ef3d 100644 --- a/.github/workflows/devportal-integration-test.yml +++ b/.github/workflows/devportal-integration-test.yml @@ -18,12 +18,18 @@ permissions: jobs: rest-api-test: - name: REST API tests (${{ matrix.db }}) + name: REST API tests (${{ matrix.db }}, ${{ matrix.mode }} mode) runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: db: [sqlite, postgres] + # auth.authorization.mode — the whole suite runs in each. "role" is the + # shipped default; "scope" is what an issuer minting dp:* scopes directly + # uses. A matrix dimension rather than the both-modes `make test-rest-api` + # target so the four combinations run in parallel and CI wall-clock stays + # where it was. See portals/api-portal/it/README.md "Authorization modes". + mode: [scope, role] steps: - name: Checkout code uses: actions/checkout@v4 @@ -39,21 +45,21 @@ jobs: - name: Build API Portal image run: make -C portals/api-portal build - - name: Run REST API integration tests (${{ matrix.db }}) + - name: Run REST API integration tests (${{ matrix.db }}, ${{ matrix.mode }} mode) env: PLATFORM_API_IMAGE: platform-api:it-api-portal run: | if [ "${{ matrix.db }}" = "postgres" ]; then - make -C portals/api-portal/it test-rest-api-postgres + make -C portals/api-portal/it test-rest-api-postgres-${{ matrix.mode }} else - make -C portals/api-portal/it test-rest-api + make -C portals/api-portal/it test-rest-api-${{ matrix.mode }} fi - name: Upload test reports uses: actions/upload-artifact@v4 if: always() with: - name: rest-api-test-reports-${{ matrix.db }} + name: rest-api-test-reports-${{ matrix.db }}-${{ matrix.mode }} path: portals/api-portal/it/reports/ retention-days: 7 diff --git a/portals/api-portal/it/Makefile b/portals/api-portal/it/Makefile index 1254870c8..a7d7ee7af 100644 --- a/portals/api-portal/it/Makefile +++ b/portals/api-portal/it/Makefile @@ -16,7 +16,9 @@ # under the License. # -------------------------------------------------------------------- -.PHONY: all test test-postgres test-rest-api test-rest-api-postgres open clean deps ensure-test-tag ensure-certs +.PHONY: all test test-postgres test-rest-api test-rest-api-scope test-rest-api-role \ + test-rest-api-postgres test-rest-api-postgres-scope test-rest-api-postgres-role \ + open clean deps ensure-test-tag ensure-certs VERSION ?= $(shell cat ../VERSION 2>/dev/null | tr -d '[:space:]' || echo "0.0.1-SNAPSHOT") DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform @@ -107,19 +109,48 @@ test-postgres: ensure-test-tag ensure-certs docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ exit $$EXIT -# Run the REST API integration test suite (Jest + Supertest) against SQLite. +# --- REST API suite (Jest + Supertest) ------------------------------------- +# +# The whole suite runs twice, once per authorization mode, because +# auth.authorization.mode changes where a request's effective scopes come from: +# +# scope — the portal reads the token's own scope claim (what platform-api mints). +# role — the portal IGNORES that claim and expands the token's roles claim +# through its own grant table (configs/portal-roles-it.yaml). This is the +# SHIPPED DEFAULT in configs/config.toml. +# +# Both are real deployment configurations, so both must pass the same specs. The +# portal-side grant table mirrors platform-api's, which is what lets one set of +# expectations hold in either mode; auth/grant-table-parity.spec.js guards the mirror +# and auth/authorization-mode.spec.js covers the deliberate difference between them. +# +# `test-rest-api` runs both, sequentially. Use the -scope / -role targets to run one. # Requires the API Portal image to be built first: make build (from portals/api-portal/). -test-rest-api: ensure-test-tag ensure-certs - @DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -p $(IT_PROJECT) -f docker-compose.test.yaml up api-portal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ +test-rest-api: + @$(MAKE) test-rest-api-scope + @$(MAKE) test-rest-api-role + +test-rest-api-scope: AUTH_MODE = scope +test-rest-api-role: AUTH_MODE = role +test-rest-api-scope test-rest-api-role: ensure-test-tag ensure-certs + @echo "==> REST API suite (SQLite, authorization mode = $(AUTH_MODE))" + @AUTH_MODE=$(AUTH_MODE) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -p $(IT_PROJECT) -f docker-compose.test.yaml up api-portal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ EXIT=$$?; \ - docker compose -p $(IT_PROJECT) -f docker-compose.test.yaml down -v --remove-orphans; \ + AUTH_MODE=$(AUTH_MODE) docker compose -p $(IT_PROJECT) -f docker-compose.test.yaml down -v --remove-orphans; \ exit $$EXIT -# Run the REST API integration test suite (Jest + Supertest) against PostgreSQL. -test-rest-api-postgres: ensure-test-tag ensure-certs - @DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml up postgres api-portal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ +# Same, against PostgreSQL. +test-rest-api-postgres: + @$(MAKE) test-rest-api-postgres-scope + @$(MAKE) test-rest-api-postgres-role + +test-rest-api-postgres-scope: AUTH_MODE = scope +test-rest-api-postgres-role: AUTH_MODE = role +test-rest-api-postgres-scope test-rest-api-postgres-role: ensure-test-tag ensure-certs + @echo "==> REST API suite (PostgreSQL, authorization mode = $(AUTH_MODE))" + @AUTH_MODE=$(AUTH_MODE) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml up postgres api-portal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ EXIT=$$?; \ - docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ + AUTH_MODE=$(AUTH_MODE) docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ exit $$EXIT # Open Cypress interactive UI — runs against a LOCALLY running portal (not in Docker). diff --git a/portals/api-portal/it/README.md b/portals/api-portal/it/README.md index 27173d170..b7b69dd4b 100644 --- a/portals/api-portal/it/README.md +++ b/portals/api-portal/it/README.md @@ -41,20 +41,39 @@ Each suite can run against either **SQLite** (default, no external DB) or **Post organization and refuses a login carrying any other one's — a check the matched pair above can never reach. Started only for the `test-rest-api*` targets (as a `rest-api-tests` dependency), and driven by `rest-api/auth/foreign-org-login.spec.js`. -- **api-portal-role-mode** — a third instance, identical to the primary one except that it - runs `auth.authorization.mode = "role"` (the shipped default) instead of `"scope"`, with its - own portal-side grant table, `configs/portal-roles-role-mode-it.yaml`. Role mode ignores a - token's scope claim and expands its roles claim instead, so the primary instance can never - exercise it. That grant table deliberately gives `dp_developer_it` *less* than - `configs/roles-platform-api-it.yaml` puts in the same user's scope claim, which is what lets - the spec prove the scope claim is ignored rather than merged. Started only for the - `test-rest-api*` targets, and driven by `rest-api/auth/role-mode-authorization.spec.js`. - Runs on its own container-local SQLite database in both DB variants — instances sharing a - database steal each other's webhook deliveries, and each has a different encryption key. - **Jest + Supertest** — REST API test framework. - **Cypress** — UI E2E test framework (headless Electron). - **SQLite / PostgreSQL** — SQLite by default; the `-postgres` targets swap in a Postgres service. +## Authorization modes + +`auth.authorization.mode` decides where a request's effective scopes come from, and the +REST suite runs **in full, once per mode**: + +| Mode | Effective scopes come from | Grant table | +|---|---|---| +| `scope` | the token's own `scope` claim, as minted by platform-api | `configs/roles-platform-api-it.yaml` | +| `role` (shipped default) | expanding the token's `roles` claim — the scope claim is **ignored** | `configs/portal-roles-it.yaml` | + +Both are real deployment configurations, so both must pass the same specs. That works +because the portal-side table mirrors platform-api's exactly, giving each IT account the +same grant either way. Two things keep that honest: + +- **`rest-api/auth/grant-table-parity.spec.js`** fails if the two tables drift apart, + naming the role and the missing scopes — instead of surfacing as a puzzling 403 in + some unrelated spec. Regenerate the portal table after editing platform-api's. +- **`rest-api/auth/authorization-mode.spec.js`** covers the one deliberate divergence. + The `narrow` account's roles claim (`dp_narrow_it`) is granted the full developer scope + set by platform-api but read-only by the portal, so the *same token* creating an + application succeeds in scope mode and is refused in role mode. Each assertion runs in + exactly one mode; together they prove the scope claim really is ignored under role mode + rather than merged — i.e. a caller cannot widen a role's grant by getting extra scopes + from their issuer. No other spec uses that account. + +Mode is selected by `AUTH_MODE`, which the compose fixture feeds to both the portal +(`APIP_AP_AUTH_AUTHORIZATION_MODE`) and the test process (`API_PORTAL_AUTH_MODE`) so they +cannot disagree. Cypress always runs in the default `scope` mode. + ## Prerequisites - Docker and Docker Compose @@ -106,8 +125,12 @@ portals/api-portal/it/ |---------|-------------| | `make test` | Run the Cypress UI suite headlessly (SQLite, CI-friendly) | | `make test-postgres` | Run the Cypress UI suite headlessly (PostgreSQL) | -| `make test-rest-api` | Run the Jest REST API suite (SQLite) | -| `make test-rest-api-postgres` | Run the Jest REST API suite (PostgreSQL) | +| `make test-rest-api` | Run the Jest REST API suite (SQLite) — **both** authorization modes, sequentially | +| `make test-rest-api-scope` | Same, scope mode only | +| `make test-rest-api-role` | Same, role mode only (the shipped default) | +| `make test-rest-api-postgres` | Run the Jest REST API suite (PostgreSQL) — both modes | +| `make test-rest-api-postgres-scope` | Same, scope mode only | +| `make test-rest-api-postgres-role` | Same, role mode only | | `make open` | Open the Cypress interactive UI against a locally running portal | | `make deps` | Install Node dependencies (only needed for `make open`) | | `make clean` | Remove test containers, volumes, and report artifacts | @@ -125,8 +148,9 @@ You can also run both UI suites from the portal root: `make -C portals/api-porta Both suites run automatically on pull requests that touch `portals/api-portal/**`, via [`.github/workflows/devportal-integration-test.yml`](../../../.github/workflows/devportal-integration-test.yml): -- **`rest-api-test`** — builds the image and runs `make test-rest-api` / - `make test-rest-api-postgres` in an `sqlite` × `postgres` matrix. +- **`rest-api-test`** — builds the image and runs the suite in an + `sqlite` × `postgres` × `scope` × `role` matrix (four parallel jobs, via the + per-mode targets, so covering both authorization modes doesn't double wall-clock). - **`ui-test`** — builds the image and runs `make test` (Cypress, SQLite). Test reports (`it/reports/`) are uploaded as workflow artifacts on every run. The workflow diff --git a/portals/api-portal/it/configs/config-platform-api-it.toml b/portals/api-portal/it/configs/config-platform-api-it.toml index 7864292ee..6081c9200 100644 --- a/portals/api-portal/it/configs/config-platform-api-it.toml +++ b/portals/api-portal/it/configs/config-platform-api-it.toml @@ -77,3 +77,17 @@ roles = ["dp_publisher_it"] username = "developer" password_hash = "$2y$10$jX3o2E5jF4i3EOgoyJ0k.uegbDYmsmFNDfIxnvcZgTNJifAPjgKKK" roles = ["dp_developer_it"] + +# Exists only to make the difference between authorization modes observable. +# +# Its roles claim is dp_narrow_it, which platform-api's grant table +# (roles-platform-api-it.yaml) gives the FULL developer scope set — so the token it +# receives carries dp:application:create in its scope claim. The portal's own table +# (portal-roles-it.yaml) grants dp_narrow_it read-only. +# +# So this one account can create an application in scope mode and is refused in role +# mode, which is what auth/authorization-mode.spec.js asserts. No other spec uses it. +[[platform_api.auth.file.users]] +username = "narrow" +password_hash = "$2b$10$87Aj.eQ6JU4WZ2SohdyZEOKRXfVVt7QPGwE9bwaYxk1WqYbF8uTR6" +roles = ["dp_narrow_it"] diff --git a/portals/api-portal/it/configs/portal-roles-it.yaml b/portals/api-portal/it/configs/portal-roles-it.yaml new file mode 100644 index 000000000..dfefcd24b --- /dev/null +++ b/portals/api-portal/it/configs/portal-roles-it.yaml @@ -0,0 +1,248 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# -------------------------------------------------------------------- +# +# The PORTAL-side role-to-scope grant table for the IT fixture +# (auth.authorization.role_to_scope_mapping). Read ONLY when the suite runs with +# AUTH_MODE=role; in scope mode the portal reads the token's scope claim instead +# and never consults this file. +# +# Two tables exist because the two components answer different questions: +# +# roles-platform-api-it.yaml — platform-api's. Expands each IT account's roles +# into the SCOPE CLAIM of the token it mints. +# this file — the portal's. In role mode the portal ignores +# that scope claim and expands the token's ROLES +# CLAIM through this file instead. +# +# The three real accounts are mirrored EXACTLY, so every spec in the suite holds +# in both modes — that is what lets the whole suite run twice rather than needing +# a mode-specific set of expectations. auth/grant-table-parity.spec.js fails if +# the two files ever drift apart, so this is checked rather than hoped for. +# Regenerate after changing platform-api's table; do not hand-edit these three. +# +# dp_narrow_it is the deliberate exception — see its comment below. +# +# Every dp:* scope must be declared in docs/api-portal-openapi-spec-v0.9.yaml; the +# portal validates this file against the spec at startup and refuses to boot on an +# unknown one, so a typo fails the fixture loudly. +# -------------------------------------------------------------------- + +roles: + # Mirror of dp_admin_it in roles-platform-api-it.yaml (84 scopes). + - name: dp_admin_it + scopes: + - dp:organization:read + - dp:organization:create + - dp:organization:update + - dp:organization:manage + - dp:organization:delete + - dp:organization_content:read + - dp:organization_content:manage + - dp:api:read + - dp:api:create + - dp:api:update + - dp:api:manage + - dp:api:delete + - dp:api_content:read + - dp:api_content:create + - dp:api_content:update + - dp:api_content:manage + - dp:api_content:delete + - dp:mcp_server:read + - dp:mcp_server:create + - dp:mcp_server:update + - dp:mcp_server:manage + - dp:mcp_server:delete + - dp:mcp_server_content:read + - dp:mcp_server_content:create + - dp:mcp_server_content:update + - dp:mcp_server_content:manage + - dp:mcp_server_content:delete + - dp:api_key:create + - dp:api_key:read + - dp:api_key:update + - dp:api_key:manage + - dp:api_key:revoke + - dp:mcp_server_key:create + - dp:mcp_server_key:read + - dp:mcp_server_key:update + - dp:mcp_server_key:manage + - dp:mcp_server_key:revoke + - dp:api_workflow:create + - dp:api_workflow:read + - dp:api_workflow:update + - dp:api_workflow:delete + - dp:api_workflow:manage + - dp:application:create + - dp:application:read + - dp:application:update + - dp:application:manage + - dp:application:delete + - dp:application_key:create + - dp:application_key:manage + - dp:application_key:revoke + - dp:application_key_mapping:read + - dp:application_key_mapping:create + - dp:application_key_mapping:manage + - dp:subscription:create + - dp:subscription:read + - dp:subscription:update + - dp:subscription:manage + - dp:subscription:delete + - dp:subscription_plan:create + - dp:subscription_plan:read + - dp:subscription_plan:update + - dp:subscription_plan:manage + - dp:subscription_plan:delete + - dp:key_manager:create + - dp:key_manager:read + - dp:key_manager:update + - dp:key_manager:manage + - dp:key_manager:delete + - dp:view:create + - dp:view:read + - dp:view:update + - dp:view:manage + - dp:view:delete + - dp:label:create + - dp:label:read + - dp:label:update + - dp:label:manage + - dp:label:delete + - dp:webhook_subscriber:create + - dp:webhook_subscriber:read + - dp:webhook_subscriber:update + - dp:webhook_subscriber:delete + - dp:webhook_subscriber:manage + - dp:event:read + + # Mirror of dp_publisher_it in roles-platform-api-it.yaml (62 scopes). + - name: dp_publisher_it + scopes: + - dp:organization:read + - dp:api:read + - dp:api:create + - dp:api:update + - dp:api:manage + - dp:api:delete + - dp:api_content:read + - dp:api_content:create + - dp:api_content:update + - dp:api_content:manage + - dp:api_content:delete + - dp:mcp_server:read + - dp:mcp_server:create + - dp:mcp_server:update + - dp:mcp_server:manage + - dp:mcp_server:delete + - dp:mcp_server_content:read + - dp:mcp_server_content:create + - dp:mcp_server_content:update + - dp:mcp_server_content:manage + - dp:mcp_server_content:delete + - dp:api_key:create + - dp:api_key:read + - dp:api_key:update + - dp:api_key:manage + - dp:api_key:revoke + - dp:mcp_server_key:create + - dp:mcp_server_key:read + - dp:mcp_server_key:update + - dp:mcp_server_key:manage + - dp:mcp_server_key:revoke + - dp:api_workflow:create + - dp:api_workflow:read + - dp:api_workflow:update + - dp:api_workflow:delete + - dp:api_workflow:manage + - dp:subscription_plan:create + - dp:subscription_plan:read + - dp:subscription_plan:update + - dp:subscription_plan:manage + - dp:subscription_plan:delete + - dp:key_manager:create + - dp:key_manager:read + - dp:key_manager:update + - dp:key_manager:manage + - dp:key_manager:delete + - dp:view:create + - dp:view:read + - dp:view:update + - dp:view:manage + - dp:view:delete + - dp:label:create + - dp:label:read + - dp:label:update + - dp:label:manage + - dp:label:delete + - dp:webhook_subscriber:create + - dp:webhook_subscriber:read + - dp:webhook_subscriber:update + - dp:webhook_subscriber:delete + - dp:webhook_subscriber:manage + - dp:event:read + + # Mirror of dp_developer_it in roles-platform-api-it.yaml (29 scopes). + - name: dp_developer_it + scopes: + - dp:organization:read + - dp:api:read + - dp:api_content:read + - dp:mcp_server:read + - dp:mcp_server_content:read + - dp:api_key:create + - dp:api_key:read + - dp:api_key:update + - dp:api_key:manage + - dp:api_key:revoke + - dp:application:create + - dp:application:read + - dp:application:update + - dp:application:manage + - dp:application:delete + - dp:application_key:create + - dp:application_key:manage + - dp:application_key:revoke + - dp:application_key_mapping:read + - dp:application_key_mapping:create + - dp:application_key_mapping:manage + - dp:subscription:create + - dp:subscription:read + - dp:subscription:update + - dp:subscription:manage + - dp:subscription:delete + - dp:subscription_plan:read + - dp:view:read + - dp:label:read + + # The one role deliberately NOT mirrored, and the only reason this file can + # prove anything scope mode cannot. + # + # platform-api grants dp_narrow_it the full developer scope set, including + # dp:application:create — so its token's scope claim permits creating an + # application. Here it gets read-only. The same account therefore succeeds in + # scope mode and is refused in role mode, which is exactly the assertion pair in + # auth/authorization-mode.spec.js: proof that role mode IGNORES the scope claim + # rather than merging it, and that a caller cannot widen a role's grant by + # obtaining extra scopes from their issuer. + # + # No other spec uses this account, so the divergence costs the suite nothing. + - name: dp_narrow_it + scopes: + - dp:api:read + - dp:api_content:read + - dp:mcp_server:read + - dp:mcp_server_content:read + - dp:organization:read + - dp:organization_content:read + - dp:subscription_plan:read + - dp:view:read + - dp:label:read diff --git a/portals/api-portal/it/configs/portal-roles-role-mode-it.yaml b/portals/api-portal/it/configs/portal-roles-role-mode-it.yaml deleted file mode 100644 index 221a4d65e..000000000 --- a/portals/api-portal/it/configs/portal-roles-role-mode-it.yaml +++ /dev/null @@ -1,96 +0,0 @@ -# -------------------------------------------------------------------- -# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). -# -# WSO2 LLC. licenses this file to you under the Apache License, -# Version 2.0 (the "License"); you may not use this file except -# in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -------------------------------------------------------------------- -# -# The PORTAL's role-to-scope grant table for the `api-portal-role-mode` service in -# docker-compose.test*.yaml (auth.authorization.role_to_scope_mapping). Read only by -# that instance, which runs with auth.authorization.mode = "role" — the shipped -# default in configs/config.toml, and the mode the rest of this suite cannot exercise -# because it is pinned to "scope" (see test-config.toml). -# -# NOT the same file as roles-platform-api-it.yaml, and deliberately so. -# -# roles-platform-api-it.yaml — platform-api's table. Expands each IT account's -# roles into the *scope claim* of the token it mints. -# this file — the portal's table. In role mode the portal ignores -# that scope claim entirely and expands the token's -# *roles claim* through this file instead. -# -# Two tables, same three role names, deliberately different grants. That divergence is -# the point: it is what lets role-mode-authorization.spec.js prove the scope claim is -# ignored rather than merged. `dp_developer_it` is read-only here while platform-api -# grants it dp:application:manage, so an application create by the developer must be -# refused by the role-mode instance even though the token it presents carries a scope -# that would allow it. Keep that asymmetry — collapsing the two tables would make the -# suite pass whether or not role mode consults the scope claim. -# -# Every dp:* scope below must be declared in docs/api-portal-openapi-spec-v0.9.yaml; -# the portal validates this file against the spec at startup (src/config/roleScopeMap.js) -# and refuses to boot on an unknown one, so a typo here fails the fixture loudly. -# -------------------------------------------------------------------- - -roles: - # Mirrors the shipped dp_admin grant — full portal administration. The admin - # assertions expect every management operation to succeed under this role. - - name: dp_admin_it - scopes: - - dp:organization:manage - - dp:organization_content:manage - - dp:api:manage - - dp:api_content:manage - - dp:mcp_server:manage - - dp:mcp_server_content:manage - - dp:api_workflow:manage - - dp:api_key:manage - - dp:mcp_server_key:manage - - dp:application:manage - - dp:application_key:manage - - dp:application_key:revoke - - dp:application_key_mapping:manage - - dp:subscription:manage - - dp:subscription_plan:manage - - dp:key_manager:manage - - dp:key_manager:read - - dp:view:manage - - dp:label:manage - - dp:webhook_subscriber:manage - - dp:event:read - - # Catalogue manager — APIs, MCP servers, views and labels. No organization - # settings, no key managers, no webhook subscribers: those separate it from - # dp_admin_it in the "a narrower role is denied an admin operation" assertions. - - name: dp_publisher_it - scopes: - - dp:api:manage - - dp:api_content:manage - - dp:mcp_server:manage - - dp:mcp_server_content:manage - - dp:api_workflow:manage - - dp:view:manage - - dp:label:manage - - dp:subscription_plan:read - - dp:organization:read - - dp:organization_content:read - - # Read-only. Intentionally NARROWER than the same role's grant in - # roles-platform-api-it.yaml, which additionally carries dp:application:manage, - # dp:subscription:manage and dp:api_key:manage. Those absences are load-bearing — - # see the header note. - - name: dp_developer_it - scopes: - - dp:api:read - - dp:api_content:read - - dp:mcp_server:read - - dp:mcp_server_content:read - - dp:subscription_plan:read - - dp:organization:read - - dp:organization_content:read - - dp:view:read - - dp:label:read diff --git a/portals/api-portal/it/configs/roles-platform-api-it.yaml b/portals/api-portal/it/configs/roles-platform-api-it.yaml index 9a46f31fe..4f191df0a 100644 --- a/portals/api-portal/it/configs/roles-platform-api-it.yaml +++ b/portals/api-portal/it/configs/roles-platform-api-it.yaml @@ -209,3 +209,40 @@ roles: - dp:subscription_plan:read - dp:view:read - dp:label:read + + # Same grant as dp_developer_it above — deliberately broad HERE so that the + # portal-side table (portal-roles-it.yaml), which grants it read-only, visibly + # diverges. The gap between the two is what auth/authorization-mode.spec.js reads + # to prove role mode ignores the scope claim. Keep these in step with + # dp_developer_it; narrowing this one would make the proof vacuous. + - name: dp_narrow_it + scopes: + - dp:organization:read + - dp:api:read + - dp:api_content:read + - dp:mcp_server:read + - dp:mcp_server_content:read + - dp:api_key:create + - dp:api_key:read + - dp:api_key:update + - dp:api_key:manage + - dp:api_key:revoke + - dp:application:create + - dp:application:read + - dp:application:update + - dp:application:manage + - dp:application:delete + - dp:application_key:create + - dp:application_key:manage + - dp:application_key:revoke + - dp:application_key_mapping:read + - dp:application_key_mapping:create + - dp:application_key_mapping:manage + - dp:subscription:create + - dp:subscription:read + - dp:subscription:update + - dp:subscription:manage + - dp:subscription:delete + - dp:subscription_plan:read + - dp:view:read + - dp:label:read diff --git a/portals/api-portal/it/docker-compose.test.postgres.yaml b/portals/api-portal/it/docker-compose.test.postgres.yaml index 5c5e7fe00..5f93ac1a9 100644 --- a/portals/api-portal/it/docker-compose.test.postgres.yaml +++ b/portals/api-portal/it/docker-compose.test.postgres.yaml @@ -91,6 +91,7 @@ services: # Mount only jwt_public.pem, not the whole .certs dir, so the private key # (also in .certs, for platform-api) is never exposed to the portal. - ./.certs/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro + - ./configs/portal-roles-it.yaml:/etc/api-portal/portal-roles-it.yaml:ro environment: # DB-agnostic settings (tls/logging/api-key/org/platform-api) live in ./test-config.toml. APIP_AP_DATABASE_HOST: postgres @@ -99,6 +100,10 @@ services: APIP_AP_DATABASE_USER: api_portal APIP_AP_DATABASE_PASSWORD: api_portal APIP_AP_DATABASE_NAME: api_portal + # Which authorization mode the ENTIRE suite runs against this time — see the + # matching note in docker-compose.test.yaml and it/README.md. + APIP_AP_AUTH_AUTHORIZATION_MODE: ${AUTH_MODE:-scope} + APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING: /etc/api-portal/portal-roles-it.yaml # portal fails closed at startup without these — fixed test-only values, # not meant to be reused outside this CI fixture. APIP_AP_SECURITY_ENCRYPTION_KEY: "7f40672c96fd437dc33550755e218d586014232ceb5b01c9405aab575bcb1f4a" @@ -159,44 +164,6 @@ services: networks: - it-api-portal-network - # Role-mode instance — see the extended note on the same service in - # docker-compose.test.yaml. Differs from `api-portal` only in - # auth.authorization.mode = "role" plus the grant table that backs it. - # - # Runs on its own container-local SQLite database even in the Postgres leg, rather - # than joining the shared `api_portal` database. Two reasons, and the first is not - # optional: every portal instance runs a webhook dispatcher and delivery worker that - # poll the events tables, so instances sharing a database steal each other's - # deliveries — and each carries its own security.encryption_key, so the thief cannot - # decrypt the subscriber secret the owner stored, which fails the webhook encryption - # assertions in api-keys/ and subscriptions/. Second, what this service exists to - # cover — how a request's effective scopes are derived — is entirely dialect- - # independent; the Postgres leg exercises the data layer through `api-portal`. - api-portal-role-mode: - image: ${DOCKER_REGISTRY:-ghcr.io/wso2/api-platform}/api-portal:test - depends_on: - platform-api: - condition: service_healthy - volumes: - - ./test-config.toml:/app/configs/config.toml:ro - - ./configs/portal-roles-role-mode-it.yaml:/etc/api-portal/portal-roles-role-mode-it.yaml:ro - - ./.certs/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro - environment: - APIP_AP_DATABASE_DRIVER: sqlite - APIP_AP_DATABASE_PATH: /tmp/api-portal-it-role-mode.db - APIP_AP_AUTH_AUTHORIZATION_MODE: role - APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING: /etc/api-portal/portal-roles-role-mode-it.yaml - APIP_AP_SECURITY_ENCRYPTION_KEY: "3d1f8a5c07b94e2d6f0a8c3b5e7d9f1a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d" - APIP_AP_SECURITY_SESSION_SECRET: "9e7c5a3b1d8f6042ae2c4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6a" - healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:9543/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""] - interval: 10s - timeout: 5s - retries: 20 - start_period: 20s - networks: - - it-api-portal-network - cypress: image: cypress/included:13.17.0 platform: ${CYPRESS_PLATFORM:-linux/amd64} @@ -225,8 +192,6 @@ services: # while keeping it out of the Cypress-only `make test-postgres`. api-portal-other-org: condition: service_healthy - api-portal-role-mode: - condition: service_healthy working_dir: /rest-api environment: API_PORTAL_BASE_URL: "http://api-portal:9543" @@ -234,9 +199,9 @@ services: # tokens for. auth/foreign-org-login.spec.js skips itself when this is unset. API_PORTAL_OTHER_ORG_BASE_URL: "http://api-portal-other-org:9543" API_PORTAL_OTHER_ORG_HANDLE: "other-org" - # The third instance, running auth.authorization.mode = "role". - # auth/role-mode-authorization.spec.js skips itself when this is unset. - API_PORTAL_ROLE_MODE_BASE_URL: "http://api-portal-role-mode:9543" + # Must track the portal service's APIP_AP_AUTH_AUTHORIZATION_MODE above; + # authorization-mode.spec.js asserts they agree. + API_PORTAL_AUTH_MODE: ${AUTH_MODE:-scope} API_PORTAL_ORG_HANDLE: "default" API_PORTAL_ADMIN_USERNAME: "admin" API_PORTAL_ADMIN_PASSWORD: "admin" @@ -256,6 +221,9 @@ services: volumes: - ./rest-api:/rest-api - ./reports:/rest-api/reports + # The two grant tables, for auth/grant-table-parity.spec.js — see the note on + # the same mount in docker-compose.test.yaml. + - ./configs:/it-configs:ro networks: - it-api-portal-network # better-sqlite3 (an optionalDependency used only by the SQLite variant) is the diff --git a/portals/api-portal/it/docker-compose.test.yaml b/portals/api-portal/it/docker-compose.test.yaml index 240825628..c5bf27195 100644 --- a/portals/api-portal/it/docker-compose.test.yaml +++ b/portals/api-portal/it/docker-compose.test.yaml @@ -65,6 +65,16 @@ services: # (tls/logging/api-key/org/platform-api) live in ./test-config.toml. APIP_AP_DATABASE_DRIVER: sqlite APIP_AP_DATABASE_PATH: /tmp/api-portal-it.db + # Which authorization mode the ENTIRE suite runs against this time — + # "scope" (default) or "role", selected by the Makefile's + # test-rest-api-scope / test-rest-api-role targets. Both modes must pass + # the same specs; see it/README.md "Authorization modes". + APIP_AP_AUTH_AUTHORIZATION_MODE: ${AUTH_MODE:-scope} + # Read only in role mode. Mirrors the scopes platform-api puts in the token's + # scope claim, so the two modes grant each IT account the same thing and the + # suite's expectations hold either way — grant-table-parity.spec.js fails if + # the two tables ever drift apart. + APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING: /etc/api-portal/portal-roles-it.yaml # portal fails closed at startup without these — fixed test-only values, # not meant to be reused outside this CI fixture. APIP_AP_SECURITY_ENCRYPTION_KEY: "7f40672c96fd437dc33550755e218d586014232ceb5b01c9405aab575bcb1f4a" @@ -83,6 +93,7 @@ services: # Mount only jwt_public.pem, not the whole .certs dir, so the private key # (also in .certs, for platform-api) is never exposed to the portal. - ./.certs/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro + - ./configs/portal-roles-it.yaml:/etc/api-portal/portal-roles-it.yaml:ro # Named volume at /tmp (not a new mount point) so Docker copies the image's # existing /tmp ownership/permissions into it on first use — a fresh mount # at a brand-new path would be root-owned and unwritable by the app user. @@ -133,51 +144,6 @@ services: networks: - it-api-portal-network - # Third portal instance, differing from `api-portal` in exactly one dimension: - # auth.authorization.mode = "role" instead of "scope". That is the shipped default - # (configs/config.toml), so without this service the suite covered only the mode - # most deployments do NOT run. - # - # In role mode the portal ignores the token's scope claim and expands its roles - # claim through its OWN grant table — mounted here from - # configs/portal-roles-role-mode-it.yaml, which defines the three dp_*_it roles the - # image's shipped table does not. Same organization and same platform-api as the - # primary instance, so the tokens are identical; only the portal's interpretation of - # them differs, which is what makes the comparison in - # rest-api/auth/role-mode-authorization.spec.js meaningful. - # - # Its own container-local SQLite database, deliberately NOT the shared volume. - # Every portal instance runs a webhook dispatcher and delivery worker that poll the - # events tables, so two instances sharing a database race for each other's - # deliveries — and since each carries its own security.encryption_key, whichever one - # wins cannot necessarily decrypt the subscriber secret the other stored. That - # breaks the webhook encryption assertions in api-keys/ and subscriptions/. Isolating - # the storage is what keeps this instance invisible to the rest of the suite. - api-portal-role-mode: - image: ${DOCKER_REGISTRY:-ghcr.io/wso2/api-platform}/api-portal:test - depends_on: - platform-api: - condition: service_healthy - environment: - APIP_AP_DATABASE_DRIVER: sqlite - APIP_AP_DATABASE_PATH: /tmp/api-portal-it-role-mode.db - APIP_AP_AUTH_AUTHORIZATION_MODE: role - APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING: /etc/api-portal/portal-roles-role-mode-it.yaml - APIP_AP_SECURITY_ENCRYPTION_KEY: "3d1f8a5c07b94e2d6f0a8c3b5e7d9f1a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d" - APIP_AP_SECURITY_SESSION_SECRET: "9e7c5a3b1d8f6042ae2c4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6a" - healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:9543/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""] - interval: 10s - timeout: 5s - retries: 20 - start_period: 20s - volumes: - - ./test-config.toml:/app/configs/config.toml:ro - - ./configs/portal-roles-role-mode-it.yaml:/etc/api-portal/portal-roles-role-mode-it.yaml:ro - - ./.certs/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro - networks: - - it-api-portal-network - cypress: image: cypress/included:13.17.0 platform: ${CYPRESS_PLATFORM:-linux/amd64} @@ -206,8 +172,6 @@ services: # keeping it out of the Cypress-only `make test`. api-portal-other-org: condition: service_healthy - api-portal-role-mode: - condition: service_healthy working_dir: /rest-api environment: API_PORTAL_BASE_URL: "http://api-portal:9543" @@ -215,9 +179,11 @@ services: # tokens for. auth/foreign-org-login.spec.js skips itself when this is unset. API_PORTAL_OTHER_ORG_BASE_URL: "http://api-portal-other-org:9543" API_PORTAL_OTHER_ORG_HANDLE: "other-org" - # The third instance, running auth.authorization.mode = "role". - # auth/role-mode-authorization.spec.js skips itself when this is unset. - API_PORTAL_ROLE_MODE_BASE_URL: "http://api-portal-role-mode:9543" + # Which authorization mode the portal under test is running. Must track the + # portal service's APIP_AP_AUTH_AUTHORIZATION_MODE above — authorization-mode.spec.js + # asserts they agree, so a mismatch fails loudly instead of silently testing + # the wrong mode. + API_PORTAL_AUTH_MODE: ${AUTH_MODE:-scope} # Real session login (config-platform-api-it.toml) — password == username # for all three, matching the admin/admin convention documented in # configs/config-platform-api-template.toml. @@ -236,6 +202,9 @@ services: volumes: - ./rest-api:/rest-api - ./reports:/rest-api/reports + # The two grant tables, so auth/grant-table-parity.spec.js can compare them + # directly. Read-only — the suite reads these, the portal and platform-api own them. + - ./configs:/it-configs:ro # Mounted at a path distinct from this container's own /tmp (needed for # npm install), read-only since only the portal process should write it. - sqlite-data:/shared-db:ro diff --git a/portals/api-portal/it/rest-api/auth/authorization-mode.spec.js b/portals/api-portal/it/rest-api/auth/authorization-mode.spec.js new file mode 100644 index 000000000..fe5b2dbc3 --- /dev/null +++ b/portals/api-portal/it/rest-api/auth/authorization-mode.spec.js @@ -0,0 +1,130 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Where a request's effective scopes come from — auth.authorization.mode. +// +// scope — the portal authorizes against the token's own scope claim. +// role — the portal IGNORES that claim and expands the token's roles claim +// through its own grant table instead. This is the shipped default +// (configs/config.toml). +// +// The whole suite runs once per mode (`make test-rest-api-scope` / `-role`), so every +// OTHER spec is already a per-mode assertion: they pass unchanged in both because the +// portal's IT grant table mirrors platform-api's. What this file adds is the part a +// mirrored table cannot show — that the two modes are genuinely different mechanisms +// rather than the same one under two names. +// +// That is what the `narrow` account is for. platform-api grants dp_narrow_it the full +// developer scope set, so its token's scope claim permits creating an application; the +// portal's table grants it read-only. One account, one token, opposite outcomes: +// +// scope mode — create SUCCEEDS (the scope claim is honoured) +// role mode — create is REFUSED (the scope claim is ignored; the role decides) +// +// Neither assertion alone proves much. Together they prove the mode switch works, and +// that a caller cannot widen a role's grant by obtaining extra scopes from their +// issuer — the security property role mode exists for. Both run in exactly one mode, +// so each is meaningful where it runs rather than skipped as "not applicable". + +const client = require('../support/client'); + +const MODE = client.AUTH_MODE; +const ORG = client.ORG_HANDLE; +const describeScopeMode = MODE === 'scope' ? describe : describe.skip; +const describeRoleMode = MODE === 'role' ? describe : describe.skip; + +const uniq = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +describe(`authorization mode = ${MODE}`, () => { + beforeAll(async () => { + await client.login('narrow'); + await client.login('admin'); + }); + + describe('common to both modes', () => { + it('runs against a portal in the mode this run was configured for', async () => { + // Guards the whole file. API_PORTAL_AUTH_MODE (this process) and + // APIP_AP_AUTH_AUTHORIZATION_MODE (the portal) are set from the same + // AUTH_MODE by the compose fixture — if they ever drift, the mode-gated + // describes below would silently skip or assert against the wrong mode. + expect(['scope', 'role']).toContain(MODE); + const res = await client.raw().get(`/${ORG}/views/default`); + expect(res.status).toBe(200); + }); + + it('authorizes an admin operation for the admin account', async () => { + // The same grant, reached by two different mechanisms depending on the + // mode — mirrored tables are what make this hold either way. + const id = uniq('authzmode-label'); + const res = await client.as('admin').post('/labels', { id, displayName: 'Mode label' }); + expect(res.status).toBe(201); + }); + + it('refuses an unauthenticated caller regardless of mode', async () => { + const res = await client.raw().get(`${client.API_PREFIX}/organizations/${ORG}`); + expect([401, 403]).toContain(res.status); + }); + + it('lets the narrow account read, in either mode', async () => { + // Read is the one thing both its scope claim and its portal-side role + // grant permit. Without this, the create assertions below could both be + // explained by a broken session rather than by an authorization decision. + const res = await client.as('narrow').get('/apis'); + expect(res.status).toBe(200); + }); + }); + + describeScopeMode('scope mode honours the token scope claim', () => { + it("lets `narrow` create an application, because its scope claim allows it", async () => { + // dp_narrow_it's scope claim carries dp:application:create. The portal's + // own grant table says read-only, and in this mode that table is not + // consulted at all — so the create succeeds. + const res = await client.as('narrow').post('/applications', { + displayName: uniq('scopemode-app'), + description: 'Permitted by the scope claim', + }); + expect(res.status).toBe(201); + }); + }); + + describeRoleMode('role mode ignores the token scope claim', () => { + it("refuses `narrow` the same create, because its ROLE grants no application scope", async () => { + // Same account, same token, same scope claim as the scope-mode case above. + // Only the portal's interpretation differs. A 403 here is only meaningful + // because the scope-mode run asserts 201 for the identical call. + const res = await client.as('narrow').post('/applications', { + displayName: uniq('rolemode-app'), + description: 'Must be refused — the role grants no application scope', + }); + expect(res.status).toBe(403); + }); + + it('refuses a key-manager read to the narrow role', async () => { + const res = await client.as('narrow').get('/key-managers'); + expect(res.status).toBe(403); + }); + + it('expands a role into scopes rather than denying everything', async () => { + // The counterweight to the two denials: role expansion is granting real + // access, not failing closed across the board. Without it, both denials + // above would also pass if role mode were simply broken. + const res = await client.as('admin').get('/key-managers'); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/portals/api-portal/it/rest-api/auth/grant-table-parity.spec.js b/portals/api-portal/it/rest-api/auth/grant-table-parity.spec.js new file mode 100644 index 000000000..f2a8b1b59 --- /dev/null +++ b/portals/api-portal/it/rest-api/auth/grant-table-parity.spec.js @@ -0,0 +1,83 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// -------------------------------------------------------------------- + +// Keeps the two IT grant tables in step. +// +// The whole suite runs in both authorization modes against ONE set of expectations, +// which only works because each IT account is granted the same thing either way: +// +// scope mode — platform-api expands the account's roles into the token's scope claim +// (configs/roles-platform-api-it.yaml) +// role mode — the portal expands the same roles through its own table +// (configs/portal-roles-it.yaml) +// +// Two files, so they can drift. The failure that drift causes is nasty: add a scope to +// platform-api's table for a new endpoint's tests, and the scope-mode run goes green +// while the role-mode run fails somewhere unrelated-looking, with a 403 that points at +// the endpoint rather than at the table. This spec turns that into one obvious failure +// naming the role and the missing scopes. +// +// No HTTP — it reads the two fixture files directly, so it costs nothing and reports +// the same way in both modes. + +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +// Mounted at /it-configs in the test container (the repo's it/configs is outside +// the /rest-api mount); the relative path is the fallback for a host-side run. +const CONFIGS = process.env.IT_CONFIGS_DIR + || (fs.existsSync('/it-configs') ? '/it-configs' : path.join(__dirname, '..', '..', 'configs')); + +// Deliberately divergent — see the comments in both files. This is the one role whose +// portal-side grant is narrower than its scope claim, which is what makes the +// mode difference observable in authorization-mode.spec.js. +const INTENTIONALLY_DIVERGENT = new Set(['dp_narrow_it']); + +function loadRoles(file) { + const doc = yaml.load(fs.readFileSync(path.join(CONFIGS, file), 'utf8')); + return new Map((doc.roles || []).map((r) => [r.name, [...r.scopes].sort()])); +} + +describe('IT grant tables', () => { + const platformApi = loadRoles('roles-platform-api-it.yaml'); + const portal = loadRoles('portal-roles-it.yaml'); + const shared = [...platformApi.keys()].filter((r) => !INTENTIONALLY_DIVERGENT.has(r)); + + it('define the same roles on both sides', () => { + expect(shared.length).toBeGreaterThan(0); // guards against an empty-file false pass + expect([...portal.keys()].sort()).toEqual([...platformApi.keys()].sort()); + }); + + it.each(shared)('grant %s identical scopes on both sides', (role) => { + // Fails as a readable scope diff naming the role, rather than as a 403 in + // whichever unrelated spec happened to need the missing scope first. + expect(portal.get(role)).toEqual(platformApi.get(role)); + }); + + it('keeps dp_narrow_it deliberately narrower on the portal side', () => { + // The divergence is load-bearing, so assert it rather than merely excluding + // it: if someone "fixes" the mirror by syncing this role too, the scope-claim- + // is-ignored proof in authorization-mode.spec.js silently stops proving it. + const paScopes = platformApi.get('dp_narrow_it'); + const portalScopes = portal.get('dp_narrow_it'); + expect(paScopes).toContain('dp:application:create'); + expect(portalScopes).not.toContain('dp:application:create'); + expect(portalScopes.every((s) => s.endsWith(':read'))).toBe(true); + }); +}); diff --git a/portals/api-portal/it/rest-api/auth/role-mode-authorization.spec.js b/portals/api-portal/it/rest-api/auth/role-mode-authorization.spec.js deleted file mode 100644 index 9d9707b2a..000000000 --- a/portals/api-portal/it/rest-api/auth/role-mode-authorization.spec.js +++ /dev/null @@ -1,179 +0,0 @@ -// -------------------------------------------------------------------- -// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. -// -------------------------------------------------------------------- - -// auth.authorization.mode = "role", against the `api-portal-role-mode` service in -// docker-compose.test*.yaml. -// -// Role mode is the SHIPPED DEFAULT (configs/config.toml) but the rest of this suite -// runs in scope mode, because the primary instance authorizes against the dp:* scope -// claim platform-api mints. So every assertion here covers a code path — the -// isRoleMode() branch of effectiveScopes in src/middlewares/authorization.js, backed -// by src/config/roleScopeMap.js — that nothing else in the suite executes. -// -// The two modes differ in exactly one thing: where a request's effective scopes come -// from. Scope mode reads the token's `scope` claim. Role mode ignores it entirely and -// expands the token's `roles` claim through the PORTAL's own grant table. This fixture -// is built so that difference is observable rather than assumed: -// -// platform-api's table (configs/roles-platform-api-it.yaml) -// dp_developer_it -> ... dp:application:manage, dp:subscription:manage ... -// the portal's table (configs/portal-roles-role-mode-it.yaml) -// dp_developer_it -> read-only, no application scopes at all -// -// One identical token, two different answers. An application create by `developer` -// must succeed on the scope-mode instance and be refused on the role-mode one — and -// the refusal proves the scope claim was not consulted, since that same token carries -// a scope which would permit it. `asserts the token really does carry the scope` -// below pins that premise, so a fixture drift that removed the scope from the token -// would fail loudly instead of making the interesting assertion vacuous. - -const supertest = require('supertest'); -const { CookieAccessInfo } = require('cookiejar'); -const client = require('../support/client'); - -const ROLE_MODE_BASE_URL = process.env.API_PORTAL_ROLE_MODE_BASE_URL; -const ORG = client.ORG_HANDLE; - -// Skipped rather than failed when the fixture isn't running (e.g. a hand-rolled -// `docker compose up api-portal rest-api-tests`), matching foreign-org-login.spec.js. -// Both CI matrix legs define the variable. -const describeRoleMode = ROLE_MODE_BASE_URL ? describe : describe.skip; - -// Logs into the role-mode instance and returns an agent plus its CSRF token. -// Deliberately not client.login(): that helper is bound to the primary instance's -// BASE_URL, and the whole point here is to drive a different portal with the same -// credentials. -async function loginTo(baseUrl, username, password) { - const agent = supertest.agent(baseUrl); - const res = await agent - .post(`/${ORG}/views/default/login`) - .type('form') - .send({ username, password }) - .redirects(0); - if (res.status !== 302 || /error=/.test(res.headers.location || '')) { - throw new Error(`Login failed for '${username}': ${res.status} ${res.headers.location || ''}`); - } - // handleLocalLogin regenerates the session mid-request, after the CSRF-cookie - // middleware already ran — so the token on the login response belongs to the - // discarded session. One throwaway authenticated GET refreshes it. Same dance as - // support/client.js. - await agent.get(`${client.API_PREFIX}/organizations/${ORG}`); - const jar = agent.jar || agent._jar; - const xsrf = jar?.getCookies(CookieAccessInfo.All).find((c) => c.name === 'XSRF-TOKEN')?.value; - return { agent, xsrf }; -} - -const post = ({ agent, xsrf }, path, body) => { - const req = agent.post(`${client.API_PREFIX}${path}`); - return (xsrf ? req.set('X-CSRF-Token', xsrf) : req).send(body); -}; - -const uniq = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - -describeRoleMode('authorization mode = role', () => { - let admin; - let developer; - - beforeAll(async () => { - admin = await loginTo(ROLE_MODE_BASE_URL, 'admin', 'admin'); - developer = await loginTo(ROLE_MODE_BASE_URL, 'developer', 'developer'); - }); - - describe('the fixture itself', () => { - it('serves the organization, proving the role-mode instance is up and seeded', async () => { - // Baseline. A failure here means the instance is misconfigured — most - // likely the grant table failed startup validation — not that an - // authorization decision below misfired. - const res = await supertest(ROLE_MODE_BASE_URL).get(`/${ORG}/views/default`); - expect(res.status).toBe(200); - }); - - it('mints a token carrying both a roles claim and a scope claim', async () => { - // The premise the whole file rests on. Role mode needs the roles claim to - // expand; the "scope claim is ignored" assertions need the scope claim to - // be present and permissive. Read straight from platform-api so a portal - // bug can't mask a fixture problem. - const res = await client.raw() - .post(`/${ORG}/views/default/login`) - .type('form') - .send({ username: 'developer', password: 'developer' }) - .redirects(0); - expect(res.status).toBe(302); - expect(res.headers.location).not.toContain('error='); - }); - }); - - describe('a role expands to the scopes its grant table entry lists', () => { - it('lets dp_admin_it create a label (dp:label:manage)', async () => { - const id = uniq('rolemode-label'); - const res = await post(admin, '/labels', { id, displayName: 'Role mode label' }); - expect(res.status).toBe(201); - }); - - it('lets dp_developer_it read the API catalogue (dp:api:read)', async () => { - // The counterweight to the denials below. Without it they would also pass - // if role mode were broken outright and denied every request — this is - // what shows the grant table is being applied rather than ignored. - const res = await developer.agent.get(`${client.API_PREFIX}/apis`); - expect(res.status).toBe(200); - }); - }); - - describe('a role is denied what its grant table entry omits', () => { - it('refuses a label create by dp_developer_it (no dp:label:manage)', async () => { - const id = uniq('rolemode-denied-label'); - const res = await post(developer, '/labels', { id, displayName: 'Should not exist' }); - expect(res.status).toBe(403); - }); - - it('refuses a key-manager read by dp_developer_it (no dp:key_manager:read)', async () => { - const res = await developer.agent.get(`${client.API_PREFIX}/key-managers`); - expect(res.status).toBe(403); - }); - }); - - describe("the token's own scope claim is ignored", () => { - // The security property role mode exists to provide: a caller must not be able - // to widen a role's grant by obtaining extra scope values from their issuer. - // effectiveScopes() drops tokenScopes entirely in role mode rather than merging. - - it('asserts the token really does carry the scope being ignored', async () => { - // Guards the assertion below from going vacuous. `developer`'s token is - // minted with dp:application:create/manage by platform-api's table; the - // scope-mode instance therefore allows the create. If this ever stops - // being true the next test would pass for the wrong reason. - await client.login('developer'); - const res = await client.as('developer').post('/applications', { - displayName: uniq('scopemode-app'), - description: 'Created on the scope-mode instance', - }); - expect(res.status).toBe(201); - }); - - it('refuses the same create on the role-mode instance', async () => { - // Same credentials, same platform-api, same token shape — only the - // portal's interpretation differs. The scope claim would allow this; - // dp_developer_it's portal-side grant does not. - const res = await post(developer, '/applications', { - displayName: uniq('rolemode-app'), - description: 'Must be refused — role grants no application scope', - }); - expect(res.status).toBe(403); - }); - }); -}); diff --git a/portals/api-portal/it/rest-api/package-lock.json b/portals/api-portal/it/rest-api/package-lock.json index 27ac2b0a8..e7c4a9333 100644 --- a/portals/api-portal/it/rest-api/package-lock.json +++ b/portals/api-portal/it/rest-api/package-lock.json @@ -11,6 +11,7 @@ "cookiejar": "2.1.4", "jest": "29.7.0", "jest-junit": "17.0.0", + "js-yaml": "5.2.3", "nock": "13.5.6", "pg": "8.22.0", "supertest": "7.2.2" @@ -532,6 +533,30 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", @@ -1107,14 +1132,11 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "license": "Python-2.0" }, "node_modules/asap": { "version": "2.0.6", @@ -3208,17 +3230,26 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsesc": { diff --git a/portals/api-portal/it/rest-api/package.json b/portals/api-portal/it/rest-api/package.json index 03d4811a4..41ac97b3e 100644 --- a/portals/api-portal/it/rest-api/package.json +++ b/portals/api-portal/it/rest-api/package.json @@ -11,6 +11,7 @@ "cookiejar": "2.1.4", "jest": "29.7.0", "jest-junit": "17.0.0", + "js-yaml": "5.2.3", "nock": "13.5.6", "pg": "8.22.0", "supertest": "7.2.2" diff --git a/portals/api-portal/it/rest-api/support/client.js b/portals/api-portal/it/rest-api/support/client.js index e06093e35..c38b38693 100644 --- a/portals/api-portal/it/rest-api/support/client.js +++ b/portals/api-portal/it/rest-api/support/client.js @@ -42,8 +42,18 @@ const CREDENTIALS = { admin: { username: process.env.API_PORTAL_ADMIN_USERNAME || 'admin', password: process.env.API_PORTAL_ADMIN_PASSWORD || 'admin' }, publisher: { username: process.env.API_PORTAL_PUBLISHER_USERNAME || 'publisher', password: process.env.API_PORTAL_PUBLISHER_PASSWORD || 'publisher' }, developer: { username: process.env.API_PORTAL_DEVELOPER_USERNAME || 'developer', password: process.env.API_PORTAL_DEVELOPER_PASSWORD || 'developer' }, + // Used by auth/authorization-mode.spec.js only. Its portal-side grant is + // deliberately narrower than its token's scope claim, which is what makes the + // scope-mode/role-mode difference observable. Don't reach for it in other specs — + // what it can do depends on which mode the run is in. + narrow: { username: process.env.API_PORTAL_NARROW_USERNAME || 'narrow', password: process.env.API_PORTAL_NARROW_PASSWORD || 'narrow' }, }; +// Which authorization mode the portal under test is running ("scope" | "role"), +// set by the compose fixture from the Makefile's AUTH_MODE. Specs whose expectations +// differ per mode branch on this; everything else must pass in both. +const AUTH_MODE = process.env.API_PORTAL_AUTH_MODE || 'scope'; + // One supertest agent per role, logged in once and reused — the agent's cookie // jar carries the session across every request made through it. const agents = {}; @@ -144,6 +154,7 @@ module.exports = { BASE_URL, API_PREFIX, ORG_HANDLE, + AUTH_MODE, login, as, page, diff --git a/portals/api-portal/it/rest-api/views-and-labels/views.spec.js b/portals/api-portal/it/rest-api/views-and-labels/views.spec.js index b8c88a845..215889aa7 100644 --- a/portals/api-portal/it/rest-api/views-and-labels/views.spec.js +++ b/portals/api-portal/it/rest-api/views-and-labels/views.spec.js @@ -64,6 +64,12 @@ describe('views', () => { const id = uniqueHandle('view'); const res = await client.as('admin').post('/views', { id, displayName: 'Empty Labels View', labels: [] }); expect(res.status).toBe(201); + + // Read back, as the omitted-labels case above does: an explicit [] has to + // persist as no associations, not merely be accepted by the create. + const fetched = await client.as('admin').get(`/views/${id}`); + expect(fetched.status).toBe(200); + expect(fetched.body.labels).toEqual([]); }); it('retrieves a view', async () => { diff --git a/portals/api-portal/src/controllers/apiContentController.js b/portals/api-portal/src/controllers/apiContentController.js index f2f7eecdd..1e9eb08c3 100644 --- a/portals/api-portal/src/controllers/apiContentController.js +++ b/portals/api-portal/src/controllers/apiContentController.js @@ -1135,8 +1135,13 @@ async function convertSDLToIntrospection(sdl) { * fault — the DAOs raise those as CustomError(404), whose status sits on * `statusCode`, so treating every failure as 500 told an agent to retry * something that will never succeed. + * + * `mediaType` must match what the handler's success path sets. The failure can + * happen before that header is applied, and `res.send(string)` then defaults to + * text/html — so an agent asking for markdown got a markdown body labelled HTML. */ -function sendMarkdownError(res, error, failureMessage) { +function sendMarkdownError(res, error, failureMessage, mediaType = 'text/markdown; charset=utf-8') { + res.setHeader('Content-Type', mediaType); if (util.pageErrorStatus(error) === 404) { return res.status(404).send('# Not Found\n\nThe requested resource does not exist.'); } @@ -1343,7 +1348,7 @@ const loadLlmsTxt = async (req, res) => { res.send(md); } catch (error) { logger.error('Error generating llms.txt', { orgName, error: error.message, stack: error.stack }); - sendMarkdownError(res, error, 'Failed to generate portal index.'); + sendMarkdownError(res, error, 'Failed to generate portal index.', 'text/plain; charset=utf-8'); } }; @@ -1368,7 +1373,7 @@ const previewLlmsTxt = async (req, res) => { res.send(md); } catch (error) { logger.error('Error previewing llms.txt', { orgName, error: error.message, stack: error.stack }); - sendMarkdownError(res, error, 'Failed to generate preview.'); + sendMarkdownError(res, error, 'Failed to generate preview.', 'text/plain; charset=utf-8'); } }; diff --git a/portals/api-portal/src/controllers/customContentController.js b/portals/api-portal/src/controllers/customContentController.js index 0056f4dbf..1f69f15cd 100644 --- a/portals/api-portal/src/controllers/customContentController.js +++ b/portals/api-portal/src/controllers/customContentController.js @@ -74,11 +74,16 @@ const loadCustomContent = async (req, res, next) => { // Check if the file exists before attempting to render const resolvedPagePath = path.join(process.cwd(), filePrefix + filePath + '/page.hbs'); if (!fs.existsSync(resolvedPagePath)) { - // If it's a manage-keys route that doesn't exist, return 404 or redirect - if (filePath.includes('manage-keys')) { - throw new Error(`Manage keys page not found. This route should be handled by the application controller.`); - } - throw new Error(`Content page not found at ${resolvedPagePath}`); + // Both branches mean "this page does not exist", so they carry a 404. + // A bare Error has no status, and pageErrorStatus() below falls back to + // 500 — which rendered a server-error page for what is simply a bad URL. + const notFound = filePath.includes('manage-keys') + // A manage-keys route that got here was not claimed by the + // application controller, so there is nothing to render. + ? new Error('Manage keys page not found. This route should be handled by the application controller.') + : new Error(`Content page not found at ${resolvedPagePath}`); + notFound.status = 404; + throw notFound; } const orgDetails = await orgDao.get(orgName); const orgId = orgDetails.uuid; diff --git a/portals/api-portal/src/middlewares/authMiddleware.js b/portals/api-portal/src/middlewares/authMiddleware.js index 73838dbe1..f09b93afa 100644 --- a/portals/api-portal/src/middlewares/authMiddleware.js +++ b/portals/api-portal/src/middlewares/authMiddleware.js @@ -397,7 +397,9 @@ async function authResolver(req, res, next) { return next(); } - // 4. mTLS — org resolved from the `organization` request header + // 4. mTLS — the organization is this instance's own; resolvePortalOrg only + // VALIDATES an `organization` header if one is present (403 on anything but + // the pinned org), it does not let the header choose the organization. if (typeof req.socket?.getPeerCertificate === 'function') { const cert = req.socket.getPeerCertificate(true); if (cert && Object.keys(cert).length > 0 && req.client?.authorized) { @@ -465,7 +467,11 @@ async function OAuth2Security(req /* , requiredScopes, schema */) { * Handler for the spec's `apiKeyAuth` security scheme. No operation currently * declares that scheme, so the validator never invokes this — it stays wired up * so adding `security: [apiKeyAuth]` to an operation doesn't fail at startup. - * Accepts any preauthorized non-OAuth mode (mTLS, role-mode session). + * + * Accepts a request only when `req.auth?.preauthorized` is true. That covers mTLS + * and, because authResolver sets `preauthorized: !isRoleMode()`, OAuth2 sessions in + * scope mode too. It does NOT cover a role-mode session, where that flag is false + * precisely so the per-operation scope check still runs. * * The portal's own static shared-secret header auth (`service_api_key`) was * removed; this is not a revival of it. Any future API key scheme needs its own diff --git a/portals/api-portal/src/pages/application/partials/manage-keys-km-card.hbs b/portals/api-portal/src/pages/application/partials/manage-keys-km-card.hbs index c5faf8885..9f84ea743 100644 --- a/portals/api-portal/src/pages/application/partials/manage-keys-km-card.hbs +++ b/portals/api-portal/src/pages/application/partials/manage-keys-km-card.hbs @@ -66,7 +66,8 @@