From 2ae62443351f58651f151aab1ee9ae955dd11c0e Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 17 Jun 2026 16:48:04 -0300 Subject: [PATCH 01/41] feat(parameters): pluggable secret/param storage with 4 providers --- aws-secret-manager-strategies.docx | Bin 0 -> 15433 bytes parameters/PENDING.md | 197 ++++++++++++++++ parameters/build_context | 100 ++++++++ parameters/delete | 6 + parameters/docs/adding_a_provider.md | 223 ++++++++++++++++++ parameters/docs/architecture.md | 115 +++++++++ parameters/docs/configuration.md | 135 +++++++++++ parameters/entrypoint | 50 ++++ parameters/notify | 10 + parameters/providers/README.md | 184 +++++++++++++++ parameters/providers/azure_key_vault/delete | 70 ++++++ .../azure_key_vault/docs/architecture.md | 103 ++++++++ parameters/providers/azure_key_vault/retrieve | 43 ++++ parameters/providers/azure_key_vault/setup | 48 ++++ parameters/providers/azure_key_vault/store | 36 +++ parameters/providers/hashicorp_vault/delete | 53 +++++ .../hashicorp_vault/docs/architecture.md | 80 +++++++ parameters/providers/hashicorp_vault/retrieve | 55 +++++ parameters/providers/hashicorp_vault/setup | 45 ++++ parameters/providers/hashicorp_vault/store | 35 +++ parameters/providers/parameter_store/delete | 43 ++++ .../parameter_store/docs/architecture.md | 104 ++++++++ .../parameter_store/docs/iam-policy.md | 112 +++++++++ parameters/providers/parameter_store/retrieve | 45 ++++ parameters/providers/parameter_store/setup | 61 +++++ parameters/providers/parameter_store/store | 51 ++++ parameters/providers/secret_manager/delete | 48 ++++ .../secret_manager/docs/architecture.md | 150 ++++++++++++ .../secret_manager/docs/iam-policy.md | 198 ++++++++++++++++ parameters/providers/secret_manager/retrieve | 44 ++++ parameters/providers/secret_manager/setup | 41 ++++ parameters/providers/secret_manager/store | 50 ++++ parameters/retrieve | 6 + parameters/store | 6 + parameters/tests/build_context.bats | 199 ++++++++++++++++ parameters/tests/delete.bats | 43 ++++ parameters/tests/entrypoint.bats | 156 ++++++++++++ parameters/tests/notify.bats | 47 ++++ .../providers/azure_key_vault/delete.bats | 128 ++++++++++ .../providers/azure_key_vault/retrieve.bats | 86 +++++++ .../providers/azure_key_vault/setup.bats | 69 ++++++ .../providers/azure_key_vault/store.bats | 78 ++++++ .../providers/hashicorp_vault/delete.bats | 103 ++++++++ .../providers/hashicorp_vault/retrieve.bats | 95 ++++++++ .../providers/hashicorp_vault/setup.bats | 84 +++++++ .../providers/hashicorp_vault/store.bats | 110 +++++++++ .../providers/parameter_store/delete.bats | 83 +++++++ .../providers/parameter_store/retrieve.bats | 86 +++++++ .../providers/parameter_store/setup.bats | 99 ++++++++ .../providers/parameter_store/store.bats | 139 +++++++++++ .../providers/secret_manager/delete.bats | 86 +++++++ .../providers/secret_manager/retrieve.bats | 85 +++++++ .../tests/providers/secret_manager/setup.bats | 70 ++++++ .../tests/providers/secret_manager/store.bats | 98 ++++++++ parameters/tests/retrieve.bats | 43 ++++ parameters/tests/store.bats | 53 +++++ parameters/tests/utils/get_config_value.bats | 89 +++++++ parameters/tests/utils/log.bats | 64 +++++ parameters/utils/get_config_value | 56 +++++ parameters/utils/log | 24 ++ parameters/workflows/delete.yaml | 12 + parameters/workflows/notify.yaml | 13 + parameters/workflows/retrieve.yaml | 14 ++ parameters/workflows/store.yaml | 16 ++ 64 files changed, 4875 insertions(+) create mode 100644 aws-secret-manager-strategies.docx create mode 100644 parameters/PENDING.md create mode 100755 parameters/build_context create mode 100755 parameters/delete create mode 100644 parameters/docs/adding_a_provider.md create mode 100644 parameters/docs/architecture.md create mode 100644 parameters/docs/configuration.md create mode 100755 parameters/entrypoint create mode 100755 parameters/notify create mode 100644 parameters/providers/README.md create mode 100755 parameters/providers/azure_key_vault/delete create mode 100644 parameters/providers/azure_key_vault/docs/architecture.md create mode 100755 parameters/providers/azure_key_vault/retrieve create mode 100755 parameters/providers/azure_key_vault/setup create mode 100755 parameters/providers/azure_key_vault/store create mode 100755 parameters/providers/hashicorp_vault/delete create mode 100644 parameters/providers/hashicorp_vault/docs/architecture.md create mode 100755 parameters/providers/hashicorp_vault/retrieve create mode 100755 parameters/providers/hashicorp_vault/setup create mode 100755 parameters/providers/hashicorp_vault/store create mode 100755 parameters/providers/parameter_store/delete create mode 100644 parameters/providers/parameter_store/docs/architecture.md create mode 100644 parameters/providers/parameter_store/docs/iam-policy.md create mode 100755 parameters/providers/parameter_store/retrieve create mode 100755 parameters/providers/parameter_store/setup create mode 100755 parameters/providers/parameter_store/store create mode 100755 parameters/providers/secret_manager/delete create mode 100644 parameters/providers/secret_manager/docs/architecture.md create mode 100644 parameters/providers/secret_manager/docs/iam-policy.md create mode 100755 parameters/providers/secret_manager/retrieve create mode 100755 parameters/providers/secret_manager/setup create mode 100755 parameters/providers/secret_manager/store create mode 100755 parameters/retrieve create mode 100755 parameters/store create mode 100644 parameters/tests/build_context.bats create mode 100644 parameters/tests/delete.bats create mode 100644 parameters/tests/entrypoint.bats create mode 100644 parameters/tests/notify.bats create mode 100644 parameters/tests/providers/azure_key_vault/delete.bats create mode 100644 parameters/tests/providers/azure_key_vault/retrieve.bats create mode 100644 parameters/tests/providers/azure_key_vault/setup.bats create mode 100644 parameters/tests/providers/azure_key_vault/store.bats create mode 100644 parameters/tests/providers/hashicorp_vault/delete.bats create mode 100644 parameters/tests/providers/hashicorp_vault/retrieve.bats create mode 100644 parameters/tests/providers/hashicorp_vault/setup.bats create mode 100644 parameters/tests/providers/hashicorp_vault/store.bats create mode 100644 parameters/tests/providers/parameter_store/delete.bats create mode 100644 parameters/tests/providers/parameter_store/retrieve.bats create mode 100644 parameters/tests/providers/parameter_store/setup.bats create mode 100644 parameters/tests/providers/parameter_store/store.bats create mode 100644 parameters/tests/providers/secret_manager/delete.bats create mode 100644 parameters/tests/providers/secret_manager/retrieve.bats create mode 100644 parameters/tests/providers/secret_manager/setup.bats create mode 100644 parameters/tests/providers/secret_manager/store.bats create mode 100644 parameters/tests/retrieve.bats create mode 100644 parameters/tests/store.bats create mode 100644 parameters/tests/utils/get_config_value.bats create mode 100644 parameters/tests/utils/log.bats create mode 100755 parameters/utils/get_config_value create mode 100755 parameters/utils/log create mode 100644 parameters/workflows/delete.yaml create mode 100644 parameters/workflows/notify.yaml create mode 100644 parameters/workflows/retrieve.yaml create mode 100644 parameters/workflows/store.yaml diff --git a/aws-secret-manager-strategies.docx b/aws-secret-manager-strategies.docx new file mode 100644 index 0000000000000000000000000000000000000000..3502ddd5f09622bb172421ee26f1ba960ba41df8 GIT binary patch literal 15433 zcmc(Gb99|u)AxyOG`7*$w(X>iZQHipsIjfaww=Ze8a8HwFTK^?eV^xB-}l$M*SgMi z);jw)XZFnOy=P|6mX`tng$Dd+4Z*eV{`m6O2iWV|#m3%{PX51kf%&_Oj=hnk!#^D% z|DZLHE@$-c+JO)h0KoZoM?)I}Co3as$G5IlmT!MRD-zaZK?o6pbb@S8Yb!LPC{Lmo z4k|S8eQWFZ>@Xpvqr5yAoT*vh&izL@m@@7eIm(-|jrH+2drcKCL{uEWi>F%{I z?JtL#K3r2vhkp^q5PhTW=`V2MH^c*lbOjI%8sY3(Iw)rg*FW&sO7vFa2{n3O6)4AZ zS3thf5dZys>t;Cz=Uq(GNsB^n-8=+{xSDDx{~(m0qjCpC{9{pNF2b9|MLMa*Awn9FIkE>={GEDz#1 zE{)KpRA@dY+>?G^KN@($N8(@0oLuNtEskjF*y+8Y^VG`sE)WPCCQ}@ZXKnwRBWr3X z_wK0ao#Xb&hpMJ*%W9I{xJeMSup(@fILpVa+fJ_2`;k#Wqym~Nce>!XnKt~c)30k@ z&g))%6WiaO!2`PU@?G#ebux>w}^kIj^l5>5$s+XK2SUXo8>>7B=+c5qYW-^kUPl>bI1|czy{I&@4^MT}1GjEj08wbKml2$}C%EJ}2F+|2}29pcK zeLUntAb?oe^I;@=b0hWHqu^W)*#UtyIP><704<07nCF{i{do!#!rN%KGsj*KptsE0 zR&OTLUdF!iu5Iv1@7r7u`dFWy2e;d&&Q&!;m?*s?90y)_V-W|*gO1(FclVr_2`v}F ztT)L0Md+i4G)c)`35H}Ay$(HI;F)rt5Y(lTE9sv50|P20e~^dMj&T2N!Cr+i zT%CNV+^ht_1;?|3?HrNIn{wA-M+7wfeIk{`4paEmwKds*MZ=s4s$`L*qy_X5Mc*J@ zD1mQgrA*yq(x9+`GwKh5OmZS19AnHOdcr;EzMuL|vk#pfp?0>PU0U3Wq9Tk5oZEpAc;|8WMp_&= zbPhhB`T(VaYlB9q1I0E^rU*3mLq6X?wS5@$yVB*I)gc0<^M&wBF_eJA)!WjCl$U$9 zwE=R8w8TaVsRpWd55VC`7H8cfq(SaRvMtmHfonBh2d40t)e-Ek&)LdNv_^=H9`#!) z3>~2L)k80EpV23}c5YmX@$I!E^F#vDclYga1(N4Xgo0IHE>IR0f=8J~4i4U>cAdTM z>cAAhBc<%>`OMCbjfAiRG2y!OmFX%0Fa=;{SSqVIQ{>o#6qO?_=^+iVBbY!W267eb zO+XrPJ9MDKK?b|-cBK`r^}m4huiK72b(R<+XYP%hqpJlBRx!6p3i{yIY&BW{hjZj}u1O!4!te7xAz zt@VQUh}?srOv+ieOEf=2O(!p=2SN)t{_4B|ogr>JpJ{Gm~Ei`K ^vMG)`7esN%45+z*8 zSn3Yc*k^3Y7!)8s^x+XT-l-_gAz#nLENy$%dS`M18ZKDnz0T=(mEAD3qZUaN2xMjx zPhIT(T3yHS#Ra`$bN#|H+2zGG1@RB^n9C6X+sJ25Fa=0E!&i#@K*;QAEMhDVdAgsG z#m6s&b7Ae2HzP7aE%Rsy2W8@G0v5@;Ztt*i^FG<1za!a(sL;X5y^nP40tq;{gV+JZ zoN?NlHEp6Dy;Q~zJvupXSTH+rf%J-Gw+JE7n35*`o>@Qz)(eA392?gqhs0IF&~YFK zi)rdv^u_cvfog&sX5qxq6tg*JO%}JUl?(TdrDme9^X01#;%1g3CnQbdJ=^2WO6K4h zF4)`x4+JPJD};y(IO^Jjp!)IlJT&u7_Eb(MDp4i7J` znn~{m_6Tc(qYJY?K?b{KXHzz&89rHU!#06?HnYdus%1&ej0$HMb2s;xN$g3oOxTSk zFwuL5)Xy3)c~i;bcSFArJi5e1Pw6?aIRG(F=ra0z+HGREGkLU{%x?E^vUPuWYO2b% zIn~lJu!+lSa57N>#=QA9DfUv^-kBm-bXT&6+}?`m<#Bd1j=zzKhugvHUf15)2`O3D zLTAHT(7DbU^5wWa6_5}*l`6P`c8_3>dG!L2A$sAzpePP1hBz>2_11XU*)7;ivr`~p z9P@X4KtgqPl=ru6>3TEDaNaPkfPMiCGG zcAyGzeri4AlWAt+8oW9T3Ul734N>r{&M-%vFpj>d3&m5&VY-{y`&+kU-)RC4Vx!r^ z!zgD15-$K(=Kuvp!gEU)QKvsVf3%iqx63jc5ep{6#>X>{oN7Mwq1845DZN!Es{nO3 zZc4`o!S}(_{;)Fe_)(RQho9fZwmNz&yPQ-!SAK)vlUYWMB4vPVx01F zdGo}9AqkIo784_e!BV`m5o3*LnghRU0Q@7=>{R+gyt6|_7{M;rv9hAKy45E=nrUls z)Rwe}E1d7Z_K#`-Rai@79AGpmnrBF%M#Upe~}5ojCTc5Je<9OyX^5M-O|s~CJia4C%__NxdWusFXh+CL^)G4$-4wmy{X_*J;9U5P5y{{sp=C{*Xaty~crzq^l)HXI zoVs3}Rp7<_)4oUR+_pdBXhRZ-o6n+CA_~Vq9wP%87fvd8G5f$MVE^fGf9F0T!Dfl9 zHWDKi!xj@%Jh{~NfdMQB!zgpRJ;X+*lb6$NR^8>KMP=KPXZ3H6-TKUsBM+J|zw zP36;v3*4~0_E5mpj1R5uy@b1?UnRD0C{$OfBb+20m_*7U6wl1pbq^Md90iDU=0Ye? z+B*)!gm;cz;)(QP?{@I11R@XoJz-P5iECzBES>PC16WN3*lfH+W|GLzfdNX=rAA~iXxa#y#}L%?xhtSp1W?it#R1iD1cS02 zMxOC3iEYCfR7!TRBtf#Mv?J_fq{a^xu_Li{gfV~}u>okXdGIBbz{ha?W7IJ6?*T?6 zt;1)+79?5Eun&>kGUd&Z43vAgF@2S(izT_v#w>3eDKk12y`SaDp>$oAVd2&mf)_zk63}67*mHm3WYt? zG$_k3ysQfID0KT(xl36_MIr$1ARyBmCjsxg-q9UXYVwXc+#DD97@iAoIl3z4=zGYo z4GLz78%bU21+XghzX2N)?=S7Tx#1XJhc824*$k)0HvxWl7l&qq*wx9qeF!nZiSzbE}NLBc$i6*cMPNtQ%2Ldu-iI8zBP6xaA8Xm z8d-Nm=?-24=DLsCW3qE7hL^Yjz{-AJb^}ofTus2#mx5+`_b{oMx;`KguEFPS20<&3 z9g;&tVy8H<{NOG0F}&CngUwx7gtWzJgoo(r=u*lbj@nX3S>N7N&9Nzr_^<5)!7R{T zG%Hx2v>^a13Cbjc!19eXOJD%%>_cNAU3FFk7}m{L8giSM>NKN^ zpD`#n+fEXbn?_;sfHFx(#1$iKTTg9dJ`W$EEXmKnHM-BMfGNWYAiqchFqqG@C;M9X z>D>ql%hNq=Ql8twXG?v^s@>5?qoxHKMa-DaMm-po3+K?jxaoEW00i;nh3C67^ckOX zk@ac#y`h^A->3}9b+f-iGi8%;E`Af6TSTUV=}C%rY6txhw7$O{{i;_U3cS*&5^?+a zi!14}J}W|m`SlP1N##id5Jr*AQ54@~l44D;NcRZikvlJ=Qgxa*5pSD{k3vD)EMW5mF(iH?S0oA^Ar4Fqy z1v)%)@h0sziyt0Hghl#^v+sTaZ^<=kTz zh<%H%?_#f1942fY#N--#9F563bZec+oJN)S6cT6h!-kabKvmrYP}@5%o{V^S=#YA+ z5+kFdT$afO-7td{jG2jN_X|{qT@fDD%1XWbDNhoFQXpPE%I9~Q8ACb@gwC`2_)MiV zEPY`ES_4enfiHk+eAj8pkW>w#sqZPdi=^q=Wa@OQr74Ces?)7@VuqR}%^^qm7L%cA z-(jwWQiCAm^fEgcJ1DwDj-qO(DVLx|$=THx_^!MO_;a|4JJD5<&kzhHYw-qJSyO+Kby_}C5#2r*1k~Z|ue2L!cn4CL5!kz>$?V~AmAFa3mBxp#zhlkDSB81nNf$hn2aPS4>Bto)o$A0-%7i% zzm_3%=&^hp+vD*v%CI6v)8#Km2GhcU^UlGxRX?B49ZVS|F~tH#?+{MXCu3`ow`>p(2}#;9t(cN|A@v2dJj&5m8PG3!lgphjtFE~B#vtnM}Ej@5LK9|FT>}&}uqa7XG%YPJBbGA#jR6&IIsANk5A8X}NFw;E)h=2>2UE-ZBMZ4y1QkTwl~Vd=^{$ll z!j%s4Z$+{-!+^HZ(l^5^We)2&$ccJUkltb>i&ZLraiP2IZQeR4=8r<6(eh2Bst+_C z3l;THcBq(SU|s=o(qP)q)J1kF7JuWGvqFEAd|ONno&`2D#ij#}B=g;($Ap-Yo~s7g zu)OJQiTkk$a2)d#NYHnwf_0N5vKWv!_dF{+=49p_4TJ(4T)ly#tP3L!i?CCr zVssJ^8grl9-h>K#-g)kj6zDg-2u$u($Wt4Fy!{$8j(=}`E%w=|y5Z>TSyT$24}R

=2p3VorI_NcYQ2eZD(m^ zx31m9CuvTk#D)g^g=deKVq$U~-z;}}-TXE=A^I5>NimG|UL!*Dh^+5kolJFo1;jfr znvBA&sZy=0LwCmMT$yqW6xl8B^~bJVm2&fXKlnhml^BAm2|;c5U_MLraHCg!4$619ux5x~W4jt&VzoC+Z*_0)R+Zwd+{I&O-K94LgFR%N zsD_^m!i_+IpvG>thge+0%ruCYHX&f?kfGbS&065Zj<+LkN@};T z>^(QkbXzQx;{e1yrUB;Q%(YVZ@kz3HJ2163uPjh~OSC5d6&k|VKm0gpzR_2YXXJwl^KE+SslLF@zi5O;rq!vq|NC_2$L7XH-wo z;Z~nkWaAt^goRpn-0z@jr!Pe)yCGG!0DdUL#?l=+Es=?Q07sOipF-%1pp%sHB}DhqtC_ z@G@oTa0}U`HNcdl#TIj+@T=1;z}`GFG@Plix_v%jHod6dEXZ!$e`z>VrpO&p=#-iW zZgnPOJ2G|(Z@dIPfGi?PvMQE@Ky7!9>LzFYdumT!F1NJ2v9)yR_M=@{tmGU`eqRGqyovf^!r?Df;LtL1+ zjHllTN(iz0Hd2GLS2Tji0wKtw z>ws>I;vwr1L1@5QbVaoN(uJwBBsJ;$&;q2%%xC3nNB(idn>NGGf@B8^>S^|qL?S#{JHyaM2JBSD1p~5oEGIXLZ;jtwVYV4<5$d_R6O^xVm2cvNo z%HwMZiS@#}WGNC@q?fM9ou!N3aH85%%s~dhey*13$|;+`u>CTww~R$OvtQ~zjEMt+ zx^ki(y?d9~n}bHbZ(}2uT}PfN%k>UJ-H~Ej-k5OY*rm}0Zh?M9{`?TK0mTTuOclw9 zD>Iz_h*NLRwwS?05Y_(67@-W49ry)}7!LU~j=mRim?(M0t_E`=xBw+-CEhgQbgB4N zWcDX?Wq1|Ua3@`8td+j84mt`G8(<%ldjWbIlD6B4?tR-*2HD+RgS~RNI9E;mk?d{p zR~bO$IAGyHS3Qm50~bmDF3X=T{c# z9w$xbM%e+n^v!SNku80+>zpJ+OxB%4`aW=GbeJ-PMW$x2fF5^6+JXchyI+Bc&Bf7A zsGtzG*!1QY_#?c5YIkZa&s{?<%!?-;tqQgy-v_f2PC#&rX+d`FEspa^FPZ%k1w*Fx zz)apZl!8SXJ>5u9yRKQk8N$p9@m<+DNd@8X!=78td2&r>l9l&}r~bDL6Z^%q2nC|( zCFl(h^Iry~P0g_Q(kDD;q{fT)N6T8zQX@S%l6mpmBAB4=EQQ2j__<+s@m#Db9B({T zSrb2!Drp;Z-HDsMXNB(e&W*zXjMbQ|KUr<4-+a5S`A$Gvy$*J*Femc$1ab72kRSK1#ZnorRtfMcz8-> z5sJb}AOBKi7Ogs_FFdP+sylNLYblHaMsK*DI6rZ&tnU|krD1pvsJX?EY`_N+&s7po z3x@eYiAyko)!K2BQ^33Htfmj6pIxnJE%GG3KDzmxe2e6^j0WoO`JmnNd7ftvUwMSs zZ6}Sl@QWZk92c0O&Zep6h~X(6F~^V*YNX6vl&yGe?DmM2jXD8@!vk^VhyL@;@)qt- zouhm^P$TlXaD99wMHMG!;W)DLF)H#DFar|rRjnGi8+#i3m%?qONca^#9Eg`$vWM5z zKf|)7f=McAMfia`rfbH+$?>U^+Pk=9V+}-YUgnYS)y;^9RihX9%@~>Uqag^48s?wn zUYJ>v?$$q=*DqginRo6sA(&>^sF7Z< zE9=WXw8dZBMABZY*8AKJR*36Lnac%xmT8&|=sB+{@M zppm}LgwYS33HnL^8E~-VAPR|cT=&(W=3x}bU)YUGcLsH1<-D*0xFE!jT`1dx@j^km z69`)k%!@@21|yNX8+IXWL(kQ->0x&+Mv-<3fPmjgvx67J@)5SB+T_na6ZcLMU>r4O ziWPb?85ajdQDqim7}Zg&&dSEi>!S^$hXX)hz=`+>;|r}=ItzL8vDqnr1eWOD!I2xHj5H0tEkEZNt&vrND{zN zP#-!teepa}qh7WsHmuSdz7?<6BK-`JY|5rQ{Vg}VT{Wy7A;EM!V>CfeCU=%#`hY)o zmTLMykw_N8btFp7yrn47yDae;2+LmXB0%*b0Hpr2$M6ekWV}dJ3$)9SN0wf})1`K8 zQ))yjkKAv zj7`f^XObMF1wEtQzlo!j9kQrN1$9*@A284WhsTWW7P6{weanDRn|(>Wx*2PY!b~*XTyr(!xw<2tgU8b0r)!O@mEK_9jU#KH9G6Yi$rG8$@z+ne%SSl6-Fr6g=Qn>($^Tg*0`)p2 zU#k=3?QLv-ul^AEkhjAL1ONm81OBLF_@|qJjlI#2xtkO}ZS7Bw8mtp^g(oQxM(jJ> z=*p5nI%|1eX%_Q2GP&?Xc*7eEjp5Qb<>cjh)CV5*GOs)U1cMq&g$TKy0??23cCJ$R zXr3tJV;5&Bdj&QaDW{*vWvMQwNtB<fDjN#-oaK=O%zBDS00z3mEy zL?&N4?n0v1Ebb;rG!sp?f-G)-CjDFh()+{=ILRl2L+viiU@Q^}6NWIzjvDyLdIbEd z;n2jMOlEH3sx5x}3XHBst31{dg^ldZs@)YaK647?CQ9&PD)!MdphGOB3YFqwQ0>iW zqo%HO&8Djl>(HB%MM$@7N46jk(_RJPSADiLZ-uhgxJ*w05%6H?lXgHu)jxdG*og&#$Xw(vV_GKmsBLs*LfRGc%fsj$#qME;0zZ_pn{O z-lEr=HK#_EH&4~x5-0pUdA+EDC5+St8y=G!9XIXZZ!5oHIN6sg61jr5MZ@5X8N?y- zhIJLKe5eDmRg7}=FWzJacF6EA8;u~^!Nk@xTE#&R>4ka&9aU^c(bh%TmZc<42Z$E< zYNkHK4y;sR1gVE5#~@@D5~m)f9O*=k4jX=++J}KINIK=h)AUG^6l#}_6`d0Lm7-#E zDce*~-#XIZBgTwzDOoHb%jiy;K#F(*(nsM*xW<(#U13nh{$A2P4cejLKp271kKyv) z&)y1Fiexa1ISoUi6^aII9wZLUv!>@_Zr1}3>on5Z8kGCs}|GuLfhis4- z2VD#z@wmjG)F3+b@glQwP?N$Ubr4x6rZ?=kxAy`btpFSx-)yxh zA3&;;L)tnTuGksyi^TPHwcxP|G(W0$j;7WzV3QR(0j)R-2FWxjI@?WWX2r0m(xHmh zK{k^@j~0{J*hU|mywe9sBsNE#5DO=!HkYONY<|Wwy zJ)qT>w&&V=*smZ;xxn&;wxdj@^IPY_QPsA2(wFm@_ulf(1l$a)tvG%T8&pt5p>iW+ zYbUJ>Z{B9=g*DKwb4A_Jg@oVw=lHq5kQ{b;@hP{TUSk)ZHtu}63lIRg7Lc-zMq1(b zsv>10-)AlP>@8(2J87lZ@YPDeXFcB>f>vPz;igQp`JCwa`qNeRp2Pk9U|7~D$n2t5 z`a$c9TPei%u1BzwaUFky9z-vCzsa@VqY%}VE|K|>qmcY!G2`zVV zXMBp0G;(iJs%2Zb+%4J~p<4}WD)h6q@nicBo#g%e`WZ+8tPC=Yi`M9JyIRF9+za*` zWCMYFi|-I{19OSlDh=)Nm|8v+#;v`P*vdx3Wzvy!y=TO)_}KNf}t)lu1Adc?;h3bnarTYkuf-L7p&oK28-9tl|b zRYo?@k32_Lgkb54IiHMpn7B|*MMriH*A|wM1)28?@68w{m~5dN?BbcvCc`9 zky*YztK_Hh3Ow$rEo+pFi`-{(UQN7jM;$2h=SnoL@RuV8Lp;@0*)%Jp`om;0zwrUm$I_Zk6ts=JU`Ao!hbtsZxgq#FQw7> zjevv5YYx!`LS65rc7w*h2SChvgE(}2w(T1eDH<0#=&3g^Ea;KyB*jz6k#9AVgHZc* zAwc#si!;1mwhMv>3<6fJFiFTIxza{RlPE+~&J!P7aPXRzFmkIUxYuLys7= zRhGECqWp%az>Mw7XAvj-88d8CV?i?7n;TJ3gw2cd-a(6&6rFImJa#g;!CHUd;fqbv znX`SH5E#^WSU6)nK{zgGM=OUI9D1@4MMGcV-VDX!BxJ5UHpM;ZPmwt75^e($=j@yj zYK>)Ll84GFCPr!!#|T@4-HT;SS9BcJPi+Y%ogv31H*o(R!k5R)&k3)r|CpeNzYamq z*7o202Oe9M!o`xp9=E?%4}K~6_S*n5!iM7}UZ+#V>+!mBnb0TG zLQ@LOV;9zG?0uP?wsf@OQW3ApPS=e}qE$$O6f8tJUP<8N%}3)M*9T!NS=bfoe;h>K z^uZ}b4baVx$SdG~?q=d9aENFrTk^GGYK*R72cBD5Z&*O7m}(g8q$|lW$@qZ24!-#e zuXl}MmQjvK_C*ea8WvA=lNqMjk*hS#!{QkDAQ|kQDhbxZu!s!;7B%0FEGD#{q}XLx zOu&xawb|iI^xNAmY+7jyB1LjkbHKKR*;nQcsovfPfSDZWw7Ezjx}jRi_2zmFd*sMib+mfVl_Sy~vMgPUjZoxrl+l*%JD(sY-SKiSAcXJ_P(_O9m!y-DUk z??PIQHxfW!)-?^8tUH3R!5oA%?J6ZWb1rJHiT9c)D>FNvxgyj+@tqn^m;JU)KGCFC z^{`K?1m+fMt}RT8(~(BRDY|XW8~q){I-Uo2N`NV|omVC$0BVN0m%8pL)%^j5@r5}+ zNrheWZd~Wz<~!r^*ktu;wjQq!?(6eMUG$$D%)e~lkKINlQUD&95LTp0u*D_(Z6rhQ zm^Hla6#!0^06s`wpSy$R{d3#xbDQ_2HSa1jnw@P14FOgr*k16 z8B|z^k(>}cs?>z9R{>>+!WOP*btChyT zTIoOa!$#JI{}}0b;)?Y)0b;OD{}g5_Wk%94dK^hJXR!Or?|SD6tCkDz-%6ViKxXH( z|36#iR?}GhSOQeh#%vdKKnNQ9IPlxGX!AX7>{k93BY_Ta(q6pfgKHoVFtdzd@;)Ny z!}k{oQ_MKrs~mP>DO{UT=wz@+B`DA=zUK|~4p=$`TY6r;^+oZf4Ma6y%kxG&%?{kOgDp! zqQPuIn+!E#MOc@Xw=Q=cy0r!v09e&jkZr4GHsyxd7l%}Dvp(<=SpXxpXw2^OdKcca z9;@KD8HW#C1{g-3h z4VwpGnhLzE`@r1T_QJHI#fQsxT6K0`#BtmGPS_AX1e^QWrwk0n9-^&(e5Bo#1B)-z zU`^yn4WHM|)o*2*O;mT5n+1H3Lr;lpgiOxYflJhpEoGPsLXqT^ybwvCyS&)@&FmtA zIr{ov^*QkBTmGX@0~@O!cRK#m=iHk87Xl3MO)WciwRm+Uw%K~}%#$nE6_^~~9_B~b zoat5_MN)~BOj&=iLz0bzl*O*zSqbI{VwPlTjR4OiKd>Zqq_?*$u0G$}SN(&W#F6JH zBrI$T(Ybke>(NvNG_K+reDU3*^NBGrQ4Jgg2m1qgiSM-2NN|&K=Y%t_)d|#K>4uc z79O;SX&sq>0t-8a#`~`+Po8huTgG(wdIHmGpGSx4dP1zZd@69TKHt4ftdF!@Oy6@3bOr!*H4k7_gbu4l$@mx^Dml;7Ph4(j(}iX1dJ?#uMs1mAf#wI{MXh|?7CB;`=8_bnw0#+t*5YwBBgK9n=~ zMjl{B0~@kut^^618r4G_S+<37k3v7|_E1AFG^p(1&SOT#cxi)f$A@NNt<2=S*Pech z;fO7dnLEeO+zK)C5U0`3BcQ9o-^2M~}8J-x-&tfZw$K|F}KBi1dF$=|I4!fd77R?bSznZ9gK7AMLMeZ2ukdBVX`m z`vD<-eSY!qSezEN@OKbZfhp5Id_KY^p<|1qKR z`#=94xc?-OO!KDJm6IQS?$1_D_b*OG_+I@oU2GpIz_& j#>u~Ri@^FXPX0yZFIK?*n0n{{bb#lpN8j`c0090E)I>Bk literal 0 HcmV?d00001 diff --git a/parameters/PENDING.md b/parameters/PENDING.md new file mode 100644 index 00000000..2f79a1ad --- /dev/null +++ b/parameters/PENDING.md @@ -0,0 +1,197 @@ +# Parameters Package — Pending Work + +Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. Para una vista de la arquitectura completa ver `parameters/docs/architecture.md`. + +--- + +## Estado actual + +| Componente | Estado | +|---|---| +| Skeleton (entrypoint, build_context, dispatch, utils, workflows) | ✅ Implementado | +| Provider `hashicorp_vault` | ✅ Implementado (migrado del `parameters/vault/` original) | +| Provider `secret_manager` | ✅ Implementado | +| Provider `parameter_store` | ✅ Implementado (nuevo) | +| Provider `azure_key_vault` | ✅ Implementado (nuevo) | +| Error handling (not_found → idempotent, otros → fail loud) | ✅ Aplicado a deletes y retrieves de los 4 providers | +| Tests BATS | ✅ 150 tests pasando | +| Docs globales | ✅ architecture.md, configuration.md, adding_a_provider.md | +| Docs por provider | ✅ architecture.md (4 providers), iam-policy.md (SM + PS) | +| Decision doc para equipo | ✅ `aws-secret-manager-strategies.docx` (en root del repo) | +| Naming NRN+slug-based | ⏳ Pendiente — ver "1. Refactor de naming" | +| `fetch_configuration` por provider | ⏳ Pendiente — ver "2. Placeholders" | + +--- + +## Decisiones tomadas + +| Decisión | Valor | Origen | +|---|---|---| +| Estrategia de granularidad | 1:1 mapping (un secret por parámetro) | Review del equipo sobre el decision doc | +| Naming convention | NRN entities con slugs+ids + dimensiones + parameter_id | Conversación de diseño | +| Provider AWS Secrets Manager | Nombre futuro: `aws_secret_manager` (rename pendiente de `secret_manager`) | Conversación de diseño | +| Selector resolution | Env-only (`SECRET_PROVIDER`, `PARAMETER_PROVIDER`) | Limitación del provider-categories de nullplatform | +| Workflow YAMLs | 4 workflows unificados (store, retrieve, delete, notify), sin discriminación por kind | Cleanup arquitectónico | +| Discriminación secret/param | En `build_context` desde `$CONTEXT.secret`, no en entrypoint | Mismo cleanup | +| Logging | Todos los niveles routean a stderr (stdout reservado para JSON) | Bug encontrado durante tests | +| Delete failure semantics | "not found" → success idempotente, todo lo demás → exit 1 con troubleshooting | Feedback de revisión | +| Retrieve failure semantics | Idem delete: "not found" → `{value: "value not found"}`, otros errores → exit 1 | Idem | + +--- + +## Pendiente + +### 1. Refactor de naming a NRN+slugs+ids + +**Bloqueado por:** falta confirmar la syntax exacta del `np` CLI para obtener slugs de entities por ID. + +**Hipótesis (a confirmar antes de implementar):** + +```bash +np organization get --id 1255165411 --query slug --output text +``` + +#### Diseño aprobado + +El `external_id` retornado a nullplatform (y por tanto el nombre del secret en cada provider) se compone así: + +``` +=-/=-/.../=/ +``` + +- Entities iteradas en orden NRN canónico: `organization → account → namespace → application → scope`. Solo se incluyen las presentes. +- Dimensiones ordenadas alfabéticamente por key para garantizar determinismo. +- `parameter_id` al final como identificador único. +- Slugs son inmutables en nullplatform (garantía del contrato), por lo que el external_id no sufre deriva en el tiempo. + +#### Ejemplo + +Con `entities = {organization: "1255165411", account: "95118862", namespace: "37094320", application: "321402625"}`, `dimensions = {env: "prod"}`, `parameter_id = 42`: + +``` +organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/env=prod/42 +``` + +#### Pasos + +1. Crear `parameters/utils/build_external_id` con fetch paralelo de slugs vía `np` CLI (usando `mktemp` + `&` + `wait`). +2. Refactorizar `store` de los 4 providers: + - `hashicorp_vault/store`: nombre `secret/data/parameters/` + - `secret_manager/store`: nombre `` + - `parameter_store/store`: nombre `` + - `azure_key_vault/store`: nombre ``. AKV solo permite alfanumérico + `-`, así que transformamos `/` → `-` y removemos `=`. +3. `retrieve`/`delete`/`notify` NO cambian: usan el `EXTERNAL_ID` que llega de nullplatform. +4. Tests: mock de `np` CLI en `$BATS_TEST_TMPDIR/bin/`, expected paths actualizados en cada provider. +5. Update de `parameters/providers//docs/architecture.md` con el nuevo naming. + +#### Edge cases (todos confirmados) + +- Entities siempre vienen (parte del contrato de nullplatform) — no hay caso de "entities vacío". +- `np` CLI siempre está disponible (instalado en la imagen Docker base del agente). +- Slugs inmutables — no hay riesgo de deriva o reconstrucción incorrecta. + +--- + +### 2. Placeholders `fetch_configuration` por provider + +Cada provider necesita un placeholder `fetch_configuration` (opcional según el contrato pero útil) que populate `PROVIDER_CONFIG` con su config específica desde donde corresponda (np CLI, REST, file, etc.). + +Hoy todos los providers funcionan vía env vars (`VAULT_ADDR`, `AWS_REGION`, etc.). El placeholder permite wirear el fetch real cuando el platform team defina el mecanismo. + +Estructura sugerida: + +```bash +#!/bin/bash +# parameters/providers//fetch_configuration +# +# TODO(platform-team): wire la lógica de fetch real (np CLI, REST, file montado, etc.) +# Mientras tanto, PROVIDER_CONFIG default a '{}' y todo cae a env vars. + +: "${PROVIDER_CONFIG:=}" +if [ -z "$PROVIDER_CONFIG" ]; then + PROVIDER_CONFIG='{}' +fi +export PROVIDER_CONFIG +``` + +A duplicar en los 4 providers. Build_context ya sourcea `$PROVIDER_DIR/fetch_configuration` si existe. + +--- + +### 3. Rename `secret_manager` → `aws_secret_manager` (opcional) + +Decisión tomada en conversación pero no aplicada todavía. No bloqueante para nada. Cuando se haga: + +- Mover `parameters/providers/secret_manager/` → `parameters/providers/aws_secret_manager/` +- Update referencias en docs (architecture.md, configuration.md, adding_a_provider.md, iam-policy.md) +- Update tests en `parameters/tests/providers/secret_manager/` (mover y renombrar) +- Actualizar valores aceptables de `SECRET_PROVIDER` / `PARAMETER_PROVIDER` en docs + +--- + +## Contrato del payload — para referencia rápida + +Notification de nullplatform tiene estos campos en `$CONTEXT` (después de que el entrypoint extrae `.notification`): + +| Campo | Tipo | Acciones | Notas | +|---|---|---|---| +| `parameter_id` | number | store, notify | nullplatform parameter ID | +| `value` | string | store | el valor a persistir | +| `external_id` | string | retrieve, delete, notify | handle generado en store (NRN+slugs+ids+dims+id) | +| `secret` | bool | todas | discriminador secret/parameter (sigue derivando PARAMETER_KIND pero no afecta routing en 1:1) | +| `parameter_name` | string | todas | display name del parámetro | +| `encoding` | string | todas | `plain`, `base64`, etc. | +| `entities` | object | todas | IDs only — slugs se fetchean por separado vía np CLI | +| `value_entities` | object | retrieve (opcional) | Mismo formato que entities, solo presente si el value tiene NRN distinto al parámetro | +| `dimensions` | object | opcional | key-value pairs (env, country, etc.) — ordenarse alfabéticamente | + +Las entities siempre vienen como IDs strings: + +```json +{ + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" +} +``` + +--- + +## Cómo correr los tests + +```bash +bats $(find parameters/tests -name "*.bats") +``` + +Distribución actual (150 tests): + +- Skeleton (entrypoint, build_context, dispatch, utils): 55 tests +- hashicorp_vault: 27 tests +- secret_manager: 17 tests +- parameter_store: 23 tests +- azure_key_vault: 15 tests +- utils/log + utils/get_config_value: 13 tests + +--- + +## Estructura del paquete + +``` +parameters/ +├── PENDING.md # este archivo +├── entrypoint, build_context # router + provider resolution +├── store, retrieve, delete, notify # dispatch one-liners +├── workflows/ # 4 YAMLs (acción-only, kind se deriva) +├── utils/ +│ ├── get_config_value # priority: provider config > env > default +│ └── log # todos los niveles a stderr +├── providers/ +│ ├── README.md # contrato del provider +│ ├── hashicorp_vault/ +│ ├── secret_manager/ +│ ├── parameter_store/ +│ └── azure_key_vault/ +├── tests/ # 150 BATS tests +└── docs/ # docs globales del paquete +``` diff --git a/parameters/build_context b/parameters/build_context new file mode 100755 index 00000000..72ce1a8e --- /dev/null +++ b/parameters/build_context @@ -0,0 +1,100 @@ +#!/bin/bash +set -euo pipefail + +# Resolves which provider implementation handles this workflow run. +# +# Inputs: +# CONTEXT — JSON of the notification body (set by entrypoint) +# PARAMETER_KIND — "secret" | "parameter" (set by workflow `configuration` block; +# falls back to deriving from $CONTEXT.secret if absent — e.g. notify) +# SECRET_PROVIDER — env var: name of provider to use when kind=secret +# PARAMETER_PROVIDER — env var: name of provider to use when kind=parameter +# +# Per-provider config fetching is delegated to providers//fetch_configuration +# (optional). Each provider owns its own fetching mechanism — no global config +# fetcher in this layer. +# +# Outputs (exported for subsequent workflow steps): +# PARAMETER_KIND, ACTIVE_PROVIDER, PROVIDER_DIR, PARAMETERS_ROOT +# EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE, PARAMETER_NAME, PARAMETER_ENCODING +# Plus any vars the provider's fetch_configuration and setup scripts export. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PARAMETERS_ROOT="$SCRIPT_DIR" + +source "$SCRIPT_DIR/utils/log" +source "$SCRIPT_DIR/utils/get_config_value" + +export EXTERNAL_ID=$(echo "$CONTEXT" | jq -r '.external_id // empty') +export PARAMETER_ID=$(echo "$CONTEXT" | jq -r '.parameter_id // empty') +export PARAMETER_VALUE=$(echo "$CONTEXT" | jq -r '.value // empty') +export PARAMETER_NAME=$(echo "$CONTEXT" | jq -r '.parameter_name // empty') +export PARAMETER_ENCODING=$(echo "$CONTEXT" | jq -r '.encoding // empty') + +if [ -z "${PARAMETER_KIND:-}" ]; then + # `// empty` would swallow `false` (jq's // treats false as missing), so use tostring. + case "$(echo "$CONTEXT" | jq -r '.secret | tostring')" in + true) PARAMETER_KIND="secret" ;; + false) PARAMETER_KIND="parameter" ;; + *) PARAMETER_KIND="" ;; + esac +fi +export PARAMETER_KIND + +# Selector resolution is env-only at this layer. +# If the platform wants to derive selectors from provider config, it must +# populate these env vars BEFORE invoking the entrypoint. +case "$PARAMETER_KIND" in + secret) + ACTIVE_PROVIDER="${SECRET_PROVIDER:-}" + selector_env="SECRET_PROVIDER" + ;; + parameter) + ACTIVE_PROVIDER="${PARAMETER_PROVIDER:-}" + selector_env="PARAMETER_PROVIDER" + ;; + *) + ACTIVE_PROVIDER="${SECRET_PROVIDER:-${PARAMETER_PROVIDER:-}}" + selector_env="SECRET_PROVIDER or PARAMETER_PROVIDER" + ;; +esac + +if [ -z "$ACTIVE_PROVIDER" ]; then + log error "❌ No provider configured for kind '$PARAMETER_KIND'" + log error "" + log error "💡 Possible causes:" + log error " • $selector_env env var is not set in the workflow runtime" + log error "" + log error "🔧 How to fix:" + log error " • Set $selector_env= in the agent/runner environment" + log error " • Available providers: $(ls "$SCRIPT_DIR/providers" 2>/dev/null | grep -v '^README' | tr '\n' ' ' || true)" + exit 1 +fi + +PROVIDER_DIR="$SCRIPT_DIR/providers/$ACTIVE_PROVIDER" +if [ ! -d "$PROVIDER_DIR" ]; then + available=$(ls "$SCRIPT_DIR/providers" 2>/dev/null | grep -v '^README' | tr '\n' ' ' || true) + log error "❌ Provider implementation not found: '$ACTIVE_PROVIDER'" + log error "" + log error "🔧 How to fix:" + log error " • Available providers: ${available:-(none installed)}" + log error " • Set $selector_env to one of the above, or add a provider at parameters/providers/$ACTIVE_PROVIDER/" + exit 1 +fi +export ACTIVE_PROVIDER +export PROVIDER_DIR + +log debug "📦 active_provider=$ACTIVE_PROVIDER kind=$PARAMETER_KIND" + +# Each provider owns its config fetching (np CLI, REST call, file, env vars, etc.) +# Optional: if absent, the provider relies on whatever's already in the environment. +if [ -f "$PROVIDER_DIR/fetch_configuration" ]; then + log debug "📡 Sourcing $ACTIVE_PROVIDER/fetch_configuration" + source "$PROVIDER_DIR/fetch_configuration" +fi + +# Validation + connection handles. Operations downstream assume invariants hold. +if [ -f "$PROVIDER_DIR/setup" ]; then + log debug "📡 Sourcing $ACTIVE_PROVIDER/setup" + source "$PROVIDER_DIR/setup" +fi diff --git a/parameters/delete b/parameters/delete new file mode 100755 index 00000000..ee412263 --- /dev/null +++ b/parameters/delete @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +# Dispatch: delegate to the active provider's delete implementation. +# PROVIDER_DIR is exported by build_context. +source "$PROVIDER_DIR/delete" diff --git a/parameters/docs/adding_a_provider.md b/parameters/docs/adding_a_provider.md new file mode 100644 index 00000000..108be4cd --- /dev/null +++ b/parameters/docs/adding_a_provider.md @@ -0,0 +1,223 @@ +# Adding a New Provider + +Step-by-step guide to add a new backend (e.g. Google Secret Manager, Doppler, 1Password Secrets Automation). + +The parameters package is designed so that adding a provider is **strictly additive**. You drop a directory under `providers/`; nothing outside it changes. + +--- + +## What you need to know about the backend + +Before you start, answer these questions: + +| Question | Why it matters | +|---------------------------------------------------------|-------------------------------------------------| +| What CLI / API do you call? | Determines your tooling (curl, aws, az, gcloud) | +| How does authentication work? | Defines what `setup` validates | +| What's the naming convention for stored items? | Defines your prefix + UUID scheme | +| Does it have soft-delete? | Determines if `delete` needs a purge step | +| Does it distinguish secret vs plain types at the API? | Determines if `store` branches on PARAMETER_KIND | + +--- + +## Step 1: Create the provider directory + +```bash +mkdir -p parameters/providers//docs +mkdir -p parameters/tests/providers/ +``` + +`` is `snake_case` and is what users will set in `SECRET_PROVIDER` / `PARAMETER_PROVIDER`. + +--- + +## Step 2: Write `setup` + +Validate config and export connection handles. Don't repeat this in operation scripts — `setup` is the DRY anchor. + +```bash +#!/bin/bash +set -euo pipefail + +# Read config (provider config wins, env fallback, defaults last) +MY_ENDPOINT=$(get_config_value --env MY_ENDPOINT --provider '.endpoint') +MY_TOKEN=$(get_config_value --env MY_TOKEN --provider '.token') +MY_PREFIX=$(get_config_value --env MY_PREFIX --provider '.prefix' --default 'parameters-') + +if [ -z "$MY_ENDPOINT" ]; then + log error "❌ endpoint not configured" + log error "" + log error "💡 Possible causes:" + log error " • MY_ENDPOINT env var is not set" + log error " • .endpoint is missing in PROVIDER_CONFIG" + log error "" + log error "🔧 How to fix:" + log error " • Set MY_ENDPOINT=" + exit 1 +fi + +# Validate format / shape if relevant +# ... + +export MY_ENDPOINT MY_TOKEN MY_PREFIX +``` + +--- + +## Step 3: Write the four operation scripts + +### `store` + +Generate a UUID, persist the value, return `{external_id, metadata}`. + +```bash +#!/bin/bash +set -euo pipefail + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +NAME="${MY_PREFIX}${EXTERNAL_ID}" + +if ! HANDLE=$(my_cli create --endpoint "$MY_ENDPOINT" --name "$NAME" --value "$PARAMETER_VALUE" 2>/dev/null); then + log error "❌ Failed to store in " + log error "" + log error "💡 Possible causes:" + log error " • " + log error "" + log error "🔧 How to fix:" + log error " • " + exit 1 +fi + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg handle "$HANDLE" \ + --arg name "$NAME" \ + '{external_id: $external_id, metadata: {handle: $handle, name: $name}}' +``` + +If your backend distinguishes types (like `parameter_store` does with String/SecureString), branch on `PARAMETER_KIND` here. + +### `retrieve` + +Read the value, return `{value}` or `{value: "value not found"}` on miss. + +```bash +#!/bin/bash +set -euo pipefail + +NAME="${MY_PREFIX}${EXTERNAL_ID}" + +if VALUE=$(my_cli get --endpoint "$MY_ENDPOINT" --name "$NAME" 2>/dev/null); then + jq -n --arg value "$VALUE" '{value: $value}' +else + echo '{ + "value": "value not found" + }' +fi +``` + +### `delete` + +Always returns `{success: true}`. Suppress errors with `|| true`. + +```bash +#!/bin/bash +set -euo pipefail + +NAME="${MY_PREFIX}${EXTERNAL_ID}" + +my_cli delete --endpoint "$MY_ENDPOINT" --name "$NAME" >/dev/null 2>&1 || true + +echo '{ + "success": true +}' +``` + +### `notify` (optional) + +Skip the file unless your backend needs a per-notify side effect. The dispatch returns the default `{success: true}` if `notify` doesn't exist. + +--- + +## Step 4: Write `fetch_configuration` (optional) + +If the platform stores your provider's config somewhere fetchable, add a `fetch_configuration` script that exports `PROVIDER_CONFIG` as a JSON string with the shape your `setup` expects. + +```bash +#!/bin/bash +# providers//fetch_configuration +PROVIDER_CONFIG=$(np provider get --type --output json) +export PROVIDER_CONFIG +``` + +If you skip this file, `PROVIDER_CONFIG` stays unset and `setup` reads everything from env vars. + +--- + +## Step 5: Write tests + +Mirror the source structure under `parameters/tests/providers//`: + +``` +tests/providers// +├── setup.bats # Config resolution, validation, error paths +├── store.bats # JSON output shape, CLI args, error paths +├── retrieve.bats # Hit case, miss case, CLI args +└── delete.bats # Always-success, CLI args, idempotency +``` + +Use the patterns from existing providers (`hashicorp_vault`, `secret_manager`, `parameter_store`, `azure_key_vault`): + +- Mock the backend CLI as a script in `$BATS_TEST_TMPDIR/bin/`, export PATH to find it. +- Capture CLI args to a log file, assert on them. +- Mock `uuidgen` for deterministic `external_id` in store tests. +- Use the `DEPS="source $PARAMETERS_DIR/utils/log"` pattern to make `log` available in `bash -c` subshells. + +Aim for at least these scenarios per provider: + +| Script | Required tests | +|-----------|-----------------------------------------------------------------------------| +| setup | Missing required config fails with troubleshooting; PROVIDER_CONFIG wins over env; defaults applied | +| store | Output JSON shape; CLI called with correct args; failure path returns non-zero with troubleshooting | +| retrieve | Hit returns value; miss returns "value not found" | +| delete | Returns `{success: true}`; idempotent on CLI failure | + +--- + +## Step 6: Write the docs + +Add at least `parameters/providers//docs/architecture.md` describing: + +- Storage layout (naming, prefix, encryption model) +- Cost model +- Authentication +- Any quirks (soft-delete, regions, multi-tenant constraints) + +If the backend needs IAM-style permissions (AWS, GCP), add `iam-policy.md` with a least-privilege example using placeholders for accounts/regions/keys. + +--- + +## Step 7: Wire it up + +1. Set the env var: `SECRET_PROVIDER=` and/or `PARAMETER_PROVIDER=`. +2. If using `fetch_configuration`, the platform team needs to ensure the fetch mechanism (np CLI, REST endpoint, etc.) returns the JSON shape your provider expects. + +Done. The new provider is reachable from every workflow without any other change. + +--- + +## Checklist + +Before considering a new provider complete: + +- [ ] `setup` validates config and exits with troubleshooting on missing fields +- [ ] `store` outputs `{external_id, metadata}` JSON +- [ ] `retrieve` outputs `{value}` (or `{value: "value not found"}`) +- [ ] `delete` outputs `{success: true}` (always — idempotent) +- [ ] Scripts use `set -euo pipefail` +- [ ] Errors go to stderr via `log error "..."` +- [ ] No stdout output other than the final JSON +- [ ] Every error has `💡 Possible causes:` and `🔧 How to fix:` blocks +- [ ] BATS tests cover setup error paths, store output shape, retrieve hit/miss, delete idempotency +- [ ] `architecture.md` documents storage layout and cost +- [ ] If the backend has IAM, `iam-policy.md` shows least-privilege scoping diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md new file mode 100644 index 00000000..3c18441e --- /dev/null +++ b/parameters/docs/architecture.md @@ -0,0 +1,115 @@ +# Parameters Package — Architecture + +A pluggable parameter and secret storage layer for nullplatform scopes. Choose any backend per-kind (one provider for plain parameters, another for secrets) without touching code outside provider directories. + +--- + +## What problem this solves + +nullplatform scopes need to persist parameter values somewhere. Different organizations want different backends: + +- AWS-native shops: AWS Secrets Manager and/or Parameter Store +- Azure-native shops: Azure Key Vault +- Existing HashiCorp infrastructure: Vault +- Hybrid: secrets in one backend, plain parameters in another + +A monolithic scope tied to one backend forces fork-and-modify for every variation. This package inverts the relationship: the **dispatch layer is the package**, the **backends are pluggable modules** dropped into `providers/`. + +--- + +## Layered design + +``` +┌────────────────────────────────────────────────────────────────┐ +│ nullplatform sends action notification │ +│ (NOTIFICATION_ACTION="parameter:", NP_ACTION_CONTEXT) │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ parameters/entrypoint │ +│ - Clean NP_ACTION_CONTEXT, export CONTEXT (= .notification) │ +│ - Pick workflow: workflows/.yaml │ +│ - Honor OVERRIDES_PATH for consumer-side workflow overrides │ +│ - No kind discrimination here — that's pushed to build_context │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ workflows/.yaml │ +│ - Step 1: build_context │ +│ - Step 2: (store / retrieve / delete / notify) │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ parameters/build_context │ +│ - Parse CONTEXT → EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE │ +│ - Derive PARAMETER_KIND from $CONTEXT.secret (true/false) │ +│ - Resolve ACTIVE_PROVIDER from SECRET_PROVIDER or PARAMETER_ │ +│ PROVIDER env var (per PARAMETER_KIND) │ +│ - Source providers/$ACTIVE_PROVIDER/fetch_configuration │ +│ - Source providers/$ACTIVE_PROVIDER/setup │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ parameters/ (dispatch) │ +│ - One-liner: source providers/$ACTIVE_PROVIDER/ │ +└────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ providers// │ +│ - Executes the actual backend call (curl, aws, az, ...) │ +│ - Writes JSON result to stdout │ +└────────────────────────────────────────────────────────────────┘ +``` + +The dispatch layer is **provider-agnostic**. It has zero knowledge of any specific provider's existence. Adding a new provider is strictly additive — no edits to `entrypoint`, `build_context`, `workflows/`, or other providers. + +--- + +## Why two env vars instead of one + +`SECRET_PROVIDER` and `PARAMETER_PROVIDER` are separate because the most common production setup uses different backends for each kind: + +- Plain parameters in Parameter Store (free Standard tier) +- Secrets in Secrets Manager (per-secret cost, but rotation + replication) + +Setting `PARAMETER_PROVIDER=parameter_store` and `SECRET_PROVIDER=secret_manager` is one configuration line that captures this. The dispatcher resolves the right provider per request based on `$CONTEXT.secret`. + +If you want a single provider for both kinds, set both env vars to the same value: + +```bash +SECRET_PROVIDER=hashicorp_vault +PARAMETER_PROVIDER=hashicorp_vault +``` + +--- + +## File tree + +``` +parameters/ +├── entrypoint # Action router (kind discrimination + workflow selection) +├── build_context # Provider resolution + sourcing of provider's setup +├── store, retrieve, # Dispatch one-liners +│ delete, notify +├── workflows/ # 4 unified (store/retrieve/delete/notify) +├── utils/ +│ ├── get_config_value # Priority: provider config > env > default +│ └── log # debug/info/warn/error with stderr routing +├── providers/ +│ ├── README.md # Contract every provider must satisfy +│ ├── hashicorp_vault/ # HTTP API +│ ├── secret_manager/ # aws CLI +│ ├── parameter_store/ # aws CLI (the only kind-branching provider) +│ └── azure_key_vault/ # az CLI +├── tests/ # BATS — mirrors source structure +└── docs/ # This file, configuration.md, adding_a_provider.md +``` + +See `parameters/providers/README.md` for the provider contract spec. +See `configuration.md` for how `PROVIDER_CONFIG` is structured and how selectors are resolved. +See `adding_a_provider.md` to drop in a new backend. diff --git a/parameters/docs/configuration.md b/parameters/docs/configuration.md new file mode 100644 index 00000000..ca0b157b --- /dev/null +++ b/parameters/docs/configuration.md @@ -0,0 +1,135 @@ +# Configuration + +How the parameters package resolves which provider to use and where each provider gets its config. + +--- + +## Two layers of configuration + +### 1. Provider selection (which backend handles this request) + +Two env variables: + +| Env var | Purpose | +|----------------------|--------------------------------------------------| +| `SECRET_PROVIDER` | Which provider handles `kind=secret` requests | +| `PARAMETER_PROVIDER` | Which provider handles `kind=parameter` requests | + +Values are the directory names under `providers/` (e.g. `secret_manager`, `parameter_store`, `hashicorp_vault`, `azure_key_vault`). + +**Resolution:** env-only. There is no provider-config fallback for selectors at this layer — that would create a chicken-and-egg problem (build_context needs to know which provider to fetch config from, but the config tells it which provider to use). If you want the platform to drive selectors, populate these env vars in the agent/runner environment before invoking the entrypoint. + +### 2. Provider-specific configuration (settings for the chosen backend) + +Each provider's `setup` script reads its own config from a combination of env vars and `PROVIDER_CONFIG` (a JSON string scoped to that one provider). + +**Resolution priority** (highest to lowest): + +1. `PROVIDER_CONFIG` (via `get_config_value --provider '.field'`) +2. Environment variable (via `get_config_value --env NAME`) +3. Default (via `get_config_value --default 'value'`) + +`PROVIDER_CONFIG` is populated by the active provider's `fetch_configuration` script (optional). If `fetch_configuration` doesn't exist or doesn't set `PROVIDER_CONFIG`, the provider falls back entirely to env vars. + +--- + +## The four strategies + +| Strategy | `PARAMETER_PROVIDER` | `SECRET_PROVIDER` | +|----------------------------------|----------------------|------------------------| +| Full Secrets Manager | `secret_manager` | `secret_manager` | +| Full Parameter Store (cheapest) | `parameter_store` | `parameter_store` | +| Mixed AWS (recommended for AWS) | `parameter_store` | `secret_manager` | +| Full HashiCorp Vault | `hashicorp_vault` | `hashicorp_vault` | +| Full Azure Key Vault | `azure_key_vault` | `azure_key_vault` | +| Hybrid Azure secrets, AWS params | `parameter_store` | `azure_key_vault` | + +Switching strategies = changing two env vars. Zero code changes. + +--- + +## Per-provider config shapes + +The shape of `PROVIDER_CONFIG` for each provider: + +### `hashicorp_vault` + +```json +{ + "address": "https://vault.example.com", + "token": "hvs.xxx", + "path_prefix": "secret/data/parameters" +} +``` + +Equivalent env vars: `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX`. + +### `secret_manager` + +```json +{ + "region": "us-east-1", + "name_prefix": "parameters/", + "kms_key_id": "alias/aws/secretsmanager" +} +``` + +Equivalent env vars: `AWS_REGION` (or `AWS_DEFAULT_REGION`), `SM_NAME_PREFIX`, `SM_KMS_KEY_ID`. `kms_key_id` is optional (defaults to AWS-managed key). + +### `parameter_store` + +```json +{ + "region": "us-east-1", + "name_prefix": "/nullplatform/parameters/", + "kms_key_id": "alias/parameters-secure", + "tier": "Standard" +} +``` + +Equivalent env vars: `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. `kms_key_id` only matters for `kind=secret` (SecureString). `tier` ∈ {`Standard`, `Advanced`, `Intelligent-Tiering`}. + +### `azure_key_vault` + +```json +{ + "vault_name": "my-keyvault", + "secret_prefix": "parameters-" +} +``` + +Equivalent env vars: `AZURE_KEY_VAULT_NAME`, `AZURE_KEY_VAULT_SECRET_PREFIX`. Auth comes from the Azure CLI's default credential chain. + +--- + +## How `PROVIDER_CONFIG` gets populated + +Each provider may have a `fetch_configuration` script. When `build_context` activates that provider, it sources `providers//fetch_configuration` before `setup`. The script's job: + +1. Fetch the provider's config from wherever it lives. +2. Export `PROVIDER_CONFIG` as a JSON string. + +Where the config "lives" is up to each provider: + +- **`np provider get`** — call the nullplatform CLI to read providers config. +- **REST call** — query an internal config service. +- **File** — read a mounted config file. +- **Env vars only** — skip `fetch_configuration` entirely; rely on env. + +The provider package doesn't care which mechanism you choose. If you want a uniform mechanism across providers, you can implement them all the same way; if you want each to source config differently (e.g. Vault config from Consul, AWS config from instance profile), nothing forces them to align. + +--- + +## Local development + +For local testing without wiring `fetch_configuration`, set everything via env vars: + +```bash +export SECRET_PROVIDER=hashicorp_vault +export PARAMETER_PROVIDER=hashicorp_vault +export VAULT_ADDR=http://localhost:8200 +export VAULT_TOKEN=root-token +# ...then invoke the entrypoint +``` + +All providers fall through to env vars when `PROVIDER_CONFIG` is unset or empty. diff --git a/parameters/entrypoint b/parameters/entrypoint new file mode 100755 index 00000000..3e6c78fa --- /dev/null +++ b/parameters/entrypoint @@ -0,0 +1,50 @@ +#!/bin/bash +set -euo pipefail + +# Entry point for the parameters package. +# - Cleans NP_ACTION_CONTEXT (strips surrounding single quotes some runners add) +# - Exports CONTEXT scoped to the .notification body (what every script reads) +# - Resolves the workflow file from the action name (parameter:) +# - Honors OVERRIDES_PATH for consumer-side workflow overrides +# +# Kind discrimination (secret vs parameter) is NOT done here. It's derived at +# the script layer: build_context reads $CONTEXT.secret and exports +# PARAMETER_KIND; providers that branch on it (e.g. parameter_store choosing +# String vs SecureString) read PARAMETER_KIND directly. + +if [ -z "${NP_ACTION_CONTEXT:-}" ]; then + echo "❌ NP_ACTION_CONTEXT is not set" >&2 + exit 1 +fi + +CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//") +export NP_ACTION_CONTEXT="$CLEAN_CONTEXT" +export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification') + +IFS=':' read -ra ACTION_PARTS <<< "${NOTIFICATION_ACTION:-}" +ACTION_TO_EXECUTE="${ACTION_PARTS[1]:-}" + +if [ -z "$ACTION_TO_EXECUTE" ]; then + echo "❌ NOTIFICATION_ACTION is missing the action part (expected 'parameter:')" >&2 + exit 1 +fi + +WORKFLOW_PATH="$SERVICE_PATH/parameters/workflows/$ACTION_TO_EXECUTE.yaml" +if [ ! -f "$WORKFLOW_PATH" ]; then + echo "❌ No workflow found at $WORKFLOW_PATH" >&2 + exit 1 +fi + +CMD="np service workflow exec --no-output --workflow $WORKFLOW_PATH" + +if [ -n "${OVERRIDES_PATH:-}" ]; then + IFS=',' read -ra OVERRIDE_PATHS <<< "$OVERRIDES_PATH" + for path in "${OVERRIDE_PATHS[@]}"; do + path=$(echo "$path" | xargs) + [ -z "$path" ] && continue + override_yaml="$path/parameters/workflows/$ACTION_TO_EXECUTE.yaml" + [ -f "$override_yaml" ] && CMD="$CMD --overrides $override_yaml" + done +fi + +eval $CMD diff --git a/parameters/notify b/parameters/notify new file mode 100755 index 00000000..7ddb095e --- /dev/null +++ b/parameters/notify @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +# Dispatch: delegate to the active provider's notify implementation if defined, +# otherwise return the default success ack. Notify is optional per provider. +if [ -f "$PROVIDER_DIR/notify" ]; then + source "$PROVIDER_DIR/notify" +else + echo '{"success":true}' +fi diff --git a/parameters/providers/README.md b/parameters/providers/README.md new file mode 100644 index 00000000..156c0c97 --- /dev/null +++ b/parameters/providers/README.md @@ -0,0 +1,184 @@ +# Provider Contract + +This directory contains all concrete provider implementations. Each subdirectory is one provider — fully self-contained. + +The dispatch layer (`parameters/build_context` + `parameters/{store,retrieve,delete,notify}`) is provider-agnostic. It selects a provider at runtime from env vars `SECRET_PROVIDER` / `PARAMETER_PROVIDER` and sources the matching scripts. + +**Adding a new provider is a strictly additive change**: drop a directory here that satisfies this contract. No edits to dispatch, build_context, workflows, or other providers are required. The parameters package has zero knowledge of any specific provider. + +--- + +## Required layout + +``` +providers// +├── fetch_configuration # (optional) Fetch this provider's config from wherever it lives +├── setup # (optional) Validate config, prepare connection handles +├── store # (required) Persist a parameter value +├── retrieve # (required) Read a value by external_id +├── delete # (required) Idempotent delete by external_id +├── notify # (optional) Per-provider notify hook (default is {"success":true}) +└── docs/ # (recommended) architecture.md, iam-policy.md, etc. +``` + +`` is the string users set in `SECRET_PROVIDER` / `PARAMETER_PROVIDER`. Use `snake_case` (e.g. `hashicorp_vault`, `azure_key_vault`, `parameter_store`). + +--- + +## Lifecycle of one workflow run + +1. `entrypoint` cleans `NP_ACTION_CONTEXT`, exports `CONTEXT` (= notification body), routes to the right workflow YAML. +2. Workflow's `build_context` step: + - Determines `PARAMETER_KIND` from workflow `configuration` or `$CONTEXT.secret`. + - Resolves `ACTIVE_PROVIDER` from `SECRET_PROVIDER` or `PARAMETER_PROVIDER` env var. + - Sources `providers/$ACTIVE_PROVIDER/fetch_configuration` if present. + - Sources `providers/$ACTIVE_PROVIDER/setup` if present. +3. Workflow's operation step (`store`/`retrieve`/`delete`/`notify`) sources `providers/$ACTIVE_PROVIDER/` and produces the JSON response. + +All steps share the same bash session — env vars set in any step are visible to the next. + +--- + +## Environment available to your scripts + +By the time any of your scripts runs, `build_context` has exported: + +| Variable | Description | +|----------------------|-----------------------------------------------------------------| +| `CONTEXT` | JSON of the notification body (`.notification` of the action) | +| `PARAMETER_KIND` | `"secret"` or `"parameter"` | +| `EXTERNAL_ID` | Existing handle for retrieve/delete/notify; empty for store | +| `PARAMETER_ID` | nullplatform parameter ID | +| `PARAMETER_VALUE` | The value to store (only set for store) | +| `PARAMETER_NAME` | Display name (e.g. `DB_PASSWORD`) | +| `PARAMETER_ENCODING` | Encoding of the value (e.g. `plain`, `base64`) | +| `PROVIDER_DIR` | Absolute path to your provider directory | +| `PARAMETERS_ROOT` | Absolute path to the parameters package root | +| `PROVIDER_CONFIG` | (optional) JSON your `fetch_configuration` set — its shape is up to you | + +The function `get_config_value` is already sourced — see usage below. + +--- + +## `fetch_configuration` (optional) + +Your provider's place to bring config in from the outside world. Sourced **once** at the start of every workflow run, before `setup`. + +Free-form by design — each provider knows best how to fetch its own config. Examples: + +- Call `np provider get --type ` and parse JSON +- `curl` a REST endpoint +- Read a file mounted by the runner +- Just rely on env vars (do nothing — omit the file) + +Convention: if you produce a JSON blob with your config, export it as `PROVIDER_CONFIG`. Then `get_config_value --provider '.field'` reads from it directly: + +```bash +#!/bin/bash +# providers/example/fetch_configuration +PROVIDER_CONFIG=$(np provider get --type my-thing --output json) +export PROVIDER_CONFIG +``` + +Then in `setup`: + +```bash +ADDR=$(get_config_value --env MY_ADDR --provider '.address') +``` + +If you don't need provider config, just skip `fetch_configuration` entirely. Operations can use env vars directly: + +```bash +ADDR="${MY_ADDR:-}" +[ -z "$ADDR" ] && { log error "❌ MY_ADDR not set"; exit 1; } +``` + +--- + +## `setup` (optional) + +Sourced after `fetch_configuration`. Use it to: + +1. Read provider-specific config (from env vars and/or `PROVIDER_CONFIG`). +2. Validate that all required fields are present. Fail fast with troubleshooting guidance if not. +3. Export connection handles (URLs, tokens, regions, prefixes) for the operation scripts. + +Do **not** repeat credential validation inside `store`/`retrieve`/`delete`. That's the whole point of `setup`. + +Example: + +```bash +#!/bin/bash +# providers/hashicorp_vault/setup +VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') +VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') + +[ -z "$VAULT_ADDR" ] && { log error "❌ vault address missing"; exit 1; } +[ -z "$VAULT_TOKEN" ] && { log error "❌ vault token missing"; exit 1; } + +export VAULT_ADDR VAULT_TOKEN +``` + +--- + +## Operation scripts + +Each produces **JSON on stdout** and routes **error messages to stderr**. The platform parses stdout as the action result. + +### `store` — required + +Input env: `PARAMETER_VALUE`, `PARAMETER_ID`, `PARAMETER_KIND`, plus your `setup` exports. + +Output: +```json +{ + "external_id": "", + "metadata": { "...": "provider-specific" } +} +``` + +`external_id` becomes the canonical handle. `metadata` is opaque to nullplatform but useful for auditing. + +### `retrieve` — required + +Input env: `EXTERNAL_ID`, plus `setup` exports. + +Output: +```json +{ "value": "" } +``` + +If not found, return `{"value": "value not found"}` rather than erroring (precedent: existing vault/secret_manager impls). + +### `delete` — required + +Input env: `EXTERNAL_ID`, plus `setup` exports. + +Output: +```json +{ "success": true } +``` + +Must be **idempotent**: re-deleting a missing handle is not an error. + +### `notify` — optional + +Input env: `EXTERNAL_ID`, `PARAMETER_ID`, plus `setup` exports. + +Output: +```json +{ "success": true } +``` + +Omit the file if your provider has nothing to do — the dispatch returns the default ack. + +--- + +## Conventions + +- Start every script with `set -euo pipefail`. +- Use `log error "..."` for error messages — it routes to stderr automatically. +- Every error message must include `💡 Possible causes:` and `🔧 How to fix:` blocks. +- Never print anything to stdout other than the final JSON result. The platform reads stdout literally. +- Don't validate `PROVIDER_DIR`, `EXTERNAL_ID`, or other dispatch-exported vars — assume `build_context` produced valid state. Validate only your provider-specific config in `setup`. +- Each operation should be **idempotent where it makes sense** (delete always, retrieve when missing, store typically not — the platform enforces store idempotency at its layer). diff --git a/parameters/providers/azure_key_vault/delete b/parameters/providers/azure_key_vault/delete new file mode 100755 index 00000000..79d8d8f1 --- /dev/null +++ b/parameters/providers/azure_key_vault/delete @@ -0,0 +1,70 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a secret from Azure Key Vault. +# +# AKV uses soft-delete by default (90-day retention). The flow is: +# 1. `delete` → moves to soft-deleted state (the user-facing "deleted" semantic) +# 2. `purge` → hard-deletes from soft-delete bin; releases the name immediately +# +# Idempotency semantics: +# - Successful delete → continue to purge +# - SecretNotFound on delete → success (already gone; idempotent) +# - Any other delete error → exit 1 with troubleshooting +# +# Purge is housekeeping (frees the name, stops retention billing). Failures +# during purge are downgraded to warnings: the user-facing delete contract is +# already satisfied by step 1. The secret stays in the soft-delete window +# (auto-cleaned at retention expiry). +# +# Required env: EXTERNAL_ID, AZ_VAULT_NAME, AZ_SECRET_PREFIX + +SECRET_NAME="${AZ_SECRET_PREFIX}${EXTERNAL_ID}" + +# --- Step 1: soft-delete --- +err_file=$(mktemp) +if az keyvault secret delete \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" >/dev/null 2>"$err_file"; then + rm -f "$err_file" +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -qE "(SecretNotFound|secret with .* was not found)"; then + log debug "Secret '$SECRET_NAME' does not exist, treating delete as success" + echo '{ + "success": true +}' + return 0 2>/dev/null || exit 0 + else + log error "❌ Failed to delete secret '$SECRET_NAME' from Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • Identity lacks 'Delete' permission on vault $AZ_VAULT_NAME" + log error " • Vault firewall blocks the caller" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: az account show" + log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" + log error "Underlying error: $err" + exit 1 + fi +fi + +# --- Step 2: purge (best-effort) --- +purge_err=$(mktemp) +if ! az keyvault secret purge \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" >/dev/null 2>"$purge_err"; then + pe=$(cat "$purge_err") + if echo "$pe" | grep -qiE "(Forbidden|not authorized|purge permission)"; then + log warn "⚠️ Purge permission missing on '$AZ_VAULT_NAME' — secret remains in soft-delete window" + else + log warn "⚠️ Purge failed (secret remains in soft-delete window): $pe" + fi +fi +rm -f "$purge_err" + +echo '{ + "success": true +}' diff --git a/parameters/providers/azure_key_vault/docs/architecture.md b/parameters/providers/azure_key_vault/docs/architecture.md new file mode 100644 index 00000000..0aea22b1 --- /dev/null +++ b/parameters/providers/azure_key_vault/docs/architecture.md @@ -0,0 +1,103 @@ +# Azure Key Vault — Provider Architecture + +This document describes the `parameters/providers/azure_key_vault/` implementation. It stores nullplatform parameters as Azure Key Vault (AKV) secrets. + +--- + +## Lifecycle + +| Step | What happens | +|------|-------------------------------------------------------------------------------| +| `setup` | Reads `AZ_VAULT_NAME`, `AZ_SECRET_PREFIX`. Fails if vault name missing or prefix has invalid chars. | +| `store` | Generates UUID. Calls `az keyvault secret set`. Returns `{external_id, metadata}`. | +| `retrieve` | Calls `az keyvault secret show`. Returns `{value}` or `{value: "value not found"}`. | +| `delete` | Calls `az keyvault secret delete` + `az keyvault secret purge` (both with `\|\| true`). | +| `notify` | Not implemented — dispatcher returns default `{success: true}`. | + +--- + +## Naming convention + +``` + +``` + +- `AZ_SECRET_PREFIX` defaults to `parameters-`. Must match `[A-Za-z0-9-]*` — AKV secret names allow only alphanumerics and dashes (no slashes, no dots, no underscores). +- `external_id` is a UUIDv4 generated at store time. UUIDs already satisfy AKV's character constraints. +- Full secret name example: `parameters-f47ac10b-58cc-4372-a567-0e02b2c3d479` +- Max 127 chars total. With a UUID (36 chars + dashes), you have ~90 chars left for the prefix. + +This naming differs from `secret_manager` and `parameter_store` (which support slashes for hierarchical organization) — AKV is flat-namespace. + +--- + +## PARAMETER_KIND is informational here + +AKV transparently encrypts all secrets using vault-managed keys (or a customer key if the vault is configured with one). The provider does **not** branch on `PARAMETER_KIND`: + +- `kind=secret` → AKV secret (encrypted at rest by AKV) +- `kind=parameter` → AKV secret (encrypted at rest by AKV) + +Both end up identical. If you need to distinguish parameter vs secret semantics at the storage layer, use the `parameter_store` provider instead (it uses SSM Type=String vs SecureString). + +--- + +## Soft-delete behavior + +Azure Key Vault has soft-delete enabled by default with 90-day retention: + +1. `az keyvault secret delete` moves the secret to a soft-deleted state. The name is reserved (cannot recreate with same name) and the secret is recoverable for 90 days. +2. `az keyvault secret purge` hard-deletes from the soft-delete bin, freeing the name immediately. + +The provider's `delete` script does **both** sequentially. Both calls suppress errors (`|| true`), so: + +- If you have the `Purge` permission: hard-deletes immediately, no retention cost. +- If you only have `Delete`: soft-deletes, retention applies. Since we use UUIDs, name reuse is not a concern in practice. +- If the secret already doesn't exist: both calls fail silently, the operation still returns `{success: true}`. + +--- + +## Configuration + +`PROVIDER_CONFIG` shape: + +```json +{ + "vault_name": "my-keyvault", + "secret_prefix": "parameters-" +} +``` + +Equivalent env vars: `AZURE_KEY_VAULT_NAME`, `AZURE_KEY_VAULT_SECRET_PREFIX`. `PROVIDER_CONFIG` wins per `get_config_value` priority. + +Authentication uses the Azure CLI's default credential chain: + +1. Managed Identity (Azure-hosted environments) +2. Service Principal env vars (`AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`) +3. `az login` cached credentials + +The provider does not validate auth in `setup` — the first `az keyvault` call surfaces auth errors. + +--- + +## Required permissions on the vault + +The identity running the provider scripts needs an access policy or RBAC role on the vault: + +| Operation | Access policy permission | RBAC role | +|-----------|--------------------------------|--------------------------------------| +| store | Set | Key Vault Secrets Officer / Contributor | +| retrieve | Get | Key Vault Secrets User | +| delete | Delete, Purge (optional) | Key Vault Secrets Officer + Purge action | + +The `Purge` permission is optional but recommended. Without it, soft-deletes accumulate and you may hit vault soft-delete quotas if you cycle many secrets. + +--- + +## Compatibility with the contract + +| Operation | Output shape | Notes | +|-----------|--------------|-------| +| store | `{external_id, metadata: {azure_secret_id, secret_name, vault_name}}` | `azure_secret_id` is the full AKV resource ID (URL form) | +| retrieve | `{value}` or `{value: "value not found"}` | | +| delete | `{success: true}` | Always; idempotent | diff --git a/parameters/providers/azure_key_vault/retrieve b/parameters/providers/azure_key_vault/retrieve new file mode 100755 index 00000000..faf09730 --- /dev/null +++ b/parameters/providers/azure_key_vault/retrieve @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a secret from Azure Key Vault by external_id. +# +# Semantics: +# - Success → return {value: ""} +# - SecretNotFound → return {value: "value not found"} +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AZ_VAULT_NAME, AZ_SECRET_PREFIX + +SECRET_NAME="${AZ_SECRET_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if VALUE=$(az keyvault secret show \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" \ + --query value \ + --output tsv 2>"$err_file"); then + rm -f "$err_file" + jq -n --arg value "$VALUE" '{value: $value}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -qE "(SecretNotFound|secret with .* was not found)"; then + echo '{ + "value": "value not found" + }' + else + log error "❌ Failed to retrieve secret '$SECRET_NAME' from Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • Identity lacks 'Get' permission on vault $AZ_VAULT_NAME" + log error " • Vault firewall blocks the caller" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: az account show" + log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/azure_key_vault/setup b/parameters/providers/azure_key_vault/setup new file mode 100755 index 00000000..4448ab3e --- /dev/null +++ b/parameters/providers/azure_key_vault/setup @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +# Validates Azure Key Vault connection config. +# +# Reads, in priority order: PROVIDER_CONFIG, environment variables, defaults. +# Exports: AZ_VAULT_NAME, AZ_SECRET_PREFIX +# +# Auth comes from the Azure CLI's default credential chain (managed identity, +# az login, service principal env vars). Not validated here — az will surface +# auth errors on the first call. + +AZ_VAULT_NAME=$(get_config_value \ + --env AZURE_KEY_VAULT_NAME \ + --provider '.vault_name') + +AZ_SECRET_PREFIX=$(get_config_value \ + --env AZURE_KEY_VAULT_SECRET_PREFIX \ + --provider '.secret_prefix' \ + --default 'parameters-') + +if [ -z "$AZ_VAULT_NAME" ]; then + log error "❌ Azure Key Vault name not configured" + log error "" + log error "💡 Possible causes:" + log error " • AZURE_KEY_VAULT_NAME env var is not set" + log error " • .vault_name is missing in the azure_key_vault provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set AZURE_KEY_VAULT_NAME=" + log error " • Or populate PROVIDER_CONFIG.vault_name via providers/azure_key_vault/fetch_configuration" + exit 1 +fi + +# Azure Key Vault secret names are constrained: alphanumeric + dashes, max 127 chars. +# UUIDs satisfy this. Validate the prefix uses only valid chars. +if [[ ! "$AZ_SECRET_PREFIX" =~ ^[A-Za-z0-9-]*$ ]]; then + log error "❌ Invalid AZ_SECRET_PREFIX '$AZ_SECRET_PREFIX'" + log error "" + log error "💡 Possible causes:" + log error " • Azure Key Vault secret names allow only alphanumerics and dashes" + log error "" + log error "🔧 How to fix:" + log error " • Set AZURE_KEY_VAULT_SECRET_PREFIX to a string matching [A-Za-z0-9-]*" + exit 1 +fi + +export AZ_VAULT_NAME AZ_SECRET_PREFIX diff --git a/parameters/providers/azure_key_vault/store b/parameters/providers/azure_key_vault/store new file mode 100755 index 00000000..7c1ef2d1 --- /dev/null +++ b/parameters/providers/azure_key_vault/store @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter value as an Azure Key Vault secret. +# AKV encrypts all secrets transparently — PARAMETER_KIND does not change behavior. +# +# Required env: PARAMETER_VALUE, AZ_VAULT_NAME, AZ_SECRET_PREFIX + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +SECRET_NAME="${AZ_SECRET_PREFIX}${EXTERNAL_ID}" + +if ! AKV_ID=$(az keyvault secret set \ + --vault-name "$AZ_VAULT_NAME" \ + --name "$SECRET_NAME" \ + --value "$PARAMETER_VALUE" \ + --query id \ + --output tsv 2>/dev/null); then + log error "❌ Failed to store secret in Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • Identity lacks Set permission on $AZ_VAULT_NAME" + log error " • Vault is in soft-deleted state or firewall blocks the caller" + log error " • Secret name '$SECRET_NAME' contains characters AKV rejects (only alphanumeric + dashes allowed)" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: az account show" + log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" + exit 1 +fi + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg secret_id "$AKV_ID" \ + --arg secret_name "$SECRET_NAME" \ + --arg vault_name "$AZ_VAULT_NAME" \ + '{external_id: $external_id, metadata: {azure_secret_id: $secret_id, secret_name: $secret_name, vault_name: $vault_name}}' diff --git a/parameters/providers/hashicorp_vault/delete b/parameters/providers/hashicorp_vault/delete new file mode 100755 index 00000000..0d711039 --- /dev/null +++ b/parameters/providers/hashicorp_vault/delete @@ -0,0 +1,53 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a secret from Vault by external_id. +# +# Idempotency semantics: +# - HTTP 2xx → success (deleted) +# - HTTP 404 → success (already gone; treated as idempotent) +# - Any other HTTP status or network error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" + +if ! RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$VAULT_ADDR/v1/$VAULT_PATH" 2>/dev/null); then + log error "❌ Network error calling Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Vault host unreachable (DNS / network / firewall)" + log error " • TLS handshake failure" + log error "" + log error "🔧 How to fix:" + log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" + exit 1 +fi + +HTTP_STATUS="${RESPONSE##*$'\n'}" + +case "$HTTP_STATUS" in + 2*) ;; + 404) + log debug "Secret at $VAULT_PATH does not exist, treating delete as success" + ;; + *) + HTTP_BODY="${RESPONSE%$'\n'*}" + log error "❌ Vault DELETE failed with HTTP $HTTP_STATUS at $VAULT_PATH" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_TOKEN lacks delete permission at this path (403)" + log error " • Server-side error (5xx) — check Vault logs" + log error "" + log error "🔧 How to fix:" + log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" + log error "Vault response: $HTTP_BODY" + exit 1 + ;; +esac + +echo '{ + "success": true +}' diff --git a/parameters/providers/hashicorp_vault/docs/architecture.md b/parameters/providers/hashicorp_vault/docs/architecture.md new file mode 100644 index 00000000..e3074fa2 --- /dev/null +++ b/parameters/providers/hashicorp_vault/docs/architecture.md @@ -0,0 +1,80 @@ +# HashiCorp Vault — Provider Architecture + +This document describes the `parameters/providers/hashicorp_vault/` implementation. It stores nullplatform parameters as Vault KV v2 secrets. + +--- + +## Lifecycle + +| Step | What happens | +|------|-----------------------------------------------------------------------| +| `setup` | Reads `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX` from env or `PROVIDER_CONFIG`. Fails fast if address or token is missing. Exports the three vars. | +| `store` | Generates a UUID `external_id`. POSTs to `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/$external_id` with a JSON payload. Returns `{external_id, metadata.vault_path}`. | +| `retrieve` | GETs from `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/$external_id`. Returns `{value}` or `{value: "value not found"}` on miss. | +| `delete` | DELETEs the secret. Idempotent — re-deleting is a no-op. Returns `{success: true}`. | +| `notify` | Not implemented — dispatcher returns the default `{success: true}` ack. | + +--- + +## Storage layout + +``` +/v1// +``` + +- **`VAULT_PATH_PREFIX`** defaults to `secret/data/parameters`. The `data/` segment is the KV v2 convention — change the default if your mount uses KV v1 (drop the `data/`) or a different mount point. +- **`external_id`** is a UUIDv4 generated at store time. It is the canonical handle nullplatform persists and re-injects for retrieve/delete. + +The stored payload at each path is a JSON envelope, not the raw value: + +```json +{ + "data": { + "parameter_id": 42, + "value": "the-actual-value", + "stored_at": "2026-05-15T12:34:56Z", + "external_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" + } +} +``` + +Keeping `parameter_id` and `external_id` inside the payload makes orphaned secrets self-describing — if someone discovers a stale entry under `parameters/`, the payload tells them which nullplatform parameter it belongs to. + +--- + +## Configuration + +`PROVIDER_CONFIG` shape (populated by `fetch_configuration` if you implement one): + +```json +{ + "address": "https://vault.example.com", + "token": "hvs.xxx", + "path_prefix": "secret/data/parameters" +} +``` + +Equivalent env vars: `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX`. Provider config wins over env per `get_config_value` priority. + +--- + +## Authentication notes + +This provider uses static token auth via `X-Vault-Token`. For production use, consider: + +- **Token rotation**: short-lived tokens (issued by AppRole, Kubernetes auth, etc.) should be refreshed by `fetch_configuration` rather than relying on a long-lived `VAULT_TOKEN` env var. +- **OIDC / Kubernetes auth**: a richer `fetch_configuration` could exchange a workload identity for a Vault token at runtime, removing the need for any pre-issued credential. + +The operation scripts (`store`/`retrieve`/`delete`) don't care how `VAULT_TOKEN` got into the environment — they just use it. Swap the auth mechanism by changing `setup` (and optionally adding `fetch_configuration`). + +--- + +## Compatibility + +The output JSON shape matches the previous `parameters/vault/` implementation byte-for-byte: + +- `store` → `{external_id, metadata: {vault_path}}` +- `retrieve` → `{value}` or `{value: "value not found"}` +- `delete` → `{success: true}` + +A scope that switches from the old layout to this provider sees no behavior change against Vault. diff --git a/parameters/providers/hashicorp_vault/retrieve b/parameters/providers/hashicorp_vault/retrieve new file mode 100755 index 00000000..a7a8f31d --- /dev/null +++ b/parameters/providers/hashicorp_vault/retrieve @@ -0,0 +1,55 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a value from Vault by external_id. +# +# Semantics: +# - HTTP 2xx → return {value: ""} +# - HTTP 404 → return {value: "value not found"} (legitimate miss) +# - Any other HTTP status or network error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" + +if ! RESPONSE=$(curl -s -w "\n%{http_code}" \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$VAULT_ADDR/v1/$VAULT_PATH" 2>/dev/null); then + log error "❌ Network error calling Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Vault host unreachable (DNS / network / firewall)" + log error "" + log error "🔧 How to fix:" + log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" + exit 1 +fi + +HTTP_STATUS="${RESPONSE##*$'\n'}" +HTTP_BODY="${RESPONSE%$'\n'*}" + +case "$HTTP_STATUS" in + 2*) + STORED_VALUE=$(echo "$HTTP_BODY" | jq -r '.data.data.value // empty') + echo '{ + "value": "'$STORED_VALUE'" + }' + ;; + 404) + echo '{ + "value": "value not found" + }' + ;; + *) + log error "❌ Vault GET failed with HTTP $HTTP_STATUS at $VAULT_PATH" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_TOKEN lacks read permission (403)" + log error " • Server-side error (5xx)" + log error "" + log error "🔧 How to fix:" + log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" + log error "Vault response: $HTTP_BODY" + exit 1 + ;; +esac diff --git a/parameters/providers/hashicorp_vault/setup b/parameters/providers/hashicorp_vault/setup new file mode 100755 index 00000000..2a84dcc0 --- /dev/null +++ b/parameters/providers/hashicorp_vault/setup @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +# Validates HashiCorp Vault connection config. +# Sourced once by parameters/build_context before any operation script runs. +# +# Reads, in priority order: PROVIDER_CONFIG (if populated by fetch_configuration), +# environment variables, defaults. +# +# Exports for operation scripts: VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') +VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') +VAULT_PATH_PREFIX=$(get_config_value \ + --env VAULT_PATH_PREFIX \ + --provider '.path_prefix' \ + --default 'secret/data/parameters') + +if [ -z "$VAULT_ADDR" ]; then + log error "❌ Vault address not configured" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_ADDR env var is not set in the workflow runtime" + log error " • .address is missing in the hashicorp_vault provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set VAULT_ADDR=https://your-vault-host" + log error " • Or populate PROVIDER_CONFIG.address via providers/hashicorp_vault/fetch_configuration" + exit 1 +fi + +if [ -z "$VAULT_TOKEN" ]; then + log error "❌ Vault token not configured" + log error "" + log error "💡 Possible causes:" + log error " • VAULT_TOKEN env var is not set in the workflow runtime" + log error " • .token is missing in the hashicorp_vault provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set VAULT_TOKEN=" + log error " • Or populate PROVIDER_CONFIG.token via providers/hashicorp_vault/fetch_configuration" + exit 1 +fi + +export VAULT_ADDR VAULT_TOKEN VAULT_PATH_PREFIX diff --git a/parameters/providers/hashicorp_vault/store b/parameters/providers/hashicorp_vault/store new file mode 100755 index 00000000..20f1d176 --- /dev/null +++ b/parameters/providers/hashicorp_vault/store @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter value in HashiCorp Vault KV v2. +# Generates a fresh UUID as external_id (the canonical handle returned to nullplatform). +# +# Required env (exported by build_context + setup): +# PARAMETER_ID, PARAMETER_VALUE, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" + +if ! curl -s -X POST \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + "$VAULT_ADDR/v1/$VAULT_PATH" \ + -d "{\"data\":{\"parameter_id\":$PARAMETER_ID,\"value\":$(echo "$PARAMETER_VALUE" | jq -R .),\"stored_at\":\"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\",\"external_id\":\"$EXTERNAL_ID\"}}" >/dev/null; then + log error "❌ Failed to store parameter in Vault at $VAULT_ADDR" + log error "" + log error "💡 Possible causes:" + log error " • Network unreachable to $VAULT_ADDR" + log error " • VAULT_TOKEN expired or lacks write permission on $VAULT_PATH" + log error " • KV mount at $VAULT_PATH_PREFIX does not exist" + log error "" + log error "🔧 How to fix:" + log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" + log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" + exit 1 +fi + +echo '{ + "external_id": "'$EXTERNAL_ID'", + "metadata": { + "vault_path": "'$VAULT_PATH'" + } +}' diff --git a/parameters/providers/parameter_store/delete b/parameters/providers/parameter_store/delete new file mode 100755 index 00000000..84c2be3e --- /dev/null +++ b/parameters/providers/parameter_store/delete @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a parameter from AWS Parameter Store. +# +# Idempotency semantics: +# - Successful delete → success +# - ParameterNotFound → success (already gone) +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AWS_REGION, PS_NAME_PREFIX + +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if aws ssm delete-parameter \ + --region "$AWS_REGION" \ + --name "$PARAM_NAME" >/dev/null 2>"$err_file"; then + rm -f "$err_file" + echo '{ + "success": true +}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ParameterNotFound"; then + log debug "Parameter '$PARAM_NAME' does not exist, treating delete as success" + echo '{ + "success": true +}' + else + log error "❌ Failed to delete parameter '$PARAM_NAME' in AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks ssm:DeleteParameter on this resource" + log error " • Region '$AWS_REGION' unreachable or wrong" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/parameter_store/docs/architecture.md b/parameters/providers/parameter_store/docs/architecture.md new file mode 100644 index 00000000..c616700e --- /dev/null +++ b/parameters/providers/parameter_store/docs/architecture.md @@ -0,0 +1,104 @@ +# AWS Systems Manager Parameter Store — Provider Architecture + +This document describes the `parameters/providers/parameter_store/` implementation. It stores nullplatform parameters as AWS SSM Parameter Store entries, using `String` for plain parameters and `SecureString` (KMS-encrypted) for secrets. + +This is the cheapest provider in the package — Standard tier is free up to 10,000 parameters. + +--- + +## Lifecycle + +| Step | What happens | +|------|-----------------------------------------------------------------------------| +| `setup` | Reads `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. Normalizes prefix to start/end with `/`. Fails if region is missing or tier is invalid. | +| `store` | Generates a UUID. Calls `aws ssm put-parameter` with `Type=String` (kind=parameter) or `Type=SecureString` (kind=secret). Returns `{external_id, metadata}`. | +| `retrieve` | Calls `aws ssm get-parameter --with-decryption`. Returns `{value}` or `{value: "value not found"}`. | +| `delete` | Calls `aws ssm delete-parameter`. Idempotent — never errors. | +| `notify` | Not implemented — dispatcher returns default `{success: true}`. | + +--- + +## Type selection via PARAMETER_KIND + +This is the first provider in the package that branches on `PARAMETER_KIND`: + +| Kind | SSM Type | KMS | +|-------------|-----------------|------------------------------------------------------| +| `parameter` | `String` | None (plain text) | +| `secret` | `SecureString` | `PS_KMS_KEY_ID` if set, otherwise `alias/aws/ssm` | + +For `secret_manager`, `hashicorp_vault`, and `azure_key_vault`, the kind is informational — those backends encrypt all values uniformly. Parameter Store is different because it distinguishes the storage type at the API level. + +--- + +## Naming convention + +``` + +``` + +- `PS_NAME_PREFIX` defaults to `/nullplatform/parameters/`. Always starts with `/` (SSM hierarchical naming) and ends with `/` (the script normalizes both). +- `external_id` is a UUIDv4 generated at store time. +- Full parameter name example: `/nullplatform/parameters/f47ac10b-58cc-4372-a567-0e02b2c3d479` + +The hierarchical prefix lets you scope IAM via path-based ARN patterns: +``` +arn:aws:ssm:::parameter/nullplatform/parameters/* +``` + +--- + +## Tiers + +Parameter Store has three tiers, selected via `PS_TIER`: + +| Tier | Free | Value size | Use case | +|-----------------------|-----------------------|-------------|-----------------------------------| +| `Standard` (default) | up to 10,000 params | 4 KB | Most cases | +| `Advanced` | $0.05/param/month | 8 KB | Large values or > 10k params | +| `Intelligent-Tiering` | Auto-promotes | varies | Mixed sizes, optimize for cost | + +Standard is the default and what most consumers should use. Switch to Advanced explicitly when you have a value > 4 KB or you'll cross 10,000 parameters. + +--- + +## Cost model + +``` +Standard: $0.00 / param / month (up to 10,000) + $0.05 / 10,000 API calls +Advanced: $0.05 / param / month + $0.05 / 10,000 API calls +Intelligent-Tiering: varies (sees Advanced rate once promoted) +``` + +For 100 secret parameters across all your apps on Standard tier: **$0/month** (vs ~$40/month with Secrets Manager). The trade-off: Parameter Store has no rotation, no replication, no resource-based policies — features Secrets Manager provides for the extra cost. + +--- + +## Configuration + +`PROVIDER_CONFIG` shape: + +```json +{ + "region": "us-east-1", + "name_prefix": "/nullplatform/parameters/", + "kms_key_id": "alias/parameters-secure", + "tier": "Standard" +} +``` + +Equivalent env vars: `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. `PROVIDER_CONFIG` wins per `get_config_value` priority. + +`kms_key_id` is only used when storing a `SecureString` (kind=secret). For plain parameters it's ignored. + +--- + +## Compatibility with the contract + +| Operation | Output shape | Notes | +|-----------|--------------|-------| +| store | `{external_id, metadata: {parameter_name, region, type, tier}}` | `type` reflects the SSM Type used (String or SecureString) | +| retrieve | `{value}` or `{value: "value not found"}` | --with-decryption is always passed; no-op for String | +| delete | `{success: true}` | Always; idempotent | diff --git a/parameters/providers/parameter_store/docs/iam-policy.md b/parameters/providers/parameter_store/docs/iam-policy.md new file mode 100644 index 00000000..f32978a7 --- /dev/null +++ b/parameters/providers/parameter_store/docs/iam-policy.md @@ -0,0 +1,112 @@ +# IAM Policy — Parameter Store Provider, Least Privilege + +Minimum IAM permissions required to operate the `parameters/providers/parameter_store/` provider, scoped to the configured `PS_NAME_PREFIX`. + +--- + +## Required actions + +| Action | Used by | Why | +|------------------------------|------------|--------------------------------------------------------| +| `ssm:PutParameter` | `store` | Creates the parameter (String or SecureString) | +| `ssm:GetParameter` | `retrieve` | Reads the value back | +| `ssm:DeleteParameter` | `delete` | Removes the parameter | +| `ssm:DescribeParameters` | optional | Useful for diagnostics | + +`PutParameterBatch`, `LabelParameterVersion`, `GetParameterHistory`, `AddTagsToResource` are **not** required and should not be granted unless code grows to use them. + +--- + +## Recommended policy + +Replace placeholders before applying: + +- `` — region where parameters are stored. +- `` — 12-digit AWS account id. +- `` — the configured prefix (e.g. `nullplatform/parameters`). Strip leading and trailing `/` when placing into the ARN. +- `` — required if you store any `SecureString` (kind=secret). For default `alias/aws/ssm` you can omit the KMS statement. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ManageNullplatformParameters", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:GetParameter", + "ssm:DeleteParameter", + "ssm:DescribeParameters" + ], + "Resource": [ + "arn:aws:ssm:::parameter//*" + ] + } + ] +} +``` + +Note the ARN format: `parameter//*` — no extra slash between `parameter` and the prefix because the prefix itself starts with `/`. So if `PS_NAME_PREFIX=/nullplatform/parameters/`, the ARN is `arn:aws:ssm:...:parameter/nullplatform/parameters/*`. + +--- + +## KMS (only when storing SecureString with a CMK) + +If `PS_KMS_KEY_ID` is set to a customer-managed key, both the agent (writer) and any consumer (reader) need KMS permissions. Add this to both policies: + +```json +{ + "Sid": "UseCustomerManagedKmsKeyForParameterStore", + "Effect": "Allow", + "Action": [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey" + ], + "Resource": [ + "arn:aws:kms:::key/" + ], + "Condition": { + "StringEquals": { + "kms:ViaService": "ssm..amazonaws.com" + } + } +} +``` + +The `kms:ViaService` condition restricts the key to SSM use — without it, the role could decrypt arbitrary ciphertexts encrypted with the same key. The CMK's **key policy** must also allow the role principal; IAM permissions alone aren't enough for KMS. + +If you use the default `alias/aws/ssm` (AWS-managed), no extra KMS statement is needed — Parameter Store handles encryption transparently. + +--- + +## Splitting agent vs consumer + +The writer (this provider's scripts) needs put + get + delete. A runtime consumer typically only needs read: + +```json +{ + "Sid": "ReadNullplatformParameters", + "Effect": "Allow", + "Action": [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ], + "Resource": [ + "arn:aws:ssm:::parameter//*" + ] +} +``` + +`GetParametersByPath` is useful if a consumer wants to enumerate all parameters under a hierarchical prefix (e.g. fetching all secrets for an app in one call). + +--- + +## What not to grant + +- `ssm:*` (account-wide) — opens access to OS commands (`AWS-RunShellScript`), maintenance windows, session manager, etc. +- `ssm:PutParameter` with `Resource: "*"` — lets the role write to ANY parameter in the account (including other apps' secrets). +- `ssm:LabelParameterVersion`, `ssm:UnlabelParameterVersion` — versioning workflows; not used by this provider. +- `iam:*` — this provider doesn't manage IAM. diff --git a/parameters/providers/parameter_store/retrieve b/parameters/providers/parameter_store/retrieve new file mode 100755 index 00000000..e00dd44d --- /dev/null +++ b/parameters/providers/parameter_store/retrieve @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a parameter from AWS Parameter Store by external_id. +# Uses --with-decryption (no-op for String, decrypts SecureString). +# +# Semantics: +# - Success → return {value: ""} +# - ParameterNotFound → return {value: "value not found"} +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AWS_REGION, PS_NAME_PREFIX + +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if VALUE=$(aws ssm get-parameter \ + --region "$AWS_REGION" \ + --name "$PARAM_NAME" \ + --with-decryption \ + --query Parameter.Value \ + --output text 2>"$err_file"); then + rm -f "$err_file" + jq -n --arg value "$VALUE" '{value: $value}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ParameterNotFound"; then + echo '{ + "value": "value not found" + }' + else + log error "❌ Failed to retrieve parameter '$PARAM_NAME' from AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks ssm:GetParameter on this resource" + log error " • KMS key permission missing (kms:Decrypt) for SecureString" + log error " • Region '$AWS_REGION' unreachable" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/parameter_store/setup b/parameters/providers/parameter_store/setup new file mode 100755 index 00000000..0345b25b --- /dev/null +++ b/parameters/providers/parameter_store/setup @@ -0,0 +1,61 @@ +#!/bin/bash +set -euo pipefail + +# Validates AWS Systems Manager Parameter Store connection config. +# +# Required env (exported by parameters/build_context): +# PARAMETER_KIND — "secret" or "parameter"; determines String vs SecureString +# +# Reads, in priority order: PROVIDER_CONFIG, environment variables, defaults. +# Exports: AWS_REGION, PS_NAME_PREFIX, PS_KMS_KEY_ID, PS_TIER + +AWS_REGION=$(get_config_value \ + --env AWS_REGION \ + --env AWS_DEFAULT_REGION \ + --provider '.region') + +PS_NAME_PREFIX=$(get_config_value \ + --env PS_NAME_PREFIX \ + --provider '.name_prefix' \ + --default '/nullplatform/parameters/') + +PS_KMS_KEY_ID=$(get_config_value \ + --env PS_KMS_KEY_ID \ + --provider '.kms_key_id' \ + --default '') + +PS_TIER=$(get_config_value \ + --env PS_TIER \ + --provider '.tier' \ + --default 'Standard') + +if [ -z "$AWS_REGION" ]; then + log error "❌ AWS region not configured for parameter_store" + log error "" + log error "💡 Possible causes:" + log error " • AWS_REGION (or AWS_DEFAULT_REGION) env var is not set" + log error " • .region is missing in the parameter_store provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set AWS_REGION=" + log error " • Or populate PROVIDER_CONFIG.region via providers/parameter_store/fetch_configuration" + exit 1 +fi + +# Normalize the name prefix: must start with '/' (SSM hierarchical naming) and end with '/'. +[[ "$PS_NAME_PREFIX" != /* ]] && PS_NAME_PREFIX="/$PS_NAME_PREFIX" +[[ "$PS_NAME_PREFIX" != */ ]] && PS_NAME_PREFIX="$PS_NAME_PREFIX/" + +# Tier must be one of the valid SSM values. +case "$PS_TIER" in + Standard|Advanced|Intelligent-Tiering) ;; + *) + log error "❌ Invalid PS_TIER '$PS_TIER'" + log error "" + log error "🔧 How to fix:" + log error " • Set PS_TIER to one of: Standard, Advanced, Intelligent-Tiering" + exit 1 + ;; +esac + +export AWS_REGION PS_NAME_PREFIX PS_KMS_KEY_ID PS_TIER diff --git a/parameters/providers/parameter_store/store b/parameters/providers/parameter_store/store new file mode 100755 index 00000000..17dedd92 --- /dev/null +++ b/parameters/providers/parameter_store/store @@ -0,0 +1,51 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter in AWS Systems Manager Parameter Store. +# - kind=secret → Type=SecureString (encrypted via KMS_KEY_ID or default aws/ssm) +# - kind=parameter → Type=String (plain text) +# +# Required env: PARAMETER_KIND, PARAMETER_VALUE, AWS_REGION, PS_NAME_PREFIX, PS_TIER +# Optional env: PS_KMS_KEY_ID + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" + +SSM_TYPE="String" +[ "${PARAMETER_KIND:-}" = "secret" ] && SSM_TYPE="SecureString" + +put_args=( + --region "$AWS_REGION" + --name "$PARAM_NAME" + --value "$PARAMETER_VALUE" + --type "$SSM_TYPE" + --tier "$PS_TIER" +) +if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then + put_args+=(--key-id "$PS_KMS_KEY_ID") +fi + +if ! aws ssm put-parameter "${put_args[@]}" >/dev/null 2>&1; then + log error "❌ Failed to store parameter in AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks ssm:PutParameter for $PARAM_NAME" + log error " • A parameter with this name already exists (UUID collision — extremely unlikely)" + log error " • Tier '$PS_TIER' rejects this value size (Standard caps at 4KB)" + if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then + log error " • IAM principal lacks kms:Encrypt on $PS_KMS_KEY_ID" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error " • For large values, set PS_TIER=Advanced" + exit 1 +fi + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg parameter_name "$PARAM_NAME" \ + --arg region "$AWS_REGION" \ + --arg type "$SSM_TYPE" \ + --arg tier "$PS_TIER" \ + '{external_id: $external_id, metadata: {parameter_name: $parameter_name, region: $region, type: $type, tier: $tier}}' diff --git a/parameters/providers/secret_manager/delete b/parameters/providers/secret_manager/delete new file mode 100755 index 00000000..cc6c8835 --- /dev/null +++ b/parameters/providers/secret_manager/delete @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +# Deletes a secret from AWS Secrets Manager. +# +# Idempotency semantics: +# - Successful delete → success +# - ResourceNotFoundException → success (already gone, idempotent) +# - Any other error → exit 1 with troubleshooting +# +# Uses --force-delete-without-recovery to end billing immediately and free the name. +# +# Required env: EXTERNAL_ID, AWS_REGION, SM_NAME_PREFIX + +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if aws secretsmanager delete-secret \ + --region "$AWS_REGION" \ + --secret-id "$SECRET_NAME" \ + --force-delete-without-recovery >/dev/null 2>"$err_file"; then + rm -f "$err_file" + echo '{ + "success": true +}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ResourceNotFoundException"; then + log debug "Secret '$SECRET_NAME' does not exist, treating delete as success" + echo '{ + "success": true +}' + else + log error "❌ Failed to delete secret '$SECRET_NAME' in AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:DeleteSecret on this resource" + log error " • Region '$AWS_REGION' unreachable or wrong" + log error " • AWS API throttling" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error " • Check IAM policy: aws iam simulate-principal-policy --action-names secretsmanager:DeleteSecret" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/secret_manager/docs/architecture.md b/parameters/providers/secret_manager/docs/architecture.md new file mode 100644 index 00000000..78b5da7e --- /dev/null +++ b/parameters/providers/secret_manager/docs/architecture.md @@ -0,0 +1,150 @@ +# AWS Secrets Manager — Architecture + +This document describes how the `parameters/providers/secret_manager/` provider stores, retrieves, and deletes nullplatform parameters using AWS Secrets Manager (SM), and how it differs from clear-text parameters. + +--- + +## Role in the parameter lifecycle + +nullplatform parameters can be of two kinds: + +| Kind | Storage location | This package | +|-------------|---------------------------------------------------|--------------| +| Clear-text | nullplatform API (its own datastore) | Not involved | +| Secret | External provider (this package: AWS SM) | Used | + +Only **secret** parameters trigger the `store` / `retrieve` / `delete` workflows. Clear-text values never leave nullplatform and never touch AWS SM. From the operator's perspective: this provider is invisible until a parameter is marked as a secret. + +The interaction is event-driven via four nullplatform actions: + +| Action | Trigger | Effect on AWS SM | +|------------|--------------------------------------------------|---------------------------------------| +| `store` | A secret parameter is created | `CreateSecret` with payload | +| `retrieve` | A consumer needs the value (deploy, runtime API) | `GetSecretValue`, returns `{value}` | +| `delete` | A secret parameter is deleted | `DeleteSecret --force-delete-without-recovery` | +| `notify` | nullplatform-side ack hook | No-op (returns `{success: true}`) | + +The contract of these four scripts is identical to the `parameters/providers/hashicorp_vault/` provider — they are drop-in replaceable. Only the storage backend changes. + +--- + +## Naming strategy + +### Path layout + +Every secret is stored under a single namespace: + +``` +parameters/ +``` + +- **`parameters/`** — fixed prefix. This is the IAM anchor: it lets a single resource ARN pattern (`arn:...:secret:parameters/*`) cover everything this provider manages, and nothing else. Removing the prefix would force IAM to either allow account-wide access or maintain an enumerated list of secret names (impractical, since names are generated at runtime). +- **``** — UUIDv4 generated by the `store` script via `uuidgen` (with `openssl rand -hex 16` as a portable fallback). This becomes the canonical handle nullplatform persists; subsequent `retrieve` / `delete` actions get it back via `NP_ACTION_CONTEXT.notification.external_id`. + +### ARN shape + +When you `CreateSecret --name parameters/`, AWS appends a random 6-character suffix to the ARN: + +``` +arn:aws:secretsmanager:::secret:parameters/-XXXXXX +``` + +This matters for IAM: + +- ARN pattern `arn:...:secret:parameters/*` **does** match (the wildcard absorbs the suffix). +- ARN pattern `arn:...:secret:parameters/` **does not** match (no suffix-aware glob). + +Always use the wildcard form in policies, even when locking down to a single secret — anchor the wildcard at the deterministic part of the name. + +### Region partitioning + +Each secret lives in a single AWS region. The `store` / `retrieve` / `delete` scripts all read `AWS_REGION` (falling back to `AWS_DEFAULT_REGION`, then `us-east-1`). The region is also written into the `metadata` returned by `store`, so a future cross-region disaster-recovery flow can locate the secret without guessing. + +Cross-region replication is **not** enabled by default. AWS SM supports it (`replicate-secret-to-regions`), but it doubles the per-secret cost and is not required for the current contract. + +--- + +## Secret payload shape + +The value stored in AWS SM is **not** the raw parameter value — it is a JSON envelope: + +```json +{ + "parameter_id": 42, + "value": "the-actual-secret", + "stored_at": "2026-05-05T12:34:56Z", + "external_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" +} +``` + +| Field | Source | Purpose | +|----------------|----------------------------------------|--------------------------------------| +| `parameter_id` | `NP_ACTION_CONTEXT.notification.parameter_id` | Reverse lookup from SM to nullplatform | +| `value` | `NP_ACTION_CONTEXT.notification.value` | The secret itself | +| `stored_at` | `date -u` at store time | Audit trail | +| `external_id` | UUID generated at store time | Self-verification (must equal the path component) | + +Keeping `parameter_id` and `external_id` inside the payload — and not just in the path — means the secret is self-describing. If someone discovers an orphaned `parameters/` secret in AWS, the JSON tells them which nullplatform parameter it belongs to without needing nullplatform's metadata. + +The `retrieve` script extracts only `.value` from this envelope, preserving the `{value: "..."}` output contract shared with the Vault provider. + +--- + +## Cost model + +AWS Secrets Manager pricing (as of writing — verify against current AWS pricing for your region): + +| Component | Price | +|------------------|--------------------------------------| +| Per secret | **$0.40 / secret / month** (prorated) | +| API calls | **$0.05 / 10,000 calls** | +| Replicated secret | $0.40 / replica / month | + +### Cost intuition + +For a service with **N** secret parameters and roughly **M** API calls per parameter per month (deploys + retrievals): + +``` +monthly_cost ≈ 0.40 * N + 0.05 * (N * M / 10_000) +``` + +The fixed $0.40 dominates: at typical deploy frequencies, the per-secret monthly fee is ~99% of the total. **Adding a secret costs $0.40/month**; reading it 100 times costs $0.0005. + +### Cost levers + +1. **Don't store non-secrets here.** Clear-text parameters belong in nullplatform's API. Mis-classification is the most common cost regression. +2. **Delete promptly.** `--force-delete-without-recovery` (already used) ends billing immediately. Without it, AWS keeps charging for the 7–30 day soft-delete window. +3. **Avoid replication unless you need DR.** Each replica is a full $0.40/month. +4. **Cache at the consumer side.** Each `GetSecretValue` is a billable call. For high-fanout reads (e.g., autoscaling pods all pulling the same value), have a single retrieve at deploy-time and inject as env var, rather than per-pod API calls. + +### Comparison with self-hosted Vault + +This isn't apples-to-apples: + +- **Vault**: no per-secret fee. Cost is the underlying infra (EC2/EKS, storage, ops time). Below ~250 secrets, self-hosted Vault tends to be cheaper on paper but more expensive in operator hours. +- **AWS SM**: linear in secret count, zero ops. Above ~250 secrets the costs converge; the trade-off becomes operational rather than financial. + +The two providers were designed to be drop-in replaceable precisely so this decision can be revisited per environment. + +--- + +## Lifecycle notes + +### Hard delete by default + +`delete` uses `--force-delete-without-recovery`. This is intentional: + +- AWS SM defaults to a **soft-delete** with a 7–30 day recovery window. During that window, the secret name is reserved (you cannot create a new secret with the same name) and you continue paying the $0.40/month fee. +- Since `external_id` is a UUID, name collisions on re-creation are not a real risk. We trade recoverability for clean re-use. + +If you need recoverability for compliance reasons, change `delete` to omit `--force-delete-without-recovery` and add a `--recovery-window-in-days ` flag — but document it in the parameter-store-level retention policy. + +### Idempotency + +- `delete` is idempotent: it suppresses errors with `|| true` and always returns `{success: true}`. Re-deleting a missing secret is a no-op. +- `retrieve` is idempotent: it returns `{"value": "value not found"}` instead of failing if the secret doesn't exist. +- `store` is **not** idempotent: a second call generates a new UUID and stores a new secret. Idempotency is enforced at the nullplatform layer (the action is only fired once per parameter create event). + +### Encryption at rest + +All values are encrypted by AWS SM. By default, SM uses the AWS-managed KMS key `aws/secretsmanager`. To use a customer-managed KMS key (CMK), pass `--kms-key-id ` in `store` and grant `kms:Decrypt` / `kms:GenerateDataKey` on that key to consumers (see `iam-policy.md`). diff --git a/parameters/providers/secret_manager/docs/iam-policy.md b/parameters/providers/secret_manager/docs/iam-policy.md new file mode 100644 index 00000000..f10e3ed7 --- /dev/null +++ b/parameters/providers/secret_manager/docs/iam-policy.md @@ -0,0 +1,198 @@ +# IAM Policy — Least Privilege + +This document specifies the minimum IAM permissions required to operate the `parameters/providers/secret_manager/` provider. The policy is scoped to the `parameters/*` namespace and avoids account-wide wildcards. + +--- + +## Wildcards: which ones are OK + +There are two distinct uses of `*` in IAM, frequently conflated: + +| Pattern | Meaning | Allowed here? | +|------------------------------------------------------|--------------------------------------|---------------| +| `"Resource": "*"` | All resources of all types in the account | **No** | +| `"Resource": "arn:...:secret:parameters/*"` | Path glob on the `parameters/` prefix | **Yes** | + +The second is not a privilege escalation — it is the only way to express "all secrets owned by this provider" given that secret names are UUIDs generated at runtime and cannot be enumerated in advance. Avoiding it would force either explicit per-secret policies (impossible for unknown UUIDs) or `Resource: "*"` (much wider). + +--- + +## Required actions + +| Action | Used by | Why | +|-----------------------------------|------------|----------------------------------------------------------------------| +| `secretsmanager:CreateSecret` | `store` | Creates the secret with the JSON envelope | +| `secretsmanager:GetSecretValue` | `retrieve` | Reads the JSON envelope back | +| `secretsmanager:DeleteSecret` | `delete` | Removes the secret (with `--force-delete-without-recovery`) | +| `secretsmanager:DescribeSecret` | optional | Useful for diagnostics; not strictly required by the current scripts | + +`UpdateSecret`, `PutSecretValue`, `RestoreSecret`, `TagResource`, `RotateSecret` are **not** required and should not be granted unless the scripts grow to use them. + +--- + +## Recommended policy + +Replace placeholders before applying: + +- `` — region where the provider stores secrets (e.g. `us-east-1`). +- `` — 12-digit AWS account id of the agent. +- `` — only if using a customer-managed KMS key (see KMS section below). Otherwise omit the entire KMS statement. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ManageNullplatformSecretParameters", + "Effect": "Allow", + "Action": [ + "secretsmanager:CreateSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:::secret:parameters/*" + ] + } + ] +} +``` + +### Why this is sufficient + +- Confined to a single region and account. +- Confined to the `parameters/` name prefix — no other secrets in the account are reachable. +- No `Resource: "*"`. +- No write actions beyond create + delete (no overwrite, no rotation). +- No tagging or policy-management actions. + +--- + +## Splitting agent vs consumer + +The policy above grants both write and read in one role. In production it is often cleaner to split them: + +### Agent role (executes the workflow scripts) + +Needs `CreateSecret`, `GetSecretValue`, `DeleteSecret`, `DescribeSecret`. Same as the recommended policy above. + +### Consumer role (the application that needs the value at runtime) + +Needs only `GetSecretValue` (and `DescribeSecret` if the consumer enumerates metadata): + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "ReadNullplatformSecretParameters", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ], + "Resource": [ + "arn:aws:secretsmanager:::secret:parameters/*" + ] + } + ] +} +``` + +If a consumer should only read **specific** secrets (rather than every parameter in the namespace), narrow the resource list: + +```json +"Resource": [ + "arn:aws:secretsmanager:::secret:parameters/-*" +] +``` + +The trailing `-*` is required because AWS SM appends a 6-character suffix to the ARN of every secret it creates (see `architecture.md`). Omitting it makes the ARN never match. + +--- + +## KMS (only if using a customer-managed key) + +If you pass `--kms-key-id` to `CreateSecret` (i.e. you do not want to use the default `aws/secretsmanager` AWS-managed key), both the agent and any consumer also need access to the CMK. Add this statement to **both** the agent and consumer policies: + +```json +{ + "Sid": "UseCustomerManagedKmsKey", + "Effect": "Allow", + "Action": [ + "kms:Decrypt", + "kms:GenerateDataKey" + ], + "Resource": [ + "arn:aws:kms:::key/" + ], + "Condition": { + "StringEquals": { + "kms:ViaService": "secretsmanager..amazonaws.com" + } + } +} +``` + +The `kms:ViaService` condition is the security-relevant part: it ensures the role can only use the key **through Secrets Manager**, not for arbitrary `kms:Decrypt` calls against other ciphertexts encrypted with the same key. + +The CMK's **key policy** must also allow the role principal — IAM permissions alone are not enough for KMS. Configure that on the key, not on the role. + +--- + +## Conditions worth adding + +Optional hardening, depending on threat model: + +### Restrict to specific VPC endpoints + +If the agent runs inside a VPC with a Secrets Manager interface endpoint: + +```json +"Condition": { + "StringEquals": { + "aws:SourceVpce": "" + } +} +``` + +### Restrict to a specific source IAM role + +For consumers running in a known service account (IRSA) or instance profile: + +```json +"Condition": { + "ArnEquals": { + "aws:PrincipalArn": "arn:aws:iam:::role/" + } +} +``` + +(This is more typically enforced via the trust policy of the role itself, but resource policies on the secret can pin it as defense-in-depth.) + +### Enforce TLS + +Mostly handled by AWS by default, but explicitly denying non-TLS traffic is cheap insurance: + +```json +"Condition": { + "Bool": { + "aws:SecureTransport": "true" + } +} +``` + +--- + +## What not to grant + +For reference — these are commonly requested but **not** needed by the current scripts and should be denied unless a specific use case is documented: + +- `secretsmanager:PutSecretValue` — would let the agent overwrite values. Not used; secrets are immutable in this design. +- `secretsmanager:UpdateSecret` — same reasoning. +- `secretsmanager:RotateSecret` — rotation is not implemented. +- `secretsmanager:TagResource`, `UntagResource` — no tagging in current scripts. +- `secretsmanager:PutResourcePolicy` — would let the agent change cross-account access. Should be reserved for a separate admin role. +- `secretsmanager:ReplicateSecretToRegions` — replication is opt-in and out of scope. +- `iam:*` of any kind — this provider does not manage IAM. diff --git a/parameters/providers/secret_manager/retrieve b/parameters/providers/secret_manager/retrieve new file mode 100755 index 00000000..350d0ef2 --- /dev/null +++ b/parameters/providers/secret_manager/retrieve @@ -0,0 +1,44 @@ +#!/bin/bash +set -euo pipefail + +# Retrieves a value from AWS Secrets Manager by external_id. +# +# Semantics: +# - Success → return {value: ""} (extracted from JSON envelope) +# - ResourceNotFoundException → return {value: "value not found"} +# - Any other error → exit 1 with troubleshooting +# +# Required env: EXTERNAL_ID, AWS_REGION, SM_NAME_PREFIX + +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" + +err_file=$(mktemp) +if SECRET_STRING=$(aws secretsmanager get-secret-value \ + --region "$AWS_REGION" \ + --secret-id "$SECRET_NAME" \ + --query SecretString \ + --output text 2>"$err_file"); then + rm -f "$err_file" + STORED_VALUE=$(echo "$SECRET_STRING" | jq -r '.value // empty') + jq -n --arg value "$STORED_VALUE" '{value: $value}' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q "ResourceNotFoundException"; then + echo '{ + "value": "value not found" + }' + else + log error "❌ Failed to retrieve secret '$SECRET_NAME' from AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:GetSecretValue" + log error " • KMS key permission missing (kms:Decrypt) for CMK-encrypted secrets" + log error " • Region '$AWS_REGION' unreachable" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi +fi diff --git a/parameters/providers/secret_manager/setup b/parameters/providers/secret_manager/setup new file mode 100755 index 00000000..b1f898ca --- /dev/null +++ b/parameters/providers/secret_manager/setup @@ -0,0 +1,41 @@ +#!/bin/bash +set -euo pipefail + +# Validates AWS Secrets Manager connection config. +# Sourced once by parameters/build_context before any operation script runs. +# +# Reads, in priority order: PROVIDER_CONFIG, environment variables, defaults. +# Exports for operation scripts: AWS_REGION, SM_NAME_PREFIX, SM_KMS_KEY_ID + +AWS_REGION=$(get_config_value \ + --env AWS_REGION \ + --env AWS_DEFAULT_REGION \ + --provider '.region') + +SM_NAME_PREFIX=$(get_config_value \ + --env SM_NAME_PREFIX \ + --provider '.name_prefix' \ + --default 'parameters/') + +SM_KMS_KEY_ID=$(get_config_value \ + --env SM_KMS_KEY_ID \ + --provider '.kms_key_id' \ + --default '') + +if [ -z "$AWS_REGION" ]; then + log error "❌ AWS region not configured for secret_manager" + log error "" + log error "💡 Possible causes:" + log error " • AWS_REGION (or AWS_DEFAULT_REGION) env var is not set" + log error " • .region is missing in the secret_manager provider config" + log error "" + log error "🔧 How to fix:" + log error " • Set AWS_REGION= (e.g. us-east-1)" + log error " • Or populate PROVIDER_CONFIG.region via providers/secret_manager/fetch_configuration" + exit 1 +fi + +# AWS credentials come from the SDK default chain (IRSA, instance profile, env vars). +# Not validated here — AWS CLI will surface auth errors on the first call. + +export AWS_REGION SM_NAME_PREFIX SM_KMS_KEY_ID diff --git a/parameters/providers/secret_manager/store b/parameters/providers/secret_manager/store new file mode 100755 index 00000000..d9295459 --- /dev/null +++ b/parameters/providers/secret_manager/store @@ -0,0 +1,50 @@ +#!/bin/bash +set -euo pipefail + +# Stores a parameter value as an AWS Secrets Manager secret. +# One secret per parameter (each ~$0.40/month). External_id is a fresh UUIDv4. +# +# Required env: PARAMETER_ID, PARAMETER_VALUE, AWS_REGION, SM_NAME_PREFIX +# Optional env: SM_KMS_KEY_ID (uses default aws/secretsmanager key when empty) + +EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" + +SECRET_PAYLOAD=$(jq -n \ + --argjson parameter_id "${PARAMETER_ID:-null}" \ + --arg value "$PARAMETER_VALUE" \ + --arg stored_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + --arg external_id "$EXTERNAL_ID" \ + '{parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id}') + +create_args=( + --region "$AWS_REGION" + --name "$SECRET_NAME" + --secret-string "$SECRET_PAYLOAD" + --query ARN + --output text +) +if [ -n "${SM_KMS_KEY_ID:-}" ]; then + create_args+=(--kms-key-id "$SM_KMS_KEY_ID") +fi + +if ! SECRET_ARN=$(aws secretsmanager create-secret "${create_args[@]}" 2>/dev/null); then + log error "❌ Failed to store parameter in AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:CreateSecret for $SECRET_NAME" + log error " • Same-name secret is in soft-delete window (change prefix or wait)" + log error " • Region '$AWS_REGION' unreachable" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error " • Check policy: aws iam simulate-principal-policy --action-names secretsmanager:CreateSecret" + exit 1 +fi + +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg secret_arn "$SECRET_ARN" \ + --arg secret_name "$SECRET_NAME" \ + --arg region "$AWS_REGION" \ + '{external_id: $external_id, metadata: {secret_arn: $secret_arn, secret_name: $secret_name, region: $region}}' diff --git a/parameters/retrieve b/parameters/retrieve new file mode 100755 index 00000000..3d400379 --- /dev/null +++ b/parameters/retrieve @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +# Dispatch: delegate to the active provider's retrieve implementation. +# PROVIDER_DIR is exported by build_context. +source "$PROVIDER_DIR/retrieve" diff --git a/parameters/store b/parameters/store new file mode 100755 index 00000000..338a2c8c --- /dev/null +++ b/parameters/store @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +# Dispatch: delegate to the active provider's store implementation. +# PROVIDER_DIR is exported by build_context. +source "$PROVIDER_DIR/store" diff --git a/parameters/tests/build_context.bats b/parameters/tests/build_context.bats new file mode 100644 index 00000000..1417e30a --- /dev/null +++ b/parameters/tests/build_context.bats @@ -0,0 +1,199 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/build_context — provider resolution + sourcing +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/build_context" + export TEST_PROVIDER_DIR="$PARAMETERS_DIR/providers/test_provider" + + # Sensible CONTEXT default (a secret payload). Individual tests can override. + export CONTEXT='{"external_id":"ext-123","parameter_id":42,"value":"my-val","parameter_name":"DB_PASS","encoding":"plain","secret":true}' +} + +teardown() { + rm -rf "$TEST_PROVIDER_DIR" + unset PARAMETER_KIND ACTIVE_PROVIDER PROVIDER_DIR PARAMETERS_ROOT + unset SECRET_PROVIDER PARAMETER_PROVIDER + unset EXTERNAL_ID PARAMETER_ID PARAMETER_VALUE PARAMETER_NAME PARAMETER_ENCODING + unset PROVIDER_CONFIG +} + +@test "build_context: extracts notification fields and exports them" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo EID=\$EXTERNAL_ID PID=\$PARAMETER_ID VAL=\$PARAMETER_VALUE NAME=\$PARAMETER_NAME ENC=\$PARAMETER_ENCODING" + + assert_equal "$status" "0" + assert_contains "$output" "EID=ext-123" + assert_contains "$output" "PID=42" + assert_contains "$output" "VAL=my-val" + assert_contains "$output" "NAME=DB_PASS" + assert_contains "$output" "ENC=plain" +} + +@test "build_context: secret kind selects SECRET_PROVIDER" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER KIND=\$PARAMETER_KIND" + + assert_equal "$status" "0" + assert_contains "$output" "PROV=test_provider" + assert_contains "$output" "KIND=secret" +} + +@test "build_context: parameter kind selects PARAMETER_PROVIDER" { + export CONTEXT='{"external_id":"e","secret":false}' + export PARAMETER_KIND="parameter" + export PARAMETER_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER KIND=\$PARAMETER_KIND" + + assert_equal "$status" "0" + assert_contains "$output" "PROV=test_provider" + assert_contains "$output" "KIND=parameter" +} + +@test "build_context: derives PARAMETER_KIND from CONTEXT.secret when unset" { + unset PARAMETER_KIND + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND" + + assert_equal "$status" "0" + assert_contains "$output" "KIND=secret" +} + +@test "build_context: derives parameter kind when CONTEXT.secret is false" { + export CONTEXT='{"external_id":"e","secret":false}' + unset PARAMETER_KIND + export PARAMETER_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND PROV=\$ACTIVE_PROVIDER" + + assert_equal "$status" "0" + assert_contains "$output" "KIND=parameter" + assert_contains "$output" "PROV=test_provider" +} + +@test "build_context: fails with troubleshooting when SECRET_PROVIDER is unset" { + export PARAMETER_KIND="secret" + unset SECRET_PROVIDER + + run bash -c "source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ No provider configured for kind 'secret'" + assert_contains "$output" "SECRET_PROVIDER env var is not set" + assert_contains "$output" "🔧 How to fix:" +} + +@test "build_context: fails with troubleshooting when PARAMETER_PROVIDER is unset" { + export PARAMETER_KIND="parameter" + unset PARAMETER_PROVIDER + + run bash -c "source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ No provider configured for kind 'parameter'" + assert_contains "$output" "PARAMETER_PROVIDER env var is not set" +} + +@test "build_context: fails when provider directory doesn't exist" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="nonexistent_provider" + + run bash -c "source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Provider implementation not found: 'nonexistent_provider'" + assert_contains "$output" "🔧 How to fix:" +} + +@test "build_context: sources provider fetch_configuration when present" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + echo 'export FETCH_RAN="yes"' > "$TEST_PROVIDER_DIR/fetch_configuration" + + run bash -c "source $SCRIPT && echo FETCH=\$FETCH_RAN" + + assert_equal "$status" "0" + assert_contains "$output" "FETCH=yes" +} + +@test "build_context: sources provider setup when present" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + echo 'export SETUP_RAN="yes"' > "$TEST_PROVIDER_DIR/setup" + + run bash -c "source $SCRIPT && echo SETUP=\$SETUP_RAN" + + assert_equal "$status" "0" + assert_contains "$output" "SETUP=yes" +} + +@test "build_context: sources fetch_configuration before setup" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + echo 'export ORDER="${ORDER:-}fetch,"' > "$TEST_PROVIDER_DIR/fetch_configuration" + echo 'export ORDER="${ORDER:-}setup"' > "$TEST_PROVIDER_DIR/setup" + + run bash -c "source $SCRIPT && echo ORDER=\$ORDER" + + assert_equal "$status" "0" + assert_contains "$output" "ORDER=fetch,setup" +} + +@test "build_context: succeeds when provider has no fetch_configuration or setup" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER" + + assert_equal "$status" "0" + assert_contains "$output" "PROV=test_provider" +} + +@test "build_context: exports PROVIDER_DIR and PARAMETERS_ROOT" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo PD=\$PROVIDER_DIR ROOT=\$PARAMETERS_ROOT" + + assert_equal "$status" "0" + assert_contains "$output" "PD=$PARAMETERS_DIR/providers/test_provider" + assert_contains "$output" "ROOT=$PARAMETERS_DIR" +} + +@test "build_context: provider setup can read get_config_value with --provider" { + export PARAMETER_KIND="secret" + export SECRET_PROVIDER="test_provider" + export PROVIDER_CONFIG='{"address":"https://example.com"}' + mkdir -p "$TEST_PROVIDER_DIR" + cat > "$TEST_PROVIDER_DIR/setup" << 'EOF' +ADDR=$(get_config_value --provider '.address') +export RESOLVED_ADDR="$ADDR" +EOF + + run bash -c "source $SCRIPT && echo ADDR=\$RESOLVED_ADDR" + + assert_equal "$status" "0" + assert_contains "$output" "ADDR=https://example.com" +} diff --git a/parameters/tests/delete.bats b/parameters/tests/delete.bats new file mode 100644 index 00000000..993a4fc5 --- /dev/null +++ b/parameters/tests/delete.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/delete (dispatch) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/delete" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" +} + +@test "delete: sources provider's delete and propagates stdout" { + cat > "$PROVIDER_DIR/delete" << 'EOF' +echo '{"success":true}' +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"success":true}' +} + +@test "delete: provider script sees EXTERNAL_ID env var" { + export EXTERNAL_ID="ext-to-delete" + cat > "$PROVIDER_DIR/delete" << 'EOF' +echo "{\"deleted\":\"$EXTERNAL_ID\"}" +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "ext-to-delete" +} + +@test "delete: fails when provider's delete doesn't exist" { + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} diff --git a/parameters/tests/entrypoint.bats b/parameters/tests/entrypoint.bats new file mode 100644 index 00000000..7304b009 --- /dev/null +++ b/parameters/tests/entrypoint.bats @@ -0,0 +1,156 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/entrypoint — workflow routing by action +# Kind discrimination is NOT done at this layer (see build_context.bats). +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/entrypoint" + + # Build a fake SERVICE_PATH with a mirror of parameters/workflows/ + export SERVICE_PATH="$BATS_TEST_TMPDIR/service" + mkdir -p "$SERVICE_PATH/parameters/workflows" + for wf in store retrieve delete notify; do + : > "$SERVICE_PATH/parameters/workflows/$wf.yaml" + done + + # Mock `np` so eval $CMD echoes the command to stdout instead of calling the real CLI + cat > "$BATS_TEST_TMPDIR/np" << 'EOF' +#!/bin/bash +echo "$@" +EOF + chmod +x "$BATS_TEST_TMPDIR/np" + export PATH="$BATS_TEST_TMPDIR:$PATH" +} + +teardown() { + unset NP_ACTION_CONTEXT NOTIFICATION_ACTION OVERRIDES_PATH SERVICE_PATH +} + +@test "entrypoint: fails when NP_ACTION_CONTEXT is empty" { + unset NP_ACTION_CONTEXT + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ NP_ACTION_CONTEXT is not set" +} + +@test "entrypoint: fails when NOTIFICATION_ACTION has no action part" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter" + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ NOTIFICATION_ACTION is missing the action part" +} + +@test "entrypoint: store action routes to store.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter:store" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "store.yaml" +} + +@test "entrypoint: retrieve action routes to retrieve.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true,"external_id":"abc"}}' + export NOTIFICATION_ACTION="parameter:retrieve" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "retrieve.yaml" +} + +@test "entrypoint: delete action routes to delete.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"secret":false,"external_id":"abc"}}' + export NOTIFICATION_ACTION="parameter:delete" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "delete.yaml" +} + +@test "entrypoint: notify action routes to notify.yaml" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true,"external_id":"abc"}}' + export NOTIFICATION_ACTION="parameter:notify" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "notify.yaml" +} + +@test "entrypoint: payload's .secret value does not affect routing" { + # Run with secret=true + export NOTIFICATION_ACTION="parameter:store" + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + run bash "$SCRIPT" + assert_equal "$status" "0" + output_true="$output" + + # Run with secret=false + export NP_ACTION_CONTEXT='{"notification":{"secret":false}}' + run bash "$SCRIPT" + assert_equal "$status" "0" + + # Both route to the same workflow path + assert_equal "$output" "$output_true" +} + +@test "entrypoint: fails when no matching workflow exists" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter:nonexistent" + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ No workflow found at" +} + +@test "entrypoint: strips surrounding single quotes from NP_ACTION_CONTEXT" { + export NP_ACTION_CONTEXT="'{\"notification\":{\"secret\":true}}'" + export NOTIFICATION_ACTION="parameter:store" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "store.yaml" +} + +@test "entrypoint: OVERRIDES_PATH appends --overrides for matching path" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter:store" + + mkdir -p "$BATS_TEST_TMPDIR/override1/parameters/workflows" + : > "$BATS_TEST_TMPDIR/override1/parameters/workflows/store.yaml" + export OVERRIDES_PATH="$BATS_TEST_TMPDIR/override1" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "--overrides $BATS_TEST_TMPDIR/override1/parameters/workflows/store.yaml" +} + +@test "entrypoint: OVERRIDES_PATH skips paths without the workflow file" { + export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NOTIFICATION_ACTION="parameter:store" + + mkdir -p "$BATS_TEST_TMPDIR/empty_override" + export OVERRIDES_PATH="$BATS_TEST_TMPDIR/empty_override" + + run bash "$SCRIPT" + + assert_equal "$status" "0" + [[ "$output" != *"--overrides $BATS_TEST_TMPDIR/empty_override"* ]] +} diff --git a/parameters/tests/notify.bats b/parameters/tests/notify.bats new file mode 100644 index 00000000..5896a964 --- /dev/null +++ b/parameters/tests/notify.bats @@ -0,0 +1,47 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/notify (dispatch with default fallback) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/notify" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" +} + +@test "notify: uses provider's notify when present" { + cat > "$PROVIDER_DIR/notify" << 'EOF' +echo '{"success":true,"provider":"fake"}' +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" '"provider":"fake"' +} + +@test "notify: falls back to default success when provider has no notify" { + # Intentionally do NOT create $PROVIDER_DIR/notify + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"success":true}' +} + +@test "notify: provider's notify failure propagates" { + cat > "$PROVIDER_DIR/notify" << 'EOF' +echo "ack failed" >&2 +exit 7 +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "7" + assert_contains "$output" "ack failed" +} diff --git a/parameters/tests/providers/azure_key_vault/delete.bats b/parameters/tests/providers/azure_key_vault/delete.bats new file mode 100644 index 00000000..81a0fe75 --- /dev/null +++ b/parameters/tests/providers/azure_key_vault/delete.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure_key_vault/delete +# Two-step: soft-delete + purge. Purge failures are warnings, not errors. +# ============================================================================= + +bats_require_minimum_version 1.5.0 + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AZ_LOG="$BATS_TEST_TMPDIR/az.log" + # The mock checks args to determine if this is `delete` or `purge`, and + # picks MOCK_DELETE_MODE / MOCK_PURGE_MODE accordingly. + cat > "$BATS_TEST_TMPDIR/bin/az" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AZ_LOG" + +# Identify which sub-command was called +sub_action="" +for arg in "$@"; do + case "$arg" in + delete) sub_action="delete" ;; + purge) sub_action="purge" ;; + esac +done + +if [ "$sub_action" = "delete" ]; then mode="${MOCK_DELETE_MODE:-success}" +elif [ "$sub_action" = "purge" ]; then mode="${MOCK_PURGE_MODE:-success}" +else mode="success"; fi + +case "$mode" in + success) ;; + not_found) + echo "(SecretNotFound) A secret with (name/id) X was not found in this key vault." >&2 + exit 3 + ;; + auth_error) + echo "(Forbidden) The user is not authorized to perform this action." >&2 + exit 1 + ;; + purge_forbidden) + echo "(Forbidden) Purge permission missing." >&2 + exit 1 + ;; + *) + echo "(InternalServerError) something went wrong." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/az" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AZ_VAULT_NAME="my-vault" + export AZ_SECRET_PREFIX="parameters-" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "azure_key_vault delete: both delete + purge succeed → {success: true}" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "azure_key_vault delete: SecretNotFound on delete is idempotent → success" { + run bash -c "$DEPS; MOCK_DELETE_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "azure_key_vault delete: delete auth_error fails with troubleshooting" { + run bash -c "$DEPS; MOCK_DELETE_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete secret" + assert_contains "$output" "lacks 'Delete' permission" +} + +@test "azure_key_vault delete: purge forbidden is downgraded to warning, still returns success" { + run --separate-stderr bash -c "$DEPS; MOCK_PURGE_MODE=purge_forbidden source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" + assert_contains "$stderr" "⚠️" + assert_contains "$stderr" "Purge permission missing" +} + +@test "azure_key_vault delete: purge other failure is warning, still success" { + run --separate-stderr bash -c "$DEPS; MOCK_PURGE_MODE=other source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" + assert_contains "$stderr" "⚠️ Purge failed" +} + +@test "azure_key_vault delete: calls both delete and purge sub-commands" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret delete" + assert_contains "$captured" "keyvault secret purge" + assert_contains "$captured" "--name parameters-abc-123" +} + +@test "azure_key_vault delete: skips purge if delete returned not_found" { + run bash -c "$DEPS; MOCK_DELETE_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret delete" + # Purge should NOT have been called since delete already said "not found" + [[ "$captured" != *"keyvault secret purge"* ]] +} diff --git a/parameters/tests/providers/azure_key_vault/retrieve.bats b/parameters/tests/providers/azure_key_vault/retrieve.bats new file mode 100644 index 00000000..89f3f0aa --- /dev/null +++ b/parameters/tests/providers/azure_key_vault/retrieve.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure_key_vault/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AZ_LOG="$BATS_TEST_TMPDIR/az.log" + cat > "$BATS_TEST_TMPDIR/bin/az" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AZ_LOG" +case "${MOCK_AZ_MODE:-success}" in + success) + echo "the-stored-value" + ;; + not_found) + echo "(SecretNotFound) A secret with (name/id) X was not found in this key vault." >&2 + exit 3 + ;; + auth_error) + echo "(Forbidden) The user is not authorized to perform this action." >&2 + exit 1 + ;; + *) + echo "(InternalServerError) something went wrong." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/az" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AZ_VAULT_NAME="my-vault" + export AZ_SECRET_PREFIX="parameters-" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "azure_key_vault retrieve: success → returns value" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-stored-value" +} + +@test "azure_key_vault retrieve: SecretNotFound → 'value not found'" { + run bash -c "$DEPS; MOCK_AZ_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "value not found" +} + +@test "azure_key_vault retrieve: auth_error fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AZ_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" + assert_contains "$output" "lacks 'Get' permission" +} + +@test "azure_key_vault retrieve: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AZ_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" +} + +@test "azure_key_vault retrieve: calls az keyvault secret show" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret show" + assert_contains "$captured" "--vault-name my-vault" + assert_contains "$captured" "--name parameters-abc-123" + assert_contains "$captured" "--query value" +} diff --git a/parameters/tests/providers/azure_key_vault/setup.bats b/parameters/tests/providers/azure_key_vault/setup.bats new file mode 100644 index 00000000..711db077 --- /dev/null +++ b/parameters/tests/providers/azure_key_vault/setup.bats @@ -0,0 +1,69 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure_key_vault/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset AZURE_KEY_VAULT_NAME AZURE_KEY_VAULT_SECRET_PREFIX AZ_VAULT_NAME AZ_SECRET_PREFIX PROVIDER_CONFIG +} + +@test "azure_key_vault setup: fails when vault name is missing" { + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Azure Key Vault name not configured" + assert_contains "$output" "🔧 How to fix:" +} + +@test "azure_key_vault setup: succeeds with vault name from env" { + export AZURE_KEY_VAULT_NAME="my-vault" + + run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME PREFIX=\$AZ_SECRET_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "VAULT=my-vault" + assert_contains "$output" "PREFIX=parameters-" +} + +@test "azure_key_vault setup: PROVIDER_CONFIG wins over env" { + export AZURE_KEY_VAULT_NAME="env-vault" + export PROVIDER_CONFIG='{"vault_name":"cfg-vault","secret_prefix":"app-secret-"}' + + run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME PREFIX=\$AZ_SECRET_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "VAULT=cfg-vault" + assert_contains "$output" "PREFIX=app-secret-" +} + +@test "azure_key_vault setup: rejects prefix with invalid characters" { + export AZURE_KEY_VAULT_NAME="my-vault" + export AZURE_KEY_VAULT_SECRET_PREFIX="invalid_prefix/" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Invalid AZ_SECRET_PREFIX 'invalid_prefix/'" + assert_contains "$output" "alphanumerics and dashes" +} + +@test "azure_key_vault setup: accepts empty prefix" { + export AZURE_KEY_VAULT_NAME="my-vault" + export AZURE_KEY_VAULT_SECRET_PREFIX="" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=[\$AZ_SECRET_PREFIX]" + + assert_equal "$status" "0" + # Empty env should fall through to default 'parameters-' + assert_contains "$output" "PREFIX=[parameters-]" +} diff --git a/parameters/tests/providers/azure_key_vault/store.bats b/parameters/tests/providers/azure_key_vault/store.bats new file mode 100644 index 00000000..24ef09e3 --- /dev/null +++ b/parameters/tests/providers/azure_key_vault/store.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/azure_key_vault/store +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' +#!/bin/bash +echo "fixed-akv-uuid" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + + export AZ_LOG="$BATS_TEST_TMPDIR/az.log" + cat > "$BATS_TEST_TMPDIR/bin/az" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$AZ_LOG" +if [ "\${MOCK_AZ_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AZ_EXIT; fi +echo "https://my-vault.vault.azure.net/secrets/parameters-fixed-akv-uuid/abc123def456" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/az" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AZ_VAULT_NAME="my-vault" + export AZ_SECRET_PREFIX="parameters-" + export PARAMETER_VALUE="my-secret-value" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "azure_key_vault store: outputs external_id and metadata" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + secret_name=$(echo "$output" | jq -r '.metadata.secret_name') + azure_secret_id=$(echo "$output" | jq -r '.metadata.azure_secret_id') + vault_name=$(echo "$output" | jq -r '.metadata.vault_name') + assert_equal "$external_id" "fixed-akv-uuid" + assert_equal "$secret_name" "parameters-fixed-akv-uuid" + assert_contains "$azure_secret_id" "vault.azure.net/secrets/parameters-fixed-akv-uuid" + assert_equal "$vault_name" "my-vault" +} + +@test "azure_key_vault store: calls az keyvault secret set" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "keyvault secret set" + assert_contains "$captured" "--vault-name my-vault" + assert_contains "$captured" "--name parameters-fixed-akv-uuid" + assert_contains "$captured" "--value my-secret-value" +} + +@test "azure_key_vault store: honors custom AZ_SECRET_PREFIX" { + export AZ_SECRET_PREFIX="app-prod-" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AZ_LOG") + assert_contains "$captured" "--name app-prod-fixed-akv-uuid" +} + +@test "azure_key_vault store: fails with troubleshooting on az error" { + run bash -c "$DEPS; MOCK_AZ_EXIT=1 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store secret in Azure Key Vault 'my-vault'" + assert_contains "$output" "💡 Possible causes:" +} diff --git a/parameters/tests/providers/hashicorp_vault/delete.bats b/parameters/tests/providers/hashicorp_vault/delete.bats new file mode 100644 index 00000000..effc3207 --- /dev/null +++ b/parameters/tests/providers/hashicorp_vault/delete.bats @@ -0,0 +1,103 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp_vault/delete +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" + cat > "$BATS_TEST_TMPDIR/bin/curl" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$CURL_LOG" +if [ "${MOCK_CURL_MODE:-success}" = "network_error" ]; then exit 6; fi +want_status=0 +for arg in "$@"; do + if [ "$arg" = "-w" ]; then want_status=1; break; fi +done +if [ -n "${MOCK_HTTP_BODY:-}" ]; then printf "%s" "$MOCK_HTTP_BODY"; fi +if [ "$want_status" = "1" ]; then printf "\n%s" "${MOCK_HTTP_STATUS:-204}"; fi +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.test-token" + export VAULT_PATH_PREFIX="secret/data/parameters" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "vault delete: 204 returns {success: true}" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "vault delete: 200 returns {success: true}" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "vault delete: 404 is idempotent — returns success" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=404 source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "vault delete: 403 fails with auth troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=403 MOCK_HTTP_BODY='{\"errors\":[\"permission denied\"]}' source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault DELETE failed with HTTP 403" + assert_contains "$output" "lacks delete permission" + assert_contains "$output" "🔧 How to fix:" +} + +@test "vault delete: 500 fails with server troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=500 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault DELETE failed with HTTP 500" + assert_contains "$output" "Server-side error" +} + +@test "vault delete: network error fails with connectivity troubleshooting" { + run bash -c "$DEPS; MOCK_CURL_MODE=network_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Network error calling Vault" + assert_contains "$output" "unreachable" +} + +@test "vault delete: DELETEs the correct Vault URL with token header" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-X DELETE" + assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/abc-123" +} + +@test "vault delete: honors custom VAULT_PATH_PREFIX" { + export VAULT_PATH_PREFIX="kv/data/custom-mount" + + run bash -c "$DEPS; MOCK_HTTP_STATUS=204 source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/kv/data/custom-mount/abc-123" +} diff --git a/parameters/tests/providers/hashicorp_vault/retrieve.bats b/parameters/tests/providers/hashicorp_vault/retrieve.bats new file mode 100644 index 00000000..3f4797a5 --- /dev/null +++ b/parameters/tests/providers/hashicorp_vault/retrieve.bats @@ -0,0 +1,95 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp_vault/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" + cat > "$BATS_TEST_TMPDIR/bin/curl" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$CURL_LOG" +if [ "${MOCK_CURL_MODE:-success}" = "network_error" ]; then exit 6; fi +want_status=0 +for arg in "$@"; do + if [ "$arg" = "-w" ]; then want_status=1; break; fi +done +if [ -n "${MOCK_HTTP_BODY:-}" ]; then printf "%s" "$MOCK_HTTP_BODY"; fi +if [ "$want_status" = "1" ]; then printf "\n%s" "${MOCK_HTTP_STATUS:-200}"; fi +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.test-token" + export VAULT_PATH_PREFIX="secret/data/parameters" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "vault retrieve: 200 returns stored value" { + body='{"data":{"data":{"value":"the-real-secret","parameter_id":42}}}' + + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-real-secret" +} + +@test "vault retrieve: 404 returns 'value not found'" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=404 source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "value not found" +} + +@test "vault retrieve: 403 fails with auth troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=403 MOCK_HTTP_BODY='{\"errors\":[\"permission denied\"]}' source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault GET failed with HTTP 403" + assert_contains "$output" "lacks read permission" +} + +@test "vault retrieve: 500 fails with server troubleshooting" { + run bash -c "$DEPS; MOCK_HTTP_STATUS=500 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault GET failed with HTTP 500" +} + +@test "vault retrieve: network error fails with connectivity troubleshooting" { + run bash -c "$DEPS; MOCK_CURL_MODE=network_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Network error calling Vault" +} + +@test "vault retrieve: GETs the correct Vault URL with token header" { + body='{"data":{"data":{"value":"x"}}}' + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/abc-123" +} + +@test "vault retrieve: honors custom VAULT_PATH_PREFIX" { + export VAULT_PATH_PREFIX="kv/data/custom-mount" + body='{"data":{"data":{"value":"x"}}}' + + run bash -c "$DEPS; MOCK_HTTP_STATUS=200 MOCK_HTTP_BODY='$body' source $SCRIPT" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/kv/data/custom-mount/abc-123" +} diff --git a/parameters/tests/providers/hashicorp_vault/setup.bats b/parameters/tests/providers/hashicorp_vault/setup.bats new file mode 100644 index 00000000..13fe0707 --- /dev/null +++ b/parameters/tests/providers/hashicorp_vault/setup.bats @@ -0,0 +1,84 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp_vault/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset VAULT_ADDR VAULT_TOKEN VAULT_PATH_PREFIX PROVIDER_CONFIG +} + +@test "vault setup: fails with troubleshooting when VAULT_ADDR is missing" { + unset VAULT_ADDR + export VAULT_TOKEN="hvs.xxx" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault address not configured" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "vault setup: fails with troubleshooting when VAULT_TOKEN is missing" { + export VAULT_ADDR="https://vault.example.com" + unset VAULT_TOKEN + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault token not configured" +} + +@test "vault setup: succeeds with both env vars set, exports them" { + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.xxx" + + run bash -c "$DEPS; source $SCRIPT && echo ADDR=\$VAULT_ADDR TOKEN=\$VAULT_TOKEN PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "ADDR=https://vault.example.com" + assert_contains "$output" "TOKEN=hvs.xxx" + assert_contains "$output" "PREFIX=secret/data/parameters" +} + +@test "vault setup: PROVIDER_CONFIG wins over env var" { + export VAULT_ADDR="https://env-vault.com" + export VAULT_TOKEN="env-token" + export PROVIDER_CONFIG='{"address":"https://provider-vault.com","token":"provider-token"}' + + run bash -c "$DEPS; source $SCRIPT && echo ADDR=\$VAULT_ADDR TOKEN=\$VAULT_TOKEN" + + assert_equal "$status" "0" + assert_contains "$output" "ADDR=https://provider-vault.com" + assert_contains "$output" "TOKEN=provider-token" +} + +@test "vault setup: custom path_prefix from PROVIDER_CONFIG" { + export PROVIDER_CONFIG='{"address":"https://v.com","token":"t","path_prefix":"kv/data/custom"}' + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=kv/data/custom" +} + +@test "vault setup: reads VAULT_PATH_PREFIX from env if PROVIDER_CONFIG has no path_prefix" { + export VAULT_ADDR="https://v.com" + export VAULT_TOKEN="t" + export VAULT_PATH_PREFIX="kv/data/from-env" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=kv/data/from-env" +} diff --git a/parameters/tests/providers/hashicorp_vault/store.bats b/parameters/tests/providers/hashicorp_vault/store.bats new file mode 100644 index 00000000..d7a50025 --- /dev/null +++ b/parameters/tests/providers/hashicorp_vault/store.bats @@ -0,0 +1,110 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/hashicorp_vault/store +# Verifies HTTP request shape AND JSON output remain byte-compatible with +# the previous parameters/vault/store implementation. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/store" + + # Mock uuidgen for deterministic external_id + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' +#!/bin/bash +echo "fixed-test-uuid" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + + # Mock curl: capture args to file, return success by default + export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" + cat > "$BATS_TEST_TMPDIR/bin/curl" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$CURL_LOG" +exit \${MOCK_CURL_EXIT:-0} +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/curl" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + # Defaults from setup() — operation script assumes these are present + export VAULT_ADDR="https://vault.example.com" + export VAULT_TOKEN="hvs.test-token" + export VAULT_PATH_PREFIX="secret/data/parameters" + export PARAMETER_ID=42 + export PARAMETER_VALUE="my-super-secret" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "vault store: outputs JSON with external_id and vault_path metadata" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + # Parse with jq to be robust against whitespace + external_id=$(echo "$output" | jq -r '.external_id') + vault_path=$(echo "$output" | jq -r '.metadata.vault_path') + assert_equal "$external_id" "fixed-test-uuid" + assert_equal "$vault_path" "secret/data/parameters/fixed-test-uuid" +} + +@test "vault store: POSTs to correct Vault URL with token header" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "-X POST" + assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/fixed-test-uuid" +} + +@test "vault store: POST body contains parameter_id, value, external_id, stored_at" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + assert_contains "$captured" '"parameter_id":42' + assert_contains "$captured" '"value":"my-super-secret"' + assert_contains "$captured" '"external_id":"fixed-test-uuid"' + assert_contains "$captured" '"stored_at":"' +} + +@test "vault store: fails with troubleshooting when curl returns non-zero" { + export MOCK_CURL_EXIT=22 + + run bash -c "$DEPS; MOCK_CURL_EXIT=22 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in Vault at https://vault.example.com" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "vault store: honors custom VAULT_PATH_PREFIX" { + export VAULT_PATH_PREFIX="kv/data/custom-mount" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + vault_path=$(echo "$output" | jq -r '.metadata.vault_path') + assert_equal "$vault_path" "kv/data/custom-mount/fixed-test-uuid" + + captured=$(cat "$CURL_LOG") + assert_contains "$captured" "https://vault.example.com/v1/kv/data/custom-mount/fixed-test-uuid" +} + +@test "vault store: jq-escapes the value so quotes inside don't break the body" { + export PARAMETER_VALUE='val"with"quotes' + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$CURL_LOG") + # jq -R turns the literal value into "val\"with\"quotes" — escaped + assert_contains "$captured" 'val\"with\"quotes' +} diff --git a/parameters/tests/providers/parameter_store/delete.bats b/parameters/tests/providers/parameter_store/delete.bats new file mode 100644 index 00000000..c4c7cec5 --- /dev/null +++ b/parameters/tests/providers/parameter_store/delete.bats @@ -0,0 +1,83 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/parameter_store/delete +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) ;; + not_found) + echo "An error occurred (ParameterNotFound) when calling the DeleteParameter operation: Parameter not found." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the DeleteParameter operation." >&2 + exit 254 + ;; + *) + echo "An error occurred (InternalServerError)." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/nullplatform/parameters/" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "parameter_store delete: success → {success: true}" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "parameter_store delete: ParameterNotFound is idempotent → success" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "parameter_store delete: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete parameter" + assert_contains "$output" "lacks ssm:DeleteParameter" +} + +@test "parameter_store delete: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete parameter" +} + +@test "parameter_store delete: calls aws ssm delete-parameter with name" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "ssm delete-parameter" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name /nullplatform/parameters/abc-123" +} diff --git a/parameters/tests/providers/parameter_store/retrieve.bats b/parameters/tests/providers/parameter_store/retrieve.bats new file mode 100644 index 00000000..dce76d7f --- /dev/null +++ b/parameters/tests/providers/parameter_store/retrieve.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/parameter_store/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) + echo "the-real-value" + ;; + not_found) + echo "An error occurred (ParameterNotFound) when calling the GetParameter operation." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the GetParameter operation." >&2 + exit 254 + ;; + *) + echo "An error occurred (InternalServerError)." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/nullplatform/parameters/" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "parameter_store retrieve: success → returns value" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-real-value" +} + +@test "parameter_store retrieve: ParameterNotFound → 'value not found'" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "value not found" +} + +@test "parameter_store retrieve: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve parameter" + assert_contains "$output" "lacks ssm:GetParameter" +} + +@test "parameter_store retrieve: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve parameter" +} + +@test "parameter_store retrieve: calls aws with --with-decryption" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "ssm get-parameter" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name /nullplatform/parameters/abc-123" + assert_contains "$captured" "--with-decryption" +} diff --git a/parameters/tests/providers/parameter_store/setup.bats b/parameters/tests/providers/parameter_store/setup.bats new file mode 100644 index 00000000..f04b9691 --- /dev/null +++ b/parameters/tests/providers/parameter_store/setup.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/parameter_store/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset AWS_REGION AWS_DEFAULT_REGION PS_NAME_PREFIX PS_KMS_KEY_ID PS_TIER PROVIDER_CONFIG +} + +@test "parameter_store setup: fails when AWS_REGION is missing" { + unset AWS_REGION AWS_DEFAULT_REGION + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ AWS region not configured for parameter_store" +} + +@test "parameter_store setup: default name_prefix has leading and trailing slash" { + export AWS_REGION="us-east-1" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=/nullplatform/parameters/" +} + +@test "parameter_store setup: normalizes prefix without leading slash" { + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="custom/path/" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=/custom/path/" +} + +@test "parameter_store setup: normalizes prefix without trailing slash" { + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/custom/path" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=/custom/path/" +} + +@test "parameter_store setup: default tier is Standard" { + export AWS_REGION="us-east-1" + + run bash -c "$DEPS; source $SCRIPT && echo TIER=\$PS_TIER" + + assert_equal "$status" "0" + assert_contains "$output" "TIER=Standard" +} + +@test "parameter_store setup: accepts Advanced tier" { + export AWS_REGION="us-east-1" + export PS_TIER="Advanced" + + run bash -c "$DEPS; source $SCRIPT && echo TIER=\$PS_TIER" + + assert_equal "$status" "0" + assert_contains "$output" "TIER=Advanced" +} + +@test "parameter_store setup: rejects invalid tier with troubleshooting" { + export AWS_REGION="us-east-1" + export PS_TIER="Bogus" + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Invalid PS_TIER 'Bogus'" + assert_contains "$output" "Standard, Advanced, Intelligent-Tiering" +} + +@test "parameter_store setup: PROVIDER_CONFIG wins over env" { + export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"region":"eu-west-1","name_prefix":"/cfg/path/","kms_key_id":"alias/cfg","tier":"Advanced"}' + + run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION PREFIX=\$PS_NAME_PREFIX KMS=\$PS_KMS_KEY_ID TIER=\$PS_TIER" + + assert_equal "$status" "0" + assert_contains "$output" "REGION=eu-west-1" + assert_contains "$output" "PREFIX=/cfg/path/" + assert_contains "$output" "KMS=alias/cfg" + assert_contains "$output" "TIER=Advanced" +} diff --git a/parameters/tests/providers/parameter_store/store.bats b/parameters/tests/providers/parameter_store/store.bats new file mode 100644 index 00000000..eda9e63e --- /dev/null +++ b/parameters/tests/providers/parameter_store/store.bats @@ -0,0 +1,139 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/parameter_store/store +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' +#!/bin/bash +echo "fixed-ps-uuid" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$AWS_LOG" +exit \${MOCK_AWS_EXIT:-0} +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export PS_NAME_PREFIX="/nullplatform/parameters/" + export PS_KMS_KEY_ID="" + export PS_TIER="Standard" + export PARAMETER_ID=42 + export PARAMETER_VALUE="my-value" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "parameter_store store: outputs external_id and metadata" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + parameter_name=$(echo "$output" | jq -r '.metadata.parameter_name') + type=$(echo "$output" | jq -r '.metadata.type') + assert_equal "$external_id" "fixed-ps-uuid" + assert_equal "$parameter_name" "/nullplatform/parameters/fixed-ps-uuid" + assert_equal "$type" "String" +} + +@test "parameter_store store: kind=secret uses SecureString" { + export PARAMETER_KIND="secret" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + type=$(echo "$output" | jq -r '.metadata.type') + assert_equal "$type" "SecureString" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--type SecureString" +} + +@test "parameter_store store: kind=parameter uses String" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--type String" + [[ "$captured" != *"SecureString"* ]] +} + +@test "parameter_store store: includes --key-id for SecureString when PS_KMS_KEY_ID set" { + export PARAMETER_KIND="secret" + export PS_KMS_KEY_ID="alias/parameters-secure" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--key-id alias/parameters-secure" +} + +@test "parameter_store store: omits --key-id when PS_KMS_KEY_ID is empty (uses default aws/ssm)" { + export PARAMETER_KIND="secret" + export PS_KMS_KEY_ID="" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + [[ "$captured" != *"--key-id"* ]] +} + +@test "parameter_store store: never includes --key-id for String (kind=parameter)" { + export PARAMETER_KIND="parameter" + export PS_KMS_KEY_ID="alias/should-not-be-used" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + [[ "$captured" != *"--key-id"* ]] +} + +@test "parameter_store store: passes tier flag" { + export PARAMETER_KIND="parameter" + export PS_TIER="Advanced" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--tier Advanced" +} + +@test "parameter_store store: calls put-parameter with name and value" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "ssm put-parameter" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name /nullplatform/parameters/fixed-ps-uuid" + assert_contains "$captured" "--value my-value" +} + +@test "parameter_store store: fails with troubleshooting on aws error" { + export PARAMETER_KIND="parameter" + + run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in AWS Parameter Store" + assert_contains "$output" "💡 Possible causes:" +} diff --git a/parameters/tests/providers/secret_manager/delete.bats b/parameters/tests/providers/secret_manager/delete.bats new file mode 100644 index 00000000..1e103489 --- /dev/null +++ b/parameters/tests/providers/secret_manager/delete.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/secret_manager/delete +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/delete" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) ;; + not_found) + echo "An error occurred (ResourceNotFoundException) when calling the DeleteSecret operation: Secret not found." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the DeleteSecret operation: User not authorized." >&2 + exit 254 + ;; + *) + echo "An error occurred (UnknownError) when calling." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export SM_NAME_PREFIX="parameters/" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "secret_manager delete: success → {success: true}" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "secret_manager delete: ResourceNotFoundException is idempotent → success" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + success=$(echo "$output" | jq -r '.success') + assert_equal "$success" "true" +} + +@test "secret_manager delete: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete secret" + assert_contains "$output" "lacks secretsmanager:DeleteSecret" + assert_contains "$output" "AccessDeniedException" +} + +@test "secret_manager delete: unknown errors fail with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to delete secret" + assert_contains "$output" "🔧 How to fix:" +} + +@test "secret_manager delete: calls aws with force-delete flag" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager delete-secret" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--secret-id parameters/abc-123" + assert_contains "$captured" "--force-delete-without-recovery" +} diff --git a/parameters/tests/providers/secret_manager/retrieve.bats b/parameters/tests/providers/secret_manager/retrieve.bats new file mode 100644 index 00000000..67543946 --- /dev/null +++ b/parameters/tests/providers/secret_manager/retrieve.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/secret_manager/retrieve +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/retrieve" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$AWS_LOG" +case "${MOCK_AWS_MODE:-success}" in + success) + echo '{"parameter_id":42,"value":"the-real-value","stored_at":"2026-01-01T00:00:00Z","external_id":"abc-123"}' + ;; + not_found) + echo "An error occurred (ResourceNotFoundException) when calling the GetSecretValue operation: Secret not found." >&2 + exit 254 + ;; + auth_error) + echo "An error occurred (AccessDeniedException) when calling the GetSecretValue operation: User not authorized." >&2 + exit 254 + ;; + *) + echo "An error occurred (UnknownError) when calling." >&2 + exit 1 + ;; +esac +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export SM_NAME_PREFIX="parameters/" + export EXTERNAL_ID="abc-123" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "secret_manager retrieve: success → extracts .value from envelope" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "the-real-value" +} + +@test "secret_manager retrieve: ResourceNotFoundException → 'value not found'" { + run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" + + assert_equal "$status" "0" + value=$(echo "$output" | jq -r '.value') + assert_equal "$value" "value not found" +} + +@test "secret_manager retrieve: AccessDenied fails with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" + assert_contains "$output" "lacks secretsmanager:GetSecretValue" +} + +@test "secret_manager retrieve: unknown errors fail loud" { + run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to retrieve secret" +} + +@test "secret_manager retrieve: calls aws with correct args" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager get-secret-value" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--secret-id parameters/abc-123" +} diff --git a/parameters/tests/providers/secret_manager/setup.bats b/parameters/tests/providers/secret_manager/setup.bats new file mode 100644 index 00000000..d9d7f5a4 --- /dev/null +++ b/parameters/tests/providers/secret_manager/setup.bats @@ -0,0 +1,70 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/secret_manager/setup +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/setup" + export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset AWS_REGION AWS_DEFAULT_REGION SM_NAME_PREFIX SM_KMS_KEY_ID PROVIDER_CONFIG +} + +@test "secret_manager setup: fails when AWS_REGION is missing" { + unset AWS_REGION AWS_DEFAULT_REGION + + run bash -c "$DEPS; source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ AWS region not configured for secret_manager" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" +} + +@test "secret_manager setup: AWS_DEFAULT_REGION is honored when AWS_REGION is unset" { + unset AWS_REGION + export AWS_DEFAULT_REGION="eu-west-1" + + run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION" + + assert_equal "$status" "0" + assert_contains "$output" "REGION=eu-west-1" +} + +@test "secret_manager setup: default name_prefix is 'parameters/'" { + export AWS_REGION="us-east-1" + + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$SM_NAME_PREFIX" + + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=parameters/" +} + +@test "secret_manager setup: PROVIDER_CONFIG wins over env" { + export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"region":"eu-central-1","name_prefix":"custom/","kms_key_id":"alias/mykey"}' + + run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION PREFIX=\$SM_NAME_PREFIX KMS=\$SM_KMS_KEY_ID" + + assert_equal "$status" "0" + assert_contains "$output" "REGION=eu-central-1" + assert_contains "$output" "PREFIX=custom/" + assert_contains "$output" "KMS=alias/mykey" +} + +@test "secret_manager setup: kms_key_id is optional (empty when unset)" { + export AWS_REGION="us-east-1" + unset SM_KMS_KEY_ID + + run bash -c "$DEPS; source $SCRIPT && echo KMS=[\$SM_KMS_KEY_ID]" + + assert_equal "$status" "0" + assert_contains "$output" "KMS=[]" +} diff --git a/parameters/tests/providers/secret_manager/store.bats b/parameters/tests/providers/secret_manager/store.bats new file mode 100644 index 00000000..8f8612bf --- /dev/null +++ b/parameters/tests/providers/secret_manager/store.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/providers/secret_manager/store +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/store" + + mkdir -p "$BATS_TEST_TMPDIR/bin" + cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' +#!/bin/bash +echo "fixed-sm-uuid" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + + export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" + cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF +#!/bin/bash +echo "ARGS: \$@" >> "$AWS_LOG" +if [ "\${MOCK_AWS_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AWS_EXIT; fi +# create-secret returns ARN +echo "arn:aws:secretsmanager:us-east-1:111122223333:secret:parameters/fixed-sm-uuid-AbCdEf" +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/aws" + + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + export AWS_REGION="us-east-1" + export SM_NAME_PREFIX="parameters/" + export SM_KMS_KEY_ID="" + export PARAMETER_ID=42 + export PARAMETER_VALUE="my-secret" + + export DEPS="source $PARAMETERS_DIR/utils/log" +} + +@test "secret_manager store: outputs external_id and metadata" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + secret_name=$(echo "$output" | jq -r '.metadata.secret_name') + secret_arn=$(echo "$output" | jq -r '.metadata.secret_arn') + region=$(echo "$output" | jq -r '.metadata.region') + assert_equal "$external_id" "fixed-sm-uuid" + assert_equal "$secret_name" "parameters/fixed-sm-uuid" + assert_contains "$secret_arn" "arn:aws:secretsmanager" + assert_equal "$region" "us-east-1" +} + +@test "secret_manager store: calls aws secretsmanager create-secret with correct args" { + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager create-secret" + assert_contains "$captured" "--region us-east-1" + assert_contains "$captured" "--name parameters/fixed-sm-uuid" +} + +@test "secret_manager store: includes --kms-key-id when SM_KMS_KEY_ID is set" { + export SM_KMS_KEY_ID="alias/my-key" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--kms-key-id alias/my-key" +} + +@test "secret_manager store: omits --kms-key-id when SM_KMS_KEY_ID is empty" { + export SM_KMS_KEY_ID="" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + [[ "$captured" != *"--kms-key-id"* ]] +} + +@test "secret_manager store: fails with troubleshooting when aws CLI fails" { + run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in AWS Secrets Manager" + assert_contains "$output" "💡 Possible causes:" +} + +@test "secret_manager store: honors custom SM_NAME_PREFIX" { + export SM_NAME_PREFIX="custom-prefix/sub/" + + run bash -c "$DEPS; source $SCRIPT" + + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "--name custom-prefix/sub/fixed-sm-uuid" +} diff --git a/parameters/tests/retrieve.bats b/parameters/tests/retrieve.bats new file mode 100644 index 00000000..47d63dab --- /dev/null +++ b/parameters/tests/retrieve.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/retrieve (dispatch) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/retrieve" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" +} + +@test "retrieve: sources provider's retrieve and propagates stdout" { + cat > "$PROVIDER_DIR/retrieve" << 'EOF' +echo '{"value":"the-actual-value"}' +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"value":"the-actual-value"}' +} + +@test "retrieve: provider script sees EXTERNAL_ID env var" { + export EXTERNAL_ID="ext-abc-123" + cat > "$PROVIDER_DIR/retrieve" << 'EOF' +echo "{\"echoed_external_id\":\"$EXTERNAL_ID\"}" +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "ext-abc-123" +} + +@test "retrieve: fails when provider's retrieve doesn't exist" { + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} diff --git a/parameters/tests/store.bats b/parameters/tests/store.bats new file mode 100644 index 00000000..af300f77 --- /dev/null +++ b/parameters/tests/store.bats @@ -0,0 +1,53 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/store (dispatch) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/store" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" +} + +@test "store: sources provider's store and propagates stdout" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo '{"external_id":"test-id","metadata":{"k":"v"}}' +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"external_id":"test-id","metadata":{"k":"v"}}' +} + +@test "store: provider script sees PROVIDER_DIR env var" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo "{\"provider_dir\":\"$PROVIDER_DIR\"}" +EOF + + run bash "$SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "\"provider_dir\":\"$PROVIDER_DIR\"" +} + +@test "store: fails when provider's store doesn't exist" { + run bash "$SCRIPT" + [ "$status" -ne 0 ] +} + +@test "store: provider script error propagates exit code" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo "fatal" >&2 +exit 1 +EOF + + run bash "$SCRIPT" + [ "$status" -ne 0 ] + assert_contains "$output" "fatal" +} diff --git a/parameters/tests/utils/get_config_value.bats b/parameters/tests/utils/get_config_value.bats new file mode 100644 index 00000000..f6fcbf10 --- /dev/null +++ b/parameters/tests/utils/get_config_value.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/get_config_value +# Priority: provider config > env var > default +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + source "$PARAMETERS_DIR/utils/get_config_value" +} + +teardown() { + unset PROVIDER_CONFIG + unset TEST_ENV_VAR OTHER_ENV +} + +@test "get_config_value: provider wins over env var" { + export PROVIDER_CONFIG='{"address":"from-provider"}' + export TEST_ENV_VAR="from-env" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address' --default "default") + assert_equal "$result" "from-provider" +} + +@test "get_config_value: env wins when provider has no match" { + export PROVIDER_CONFIG='{"other":"value"}' + export TEST_ENV_VAR="from-env" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address' --default "default") + assert_equal "$result" "from-env" +} + +@test "get_config_value: default is last resort" { + result=$(get_config_value --env UNSET_VAR --provider '.address' --default "fallback") + assert_equal "$result" "fallback" +} + +@test "get_config_value: returns empty when no match and no default" { + result=$(get_config_value --env UNSET_VAR --provider '.address') + assert_equal "$result" "" +} + +@test "get_config_value: works without PROVIDER_CONFIG set" { + unset PROVIDER_CONFIG + export TEST_ENV_VAR="env-only" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address') + assert_equal "$result" "env-only" +} + +@test "get_config_value: multiple provider paths, first match wins" { + export PROVIDER_CONFIG='{"a":null,"b":"second"}' + + result=$(get_config_value --provider '.a' --provider '.b' --default "fallback") + assert_equal "$result" "second" +} + +@test "get_config_value: multiple env vars, first set wins" { + export TEST_ENV_VAR="" + export OTHER_ENV="other-set" + + result=$(get_config_value --env TEST_ENV_VAR --env OTHER_ENV --default "fallback") + assert_equal "$result" "other-set" +} + +@test "get_config_value: null value in PROVIDER_CONFIG is treated as missing" { + export PROVIDER_CONFIG='{"address":null}' + + result=$(get_config_value --provider '.address' --default "fallback") + assert_equal "$result" "fallback" +} + +@test "get_config_value: invalid JSON in PROVIDER_CONFIG falls through to env" { + export PROVIDER_CONFIG='not-valid-json' + export TEST_ENV_VAR="env-val" + + result=$(get_config_value --env TEST_ENV_VAR --provider '.address') + assert_equal "$result" "env-val" +} + +@test "get_config_value: nested provider path resolves correctly" { + export PROVIDER_CONFIG='{"hashicorp_vault":{"address":"https://vault.example.com"}}' + + result=$(get_config_value --provider '.hashicorp_vault.address') + assert_equal "$result" "https://vault.example.com" +} diff --git a/parameters/tests/utils/log.bats b/parameters/tests/utils/log.bats new file mode 100644 index 00000000..b4866b23 --- /dev/null +++ b/parameters/tests/utils/log.bats @@ -0,0 +1,64 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/log +# All log levels route to stderr (stdout is reserved for JSON contract). +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" +} + +teardown() { + unset -f log 2>/dev/null || true + unset LOG_LEVEL +} + +@test "log: info routes to stderr" { + source "$PARAMETERS_DIR/utils/log" + err=$(log info "hello" 2>&1 >/dev/null) + assert_equal "$err" "hello" + + # Verify stdout is empty + out=$(log info "hello" 2>/dev/null) + assert_equal "$out" "" +} + +@test "log: warn routes to stderr" { + source "$PARAMETERS_DIR/utils/log" + err=$(log warn "uh oh" 2>&1 >/dev/null) + assert_equal "$err" "uh oh" +} + +@test "log: error routes to stderr" { + source "$PARAMETERS_DIR/utils/log" + err=$(log error "boom" 2>&1 >/dev/null) + assert_equal "$err" "boom" +} + +@test "log: debug is silent by default" { + source "$PARAMETERS_DIR/utils/log" + err=$(log debug "shhh" 2>&1 >/dev/null) + assert_equal "$err" "" +} + +@test "log: debug emits to stderr when LOG_LEVEL=debug" { + export LOG_LEVEL=debug + source "$PARAMETERS_DIR/utils/log" + err=$(log debug "spoke up" 2>&1 >/dev/null) + assert_equal "$err" "spoke up" +} + +@test "log: stdout is always empty (JSON contract)" { + source "$PARAMETERS_DIR/utils/log" + out=$( + log info "info msg" + log warn "warn msg" + log error "error msg" + log debug "debug msg" + LOG_LEVEL=debug log debug "debug enabled msg" + ) + assert_equal "$out" "" +} diff --git a/parameters/utils/get_config_value b/parameters/utils/get_config_value new file mode 100755 index 00000000..7d173131 --- /dev/null +++ b/parameters/utils/get_config_value @@ -0,0 +1,56 @@ +#!/bin/bash + +# Get configuration value with priority: provider config > environment variable > default. +# Usage: get_config_value [--provider "jq.path"] ... [--env ENV_VAR] ... [--default "value"] +# +# Reads provider config from $PROVIDER_CONFIG — a JSON string scoped to the +# currently active provider. It is populated by providers//fetch_configuration +# (each provider owns its own config-fetching mechanism). The shape of +# PROVIDER_CONFIG is defined by each provider. +# +# Example (inside providers/hashicorp_vault/setup): +# VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') +# VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') + +get_config_value() { + local default_value="" + local -a providers=() + local -a env_vars=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --env) env_vars+=("${2:-}"); shift 2 ;; + --provider) providers+=("${2:-}"); shift 2 ;; + --default) default_value="${2:-}"; shift 2 ;; + *) shift ;; + esac + done + + # Priority 1: provider config + for jq_path in "${providers[@]}"; do + if [[ -n "$jq_path" && -n "${PROVIDER_CONFIG:-}" ]]; then + local v + v=$(echo "$PROVIDER_CONFIG" | jq -r "$jq_path // empty" 2>/dev/null || true) + if [[ -n "$v" && "$v" != "null" ]]; then + echo "$v" + return 0 + fi + fi + done + + # Priority 2: env vars + for env_var in "${env_vars[@]}"; do + if [[ -n "$env_var" && -n "${!env_var:-}" ]]; then + echo "${!env_var}" + return 0 + fi + done + + # Priority 3: default + if [[ -n "$default_value" ]]; then + echo "$default_value" + return 0 + fi + + echo "" +} diff --git a/parameters/utils/log b/parameters/utils/log new file mode 100755 index 00000000..09fd60c2 --- /dev/null +++ b/parameters/utils/log @@ -0,0 +1,24 @@ +#!/bin/bash + +# Minimal structured logging. +# Usage: log +# level ∈ debug | info | warn | error +# +# All levels route to STDERR. stdout is reserved for the JSON contract output +# of operation scripts (store/retrieve/delete/notify). Logging on stdout would +# corrupt the JSON parsed by the platform. +# +# Debug is silent unless LOG_LEVEL=debug. + +log() { + local level="${1:-info}" + shift || true + local msg="$*" + case "$level" in + debug) [ "${LOG_LEVEL:-info}" = "debug" ] && echo "$msg" >&2 || true ;; + info) echo "$msg" >&2 ;; + warn) echo "$msg" >&2 ;; + error) echo "$msg" >&2 ;; + *) echo "$level $msg" >&2 ;; + esac +} diff --git a/parameters/workflows/delete.yaml b/parameters/workflows/delete.yaml new file mode 100644 index 00000000..34e9e4f7 --- /dev/null +++ b/parameters/workflows/delete.yaml @@ -0,0 +1,12 @@ +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/parameters/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - name: delete + type: script + file: "$SERVICE_PATH/parameters/delete" diff --git a/parameters/workflows/notify.yaml b/parameters/workflows/notify.yaml new file mode 100644 index 00000000..b9e846cd --- /dev/null +++ b/parameters/workflows/notify.yaml @@ -0,0 +1,13 @@ +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/parameters/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: PARAMETER_ID, type: environment } + - name: notify + type: script + file: "$SERVICE_PATH/parameters/notify" diff --git a/parameters/workflows/retrieve.yaml b/parameters/workflows/retrieve.yaml new file mode 100644 index 00000000..a669885f --- /dev/null +++ b/parameters/workflows/retrieve.yaml @@ -0,0 +1,14 @@ +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/parameters/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: PARAMETER_NAME, type: environment } + - { name: PARAMETER_ENCODING, type: environment } + - name: retrieve + type: script + file: "$SERVICE_PATH/parameters/retrieve" diff --git a/parameters/workflows/store.yaml b/parameters/workflows/store.yaml new file mode 100644 index 00000000..a63146ec --- /dev/null +++ b/parameters/workflows/store.yaml @@ -0,0 +1,16 @@ +steps: + - name: build_context + type: script + file: "$SERVICE_PATH/parameters/build_context" + output: + - { name: ACTIVE_PROVIDER, type: environment } + - { name: PROVIDER_DIR, type: environment } + - { name: PARAMETER_KIND, type: environment } + - { name: EXTERNAL_ID, type: environment } + - { name: PARAMETER_ID, type: environment } + - { name: PARAMETER_VALUE, type: environment } + - { name: PARAMETER_NAME, type: environment } + - { name: PARAMETER_ENCODING, type: environment } + - name: store + type: script + file: "$SERVICE_PATH/parameters/store" From 60b4e881f4a1983abcbe178f70cde4da979be8ca Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Tue, 23 Jun 2026 13:29:38 -0300 Subject: [PATCH 02/41] refactor(parameters): resolve provider via specification_id + config from payload --- parameters/PENDING.md | 144 ++++++++++----------- parameters/build_context | 111 ++++++++-------- parameters/docs/architecture.md | 53 ++++---- parameters/docs/configuration.md | 123 ++++++++++-------- parameters/tests/build_context.bats | 188 +++++++++++++++------------- 5 files changed, 333 insertions(+), 286 deletions(-) diff --git a/parameters/PENDING.md b/parameters/PENDING.md index 2f79a1ad..6d3e985b 100644 --- a/parameters/PENDING.md +++ b/parameters/PENDING.md @@ -1,6 +1,6 @@ # Parameters Package — Pending Work -Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. Para una vista de la arquitectura completa ver `parameters/docs/architecture.md`. +Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. Para vista de la arquitectura completa: `parameters/docs/architecture.md`. --- @@ -9,17 +9,19 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. | Componente | Estado | |---|---| | Skeleton (entrypoint, build_context, dispatch, utils, workflows) | ✅ Implementado | -| Provider `hashicorp_vault` | ✅ Implementado (migrado del `parameters/vault/` original) | -| Provider `secret_manager` | ✅ Implementado | -| Provider `parameter_store` | ✅ Implementado (nuevo) | -| Provider `azure_key_vault` | ✅ Implementado (nuevo) | -| Error handling (not_found → idempotent, otros → fail loud) | ✅ Aplicado a deletes y retrieves de los 4 providers | -| Tests BATS | ✅ 150 tests pasando | +| Provider `hashicorp_vault` | ✅ Implementado | +| Provider `secret_manager` | ✅ Implementado (renombre a `aws_secret_manager` pendiente) | +| Provider `parameter_store` | ✅ Implementado | +| Provider `azure_key_vault` | ✅ Implementado | +| Error handling (not_found → idempotent, otros → fail loud) | ✅ Aplicado a deletes y retrieves | +| Tests BATS | ✅ 151 tests pasando | | Docs globales | ✅ architecture.md, configuration.md, adding_a_provider.md | | Docs por provider | ✅ architecture.md (4 providers), iam-policy.md (SM + PS) | | Decision doc para equipo | ✅ `aws-secret-manager-strategies.docx` (en root del repo) | +| **Resolución de provider via `provider.specification_id`** | **✅ Implementado** (era pendiente, hecho hoy) | +| **`PROVIDER_CONFIG` desde `provider.attributes`** | **✅ Implementado** (era pendiente como `fetch_configuration`, ahora viene en payload) | | Naming NRN+slug-based | ⏳ Pendiente — ver "1. Refactor de naming" | -| `fetch_configuration` por provider | ⏳ Pendiente — ver "2. Placeholders" | +| Rename `secret_manager` → `aws_secret_manager` | ⏳ Pendiente (opcional, no bloqueante) | --- @@ -29,13 +31,14 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. |---|---|---| | Estrategia de granularidad | 1:1 mapping (un secret por parámetro) | Review del equipo sobre el decision doc | | Naming convention | NRN entities con slugs+ids + dimensiones + parameter_id | Conversación de diseño | -| Provider AWS Secrets Manager | Nombre futuro: `aws_secret_manager` (rename pendiente de `secret_manager`) | Conversación de diseño | -| Selector resolution | Env-only (`SECRET_PROVIDER`, `PARAMETER_PROVIDER`) | Limitación del provider-categories de nullplatform | -| Workflow YAMLs | 4 workflows unificados (store, retrieve, delete, notify), sin discriminación por kind | Cleanup arquitectónico | -| Discriminación secret/param | En `build_context` desde `$CONTEXT.secret`, no en entrypoint | Mismo cleanup | +| Provider AWS Secrets Manager | Nombre futuro: `aws_secret_manager` | Conversación de diseño | +| Provider selection | Via `provider.specification_id` → np CLI → slug | Cambio reciente con payload real | +| Provider config source | `provider.attributes` en el payload (no env vars, no fetch script) | Cambio reciente | +| Workflow YAMLs | 4 workflows unificados (store, retrieve, delete, notify) | Cleanup arquitectónico | +| Discriminación secret/param | En `build_context` desde `$CONTEXT.secret`, no en entrypoint | Cleanup | | Logging | Todos los niveles routean a stderr (stdout reservado para JSON) | Bug encontrado durante tests | -| Delete failure semantics | "not found" → success idempotente, todo lo demás → exit 1 con troubleshooting | Feedback de revisión | -| Retrieve failure semantics | Idem delete: "not found" → `{value: "value not found"}`, otros errores → exit 1 | Idem | +| Delete failure semantics | "not found" → success idempotente, otros → exit 1 | Feedback de revisión | +| Retrieve failure semantics | Idem delete | Feedback de revisión | --- @@ -43,12 +46,13 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. ### 1. Refactor de naming a NRN+slugs+ids -**Bloqueado por:** falta confirmar la syntax exacta del `np` CLI para obtener slugs de entities por ID. +**Bloqueado por:** confirmar syntax exacta del `np` CLI para obtener slugs de entities por ID. **Hipótesis (a confirmar antes de implementar):** ```bash -np organization get --id 1255165411 --query slug --output text +np organization get --id 1255165411 --output json +# → { "slug": "acme", "id": "1255165411", ... } ``` #### Diseño aprobado @@ -60,16 +64,16 @@ El `external_id` retornado a nullplatform (y por tanto el nombre del secret en c ``` - Entities iteradas en orden NRN canónico: `organization → account → namespace → application → scope`. Solo se incluyen las presentes. -- Dimensiones ordenadas alfabéticamente por key para garantizar determinismo. +- Dimensiones (desde `$CONTEXT.dimensions`, top-level, no `provider.dimensions`) ordenadas alfabéticamente por key. - `parameter_id` al final como identificador único. -- Slugs son inmutables en nullplatform (garantía del contrato), por lo que el external_id no sufre deriva en el tiempo. +- Slugs son inmutables en nullplatform (garantía del contrato), por lo que el external_id no sufre deriva. #### Ejemplo -Con `entities = {organization: "1255165411", account: "95118862", namespace: "37094320", application: "321402625"}`, `dimensions = {env: "prod"}`, `parameter_id = 42`: +Con `entities = {organization: "1255165411", account: "95118862", namespace: "37094320", application: "321402625"}`, `dimensions = {environment: "development", country: "argentina"}`, `parameter_id = 359535238`: ``` -organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/env=prod/42 +organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/country=argentina/environment=development/359535238 ``` #### Pasos @@ -79,80 +83,76 @@ organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/ap - `hashicorp_vault/store`: nombre `secret/data/parameters/` - `secret_manager/store`: nombre `` - `parameter_store/store`: nombre `` - - `azure_key_vault/store`: nombre ``. AKV solo permite alfanumérico + `-`, así que transformamos `/` → `-` y removemos `=`. + - `azure_key_vault/store`: AKV solo permite alfanumérico + `-`, transformar `/` → `-` y remover `=`. 3. `retrieve`/`delete`/`notify` NO cambian: usan el `EXTERNAL_ID` que llega de nullplatform. -4. Tests: mock de `np` CLI en `$BATS_TEST_TMPDIR/bin/`, expected paths actualizados en cada provider. +4. Tests: mock de `np get` en `$BATS_TEST_TMPDIR/bin/`, expected paths actualizados. 5. Update de `parameters/providers//docs/architecture.md` con el nuevo naming. #### Edge cases (todos confirmados) -- Entities siempre vienen (parte del contrato de nullplatform) — no hay caso de "entities vacío". +- Entities siempre vienen (parte del contrato de nullplatform). - `np` CLI siempre está disponible (instalado en la imagen Docker base del agente). - Slugs inmutables — no hay riesgo de deriva o reconstrucción incorrecta. --- -### 2. Placeholders `fetch_configuration` por provider +### 2. Rename `secret_manager` → `aws_secret_manager` (opcional) -Cada provider necesita un placeholder `fetch_configuration` (opcional según el contrato pero útil) que populate `PROVIDER_CONFIG` con su config específica desde donde corresponda (np CLI, REST, file, etc.). - -Hoy todos los providers funcionan vía env vars (`VAULT_ADDR`, `AWS_REGION`, etc.). El placeholder permite wirear el fetch real cuando el platform team defina el mecanismo. - -Estructura sugerida: - -```bash -#!/bin/bash -# parameters/providers//fetch_configuration -# -# TODO(platform-team): wire la lógica de fetch real (np CLI, REST, file montado, etc.) -# Mientras tanto, PROVIDER_CONFIG default a '{}' y todo cae a env vars. - -: "${PROVIDER_CONFIG:=}" -if [ -z "$PROVIDER_CONFIG" ]; then - PROVIDER_CONFIG='{}' -fi -export PROVIDER_CONFIG -``` - -A duplicar en los 4 providers. Build_context ya sourcea `$PROVIDER_DIR/fetch_configuration` si existe. - ---- - -### 3. Rename `secret_manager` → `aws_secret_manager` (opcional) - -Decisión tomada en conversación pero no aplicada todavía. No bloqueante para nada. Cuando se haga: +Decisión tomada pero no aplicada. No bloqueante. Cuando se haga: - Mover `parameters/providers/secret_manager/` → `parameters/providers/aws_secret_manager/` -- Update referencias en docs (architecture.md, configuration.md, adding_a_provider.md, iam-policy.md) +- Update referencias en docs - Update tests en `parameters/tests/providers/secret_manager/` (mover y renombrar) -- Actualizar valores aceptables de `SECRET_PROVIDER` / `PARAMETER_PROVIDER` en docs --- -## Contrato del payload — para referencia rápida +## Contrato del payload — referencia rápida -Notification de nullplatform tiene estos campos en `$CONTEXT` (después de que el entrypoint extrae `.notification`): +`$CONTEXT` (después de que el entrypoint extrae `.notification`): | Campo | Tipo | Acciones | Notas | |---|---|---|---| -| `parameter_id` | number | store, notify | nullplatform parameter ID | +| `parameter_id` | number | todas | nullplatform parameter ID | | `value` | string | store | el valor a persistir | -| `external_id` | string | retrieve, delete, notify | handle generado en store (NRN+slugs+ids+dims+id) | -| `secret` | bool | todas | discriminador secret/parameter (sigue derivando PARAMETER_KIND pero no afecta routing en 1:1) | -| `parameter_name` | string | todas | display name del parámetro | +| `external_id` | string | retrieve, delete, notify | handle generado en store | +| `secret` | bool | todas | discriminador secret/parameter (informativo en 1:1) | +| `parameter_name` | string | todas | display name | | `encoding` | string | todas | `plain`, `base64`, etc. | -| `entities` | object | todas | IDs only — slugs se fetchean por separado vía np CLI | -| `value_entities` | object | retrieve (opcional) | Mismo formato que entities, solo presente si el value tiene NRN distinto al parámetro | -| `dimensions` | object | opcional | key-value pairs (env, country, etc.) — ordenarse alfabéticamente | +| `entities` | object | todas | IDs only — slugs vía np CLI (solo en store, para naming) | +| `dimensions` | object | opcional | top-level, NO en `provider.dimensions` | +| `provider.specification_id` | uuid | todas | **el que decide qué provider usar** | +| `provider.attributes` | object | todas | **config del provider, viene en el payload** | +| `provider.nrn` | string | todas | informacional (NRN del provider instance) | +| `provider.dimensions` | object | todas | informacional (dimensions del provider instance) | +| `provider.id` | uuid | todas | informacional | -Las entities siempre vienen como IDs strings: +Ejemplo de payload completo de store: ```json { - "organization": "1255165411", - "account": "95118862", - "namespace": "37094320", - "application": "321402625" + "action": "parameter:store", + "parameter_id": 359535238, + "value": "the-value", + "parameter_name": "test_param", + "secret": false, + "encoding": "plaintext", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": { + "environment": "development", + "country": "argentina" + }, + "provider": { + "id": "e4105634-4ee0-4ffa-996b-1fb8213e56b6", + "nrn": "organization=1255165411:account=95118862:namespace=37094320:application=321402625", + "dimensions": {}, + "specification_id": "ec885dd0-7c38-45b8-af2c-0b9e1deb7d3d", + "attributes": {} + } } ``` @@ -164,9 +164,9 @@ Las entities siempre vienen como IDs strings: bats $(find parameters/tests -name "*.bats") ``` -Distribución actual (150 tests): +Distribución actual (151 tests): -- Skeleton (entrypoint, build_context, dispatch, utils): 55 tests +- Skeleton (entrypoint, build_context, dispatch, utils): 56 tests - hashicorp_vault: 27 tests - secret_manager: 17 tests - parameter_store: 23 tests @@ -180,9 +180,9 @@ Distribución actual (150 tests): ``` parameters/ ├── PENDING.md # este archivo -├── entrypoint, build_context # router + provider resolution +├── entrypoint, build_context # router + provider resolution via spec_id ├── store, retrieve, delete, notify # dispatch one-liners -├── workflows/ # 4 YAMLs (acción-only, kind se deriva) +├── workflows/ # 4 YAMLs (acción-only) ├── utils/ │ ├── get_config_value # priority: provider config > env > default │ └── log # todos los niveles a stderr @@ -192,6 +192,6 @@ parameters/ │ ├── secret_manager/ │ ├── parameter_store/ │ └── azure_key_vault/ -├── tests/ # 150 BATS tests +├── tests/ # 151 BATS tests └── docs/ # docs globales del paquete ``` diff --git a/parameters/build_context b/parameters/build_context index 72ce1a8e..192e8ee8 100755 --- a/parameters/build_context +++ b/parameters/build_context @@ -4,20 +4,24 @@ set -euo pipefail # Resolves which provider implementation handles this workflow run. # # Inputs: -# CONTEXT — JSON of the notification body (set by entrypoint) -# PARAMETER_KIND — "secret" | "parameter" (set by workflow `configuration` block; -# falls back to deriving from $CONTEXT.secret if absent — e.g. notify) -# SECRET_PROVIDER — env var: name of provider to use when kind=secret -# PARAMETER_PROVIDER — env var: name of provider to use when kind=parameter +# CONTEXT — JSON of the notification body (set by entrypoint) # -# Per-provider config fetching is delegated to providers//fetch_configuration -# (optional). Each provider owns its own fetching mechanism — no global config -# fetcher in this layer. +# Flow: +# 1. Extract notification fields into env vars +# 2. Derive PARAMETER_KIND from $CONTEXT.secret (still useful for providers +# like parameter_store that choose String vs SecureString) +# 3. Resolve ACTIVE_PROVIDER from $CONTEXT.provider.specification_id via +# `np provider specification read` — its `.slug` IS the provider directory name +# 4. Set PROVIDER_CONFIG from $CONTEXT.provider.attributes (config travels in +# the payload — no separate fetch_configuration step needed) +# 5. Source providers//setup (validates provider-specific config and +# exports connection handles) # -# Outputs (exported for subsequent workflow steps): -# PARAMETER_KIND, ACTIVE_PROVIDER, PROVIDER_DIR, PARAMETERS_ROOT -# EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE, PARAMETER_NAME, PARAMETER_ENCODING -# Plus any vars the provider's fetch_configuration and setup scripts export. +# Outputs (exported for downstream workflow steps): +# PARAMETERS_ROOT, ACTIVE_PROVIDER, PROVIDER_DIR, PARAMETER_KIND +# PROVIDER_CONFIG, EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE +# PARAMETER_NAME, PARAMETER_ENCODING +# Plus anything the provider's setup exports (VAULT_ADDR, AWS_REGION, etc.) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export PARAMETERS_ROOT="$SCRIPT_DIR" @@ -25,75 +29,78 @@ export PARAMETERS_ROOT="$SCRIPT_DIR" source "$SCRIPT_DIR/utils/log" source "$SCRIPT_DIR/utils/get_config_value" +# --- Notification fields --- export EXTERNAL_ID=$(echo "$CONTEXT" | jq -r '.external_id // empty') export PARAMETER_ID=$(echo "$CONTEXT" | jq -r '.parameter_id // empty') export PARAMETER_VALUE=$(echo "$CONTEXT" | jq -r '.value // empty') export PARAMETER_NAME=$(echo "$CONTEXT" | jq -r '.parameter_name // empty') export PARAMETER_ENCODING=$(echo "$CONTEXT" | jq -r '.encoding // empty') -if [ -z "${PARAMETER_KIND:-}" ]; then - # `// empty` would swallow `false` (jq's // treats false as missing), so use tostring. - case "$(echo "$CONTEXT" | jq -r '.secret | tostring')" in - true) PARAMETER_KIND="secret" ;; - false) PARAMETER_KIND="parameter" ;; - *) PARAMETER_KIND="" ;; - esac -fi +case "$(echo "$CONTEXT" | jq -r '.secret | tostring')" in + true) PARAMETER_KIND="secret" ;; + false) PARAMETER_KIND="parameter" ;; + *) PARAMETER_KIND="" ;; +esac export PARAMETER_KIND -# Selector resolution is env-only at this layer. -# If the platform wants to derive selectors from provider config, it must -# populate these env vars BEFORE invoking the entrypoint. -case "$PARAMETER_KIND" in - secret) - ACTIVE_PROVIDER="${SECRET_PROVIDER:-}" - selector_env="SECRET_PROVIDER" - ;; - parameter) - ACTIVE_PROVIDER="${PARAMETER_PROVIDER:-}" - selector_env="PARAMETER_PROVIDER" - ;; - *) - ACTIVE_PROVIDER="${SECRET_PROVIDER:-${PARAMETER_PROVIDER:-}}" - selector_env="SECRET_PROVIDER or PARAMETER_PROVIDER" - ;; -esac +# --- Resolve ACTIVE_PROVIDER from $.provider.specification_id --- +SPEC_ID=$(echo "$CONTEXT" | jq -r '.provider.specification_id // empty') +if [ -z "$SPEC_ID" ]; then + log error "❌ Missing .provider.specification_id in CONTEXT" + log error "" + log error "💡 Possible causes:" + log error " • The notification payload is malformed" + log error " • The parameter has no associated provider in nullplatform" + log error "" + log error "🔧 How to fix:" + log error " • Verify the parameter has a provider configured" + exit 1 +fi -if [ -z "$ACTIVE_PROVIDER" ]; then - log error "❌ No provider configured for kind '$PARAMETER_KIND'" +if ! SPEC_JSON=$(np provider specification read --id "$SPEC_ID" --output json 2>&1); then + log error "❌ Failed to read provider specification (id=$SPEC_ID)" log error "" log error "💡 Possible causes:" - log error " • $selector_env env var is not set in the workflow runtime" + log error " • The specification_id does not exist in nullplatform" + log error " • np CLI is not authenticated in this environment" + log error " • Network connectivity to nullplatform API" log error "" log error "🔧 How to fix:" - log error " • Set $selector_env= in the agent/runner environment" - log error " • Available providers: $(ls "$SCRIPT_DIR/providers" 2>/dev/null | grep -v '^README' | tr '\n' ' ' || true)" + log error " • Verify the spec exists: np provider specification read --id $SPEC_ID" + log error " • Check np auth: np whoami" + log error "Underlying output: $SPEC_JSON" + exit 1 +fi + +ACTIVE_PROVIDER=$(echo "$SPEC_JSON" | jq -r '.slug // empty') +if [ -z "$ACTIVE_PROVIDER" ]; then + log error "❌ Provider specification has no slug (id=$SPEC_ID)" + log error "Spec response: $SPEC_JSON" exit 1 fi PROVIDER_DIR="$SCRIPT_DIR/providers/$ACTIVE_PROVIDER" if [ ! -d "$PROVIDER_DIR" ]; then available=$(ls "$SCRIPT_DIR/providers" 2>/dev/null | grep -v '^README' | tr '\n' ' ' || true) - log error "❌ Provider implementation not found: '$ACTIVE_PROVIDER'" + log error "❌ Provider implementation not found for slug '$ACTIVE_PROVIDER'" + log error "" + log error "💡 Possible causes:" + log error " • The provider specification slug doesn't match any installed provider" log error "" log error "🔧 How to fix:" log error " • Available providers: ${available:-(none installed)}" - log error " • Set $selector_env to one of the above, or add a provider at parameters/providers/$ACTIVE_PROVIDER/" + log error " • Rename the spec slug, or add a provider at parameters/providers/$ACTIVE_PROVIDER/" exit 1 fi export ACTIVE_PROVIDER export PROVIDER_DIR -log debug "📦 active_provider=$ACTIVE_PROVIDER kind=$PARAMETER_KIND" +# --- PROVIDER_CONFIG comes directly from the payload (no fetch needed) --- +export PROVIDER_CONFIG=$(echo "$CONTEXT" | jq -c '.provider.attributes // {}') -# Each provider owns its config fetching (np CLI, REST call, file, env vars, etc.) -# Optional: if absent, the provider relies on whatever's already in the environment. -if [ -f "$PROVIDER_DIR/fetch_configuration" ]; then - log debug "📡 Sourcing $ACTIVE_PROVIDER/fetch_configuration" - source "$PROVIDER_DIR/fetch_configuration" -fi +log debug "📦 active_provider=$ACTIVE_PROVIDER kind=$PARAMETER_KIND spec_id=$SPEC_ID" -# Validation + connection handles. Operations downstream assume invariants hold. +# --- Source provider's setup for validation + connection handles --- if [ -f "$PROVIDER_DIR/setup" ]; then log debug "📡 Sourcing $ACTIVE_PROVIDER/setup" source "$PROVIDER_DIR/setup" diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md index 3c18441e..90fa626e 100644 --- a/parameters/docs/architecture.md +++ b/parameters/docs/architecture.md @@ -1,6 +1,6 @@ # Parameters Package — Architecture -A pluggable parameter and secret storage layer for nullplatform scopes. Choose any backend per-kind (one provider for plain parameters, another for secrets) without touching code outside provider directories. +A pluggable parameter and secret storage layer for nullplatform scopes. The provider for each parameter is chosen by the platform itself (via `provider.specification_id` in the notification payload), and the provider's configuration travels in the same payload. --- @@ -8,13 +8,15 @@ A pluggable parameter and secret storage layer for nullplatform scopes. Choose a nullplatform scopes need to persist parameter values somewhere. Different organizations want different backends: -- AWS-native shops: AWS Secrets Manager and/or Parameter Store -- Azure-native shops: Azure Key Vault +- AWS-native: Secrets Manager and/or Parameter Store +- Azure-native: Key Vault - Existing HashiCorp infrastructure: Vault -- Hybrid: secrets in one backend, plain parameters in another +- Hybrid setups: any combination of the above A monolithic scope tied to one backend forces fork-and-modify for every variation. This package inverts the relationship: the **dispatch layer is the package**, the **backends are pluggable modules** dropped into `providers/`. +The platform decides which provider handles each parameter — there is no per-environment / per-agent configuration of "which provider to use". The notification payload carries that information directly. + --- ## Layered design @@ -31,7 +33,6 @@ A monolithic scope tied to one backend forces fork-and-modify for every variatio │ - Clean NP_ACTION_CONTEXT, export CONTEXT (= .notification) │ │ - Pick workflow: workflows/.yaml │ │ - Honor OVERRIDES_PATH for consumer-side workflow overrides │ -│ - No kind discrimination here — that's pushed to build_context │ └────────────────────────────────────────────────────────────────┘ │ ▼ @@ -45,10 +46,11 @@ A monolithic scope tied to one backend forces fork-and-modify for every variatio ┌────────────────────────────────────────────────────────────────┐ │ parameters/build_context │ │ - Parse CONTEXT → EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE │ -│ - Derive PARAMETER_KIND from $CONTEXT.secret (true/false) │ -│ - Resolve ACTIVE_PROVIDER from SECRET_PROVIDER or PARAMETER_ │ -│ PROVIDER env var (per PARAMETER_KIND) │ -│ - Source providers/$ACTIVE_PROVIDER/fetch_configuration │ +│ - Derive PARAMETER_KIND from $CONTEXT.secret │ +│ - Read $CONTEXT.provider.specification_id │ +│ - np provider specification read --id → slug │ +│ - ACTIVE_PROVIDER = slug; PROVIDER_DIR = providers/$slug │ +│ - PROVIDER_CONFIG = $CONTEXT.provider.attributes │ │ - Source providers/$ACTIVE_PROVIDER/setup │ └────────────────────────────────────────────────────────────────┘ │ @@ -70,21 +72,22 @@ The dispatch layer is **provider-agnostic**. It has zero knowledge of any specif --- -## Why two env vars instead of one +## How the provider is chosen -`SECRET_PROVIDER` and `PARAMETER_PROVIDER` are separate because the most common production setup uses different backends for each kind: +For each parameter, nullplatform stores which provider should handle it. That choice travels with every notification as `provider.specification_id` — a UUID pointing to a "provider specification" entity in nullplatform. -- Plain parameters in Parameter Store (free Standard tier) -- Secrets in Secrets Manager (per-secret cost, but rotation + replication) +`build_context` resolves this UUID into a slug using the np CLI: -Setting `PARAMETER_PROVIDER=parameter_store` and `SECRET_PROVIDER=secret_manager` is one configuration line that captures this. The dispatcher resolves the right provider per request based on `$CONTEXT.secret`. +``` +np provider specification read --id --output json +→ { "slug": "aws_secret_manager", ... } +``` -If you want a single provider for both kinds, set both env vars to the same value: +The slug becomes `ACTIVE_PROVIDER`, which must match a directory under `parameters/providers/`. The match is exact, case-sensitive. -```bash -SECRET_PROVIDER=hashicorp_vault -PARAMETER_PROVIDER=hashicorp_vault -``` +The provider's configuration travels in the same payload at `provider.attributes`. `build_context` exports it as `PROVIDER_CONFIG` (a JSON string). Each provider's `setup` reads from `PROVIDER_CONFIG` via `get_config_value --provider '.field'` to extract specific fields (region, kms_key_id, etc.). + +This means **there is no per-environment configuration of "which provider"** — the platform decides per-parameter. A single agent can serve parameters routed to Vault and secrets routed to Secrets Manager at the same time, without any agent-side configuration. --- @@ -92,24 +95,24 @@ PARAMETER_PROVIDER=hashicorp_vault ``` parameters/ -├── entrypoint # Action router (kind discrimination + workflow selection) -├── build_context # Provider resolution + sourcing of provider's setup +├── entrypoint # Action router (action → workflow) +├── build_context # Resolves ACTIVE_PROVIDER from spec_id, sources setup ├── store, retrieve, # Dispatch one-liners │ delete, notify -├── workflows/ # 4 unified (store/retrieve/delete/notify) +├── workflows/ # 4 YAMLs (one per action) ├── utils/ │ ├── get_config_value # Priority: provider config > env > default -│ └── log # debug/info/warn/error with stderr routing +│ └── log # All levels route to stderr ├── providers/ │ ├── README.md # Contract every provider must satisfy │ ├── hashicorp_vault/ # HTTP API │ ├── secret_manager/ # aws CLI -│ ├── parameter_store/ # aws CLI (the only kind-branching provider) +│ ├── parameter_store/ # aws CLI (only kind-branching provider) │ └── azure_key_vault/ # az CLI ├── tests/ # BATS — mirrors source structure └── docs/ # This file, configuration.md, adding_a_provider.md ``` See `parameters/providers/README.md` for the provider contract spec. -See `configuration.md` for how `PROVIDER_CONFIG` is structured and how selectors are resolved. +See `configuration.md` for the payload shape and how `PROVIDER_CONFIG` is structured. See `adding_a_provider.md` to drop in a new backend. diff --git a/parameters/docs/configuration.md b/parameters/docs/configuration.md index ca0b157b..b2fc9a95 100644 --- a/parameters/docs/configuration.md +++ b/parameters/docs/configuration.md @@ -1,56 +1,77 @@ # Configuration -How the parameters package resolves which provider to use and where each provider gets its config. +How the parameters package decides which provider to use and where each provider gets its config — all from the notification payload. --- -## Two layers of configuration +## Where everything comes from -### 1. Provider selection (which backend handles this request) +Each notification from nullplatform includes the full information needed to handle the parameter: -Two env variables: +| Field in `$CONTEXT` | Purpose | +|---|---| +| `parameter_id` | nullplatform parameter ID | +| `value` | the value to persist (only on store) | +| `external_id` | provider's handle for the parameter (on retrieve/delete/notify) | +| `secret` | bool — discriminates secret vs plain parameter | +| `parameter_name` | human-readable name | +| `encoding` | encoding of the value (`plain`, `base64`, etc.) | +| `entities` | NRN parsed into entity IDs (organization, account, namespace, application) | +| `dimensions` | optional object — parameter scoping (env, country, etc.) | +| **`provider.specification_id`** | **UUID identifying which provider handles this parameter** | +| **`provider.attributes`** | **Provider-specific configuration (region, vault address, etc.)** | +| `provider.nrn` | Provider-instance NRN (informational) | +| `provider.dimensions` | Provider-instance dimensions (informational, different from parameter dimensions) | +| `provider.id` | Provider-instance ID (informational) | -| Env var | Purpose | -|----------------------|--------------------------------------------------| -| `SECRET_PROVIDER` | Which provider handles `kind=secret` requests | -| `PARAMETER_PROVIDER` | Which provider handles `kind=parameter` requests | +The two fields that drive the dispatch are `provider.specification_id` (which provider) and `provider.attributes` (its config). -Values are the directory names under `providers/` (e.g. `secret_manager`, `parameter_store`, `hashicorp_vault`, `azure_key_vault`). +--- -**Resolution:** env-only. There is no provider-config fallback for selectors at this layer — that would create a chicken-and-egg problem (build_context needs to know which provider to fetch config from, but the config tells it which provider to use). If you want the platform to drive selectors, populate these env vars in the agent/runner environment before invoking the entrypoint. +## Provider resolution -### 2. Provider-specific configuration (settings for the chosen backend) +`build_context` calls: -Each provider's `setup` script reads its own config from a combination of env vars and `PROVIDER_CONFIG` (a JSON string scoped to that one provider). +```bash +np provider specification read --id --output json +``` -**Resolution priority** (highest to lowest): +The response includes a `slug` field. That slug must match the name of a directory under `parameters/providers/`. For example: -1. `PROVIDER_CONFIG` (via `get_config_value --provider '.field'`) -2. Environment variable (via `get_config_value --env NAME`) -3. Default (via `get_config_value --default 'value'`) +| Slug returned | Provider directory used | +|---|---| +| `hashicorp_vault` | `parameters/providers/hashicorp_vault/` | +| `aws_secret_manager` | `parameters/providers/aws_secret_manager/` | +| `parameter_store` | `parameters/providers/parameter_store/` | +| `azure_key_vault` | `parameters/providers/azure_key_vault/` | -`PROVIDER_CONFIG` is populated by the active provider's `fetch_configuration` script (optional). If `fetch_configuration` doesn't exist or doesn't set `PROVIDER_CONFIG`, the provider falls back entirely to env vars. +If the slug doesn't match any installed provider, `build_context` fails with a list of available providers and instructions to either rename the spec slug or add the missing provider. --- -## The four strategies +## Provider config + +`build_context` exports `PROVIDER_CONFIG` as a JSON string containing whatever is in `$CONTEXT.provider.attributes`. The shape is provider-specific. -| Strategy | `PARAMETER_PROVIDER` | `SECRET_PROVIDER` | -|----------------------------------|----------------------|------------------------| -| Full Secrets Manager | `secret_manager` | `secret_manager` | -| Full Parameter Store (cheapest) | `parameter_store` | `parameter_store` | -| Mixed AWS (recommended for AWS) | `parameter_store` | `secret_manager` | -| Full HashiCorp Vault | `hashicorp_vault` | `hashicorp_vault` | -| Full Azure Key Vault | `azure_key_vault` | `azure_key_vault` | -| Hybrid Azure secrets, AWS params | `parameter_store` | `azure_key_vault` | +Each provider's `setup` script reads from `PROVIDER_CONFIG` via `get_config_value`: + +```bash +REGION=$(get_config_value --env AWS_REGION --provider '.region') +``` -Switching strategies = changing two env vars. Zero code changes. +Priority order (highest to lowest): + +1. Provider config (`get_config_value --provider '.field'`) +2. Environment variable (`get_config_value --env NAME`) +3. Default (`get_config_value --default 'value'`) + +Env vars take precedence ONLY when the provider attribute is missing. This lets you override config in a local dev environment by setting env vars while keeping the platform-controlled config as the production source of truth. --- ## Per-provider config shapes -The shape of `PROVIDER_CONFIG` for each provider: +The shape of `$CONTEXT.provider.attributes` for each provider: ### `hashicorp_vault` @@ -62,9 +83,7 @@ The shape of `PROVIDER_CONFIG` for each provider: } ``` -Equivalent env vars: `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX`. - -### `secret_manager` +### `aws_secret_manager` (currently named `secret_manager`) ```json { @@ -74,7 +93,7 @@ Equivalent env vars: `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX`. } ``` -Equivalent env vars: `AWS_REGION` (or `AWS_DEFAULT_REGION`), `SM_NAME_PREFIX`, `SM_KMS_KEY_ID`. `kms_key_id` is optional (defaults to AWS-managed key). +`kms_key_id` is optional (defaults to AWS-managed key). ### `parameter_store` @@ -87,7 +106,7 @@ Equivalent env vars: `AWS_REGION` (or `AWS_DEFAULT_REGION`), `SM_NAME_PREFIX`, ` } ``` -Equivalent env vars: `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. `kms_key_id` only matters for `kind=secret` (SecureString). `tier` ∈ {`Standard`, `Advanced`, `Intelligent-Tiering`}. +`kms_key_id` only matters for `kind=secret` (SecureString). `tier` ∈ {`Standard`, `Advanced`, `Intelligent-Tiering`}. ### `azure_key_vault` @@ -98,38 +117,40 @@ Equivalent env vars: `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. } ``` -Equivalent env vars: `AZURE_KEY_VAULT_NAME`, `AZURE_KEY_VAULT_SECRET_PREFIX`. Auth comes from the Azure CLI's default credential chain. +Authentication comes from the Azure CLI's default credential chain. --- -## How `PROVIDER_CONFIG` gets populated +## What's NOT in this package -Each provider may have a `fetch_configuration` script. When `build_context` activates that provider, it sources `providers//fetch_configuration` before `setup`. The script's job: +Two things that used to be design points but are obsolete now: -1. Fetch the provider's config from wherever it lives. -2. Export `PROVIDER_CONFIG` as a JSON string. +- **`SECRET_PROVIDER` / `PARAMETER_PROVIDER` env vars** — not needed. The platform sends `specification_id` per parameter, so there's no global "which provider to use" setting. +- **`fetch_configuration` scripts per provider** — not needed. Config comes in the payload as `provider.attributes`, no separate fetching step. -Where the config "lives" is up to each provider: - -- **`np provider get`** — call the nullplatform CLI to read providers config. -- **REST call** — query an internal config service. -- **File** — read a mounted config file. -- **Env vars only** — skip `fetch_configuration` entirely; rely on env. - -The provider package doesn't care which mechanism you choose. If you want a uniform mechanism across providers, you can implement them all the same way; if you want each to source config differently (e.g. Vault config from Consul, AWS config from instance profile), nothing forces them to align. +Providers can still be tested locally with env vars (e.g., `VAULT_ADDR=http://localhost:8200`) because `get_config_value` falls back to env when `PROVIDER_CONFIG` doesn't have the field. This is useful for development without involving the platform. --- ## Local development -For local testing without wiring `fetch_configuration`, set everything via env vars: +For local testing without involving the platform, set the relevant env vars and use a stubbed `np` CLI that returns a known slug: ```bash -export SECRET_PROVIDER=hashicorp_vault -export PARAMETER_PROVIDER=hashicorp_vault +# Stub np in PATH +cat > /tmp/np << 'EOF' +#!/bin/bash +echo '{"slug": "hashicorp_vault"}' +EOF +chmod +x /tmp/np +export PATH=/tmp:$PATH + +# Set the provider's env vars export VAULT_ADDR=http://localhost:8200 export VAULT_TOKEN=root-token -# ...then invoke the entrypoint + +# Now invoke the entrypoint +NP_ACTION_CONTEXT='...' NOTIFICATION_ACTION='parameter:store' ./parameters/entrypoint ``` -All providers fall through to env vars when `PROVIDER_CONFIG` is unset or empty. +All providers fall through to env vars when `PROVIDER_CONFIG` is missing fields, making local-only iteration possible. diff --git a/parameters/tests/build_context.bats b/parameters/tests/build_context.bats index 1417e30a..6cb43667 100644 --- a/parameters/tests/build_context.bats +++ b/parameters/tests/build_context.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/build_context — provider resolution + sourcing +# Unit tests for parameters/build_context — provider resolution via spec_id # ============================================================================= setup() { @@ -12,131 +12,167 @@ setup() { export SCRIPT="$PARAMETERS_DIR/build_context" export TEST_PROVIDER_DIR="$PARAMETERS_DIR/providers/test_provider" - # Sensible CONTEXT default (a secret payload). Individual tests can override. - export CONTEXT='{"external_id":"ext-123","parameter_id":42,"value":"my-val","parameter_name":"DB_PASS","encoding":"plain","secret":true}' + # Mock the np CLI + mkdir -p "$BATS_TEST_TMPDIR/bin" + export NP_LOG="$BATS_TEST_TMPDIR/np.log" + cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' +#!/bin/bash +echo "ARGS: $@" >> "$NP_LOG" +if [ "$1" = "provider" ] && [ "$2" = "specification" ] && [ "$3" = "read" ]; then + case "${MOCK_NP_SPEC_MODE:-success}" in + success) + slug="${MOCK_NP_SPEC_SLUG:-test_provider}" + echo "{\"slug\":\"$slug\",\"id\":\"some-uuid\"}" + ;; + not_found) + echo "Error: Specification not found" >&2 + exit 1 + ;; + no_slug) + echo "{\"id\":\"some-uuid\"}" + ;; + esac +fi +EOF + chmod +x "$BATS_TEST_TMPDIR/bin/np" + export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + + # Default CONTEXT: a valid payload with provider.specification_id pointing to test_provider + export CONTEXT='{ + "parameter_id": 42, + "value": "my-val", + "parameter_name": "DB_PASS", + "encoding": "plain", + "secret": true, + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "provider": { + "specification_id": "ec885dd0-7c38-45b8-af2c-0b9e1deb7d3d", + "attributes": { + "region": "us-east-1", + "name_prefix": "parameters/" + } + } + }' } teardown() { rm -rf "$TEST_PROVIDER_DIR" + unset MOCK_NP_SPEC_MODE MOCK_NP_SPEC_SLUG unset PARAMETER_KIND ACTIVE_PROVIDER PROVIDER_DIR PARAMETERS_ROOT - unset SECRET_PROVIDER PARAMETER_PROVIDER unset EXTERNAL_ID PARAMETER_ID PARAMETER_VALUE PARAMETER_NAME PARAMETER_ENCODING unset PROVIDER_CONFIG } @test "build_context: extracts notification fields and exports them" { - export PARAMETER_KIND="secret" - export SECRET_PROVIDER="test_provider" mkdir -p "$TEST_PROVIDER_DIR" - run bash -c "source $SCRIPT && echo EID=\$EXTERNAL_ID PID=\$PARAMETER_ID VAL=\$PARAMETER_VALUE NAME=\$PARAMETER_NAME ENC=\$PARAMETER_ENCODING" + run bash -c "source $SCRIPT && echo PID=\$PARAMETER_ID VAL=\$PARAMETER_VALUE NAME=\$PARAMETER_NAME ENC=\$PARAMETER_ENCODING" assert_equal "$status" "0" - assert_contains "$output" "EID=ext-123" assert_contains "$output" "PID=42" assert_contains "$output" "VAL=my-val" assert_contains "$output" "NAME=DB_PASS" assert_contains "$output" "ENC=plain" } -@test "build_context: secret kind selects SECRET_PROVIDER" { - export PARAMETER_KIND="secret" - export SECRET_PROVIDER="test_provider" +@test "build_context: derives PARAMETER_KIND=secret when .secret is true" { mkdir -p "$TEST_PROVIDER_DIR" - run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER KIND=\$PARAMETER_KIND" + run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND" assert_equal "$status" "0" - assert_contains "$output" "PROV=test_provider" assert_contains "$output" "KIND=secret" } -@test "build_context: parameter kind selects PARAMETER_PROVIDER" { - export CONTEXT='{"external_id":"e","secret":false}' - export PARAMETER_KIND="parameter" - export PARAMETER_PROVIDER="test_provider" +@test "build_context: derives PARAMETER_KIND=parameter when .secret is false" { + export CONTEXT=$(echo "$CONTEXT" | jq '.secret = false') mkdir -p "$TEST_PROVIDER_DIR" - run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER KIND=\$PARAMETER_KIND" + run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND" assert_equal "$status" "0" - assert_contains "$output" "PROV=test_provider" assert_contains "$output" "KIND=parameter" } -@test "build_context: derives PARAMETER_KIND from CONTEXT.secret when unset" { - unset PARAMETER_KIND - export SECRET_PROVIDER="test_provider" +@test "build_context: resolves ACTIVE_PROVIDER from spec_id via np CLI" { mkdir -p "$TEST_PROVIDER_DIR" - run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND" + run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER" assert_equal "$status" "0" - assert_contains "$output" "KIND=secret" + assert_contains "$output" "PROV=test_provider" } -@test "build_context: derives parameter kind when CONTEXT.secret is false" { - export CONTEXT='{"external_id":"e","secret":false}' - unset PARAMETER_KIND - export PARAMETER_PROVIDER="test_provider" +@test "build_context: calls np with correct args" { mkdir -p "$TEST_PROVIDER_DIR" - run bash -c "source $SCRIPT && echo KIND=\$PARAMETER_KIND PROV=\$ACTIVE_PROVIDER" + run bash -c "source $SCRIPT" - assert_equal "$status" "0" - assert_contains "$output" "KIND=parameter" - assert_contains "$output" "PROV=test_provider" + captured=$(cat "$NP_LOG") + assert_contains "$captured" "provider specification read" + assert_contains "$captured" "--id ec885dd0-7c38-45b8-af2c-0b9e1deb7d3d" + assert_contains "$captured" "--output json" } -@test "build_context: fails with troubleshooting when SECRET_PROVIDER is unset" { - export PARAMETER_KIND="secret" - unset SECRET_PROVIDER +@test "build_context: fails when specification_id is missing" { + export CONTEXT=$(echo "$CONTEXT" | jq 'del(.provider.specification_id)') run bash -c "source $SCRIPT" [ "$status" -ne 0 ] - assert_contains "$output" "❌ No provider configured for kind 'secret'" - assert_contains "$output" "SECRET_PROVIDER env var is not set" - assert_contains "$output" "🔧 How to fix:" + assert_contains "$output" "❌ Missing .provider.specification_id" + assert_contains "$output" "💡 Possible causes:" } -@test "build_context: fails with troubleshooting when PARAMETER_PROVIDER is unset" { - export PARAMETER_KIND="parameter" - unset PARAMETER_PROVIDER +@test "build_context: fails when np CLI fails to read spec" { + run bash -c "MOCK_NP_SPEC_MODE=not_found source $SCRIPT" - run bash -c "source $SCRIPT" + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to read provider specification" + assert_contains "$output" "🔧 How to fix:" +} + +@test "build_context: fails when spec has no slug" { + run bash -c "MOCK_NP_SPEC_MODE=no_slug source $SCRIPT" [ "$status" -ne 0 ] - assert_contains "$output" "❌ No provider configured for kind 'parameter'" - assert_contains "$output" "PARAMETER_PROVIDER env var is not set" + assert_contains "$output" "❌ Provider specification has no slug" } @test "build_context: fails when provider directory doesn't exist" { - export PARAMETER_KIND="secret" - export SECRET_PROVIDER="nonexistent_provider" - - run bash -c "source $SCRIPT" + # Resolved slug points to "nonexistent_provider" but no dir + run bash -c "MOCK_NP_SPEC_SLUG=nonexistent_provider source $SCRIPT" [ "$status" -ne 0 ] - assert_contains "$output" "❌ Provider implementation not found: 'nonexistent_provider'" - assert_contains "$output" "🔧 How to fix:" + assert_contains "$output" "❌ Provider implementation not found for slug 'nonexistent_provider'" +} + +@test "build_context: PROVIDER_CONFIG comes from .provider.attributes" { + mkdir -p "$TEST_PROVIDER_DIR" + + run bash -c "source $SCRIPT && echo CONFIG=\$PROVIDER_CONFIG" + + assert_equal "$status" "0" + assert_contains "$output" '"region":"us-east-1"' + assert_contains "$output" '"name_prefix":"parameters/"' } -@test "build_context: sources provider fetch_configuration when present" { - export PARAMETER_KIND="secret" - export SECRET_PROVIDER="test_provider" +@test "build_context: PROVIDER_CONFIG defaults to {} when attributes is missing" { + export CONTEXT=$(echo "$CONTEXT" | jq 'del(.provider.attributes)') mkdir -p "$TEST_PROVIDER_DIR" - echo 'export FETCH_RAN="yes"' > "$TEST_PROVIDER_DIR/fetch_configuration" - run bash -c "source $SCRIPT && echo FETCH=\$FETCH_RAN" + run bash -c "source $SCRIPT && echo CONFIG=\$PROVIDER_CONFIG" assert_equal "$status" "0" - assert_contains "$output" "FETCH=yes" + assert_contains "$output" "CONFIG={}" } @test "build_context: sources provider setup when present" { - export PARAMETER_KIND="secret" - export SECRET_PROVIDER="test_provider" mkdir -p "$TEST_PROVIDER_DIR" echo 'export SETUP_RAN="yes"' > "$TEST_PROVIDER_DIR/setup" @@ -146,22 +182,20 @@ teardown() { assert_contains "$output" "SETUP=yes" } -@test "build_context: sources fetch_configuration before setup" { - export PARAMETER_KIND="secret" - export SECRET_PROVIDER="test_provider" +@test "build_context: setup can read PROVIDER_CONFIG via get_config_value" { mkdir -p "$TEST_PROVIDER_DIR" - echo 'export ORDER="${ORDER:-}fetch,"' > "$TEST_PROVIDER_DIR/fetch_configuration" - echo 'export ORDER="${ORDER:-}setup"' > "$TEST_PROVIDER_DIR/setup" + cat > "$TEST_PROVIDER_DIR/setup" << 'EOF' +REGION=$(get_config_value --provider '.region') +export RESOLVED_REGION="$REGION" +EOF - run bash -c "source $SCRIPT && echo ORDER=\$ORDER" + run bash -c "source $SCRIPT && echo REGION=\$RESOLVED_REGION" assert_equal "$status" "0" - assert_contains "$output" "ORDER=fetch,setup" + assert_contains "$output" "REGION=us-east-1" } -@test "build_context: succeeds when provider has no fetch_configuration or setup" { - export PARAMETER_KIND="secret" - export SECRET_PROVIDER="test_provider" +@test "build_context: succeeds when provider has no setup" { mkdir -p "$TEST_PROVIDER_DIR" run bash -c "source $SCRIPT && echo PROV=\$ACTIVE_PROVIDER" @@ -171,8 +205,6 @@ teardown() { } @test "build_context: exports PROVIDER_DIR and PARAMETERS_ROOT" { - export PARAMETER_KIND="secret" - export SECRET_PROVIDER="test_provider" mkdir -p "$TEST_PROVIDER_DIR" run bash -c "source $SCRIPT && echo PD=\$PROVIDER_DIR ROOT=\$PARAMETERS_ROOT" @@ -181,19 +213,3 @@ teardown() { assert_contains "$output" "PD=$PARAMETERS_DIR/providers/test_provider" assert_contains "$output" "ROOT=$PARAMETERS_DIR" } - -@test "build_context: provider setup can read get_config_value with --provider" { - export PARAMETER_KIND="secret" - export SECRET_PROVIDER="test_provider" - export PROVIDER_CONFIG='{"address":"https://example.com"}' - mkdir -p "$TEST_PROVIDER_DIR" - cat > "$TEST_PROVIDER_DIR/setup" << 'EOF' -ADDR=$(get_config_value --provider '.address') -export RESOLVED_ADDR="$ADDR" -EOF - - run bash -c "source $SCRIPT && echo ADDR=\$RESOLVED_ADDR" - - assert_equal "$status" "0" - assert_contains "$output" "ADDR=https://example.com" -} From 24eceb4f131eaef3f294eed7584cd0e8e292f961 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Tue, 23 Jun 2026 14:00:19 -0300 Subject: [PATCH 03/41] feat(parameters): NRN+slug-based naming for external_id with parallel np fetches --- parameters/PENDING.md | 56 +--------- parameters/build_context | 2 +- parameters/providers/azure_key_vault/delete | 27 ++--- parameters/providers/azure_key_vault/retrieve | 12 +-- parameters/providers/azure_key_vault/store | 18 +++- parameters/providers/hashicorp_vault/store | 20 ++-- parameters/providers/parameter_store/store | 11 +- parameters/providers/secret_manager/store | 13 +-- parameters/tests/build_context.bats | 2 +- .../providers/azure_key_vault/store.bats | 74 +++++++++---- .../providers/hashicorp_vault/store.bats | 102 +++++++++++------- .../providers/parameter_store/store.bats | 67 +++++------- .../tests/providers/secret_manager/store.bats | 79 +++++++++----- parameters/utils/build_external_id | 77 +++++++++++++ 14 files changed, 321 insertions(+), 239 deletions(-) create mode 100755 parameters/utils/build_external_id diff --git a/parameters/PENDING.md b/parameters/PENDING.md index 6d3e985b..cd26d864 100644 --- a/parameters/PENDING.md +++ b/parameters/PENDING.md @@ -20,7 +20,7 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. | Decision doc para equipo | ✅ `aws-secret-manager-strategies.docx` (en root del repo) | | **Resolución de provider via `provider.specification_id`** | **✅ Implementado** (era pendiente, hecho hoy) | | **`PROVIDER_CONFIG` desde `provider.attributes`** | **✅ Implementado** (era pendiente como `fetch_configuration`, ahora viene en payload) | -| Naming NRN+slug-based | ⏳ Pendiente — ver "1. Refactor de naming" | +| Naming NRN+slug-based | ✅ Implementado (utils/build_external_id + 4 providers refactorizados) | | Rename `secret_manager` → `aws_secret_manager` | ⏳ Pendiente (opcional, no bloqueante) | --- @@ -44,59 +44,7 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. ## Pendiente -### 1. Refactor de naming a NRN+slugs+ids - -**Bloqueado por:** confirmar syntax exacta del `np` CLI para obtener slugs de entities por ID. - -**Hipótesis (a confirmar antes de implementar):** - -```bash -np organization get --id 1255165411 --output json -# → { "slug": "acme", "id": "1255165411", ... } -``` - -#### Diseño aprobado - -El `external_id` retornado a nullplatform (y por tanto el nombre del secret en cada provider) se compone así: - -``` -=-/=-/.../=/ -``` - -- Entities iteradas en orden NRN canónico: `organization → account → namespace → application → scope`. Solo se incluyen las presentes. -- Dimensiones (desde `$CONTEXT.dimensions`, top-level, no `provider.dimensions`) ordenadas alfabéticamente por key. -- `parameter_id` al final como identificador único. -- Slugs son inmutables en nullplatform (garantía del contrato), por lo que el external_id no sufre deriva. - -#### Ejemplo - -Con `entities = {organization: "1255165411", account: "95118862", namespace: "37094320", application: "321402625"}`, `dimensions = {environment: "development", country: "argentina"}`, `parameter_id = 359535238`: - -``` -organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/country=argentina/environment=development/359535238 -``` - -#### Pasos - -1. Crear `parameters/utils/build_external_id` con fetch paralelo de slugs vía `np` CLI (usando `mktemp` + `&` + `wait`). -2. Refactorizar `store` de los 4 providers: - - `hashicorp_vault/store`: nombre `secret/data/parameters/` - - `secret_manager/store`: nombre `` - - `parameter_store/store`: nombre `` - - `azure_key_vault/store`: AKV solo permite alfanumérico + `-`, transformar `/` → `-` y remover `=`. -3. `retrieve`/`delete`/`notify` NO cambian: usan el `EXTERNAL_ID` que llega de nullplatform. -4. Tests: mock de `np get` en `$BATS_TEST_TMPDIR/bin/`, expected paths actualizados. -5. Update de `parameters/providers//docs/architecture.md` con el nuevo naming. - -#### Edge cases (todos confirmados) - -- Entities siempre vienen (parte del contrato de nullplatform). -- `np` CLI siempre está disponible (instalado en la imagen Docker base del agente). -- Slugs inmutables — no hay riesgo de deriva o reconstrucción incorrecta. - ---- - -### 2. Rename `secret_manager` → `aws_secret_manager` (opcional) +### 1. Rename `secret_manager` → `aws_secret_manager` (opcional) Decisión tomada pero no aplicada. No bloqueante. Cuando se haga: diff --git a/parameters/build_context b/parameters/build_context index 192e8ee8..212cbdec 100755 --- a/parameters/build_context +++ b/parameters/build_context @@ -57,7 +57,7 @@ if [ -z "$SPEC_ID" ]; then exit 1 fi -if ! SPEC_JSON=$(np provider specification read --id "$SPEC_ID" --output json 2>&1); then +if ! SPEC_JSON=$(np provider specification read --id "$SPEC_ID" --format json 2>&1); then log error "❌ Failed to read provider specification (id=$SPEC_ID)" log error "" log error "💡 Possible causes:" diff --git a/parameters/providers/azure_key_vault/delete b/parameters/providers/azure_key_vault/delete index 79d8d8f1..0663cb97 100755 --- a/parameters/providers/azure_key_vault/delete +++ b/parameters/providers/azure_key_vault/delete @@ -1,27 +1,15 @@ #!/bin/bash set -euo pipefail -# Deletes a secret from Azure Key Vault. +# Deletes a secret from Azure Key Vault. Idempotent. +# Soft-delete + purge (purge is best-effort, downgraded to warning on failure). # -# AKV uses soft-delete by default (90-day retention). The flow is: -# 1. `delete` → moves to soft-deleted state (the user-facing "deleted" semantic) -# 2. `purge` → hard-deletes from soft-delete bin; releases the name immediately -# -# Idempotency semantics: -# - Successful delete → continue to purge -# - SecretNotFound on delete → success (already gone; idempotent) -# - Any other delete error → exit 1 with troubleshooting -# -# Purge is housekeeping (frees the name, stops retention billing). Failures -# during purge are downgraded to warnings: the user-facing delete contract is -# already satisfied by step 1. The secret stays in the soft-delete window -# (auto-cleaned at retention expiry). -# -# Required env: EXTERNAL_ID, AZ_VAULT_NAME, AZ_SECRET_PREFIX +# Required env: EXTERNAL_ID (canonical slash form), AZ_VAULT_NAME, AZ_SECRET_PREFIX -SECRET_NAME="${AZ_SECRET_PREFIX}${EXTERNAL_ID}" +# AKV stores with dashes (no `/` or `=`); transform from canonical form. +AKV_SUFFIX=$(echo "$EXTERNAL_ID" | tr '/=' '--') +SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" -# --- Step 1: soft-delete --- err_file=$(mktemp) if az keyvault secret delete \ --vault-name "$AZ_VAULT_NAME" \ @@ -45,13 +33,12 @@ else log error "" log error "🔧 How to fix:" log error " • Verify identity: az account show" - log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" log error "Underlying error: $err" exit 1 fi fi -# --- Step 2: purge (best-effort) --- +# Best-effort purge to release name and stop retention billing. purge_err=$(mktemp) if ! az keyvault secret purge \ --vault-name "$AZ_VAULT_NAME" \ diff --git a/parameters/providers/azure_key_vault/retrieve b/parameters/providers/azure_key_vault/retrieve index faf09730..e12a614f 100755 --- a/parameters/providers/azure_key_vault/retrieve +++ b/parameters/providers/azure_key_vault/retrieve @@ -3,14 +3,11 @@ set -euo pipefail # Retrieves a secret from Azure Key Vault by external_id. # -# Semantics: -# - Success → return {value: ""} -# - SecretNotFound → return {value: "value not found"} -# - Any other error → exit 1 with troubleshooting -# -# Required env: EXTERNAL_ID, AZ_VAULT_NAME, AZ_SECRET_PREFIX +# Required env: EXTERNAL_ID (canonical slash form), AZ_VAULT_NAME, AZ_SECRET_PREFIX -SECRET_NAME="${AZ_SECRET_PREFIX}${EXTERNAL_ID}" +# AKV stores with dashes (no `/` or `=`); transform from canonical form. +AKV_SUFFIX=$(echo "$EXTERNAL_ID" | tr '/=' '--') +SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" err_file=$(mktemp) if VALUE=$(az keyvault secret show \ @@ -36,7 +33,6 @@ else log error "" log error "🔧 How to fix:" log error " • Verify identity: az account show" - log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" log error "Underlying error: $err" exit 1 fi diff --git a/parameters/providers/azure_key_vault/store b/parameters/providers/azure_key_vault/store index 7c1ef2d1..4e744296 100755 --- a/parameters/providers/azure_key_vault/store +++ b/parameters/providers/azure_key_vault/store @@ -1,13 +1,21 @@ #!/bin/bash set -euo pipefail -# Stores a parameter value as an Azure Key Vault secret. -# AKV encrypts all secrets transparently — PARAMETER_KIND does not change behavior. +# Stores a parameter as an Azure Key Vault secret. +# external_id composed from entities + dimensions + parameter_id via build_external_id. +# AKV doesn't allow `/` or `=` in secret names, so the canonical EXTERNAL_ID is +# transformed (slash → dash, equals → dash) for the secret name. The external_id +# returned to nullplatform keeps the canonical slash form for cross-provider +# consistency. # # Required env: PARAMETER_VALUE, AZ_VAULT_NAME, AZ_SECRET_PREFIX -EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") -SECRET_NAME="${AZ_SECRET_PREFIX}${EXTERNAL_ID}" +source "$PARAMETERS_ROOT/utils/build_external_id" +build_external_id + +# AKV-safe name: replace / and = with - +AKV_SUFFIX=$(echo "$EXTERNAL_ID" | tr '/=' '--') +SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" if ! AKV_ID=$(az keyvault secret set \ --vault-name "$AZ_VAULT_NAME" \ @@ -20,7 +28,7 @@ if ! AKV_ID=$(az keyvault secret set \ log error "💡 Possible causes:" log error " • Identity lacks Set permission on $AZ_VAULT_NAME" log error " • Vault is in soft-deleted state or firewall blocks the caller" - log error " • Secret name '$SECRET_NAME' contains characters AKV rejects (only alphanumeric + dashes allowed)" + log error " • Secret name length exceeds AKV limit of 127 chars (current: ${#SECRET_NAME})" log error "" log error "🔧 How to fix:" log error " • Verify identity: az account show" diff --git a/parameters/providers/hashicorp_vault/store b/parameters/providers/hashicorp_vault/store index 20f1d176..5160b43a 100755 --- a/parameters/providers/hashicorp_vault/store +++ b/parameters/providers/hashicorp_vault/store @@ -2,12 +2,14 @@ set -euo pipefail # Stores a parameter value in HashiCorp Vault KV v2. -# Generates a fresh UUID as external_id (the canonical handle returned to nullplatform). +# external_id is composed from entities (with slugs fetched via np CLI) + +# dimensions + parameter_id. See parameters/utils/build_external_id. # -# Required env (exported by build_context + setup): -# PARAMETER_ID, PARAMETER_VALUE, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX +# Required env: PARAMETER_ID, PARAMETER_VALUE, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX + +source "$PARAMETERS_ROOT/utils/build_external_id" +build_external_id -EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" if ! curl -s -X POST \ @@ -27,9 +29,7 @@ if ! curl -s -X POST \ exit 1 fi -echo '{ - "external_id": "'$EXTERNAL_ID'", - "metadata": { - "vault_path": "'$VAULT_PATH'" - } -}' +jq -n \ + --arg external_id "$EXTERNAL_ID" \ + --arg vault_path "$VAULT_PATH" \ + '{external_id: $external_id, metadata: {vault_path: $vault_path}}' diff --git a/parameters/providers/parameter_store/store b/parameters/providers/parameter_store/store index 17dedd92..920af5b9 100755 --- a/parameters/providers/parameter_store/store +++ b/parameters/providers/parameter_store/store @@ -1,14 +1,16 @@ #!/bin/bash set -euo pipefail -# Stores a parameter in AWS Systems Manager Parameter Store. -# - kind=secret → Type=SecureString (encrypted via KMS_KEY_ID or default aws/ssm) -# - kind=parameter → Type=String (plain text) +# Stores a parameter in AWS SSM Parameter Store. +# external_id composed from entities + dimensions + parameter_id via build_external_id. +# Type=SecureString for kind=secret, Type=String for kind=parameter. # # Required env: PARAMETER_KIND, PARAMETER_VALUE, AWS_REGION, PS_NAME_PREFIX, PS_TIER # Optional env: PS_KMS_KEY_ID -EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") +source "$PARAMETERS_ROOT/utils/build_external_id" +build_external_id + PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" SSM_TYPE="String" @@ -30,7 +32,6 @@ if ! aws ssm put-parameter "${put_args[@]}" >/dev/null 2>&1; then log error "" log error "💡 Possible causes:" log error " • IAM principal lacks ssm:PutParameter for $PARAM_NAME" - log error " • A parameter with this name already exists (UUID collision — extremely unlikely)" log error " • Tier '$PS_TIER' rejects this value size (Standard caps at 4KB)" if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then log error " • IAM principal lacks kms:Encrypt on $PS_KMS_KEY_ID" diff --git a/parameters/providers/secret_manager/store b/parameters/providers/secret_manager/store index d9295459..09a7e7f0 100755 --- a/parameters/providers/secret_manager/store +++ b/parameters/providers/secret_manager/store @@ -1,13 +1,15 @@ #!/bin/bash set -euo pipefail -# Stores a parameter value as an AWS Secrets Manager secret. -# One secret per parameter (each ~$0.40/month). External_id is a fresh UUIDv4. +# Stores a parameter as an AWS Secrets Manager secret. +# external_id composed from entities + dimensions + parameter_id via build_external_id. # # Required env: PARAMETER_ID, PARAMETER_VALUE, AWS_REGION, SM_NAME_PREFIX -# Optional env: SM_KMS_KEY_ID (uses default aws/secretsmanager key when empty) +# Optional env: SM_KMS_KEY_ID + +source "$PARAMETERS_ROOT/utils/build_external_id" +build_external_id -EXTERNAL_ID=$(uuidgen 2>/dev/null || echo "$(openssl rand -hex 16 | sed 's/\(.{8}\)\(.{4}\)\(.{4}\)\(.{4}\)\(.{12}\)/\1-\2-\3-\4-\5/')") SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" SECRET_PAYLOAD=$(jq -n \ @@ -33,12 +35,11 @@ if ! SECRET_ARN=$(aws secretsmanager create-secret "${create_args[@]}" 2>/dev/nu log error "" log error "💡 Possible causes:" log error " • IAM principal lacks secretsmanager:CreateSecret for $SECRET_NAME" - log error " • Same-name secret is in soft-delete window (change prefix or wait)" + log error " • Same-name secret is in soft-delete window" log error " • Region '$AWS_REGION' unreachable" log error "" log error "🔧 How to fix:" log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" - log error " • Check policy: aws iam simulate-principal-policy --action-names secretsmanager:CreateSecret" exit 1 fi diff --git a/parameters/tests/build_context.bats b/parameters/tests/build_context.bats index 6cb43667..26e93275 100644 --- a/parameters/tests/build_context.bats +++ b/parameters/tests/build_context.bats @@ -116,7 +116,7 @@ teardown() { captured=$(cat "$NP_LOG") assert_contains "$captured" "provider specification read" assert_contains "$captured" "--id ec885dd0-7c38-45b8-af2c-0b9e1deb7d3d" - assert_contains "$captured" "--output json" + assert_contains "$captured" "--format json" } @test "build_context: fails when specification_id is missing" { diff --git a/parameters/tests/providers/azure_key_vault/store.bats b/parameters/tests/providers/azure_key_vault/store.bats index 24ef09e3..f0682643 100644 --- a/parameters/tests/providers/azure_key_vault/store.bats +++ b/parameters/tests/providers/azure_key_vault/store.bats @@ -1,29 +1,39 @@ #!/usr/bin/env bats # ============================================================================= # Unit tests for parameters/providers/azure_key_vault/store +# AKV transforms / and = to - in the secret name (canonical form has slashes). # ============================================================================= setup() { export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_ROOT="$PARAMETERS_DIR" source "$PROJECT_ROOT/testing/assertions.sh" export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/store" mkdir -p "$BATS_TEST_TMPDIR/bin" - cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' + + cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' #!/bin/bash -echo "fixed-akv-uuid" +entity_type="$1" +case "$entity_type" in + organization) echo "\"acme\"" ;; + account) echo "\"prod\"" ;; + namespace) echo "\"billing\"" ;; + application) echo "\"api\"" ;; + *) echo "\"unknown\"" ;; +esac EOF - chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + chmod +x "$BATS_TEST_TMPDIR/bin/np" export AZ_LOG="$BATS_TEST_TMPDIR/az.log" cat > "$BATS_TEST_TMPDIR/bin/az" << EOF #!/bin/bash echo "ARGS: \$@" >> "$AZ_LOG" if [ "\${MOCK_AZ_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AZ_EXIT; fi -echo "https://my-vault.vault.azure.net/secrets/parameters-fixed-akv-uuid/abc123def456" +echo "https://my-vault.vault.azure.net/secrets/some-name/abc123" EOF chmod +x "$BATS_TEST_TMPDIR/bin/az" @@ -31,48 +41,70 @@ EOF export AZ_VAULT_NAME="my-vault" export AZ_SECRET_PREFIX="parameters-" - export PARAMETER_VALUE="my-secret-value" + export PARAMETER_VALUE="my-secret" + export CONTEXT='{ + "parameter_id": 42, + "value": "my-secret", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": {} + }' export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "azure_key_vault store: outputs external_id and metadata" { +@test "azure_key_vault store: external_id is canonical slash form" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + assert_equal "$external_id" "$expected" +} + +@test "azure_key_vault store: secret_name uses dashes (AKV-safe)" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" secret_name=$(echo "$output" | jq -r '.metadata.secret_name') - azure_secret_id=$(echo "$output" | jq -r '.metadata.azure_secret_id') - vault_name=$(echo "$output" | jq -r '.metadata.vault_name') - assert_equal "$external_id" "fixed-akv-uuid" - assert_equal "$secret_name" "parameters-fixed-akv-uuid" - assert_contains "$azure_secret_id" "vault.azure.net/secrets/parameters-fixed-akv-uuid" - assert_equal "$vault_name" "my-vault" + # AKV: / and = both become - + assert_contains "$secret_name" "parameters-organization-acme-1255165411-account-prod-95118862" + assert_contains "$secret_name" "-42" + # Must not contain / or = + [[ "$secret_name" != *"/"* ]] + [[ "$secret_name" != *"="* ]] } -@test "azure_key_vault store: calls az keyvault secret set" { +@test "azure_key_vault store: calls az with AKV-safe name" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AZ_LOG") assert_contains "$captured" "keyvault secret set" assert_contains "$captured" "--vault-name my-vault" - assert_contains "$captured" "--name parameters-fixed-akv-uuid" - assert_contains "$captured" "--value my-secret-value" + assert_contains "$captured" "--name parameters-organization-acme-1255165411" + assert_contains "$captured" "--value my-secret" } -@test "azure_key_vault store: honors custom AZ_SECRET_PREFIX" { - export AZ_SECRET_PREFIX="app-prod-" +@test "azure_key_vault store: dimensions sorted alphabetically in external_id" { + export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') run bash -c "$DEPS; source $SCRIPT" - captured=$(cat "$AZ_LOG") - assert_contains "$captured" "--name app-prod-fixed-akv-uuid" + external_id=$(echo "$output" | jq -r '.external_id') + assert_contains "$external_id" "country=arg/environment=prod/42" + + # AKV transformed name should have dashes + secret_name=$(echo "$output" | jq -r '.metadata.secret_name') + assert_contains "$secret_name" "country-arg-environment-prod-42" } @test "azure_key_vault store: fails with troubleshooting on az error" { run bash -c "$DEPS; MOCK_AZ_EXIT=1 source $SCRIPT" [ "$status" -ne 0 ] - assert_contains "$output" "❌ Failed to store secret in Azure Key Vault 'my-vault'" - assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "❌ Failed to store secret in Azure Key Vault" } diff --git a/parameters/tests/providers/hashicorp_vault/store.bats b/parameters/tests/providers/hashicorp_vault/store.bats index d7a50025..848df402 100644 --- a/parameters/tests/providers/hashicorp_vault/store.bats +++ b/parameters/tests/providers/hashicorp_vault/store.bats @@ -1,27 +1,37 @@ #!/usr/bin/env bats # ============================================================================= # Unit tests for parameters/providers/hashicorp_vault/store -# Verifies HTTP request shape AND JSON output remain byte-compatible with -# the previous parameters/vault/store implementation. +# external_id is now composed via parameters/utils/build_external_id. # ============================================================================= setup() { export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_ROOT="$PARAMETERS_DIR" source "$PROJECT_ROOT/testing/assertions.sh" export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/store" - # Mock uuidgen for deterministic external_id mkdir -p "$BATS_TEST_TMPDIR/bin" - cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' + + # Mock np CLI: handles ` read --id --format json --query .slug` + cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' #!/bin/bash -echo "fixed-test-uuid" +# Args: read --id --format json --query .slug +entity_type="$1" +case "$entity_type" in + organization) echo "\"acme\"" ;; + account) echo "\"prod\"" ;; + namespace) echo "\"billing\"" ;; + application) echo "\"api\"" ;; + scope) echo "\"main\"" ;; + *) echo "\"unknown\"" ;; +esac EOF - chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + chmod +x "$BATS_TEST_TMPDIR/bin/np" - # Mock curl: capture args to file, return success by default + # Mock curl export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" cat > "$BATS_TEST_TMPDIR/bin/curl" << EOF #!/bin/bash @@ -32,79 +42,89 @@ EOF export PATH="$BATS_TEST_TMPDIR/bin:$PATH" - # Defaults from setup() — operation script assumes these are present export VAULT_ADDR="https://vault.example.com" export VAULT_TOKEN="hvs.test-token" export VAULT_PATH_PREFIX="secret/data/parameters" export PARAMETER_ID=42 - export PARAMETER_VALUE="my-super-secret" + export PARAMETER_VALUE="my-secret" + export CONTEXT='{ + "parameter_id": 42, + "value": "my-secret", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": {} + }' export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "vault store: outputs JSON with external_id and vault_path metadata" { +@test "vault store: external_id composed from entities + parameter_id" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" - # Parse with jq to be robust against whitespace external_id=$(echo "$output" | jq -r '.external_id') - vault_path=$(echo "$output" | jq -r '.metadata.vault_path') - assert_equal "$external_id" "fixed-test-uuid" - assert_equal "$vault_path" "secret/data/parameters/fixed-test-uuid" + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + assert_equal "$external_id" "$expected" +} + +@test "vault store: external_id includes sorted dimensions" { + export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + # Dimensions sorted alphabetically: country before environment + assert_contains "$external_id" "country=arg/environment=prod/42" } -@test "vault store: POSTs to correct Vault URL with token header" { +@test "vault store: vault_path contains external_id" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" + vault_path=$(echo "$output" | jq -r '.metadata.vault_path') + assert_contains "$vault_path" "secret/data/parameters/organization=acme-1255165411" + assert_contains "$vault_path" "/42" +} + +@test "vault store: POSTs to Vault URL with token" { + run bash -c "$DEPS; source $SCRIPT" + captured=$(cat "$CURL_LOG") assert_contains "$captured" "-X POST" assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" - assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/fixed-test-uuid" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/organization=acme-1255165411" } @test "vault store: POST body contains parameter_id, value, external_id, stored_at" { run bash -c "$DEPS; source $SCRIPT" - assert_equal "$status" "0" captured=$(cat "$CURL_LOG") assert_contains "$captured" '"parameter_id":42' - assert_contains "$captured" '"value":"my-super-secret"' - assert_contains "$captured" '"external_id":"fixed-test-uuid"' + assert_contains "$captured" '"value":"my-secret"' + assert_contains "$captured" '"external_id":"organization=acme-1255165411' assert_contains "$captured" '"stored_at":"' } @test "vault store: fails with troubleshooting when curl returns non-zero" { - export MOCK_CURL_EXIT=22 - run bash -c "$DEPS; MOCK_CURL_EXIT=22 source $SCRIPT" [ "$status" -ne 0 ] - assert_contains "$output" "❌ Failed to store parameter in Vault at https://vault.example.com" + assert_contains "$output" "❌ Failed to store parameter in Vault" assert_contains "$output" "💡 Possible causes:" - assert_contains "$output" "🔧 How to fix:" } -@test "vault store: honors custom VAULT_PATH_PREFIX" { - export VAULT_PATH_PREFIX="kv/data/custom-mount" +@test "vault store: works without dimensions" { + export CONTEXT=$(echo "$CONTEXT" | jq 'del(.dimensions)') run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" - vault_path=$(echo "$output" | jq -r '.metadata.vault_path') - assert_equal "$vault_path" "kv/data/custom-mount/fixed-test-uuid" - - captured=$(cat "$CURL_LOG") - assert_contains "$captured" "https://vault.example.com/v1/kv/data/custom-mount/fixed-test-uuid" -} - -@test "vault store: jq-escapes the value so quotes inside don't break the body" { - export PARAMETER_VALUE='val"with"quotes' - - run bash -c "$DEPS; source $SCRIPT" - - assert_equal "$status" "0" - captured=$(cat "$CURL_LOG") - # jq -R turns the literal value into "val\"with\"quotes" — escaped - assert_contains "$captured" 'val\"with\"quotes' + external_id=$(echo "$output" | jq -r '.external_id') + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + assert_equal "$external_id" "$expected" } diff --git a/parameters/tests/providers/parameter_store/store.bats b/parameters/tests/providers/parameter_store/store.bats index eda9e63e..2f8ef4cf 100644 --- a/parameters/tests/providers/parameter_store/store.bats +++ b/parameters/tests/providers/parameter_store/store.bats @@ -6,17 +6,26 @@ setup() { export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_ROOT="$PARAMETERS_DIR" source "$PROJECT_ROOT/testing/assertions.sh" export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/store" mkdir -p "$BATS_TEST_TMPDIR/bin" - cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' + + cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' #!/bin/bash -echo "fixed-ps-uuid" +entity_type="$1" +case "$entity_type" in + organization) echo "\"acme\"" ;; + account) echo "\"prod\"" ;; + namespace) echo "\"billing\"" ;; + application) echo "\"api\"" ;; + *) echo "\"unknown\"" ;; +esac EOF - chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + chmod +x "$BATS_TEST_TMPDIR/bin/np" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF @@ -34,22 +43,30 @@ EOF export PS_TIER="Standard" export PARAMETER_ID=42 export PARAMETER_VALUE="my-value" + export CONTEXT='{ + "parameter_id": 42, + "value": "my-value", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": {} + }' export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "parameter_store store: outputs external_id and metadata" { +@test "parameter_store store: external_id composed from entities + parameter_id" { export PARAMETER_KIND="parameter" run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') - parameter_name=$(echo "$output" | jq -r '.metadata.parameter_name') - type=$(echo "$output" | jq -r '.metadata.type') - assert_equal "$external_id" "fixed-ps-uuid" - assert_equal "$parameter_name" "/nullplatform/parameters/fixed-ps-uuid" - assert_equal "$type" "String" + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + assert_equal "$external_id" "$expected" } @test "parameter_store store: kind=secret uses SecureString" { @@ -60,7 +77,6 @@ EOF assert_equal "$status" "0" type=$(echo "$output" | jq -r '.metadata.type') assert_equal "$type" "SecureString" - captured=$(cat "$AWS_LOG") assert_contains "$captured" "--type SecureString" } @@ -70,7 +86,6 @@ EOF run bash -c "$DEPS; source $SCRIPT" - assert_equal "$status" "0" captured=$(cat "$AWS_LOG") assert_contains "$captured" "--type String" [[ "$captured" != *"SecureString"* ]] @@ -86,24 +101,13 @@ EOF assert_contains "$captured" "--key-id alias/parameters-secure" } -@test "parameter_store store: omits --key-id when PS_KMS_KEY_ID is empty (uses default aws/ssm)" { - export PARAMETER_KIND="secret" - export PS_KMS_KEY_ID="" - - run bash -c "$DEPS; source $SCRIPT" - - captured=$(cat "$AWS_LOG") - [[ "$captured" != *"--key-id"* ]] -} - -@test "parameter_store store: never includes --key-id for String (kind=parameter)" { +@test "parameter_store store: parameter_name has PS_NAME_PREFIX + composite" { export PARAMETER_KIND="parameter" - export PS_KMS_KEY_ID="alias/should-not-be-used" run bash -c "$DEPS; source $SCRIPT" - captured=$(cat "$AWS_LOG") - [[ "$captured" != *"--key-id"* ]] + param_name=$(echo "$output" | jq -r '.metadata.parameter_name') + assert_contains "$param_name" "/nullplatform/parameters/organization=acme-1255165411" } @test "parameter_store store: passes tier flag" { @@ -116,18 +120,6 @@ EOF assert_contains "$captured" "--tier Advanced" } -@test "parameter_store store: calls put-parameter with name and value" { - export PARAMETER_KIND="parameter" - - run bash -c "$DEPS; source $SCRIPT" - - captured=$(cat "$AWS_LOG") - assert_contains "$captured" "ssm put-parameter" - assert_contains "$captured" "--region us-east-1" - assert_contains "$captured" "--name /nullplatform/parameters/fixed-ps-uuid" - assert_contains "$captured" "--value my-value" -} - @test "parameter_store store: fails with troubleshooting on aws error" { export PARAMETER_KIND="parameter" @@ -135,5 +127,4 @@ EOF [ "$status" -ne 0 ] assert_contains "$output" "❌ Failed to store parameter in AWS Parameter Store" - assert_contains "$output" "💡 Possible causes:" } diff --git a/parameters/tests/providers/secret_manager/store.bats b/parameters/tests/providers/secret_manager/store.bats index 8f8612bf..af3f4b13 100644 --- a/parameters/tests/providers/secret_manager/store.bats +++ b/parameters/tests/providers/secret_manager/store.bats @@ -6,25 +6,34 @@ setup() { export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../../.." && pwd)" export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_ROOT="$PARAMETERS_DIR" source "$PROJECT_ROOT/testing/assertions.sh" export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/store" mkdir -p "$BATS_TEST_TMPDIR/bin" - cat > "$BATS_TEST_TMPDIR/bin/uuidgen" << 'EOF' + + # Mock np CLI for entity slug fetches + cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' #!/bin/bash -echo "fixed-sm-uuid" +entity_type="$1" +case "$entity_type" in + organization) echo "\"acme\"" ;; + account) echo "\"prod\"" ;; + namespace) echo "\"billing\"" ;; + application) echo "\"api\"" ;; + *) echo "\"unknown\"" ;; +esac EOF - chmod +x "$BATS_TEST_TMPDIR/bin/uuidgen" + chmod +x "$BATS_TEST_TMPDIR/bin/np" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF #!/bin/bash echo "ARGS: \$@" >> "$AWS_LOG" if [ "\${MOCK_AWS_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AWS_EXIT; fi -# create-secret returns ARN -echo "arn:aws:secretsmanager:us-east-1:111122223333:secret:parameters/fixed-sm-uuid-AbCdEf" +echo "arn:aws:secretsmanager:us-east-1:111122223333:secret:test-AbCdEf" EOF chmod +x "$BATS_TEST_TMPDIR/bin/aws" @@ -35,34 +44,48 @@ EOF export SM_KMS_KEY_ID="" export PARAMETER_ID=42 export PARAMETER_VALUE="my-secret" + export CONTEXT='{ + "parameter_id": 42, + "value": "my-secret", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "dimensions": {} + }' export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "secret_manager store: outputs external_id and metadata" { +@test "secret_manager store: external_id is composite of entities + parameter_id" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + assert_equal "$external_id" "$expected" +} + +@test "secret_manager store: secret_name has prefix + composite" { + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" secret_name=$(echo "$output" | jq -r '.metadata.secret_name') - secret_arn=$(echo "$output" | jq -r '.metadata.secret_arn') - region=$(echo "$output" | jq -r '.metadata.region') - assert_equal "$external_id" "fixed-sm-uuid" - assert_equal "$secret_name" "parameters/fixed-sm-uuid" - assert_contains "$secret_arn" "arn:aws:secretsmanager" - assert_equal "$region" "us-east-1" + assert_contains "$secret_name" "parameters/organization=acme-1255165411" } -@test "secret_manager store: calls aws secretsmanager create-secret with correct args" { +@test "secret_manager store: calls aws with composite name" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") assert_contains "$captured" "secretsmanager create-secret" assert_contains "$captured" "--region us-east-1" - assert_contains "$captured" "--name parameters/fixed-sm-uuid" + assert_contains "$captured" "--name parameters/organization=acme-1255165411" } -@test "secret_manager store: includes --kms-key-id when SM_KMS_KEY_ID is set" { +@test "secret_manager store: includes --kms-key-id when SM_KMS_KEY_ID set" { export SM_KMS_KEY_ID="alias/my-key" run bash -c "$DEPS; source $SCRIPT" @@ -71,28 +94,26 @@ EOF assert_contains "$captured" "--kms-key-id alias/my-key" } -@test "secret_manager store: omits --kms-key-id when SM_KMS_KEY_ID is empty" { - export SM_KMS_KEY_ID="" - +@test "secret_manager store: omits --kms-key-id when SM_KMS_KEY_ID empty" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") [[ "$captured" != *"--kms-key-id"* ]] } -@test "secret_manager store: fails with troubleshooting when aws CLI fails" { - run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" +@test "secret_manager store: dimensions sorted alphabetically in external_id" { + export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') - [ "$status" -ne 0 ] - assert_contains "$output" "❌ Failed to store parameter in AWS Secrets Manager" - assert_contains "$output" "💡 Possible causes:" -} + run bash -c "$DEPS; source $SCRIPT" -@test "secret_manager store: honors custom SM_NAME_PREFIX" { - export SM_NAME_PREFIX="custom-prefix/sub/" + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + assert_contains "$external_id" "country=arg/environment=prod/42" +} - run bash -c "$DEPS; source $SCRIPT" +@test "secret_manager store: fails with troubleshooting on aws error" { + run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" - captured=$(cat "$AWS_LOG") - assert_contains "$captured" "--name custom-prefix/sub/fixed-sm-uuid" + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in AWS Secrets Manager" } diff --git a/parameters/utils/build_external_id b/parameters/utils/build_external_id new file mode 100755 index 00000000..7645f009 --- /dev/null +++ b/parameters/utils/build_external_id @@ -0,0 +1,77 @@ +#!/bin/bash + +# Constructs EXTERNAL_ID for a store operation by composing: +# =-/.../=/ +# +# Slugs are fetched in parallel via `np read --id --format json --query '.slug'`. +# Entities are iterated in canonical NRN order; only present entities are included. +# Dimensions are sorted alphabetically by key for determinism. +# +# Slugs in nullplatform are immutable (contract guarantee), so the resulting +# EXTERNAL_ID is stable for the lifetime of the parameter. +# +# Requires: np CLI in PATH, jq, mktemp +# Reads: CONTEXT (set by entrypoint), log function in scope +# Exports: EXTERNAL_ID (slash-separated canonical form) +# +# Each provider's store decides how to use EXTERNAL_ID: +# - vault/SM/PS: append to a prefix that ends with `/` +# - AKV: replace `/` and `=` with `-` (AKV doesn't allow those chars) + +build_external_id() { + local entities_json + entities_json=$(echo "$CONTEXT" | jq -c '.entities // {}') + + local tmp_dir + tmp_dir=$(mktemp -d) + + local entity_order=("organization" "account" "namespace" "application" "scope") + local present=() + + for entity_type in "${entity_order[@]}"; do + local entity_id + entity_id=$(echo "$entities_json" | jq -r ".$entity_type // empty") + if [ -n "$entity_id" ]; then + present+=("$entity_type:$entity_id") + ( np "$entity_type" read --id "$entity_id" --format json --query '.slug' 2>/dev/null \ + | jq -r '. // empty' > "$tmp_dir/$entity_type" || true ) & + fi + done + wait + + local segments=() + for pair in "${present[@]}"; do + local entity_type="${pair%%:*}" + local entity_id="${pair##*:}" + if [ ! -s "$tmp_dir/$entity_type" ]; then + log error "❌ Failed to fetch slug for $entity_type=$entity_id via np CLI" + log error "" + log error "💡 Possible causes:" + log error " • Entity does not exist in nullplatform" + log error " • np CLI is not authenticated" + log error " • Entity type '$entity_type' is not supported by np CLI" + log error "" + log error "🔧 How to fix:" + log error " • Verify: np $entity_type read --id $entity_id --format json" + rm -rf "$tmp_dir" + exit 1 + fi + local slug + slug=$(cat "$tmp_dir/$entity_type") + segments+=("$entity_type=${slug}-${entity_id}") + done + + while IFS= read -r dim; do + [ -n "$dim" ] && segments+=("$dim") + done < <(echo "$CONTEXT" | jq -r '(.dimensions // {}) | to_entries | sort_by(.key) | .[] | "\(.key)=\(.value)"') + + local param_id + param_id=$(echo "$CONTEXT" | jq -r '.parameter_id // empty') + [ -n "$param_id" ] && segments+=("$param_id") + + rm -rf "$tmp_dir" + + local IFS=/ + EXTERNAL_ID="${segments[*]}" + export EXTERNAL_ID +} From 6dc071e2462e9db19d9e2defda4374aa28cb9730 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Tue, 23 Jun 2026 14:22:58 -0300 Subject: [PATCH 04/41] refactor(parameters): rename secret_manager provider to aws_secret_manager --- parameters/PENDING.md | 16 +++++----------- parameters/docs/adding_a_provider.md | 2 +- parameters/docs/architecture.md | 2 +- parameters/docs/configuration.md | 2 +- parameters/providers/README.md | 2 +- .../delete | 0 .../docs/architecture.md | 2 +- .../docs/iam-policy.md | 2 +- .../retrieve | 0 .../setup | 6 +++--- .../store | 0 .../azure_key_vault/docs/architecture.md | 2 +- .../parameter_store/docs/architecture.md | 2 +- .../delete.bats | 14 +++++++------- .../retrieve.bats | 14 +++++++------- .../setup.bats | 16 ++++++++-------- .../store.bats | 18 +++++++++--------- 17 files changed, 47 insertions(+), 53 deletions(-) rename parameters/providers/{secret_manager => aws_secret_manager}/delete (100%) rename parameters/providers/{secret_manager => aws_secret_manager}/docs/architecture.md (97%) rename parameters/providers/{secret_manager => aws_secret_manager}/docs/iam-policy.md (97%) rename parameters/providers/{secret_manager => aws_secret_manager}/retrieve (100%) rename parameters/providers/{secret_manager => aws_secret_manager}/setup (86%) rename parameters/providers/{secret_manager => aws_secret_manager}/store (100%) rename parameters/tests/providers/{secret_manager => aws_secret_manager}/delete.bats (81%) rename parameters/tests/providers/{secret_manager => aws_secret_manager}/retrieve.bats (81%) rename parameters/tests/providers/{secret_manager => aws_secret_manager}/setup.bats (75%) rename parameters/tests/providers/{secret_manager => aws_secret_manager}/store.bats (82%) diff --git a/parameters/PENDING.md b/parameters/PENDING.md index cd26d864..f98303d0 100644 --- a/parameters/PENDING.md +++ b/parameters/PENDING.md @@ -10,7 +10,7 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. |---|---| | Skeleton (entrypoint, build_context, dispatch, utils, workflows) | ✅ Implementado | | Provider `hashicorp_vault` | ✅ Implementado | -| Provider `secret_manager` | ✅ Implementado (renombre a `aws_secret_manager` pendiente) | +| Provider `aws_secret_manager` | ✅ Implementado | | Provider `parameter_store` | ✅ Implementado | | Provider `azure_key_vault` | ✅ Implementado | | Error handling (not_found → idempotent, otros → fail loud) | ✅ Aplicado a deletes y retrieves | @@ -21,7 +21,7 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. | **Resolución de provider via `provider.specification_id`** | **✅ Implementado** (era pendiente, hecho hoy) | | **`PROVIDER_CONFIG` desde `provider.attributes`** | **✅ Implementado** (era pendiente como `fetch_configuration`, ahora viene en payload) | | Naming NRN+slug-based | ✅ Implementado (utils/build_external_id + 4 providers refactorizados) | -| Rename `secret_manager` → `aws_secret_manager` | ⏳ Pendiente (opcional, no bloqueante) | +| Rename `secret_manager` → `aws_secret_manager` | ✅ Implementado | --- @@ -44,13 +44,7 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. ## Pendiente -### 1. Rename `secret_manager` → `aws_secret_manager` (opcional) - -Decisión tomada pero no aplicada. No bloqueante. Cuando se haga: - -- Mover `parameters/providers/secret_manager/` → `parameters/providers/aws_secret_manager/` -- Update referencias en docs -- Update tests en `parameters/tests/providers/secret_manager/` (mover y renombrar) +Sin items pendientes a la fecha. Todas las decisiones aprobadas están implementadas. --- @@ -116,7 +110,7 @@ Distribución actual (151 tests): - Skeleton (entrypoint, build_context, dispatch, utils): 56 tests - hashicorp_vault: 27 tests -- secret_manager: 17 tests +- aws_secret_manager: 17 tests (renombrado desde `secret_manager`) - parameter_store: 23 tests - azure_key_vault: 15 tests - utils/log + utils/get_config_value: 13 tests @@ -137,7 +131,7 @@ parameters/ ├── providers/ │ ├── README.md # contrato del provider │ ├── hashicorp_vault/ -│ ├── secret_manager/ +│ ├── aws_secret_manager/ │ ├── parameter_store/ │ └── azure_key_vault/ ├── tests/ # 151 BATS tests diff --git a/parameters/docs/adding_a_provider.md b/parameters/docs/adding_a_provider.md index 108be4cd..a977f836 100644 --- a/parameters/docs/adding_a_provider.md +++ b/parameters/docs/adding_a_provider.md @@ -166,7 +166,7 @@ tests/providers// └── delete.bats # Always-success, CLI args, idempotency ``` -Use the patterns from existing providers (`hashicorp_vault`, `secret_manager`, `parameter_store`, `azure_key_vault`): +Use the patterns from existing providers (`hashicorp_vault`, `aws_secret_manager`, `parameter_store`, `azure_key_vault`): - Mock the backend CLI as a script in `$BATS_TEST_TMPDIR/bin/`, export PATH to find it. - Capture CLI args to a log file, assert on them. diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md index 90fa626e..93d1eeb0 100644 --- a/parameters/docs/architecture.md +++ b/parameters/docs/architecture.md @@ -106,7 +106,7 @@ parameters/ ├── providers/ │ ├── README.md # Contract every provider must satisfy │ ├── hashicorp_vault/ # HTTP API -│ ├── secret_manager/ # aws CLI +│ ├── aws_secret_manager/ # aws CLI │ ├── parameter_store/ # aws CLI (only kind-branching provider) │ └── azure_key_vault/ # az CLI ├── tests/ # BATS — mirrors source structure diff --git a/parameters/docs/configuration.md b/parameters/docs/configuration.md index b2fc9a95..8c8d909d 100644 --- a/parameters/docs/configuration.md +++ b/parameters/docs/configuration.md @@ -83,7 +83,7 @@ The shape of `$CONTEXT.provider.attributes` for each provider: } ``` -### `aws_secret_manager` (currently named `secret_manager`) +### `aws_secret_manager` (currently named `aws_secret_manager`) ```json { diff --git a/parameters/providers/README.md b/parameters/providers/README.md index 156c0c97..e783256b 100644 --- a/parameters/providers/README.md +++ b/parameters/providers/README.md @@ -148,7 +148,7 @@ Output: { "value": "" } ``` -If not found, return `{"value": "value not found"}` rather than erroring (precedent: existing vault/secret_manager impls). +If not found, return `{"value": "value not found"}` rather than erroring (precedent: existing vault/aws_secret_manager impls). ### `delete` — required diff --git a/parameters/providers/secret_manager/delete b/parameters/providers/aws_secret_manager/delete similarity index 100% rename from parameters/providers/secret_manager/delete rename to parameters/providers/aws_secret_manager/delete diff --git a/parameters/providers/secret_manager/docs/architecture.md b/parameters/providers/aws_secret_manager/docs/architecture.md similarity index 97% rename from parameters/providers/secret_manager/docs/architecture.md rename to parameters/providers/aws_secret_manager/docs/architecture.md index 78b5da7e..4ff64fce 100644 --- a/parameters/providers/secret_manager/docs/architecture.md +++ b/parameters/providers/aws_secret_manager/docs/architecture.md @@ -1,6 +1,6 @@ # AWS Secrets Manager — Architecture -This document describes how the `parameters/providers/secret_manager/` provider stores, retrieves, and deletes nullplatform parameters using AWS Secrets Manager (SM), and how it differs from clear-text parameters. +This document describes how the `parameters/providers/aws_secret_manager/` provider stores, retrieves, and deletes nullplatform parameters using AWS Secrets Manager (SM), and how it differs from clear-text parameters. --- diff --git a/parameters/providers/secret_manager/docs/iam-policy.md b/parameters/providers/aws_secret_manager/docs/iam-policy.md similarity index 97% rename from parameters/providers/secret_manager/docs/iam-policy.md rename to parameters/providers/aws_secret_manager/docs/iam-policy.md index f10e3ed7..53e86b7b 100644 --- a/parameters/providers/secret_manager/docs/iam-policy.md +++ b/parameters/providers/aws_secret_manager/docs/iam-policy.md @@ -1,6 +1,6 @@ # IAM Policy — Least Privilege -This document specifies the minimum IAM permissions required to operate the `parameters/providers/secret_manager/` provider. The policy is scoped to the `parameters/*` namespace and avoids account-wide wildcards. +This document specifies the minimum IAM permissions required to operate the `parameters/providers/aws_secret_manager/` provider. The policy is scoped to the `parameters/*` namespace and avoids account-wide wildcards. --- diff --git a/parameters/providers/secret_manager/retrieve b/parameters/providers/aws_secret_manager/retrieve similarity index 100% rename from parameters/providers/secret_manager/retrieve rename to parameters/providers/aws_secret_manager/retrieve diff --git a/parameters/providers/secret_manager/setup b/parameters/providers/aws_secret_manager/setup similarity index 86% rename from parameters/providers/secret_manager/setup rename to parameters/providers/aws_secret_manager/setup index b1f898ca..8b68c8ab 100755 --- a/parameters/providers/secret_manager/setup +++ b/parameters/providers/aws_secret_manager/setup @@ -23,15 +23,15 @@ SM_KMS_KEY_ID=$(get_config_value \ --default '') if [ -z "$AWS_REGION" ]; then - log error "❌ AWS region not configured for secret_manager" + log error "❌ AWS region not configured for aws_secret_manager" log error "" log error "💡 Possible causes:" log error " • AWS_REGION (or AWS_DEFAULT_REGION) env var is not set" - log error " • .region is missing in the secret_manager provider config" + log error " • .region is missing in the aws_secret_manager provider config" log error "" log error "🔧 How to fix:" log error " • Set AWS_REGION= (e.g. us-east-1)" - log error " • Or populate PROVIDER_CONFIG.region via providers/secret_manager/fetch_configuration" + log error " • Or populate PROVIDER_CONFIG.region via providers/aws_secret_manager/fetch_configuration" exit 1 fi diff --git a/parameters/providers/secret_manager/store b/parameters/providers/aws_secret_manager/store similarity index 100% rename from parameters/providers/secret_manager/store rename to parameters/providers/aws_secret_manager/store diff --git a/parameters/providers/azure_key_vault/docs/architecture.md b/parameters/providers/azure_key_vault/docs/architecture.md index 0aea22b1..9406fef8 100644 --- a/parameters/providers/azure_key_vault/docs/architecture.md +++ b/parameters/providers/azure_key_vault/docs/architecture.md @@ -27,7 +27,7 @@ This document describes the `parameters/providers/azure_key_vault/` implementati - Full secret name example: `parameters-f47ac10b-58cc-4372-a567-0e02b2c3d479` - Max 127 chars total. With a UUID (36 chars + dashes), you have ~90 chars left for the prefix. -This naming differs from `secret_manager` and `parameter_store` (which support slashes for hierarchical organization) — AKV is flat-namespace. +This naming differs from `aws_secret_manager` and `parameter_store` (which support slashes for hierarchical organization) — AKV is flat-namespace. --- diff --git a/parameters/providers/parameter_store/docs/architecture.md b/parameters/providers/parameter_store/docs/architecture.md index c616700e..af881f43 100644 --- a/parameters/providers/parameter_store/docs/architecture.md +++ b/parameters/providers/parameter_store/docs/architecture.md @@ -27,7 +27,7 @@ This is the first provider in the package that branches on `PARAMETER_KIND`: | `parameter` | `String` | None (plain text) | | `secret` | `SecureString` | `PS_KMS_KEY_ID` if set, otherwise `alias/aws/ssm` | -For `secret_manager`, `hashicorp_vault`, and `azure_key_vault`, the kind is informational — those backends encrypt all values uniformly. Parameter Store is different because it distinguishes the storage type at the API level. +For `aws_secret_manager`, `hashicorp_vault`, and `azure_key_vault`, the kind is informational — those backends encrypt all values uniformly. Parameter Store is different because it distinguishes the storage type at the API level. --- diff --git a/parameters/tests/providers/secret_manager/delete.bats b/parameters/tests/providers/aws_secret_manager/delete.bats similarity index 81% rename from parameters/tests/providers/secret_manager/delete.bats rename to parameters/tests/providers/aws_secret_manager/delete.bats index 1e103489..da8d4f15 100644 --- a/parameters/tests/providers/secret_manager/delete.bats +++ b/parameters/tests/providers/aws_secret_manager/delete.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/secret_manager/delete +# Unit tests for parameters/providers/aws_secret_manager/delete # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/delete" + export SCRIPT="$PARAMETERS_DIR/providers/aws_secret_manager/delete" mkdir -p "$BATS_TEST_TMPDIR/bin" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" @@ -42,7 +42,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "secret_manager delete: success → {success: true}" { +@test "aws_secret_manager delete: success → {success: true}" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -50,7 +50,7 @@ EOF assert_equal "$success" "true" } -@test "secret_manager delete: ResourceNotFoundException is idempotent → success" { +@test "aws_secret_manager delete: ResourceNotFoundException is idempotent → success" { run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" assert_equal "$status" "0" @@ -58,7 +58,7 @@ EOF assert_equal "$success" "true" } -@test "secret_manager delete: AccessDenied fails with troubleshooting" { +@test "aws_secret_manager delete: AccessDenied fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" [ "$status" -ne 0 ] @@ -67,7 +67,7 @@ EOF assert_contains "$output" "AccessDeniedException" } -@test "secret_manager delete: unknown errors fail with troubleshooting" { +@test "aws_secret_manager delete: unknown errors fail with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" [ "$status" -ne 0 ] @@ -75,7 +75,7 @@ EOF assert_contains "$output" "🔧 How to fix:" } -@test "secret_manager delete: calls aws with force-delete flag" { +@test "aws_secret_manager delete: calls aws with force-delete flag" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") diff --git a/parameters/tests/providers/secret_manager/retrieve.bats b/parameters/tests/providers/aws_secret_manager/retrieve.bats similarity index 81% rename from parameters/tests/providers/secret_manager/retrieve.bats rename to parameters/tests/providers/aws_secret_manager/retrieve.bats index 67543946..60b722fa 100644 --- a/parameters/tests/providers/secret_manager/retrieve.bats +++ b/parameters/tests/providers/aws_secret_manager/retrieve.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/secret_manager/retrieve +# Unit tests for parameters/providers/aws_secret_manager/retrieve # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/retrieve" + export SCRIPT="$PARAMETERS_DIR/providers/aws_secret_manager/retrieve" mkdir -p "$BATS_TEST_TMPDIR/bin" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" @@ -44,7 +44,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "secret_manager retrieve: success → extracts .value from envelope" { +@test "aws_secret_manager retrieve: success → extracts .value from envelope" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -52,7 +52,7 @@ EOF assert_equal "$value" "the-real-value" } -@test "secret_manager retrieve: ResourceNotFoundException → 'value not found'" { +@test "aws_secret_manager retrieve: ResourceNotFoundException → 'value not found'" { run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" assert_equal "$status" "0" @@ -60,7 +60,7 @@ EOF assert_equal "$value" "value not found" } -@test "secret_manager retrieve: AccessDenied fails with troubleshooting" { +@test "aws_secret_manager retrieve: AccessDenied fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" [ "$status" -ne 0 ] @@ -68,14 +68,14 @@ EOF assert_contains "$output" "lacks secretsmanager:GetSecretValue" } -@test "secret_manager retrieve: unknown errors fail loud" { +@test "aws_secret_manager retrieve: unknown errors fail loud" { run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" [ "$status" -ne 0 ] assert_contains "$output" "❌ Failed to retrieve secret" } -@test "secret_manager retrieve: calls aws with correct args" { +@test "aws_secret_manager retrieve: calls aws with correct args" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") diff --git a/parameters/tests/providers/secret_manager/setup.bats b/parameters/tests/providers/aws_secret_manager/setup.bats similarity index 75% rename from parameters/tests/providers/secret_manager/setup.bats rename to parameters/tests/providers/aws_secret_manager/setup.bats index d9d7f5a4..3be90a73 100644 --- a/parameters/tests/providers/secret_manager/setup.bats +++ b/parameters/tests/providers/aws_secret_manager/setup.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/secret_manager/setup +# Unit tests for parameters/providers/aws_secret_manager/setup # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/setup" + export SCRIPT="$PARAMETERS_DIR/providers/aws_secret_manager/setup" export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" } @@ -17,18 +17,18 @@ teardown() { unset AWS_REGION AWS_DEFAULT_REGION SM_NAME_PREFIX SM_KMS_KEY_ID PROVIDER_CONFIG } -@test "secret_manager setup: fails when AWS_REGION is missing" { +@test "aws_secret_manager setup: fails when AWS_REGION is missing" { unset AWS_REGION AWS_DEFAULT_REGION run bash -c "$DEPS; source $SCRIPT" [ "$status" -ne 0 ] - assert_contains "$output" "❌ AWS region not configured for secret_manager" + assert_contains "$output" "❌ AWS region not configured for aws_secret_manager" assert_contains "$output" "💡 Possible causes:" assert_contains "$output" "🔧 How to fix:" } -@test "secret_manager setup: AWS_DEFAULT_REGION is honored when AWS_REGION is unset" { +@test "aws_secret_manager setup: AWS_DEFAULT_REGION is honored when AWS_REGION is unset" { unset AWS_REGION export AWS_DEFAULT_REGION="eu-west-1" @@ -38,7 +38,7 @@ teardown() { assert_contains "$output" "REGION=eu-west-1" } -@test "secret_manager setup: default name_prefix is 'parameters/'" { +@test "aws_secret_manager setup: default name_prefix is 'parameters/'" { export AWS_REGION="us-east-1" run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$SM_NAME_PREFIX" @@ -47,7 +47,7 @@ teardown() { assert_contains "$output" "PREFIX=parameters/" } -@test "secret_manager setup: PROVIDER_CONFIG wins over env" { +@test "aws_secret_manager setup: PROVIDER_CONFIG wins over env" { export AWS_REGION="us-east-1" export PROVIDER_CONFIG='{"region":"eu-central-1","name_prefix":"custom/","kms_key_id":"alias/mykey"}' @@ -59,7 +59,7 @@ teardown() { assert_contains "$output" "KMS=alias/mykey" } -@test "secret_manager setup: kms_key_id is optional (empty when unset)" { +@test "aws_secret_manager setup: kms_key_id is optional (empty when unset)" { export AWS_REGION="us-east-1" unset SM_KMS_KEY_ID diff --git a/parameters/tests/providers/secret_manager/store.bats b/parameters/tests/providers/aws_secret_manager/store.bats similarity index 82% rename from parameters/tests/providers/secret_manager/store.bats rename to parameters/tests/providers/aws_secret_manager/store.bats index af3f4b13..dbf2872e 100644 --- a/parameters/tests/providers/secret_manager/store.bats +++ b/parameters/tests/providers/aws_secret_manager/store.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/secret_manager/store +# Unit tests for parameters/providers/aws_secret_manager/store # ============================================================================= setup() { @@ -10,7 +10,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/secret_manager/store" + export SCRIPT="$PARAMETERS_DIR/providers/aws_secret_manager/store" mkdir -p "$BATS_TEST_TMPDIR/bin" @@ -59,7 +59,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "secret_manager store: external_id is composite of entities + parameter_id" { +@test "aws_secret_manager store: external_id is composite of entities + parameter_id" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -68,7 +68,7 @@ EOF assert_equal "$external_id" "$expected" } -@test "secret_manager store: secret_name has prefix + composite" { +@test "aws_secret_manager store: secret_name has prefix + composite" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -76,7 +76,7 @@ EOF assert_contains "$secret_name" "parameters/organization=acme-1255165411" } -@test "secret_manager store: calls aws with composite name" { +@test "aws_secret_manager store: calls aws with composite name" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") @@ -85,7 +85,7 @@ EOF assert_contains "$captured" "--name parameters/organization=acme-1255165411" } -@test "secret_manager store: includes --kms-key-id when SM_KMS_KEY_ID set" { +@test "aws_secret_manager store: includes --kms-key-id when SM_KMS_KEY_ID set" { export SM_KMS_KEY_ID="alias/my-key" run bash -c "$DEPS; source $SCRIPT" @@ -94,14 +94,14 @@ EOF assert_contains "$captured" "--kms-key-id alias/my-key" } -@test "secret_manager store: omits --kms-key-id when SM_KMS_KEY_ID empty" { +@test "aws_secret_manager store: omits --kms-key-id when SM_KMS_KEY_ID empty" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") [[ "$captured" != *"--kms-key-id"* ]] } -@test "secret_manager store: dimensions sorted alphabetically in external_id" { +@test "aws_secret_manager store: dimensions sorted alphabetically in external_id" { export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') run bash -c "$DEPS; source $SCRIPT" @@ -111,7 +111,7 @@ EOF assert_contains "$external_id" "country=arg/environment=prod/42" } -@test "secret_manager store: fails with troubleshooting on aws error" { +@test "aws_secret_manager store: fails with troubleshooting on aws error" { run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" [ "$status" -ne 0 ] From 82c83313523838d6443e2fdda8566ae8a851c280 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Tue, 23 Jun 2026 15:40:30 -0300 Subject: [PATCH 05/41] feat(parameters): PR review fixes - versioning, nullplatform path, managed_by, docs --- parameters/docs/adding_a_provider.md | 33 ++-- parameters/docs/architecture.md | 18 +- parameters/docs/configuration.md | 15 +- .../aws_secret_manager_configuration.json.tpl | 35 ++++ .../aws_secret_manager/docs/architecture.md | 174 +++++++++--------- .../aws_secret_manager/docs/iam-policy.md | 160 ++-------------- parameters/providers/aws_secret_manager/setup | 2 +- parameters/providers/aws_secret_manager/store | 57 +++++- .../azure_key_vault_configuration.json.tpl | 31 ++++ parameters/providers/azure_key_vault/setup | 2 +- parameters/providers/azure_key_vault/store | 11 +- .../hashicorp_vault_configuration.json.tpl | 30 +++ parameters/providers/hashicorp_vault/setup | 2 +- parameters/providers/hashicorp_vault/store | 18 +- .../parameter_store_configuration.json.tpl | 46 +++++ parameters/providers/parameter_store/setup | 2 +- parameters/providers/parameter_store/store | 28 ++- .../providers/aws_secret_manager/delete.bats | 4 +- .../aws_secret_manager/retrieve.bats | 4 +- .../providers/aws_secret_manager/setup.bats | 4 +- .../providers/aws_secret_manager/store.bats | 113 +++++++++--- .../providers/azure_key_vault/setup.bats | 4 +- .../providers/hashicorp_vault/setup.bats | 2 +- .../providers/parameter_store/setup.bats | 2 +- parameters/utils/build_external_id | 22 ++- 25 files changed, 493 insertions(+), 326 deletions(-) create mode 100644 parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl create mode 100644 parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl create mode 100644 parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl create mode 100644 parameters/providers/parameter_store/parameter_store_configuration.json.tpl diff --git a/parameters/docs/adding_a_provider.md b/parameters/docs/adding_a_provider.md index a977f836..8aec83d4 100644 --- a/parameters/docs/adding_a_provider.md +++ b/parameters/docs/adding_a_provider.md @@ -27,7 +27,7 @@ mkdir -p parameters/providers//docs mkdir -p parameters/tests/providers/ ``` -`` is `snake_case` and is what users will set in `SECRET_PROVIDER` / `PARAMETER_PROVIDER`. +`` must match the `slug` field of the `parameters-storage` provider specification you (or the platform admin) registered in nullplatform. The agent's `build_context` calls `np provider specification read --id `, reads `.slug`, and uses it to find your directory. --- @@ -35,14 +35,20 @@ mkdir -p parameters/tests/providers/ Validate config and export connection handles. Don't repeat this in operation scripts — `setup` is the DRY anchor. +Config values can come from **two sources**, and `get_config_value` picks whichever is present (provider config wins, env fallback, defaults last): + +1. **`parameters-storage` provider in nullplatform** — values set when the provider is registered, sent to the agent in `$CONTEXT.provider.attributes`. Good for non-sensitive operational settings (region, name prefix, vault address, etc.). +2. **Environment variables on the agent** — set by the operator outside nullplatform. **Recommended for credentials, tokens, and any sensitive material** that should not be stored in nullplatform's database. This keeps ownership of sensitive data 100% on the operator side and lets them use their own protection mechanisms (secret stores, rotation, etc.). + ```bash #!/bin/bash set -euo pipefail # Read config (provider config wins, env fallback, defaults last) MY_ENDPOINT=$(get_config_value --env MY_ENDPOINT --provider '.endpoint') -MY_TOKEN=$(get_config_value --env MY_TOKEN --provider '.token') -MY_PREFIX=$(get_config_value --env MY_PREFIX --provider '.prefix' --default 'parameters-') +# Token: only env var — do NOT pass via provider config (keep credentials off-platform) +MY_TOKEN=$(get_config_value --env MY_TOKEN) +MY_PREFIX=$(get_config_value --env MY_PREFIX --provider '.prefix' --default 'nullplatform-') if [ -z "$MY_ENDPOINT" ]; then log error "❌ endpoint not configured" @@ -139,21 +145,6 @@ Skip the file unless your backend needs a per-notify side effect. The dispatch r --- -## Step 4: Write `fetch_configuration` (optional) - -If the platform stores your provider's config somewhere fetchable, add a `fetch_configuration` script that exports `PROVIDER_CONFIG` as a JSON string with the shape your `setup` expects. - -```bash -#!/bin/bash -# providers//fetch_configuration -PROVIDER_CONFIG=$(np provider get --type --output json) -export PROVIDER_CONFIG -``` - -If you skip this file, `PROVIDER_CONFIG` stays unset and `setup` reads everything from env vars. - ---- - ## Step 5: Write tests Mirror the source structure under `parameters/tests/providers//`: @@ -199,10 +190,10 @@ If the backend needs IAM-style permissions (AWS, GCP), add `iam-policy.md` with ## Step 7: Wire it up -1. Set the env var: `SECRET_PROVIDER=` and/or `PARAMETER_PROVIDER=`. -2. If using `fetch_configuration`, the platform team needs to ensure the fetch mechanism (np CLI, REST endpoint, etc.) returns the JSON shape your provider expects. +1. Register a `parameters-storage` provider in nullplatform with `slug: ` and the schema for your provider's config attributes (use a `.json.tpl` file as the spec — see existing providers for examples). +2. Bind parameters in nullplatform to that provider specification. -Done. The new provider is reachable from every workflow without any other change. +Done. The agent receives `provider.specification_id` and `provider.attributes` in every notification for those parameters; `build_context` resolves the slug, finds your directory, and dispatches. --- diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md index 93d1eeb0..1fd5713f 100644 --- a/parameters/docs/architecture.md +++ b/parameters/docs/architecture.md @@ -15,7 +15,7 @@ nullplatform scopes need to persist parameter values somewhere. Different organi A monolithic scope tied to one backend forces fork-and-modify for every variation. This package inverts the relationship: the **dispatch layer is the package**, the **backends are pluggable modules** dropped into `providers/`. -The platform decides which provider handles each parameter — there is no per-environment / per-agent configuration of "which provider to use". The notification payload carries that information directly. +The platform decides which provider handles each parameter and which configuration it uses. Operators register `parameters-storage` providers in nullplatform (one per backend they want to support — AWS Secrets Manager, Vault, etc.) with their region/address/etc. The notification payload then carries both the choice and its configuration to the agent. A single agent can serve parameters routed to multiple backends simultaneously without per-agent configuration. --- @@ -72,6 +72,20 @@ The dispatch layer is **provider-agnostic**. It has zero knowledge of any specif --- +## Storage naming: human-friendliness principle + +Every provider composes its storage path from the parameter's NRN entities (slugs + IDs), dimensions, and parameter name + ID. The principle is that an operator entering the storage layer manually (AWS console, Vault UI, az portal) must be able to find any secret by knowing the parameter's context, without consulting nullplatform's database. + +The shared helper `parameters/utils/build_external_id` constructs the canonical form, fetching slugs from the np CLI in parallel: + +``` +/organization=-/account=-/.../=/- +``` + +Each provider applies the prefix (default `nullplatform/`) and any backend-specific sanitization (Azure Key Vault flattens slashes and equals to dashes; everyone else uses the canonical form). The canonical `external_id` returned to nullplatform is the same across all providers, which makes parameter migration between backends mechanically possible. + +--- + ## How the provider is chosen For each parameter, nullplatform stores which provider should handle it. That choice travels with every notification as `provider.specification_id` — a UUID pointing to a "provider specification" entity in nullplatform. @@ -87,7 +101,7 @@ The slug becomes `ACTIVE_PROVIDER`, which must match a directory under `paramete The provider's configuration travels in the same payload at `provider.attributes`. `build_context` exports it as `PROVIDER_CONFIG` (a JSON string). Each provider's `setup` reads from `PROVIDER_CONFIG` via `get_config_value --provider '.field'` to extract specific fields (region, kms_key_id, etc.). -This means **there is no per-environment configuration of "which provider"** — the platform decides per-parameter. A single agent can serve parameters routed to Vault and secrets routed to Secrets Manager at the same time, without any agent-side configuration. +The provider's configuration is registered upfront as a `parameters-storage` provider in nullplatform. The platform then attaches that configuration to each parameter via `provider.specification_id`. A single agent can serve parameters routed to multiple backends at the same time, without per-agent configuration. --- diff --git a/parameters/docs/configuration.md b/parameters/docs/configuration.md index 8c8d909d..887c25d1 100644 --- a/parameters/docs/configuration.md +++ b/parameters/docs/configuration.md @@ -21,7 +21,7 @@ Each notification from nullplatform includes the full information needed to hand | **`provider.specification_id`** | **UUID identifying which provider handles this parameter** | | **`provider.attributes`** | **Provider-specific configuration (region, vault address, etc.)** | | `provider.nrn` | Provider-instance NRN (informational) | -| `provider.dimensions` | Provider-instance dimensions (informational, different from parameter dimensions) | +| `provider.dimensions` | Provider-instance dimensions. **Do NOT use this field** — it is internal to the platform's provider system and unrelated to the parameter's `.dimensions`. Parameter dimensions come from top-level `.dimensions` only. | | `provider.id` | Provider-instance ID (informational) | The two fields that drive the dispatch are `provider.specification_id` (which provider) and `provider.attributes` (its config). @@ -33,7 +33,7 @@ The two fields that drive the dispatch are `provider.specification_id` (which pr `build_context` calls: ```bash -np provider specification read --id --output json +np provider specification read --id --format json ``` The response includes a `slug` field. That slug must match the name of a directory under `parameters/providers/`. For example: @@ -121,17 +121,6 @@ Authentication comes from the Azure CLI's default credential chain. --- -## What's NOT in this package - -Two things that used to be design points but are obsolete now: - -- **`SECRET_PROVIDER` / `PARAMETER_PROVIDER` env vars** — not needed. The platform sends `specification_id` per parameter, so there's no global "which provider to use" setting. -- **`fetch_configuration` scripts per provider** — not needed. Config comes in the payload as `provider.attributes`, no separate fetching step. - -Providers can still be tested locally with env vars (e.g., `VAULT_ADDR=http://localhost:8200`) because `get_config_value` falls back to env when `PROVIDER_CONFIG` doesn't have the field. This is useful for development without involving the platform. - ---- - ## Local development For local testing without involving the platform, set the relevant env vars and use a stubbed `np` CLI that returns a known slug: diff --git a/parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl b/parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl new file mode 100644 index 00000000..68ec3381 --- /dev/null +++ b/parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl @@ -0,0 +1,35 @@ +{ + "name": "AWS Secrets Manager", + "description": "Stores nullplatform parameter values in AWS Secrets Manager using native versioning", + "slug": "aws_secret_manager", + "category": "parameters-storage", + "icon": "mdi:aws", + "visible_to": [ + "{{ env.Getenv \"NRN\" }}" + ], + "allow_dimensions": true, + "schema": { + "type": "object", + "required": [ + "region" + ], + "properties": { + "region": { + "type": "string", + "title": "AWS Region", + "description": "AWS region where secrets will be stored (e.g. us-east-1)" + }, + "name_prefix": { + "type": "string", + "title": "Secret Name Prefix", + "description": "Prefix prepended to every secret name. Acts as the IAM scoping anchor", + "default": "nullplatform/" + }, + "kms_key_id": { + "type": "string", + "title": "KMS Key ID (optional)", + "description": "Customer-managed KMS key ARN or alias. If empty, the default aws/secretsmanager managed key is used" + } + } + } +} diff --git a/parameters/providers/aws_secret_manager/docs/architecture.md b/parameters/providers/aws_secret_manager/docs/architecture.md index 4ff64fce..ac81b44c 100644 --- a/parameters/providers/aws_secret_manager/docs/architecture.md +++ b/parameters/providers/aws_secret_manager/docs/architecture.md @@ -1,150 +1,154 @@ # AWS Secrets Manager — Architecture -This document describes how the `parameters/providers/aws_secret_manager/` provider stores, retrieves, and deletes nullplatform parameters using AWS Secrets Manager (SM), and how it differs from clear-text parameters. +This document describes how the `parameters/providers/aws_secret_manager/` provider stores, retrieves, and deletes nullplatform parameters using AWS Secrets Manager (SM). --- ## Role in the parameter lifecycle -nullplatform parameters can be of two kinds: +A nullplatform parameter has a `kind` that decides where its values are persisted: -| Kind | Storage location | This package | -|-------------|---------------------------------------------------|--------------| -| Clear-text | nullplatform API (its own datastore) | Not involved | -| Secret | External provider (this package: AWS SM) | Used | +| Kind | Storage location | This provider | +|-----------------------|------------------------------------------|---------------| +| `nullplatform-storage` | nullplatform's own datastore | Not involved | +| `third-party-storage` | External provider (AWS SM, Vault, etc.) | Used | -Only **secret** parameters trigger the `store` / `retrieve` / `delete` workflows. Clear-text values never leave nullplatform and never touch AWS SM. From the operator's perspective: this provider is invisible until a parameter is marked as a secret. +This provider handles parameters configured for third-party storage that the platform routes to AWS Secrets Manager. The platform's choice is per-parameter, and a single parameter — secret or not — can be routed here. Routing a non-secret parameter to AWS SM is supported but costlier than alternatives like Parameter Store; the choice is the platform operator's. -The interaction is event-driven via four nullplatform actions: +The interaction is event-driven via four actions: -| Action | Trigger | Effect on AWS SM | -|------------|--------------------------------------------------|---------------------------------------| -| `store` | A secret parameter is created | `CreateSecret` with payload | -| `retrieve` | A consumer needs the value (deploy, runtime API) | `GetSecretValue`, returns `{value}` | -| `delete` | A secret parameter is deleted | `DeleteSecret --force-delete-without-recovery` | -| `notify` | nullplatform-side ack hook | No-op (returns `{success: true}`) | - -The contract of these four scripts is identical to the `parameters/providers/hashicorp_vault/` provider — they are drop-in replaceable. Only the storage backend changes. +| Action | Trigger | Effect on AWS SM | +|------------|--------------------------------------------------|----------------------------------------------------| +| `store` | A parameter value is created or updated | `CreateSecret` first time, `PutSecretValue` otherwise (new version) | +| `retrieve` | A consumer needs the value | `GetSecretValue`, returns the AWSCURRENT version | +| `delete` | The parameter is deleted | `DeleteSecret --force-delete-without-recovery` | +| `notify` | nullplatform-side ack hook | No-op (returns `{success: true}`) | --- ## Naming strategy -### Path layout - -Every secret is stored under a single namespace: +Every secret name is composed from the parameter's NRN entities (with slugs and IDs), its dimensions, and the parameter name + ID: ``` -parameters/ +nullplatform/organization=-/account=-/.../=/- ``` -- **`parameters/`** — fixed prefix. This is the IAM anchor: it lets a single resource ARN pattern (`arn:...:secret:parameters/*`) cover everything this provider manages, and nothing else. Removing the prefix would force IAM to either allow account-wide access or maintain an enumerated list of secret names (impractical, since names are generated at runtime). -- **``** — UUIDv4 generated by the `store` script via `uuidgen` (with `openssl rand -hex 16` as a portable fallback). This becomes the canonical handle nullplatform persists; subsequent `retrieve` / `delete` actions get it back via `NP_ACTION_CONTEXT.notification.external_id`. +The path follows the **human-friendliness principle**: anyone entering the AWS Secrets Manager console must be able to find the secret by knowing the parameter's context, without consulting nullplatform metadata. + +### Example + +For a parameter with: -### ARN shape +- Entities: `organization=1255165411`, `account=95118862`, `namespace=37094320`, `application=321402625` +- Dimensions: `environment=production`, `country=argentina` +- Parameter: `name=DB_PASSWORD`, `id=42` -When you `CreateSecret --name parameters/`, AWS appends a random 6-character suffix to the ARN: +The secret name is: ``` -arn:aws:secretsmanager:::secret:parameters/-XXXXXX +nullplatform/organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/country=argentina/environment=production/DB_PASSWORD-42 ``` -This matters for IAM: +Notes: -- ARN pattern `arn:...:secret:parameters/*` **does** match (the wildcard absorbs the suffix). -- ARN pattern `arn:...:secret:parameters/` **does not** match (no suffix-aware glob). +- **Slug-id format** (`-`): slugs are human-readable, IDs are stable. Combining both gives both readability and resilience to potential slug rename support in the future. +- **Slugs are fetched via `np read --id --format json --query '.slug'`** in parallel during the `store` operation. +- **Dimensions are sorted alphabetically by key** for determinism — the same (NRN, dimensions, parameter) tuple always produces the same secret name. +- **`parameter_name-parameter_id`** at the end: name for legibility, ID for uniqueness across renames. -Always use the wildcard form in policies, even when locking down to a single secret — anchor the wildcard at the deterministic part of the name. +### IAM anchor -### Region partitioning +The fixed `nullplatform/` prefix is the IAM scoping anchor: -Each secret lives in a single AWS region. The `store` / `retrieve` / `delete` scripts all read `AWS_REGION` (falling back to `AWS_DEFAULT_REGION`, then `us-east-1`). The region is also written into the `metadata` returned by `store`, so a future cross-region disaster-recovery flow can locate the secret without guessing. +``` +arn:aws:secretsmanager:::secret:nullplatform/* +``` -Cross-region replication is **not** enabled by default. AWS SM supports it (`replicate-secret-to-regions`), but it doubles the per-secret cost and is not required for the current contract. +A single ARN pattern covers everything this provider creates, without granting account-wide access. See `iam-policy.md`. ---- +### ARN suffix -## Secret payload shape - -The value stored in AWS SM is **not** the raw parameter value — it is a JSON envelope: +AWS SM appends a random 6-character suffix to every secret ARN: -```json -{ - "parameter_id": 42, - "value": "the-actual-secret", - "stored_at": "2026-05-05T12:34:56Z", - "external_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" -} +``` +arn:aws:secretsmanager:::secret:nullplatform/.../DB_PASSWORD-42-XXXXXX ``` -| Field | Source | Purpose | -|----------------|----------------------------------------|--------------------------------------| -| `parameter_id` | `NP_ACTION_CONTEXT.notification.parameter_id` | Reverse lookup from SM to nullplatform | -| `value` | `NP_ACTION_CONTEXT.notification.value` | The secret itself | -| `stored_at` | `date -u` at store time | Audit trail | -| `external_id` | UUID generated at store time | Self-verification (must equal the path component) | +Use the wildcard form (`nullplatform/*`) in IAM policies — exact ARN matches without the suffix will not match. -Keeping `parameter_id` and `external_id` inside the payload — and not just in the path — means the secret is self-describing. If someone discovers an orphaned `parameters/` secret in AWS, the JSON tells them which nullplatform parameter it belongs to without needing nullplatform's metadata. +--- -The `retrieve` script extracts only `.value` from this envelope, preserving the `{value: "..."}` output contract shared with the Vault provider. +## Versioning ---- +nullplatform parameter values are **immutable**. Each update of the same (parameter_id, NRN, dimensions) tuple creates a new VERSION of the same value, not a new value. -## Cost model +AWS Secrets Manager has native version retention. We use it as the source of truth: -AWS Secrets Manager pricing (as of writing — verify against current AWS pricing for your region): +- **First `store`** for a given external_id → `CreateSecret`. A new secret is created with version 1. +- **Subsequent `store`** for the same external_id → `PutSecretValue`. A new version is appended, and `AWSCURRENT` moves to it. +- **All previous versions are retained inside the same secret** (up to AWS SM's 100-version cap, after which the oldest unlabeled versions are pruned automatically). -| Component | Price | -|------------------|--------------------------------------| -| Per secret | **$0.40 / secret / month** (prorated) | -| API calls | **$0.05 / 10,000 calls** | -| Replicated secret | $0.40 / replica / month | +### Why this matters for cost -### Cost intuition +AWS SM charges $0.40 per secret per month, **regardless of version count**. Putting versions in the same secret is essentially free; creating a new secret per version would multiply cost linearly with update frequency. The implementation enforces the cheap path. -For a service with **N** secret parameters and roughly **M** API calls per parameter per month (deploys + retrievals): +### Why this matters for history -``` -monthly_cost ≈ 0.40 * N + 0.05 * (N * M / 10_000) -``` +Storing all versions in a single secret means operators can view and (with the right API call) restore older values. The version history is the audit trail. -The fixed $0.40 dominates: at typical deploy frequencies, the per-secret monthly fee is ~99% of the total. **Adding a secret costs $0.40/month**; reading it 100 times costs $0.0005. +--- -### Cost levers +## Secret payload shape -1. **Don't store non-secrets here.** Clear-text parameters belong in nullplatform's API. Mis-classification is the most common cost regression. -2. **Delete promptly.** `--force-delete-without-recovery` (already used) ends billing immediately. Without it, AWS keeps charging for the 7–30 day soft-delete window. -3. **Avoid replication unless you need DR.** Each replica is a full $0.40/month. -4. **Cache at the consumer side.** Each `GetSecretValue` is a billable call. For high-fanout reads (e.g., autoscaling pods all pulling the same value), have a single retrieve at deploy-time and inject as env var, rather than per-pod API calls. +The value stored in AWS SM is a JSON envelope, not the raw value: -### Comparison with self-hosted Vault +```json +{ + "parameter_id": 42, + "value": "the-actual-secret-value", + "stored_at": "2026-06-23T12:34:56Z", + "external_id": "organization=acme-1255165411/.../DB_PASSWORD-42", + "managed_by": "nullplatform" +} +``` -This isn't apples-to-apples: +| Field | Purpose | +|----------------|--------------------------------------------------------------| +| `parameter_id` | nullplatform parameter ID (reverse lookup) | +| `value` | The actual stored value | +| `stored_at` | UTC timestamp of this version (audit trail) | +| `external_id` | Canonical handle nullplatform persists (matches secret name) | +| `managed_by` | Always `"nullplatform"` — identifies the secret as platform-owned | -- **Vault**: no per-secret fee. Cost is the underlying infra (EC2/EKS, storage, ops time). Below ~250 secrets, self-hosted Vault tends to be cheaper on paper but more expensive in operator hours. -- **AWS SM**: linear in secret count, zero ops. Above ~250 secrets the costs converge; the trade-off becomes operational rather than financial. +Each version of the secret carries its own `stored_at` and the value that was active at that point in time. -The two providers were designed to be drop-in replaceable precisely so this decision can be revisited per environment. +The secret is also tagged at creation time with `managed_by=nullplatform`. This is visible in the AWS console and usable in IAM resource conditions. --- ## Lifecycle notes -### Hard delete by default +### Hard delete + +`delete` uses `--force-delete-without-recovery`. This bypasses AWS SM's default 7–30 day soft-delete window. The trade-off: -`delete` uses `--force-delete-without-recovery`. This is intentional: +- Recoverability after deletion: lost. +- Cost: no longer paying for soft-deleted secrets. +- Name reuse: immediate. -- AWS SM defaults to a **soft-delete** with a 7–30 day recovery window. During that window, the secret name is reserved (you cannot create a new secret with the same name) and you continue paying the $0.40/month fee. -- Since `external_id` is a UUID, name collisions on re-creation are not a real risk. We trade recoverability for clean re-use. +For nullplatform's model — where the version history is the recovery mechanism, not the soft-delete window — this is the right default. An operator who needs the soft-delete window can override via provider config (future extension). -If you need recoverability for compliance reasons, change `delete` to omit `--force-delete-without-recovery` and add a `--recovery-window-in-days ` flag — but document it in the parameter-store-level retention policy. +### Error handling -### Idempotency +| Error condition | `store` | `retrieve` | `delete` | +|----------------------------------------|-------------------|--------------------------|--------------------| +| Resource exists (on store) | New version added | N/A | N/A | +| ResourceNotFoundException | N/A | `{value: "value not found"}` | Idempotent success | +| Any other error (IAM, network, region) | Exit 1 + troubleshooting | Exit 1 + troubleshooting | Exit 1 + troubleshooting | -- `delete` is idempotent: it suppresses errors with `|| true` and always returns `{success: true}`. Re-deleting a missing secret is a no-op. -- `retrieve` is idempotent: it returns `{"value": "value not found"}` instead of failing if the secret doesn't exist. -- `store` is **not** idempotent: a second call generates a new UUID and stores a new secret. Idempotency is enforced at the nullplatform layer (the action is only fired once per parameter create event). +Only `ResourceNotFoundException` is treated as idempotent success. Every other error — particularly IAM permission failures — propagates as a real error with troubleshooting guidance. Silent success on permission failures would lead to "the parameter says deleted but the secret is still there" bugs. ### Encryption at rest -All values are encrypted by AWS SM. By default, SM uses the AWS-managed KMS key `aws/secretsmanager`. To use a customer-managed KMS key (CMK), pass `--kms-key-id ` in `store` and grant `kms:Decrypt` / `kms:GenerateDataKey` on that key to consumers (see `iam-policy.md`). +All values are encrypted by AWS SM. By default, SM uses the AWS-managed KMS key `aws/secretsmanager`. To use a customer-managed KMS key (CMK), set `kms_key_id` in the provider's configuration; the agent then grants `kms:Decrypt` and `kms:GenerateDataKey` on that key (see `iam-policy.md`). diff --git a/parameters/providers/aws_secret_manager/docs/iam-policy.md b/parameters/providers/aws_secret_manager/docs/iam-policy.md index 53e86b7b..9fd848be 100644 --- a/parameters/providers/aws_secret_manager/docs/iam-policy.md +++ b/parameters/providers/aws_secret_manager/docs/iam-policy.md @@ -1,120 +1,52 @@ -# IAM Policy — Least Privilege +# IAM Policy -This document specifies the minimum IAM permissions required to operate the `parameters/providers/aws_secret_manager/` provider. The policy is scoped to the `parameters/*` namespace and avoids account-wide wildcards. - ---- - -## Wildcards: which ones are OK - -There are two distinct uses of `*` in IAM, frequently conflated: - -| Pattern | Meaning | Allowed here? | -|------------------------------------------------------|--------------------------------------|---------------| -| `"Resource": "*"` | All resources of all types in the account | **No** | -| `"Resource": "arn:...:secret:parameters/*"` | Path glob on the `parameters/` prefix | **Yes** | - -The second is not a privilege escalation — it is the only way to express "all secrets owned by this provider" given that secret names are UUIDs generated at runtime and cannot be enumerated in advance. Avoiding it would force either explicit per-secret policies (impossible for unknown UUIDs) or `Resource: "*"` (much wider). +Minimum IAM permissions for `parameters/providers/aws_secret_manager/`. Scoped to the `nullplatform/*` namespace so the agent cannot reach any secret outside this provider's domain. --- ## Required actions -| Action | Used by | Why | -|-----------------------------------|------------|----------------------------------------------------------------------| -| `secretsmanager:CreateSecret` | `store` | Creates the secret with the JSON envelope | -| `secretsmanager:GetSecretValue` | `retrieve` | Reads the JSON envelope back | -| `secretsmanager:DeleteSecret` | `delete` | Removes the secret (with `--force-delete-without-recovery`) | -| `secretsmanager:DescribeSecret` | optional | Useful for diagnostics; not strictly required by the current scripts | - -`UpdateSecret`, `PutSecretValue`, `RestoreSecret`, `TagResource`, `RotateSecret` are **not** required and should not be granted unless the scripts grow to use them. +| Action | Used by | Why | +|-----------------------------------|------------|-----------------------------------------------------| +| `secretsmanager:CreateSecret` | `store` | Creates the secret on the first version | +| `secretsmanager:PutSecretValue` | `store` | Adds a new version when the secret already exists | +| `secretsmanager:GetSecretValue` | `retrieve` | Reads the current value | +| `secretsmanager:DeleteSecret` | `delete` | Removes the secret entirely | --- ## Recommended policy -Replace placeholders before applying: - -- `` — region where the provider stores secrets (e.g. `us-east-1`). -- `` — 12-digit AWS account id of the agent. -- `` — only if using a customer-managed KMS key (see KMS section below). Otherwise omit the entire KMS statement. +Replace `` and `` before applying. The `nullplatform/*` resource pattern restricts the agent to secrets created and managed by this provider. ```json { "Version": "2012-10-17", "Statement": [ { - "Sid": "ManageNullplatformSecretParameters", + "Sid": "ManageNullplatformParameters", "Effect": "Allow", "Action": [ "secretsmanager:CreateSecret", + "secretsmanager:PutSecretValue", "secretsmanager:GetSecretValue", - "secretsmanager:DeleteSecret", - "secretsmanager:DescribeSecret" + "secretsmanager:DeleteSecret" ], "Resource": [ - "arn:aws:secretsmanager:::secret:parameters/*" + "arn:aws:secretsmanager:::secret:nullplatform/*" ] } ] } ``` -### Why this is sufficient - -- Confined to a single region and account. -- Confined to the `parameters/` name prefix — no other secrets in the account are reachable. -- No `Resource: "*"`. -- No write actions beyond create + delete (no overwrite, no rotation). -- No tagging or policy-management actions. - ---- - -## Splitting agent vs consumer - -The policy above grants both write and read in one role. In production it is often cleaner to split them: - -### Agent role (executes the workflow scripts) - -Needs `CreateSecret`, `GetSecretValue`, `DeleteSecret`, `DescribeSecret`. Same as the recommended policy above. - -### Consumer role (the application that needs the value at runtime) - -Needs only `GetSecretValue` (and `DescribeSecret` if the consumer enumerates metadata): - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Sid": "ReadNullplatformSecretParameters", - "Effect": "Allow", - "Action": [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret" - ], - "Resource": [ - "arn:aws:secretsmanager:::secret:parameters/*" - ] - } - ] -} -``` - -If a consumer should only read **specific** secrets (rather than every parameter in the namespace), narrow the resource list: - -```json -"Resource": [ - "arn:aws:secretsmanager:::secret:parameters/-*" -] -``` - -The trailing `-*` is required because AWS SM appends a 6-character suffix to the ARN of every secret it creates (see `architecture.md`). Omitting it makes the ARN never match. +The trailing `*` in the resource ARN absorbs both the path under `nullplatform/` and the random 6-character suffix AWS SM appends to every secret ARN. --- ## KMS (only if using a customer-managed key) -If you pass `--kms-key-id` to `CreateSecret` (i.e. you do not want to use the default `aws/secretsmanager` AWS-managed key), both the agent and any consumer also need access to the CMK. Add this statement to **both** the agent and consumer policies: +If the provider's configuration sets `kms_key_id` to a customer-managed key (rather than the default `aws/secretsmanager`), the agent also needs KMS permissions on that key: ```json { @@ -135,64 +67,6 @@ If you pass `--kms-key-id` to `CreateSecret` (i.e. you do not want to use the de } ``` -The `kms:ViaService` condition is the security-relevant part: it ensures the role can only use the key **through Secrets Manager**, not for arbitrary `kms:Decrypt` calls against other ciphertexts encrypted with the same key. - -The CMK's **key policy** must also allow the role principal — IAM permissions alone are not enough for KMS. Configure that on the key, not on the role. - ---- - -## Conditions worth adding - -Optional hardening, depending on threat model: - -### Restrict to specific VPC endpoints - -If the agent runs inside a VPC with a Secrets Manager interface endpoint: - -```json -"Condition": { - "StringEquals": { - "aws:SourceVpce": "" - } -} -``` - -### Restrict to a specific source IAM role - -For consumers running in a known service account (IRSA) or instance profile: - -```json -"Condition": { - "ArnEquals": { - "aws:PrincipalArn": "arn:aws:iam:::role/" - } -} -``` - -(This is more typically enforced via the trust policy of the role itself, but resource policies on the secret can pin it as defense-in-depth.) - -### Enforce TLS - -Mostly handled by AWS by default, but explicitly denying non-TLS traffic is cheap insurance: - -```json -"Condition": { - "Bool": { - "aws:SecureTransport": "true" - } -} -``` - ---- - -## What not to grant - -For reference — these are commonly requested but **not** needed by the current scripts and should be denied unless a specific use case is documented: +`kms:ViaService` restricts the key to SSM use only — without it, the role could decrypt arbitrary ciphertexts encrypted with the same key. -- `secretsmanager:PutSecretValue` — would let the agent overwrite values. Not used; secrets are immutable in this design. -- `secretsmanager:UpdateSecret` — same reasoning. -- `secretsmanager:RotateSecret` — rotation is not implemented. -- `secretsmanager:TagResource`, `UntagResource` — no tagging in current scripts. -- `secretsmanager:PutResourcePolicy` — would let the agent change cross-account access. Should be reserved for a separate admin role. -- `secretsmanager:ReplicateSecretToRegions` — replication is opt-in and out of scope. -- `iam:*` of any kind — this provider does not manage IAM. +The CMK's **key policy** must also allow the role principal. IAM permissions alone are not enough for KMS. diff --git a/parameters/providers/aws_secret_manager/setup b/parameters/providers/aws_secret_manager/setup index 8b68c8ab..45e4c59e 100755 --- a/parameters/providers/aws_secret_manager/setup +++ b/parameters/providers/aws_secret_manager/setup @@ -15,7 +15,7 @@ AWS_REGION=$(get_config_value \ SM_NAME_PREFIX=$(get_config_value \ --env SM_NAME_PREFIX \ --provider '.name_prefix' \ - --default 'parameters/') + --default 'nullplatform/') SM_KMS_KEY_ID=$(get_config_value \ --env SM_KMS_KEY_ID \ diff --git a/parameters/providers/aws_secret_manager/store b/parameters/providers/aws_secret_manager/store index 09a7e7f0..8124be27 100755 --- a/parameters/providers/aws_secret_manager/store +++ b/parameters/providers/aws_secret_manager/store @@ -1,8 +1,18 @@ #!/bin/bash set -euo pipefail -# Stores a parameter as an AWS Secrets Manager secret. -# external_id composed from entities + dimensions + parameter_id via build_external_id. +# Stores a parameter value in AWS Secrets Manager. +# +# Versioning model: +# nullplatform parameter values are immutable. Each update of the same +# (parameter_id, nrn, dimensions) tuple is a new VERSION of the same value. +# AWS Secrets Manager has native versioning — every PutSecretValue creates a +# new version, all retained inside the same secret, and AWSCURRENT moves to +# the latest. We exploit this: +# - First store for this external_id → CreateSecret +# - Subsequent stores → PutSecretValue (new version, same secret) +# This keeps the version history without paying for a separate secret per +# version (each AWS SM secret costs $0.40/month regardless of version count). # # Required env: PARAMETER_ID, PARAMETER_VALUE, AWS_REGION, SM_NAME_PREFIX # Optional env: SM_KMS_KEY_ID @@ -12,17 +22,19 @@ build_external_id SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" -SECRET_PAYLOAD=$(jq -n \ +SECRET_PAYLOAD=$(jq -nc \ --argjson parameter_id "${PARAMETER_ID:-null}" \ --arg value "$PARAMETER_VALUE" \ --arg stored_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --arg external_id "$EXTERNAL_ID" \ - '{parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id}') + --arg managed_by "nullplatform" \ + '{parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id, managed_by: $managed_by}') create_args=( --region "$AWS_REGION" --name "$SECRET_NAME" --secret-string "$SECRET_PAYLOAD" + --tags "Key=managed_by,Value=nullplatform" --query ARN --output text ) @@ -30,16 +42,45 @@ if [ -n "${SM_KMS_KEY_ID:-}" ]; then create_args+=(--kms-key-id "$SM_KMS_KEY_ID") fi -if ! SECRET_ARN=$(aws secretsmanager create-secret "${create_args[@]}" 2>/dev/null); then +err_file=$(mktemp) +if SECRET_ARN=$(aws secretsmanager create-secret "${create_args[@]}" 2>"$err_file"); then + rm -f "$err_file" +elif grep -q "ResourceExistsException" "$err_file"; then + rm -f "$err_file" + log debug "Secret '$SECRET_NAME' exists; adding new version (history preserved by AWS SM)" + err_file=$(mktemp) + if ! SECRET_ARN=$(aws secretsmanager put-secret-value \ + --region "$AWS_REGION" \ + --secret-id "$SECRET_NAME" \ + --secret-string "$SECRET_PAYLOAD" \ + --query ARN \ + --output text 2>"$err_file"); then + err=$(cat "$err_file") + rm -f "$err_file" + log error "❌ Failed to add new version to secret '$SECRET_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • IAM principal lacks secretsmanager:PutSecretValue on this resource" + log error " • Secret is in a deletion-pending state" + log error "" + log error "🔧 How to fix:" + log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" + exit 1 + fi + rm -f "$err_file" +else + err=$(cat "$err_file") + rm -f "$err_file" log error "❌ Failed to store parameter in AWS Secrets Manager" log error "" log error "💡 Possible causes:" - log error " • IAM principal lacks secretsmanager:CreateSecret for $SECRET_NAME" - log error " • Same-name secret is in soft-delete window" - log error " • Region '$AWS_REGION' unreachable" + log error " • IAM principal lacks secretsmanager:CreateSecret on this resource" + log error " • Region '$AWS_REGION' unreachable or wrong" log error "" log error "🔧 How to fix:" log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" + log error "Underlying error: $err" exit 1 fi diff --git a/parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl b/parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl new file mode 100644 index 00000000..78cd747e --- /dev/null +++ b/parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl @@ -0,0 +1,31 @@ +{ + "name": "Azure Key Vault", + "description": "Stores nullplatform parameter values in Azure Key Vault with native versioning", + "slug": "azure_key_vault", + "category": "parameters-storage", + "icon": "mdi:microsoft-azure", + "visible_to": [ + "{{ env.Getenv \"NRN\" }}" + ], + "allow_dimensions": true, + "schema": { + "type": "object", + "required": [ + "vault_name" + ], + "properties": { + "vault_name": { + "type": "string", + "title": "Vault Name", + "description": "Azure Key Vault name (without https:// or .vault.azure.net suffix)" + }, + "secret_prefix": { + "type": "string", + "title": "Secret Name Prefix", + "description": "Prefix prepended to every secret name. AKV only allows alphanumerics and dashes", + "default": "nullplatform-", + "pattern": "^[A-Za-z0-9-]*$" + } + } + } +} diff --git a/parameters/providers/azure_key_vault/setup b/parameters/providers/azure_key_vault/setup index 4448ab3e..89cc1fbc 100755 --- a/parameters/providers/azure_key_vault/setup +++ b/parameters/providers/azure_key_vault/setup @@ -17,7 +17,7 @@ AZ_VAULT_NAME=$(get_config_value \ AZ_SECRET_PREFIX=$(get_config_value \ --env AZURE_KEY_VAULT_SECRET_PREFIX \ --provider '.secret_prefix' \ - --default 'parameters-') + --default 'nullplatform-') if [ -z "$AZ_VAULT_NAME" ]; then log error "❌ Azure Key Vault name not configured" diff --git a/parameters/providers/azure_key_vault/store b/parameters/providers/azure_key_vault/store index 4e744296..836a8bf1 100755 --- a/parameters/providers/azure_key_vault/store +++ b/parameters/providers/azure_key_vault/store @@ -2,11 +2,15 @@ set -euo pipefail # Stores a parameter as an Azure Key Vault secret. -# external_id composed from entities + dimensions + parameter_id via build_external_id. +# +# Versioning model: +# nullplatform parameter values are immutable. Each update is a new VERSION. +# Azure Key Vault has native versioning — every `secret set` creates a new +# version, all retained inside the same secret. No special logic needed. +# # AKV doesn't allow `/` or `=` in secret names, so the canonical EXTERNAL_ID is # transformed (slash → dash, equals → dash) for the secret name. The external_id -# returned to nullplatform keeps the canonical slash form for cross-provider -# consistency. +# returned to nullplatform keeps the canonical slash form. # # Required env: PARAMETER_VALUE, AZ_VAULT_NAME, AZ_SECRET_PREFIX @@ -21,6 +25,7 @@ if ! AKV_ID=$(az keyvault secret set \ --vault-name "$AZ_VAULT_NAME" \ --name "$SECRET_NAME" \ --value "$PARAMETER_VALUE" \ + --tags "managed_by=nullplatform" \ --query id \ --output tsv 2>/dev/null); then log error "❌ Failed to store secret in Azure Key Vault '$AZ_VAULT_NAME'" diff --git a/parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl b/parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl new file mode 100644 index 00000000..138e85de --- /dev/null +++ b/parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl @@ -0,0 +1,30 @@ +{ + "name": "HashiCorp Vault", + "description": "Stores nullplatform parameter values in HashiCorp Vault KV v2 with native versioning", + "slug": "hashicorp_vault", + "category": "parameters-storage", + "icon": "mdi:vault", + "visible_to": [ + "{{ env.Getenv \"NRN\" }}" + ], + "allow_dimensions": true, + "schema": { + "type": "object", + "required": [ + "address" + ], + "properties": { + "address": { + "type": "string", + "title": "Vault Address", + "description": "Vault HTTP(S) endpoint (e.g. https://vault.example.com:8200)" + }, + "path_prefix": { + "type": "string", + "title": "Path Prefix", + "description": "KV v2 path prefix prepended to every secret. Format: secret/data/", + "default": "secret/data/nullplatform" + } + } + } +} diff --git a/parameters/providers/hashicorp_vault/setup b/parameters/providers/hashicorp_vault/setup index 2a84dcc0..72cfec2a 100755 --- a/parameters/providers/hashicorp_vault/setup +++ b/parameters/providers/hashicorp_vault/setup @@ -14,7 +14,7 @@ VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') VAULT_PATH_PREFIX=$(get_config_value \ --env VAULT_PATH_PREFIX \ --provider '.path_prefix' \ - --default 'secret/data/parameters') + --default 'secret/data/nullplatform') if [ -z "$VAULT_ADDR" ]; then log error "❌ Vault address not configured" diff --git a/parameters/providers/hashicorp_vault/store b/parameters/providers/hashicorp_vault/store index 5160b43a..b2cb80e6 100755 --- a/parameters/providers/hashicorp_vault/store +++ b/parameters/providers/hashicorp_vault/store @@ -2,8 +2,12 @@ set -euo pipefail # Stores a parameter value in HashiCorp Vault KV v2. -# external_id is composed from entities (with slugs fetched via np CLI) + -# dimensions + parameter_id. See parameters/utils/build_external_id. +# +# Versioning model: +# nullplatform parameter values are immutable. Each update is a new VERSION. +# Vault KV v2 has native versioning — every POST to the data endpoint creates +# a new version, all retained inside the same path, and the latest is returned +# on read by default. No special logic needed; the POST IS the versioning. # # Required env: PARAMETER_ID, PARAMETER_VALUE, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX @@ -12,10 +16,18 @@ build_external_id VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" +PAYLOAD=$(jq -nc \ + --argjson parameter_id "${PARAMETER_ID:-null}" \ + --arg value "$PARAMETER_VALUE" \ + --arg stored_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ + --arg external_id "$EXTERNAL_ID" \ + --arg managed_by "nullplatform" \ + '{data: {parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id, managed_by: $managed_by}}') + if ! curl -s -X POST \ -H "X-Vault-Token: $VAULT_TOKEN" \ "$VAULT_ADDR/v1/$VAULT_PATH" \ - -d "{\"data\":{\"parameter_id\":$PARAMETER_ID,\"value\":$(echo "$PARAMETER_VALUE" | jq -R .),\"stored_at\":\"$(date -u +"%Y-%m-%dT%H:%M:%SZ")\",\"external_id\":\"$EXTERNAL_ID\"}}" >/dev/null; then + -d "$PAYLOAD" >/dev/null; then log error "❌ Failed to store parameter in Vault at $VAULT_ADDR" log error "" log error "💡 Possible causes:" diff --git a/parameters/providers/parameter_store/parameter_store_configuration.json.tpl b/parameters/providers/parameter_store/parameter_store_configuration.json.tpl new file mode 100644 index 00000000..1a83b7af --- /dev/null +++ b/parameters/providers/parameter_store/parameter_store_configuration.json.tpl @@ -0,0 +1,46 @@ +{ + "name": "AWS Parameter Store", + "description": "Stores nullplatform parameter values in AWS SSM Parameter Store with native versioning. Cheapest option (Standard tier is free up to 10,000 parameters)", + "slug": "parameter_store", + "category": "parameters-storage", + "icon": "mdi:aws", + "visible_to": [ + "{{ env.Getenv \"NRN\" }}" + ], + "allow_dimensions": true, + "schema": { + "type": "object", + "required": [ + "region" + ], + "properties": { + "region": { + "type": "string", + "title": "AWS Region", + "description": "AWS region where parameters will be stored (e.g. us-east-1)" + }, + "name_prefix": { + "type": "string", + "title": "Parameter Name Prefix", + "description": "Prefix prepended to every parameter name. Must start with a slash", + "default": "/nullplatform/" + }, + "kms_key_id": { + "type": "string", + "title": "KMS Key ID (optional)", + "description": "Customer-managed KMS key for SecureString parameters. If empty, the default alias/aws/ssm key is used" + }, + "tier": { + "type": "string", + "title": "Parameter Tier", + "description": "Standard is free for up to 10,000 parameters. Advanced supports larger values but costs $0.05/param/month", + "default": "Standard", + "oneOf": [ + { "const": "Standard", "title": "Standard (free)" }, + { "const": "Advanced", "title": "Advanced ($0.05/param/month)" }, + { "const": "Intelligent-Tiering", "title": "Intelligent-Tiering" } + ] + } + } + } +} diff --git a/parameters/providers/parameter_store/setup b/parameters/providers/parameter_store/setup index 0345b25b..e0e883f6 100755 --- a/parameters/providers/parameter_store/setup +++ b/parameters/providers/parameter_store/setup @@ -17,7 +17,7 @@ AWS_REGION=$(get_config_value \ PS_NAME_PREFIX=$(get_config_value \ --env PS_NAME_PREFIX \ --provider '.name_prefix' \ - --default '/nullplatform/parameters/') + --default '/nullplatform/') PS_KMS_KEY_ID=$(get_config_value \ --env PS_KMS_KEY_ID \ diff --git a/parameters/providers/parameter_store/store b/parameters/providers/parameter_store/store index 920af5b9..020580fb 100755 --- a/parameters/providers/parameter_store/store +++ b/parameters/providers/parameter_store/store @@ -2,8 +2,17 @@ set -euo pipefail # Stores a parameter in AWS SSM Parameter Store. -# external_id composed from entities + dimensions + parameter_id via build_external_id. +# +# Versioning model: +# nullplatform parameter values are immutable. Each update is a new VERSION. +# AWS Parameter Store retains versions automatically inside the same parameter +# name. We use `--overwrite` so the first store creates the parameter and +# subsequent stores append a new version. Version 1 is created on first store; +# each subsequent store increments the version counter. +# # Type=SecureString for kind=secret, Type=String for kind=parameter. +# Tagging is done via a separate `add-tags-to-resource` call (put-parameter +# does not support tags on overwrite to avoid resetting existing ones). # # Required env: PARAMETER_KIND, PARAMETER_VALUE, AWS_REGION, PS_NAME_PREFIX, PS_TIER # Optional env: PS_KMS_KEY_ID @@ -22,12 +31,16 @@ put_args=( --value "$PARAMETER_VALUE" --type "$SSM_TYPE" --tier "$PS_TIER" + --overwrite ) if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then put_args+=(--key-id "$PS_KMS_KEY_ID") fi -if ! aws ssm put-parameter "${put_args[@]}" >/dev/null 2>&1; then +err_file=$(mktemp) +if ! aws ssm put-parameter "${put_args[@]}" >/dev/null 2>"$err_file"; then + err=$(cat "$err_file") + rm -f "$err_file" log error "❌ Failed to store parameter in AWS Parameter Store" log error "" log error "💡 Possible causes:" @@ -40,8 +53,19 @@ if ! aws ssm put-parameter "${put_args[@]}" >/dev/null 2>&1; then log error "🔧 How to fix:" log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" log error " • For large values, set PS_TIER=Advanced" + log error "Underlying error: $err" exit 1 fi +rm -f "$err_file" + +# Best-effort tagging — Parameter Store tags survive overwrites; we only need +# to (re)apply on first creation. Suppress errors here: missing tags don't +# break correctness. +aws ssm add-tags-to-resource \ + --region "$AWS_REGION" \ + --resource-type Parameter \ + --resource-id "$PARAM_NAME" \ + --tags "Key=managed_by,Value=nullplatform" >/dev/null 2>&1 || true jq -n \ --arg external_id "$EXTERNAL_ID" \ diff --git a/parameters/tests/providers/aws_secret_manager/delete.bats b/parameters/tests/providers/aws_secret_manager/delete.bats index da8d4f15..813f4dc5 100644 --- a/parameters/tests/providers/aws_secret_manager/delete.bats +++ b/parameters/tests/providers/aws_secret_manager/delete.bats @@ -36,7 +36,7 @@ EOF export PATH="$BATS_TEST_TMPDIR/bin:$PATH" export AWS_REGION="us-east-1" - export SM_NAME_PREFIX="parameters/" + export SM_NAME_PREFIX="nullplatform/" export EXTERNAL_ID="abc-123" export DEPS="source $PARAMETERS_DIR/utils/log" @@ -81,6 +81,6 @@ EOF captured=$(cat "$AWS_LOG") assert_contains "$captured" "secretsmanager delete-secret" assert_contains "$captured" "--region us-east-1" - assert_contains "$captured" "--secret-id parameters/abc-123" + assert_contains "$captured" "--secret-id nullplatform/abc-123" assert_contains "$captured" "--force-delete-without-recovery" } diff --git a/parameters/tests/providers/aws_secret_manager/retrieve.bats b/parameters/tests/providers/aws_secret_manager/retrieve.bats index 60b722fa..d70fa369 100644 --- a/parameters/tests/providers/aws_secret_manager/retrieve.bats +++ b/parameters/tests/providers/aws_secret_manager/retrieve.bats @@ -38,7 +38,7 @@ EOF export PATH="$BATS_TEST_TMPDIR/bin:$PATH" export AWS_REGION="us-east-1" - export SM_NAME_PREFIX="parameters/" + export SM_NAME_PREFIX="nullplatform/" export EXTERNAL_ID="abc-123" export DEPS="source $PARAMETERS_DIR/utils/log" @@ -81,5 +81,5 @@ EOF captured=$(cat "$AWS_LOG") assert_contains "$captured" "secretsmanager get-secret-value" assert_contains "$captured" "--region us-east-1" - assert_contains "$captured" "--secret-id parameters/abc-123" + assert_contains "$captured" "--secret-id nullplatform/abc-123" } diff --git a/parameters/tests/providers/aws_secret_manager/setup.bats b/parameters/tests/providers/aws_secret_manager/setup.bats index 3be90a73..82c82394 100644 --- a/parameters/tests/providers/aws_secret_manager/setup.bats +++ b/parameters/tests/providers/aws_secret_manager/setup.bats @@ -38,13 +38,13 @@ teardown() { assert_contains "$output" "REGION=eu-west-1" } -@test "aws_secret_manager setup: default name_prefix is 'parameters/'" { +@test "aws_secret_manager setup: default name_prefix is 'nullplatform/'" { export AWS_REGION="us-east-1" run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$SM_NAME_PREFIX" assert_equal "$status" "0" - assert_contains "$output" "PREFIX=parameters/" + assert_contains "$output" "PREFIX=nullplatform/" } @test "aws_secret_manager setup: PROVIDER_CONFIG wins over env" { diff --git a/parameters/tests/providers/aws_secret_manager/store.bats b/parameters/tests/providers/aws_secret_manager/store.bats index dbf2872e..3174319d 100644 --- a/parameters/tests/providers/aws_secret_manager/store.bats +++ b/parameters/tests/providers/aws_secret_manager/store.bats @@ -1,6 +1,14 @@ #!/usr/bin/env bats # ============================================================================= # Unit tests for parameters/providers/aws_secret_manager/store +# +# Verifies: +# - external_id composed from entities + dimensions + parameter_name-id +# - Path prefix is `nullplatform/` +# - First store uses CreateSecret with managed_by tag +# - Subsequent stores (ResourceExistsException) fall through to PutSecretValue +# - Payload includes managed_by: nullplatform +# - Real errors propagate with troubleshooting # ============================================================================= setup() { @@ -14,7 +22,6 @@ setup() { mkdir -p "$BATS_TEST_TMPDIR/bin" - # Mock np CLI for entity slug fetches cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' #!/bin/bash entity_type="$1" @@ -29,23 +36,52 @@ EOF chmod +x "$BATS_TEST_TMPDIR/bin/np" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" - cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF + cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' #!/bin/bash -echo "ARGS: \$@" >> "$AWS_LOG" -if [ "\${MOCK_AWS_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AWS_EXIT; fi -echo "arn:aws:secretsmanager:us-east-1:111122223333:secret:test-AbCdEf" +echo "ARGS: $@" >> "$AWS_LOG" +mode="${MOCK_AWS_MODE:-success}" + +if [[ "$*" == *"create-secret"* ]]; then + case "$mode" in + success) + echo "arn:aws:secretsmanager:us-east-1:111122223333:secret:nullplatform/test-AbCdEf" + exit 0 + ;; + exists|put_error) + echo "An error occurred (ResourceExistsException) when calling the CreateSecret operation: resource already exists." >&2 + exit 254 + ;; + create_error) + echo "An error occurred (AccessDeniedException) when calling the CreateSecret operation: not authorized." >&2 + exit 254 + ;; + esac +elif [[ "$*" == *"put-secret-value"* ]]; then + case "$mode" in + exists) + echo "arn:aws:secretsmanager:us-east-1:111122223333:secret:nullplatform/test-AbCdEf" + exit 0 + ;; + put_error) + echo "An error occurred (AccessDeniedException) when calling the PutSecretValue operation: not authorized." >&2 + exit 254 + ;; + esac +fi +exit 1 EOF chmod +x "$BATS_TEST_TMPDIR/bin/aws" export PATH="$BATS_TEST_TMPDIR/bin:$PATH" export AWS_REGION="us-east-1" - export SM_NAME_PREFIX="parameters/" + export SM_NAME_PREFIX="nullplatform/" export SM_KMS_KEY_ID="" export PARAMETER_ID=42 export PARAMETER_VALUE="my-secret" export CONTEXT='{ "parameter_id": 42, + "parameter_name": "DB_PASSWORD", "value": "my-secret", "entities": { "organization": "1255165411", @@ -59,61 +95,82 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "aws_secret_manager store: external_id is composite of entities + parameter_id" { +@test "aws_secret_manager store: external_id includes entities + parameter_name-id" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') - expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/DB_PASSWORD-42" assert_equal "$external_id" "$expected" } -@test "aws_secret_manager store: secret_name has prefix + composite" { +@test "aws_secret_manager store: secret_name uses nullplatform/ prefix" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" secret_name=$(echo "$output" | jq -r '.metadata.secret_name') - assert_contains "$secret_name" "parameters/organization=acme-1255165411" + assert_contains "$secret_name" "nullplatform/organization=acme-1255165411" + assert_contains "$secret_name" "DB_PASSWORD-42" } -@test "aws_secret_manager store: calls aws with composite name" { +@test "aws_secret_manager store: first store uses CreateSecret with managed_by tag" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") assert_contains "$captured" "secretsmanager create-secret" - assert_contains "$captured" "--region us-east-1" - assert_contains "$captured" "--name parameters/organization=acme-1255165411" + assert_contains "$captured" "--tags Key=managed_by,Value=nullplatform" + [[ "$captured" != *"put-secret-value"* ]] } -@test "aws_secret_manager store: includes --kms-key-id when SM_KMS_KEY_ID set" { - export SM_KMS_KEY_ID="alias/my-key" - +@test "aws_secret_manager store: payload includes managed_by=nullplatform" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") - assert_contains "$captured" "--kms-key-id alias/my-key" + assert_contains "$captured" '"managed_by":"nullplatform"' +} + +@test "aws_secret_manager store: ResourceExistsException falls through to PutSecretValue" { + run bash -c "$DEPS; MOCK_AWS_MODE=exists source $SCRIPT" + + assert_equal "$status" "0" + captured=$(cat "$AWS_LOG") + assert_contains "$captured" "secretsmanager create-secret" + assert_contains "$captured" "secretsmanager put-secret-value" + assert_contains "$captured" "--secret-id nullplatform/organization=acme-1255165411" +} + +@test "aws_secret_manager store: PutSecretValue failure propagates with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=put_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to add new version" + assert_contains "$output" "secretsmanager:PutSecretValue" + assert_contains "$output" "🔧 How to fix:" } -@test "aws_secret_manager store: omits --kms-key-id when SM_KMS_KEY_ID empty" { +@test "aws_secret_manager store: non-exists create errors propagate with troubleshooting" { + run bash -c "$DEPS; MOCK_AWS_MODE=create_error source $SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Failed to store parameter in AWS Secrets Manager" + assert_contains "$output" "secretsmanager:CreateSecret" +} + +@test "aws_secret_manager store: includes --kms-key-id when SM_KMS_KEY_ID set" { + export SM_KMS_KEY_ID="alias/my-key" + run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") - [[ "$captured" != *"--kms-key-id"* ]] + assert_contains "$captured" "--kms-key-id alias/my-key" } -@test "aws_secret_manager store: dimensions sorted alphabetically in external_id" { +@test "aws_secret_manager store: dimensions sort alphabetically in external_id" { export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') - assert_contains "$external_id" "country=arg/environment=prod/42" -} - -@test "aws_secret_manager store: fails with troubleshooting on aws error" { - run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" - - [ "$status" -ne 0 ] - assert_contains "$output" "❌ Failed to store parameter in AWS Secrets Manager" + assert_contains "$external_id" "country=arg/environment=prod/DB_PASSWORD-42" } diff --git a/parameters/tests/providers/azure_key_vault/setup.bats b/parameters/tests/providers/azure_key_vault/setup.bats index 711db077..cd2834cd 100644 --- a/parameters/tests/providers/azure_key_vault/setup.bats +++ b/parameters/tests/providers/azure_key_vault/setup.bats @@ -32,7 +32,7 @@ teardown() { assert_equal "$status" "0" assert_contains "$output" "VAULT=my-vault" - assert_contains "$output" "PREFIX=parameters-" + assert_contains "$output" "PREFIX=nullplatform-" } @test "azure_key_vault setup: PROVIDER_CONFIG wins over env" { @@ -65,5 +65,5 @@ teardown() { assert_equal "$status" "0" # Empty env should fall through to default 'parameters-' - assert_contains "$output" "PREFIX=[parameters-]" + assert_contains "$output" "PREFIX=[nullplatform-]" } diff --git a/parameters/tests/providers/hashicorp_vault/setup.bats b/parameters/tests/providers/hashicorp_vault/setup.bats index 13fe0707..64fa929a 100644 --- a/parameters/tests/providers/hashicorp_vault/setup.bats +++ b/parameters/tests/providers/hashicorp_vault/setup.bats @@ -48,7 +48,7 @@ teardown() { assert_equal "$status" "0" assert_contains "$output" "ADDR=https://vault.example.com" assert_contains "$output" "TOKEN=hvs.xxx" - assert_contains "$output" "PREFIX=secret/data/parameters" + assert_contains "$output" "PREFIX=secret/data/nullplatform" } @test "vault setup: PROVIDER_CONFIG wins over env var" { diff --git a/parameters/tests/providers/parameter_store/setup.bats b/parameters/tests/providers/parameter_store/setup.bats index f04b9691..30f5bdca 100644 --- a/parameters/tests/providers/parameter_store/setup.bats +++ b/parameters/tests/providers/parameter_store/setup.bats @@ -32,7 +32,7 @@ teardown() { run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" assert_equal "$status" "0" - assert_contains "$output" "PREFIX=/nullplatform/parameters/" + assert_contains "$output" "PREFIX=/nullplatform/" } @test "parameter_store setup: normalizes prefix without leading slash" { diff --git a/parameters/utils/build_external_id b/parameters/utils/build_external_id index 7645f009..43195a5f 100755 --- a/parameters/utils/build_external_id +++ b/parameters/utils/build_external_id @@ -1,7 +1,11 @@ #!/bin/bash # Constructs EXTERNAL_ID for a store operation by composing: -# =-/.../=/ +# =-/.../=/- +# +# The path is human-friendly: anyone entering the storage layer manually (AWS +# console, Vault UI, az portal) sees the entity hierarchy, the dimensions, and +# the parameter name + id directly in the path. # # Slugs are fetched in parallel via `np read --id --format json --query '.slug'`. # Entities are iterated in canonical NRN order; only present entities are included. @@ -18,6 +22,12 @@ # - vault/SM/PS: append to a prefix that ends with `/` # - AKV: replace `/` and `=` with `-` (AKV doesn't allow those chars) +# Replaces characters not allowed by AWS SM / Parameter Store secret names. +# AWS SM allows alphanumerics and /_+=.@- ; everything else becomes underscore. +sanitize_for_path() { + echo "$1" | sed 's|[^A-Za-z0-9._-]|_|g' +} + build_external_id() { local entities_json entities_json=$(echo "$CONTEXT" | jq -c '.entities // {}') @@ -49,7 +59,6 @@ build_external_id() { log error "💡 Possible causes:" log error " • Entity does not exist in nullplatform" log error " • np CLI is not authenticated" - log error " • Entity type '$entity_type' is not supported by np CLI" log error "" log error "🔧 How to fix:" log error " • Verify: np $entity_type read --id $entity_id --format json" @@ -65,9 +74,14 @@ build_external_id() { [ -n "$dim" ] && segments+=("$dim") done < <(echo "$CONTEXT" | jq -r '(.dimensions // {}) | to_entries | sort_by(.key) | .[] | "\(.key)=\(.value)"') - local param_id + local param_id param_name param_id=$(echo "$CONTEXT" | jq -r '.parameter_id // empty') - [ -n "$param_id" ] && segments+=("$param_id") + param_name=$(echo "$CONTEXT" | jq -r '.parameter_name // empty') + if [ -n "$param_name" ] && [ -n "$param_id" ]; then + segments+=("$(sanitize_for_path "$param_name")-$param_id") + elif [ -n "$param_id" ]; then + segments+=("$param_id") + fi rm -rf "$tmp_dir" From de99ba9fb3e2100c7cffc9ffb6193405c5368907 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Tue, 23 Jun 2026 15:57:05 -0300 Subject: [PATCH 06/41] feat(parameters): version_id in metadata + remove managed_by from payload --- .../providers/aws_secret_manager/retrieve | 27 ++++++++++++++----- parameters/providers/aws_secret_manager/store | 23 +++++++++------- parameters/providers/azure_key_vault/retrieve | 17 ++++++++---- parameters/providers/azure_key_vault/store | 7 ++++- parameters/providers/hashicorp_vault/retrieve | 9 ++++++- parameters/providers/hashicorp_vault/store | 16 +++++++---- parameters/providers/parameter_store/retrieve | 8 +++++- parameters/providers/parameter_store/store | 7 +++-- .../aws_secret_manager/retrieve.bats | 1 + .../providers/aws_secret_manager/store.bats | 19 +++++++++---- .../providers/azure_key_vault/retrieve.bats | 1 + .../providers/hashicorp_vault/delete.bats | 4 +-- .../providers/hashicorp_vault/retrieve.bats | 5 ++-- .../providers/hashicorp_vault/store.bats | 10 ++++--- .../providers/parameter_store/delete.bats | 4 +-- .../providers/parameter_store/retrieve.bats | 5 ++-- .../providers/parameter_store/store.bats | 11 +++++--- 17 files changed, 124 insertions(+), 50 deletions(-) diff --git a/parameters/providers/aws_secret_manager/retrieve b/parameters/providers/aws_secret_manager/retrieve index 350d0ef2..32a2381e 100755 --- a/parameters/providers/aws_secret_manager/retrieve +++ b/parameters/providers/aws_secret_manager/retrieve @@ -1,7 +1,11 @@ #!/bin/bash set -euo pipefail -# Retrieves a value from AWS Secrets Manager by external_id. +# Retrieves a value from AWS Secrets Manager. +# +# Versioning: +# - If $CONTEXT.version_id is present, retrieves that specific historical version. +# - Otherwise retrieves AWSCURRENT (the latest version). # # Semantics: # - Success → return {value: ""} (extracted from JSON envelope) @@ -12,12 +16,20 @@ set -euo pipefail SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" +VERSION_ID=$(echo "$CONTEXT" | jq -r '.version_id // empty') + +get_args=( + --region "$AWS_REGION" + --secret-id "$SECRET_NAME" + --query SecretString + --output text +) +if [ -n "$VERSION_ID" ]; then + get_args+=(--version-id "$VERSION_ID") +fi + err_file=$(mktemp) -if SECRET_STRING=$(aws secretsmanager get-secret-value \ - --region "$AWS_REGION" \ - --secret-id "$SECRET_NAME" \ - --query SecretString \ - --output text 2>"$err_file"); then +if SECRET_STRING=$(aws secretsmanager get-secret-value "${get_args[@]}" 2>"$err_file"); then rm -f "$err_file" STORED_VALUE=$(echo "$SECRET_STRING" | jq -r '.value // empty') jq -n --arg value "$STORED_VALUE" '{value: $value}' @@ -35,6 +47,9 @@ else log error " • IAM principal lacks secretsmanager:GetSecretValue" log error " • KMS key permission missing (kms:Decrypt) for CMK-encrypted secrets" log error " • Region '$AWS_REGION' unreachable" + if [ -n "$VERSION_ID" ]; then + log error " • Requested version_id '$VERSION_ID' does not exist for this secret" + fi log error "" log error "🔧 How to fix:" log error " • Verify identity: aws sts get-caller-identity --region $AWS_REGION" diff --git a/parameters/providers/aws_secret_manager/store b/parameters/providers/aws_secret_manager/store index 8124be27..cbf92867 100755 --- a/parameters/providers/aws_secret_manager/store +++ b/parameters/providers/aws_secret_manager/store @@ -14,6 +14,10 @@ set -euo pipefail # This keeps the version history without paying for a separate secret per # version (each AWS SM secret costs $0.40/month regardless of version count). # +# The VersionId returned by AWS is included in the result metadata so the +# platform can persist it per value version. Retrieve uses it to fetch a +# specific historical version when the platform passes it back. +# # Required env: PARAMETER_ID, PARAMETER_VALUE, AWS_REGION, SM_NAME_PREFIX # Optional env: SM_KMS_KEY_ID @@ -27,34 +31,31 @@ SECRET_PAYLOAD=$(jq -nc \ --arg value "$PARAMETER_VALUE" \ --arg stored_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --arg external_id "$EXTERNAL_ID" \ - --arg managed_by "nullplatform" \ - '{parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id, managed_by: $managed_by}') + '{parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id}') create_args=( --region "$AWS_REGION" --name "$SECRET_NAME" --secret-string "$SECRET_PAYLOAD" --tags "Key=managed_by,Value=nullplatform" - --query ARN - --output text + --output json ) if [ -n "${SM_KMS_KEY_ID:-}" ]; then create_args+=(--kms-key-id "$SM_KMS_KEY_ID") fi err_file=$(mktemp) -if SECRET_ARN=$(aws secretsmanager create-secret "${create_args[@]}" 2>"$err_file"); then +if RESULT=$(aws secretsmanager create-secret "${create_args[@]}" 2>"$err_file"); then rm -f "$err_file" elif grep -q "ResourceExistsException" "$err_file"; then rm -f "$err_file" log debug "Secret '$SECRET_NAME' exists; adding new version (history preserved by AWS SM)" err_file=$(mktemp) - if ! SECRET_ARN=$(aws secretsmanager put-secret-value \ + if ! RESULT=$(aws secretsmanager put-secret-value \ --region "$AWS_REGION" \ --secret-id "$SECRET_NAME" \ --secret-string "$SECRET_PAYLOAD" \ - --query ARN \ - --output text 2>"$err_file"); then + --output json 2>"$err_file"); then err=$(cat "$err_file") rm -f "$err_file" log error "❌ Failed to add new version to secret '$SECRET_NAME'" @@ -84,9 +85,13 @@ else exit 1 fi +SECRET_ARN=$(echo "$RESULT" | jq -r '.ARN') +VERSION_ID=$(echo "$RESULT" | jq -r '.VersionId') + jq -n \ --arg external_id "$EXTERNAL_ID" \ --arg secret_arn "$SECRET_ARN" \ --arg secret_name "$SECRET_NAME" \ --arg region "$AWS_REGION" \ - '{external_id: $external_id, metadata: {secret_arn: $secret_arn, secret_name: $secret_name, region: $region}}' + --arg version_id "$VERSION_ID" \ + '{external_id: $external_id, metadata: {secret_arn: $secret_arn, secret_name: $secret_name, region: $region, version_id: $version_id}}' diff --git a/parameters/providers/azure_key_vault/retrieve b/parameters/providers/azure_key_vault/retrieve index e12a614f..ebb15b76 100755 --- a/parameters/providers/azure_key_vault/retrieve +++ b/parameters/providers/azure_key_vault/retrieve @@ -9,12 +9,19 @@ set -euo pipefail AKV_SUFFIX=$(echo "$EXTERNAL_ID" | tr '/=' '--') SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" +# Optional historical version via .version_id (AKV uses hex strings). +VERSION_ID=$(echo "$CONTEXT" | jq -r '.version_id // empty') + +show_args=( + --vault-name "$AZ_VAULT_NAME" + --name "$SECRET_NAME" + --query value + --output tsv +) +[ -n "$VERSION_ID" ] && show_args+=(--version "$VERSION_ID") + err_file=$(mktemp) -if VALUE=$(az keyvault secret show \ - --vault-name "$AZ_VAULT_NAME" \ - --name "$SECRET_NAME" \ - --query value \ - --output tsv 2>"$err_file"); then +if VALUE=$(az keyvault secret show "${show_args[@]}" 2>"$err_file"); then rm -f "$err_file" jq -n --arg value "$VALUE" '{value: $value}' else diff --git a/parameters/providers/azure_key_vault/store b/parameters/providers/azure_key_vault/store index 836a8bf1..0bf0ab4d 100755 --- a/parameters/providers/azure_key_vault/store +++ b/parameters/providers/azure_key_vault/store @@ -41,9 +41,14 @@ if ! AKV_ID=$(az keyvault secret set \ exit 1 fi +# AKV id URL format: https://.vault.azure.net/secrets// +# The last path component is the version id (hex string). +VERSION_ID="${AKV_ID##*/}" + jq -n \ --arg external_id "$EXTERNAL_ID" \ --arg secret_id "$AKV_ID" \ --arg secret_name "$SECRET_NAME" \ --arg vault_name "$AZ_VAULT_NAME" \ - '{external_id: $external_id, metadata: {azure_secret_id: $secret_id, secret_name: $secret_name, vault_name: $vault_name}}' + --arg version_id "$VERSION_ID" \ + '{external_id: $external_id, metadata: {azure_secret_id: $secret_id, secret_name: $secret_name, vault_name: $vault_name, version_id: $version_id}}' diff --git a/parameters/providers/hashicorp_vault/retrieve b/parameters/providers/hashicorp_vault/retrieve index a7a8f31d..50bcb489 100755 --- a/parameters/providers/hashicorp_vault/retrieve +++ b/parameters/providers/hashicorp_vault/retrieve @@ -12,9 +12,16 @@ set -euo pipefail VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" +# Optional historical version via .version_id (Vault KV v2 uses integer versions) +VERSION_ID=$(echo "$CONTEXT" | jq -r '.version_id // empty') +URL="$VAULT_ADDR/v1/$VAULT_PATH" +if [ -n "$VERSION_ID" ]; then + URL="${URL}?version=${VERSION_ID}" +fi + if ! RESPONSE=$(curl -s -w "\n%{http_code}" \ -H "X-Vault-Token: $VAULT_TOKEN" \ - "$VAULT_ADDR/v1/$VAULT_PATH" 2>/dev/null); then + "$URL" 2>/dev/null); then log error "❌ Network error calling Vault at $VAULT_ADDR" log error "" log error "💡 Possible causes:" diff --git a/parameters/providers/hashicorp_vault/store b/parameters/providers/hashicorp_vault/store index b2cb80e6..5e11f4a8 100755 --- a/parameters/providers/hashicorp_vault/store +++ b/parameters/providers/hashicorp_vault/store @@ -9,6 +9,10 @@ set -euo pipefail # a new version, all retained inside the same path, and the latest is returned # on read by default. No special logic needed; the POST IS the versioning. # +# Vault returns the new version number in the response body +# (.data.version). We include it in metadata so the platform can persist it +# and pass it back to retrieve when fetching a specific historical version. +# # Required env: PARAMETER_ID, PARAMETER_VALUE, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX source "$PARAMETERS_ROOT/utils/build_external_id" @@ -21,13 +25,12 @@ PAYLOAD=$(jq -nc \ --arg value "$PARAMETER_VALUE" \ --arg stored_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --arg external_id "$EXTERNAL_ID" \ - --arg managed_by "nullplatform" \ - '{data: {parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id, managed_by: $managed_by}}') + '{data: {parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id}}') -if ! curl -s -X POST \ +if ! RESPONSE=$(curl -s -X POST \ -H "X-Vault-Token: $VAULT_TOKEN" \ "$VAULT_ADDR/v1/$VAULT_PATH" \ - -d "$PAYLOAD" >/dev/null; then + -d "$PAYLOAD"); then log error "❌ Failed to store parameter in Vault at $VAULT_ADDR" log error "" log error "💡 Possible causes:" @@ -41,7 +44,10 @@ if ! curl -s -X POST \ exit 1 fi +VERSION_ID=$(echo "$RESPONSE" | jq -r '.data.version // empty') + jq -n \ --arg external_id "$EXTERNAL_ID" \ --arg vault_path "$VAULT_PATH" \ - '{external_id: $external_id, metadata: {vault_path: $vault_path}}' + --arg version_id "$VERSION_ID" \ + '{external_id: $external_id, metadata: {vault_path: $vault_path, version_id: $version_id}}' diff --git a/parameters/providers/parameter_store/retrieve b/parameters/providers/parameter_store/retrieve index e00dd44d..25fdf944 100755 --- a/parameters/providers/parameter_store/retrieve +++ b/parameters/providers/parameter_store/retrieve @@ -13,10 +13,16 @@ set -euo pipefail PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" +# Optional historical version via .version_id (PS uses integer versions, addressed +# by suffixing the parameter name with `:`). +VERSION_ID=$(echo "$CONTEXT" | jq -r '.version_id // empty') +LOOKUP_NAME="$PARAM_NAME" +[ -n "$VERSION_ID" ] && LOOKUP_NAME="${PARAM_NAME}:${VERSION_ID}" + err_file=$(mktemp) if VALUE=$(aws ssm get-parameter \ --region "$AWS_REGION" \ - --name "$PARAM_NAME" \ + --name "$LOOKUP_NAME" \ --with-decryption \ --query Parameter.Value \ --output text 2>"$err_file"); then diff --git a/parameters/providers/parameter_store/store b/parameters/providers/parameter_store/store index 020580fb..b4003aa9 100755 --- a/parameters/providers/parameter_store/store +++ b/parameters/providers/parameter_store/store @@ -38,7 +38,7 @@ if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then fi err_file=$(mktemp) -if ! aws ssm put-parameter "${put_args[@]}" >/dev/null 2>"$err_file"; then +if ! PUT_RESULT=$(aws ssm put-parameter "${put_args[@]}" --output json 2>"$err_file"); then err=$(cat "$err_file") rm -f "$err_file" log error "❌ Failed to store parameter in AWS Parameter Store" @@ -58,6 +58,8 @@ if ! aws ssm put-parameter "${put_args[@]}" >/dev/null 2>"$err_file"; then fi rm -f "$err_file" +VERSION_ID=$(echo "$PUT_RESULT" | jq -r '.Version // empty') + # Best-effort tagging — Parameter Store tags survive overwrites; we only need # to (re)apply on first creation. Suppress errors here: missing tags don't # break correctness. @@ -73,4 +75,5 @@ jq -n \ --arg region "$AWS_REGION" \ --arg type "$SSM_TYPE" \ --arg tier "$PS_TIER" \ - '{external_id: $external_id, metadata: {parameter_name: $parameter_name, region: $region, type: $type, tier: $tier}}' + --arg version_id "$VERSION_ID" \ + '{external_id: $external_id, metadata: {parameter_name: $parameter_name, region: $region, type: $type, tier: $tier, version_id: $version_id}}' diff --git a/parameters/tests/providers/aws_secret_manager/retrieve.bats b/parameters/tests/providers/aws_secret_manager/retrieve.bats index d70fa369..9d6a3b66 100644 --- a/parameters/tests/providers/aws_secret_manager/retrieve.bats +++ b/parameters/tests/providers/aws_secret_manager/retrieve.bats @@ -41,6 +41,7 @@ EOF export SM_NAME_PREFIX="nullplatform/" export EXTERNAL_ID="abc-123" + export CONTEXT='{}' export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/aws_secret_manager/store.bats b/parameters/tests/providers/aws_secret_manager/store.bats index 3174319d..e98b47f0 100644 --- a/parameters/tests/providers/aws_secret_manager/store.bats +++ b/parameters/tests/providers/aws_secret_manager/store.bats @@ -44,7 +44,7 @@ mode="${MOCK_AWS_MODE:-success}" if [[ "$*" == *"create-secret"* ]]; then case "$mode" in success) - echo "arn:aws:secretsmanager:us-east-1:111122223333:secret:nullplatform/test-AbCdEf" + echo '{"ARN":"arn:aws:secretsmanager:us-east-1:111122223333:secret:nullplatform/test-AbCdEf","VersionId":"v1-create-uuid","Name":"nullplatform/test"}' exit 0 ;; exists|put_error) @@ -59,7 +59,7 @@ if [[ "$*" == *"create-secret"* ]]; then elif [[ "$*" == *"put-secret-value"* ]]; then case "$mode" in exists) - echo "arn:aws:secretsmanager:us-east-1:111122223333:secret:nullplatform/test-AbCdEf" + echo '{"ARN":"arn:aws:secretsmanager:us-east-1:111122223333:secret:nullplatform/test-AbCdEf","VersionId":"v2-put-uuid"}' exit 0 ;; put_error) @@ -122,11 +122,20 @@ EOF [[ "$captured" != *"put-secret-value"* ]] } -@test "aws_secret_manager store: payload includes managed_by=nullplatform" { +@test "aws_secret_manager store: returns version_id in metadata" { run bash -c "$DEPS; source $SCRIPT" - captured=$(cat "$AWS_LOG") - assert_contains "$captured" '"managed_by":"nullplatform"' + assert_equal "$status" "0" + version_id=$(echo "$output" | jq -r '.metadata.version_id') + assert_equal "$version_id" "v1-create-uuid" +} + +@test "aws_secret_manager store: PutSecretValue returns its own version_id (different from create)" { + run bash -c "$DEPS; MOCK_AWS_MODE=exists source $SCRIPT" + + assert_equal "$status" "0" + version_id=$(echo "$output" | jq -r '.metadata.version_id') + assert_equal "$version_id" "v2-put-uuid" } @test "aws_secret_manager store: ResourceExistsException falls through to PutSecretValue" { diff --git a/parameters/tests/providers/azure_key_vault/retrieve.bats b/parameters/tests/providers/azure_key_vault/retrieve.bats index 89f3f0aa..d5fe6ab0 100644 --- a/parameters/tests/providers/azure_key_vault/retrieve.bats +++ b/parameters/tests/providers/azure_key_vault/retrieve.bats @@ -41,6 +41,7 @@ EOF export AZ_SECRET_PREFIX="parameters-" export EXTERNAL_ID="abc-123" + export CONTEXT='{}' export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/hashicorp_vault/delete.bats b/parameters/tests/providers/hashicorp_vault/delete.bats index effc3207..92b2a47f 100644 --- a/parameters/tests/providers/hashicorp_vault/delete.bats +++ b/parameters/tests/providers/hashicorp_vault/delete.bats @@ -29,7 +29,7 @@ EOF export VAULT_ADDR="https://vault.example.com" export VAULT_TOKEN="hvs.test-token" - export VAULT_PATH_PREFIX="secret/data/parameters" + export VAULT_PATH_PREFIX="secret/data/nullplatform" export EXTERNAL_ID="abc-123" export DEPS="source $PARAMETERS_DIR/utils/log" @@ -90,7 +90,7 @@ EOF captured=$(cat "$CURL_LOG") assert_contains "$captured" "-X DELETE" assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" - assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/abc-123" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/nullplatform/abc-123" } @test "vault delete: honors custom VAULT_PATH_PREFIX" { diff --git a/parameters/tests/providers/hashicorp_vault/retrieve.bats b/parameters/tests/providers/hashicorp_vault/retrieve.bats index 3f4797a5..98139b6d 100644 --- a/parameters/tests/providers/hashicorp_vault/retrieve.bats +++ b/parameters/tests/providers/hashicorp_vault/retrieve.bats @@ -29,9 +29,10 @@ EOF export VAULT_ADDR="https://vault.example.com" export VAULT_TOKEN="hvs.test-token" - export VAULT_PATH_PREFIX="secret/data/parameters" + export VAULT_PATH_PREFIX="secret/data/nullplatform" export EXTERNAL_ID="abc-123" + export CONTEXT='{}' export DEPS="source $PARAMETERS_DIR/utils/log" } @@ -81,7 +82,7 @@ EOF captured=$(cat "$CURL_LOG") assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" - assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/abc-123" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/nullplatform/abc-123" } @test "vault retrieve: honors custom VAULT_PATH_PREFIX" { diff --git a/parameters/tests/providers/hashicorp_vault/store.bats b/parameters/tests/providers/hashicorp_vault/store.bats index 848df402..069d093e 100644 --- a/parameters/tests/providers/hashicorp_vault/store.bats +++ b/parameters/tests/providers/hashicorp_vault/store.bats @@ -36,7 +36,9 @@ EOF cat > "$BATS_TEST_TMPDIR/bin/curl" << EOF #!/bin/bash echo "ARGS: \$@" >> "$CURL_LOG" -exit \${MOCK_CURL_EXIT:-0} +if [ "\${MOCK_CURL_EXIT:-0}" -ne 0 ]; then exit \$MOCK_CURL_EXIT; fi +# Vault KV v2 returns the new version number in response body +echo '{"data":{"created_time":"2026-06-23T00:00:00Z","version":3,"deletion_time":"","destroyed":false}}' EOF chmod +x "$BATS_TEST_TMPDIR/bin/curl" @@ -44,7 +46,7 @@ EOF export VAULT_ADDR="https://vault.example.com" export VAULT_TOKEN="hvs.test-token" - export VAULT_PATH_PREFIX="secret/data/parameters" + export VAULT_PATH_PREFIX="secret/data/nullplatform" export PARAMETER_ID=42 export PARAMETER_VALUE="my-secret" export CONTEXT='{ @@ -87,7 +89,7 @@ EOF assert_equal "$status" "0" vault_path=$(echo "$output" | jq -r '.metadata.vault_path') - assert_contains "$vault_path" "secret/data/parameters/organization=acme-1255165411" + assert_contains "$vault_path" "secret/data/nullplatform/organization=acme-1255165411" assert_contains "$vault_path" "/42" } @@ -97,7 +99,7 @@ EOF captured=$(cat "$CURL_LOG") assert_contains "$captured" "-X POST" assert_contains "$captured" "-H X-Vault-Token: hvs.test-token" - assert_contains "$captured" "https://vault.example.com/v1/secret/data/parameters/organization=acme-1255165411" + assert_contains "$captured" "https://vault.example.com/v1/secret/data/nullplatform/organization=acme-1255165411" } @test "vault store: POST body contains parameter_id, value, external_id, stored_at" { diff --git a/parameters/tests/providers/parameter_store/delete.bats b/parameters/tests/providers/parameter_store/delete.bats index c4c7cec5..f2bc7d43 100644 --- a/parameters/tests/providers/parameter_store/delete.bats +++ b/parameters/tests/providers/parameter_store/delete.bats @@ -36,7 +36,7 @@ EOF export PATH="$BATS_TEST_TMPDIR/bin:$PATH" export AWS_REGION="us-east-1" - export PS_NAME_PREFIX="/nullplatform/parameters/" + export PS_NAME_PREFIX="/nullplatform/" export EXTERNAL_ID="abc-123" export DEPS="source $PARAMETERS_DIR/utils/log" @@ -79,5 +79,5 @@ EOF captured=$(cat "$AWS_LOG") assert_contains "$captured" "ssm delete-parameter" assert_contains "$captured" "--region us-east-1" - assert_contains "$captured" "--name /nullplatform/parameters/abc-123" + assert_contains "$captured" "--name /nullplatform/abc-123" } diff --git a/parameters/tests/providers/parameter_store/retrieve.bats b/parameters/tests/providers/parameter_store/retrieve.bats index dce76d7f..b4e3199a 100644 --- a/parameters/tests/providers/parameter_store/retrieve.bats +++ b/parameters/tests/providers/parameter_store/retrieve.bats @@ -38,9 +38,10 @@ EOF export PATH="$BATS_TEST_TMPDIR/bin:$PATH" export AWS_REGION="us-east-1" - export PS_NAME_PREFIX="/nullplatform/parameters/" + export PS_NAME_PREFIX="/nullplatform/" export EXTERNAL_ID="abc-123" + export CONTEXT='{}' export DEPS="source $PARAMETERS_DIR/utils/log" } @@ -81,6 +82,6 @@ EOF captured=$(cat "$AWS_LOG") assert_contains "$captured" "ssm get-parameter" assert_contains "$captured" "--region us-east-1" - assert_contains "$captured" "--name /nullplatform/parameters/abc-123" + assert_contains "$captured" "--name /nullplatform/abc-123" assert_contains "$captured" "--with-decryption" } diff --git a/parameters/tests/providers/parameter_store/store.bats b/parameters/tests/providers/parameter_store/store.bats index 2f8ef4cf..c550e5af 100644 --- a/parameters/tests/providers/parameter_store/store.bats +++ b/parameters/tests/providers/parameter_store/store.bats @@ -31,14 +31,19 @@ EOF cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF #!/bin/bash echo "ARGS: \$@" >> "$AWS_LOG" -exit \${MOCK_AWS_EXIT:-0} +if [ "\${MOCK_AWS_EXIT:-0}" -ne 0 ]; then exit \$MOCK_AWS_EXIT; fi +# put-parameter --output json returns { "Version": N, "Tier": "..." } +if [[ "\$*" == *"put-parameter"* ]]; then + echo '{"Version":7,"Tier":"Standard"}' +fi +exit 0 EOF chmod +x "$BATS_TEST_TMPDIR/bin/aws" export PATH="$BATS_TEST_TMPDIR/bin:$PATH" export AWS_REGION="us-east-1" - export PS_NAME_PREFIX="/nullplatform/parameters/" + export PS_NAME_PREFIX="/nullplatform/" export PS_KMS_KEY_ID="" export PS_TIER="Standard" export PARAMETER_ID=42 @@ -107,7 +112,7 @@ EOF run bash -c "$DEPS; source $SCRIPT" param_name=$(echo "$output" | jq -r '.metadata.parameter_name') - assert_contains "$param_name" "/nullplatform/parameters/organization=acme-1255165411" + assert_contains "$param_name" "/nullplatform/organization=acme-1255165411" } @test "parameter_store store: passes tier flag" { From adb06bcd9b751448c675c62cacb324e7988eee26 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Tue, 23 Jun 2026 16:15:50 -0300 Subject: [PATCH 07/41] feat(parameters): encode version_id into external_id suffix (no platform-side persistence) --- parameters/build_context | 10 +++++++ .../providers/aws_secret_manager/delete | 3 ++- .../aws_secret_manager/docs/architecture.md | 27 ++++++++++++++++--- .../providers/aws_secret_manager/retrieve | 13 +++++---- parameters/providers/aws_secret_manager/store | 7 +++-- parameters/providers/azure_key_vault/delete | 2 +- parameters/providers/azure_key_vault/retrieve | 10 +++---- parameters/providers/azure_key_vault/store | 6 +++-- parameters/providers/hashicorp_vault/delete | 2 +- parameters/providers/hashicorp_vault/retrieve | 9 +++---- parameters/providers/hashicorp_vault/store | 6 +++-- parameters/providers/parameter_store/delete | 2 +- parameters/providers/parameter_store/retrieve | 9 +++---- parameters/providers/parameter_store/store | 6 +++-- .../providers/aws_secret_manager/delete.bats | 2 ++ .../aws_secret_manager/retrieve.bats | 2 ++ .../providers/aws_secret_manager/store.bats | 18 +++++++------ .../providers/azure_key_vault/delete.bats | 2 ++ .../providers/azure_key_vault/retrieve.bats | 2 ++ .../providers/azure_key_vault/store.bats | 5 ++-- .../providers/hashicorp_vault/delete.bats | 2 ++ .../providers/hashicorp_vault/retrieve.bats | 2 ++ .../providers/hashicorp_vault/store.bats | 7 ++--- .../providers/parameter_store/delete.bats | 2 ++ .../providers/parameter_store/retrieve.bats | 2 ++ .../providers/parameter_store/store.bats | 5 ++-- 26 files changed, 110 insertions(+), 53 deletions(-) diff --git a/parameters/build_context b/parameters/build_context index 212cbdec..e545daf6 100755 --- a/parameters/build_context +++ b/parameters/build_context @@ -31,6 +31,16 @@ source "$SCRIPT_DIR/utils/get_config_value" # --- Notification fields --- export EXTERNAL_ID=$(echo "$CONTEXT" | jq -r '.external_id // empty') +# external_id format: # +# Split into path (used for backend naming) and version (optional, used by retrieve). +# Store rebuilds EXTERNAL_ID after the backend returns the new version_id. +if [[ "$EXTERNAL_ID" == *"#"* ]]; then + export EXTERNAL_ID_PATH="${EXTERNAL_ID%#*}" + export EXTERNAL_ID_VERSION="${EXTERNAL_ID##*#}" +else + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" +fi export PARAMETER_ID=$(echo "$CONTEXT" | jq -r '.parameter_id // empty') export PARAMETER_VALUE=$(echo "$CONTEXT" | jq -r '.value // empty') export PARAMETER_NAME=$(echo "$CONTEXT" | jq -r '.parameter_name // empty') diff --git a/parameters/providers/aws_secret_manager/delete b/parameters/providers/aws_secret_manager/delete index cc6c8835..f8f6da87 100755 --- a/parameters/providers/aws_secret_manager/delete +++ b/parameters/providers/aws_secret_manager/delete @@ -12,7 +12,8 @@ set -euo pipefail # # Required env: EXTERNAL_ID, AWS_REGION, SM_NAME_PREFIX -SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" +# Delete removes ALL versions; EXTERNAL_ID_VERSION suffix (if present) is ignored. +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID_PATH}" err_file=$(mktemp) if aws secretsmanager delete-secret \ diff --git a/parameters/providers/aws_secret_manager/docs/architecture.md b/parameters/providers/aws_secret_manager/docs/architecture.md index ac81b44c..0344ae67 100644 --- a/parameters/providers/aws_secret_manager/docs/architecture.md +++ b/parameters/providers/aws_secret_manager/docs/architecture.md @@ -85,17 +85,38 @@ nullplatform parameter values are **immutable**. Each update of the same (parame AWS Secrets Manager has native version retention. We use it as the source of truth: -- **First `store`** for a given external_id → `CreateSecret`. A new secret is created with version 1. -- **Subsequent `store`** for the same external_id → `PutSecretValue`. A new version is appended, and `AWSCURRENT` moves to it. +- **First `store`** for a given path → `CreateSecret`. A new secret is created with version 1. +- **Subsequent `store`** for the same path → `PutSecretValue`. A new version is appended, and `AWSCURRENT` moves to it. - **All previous versions are retained inside the same secret** (up to AWS SM's 100-version cap, after which the oldest unlabeled versions are pruned automatically). +### Version identity in external_id + +The `external_id` returned by `store` encodes both the path and the version: + +``` +# +``` + +For AWS SM, `version_id` is the `VersionId` UUID returned by `CreateSecret` / `PutSecretValue`. Example: + +``` +organization=acme-1255165411/.../DB_PASSWORD-42#abcd1234-uuid-5678-version +``` + +This means nullplatform — which already persists and re-sends `external_id` on every operation — automatically retains the version reference without needing a separate field. On `retrieve`: + +- If `external_id` carries `#version` → fetch that specific historical version. +- If `external_id` has no `#` suffix → fetch `AWSCURRENT` (latest). + +On `delete`, the version suffix is ignored — `DeleteSecret` removes all versions of the secret. + ### Why this matters for cost AWS SM charges $0.40 per secret per month, **regardless of version count**. Putting versions in the same secret is essentially free; creating a new secret per version would multiply cost linearly with update frequency. The implementation enforces the cheap path. ### Why this matters for history -Storing all versions in a single secret means operators can view and (with the right API call) restore older values. The version history is the audit trail. +Storing all versions in a single secret means operators can view and restore older values. Restoration is platform-orchestrated: read an old version via `retrieve(external_id with #version)`, then store the value again — that becomes the new latest version. --- diff --git a/parameters/providers/aws_secret_manager/retrieve b/parameters/providers/aws_secret_manager/retrieve index 32a2381e..5addb766 100755 --- a/parameters/providers/aws_secret_manager/retrieve +++ b/parameters/providers/aws_secret_manager/retrieve @@ -14,9 +14,8 @@ set -euo pipefail # # Required env: EXTERNAL_ID, AWS_REGION, SM_NAME_PREFIX -SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID}" - -VERSION_ID=$(echo "$CONTEXT" | jq -r '.version_id // empty') +# EXTERNAL_ID_PATH and EXTERNAL_ID_VERSION are set by build_context (split on `#`). +SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID_PATH}" get_args=( --region "$AWS_REGION" @@ -24,8 +23,8 @@ get_args=( --query SecretString --output text ) -if [ -n "$VERSION_ID" ]; then - get_args+=(--version-id "$VERSION_ID") +if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + get_args+=(--version-id "$EXTERNAL_ID_VERSION") fi err_file=$(mktemp) @@ -47,8 +46,8 @@ else log error " • IAM principal lacks secretsmanager:GetSecretValue" log error " • KMS key permission missing (kms:Decrypt) for CMK-encrypted secrets" log error " • Region '$AWS_REGION' unreachable" - if [ -n "$VERSION_ID" ]; then - log error " • Requested version_id '$VERSION_ID' does not exist for this secret" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' does not exist for this secret" fi log error "" log error "🔧 How to fix:" diff --git a/parameters/providers/aws_secret_manager/store b/parameters/providers/aws_secret_manager/store index cbf92867..573917d4 100755 --- a/parameters/providers/aws_secret_manager/store +++ b/parameters/providers/aws_secret_manager/store @@ -88,10 +88,13 @@ fi SECRET_ARN=$(echo "$RESULT" | jq -r '.ARN') VERSION_ID=$(echo "$RESULT" | jq -r '.VersionId') +# Encode version into external_id (the canonical handle nullplatform persists). +# Retrieve will parse this to fetch the specific version; delete ignores the suffix. +[ -n "$VERSION_ID" ] && EXTERNAL_ID="${EXTERNAL_ID}#${VERSION_ID}" + jq -n \ --arg external_id "$EXTERNAL_ID" \ --arg secret_arn "$SECRET_ARN" \ --arg secret_name "$SECRET_NAME" \ --arg region "$AWS_REGION" \ - --arg version_id "$VERSION_ID" \ - '{external_id: $external_id, metadata: {secret_arn: $secret_arn, secret_name: $secret_name, region: $region, version_id: $version_id}}' + '{external_id: $external_id, metadata: {secret_arn: $secret_arn, secret_name: $secret_name, region: $region}}' diff --git a/parameters/providers/azure_key_vault/delete b/parameters/providers/azure_key_vault/delete index 0663cb97..f3fab6d2 100755 --- a/parameters/providers/azure_key_vault/delete +++ b/parameters/providers/azure_key_vault/delete @@ -7,7 +7,7 @@ set -euo pipefail # Required env: EXTERNAL_ID (canonical slash form), AZ_VAULT_NAME, AZ_SECRET_PREFIX # AKV stores with dashes (no `/` or `=`); transform from canonical form. -AKV_SUFFIX=$(echo "$EXTERNAL_ID" | tr '/=' '--') +AKV_SUFFIX=$(echo "$EXTERNAL_ID_PATH" | tr '/=' '--') SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" err_file=$(mktemp) diff --git a/parameters/providers/azure_key_vault/retrieve b/parameters/providers/azure_key_vault/retrieve index ebb15b76..7bb2b2b8 100755 --- a/parameters/providers/azure_key_vault/retrieve +++ b/parameters/providers/azure_key_vault/retrieve @@ -5,20 +5,18 @@ set -euo pipefail # # Required env: EXTERNAL_ID (canonical slash form), AZ_VAULT_NAME, AZ_SECRET_PREFIX -# AKV stores with dashes (no `/` or `=`); transform from canonical form. -AKV_SUFFIX=$(echo "$EXTERNAL_ID" | tr '/=' '--') +# EXTERNAL_ID_PATH and EXTERNAL_ID_VERSION are set by build_context. +# AKV stores with dashes (no `/` or `=`); transform from canonical path. +AKV_SUFFIX=$(echo "$EXTERNAL_ID_PATH" | tr '/=' '--') SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" -# Optional historical version via .version_id (AKV uses hex strings). -VERSION_ID=$(echo "$CONTEXT" | jq -r '.version_id // empty') - show_args=( --vault-name "$AZ_VAULT_NAME" --name "$SECRET_NAME" --query value --output tsv ) -[ -n "$VERSION_ID" ] && show_args+=(--version "$VERSION_ID") +[ -n "${EXTERNAL_ID_VERSION:-}" ] && show_args+=(--version "$EXTERNAL_ID_VERSION") err_file=$(mktemp) if VALUE=$(az keyvault secret show "${show_args[@]}" 2>"$err_file"); then diff --git a/parameters/providers/azure_key_vault/store b/parameters/providers/azure_key_vault/store index 0bf0ab4d..e3403ea4 100755 --- a/parameters/providers/azure_key_vault/store +++ b/parameters/providers/azure_key_vault/store @@ -45,10 +45,12 @@ fi # The last path component is the version id (hex string). VERSION_ID="${AKV_ID##*/}" +# Encode version into external_id (the canonical handle nullplatform persists). +[ -n "$VERSION_ID" ] && EXTERNAL_ID="${EXTERNAL_ID}#${VERSION_ID}" + jq -n \ --arg external_id "$EXTERNAL_ID" \ --arg secret_id "$AKV_ID" \ --arg secret_name "$SECRET_NAME" \ --arg vault_name "$AZ_VAULT_NAME" \ - --arg version_id "$VERSION_ID" \ - '{external_id: $external_id, metadata: {azure_secret_id: $secret_id, secret_name: $secret_name, vault_name: $vault_name, version_id: $version_id}}' + '{external_id: $external_id, metadata: {azure_secret_id: $secret_id, secret_name: $secret_name, vault_name: $vault_name}}' diff --git a/parameters/providers/hashicorp_vault/delete b/parameters/providers/hashicorp_vault/delete index 0d711039..8b513069 100755 --- a/parameters/providers/hashicorp_vault/delete +++ b/parameters/providers/hashicorp_vault/delete @@ -10,7 +10,7 @@ set -euo pipefail # # Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX -VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID_PATH" if ! RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \ -H "X-Vault-Token: $VAULT_TOKEN" \ diff --git a/parameters/providers/hashicorp_vault/retrieve b/parameters/providers/hashicorp_vault/retrieve index 50bcb489..5d63b105 100755 --- a/parameters/providers/hashicorp_vault/retrieve +++ b/parameters/providers/hashicorp_vault/retrieve @@ -10,13 +10,12 @@ set -euo pipefail # # Required env: EXTERNAL_ID, VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX -VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID" +# EXTERNAL_ID_PATH and EXTERNAL_ID_VERSION are set by build_context. +VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID_PATH" -# Optional historical version via .version_id (Vault KV v2 uses integer versions) -VERSION_ID=$(echo "$CONTEXT" | jq -r '.version_id // empty') URL="$VAULT_ADDR/v1/$VAULT_PATH" -if [ -n "$VERSION_ID" ]; then - URL="${URL}?version=${VERSION_ID}" +if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + URL="${URL}?version=${EXTERNAL_ID_VERSION}" fi if ! RESPONSE=$(curl -s -w "\n%{http_code}" \ diff --git a/parameters/providers/hashicorp_vault/store b/parameters/providers/hashicorp_vault/store index 5e11f4a8..465ce6a4 100755 --- a/parameters/providers/hashicorp_vault/store +++ b/parameters/providers/hashicorp_vault/store @@ -46,8 +46,10 @@ fi VERSION_ID=$(echo "$RESPONSE" | jq -r '.data.version // empty') +# Encode version into external_id (the canonical handle nullplatform persists). +[ -n "$VERSION_ID" ] && EXTERNAL_ID="${EXTERNAL_ID}#${VERSION_ID}" + jq -n \ --arg external_id "$EXTERNAL_ID" \ --arg vault_path "$VAULT_PATH" \ - --arg version_id "$VERSION_ID" \ - '{external_id: $external_id, metadata: {vault_path: $vault_path, version_id: $version_id}}' + '{external_id: $external_id, metadata: {vault_path: $vault_path}}' diff --git a/parameters/providers/parameter_store/delete b/parameters/providers/parameter_store/delete index 84c2be3e..f6538447 100755 --- a/parameters/providers/parameter_store/delete +++ b/parameters/providers/parameter_store/delete @@ -10,7 +10,7 @@ set -euo pipefail # # Required env: EXTERNAL_ID, AWS_REGION, PS_NAME_PREFIX -PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID_PATH}" err_file=$(mktemp) if aws ssm delete-parameter \ diff --git a/parameters/providers/parameter_store/retrieve b/parameters/providers/parameter_store/retrieve index 25fdf944..c9cf6db9 100755 --- a/parameters/providers/parameter_store/retrieve +++ b/parameters/providers/parameter_store/retrieve @@ -11,13 +11,12 @@ set -euo pipefail # # Required env: EXTERNAL_ID, AWS_REGION, PS_NAME_PREFIX -PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID}" +# EXTERNAL_ID_PATH and EXTERNAL_ID_VERSION are set by build_context. +PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID_PATH}" -# Optional historical version via .version_id (PS uses integer versions, addressed -# by suffixing the parameter name with `:`). -VERSION_ID=$(echo "$CONTEXT" | jq -r '.version_id // empty') +# PS addresses historical versions by suffixing `:` to the parameter name. LOOKUP_NAME="$PARAM_NAME" -[ -n "$VERSION_ID" ] && LOOKUP_NAME="${PARAM_NAME}:${VERSION_ID}" +[ -n "${EXTERNAL_ID_VERSION:-}" ] && LOOKUP_NAME="${PARAM_NAME}:${EXTERNAL_ID_VERSION}" err_file=$(mktemp) if VALUE=$(aws ssm get-parameter \ diff --git a/parameters/providers/parameter_store/store b/parameters/providers/parameter_store/store index b4003aa9..01d1e686 100755 --- a/parameters/providers/parameter_store/store +++ b/parameters/providers/parameter_store/store @@ -69,11 +69,13 @@ aws ssm add-tags-to-resource \ --resource-id "$PARAM_NAME" \ --tags "Key=managed_by,Value=nullplatform" >/dev/null 2>&1 || true +# Encode version into external_id (the canonical handle nullplatform persists). +[ -n "$VERSION_ID" ] && EXTERNAL_ID="${EXTERNAL_ID}#${VERSION_ID}" + jq -n \ --arg external_id "$EXTERNAL_ID" \ --arg parameter_name "$PARAM_NAME" \ --arg region "$AWS_REGION" \ --arg type "$SSM_TYPE" \ --arg tier "$PS_TIER" \ - --arg version_id "$VERSION_ID" \ - '{external_id: $external_id, metadata: {parameter_name: $parameter_name, region: $region, type: $type, tier: $tier, version_id: $version_id}}' + '{external_id: $external_id, metadata: {parameter_name: $parameter_name, region: $region, type: $type, tier: $tier}}' diff --git a/parameters/tests/providers/aws_secret_manager/delete.bats b/parameters/tests/providers/aws_secret_manager/delete.bats index 813f4dc5..bac1c14b 100644 --- a/parameters/tests/providers/aws_secret_manager/delete.bats +++ b/parameters/tests/providers/aws_secret_manager/delete.bats @@ -39,6 +39,8 @@ EOF export SM_NAME_PREFIX="nullplatform/" export EXTERNAL_ID="abc-123" + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/aws_secret_manager/retrieve.bats b/parameters/tests/providers/aws_secret_manager/retrieve.bats index 9d6a3b66..1d4dcf34 100644 --- a/parameters/tests/providers/aws_secret_manager/retrieve.bats +++ b/parameters/tests/providers/aws_secret_manager/retrieve.bats @@ -41,6 +41,8 @@ EOF export SM_NAME_PREFIX="nullplatform/" export EXTERNAL_ID="abc-123" + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" export CONTEXT='{}' export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/aws_secret_manager/store.bats b/parameters/tests/providers/aws_secret_manager/store.bats index e98b47f0..9aa775cb 100644 --- a/parameters/tests/providers/aws_secret_manager/store.bats +++ b/parameters/tests/providers/aws_secret_manager/store.bats @@ -95,12 +95,12 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "aws_secret_manager store: external_id includes entities + parameter_name-id" { +@test "aws_secret_manager store: external_id includes entities + parameter_name-id + version" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') - expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/DB_PASSWORD-42" + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/DB_PASSWORD-42#v1-create-uuid" assert_equal "$external_id" "$expected" } @@ -122,20 +122,22 @@ EOF [[ "$captured" != *"put-secret-value"* ]] } -@test "aws_secret_manager store: returns version_id in metadata" { +@test "aws_secret_manager store: version_id is encoded as #suffix in external_id" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" - version_id=$(echo "$output" | jq -r '.metadata.version_id') - assert_equal "$version_id" "v1-create-uuid" + external_id=$(echo "$output" | jq -r '.external_id') + version="${external_id##*#}" + assert_equal "$version" "v1-create-uuid" } -@test "aws_secret_manager store: PutSecretValue returns its own version_id (different from create)" { +@test "aws_secret_manager store: PutSecretValue's version_id replaces create's in external_id" { run bash -c "$DEPS; MOCK_AWS_MODE=exists source $SCRIPT" assert_equal "$status" "0" - version_id=$(echo "$output" | jq -r '.metadata.version_id') - assert_equal "$version_id" "v2-put-uuid" + external_id=$(echo "$output" | jq -r '.external_id') + version="${external_id##*#}" + assert_equal "$version" "v2-put-uuid" } @test "aws_secret_manager store: ResourceExistsException falls through to PutSecretValue" { diff --git a/parameters/tests/providers/azure_key_vault/delete.bats b/parameters/tests/providers/azure_key_vault/delete.bats index 81a0fe75..439be31d 100644 --- a/parameters/tests/providers/azure_key_vault/delete.bats +++ b/parameters/tests/providers/azure_key_vault/delete.bats @@ -62,6 +62,8 @@ EOF export AZ_SECRET_PREFIX="parameters-" export EXTERNAL_ID="abc-123" + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/azure_key_vault/retrieve.bats b/parameters/tests/providers/azure_key_vault/retrieve.bats index d5fe6ab0..f34e042d 100644 --- a/parameters/tests/providers/azure_key_vault/retrieve.bats +++ b/parameters/tests/providers/azure_key_vault/retrieve.bats @@ -41,6 +41,8 @@ EOF export AZ_SECRET_PREFIX="parameters-" export EXTERNAL_ID="abc-123" + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" export CONTEXT='{}' export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/azure_key_vault/store.bats b/parameters/tests/providers/azure_key_vault/store.bats index f0682643..41f1b663 100644 --- a/parameters/tests/providers/azure_key_vault/store.bats +++ b/parameters/tests/providers/azure_key_vault/store.bats @@ -57,12 +57,13 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "azure_key_vault store: external_id is canonical slash form" { +@test "azure_key_vault store: external_id is canonical slash form + version suffix" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') - expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + # Mock URL ends in /abc123 — that's the version + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42#abc123" assert_equal "$external_id" "$expected" } diff --git a/parameters/tests/providers/hashicorp_vault/delete.bats b/parameters/tests/providers/hashicorp_vault/delete.bats index 92b2a47f..1f02ee3f 100644 --- a/parameters/tests/providers/hashicorp_vault/delete.bats +++ b/parameters/tests/providers/hashicorp_vault/delete.bats @@ -32,6 +32,8 @@ EOF export VAULT_PATH_PREFIX="secret/data/nullplatform" export EXTERNAL_ID="abc-123" + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/hashicorp_vault/retrieve.bats b/parameters/tests/providers/hashicorp_vault/retrieve.bats index 98139b6d..eb27f9d7 100644 --- a/parameters/tests/providers/hashicorp_vault/retrieve.bats +++ b/parameters/tests/providers/hashicorp_vault/retrieve.bats @@ -32,6 +32,8 @@ EOF export VAULT_PATH_PREFIX="secret/data/nullplatform" export EXTERNAL_ID="abc-123" + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" export CONTEXT='{}' export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/hashicorp_vault/store.bats b/parameters/tests/providers/hashicorp_vault/store.bats index 069d093e..0ce7a640 100644 --- a/parameters/tests/providers/hashicorp_vault/store.bats +++ b/parameters/tests/providers/hashicorp_vault/store.bats @@ -64,12 +64,13 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "vault store: external_id composed from entities + parameter_id" { +@test "vault store: external_id composed from entities + parameter_id + version" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') - expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + # Mock returns .data.version=3 + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42#3" assert_equal "$external_id" "$expected" } @@ -127,6 +128,6 @@ EOF assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') - expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42#3" assert_equal "$external_id" "$expected" } diff --git a/parameters/tests/providers/parameter_store/delete.bats b/parameters/tests/providers/parameter_store/delete.bats index f2bc7d43..aaf7ca09 100644 --- a/parameters/tests/providers/parameter_store/delete.bats +++ b/parameters/tests/providers/parameter_store/delete.bats @@ -39,6 +39,8 @@ EOF export PS_NAME_PREFIX="/nullplatform/" export EXTERNAL_ID="abc-123" + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/parameter_store/retrieve.bats b/parameters/tests/providers/parameter_store/retrieve.bats index b4e3199a..f31ccc0f 100644 --- a/parameters/tests/providers/parameter_store/retrieve.bats +++ b/parameters/tests/providers/parameter_store/retrieve.bats @@ -41,6 +41,8 @@ EOF export PS_NAME_PREFIX="/nullplatform/" export EXTERNAL_ID="abc-123" + export EXTERNAL_ID_PATH="$EXTERNAL_ID" + export EXTERNAL_ID_VERSION="" export CONTEXT='{}' export DEPS="source $PARAMETERS_DIR/utils/log" } diff --git a/parameters/tests/providers/parameter_store/store.bats b/parameters/tests/providers/parameter_store/store.bats index c550e5af..f79a1a95 100644 --- a/parameters/tests/providers/parameter_store/store.bats +++ b/parameters/tests/providers/parameter_store/store.bats @@ -63,14 +63,15 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "parameter_store store: external_id composed from entities + parameter_id" { +@test "parameter_store store: external_id composed from entities + parameter_id + version" { export PARAMETER_KIND="parameter" run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" external_id=$(echo "$output" | jq -r '.external_id') - expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42" + # Mock returns .Version=7 + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/42#7" assert_equal "$external_id" "$expected" } From ad83c79c5e6cf949c2c54bf50d55cbbb7b94e9d0 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Tue, 23 Jun 2026 16:35:01 -0300 Subject: [PATCH 08/41] docs(parameters): document native version_id format per provider with real examples --- parameters/docs/architecture.md | 19 ++++ .../aws_secret_manager/docs/architecture.md | 8 +- .../azure_key_vault/docs/architecture.md | 101 +++++++----------- .../hashicorp_vault/docs/architecture.md | 96 +++++++++-------- .../parameter_store/docs/architecture.md | 97 +++++++++-------- 5 files changed, 168 insertions(+), 153 deletions(-) diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md index 1fd5713f..ec610cf0 100644 --- a/parameters/docs/architecture.md +++ b/parameters/docs/architecture.md @@ -84,6 +84,25 @@ The shared helper `parameters/utils/build_external_id` constructs the canonical Each provider applies the prefix (default `nullplatform/`) and any backend-specific sanitization (Azure Key Vault flattens slashes and equals to dashes; everyone else uses the canonical form). The canonical `external_id` returned to nullplatform is the same across all providers, which makes parameter migration between backends mechanically possible. +### Version encoding in external_id + +The `external_id` also carries the version identifier as a suffix: + +``` +# +``` + +The `version_id` is **the native version identifier returned by each backend** — no normalization, no invention. Each provider copies it verbatim from the backend's response. The format varies per backend: + +| Provider | Version ID format | Example | +|----------------------|----------------------------------|--------------------------------------------------| +| `aws_secret_manager` | UUID v4 (from `VersionId`) | `a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d` | +| `hashicorp_vault` | Integer (from `.data.version`) | `3` | +| `parameter_store` | Integer (from `.Version`) | `7` | +| `azure_key_vault` | 32-char hex (URL last segment) | `93a0b2eb12a64fa7b3acb18900a8d33d` | + +Because nullplatform already persists and re-sends `external_id` on every operation, this versioning works without any platform-side changes. On `retrieve`, the dispatcher's `build_context` splits the suffix; provider scripts use it to target a specific historical version via the backend's native version-fetching mechanism (`--version-id`, `?version=N`, `:N`, `--version`). + --- ## How the provider is chosen diff --git a/parameters/providers/aws_secret_manager/docs/architecture.md b/parameters/providers/aws_secret_manager/docs/architecture.md index 0344ae67..2028629f 100644 --- a/parameters/providers/aws_secret_manager/docs/architecture.md +++ b/parameters/providers/aws_secret_manager/docs/architecture.md @@ -97,15 +97,17 @@ The `external_id` returned by `store` encodes both the path and the version: # ``` -For AWS SM, `version_id` is the `VersionId` UUID returned by `CreateSecret` / `PutSecretValue`. Example: +For AWS SM, `version_id` is **the literal `VersionId` UUID v4 returned by `CreateSecret` / `PutSecretValue`** — we do not invent or normalize it. AWS returns it in the response; we copy it verbatim into the suffix. Real example: ``` -organization=acme-1255165411/.../DB_PASSWORD-42#abcd1234-uuid-5678-version +organization=acme-1255165411/.../DB_PASSWORD-42#a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d ``` +The hex string after `#` is the exact VersionId AWS reports for that PutSecretValue / CreateSecret call. It can be used as-is with `aws secretsmanager get-secret-value --version-id` to fetch that specific version. + This means nullplatform — which already persists and re-sends `external_id` on every operation — automatically retains the version reference without needing a separate field. On `retrieve`: -- If `external_id` carries `#version` → fetch that specific historical version. +- If `external_id` carries `#` → fetch that specific historical version using `--version-id `. - If `external_id` has no `#` suffix → fetch `AWSCURRENT` (latest). On `delete`, the version suffix is ignored — `DeleteSecret` removes all versions of the secret. diff --git a/parameters/providers/azure_key_vault/docs/architecture.md b/parameters/providers/azure_key_vault/docs/architecture.md index 9406fef8..5aebaf36 100644 --- a/parameters/providers/azure_key_vault/docs/architecture.md +++ b/parameters/providers/azure_key_vault/docs/architecture.md @@ -1,6 +1,6 @@ # Azure Key Vault — Provider Architecture -This document describes the `parameters/providers/azure_key_vault/` implementation. It stores nullplatform parameters as Azure Key Vault (AKV) secrets. +This document describes the `parameters/providers/azure_key_vault/` implementation. It stores nullplatform parameters as Azure Key Vault (AKV) secrets, exploiting AKV's native versioning. --- @@ -8,52 +8,65 @@ This document describes the `parameters/providers/azure_key_vault/` implementati | Step | What happens | |------|-------------------------------------------------------------------------------| -| `setup` | Reads `AZ_VAULT_NAME`, `AZ_SECRET_PREFIX`. Fails if vault name missing or prefix has invalid chars. | -| `store` | Generates UUID. Calls `az keyvault secret set`. Returns `{external_id, metadata}`. | -| `retrieve` | Calls `az keyvault secret show`. Returns `{value}` or `{value: "value not found"}`. | -| `delete` | Calls `az keyvault secret delete` + `az keyvault secret purge` (both with `\|\| true`). | +| `setup` | Reads `AZ_VAULT_NAME`, `AZ_SECRET_PREFIX` (default `nullplatform-`). Validates prefix matches `[A-Za-z0-9-]*`. | +| `store` | Composes canonical path via `build_external_id`. Transforms (slash → dash, equals → dash) for AKV naming. Calls `az keyvault secret set` with `--tags managed_by=nullplatform`. Extracts version from the returned id URL. Returns `external_id = #`. | +| `retrieve` | Parses canonical path + version. Re-transforms path to AKV name. Calls `az keyvault secret show` with `--version ` if a version is present. | +| `delete` | Calls `az keyvault secret delete` + best-effort `purge`. Idempotent. | | `notify` | Not implemented — dispatcher returns default `{success: true}`. | --- -## Naming convention +## Storage layout + +AKV secret names allow only alphanumerics and dashes (no slashes, no equals, no underscores). The canonical path from `build_external_id` contains slashes and equals, so we transform it: ``` - +canonical: organization=acme-1255165411/account=prod-95118862/.../DB_PASSWORD-42 +AKV name: nullplatform-organization-acme-1255165411-account-prod-95118862-...-DB_PASSWORD-42 ``` -- `AZ_SECRET_PREFIX` defaults to `parameters-`. Must match `[A-Za-z0-9-]*` — AKV secret names allow only alphanumerics and dashes (no slashes, no dots, no underscores). -- `external_id` is a UUIDv4 generated at store time. UUIDs already satisfy AKV's character constraints. -- Full secret name example: `parameters-f47ac10b-58cc-4372-a567-0e02b2c3d479` -- Max 127 chars total. With a UUID (36 chars + dashes), you have ~90 chars left for the prefix. +The transformation is `/=` → `-`, deterministic. The canonical form (with `/` and `=`) is what nullplatform sees in `external_id`; the AKV-safe form is only used internally to address the secret. -This naming differs from `aws_secret_manager` and `parameter_store` (which support slashes for hierarchical organization) — AKV is flat-namespace. +Max secret name length in AKV is 127 characters. The provider checks this and surfaces a helpful error if exceeded. --- -## PARAMETER_KIND is informational here +## Versioning -AKV transparently encrypts all secrets using vault-managed keys (or a customer key if the vault is configured with one). The provider does **not** branch on `PARAMETER_KIND`: +AKV has native versioning. Every `az keyvault secret set` creates a new version, all retained inside the same secret. The version identifier is the last segment of the returned `id` URL. -- `kind=secret` → AKV secret (encrypted at rest by AKV) -- `kind=parameter` → AKV secret (encrypted at rest by AKV) +### Version identity in external_id -Both end up identical. If you need to distinguish parameter vs secret semantics at the storage layer, use the `parameter_store` provider instead (it uses SSM Type=String vs SecureString). +The `external_id` returned by `store` encodes both the path and the version: ---- +``` +# +``` -## Soft-delete behavior +For Azure Key Vault, `version_id` is **the literal hex string version returned by AKV** — we do not invent or normalize it. AKV returns the secret's id as a URL like `https://my-vault.vault.azure.net/secrets/my-secret/93a0b2eb12a64fa7b3acb18900a8d33d`; we extract the last path segment. Real example: -Azure Key Vault has soft-delete enabled by default with 90-day retention: +``` +organization=acme-1255165411/.../DB_PASSWORD-42#93a0b2eb12a64fa7b3acb18900a8d33d +``` -1. `az keyvault secret delete` moves the secret to a soft-deleted state. The name is reserved (cannot recreate with same name) and the secret is recoverable for 90 days. -2. `az keyvault secret purge` hard-deletes from the soft-delete bin, freeing the name immediately. +That 32-char hex string is the AKV version identifier. It can be used as-is with `az keyvault secret show --version 93a0b2eb12a64fa7b3acb18900a8d33d` to fetch that specific historical version. -The provider's `delete` script does **both** sequentially. Both calls suppress errors (`|| true`), so: +On `retrieve`: +- With `#` → fetch that version via `--version `. +- Without → fetch the latest. -- If you have the `Purge` permission: hard-deletes immediately, no retention cost. -- If you only have `Delete`: soft-deletes, retention applies. Since we use UUIDs, name reuse is not a concern in practice. -- If the secret already doesn't exist: both calls fail silently, the operation still returns `{success: true}`. +On `delete`, the version suffix is ignored — `secret delete` + `secret purge` remove all versions. + +--- + +## Soft-delete + purge + +AKV uses soft-delete by default (90-day retention). The provider does both: + +1. `az keyvault secret delete` — moves to soft-deleted state. +2. `az keyvault secret purge` — hard-deletes from the soft-delete bin, freeing the name immediately. + +If the identity lacks `Purge` permission, purge fails with a warning but delete still succeeds. The secret stays in the soft-delete window and is auto-cleaned by Azure at retention expiry. --- @@ -64,40 +77,8 @@ The provider's `delete` script does **both** sequentially. Both calls suppress e ```json { "vault_name": "my-keyvault", - "secret_prefix": "parameters-" + "secret_prefix": "nullplatform-" } ``` -Equivalent env vars: `AZURE_KEY_VAULT_NAME`, `AZURE_KEY_VAULT_SECRET_PREFIX`. `PROVIDER_CONFIG` wins per `get_config_value` priority. - -Authentication uses the Azure CLI's default credential chain: - -1. Managed Identity (Azure-hosted environments) -2. Service Principal env vars (`AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`) -3. `az login` cached credentials - -The provider does not validate auth in `setup` — the first `az keyvault` call surfaces auth errors. - ---- - -## Required permissions on the vault - -The identity running the provider scripts needs an access policy or RBAC role on the vault: - -| Operation | Access policy permission | RBAC role | -|-----------|--------------------------------|--------------------------------------| -| store | Set | Key Vault Secrets Officer / Contributor | -| retrieve | Get | Key Vault Secrets User | -| delete | Delete, Purge (optional) | Key Vault Secrets Officer + Purge action | - -The `Purge` permission is optional but recommended. Without it, soft-deletes accumulate and you may hit vault soft-delete quotas if you cycle many secrets. - ---- - -## Compatibility with the contract - -| Operation | Output shape | Notes | -|-----------|--------------|-------| -| store | `{external_id, metadata: {azure_secret_id, secret_name, vault_name}}` | `azure_secret_id` is the full AKV resource ID (URL form) | -| retrieve | `{value}` or `{value: "value not found"}` | | -| delete | `{success: true}` | Always; idempotent | +Authentication comes from the Azure CLI's default credential chain (managed identity, az login, service principal env vars). diff --git a/parameters/providers/hashicorp_vault/docs/architecture.md b/parameters/providers/hashicorp_vault/docs/architecture.md index e3074fa2..f516b37d 100644 --- a/parameters/providers/hashicorp_vault/docs/architecture.md +++ b/parameters/providers/hashicorp_vault/docs/architecture.md @@ -1,80 +1,90 @@ # HashiCorp Vault — Provider Architecture -This document describes the `parameters/providers/hashicorp_vault/` implementation. It stores nullplatform parameters as Vault KV v2 secrets. +This document describes the `parameters/providers/hashicorp_vault/` implementation. It stores nullplatform parameters as Vault KV v2 secrets, exploiting Vault's native versioning. --- ## Lifecycle -| Step | What happens | -|------|-----------------------------------------------------------------------| -| `setup` | Reads `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX` from env or `PROVIDER_CONFIG`. Fails fast if address or token is missing. Exports the three vars. | -| `store` | Generates a UUID `external_id`. POSTs to `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/$external_id` with a JSON payload. Returns `{external_id, metadata.vault_path}`. | -| `retrieve` | GETs from `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/$external_id`. Returns `{value}` or `{value: "value not found"}` on miss. | -| `delete` | DELETEs the secret. Idempotent — re-deleting is a no-op. Returns `{success: true}`. | -| `notify` | Not implemented — dispatcher returns the default `{success: true}` ack. | +| Step | What happens | +|------|---------------------------------------------------------------------------------------| +| `setup` | Reads `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX` (default `secret/data/nullplatform`). Fails fast if address or token is missing. | +| `store` | Composes the canonical path via `build_external_id`. POSTs to `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/` with a JSON payload. Captures the new version number from Vault's response. Returns `external_id = #`. | +| `retrieve` | Parses `EXTERNAL_ID` into path + version. GETs `$VAULT_ADDR/v1/$VAULT_PATH_PREFIX/?version=` if a version is present; otherwise fetches the latest. Returns `{value}` or `{value: "value not found"}`. | +| `delete` | Parses path from external_id. DELETEs the metadata endpoint (KV v2) — removes all versions. Idempotent. | +| `notify` | Not implemented — dispatcher returns default `{success: true}`. | --- ## Storage layout +Every secret path is composed by `parameters/utils/build_external_id`: + ``` -/v1// +/=-/.../=/- ``` -- **`VAULT_PATH_PREFIX`** defaults to `secret/data/parameters`. The `data/` segment is the KV v2 convention — change the default if your mount uses KV v1 (drop the `data/`) or a different mount point. -- **`external_id`** is a UUIDv4 generated at store time. It is the canonical handle nullplatform persists and re-injects for retrieve/delete. +Default `VAULT_PATH_PREFIX` is `secret/data/nullplatform` (KV v2 — note the `data/` segment is required by the v2 API). -The stored payload at each path is a JSON envelope, not the raw value: +Example full path: -```json -{ - "data": { - "parameter_id": 42, - "value": "the-actual-value", - "stored_at": "2026-05-15T12:34:56Z", - "external_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" - } -} +``` +secret/data/nullplatform/organization=acme-1255165411/account=prod-95118862/.../DB_PASSWORD-42 ``` -Keeping `parameter_id` and `external_id` inside the payload makes orphaned secrets self-describing — if someone discovers a stale entry under `parameters/`, the payload tells them which nullplatform parameter it belongs to. +The path is human-friendly: navigating the Vault UI, an operator can find any secret by knowing the parameter's NRN + dimensions + name. --- -## Configuration +## Versioning -`PROVIDER_CONFIG` shape (populated by `fetch_configuration` if you implement one): +Vault KV v2 has native versioning. Every `POST /v1/secret/data/` creates a new version, all retained inside the same path. Old versions can be fetched with `?version=`. -```json -{ - "address": "https://vault.example.com", - "token": "hvs.xxx", - "path_prefix": "secret/data/parameters" -} +### Version identity in external_id + +The `external_id` returned by `store` encodes both the path and the version: + +``` +# ``` -Equivalent env vars: `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_PATH_PREFIX`. Provider config wins over env per `get_config_value` priority. +For Vault KV v2, `version_id` is **the literal integer version number returned by Vault** in `.data.version` — we do not invent or normalize it. Real example: + +``` +organization=acme-1255165411/.../DB_PASSWORD-42#3 +``` + +Here `3` means "Vault version 3 of this secret". It can be used as-is with `?version=3` to fetch that specific version. + +On `retrieve`: +- If `external_id` carries `#` → fetch that historical version via `?version=N`. +- If no `#` suffix → fetch the latest (default Vault behavior). + +On `delete`, the version suffix is ignored. KV v2's data DELETE removes the latest version label; for full purging across all versions you'd use `metadata` endpoint — see Vault docs for the soft/hard delete distinction. --- -## Authentication notes +## Secret payload -This provider uses static token auth via `X-Vault-Token`. For production use, consider: +The body stored in Vault is a JSON envelope: -- **Token rotation**: short-lived tokens (issued by AppRole, Kubernetes auth, etc.) should be refreshed by `fetch_configuration` rather than relying on a long-lived `VAULT_TOKEN` env var. -- **OIDC / Kubernetes auth**: a richer `fetch_configuration` could exchange a workload identity for a Vault token at runtime, removing the need for any pre-issued credential. +```json +{ + "data": { + "parameter_id": 42, + "value": "the-actual-value", + "stored_at": "2026-06-23T12:34:56Z", + "external_id": "organization=acme-1255165411/.../DB_PASSWORD-42" + } +} +``` -The operation scripts (`store`/`retrieve`/`delete`) don't care how `VAULT_TOKEN` got into the environment — they just use it. Swap the auth mechanism by changing `setup` (and optionally adding `fetch_configuration`). +The `data` wrapper is KV v2's API requirement; the inner object is our envelope. --- -## Compatibility - -The output JSON shape matches the previous `parameters/vault/` implementation byte-for-byte: +## Authentication -- `store` → `{external_id, metadata: {vault_path}}` -- `retrieve` → `{value}` or `{value: "value not found"}` -- `delete` → `{success: true}` +Token-based via `X-Vault-Token` header. The token must have read/write permissions on the configured `VAULT_PATH_PREFIX` namespace. -A scope that switches from the old layout to this provider sees no behavior change against Vault. +For production: use short-lived tokens (issued by AppRole, Kubernetes auth, etc.) refreshed by the operator outside this package. The agent only reads `VAULT_TOKEN` — credential lifecycle management is the operator's responsibility. diff --git a/parameters/providers/parameter_store/docs/architecture.md b/parameters/providers/parameter_store/docs/architecture.md index af881f43..9ecb4601 100644 --- a/parameters/providers/parameter_store/docs/architecture.md +++ b/parameters/providers/parameter_store/docs/architecture.md @@ -1,8 +1,8 @@ # AWS Systems Manager Parameter Store — Provider Architecture -This document describes the `parameters/providers/parameter_store/` implementation. It stores nullplatform parameters as AWS SSM Parameter Store entries, using `String` for plain parameters and `SecureString` (KMS-encrypted) for secrets. +This document describes the `parameters/providers/parameter_store/` implementation. It stores nullplatform parameters in AWS SSM Parameter Store, using `String` for plain parameters and `SecureString` (KMS-encrypted) for secrets. -This is the cheapest provider in the package — Standard tier is free up to 10,000 parameters. +Cheapest provider in the package — Standard tier is free up to 10,000 parameters. --- @@ -10,69 +10,84 @@ This is the cheapest provider in the package — Standard tier is free up to 10, | Step | What happens | |------|-----------------------------------------------------------------------------| -| `setup` | Reads `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. Normalizes prefix to start/end with `/`. Fails if region is missing or tier is invalid. | -| `store` | Generates a UUID. Calls `aws ssm put-parameter` with `Type=String` (kind=parameter) or `Type=SecureString` (kind=secret). Returns `{external_id, metadata}`. | -| `retrieve` | Calls `aws ssm get-parameter --with-decryption`. Returns `{value}` or `{value: "value not found"}`. | -| `delete` | Calls `aws ssm delete-parameter`. Idempotent — never errors. | +| `setup` | Reads `AWS_REGION`, `PS_NAME_PREFIX` (default `/nullplatform/`), `PS_KMS_KEY_ID`, `PS_TIER`. Normalizes prefix to start/end with `/`. | +| `store` | Composes path via `build_external_id`. Calls `aws ssm put-parameter --overwrite`. Captures `.Version` from response. Returns `external_id = #`. | +| `retrieve` | Parses path + version. Calls `aws ssm get-parameter --with-decryption`. If a version is present in external_id, appends `:` to target that specific version. | +| `delete` | Calls `aws ssm delete-parameter`. Idempotent (suppresses `ParameterNotFound`). | | `notify` | Not implemented — dispatcher returns default `{success: true}`. | --- +## Storage layout + +Every parameter name is composed by `parameters/utils/build_external_id`: + +``` +=-/.../=/- +``` + +Default `PS_NAME_PREFIX` is `/nullplatform/`. SSM requires names to start with `/` for hierarchical organization. + +Example: + +``` +/nullplatform/organization=acme-1255165411/account=prod-95118862/.../DB_PASSWORD-42 +``` + +IAM can target the hierarchy via ARN pattern `arn:aws:ssm:::parameter/nullplatform/*`. + +--- + ## Type selection via PARAMETER_KIND -This is the first provider in the package that branches on `PARAMETER_KIND`: +This is the only provider in the package that branches on `PARAMETER_KIND`: | Kind | SSM Type | KMS | |-------------|-----------------|------------------------------------------------------| | `parameter` | `String` | None (plain text) | | `secret` | `SecureString` | `PS_KMS_KEY_ID` if set, otherwise `alias/aws/ssm` | -For `aws_secret_manager`, `hashicorp_vault`, and `azure_key_vault`, the kind is informational — those backends encrypt all values uniformly. Parameter Store is different because it distinguishes the storage type at the API level. +For other providers in the package, kind is informational — their backends encrypt all values uniformly. --- -## Naming convention +## Versioning + +Parameter Store retains versions automatically. `put-parameter --overwrite` creates a new version on every call. + +### Version identity in external_id + +The `external_id` returned by `store` encodes both the path and the version: ``` - +# ``` -- `PS_NAME_PREFIX` defaults to `/nullplatform/parameters/`. Always starts with `/` (SSM hierarchical naming) and ends with `/` (the script normalizes both). -- `external_id` is a UUIDv4 generated at store time. -- Full parameter name example: `/nullplatform/parameters/f47ac10b-58cc-4372-a567-0e02b2c3d479` +For Parameter Store, `version_id` is **the literal integer `.Version` returned by `put-parameter`** — we do not invent or normalize it. AWS returns it; we copy it verbatim. Real example: -The hierarchical prefix lets you scope IAM via path-based ARN patterns: ``` -arn:aws:ssm:::parameter/nullplatform/parameters/* +organization=acme-1255165411/.../DB_PASSWORD-42#7 ``` +Here `7` means "the 7th version of this parameter". SSM addresses historical versions by suffixing the parameter name with `:`, so on retrieve we call `get-parameter --name ":7"`. + +On `retrieve`: +- With `#` → fetch version N. +- Without → fetch latest. + +On `delete`, the version suffix is ignored — `delete-parameter` removes all versions. + --- ## Tiers -Parameter Store has three tiers, selected via `PS_TIER`: - | Tier | Free | Value size | Use case | |-----------------------|-----------------------|-------------|-----------------------------------| | `Standard` (default) | up to 10,000 params | 4 KB | Most cases | | `Advanced` | $0.05/param/month | 8 KB | Large values or > 10k params | -| `Intelligent-Tiering` | Auto-promotes | varies | Mixed sizes, optimize for cost | - -Standard is the default and what most consumers should use. Switch to Advanced explicitly when you have a value > 4 KB or you'll cross 10,000 parameters. - ---- - -## Cost model +| `Intelligent-Tiering` | Auto-promotes | varies | Mixed sizes | -``` -Standard: $0.00 / param / month (up to 10,000) - $0.05 / 10,000 API calls -Advanced: $0.05 / param / month - $0.05 / 10,000 API calls -Intelligent-Tiering: varies (sees Advanced rate once promoted) -``` - -For 100 secret parameters across all your apps on Standard tier: **$0/month** (vs ~$40/month with Secrets Manager). The trade-off: Parameter Store has no rotation, no replication, no resource-based policies — features Secrets Manager provides for the extra cost. +Switch tiers via provider config `tier` attribute. --- @@ -83,22 +98,10 @@ For 100 secret parameters across all your apps on Standard tier: **$0/month** (v ```json { "region": "us-east-1", - "name_prefix": "/nullplatform/parameters/", + "name_prefix": "/nullplatform/", "kms_key_id": "alias/parameters-secure", "tier": "Standard" } ``` -Equivalent env vars: `AWS_REGION`, `PS_NAME_PREFIX`, `PS_KMS_KEY_ID`, `PS_TIER`. `PROVIDER_CONFIG` wins per `get_config_value` priority. - -`kms_key_id` is only used when storing a `SecureString` (kind=secret). For plain parameters it's ignored. - ---- - -## Compatibility with the contract - -| Operation | Output shape | Notes | -|-----------|--------------|-------| -| store | `{external_id, metadata: {parameter_name, region, type, tier}}` | `type` reflects the SSM Type used (String or SecureString) | -| retrieve | `{value}` or `{value: "value not found"}` | --with-decryption is always passed; no-op for String | -| delete | `{success: true}` | Always; idempotent | +`kms_key_id` only matters for `kind=secret` (SecureString). For `kind=parameter` (String) it's ignored. From 8826f2f55fa2bbb572579c0e69108837b280103a Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 24 Jun 2026 10:06:10 -0300 Subject: [PATCH 09/41] feat(parameters): retrieve fails on not-found + complete adding_a_provider guide --- parameters/docs/adding_a_provider.md | 128 +++++++++++++++--- .../providers/aws_secret_manager/retrieve | 16 ++- parameters/providers/azure_key_vault/retrieve | 16 ++- parameters/providers/hashicorp_vault/retrieve | 16 ++- parameters/providers/parameter_store/retrieve | 16 ++- .../aws_secret_manager/retrieve.bats | 10 +- .../providers/azure_key_vault/retrieve.bats | 9 +- .../providers/hashicorp_vault/retrieve.bats | 9 +- .../providers/parameter_store/retrieve.bats | 9 +- 9 files changed, 183 insertions(+), 46 deletions(-) diff --git a/parameters/docs/adding_a_provider.md b/parameters/docs/adding_a_provider.md index 8aec83d4..3a6ffa45 100644 --- a/parameters/docs/adding_a_provider.md +++ b/parameters/docs/adding_a_provider.md @@ -6,6 +6,47 @@ The parameters package is designed so that adding a provider is **strictly addit --- +## Core principles (read this before anything else) + +Every provider in this package follows the same two cross-cutting principles. Your implementation MUST honor them; deviation is not a per-provider choice. + +### 1. Naming is human-friendly and hierarchical + +The storage path/name for every secret is composed from the parameter's context: account + namespace + application + (scope if present) + (dimensions if present) + parameter name + parameter id + revision. Both the entity slug AND id are included so the name is readable AND stable across renames. + +All names are grouped under a `nullplatform` top-level prefix (default — operators can override via provider config). This is the IAM scoping anchor and the visual marker in the backend's console. + +The shared helper `parameters/utils/build_external_id` constructs the canonical form. Your `store` calls it once at the top: + +``` +nullplatform/organization=-/account=-/namespace=-/application=-/=/- +``` + +The principle: an operator who opens the backend's UI (AWS Console, Vault UI, Azure Portal) must be able to navigate to any secret by knowing the parameter's nullplatform context, without consulting the platform's database. The path tells the story. + +If your backend has naming restrictions (e.g. Azure Key Vault disallows `/` and `=`), transform the canonical form deterministically inside your `store`/`retrieve`/`delete`. The `external_id` returned to nullplatform always uses the canonical (slash) form so it's portable across providers. + +### 2. Versioning is mandatory and cost-aware + +nullplatform parameter values are **immutable**. Every update to a `(parameter_id, NRN, dimensions)` tuple creates a new revision. nullplatform may ask you to retrieve a specific historical revision (e.g. to display in UI or to support restore — which is implemented as "read old revision + store as new revision"). + +Your provider MUST keep the version history. Two rules: + +- **Don't lose old revisions on update**. If the backend has native versioning (AWS SM, Vault KV v2, AWS Parameter Store, Azure Key Vault all do), use it: append a new version to the same key. If the backend doesn't have native versioning, store revisions inside a single record (e.g. JSON list, append-only structure) — keep them in one logical entity to avoid cost explosion. +- **Never create a new top-level entity per revision** if the backend charges per entity. AWS Secrets Manager charges $0.40/secret/month regardless of version count; creating one secret per version would multiply cost linearly with update frequency. Native versioning is essentially free. + +Version identity is encoded in the `external_id` returned by store: + +``` +# +``` + +Where `version_id` is the **literal native identifier the backend returns** — not invented, not normalized. AWS SM gives a UUID; Vault gives an integer; Parameter Store gives an integer; AKV gives a 32-char hex. Use whatever the backend hands you, verbatim. + +Because nullplatform persists and re-sends `external_id` on every operation, the version reference round-trips automatically without any platform-side state. On `retrieve`, split on `#` to get both pieces; use the version via the backend's native lookup (e.g. `--version-id`, `?version=N`, `:N`, `--version`). On `delete`, ignore the version suffix — delete removes all revisions. + +--- + ## What you need to know about the backend Before you start, answer these questions: @@ -105,38 +146,83 @@ If your backend distinguishes types (like `parameter_store` does with String/Sec ### `retrieve` -Read the value, return `{value}` or `{value: "value not found"}` on miss. +Read the value and return `{value}`. On any failure — including "not found" — exit non-zero with troubleshooting. Do NOT return a sentinel value like "value not found" as if it were a real value; that would be a misleading payload (the platform would treat the literal string as the parameter's value). + +If the backend distinguishes "not found" from other errors, include that distinction in the troubleshooting message so the platform can categorize. Both still exit non-zero. ```bash #!/bin/bash set -euo pipefail -NAME="${MY_PREFIX}${EXTERNAL_ID}" +NAME="${MY_PREFIX}${EXTERNAL_ID_PATH}" # use _PATH (canonical, no version suffix) -if VALUE=$(my_cli get --endpoint "$MY_ENDPOINT" --name "$NAME" 2>/dev/null); then +err_file=$(mktemp) +if VALUE=$(my_cli get --endpoint "$MY_ENDPOINT" --name "$NAME" 2>"$err_file"); then + rm -f "$err_file" jq -n --arg value "$VALUE" '{value: $value}' else - echo '{ - "value": "value not found" - }' + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q ""; then + log error "❌ Secret '$NAME' not found in " + log error "" + log error "💡 Possible causes:" + log error " • The secret was manually deleted from the backend" + log error " • The external_id is stale" + log error "" + log error "🔧 How to fix:" + log error " • Verify: my_cli describe --name $NAME" + exit 1 + else + log error "❌ Failed to retrieve from " + log error "" + log error "💡 Possible causes:" + log error " • " + log error "" + log error "🔧 How to fix:" + log error " • " + log error "Underlying error: $err" + exit 1 + fi fi ``` ### `delete` -Always returns `{success: true}`. Suppress errors with `|| true`. +Idempotent — re-deleting a missing resource is success. But **only "not found" is suppressed**; any other failure (permission denied, network error, server error) MUST propagate as exit 1 with troubleshooting. Reporting success when the work didn't actually happen leads to "client thinks it's deleted but it's still there" bugs. ```bash #!/bin/bash set -euo pipefail -NAME="${MY_PREFIX}${EXTERNAL_ID}" - -my_cli delete --endpoint "$MY_ENDPOINT" --name "$NAME" >/dev/null 2>&1 || true +NAME="${MY_PREFIX}${EXTERNAL_ID_PATH}" # delete removes ALL versions; ignore version suffix -echo '{ +err_file=$(mktemp) +if my_cli delete --endpoint "$MY_ENDPOINT" --name "$NAME" >/dev/null 2>"$err_file"; then + rm -f "$err_file" + echo '{ "success": true }' +else + err=$(cat "$err_file") + rm -f "$err_file" + if echo "$err" | grep -q ""; then + log debug "Resource '$NAME' does not exist, treating delete as idempotent success" + echo '{ + "success": true +}' + else + log error "❌ Failed to delete '$NAME' from " + log error "" + log error "💡 Possible causes:" + log error " • " + log error "" + log error "🔧 How to fix:" + log error " • " + log error "Underlying error: $err" + exit 1 + fi +fi ``` ### `notify` (optional) @@ -169,9 +255,9 @@ Aim for at least these scenarios per provider: | Script | Required tests | |-----------|-----------------------------------------------------------------------------| | setup | Missing required config fails with troubleshooting; PROVIDER_CONFIG wins over env; defaults applied | -| store | Output JSON shape; CLI called with correct args; failure path returns non-zero with troubleshooting | -| retrieve | Hit returns value; miss returns "value not found" | -| delete | Returns `{success: true}`; idempotent on CLI failure | +| store | Output JSON shape; external_id includes path + #version suffix; first store uses create, subsequent uses native versioning API; failure paths exit non-zero with troubleshooting | +| retrieve | Hit returns value; not-found exits non-zero with troubleshooting; with-version targets historical revision; auth/network errors exit non-zero | +| delete | Returns `{success: true}` on success; not-found is treated as success (idempotent); other errors propagate as exit 1 with troubleshooting | --- @@ -201,14 +287,20 @@ Done. The agent receives `provider.specification_id` and `provider.attributes` i Before considering a new provider complete: +- [ ] Naming follows the human-friendly hierarchical convention (entity slug-id + dimensions + parameter name-id), under the `nullplatform/` prefix +- [ ] `store` uses `build_external_id` to compose the canonical path +- [ ] `store` returns `external_id = #` with the native backend version identifier (not invented) +- [ ] Versioning uses the backend's native mechanism (not new top-level entities per revision) - [ ] `setup` validates config and exits with troubleshooting on missing fields - [ ] `store` outputs `{external_id, metadata}` JSON -- [ ] `retrieve` outputs `{value}` (or `{value: "value not found"}`) -- [ ] `delete` outputs `{success: true}` (always — idempotent) +- [ ] `retrieve` outputs `{value}` on success; **exits non-zero on not-found with clear troubleshooting** (no "value not found" sentinel) +- [ ] `retrieve` honors `EXTERNAL_ID_VERSION` to target historical revisions +- [ ] `delete` outputs `{success: true}` on success and on not-found (idempotent); **exits non-zero on any other error** (no `|| true` blanket suppression) - [ ] Scripts use `set -euo pipefail` - [ ] Errors go to stderr via `log error "..."` - [ ] No stdout output other than the final JSON - [ ] Every error has `💡 Possible causes:` and `🔧 How to fix:` blocks -- [ ] BATS tests cover setup error paths, store output shape, retrieve hit/miss, delete idempotency -- [ ] `architecture.md` documents storage layout and cost +- [ ] BATS tests cover setup error paths, store output shape (incl. version suffix), retrieve hit + not-found-error + auth-error, delete success + not-found-idempotent + other-error-propagation +- [ ] `architecture.md` documents storage layout, versioning behavior, and the native version_id format - [ ] If the backend has IAM, `iam-policy.md` shows least-privilege scoping +- [ ] A `_configuration.json.tpl` exists with the `parameters-storage` category and the schema for the provider's config attributes diff --git a/parameters/providers/aws_secret_manager/retrieve b/parameters/providers/aws_secret_manager/retrieve index 5addb766..0fd1f028 100755 --- a/parameters/providers/aws_secret_manager/retrieve +++ b/parameters/providers/aws_secret_manager/retrieve @@ -36,9 +36,19 @@ else err=$(cat "$err_file") rm -f "$err_file" if echo "$err" | grep -q "ResourceNotFoundException"; then - echo '{ - "value": "value not found" - }' + log error "❌ Secret '$SECRET_NAME' not found in AWS Secrets Manager" + log error "" + log error "💡 Possible causes:" + log error " • The secret was manually deleted from AWS" + log error " • The external_id is stale (parameter was deleted in nullplatform but referenced elsewhere)" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' was pruned (AWS SM auto-removes oldest unlabeled versions past 100)" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify the secret exists: aws secretsmanager describe-secret --secret-id $SECRET_NAME" + log error " • If genuinely missing, the parameter value needs to be re-stored" + exit 1 else log error "❌ Failed to retrieve secret '$SECRET_NAME' from AWS Secrets Manager" log error "" diff --git a/parameters/providers/azure_key_vault/retrieve b/parameters/providers/azure_key_vault/retrieve index 7bb2b2b8..1f821a4d 100755 --- a/parameters/providers/azure_key_vault/retrieve +++ b/parameters/providers/azure_key_vault/retrieve @@ -26,9 +26,19 @@ else err=$(cat "$err_file") rm -f "$err_file" if echo "$err" | grep -qE "(SecretNotFound|secret with .* was not found)"; then - echo '{ - "value": "value not found" - }' + log error "❌ Secret '$SECRET_NAME' not found in Azure Key Vault '$AZ_VAULT_NAME'" + log error "" + log error "💡 Possible causes:" + log error " • The secret was manually deleted (and possibly purged) from AKV" + log error " • The external_id is stale" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' was purged from AKV" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify: az keyvault secret show --vault-name $AZ_VAULT_NAME --name $SECRET_NAME" + log error " • If genuinely missing, the parameter value needs to be re-stored" + exit 1 else log error "❌ Failed to retrieve secret '$SECRET_NAME' from Azure Key Vault '$AZ_VAULT_NAME'" log error "" diff --git a/parameters/providers/hashicorp_vault/retrieve b/parameters/providers/hashicorp_vault/retrieve index 5d63b105..25e62f5d 100755 --- a/parameters/providers/hashicorp_vault/retrieve +++ b/parameters/providers/hashicorp_vault/retrieve @@ -42,9 +42,19 @@ case "$HTTP_STATUS" in }' ;; 404) - echo '{ - "value": "value not found" - }' + log error "❌ Secret at $VAULT_PATH not found in Vault" + log error "" + log error "💡 Possible causes:" + log error " • The secret was manually deleted from Vault" + log error " • The external_id is stale" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' was destroyed via Vault's metadata API" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/$VAULT_PATH" + log error " • If genuinely missing, the parameter value needs to be re-stored" + exit 1 ;; *) log error "❌ Vault GET failed with HTTP $HTTP_STATUS at $VAULT_PATH" diff --git a/parameters/providers/parameter_store/retrieve b/parameters/providers/parameter_store/retrieve index c9cf6db9..87029fc9 100755 --- a/parameters/providers/parameter_store/retrieve +++ b/parameters/providers/parameter_store/retrieve @@ -31,9 +31,19 @@ else err=$(cat "$err_file") rm -f "$err_file" if echo "$err" | grep -q "ParameterNotFound"; then - echo '{ - "value": "value not found" - }' + log error "❌ Parameter '$PARAM_NAME' not found in AWS Parameter Store" + log error "" + log error "💡 Possible causes:" + log error " • The parameter was manually deleted from AWS SSM" + log error " • The external_id is stale" + if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then + log error " • Requested version '$EXTERNAL_ID_VERSION' is outside Parameter Store's retention window" + fi + log error "" + log error "🔧 How to fix:" + log error " • Verify: aws ssm describe-parameters --filters Key=Name,Values=$PARAM_NAME --region $AWS_REGION" + log error " • If genuinely missing, the parameter value needs to be re-stored" + exit 1 else log error "❌ Failed to retrieve parameter '$PARAM_NAME' from AWS Parameter Store" log error "" diff --git a/parameters/tests/providers/aws_secret_manager/retrieve.bats b/parameters/tests/providers/aws_secret_manager/retrieve.bats index 1d4dcf34..32deadd6 100644 --- a/parameters/tests/providers/aws_secret_manager/retrieve.bats +++ b/parameters/tests/providers/aws_secret_manager/retrieve.bats @@ -55,12 +55,14 @@ EOF assert_equal "$value" "the-real-value" } -@test "aws_secret_manager retrieve: ResourceNotFoundException → 'value not found'" { +@test "aws_secret_manager retrieve: ResourceNotFoundException fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" - assert_equal "$status" "0" - value=$(echo "$output" | jq -r '.value') - assert_equal "$value" "value not found" + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Secret" + assert_contains "$output" "not found in AWS Secrets Manager" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" } @test "aws_secret_manager retrieve: AccessDenied fails with troubleshooting" { diff --git a/parameters/tests/providers/azure_key_vault/retrieve.bats b/parameters/tests/providers/azure_key_vault/retrieve.bats index f34e042d..79e20a50 100644 --- a/parameters/tests/providers/azure_key_vault/retrieve.bats +++ b/parameters/tests/providers/azure_key_vault/retrieve.bats @@ -55,12 +55,13 @@ EOF assert_equal "$value" "the-stored-value" } -@test "azure_key_vault retrieve: SecretNotFound → 'value not found'" { +@test "azure_key_vault retrieve: SecretNotFound fails with troubleshooting" { run bash -c "$DEPS; MOCK_AZ_MODE=not_found source $SCRIPT" - assert_equal "$status" "0" - value=$(echo "$output" | jq -r '.value') - assert_equal "$value" "value not found" + [ "$status" -ne 0 ] + assert_contains "$output" "not found in Azure Key Vault" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" } @test "azure_key_vault retrieve: auth_error fails with troubleshooting" { diff --git a/parameters/tests/providers/hashicorp_vault/retrieve.bats b/parameters/tests/providers/hashicorp_vault/retrieve.bats index eb27f9d7..d3bcc7b5 100644 --- a/parameters/tests/providers/hashicorp_vault/retrieve.bats +++ b/parameters/tests/providers/hashicorp_vault/retrieve.bats @@ -48,12 +48,13 @@ EOF assert_equal "$value" "the-real-secret" } -@test "vault retrieve: 404 returns 'value not found'" { +@test "vault retrieve: 404 fails with troubleshooting" { run bash -c "$DEPS; MOCK_HTTP_STATUS=404 source $SCRIPT" - assert_equal "$status" "0" - value=$(echo "$output" | jq -r '.value') - assert_equal "$value" "value not found" + [ "$status" -ne 0 ] + assert_contains "$output" "not found in Vault" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" } @test "vault retrieve: 403 fails with auth troubleshooting" { diff --git a/parameters/tests/providers/parameter_store/retrieve.bats b/parameters/tests/providers/parameter_store/retrieve.bats index f31ccc0f..ff25feba 100644 --- a/parameters/tests/providers/parameter_store/retrieve.bats +++ b/parameters/tests/providers/parameter_store/retrieve.bats @@ -55,12 +55,13 @@ EOF assert_equal "$value" "the-real-value" } -@test "parameter_store retrieve: ParameterNotFound → 'value not found'" { +@test "parameter_store retrieve: ParameterNotFound fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" - assert_equal "$status" "0" - value=$(echo "$output" | jq -r '.value') - assert_equal "$value" "value not found" + [ "$status" -ne 0 ] + assert_contains "$output" "not found in AWS Parameter Store" + assert_contains "$output" "💡 Possible causes:" + assert_contains "$output" "🔧 How to fix:" } @test "parameter_store retrieve: AccessDenied fails with troubleshooting" { From 75d0bc9210616d3dcfd92ff2beb4c5668e5d962a Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 24 Jun 2026 10:22:25 -0300 Subject: [PATCH 10/41] refactor(parameters): unified dispatch script + move shared scripts to utils/ --- parameters/delete | 6 -- parameters/docs/architecture.md | 24 ++--- parameters/notify | 10 -- parameters/retrieve | 6 -- parameters/store | 6 -- parameters/tests/delete.bats | 43 -------- parameters/tests/notify.bats | 47 --------- parameters/tests/retrieve.bats | 43 -------- parameters/tests/store.bats | 53 ---------- .../tests/{ => utils}/build_context.bats | 8 +- parameters/tests/utils/dispatch.bats | 99 +++++++++++++++++++ parameters/{ => utils}/build_context | 11 ++- parameters/utils/dispatch | 22 +++++ parameters/workflows/delete.yaml | 7 +- parameters/workflows/notify.yaml | 7 +- parameters/workflows/retrieve.yaml | 8 +- parameters/workflows/store.yaml | 8 +- 17 files changed, 166 insertions(+), 242 deletions(-) delete mode 100755 parameters/delete delete mode 100755 parameters/notify delete mode 100755 parameters/retrieve delete mode 100755 parameters/store delete mode 100644 parameters/tests/delete.bats delete mode 100644 parameters/tests/notify.bats delete mode 100644 parameters/tests/retrieve.bats delete mode 100644 parameters/tests/store.bats rename parameters/tests/{ => utils}/build_context.bats (95%) create mode 100644 parameters/tests/utils/dispatch.bats rename parameters/{ => utils}/build_context (92%) create mode 100755 parameters/utils/dispatch diff --git a/parameters/delete b/parameters/delete deleted file mode 100755 index ee412263..00000000 --- a/parameters/delete +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Dispatch: delegate to the active provider's delete implementation. -# PROVIDER_DIR is exported by build_context. -source "$PROVIDER_DIR/delete" diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md index ec610cf0..fc039102 100644 --- a/parameters/docs/architecture.md +++ b/parameters/docs/architecture.md @@ -44,7 +44,7 @@ The platform decides which provider handles each parameter and which configurati │ ▼ ┌────────────────────────────────────────────────────────────────┐ -│ parameters/build_context │ +│ parameters/utils/build_context │ │ - Parse CONTEXT → EXTERNAL_ID, PARAMETER_ID, PARAMETER_VALUE │ │ - Derive PARAMETER_KIND from $CONTEXT.secret │ │ - Read $CONTEXT.provider.specification_id │ @@ -56,8 +56,10 @@ The platform decides which provider handles each parameter and which configurati │ ▼ ┌────────────────────────────────────────────────────────────────┐ -│ parameters/ (dispatch) │ -│ - One-liner: source providers/$ACTIVE_PROVIDER/ │ +│ parameters/utils/dispatch (unified dispatcher) │ +│ - Reads $ACTION (set by workflow `configuration:` block) │ +│ - source providers/$ACTIVE_PROVIDER/$ACTION │ +│ - Special-case: ACTION=notify with no provider notify → ack │ └────────────────────────────────────────────────────────────────┘ │ ▼ @@ -112,7 +114,7 @@ For each parameter, nullplatform stores which provider should handle it. That ch `build_context` resolves this UUID into a slug using the np CLI: ``` -np provider specification read --id --output json +np provider specification read --id --format json → { "slug": "aws_secret_manager", ... } ``` @@ -128,18 +130,18 @@ The provider's configuration is registered upfront as a `parameters-storage` pro ``` parameters/ -├── entrypoint # Action router (action → workflow) -├── build_context # Resolves ACTIVE_PROVIDER from spec_id, sources setup -├── store, retrieve, # Dispatch one-liners -│ delete, notify -├── workflows/ # 4 YAMLs (one per action) -├── utils/ +├── entrypoint # Action router (the only loose script — entry point) +├── workflows/ # 4 YAMLs (one per action), each sets ACTION via configuration +├── utils/ # All shared scripts live here +│ ├── build_context # Resolves ACTIVE_PROVIDER from spec_id, sources setup +│ ├── build_external_id # Composes # via parallel np slug fetches +│ ├── dispatch # Unified action dispatcher (reads $ACTION) │ ├── get_config_value # Priority: provider config > env > default │ └── log # All levels route to stderr ├── providers/ │ ├── README.md # Contract every provider must satisfy │ ├── hashicorp_vault/ # HTTP API -│ ├── aws_secret_manager/ # aws CLI +│ ├── aws_secret_manager/ # aws CLI │ ├── parameter_store/ # aws CLI (only kind-branching provider) │ └── azure_key_vault/ # az CLI ├── tests/ # BATS — mirrors source structure diff --git a/parameters/notify b/parameters/notify deleted file mode 100755 index 7ddb095e..00000000 --- a/parameters/notify +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Dispatch: delegate to the active provider's notify implementation if defined, -# otherwise return the default success ack. Notify is optional per provider. -if [ -f "$PROVIDER_DIR/notify" ]; then - source "$PROVIDER_DIR/notify" -else - echo '{"success":true}' -fi diff --git a/parameters/retrieve b/parameters/retrieve deleted file mode 100755 index 3d400379..00000000 --- a/parameters/retrieve +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Dispatch: delegate to the active provider's retrieve implementation. -# PROVIDER_DIR is exported by build_context. -source "$PROVIDER_DIR/retrieve" diff --git a/parameters/store b/parameters/store deleted file mode 100755 index 338a2c8c..00000000 --- a/parameters/store +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Dispatch: delegate to the active provider's store implementation. -# PROVIDER_DIR is exported by build_context. -source "$PROVIDER_DIR/store" diff --git a/parameters/tests/delete.bats b/parameters/tests/delete.bats deleted file mode 100644 index 993a4fc5..00000000 --- a/parameters/tests/delete.bats +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bats -# ============================================================================= -# Unit tests for parameters/delete (dispatch) -# ============================================================================= - -setup() { - export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" - - source "$PROJECT_ROOT/testing/assertions.sh" - - export SCRIPT="$PARAMETERS_DIR/delete" - export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" - mkdir -p "$PROVIDER_DIR" -} - -@test "delete: sources provider's delete and propagates stdout" { - cat > "$PROVIDER_DIR/delete" << 'EOF' -echo '{"success":true}' -EOF - - run bash "$SCRIPT" - - assert_equal "$status" "0" - assert_equal "$output" '{"success":true}' -} - -@test "delete: provider script sees EXTERNAL_ID env var" { - export EXTERNAL_ID="ext-to-delete" - cat > "$PROVIDER_DIR/delete" << 'EOF' -echo "{\"deleted\":\"$EXTERNAL_ID\"}" -EOF - - run bash "$SCRIPT" - - assert_equal "$status" "0" - assert_contains "$output" "ext-to-delete" -} - -@test "delete: fails when provider's delete doesn't exist" { - run bash "$SCRIPT" - [ "$status" -ne 0 ] -} diff --git a/parameters/tests/notify.bats b/parameters/tests/notify.bats deleted file mode 100644 index 5896a964..00000000 --- a/parameters/tests/notify.bats +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bats -# ============================================================================= -# Unit tests for parameters/notify (dispatch with default fallback) -# ============================================================================= - -setup() { - export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" - - source "$PROJECT_ROOT/testing/assertions.sh" - - export SCRIPT="$PARAMETERS_DIR/notify" - export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" - mkdir -p "$PROVIDER_DIR" -} - -@test "notify: uses provider's notify when present" { - cat > "$PROVIDER_DIR/notify" << 'EOF' -echo '{"success":true,"provider":"fake"}' -EOF - - run bash "$SCRIPT" - - assert_equal "$status" "0" - assert_contains "$output" '"provider":"fake"' -} - -@test "notify: falls back to default success when provider has no notify" { - # Intentionally do NOT create $PROVIDER_DIR/notify - - run bash "$SCRIPT" - - assert_equal "$status" "0" - assert_equal "$output" '{"success":true}' -} - -@test "notify: provider's notify failure propagates" { - cat > "$PROVIDER_DIR/notify" << 'EOF' -echo "ack failed" >&2 -exit 7 -EOF - - run bash "$SCRIPT" - - assert_equal "$status" "7" - assert_contains "$output" "ack failed" -} diff --git a/parameters/tests/retrieve.bats b/parameters/tests/retrieve.bats deleted file mode 100644 index 47d63dab..00000000 --- a/parameters/tests/retrieve.bats +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bats -# ============================================================================= -# Unit tests for parameters/retrieve (dispatch) -# ============================================================================= - -setup() { - export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" - - source "$PROJECT_ROOT/testing/assertions.sh" - - export SCRIPT="$PARAMETERS_DIR/retrieve" - export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" - mkdir -p "$PROVIDER_DIR" -} - -@test "retrieve: sources provider's retrieve and propagates stdout" { - cat > "$PROVIDER_DIR/retrieve" << 'EOF' -echo '{"value":"the-actual-value"}' -EOF - - run bash "$SCRIPT" - - assert_equal "$status" "0" - assert_equal "$output" '{"value":"the-actual-value"}' -} - -@test "retrieve: provider script sees EXTERNAL_ID env var" { - export EXTERNAL_ID="ext-abc-123" - cat > "$PROVIDER_DIR/retrieve" << 'EOF' -echo "{\"echoed_external_id\":\"$EXTERNAL_ID\"}" -EOF - - run bash "$SCRIPT" - - assert_equal "$status" "0" - assert_contains "$output" "ext-abc-123" -} - -@test "retrieve: fails when provider's retrieve doesn't exist" { - run bash "$SCRIPT" - [ "$status" -ne 0 ] -} diff --git a/parameters/tests/store.bats b/parameters/tests/store.bats deleted file mode 100644 index af300f77..00000000 --- a/parameters/tests/store.bats +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bats -# ============================================================================= -# Unit tests for parameters/store (dispatch) -# ============================================================================= - -setup() { - export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" - - source "$PROJECT_ROOT/testing/assertions.sh" - - export SCRIPT="$PARAMETERS_DIR/store" - export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" - mkdir -p "$PROVIDER_DIR" -} - -@test "store: sources provider's store and propagates stdout" { - cat > "$PROVIDER_DIR/store" << 'EOF' -echo '{"external_id":"test-id","metadata":{"k":"v"}}' -EOF - - run bash "$SCRIPT" - - assert_equal "$status" "0" - assert_equal "$output" '{"external_id":"test-id","metadata":{"k":"v"}}' -} - -@test "store: provider script sees PROVIDER_DIR env var" { - cat > "$PROVIDER_DIR/store" << 'EOF' -echo "{\"provider_dir\":\"$PROVIDER_DIR\"}" -EOF - - run bash "$SCRIPT" - - assert_equal "$status" "0" - assert_contains "$output" "\"provider_dir\":\"$PROVIDER_DIR\"" -} - -@test "store: fails when provider's store doesn't exist" { - run bash "$SCRIPT" - [ "$status" -ne 0 ] -} - -@test "store: provider script error propagates exit code" { - cat > "$PROVIDER_DIR/store" << 'EOF' -echo "fatal" >&2 -exit 1 -EOF - - run bash "$SCRIPT" - [ "$status" -ne 0 ] - assert_contains "$output" "fatal" -} diff --git a/parameters/tests/build_context.bats b/parameters/tests/utils/build_context.bats similarity index 95% rename from parameters/tests/build_context.bats rename to parameters/tests/utils/build_context.bats index 26e93275..28541c87 100644 --- a/parameters/tests/build_context.bats +++ b/parameters/tests/utils/build_context.bats @@ -1,15 +1,15 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/build_context — provider resolution via spec_id +# Unit tests for parameters/utils/build_context — provider resolution via spec_id # ============================================================================= setup() { - export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" - export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/build_context" + export SCRIPT="$PARAMETERS_DIR/utils/build_context" export TEST_PROVIDER_DIR="$PARAMETERS_DIR/providers/test_provider" # Mock the np CLI diff --git a/parameters/tests/utils/dispatch.bats b/parameters/tests/utils/dispatch.bats new file mode 100644 index 00000000..e4140ce7 --- /dev/null +++ b/parameters/tests/utils/dispatch.bats @@ -0,0 +1,99 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/dispatch — unified action dispatcher +# Replaces the previous 4 standalone scripts (store, retrieve, delete, notify) +# with a single dispatcher that takes the action via $ACTION env var. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/utils/dispatch" + export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" + mkdir -p "$PROVIDER_DIR" +} + +@test "dispatch: ACTION=store sources provider's store script" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo '{"external_id":"id-1","metadata":{}}' +EOF + + run bash -c "ACTION=store source $SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"external_id":"id-1","metadata":{}}' +} + +@test "dispatch: ACTION=retrieve sources provider's retrieve script" { + cat > "$PROVIDER_DIR/retrieve" << 'EOF' +echo '{"value":"v"}' +EOF + + run bash -c "ACTION=retrieve source $SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"value":"v"}' +} + +@test "dispatch: ACTION=delete sources provider's delete script" { + cat > "$PROVIDER_DIR/delete" << 'EOF' +echo '{"success":true}' +EOF + + run bash -c "ACTION=delete source $SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"success":true}' +} + +@test "dispatch: ACTION=notify with provider notify file sources it" { + cat > "$PROVIDER_DIR/notify" << 'EOF' +echo '{"success":true,"provider":"fake"}' +EOF + + run bash -c "ACTION=notify source $SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" '"provider":"fake"' +} + +@test "dispatch: ACTION=notify falls back to default ack when provider has no notify" { + # Intentionally do NOT create $PROVIDER_DIR/notify + run bash -c "ACTION=notify source $SCRIPT" + + assert_equal "$status" "0" + assert_equal "$output" '{"success":true}' +} + +@test "dispatch: provider script's exit code propagates" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo "fatal" >&2 +exit 7 +EOF + + run bash -c "ACTION=store source $SCRIPT" + + assert_equal "$status" "7" + assert_contains "$output" "fatal" +} + +@test "dispatch: provider script sees PROVIDER_DIR env var" { + cat > "$PROVIDER_DIR/store" << 'EOF' +echo "{\"provider_dir\":\"$PROVIDER_DIR\"}" +EOF + + run bash -c "ACTION=store source $SCRIPT" + + assert_equal "$status" "0" + assert_contains "$output" "$PROVIDER_DIR" +} + +@test "dispatch: fails when provider's script doesn't exist (non-notify)" { + # No store script exists + run bash -c "ACTION=store source $SCRIPT" + + [ "$status" -ne 0 ] +} diff --git a/parameters/build_context b/parameters/utils/build_context similarity index 92% rename from parameters/build_context rename to parameters/utils/build_context index e545daf6..9c261048 100755 --- a/parameters/build_context +++ b/parameters/utils/build_context @@ -24,10 +24,11 @@ set -euo pipefail # Plus anything the provider's setup exports (VAULT_ADDR, AWS_REGION, etc.) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -export PARAMETERS_ROOT="$SCRIPT_DIR" +# build_context lives under utils/; PARAMETERS_ROOT is the parent (parameters/) +export PARAMETERS_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -source "$SCRIPT_DIR/utils/log" -source "$SCRIPT_DIR/utils/get_config_value" +source "$SCRIPT_DIR/log" +source "$SCRIPT_DIR/get_config_value" # --- Notification fields --- export EXTERNAL_ID=$(echo "$CONTEXT" | jq -r '.external_id // empty') @@ -89,9 +90,9 @@ if [ -z "$ACTIVE_PROVIDER" ]; then exit 1 fi -PROVIDER_DIR="$SCRIPT_DIR/providers/$ACTIVE_PROVIDER" +PROVIDER_DIR="$PARAMETERS_ROOT/providers/$ACTIVE_PROVIDER" if [ ! -d "$PROVIDER_DIR" ]; then - available=$(ls "$SCRIPT_DIR/providers" 2>/dev/null | grep -v '^README' | tr '\n' ' ' || true) + available=$(ls "$PARAMETERS_ROOT/providers" 2>/dev/null | grep -v '^README' | tr '\n' ' ' || true) log error "❌ Provider implementation not found for slug '$ACTIVE_PROVIDER'" log error "" log error "💡 Possible causes:" diff --git a/parameters/utils/dispatch b/parameters/utils/dispatch new file mode 100755 index 00000000..e6c03431 --- /dev/null +++ b/parameters/utils/dispatch @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +# Unified dispatcher — delegates to the active provider's implementation for $ACTION. +# +# Invoked by every workflow as the second step, after build_context has resolved +# ACTIVE_PROVIDER and exported PROVIDER_DIR. The workflow YAML supplies the +# action via configuration: +# +# configuration: +# ACTION: store # or retrieve / delete / notify +# +# Special case: notify is OPTIONAL per provider. If the provider doesn't expose +# a notify script, return the default success ack so the action stays a no-op +# without forcing every provider to ship a trivial notify file. + +if [ "$ACTION" = "notify" ] && [ ! -f "$PROVIDER_DIR/notify" ]; then + echo '{"success":true}' + exit 0 +fi + +source "$PROVIDER_DIR/$ACTION" diff --git a/parameters/workflows/delete.yaml b/parameters/workflows/delete.yaml index 34e9e4f7..59b74c3d 100644 --- a/parameters/workflows/delete.yaml +++ b/parameters/workflows/delete.yaml @@ -1,12 +1,15 @@ +configuration: + ACTION: delete steps: - name: build_context type: script - file: "$SERVICE_PATH/parameters/build_context" + file: "$SERVICE_PATH/parameters/utils/build_context" output: - { name: ACTIVE_PROVIDER, type: environment } - { name: PROVIDER_DIR, type: environment } - { name: PARAMETER_KIND, type: environment } - { name: EXTERNAL_ID, type: environment } + - { name: EXTERNAL_ID_PATH, type: environment } - name: delete type: script - file: "$SERVICE_PATH/parameters/delete" + file: "$SERVICE_PATH/parameters/utils/dispatch" diff --git a/parameters/workflows/notify.yaml b/parameters/workflows/notify.yaml index b9e846cd..fb57ca5a 100644 --- a/parameters/workflows/notify.yaml +++ b/parameters/workflows/notify.yaml @@ -1,13 +1,16 @@ +configuration: + ACTION: notify steps: - name: build_context type: script - file: "$SERVICE_PATH/parameters/build_context" + file: "$SERVICE_PATH/parameters/utils/build_context" output: - { name: ACTIVE_PROVIDER, type: environment } - { name: PROVIDER_DIR, type: environment } - { name: PARAMETER_KIND, type: environment } - { name: EXTERNAL_ID, type: environment } + - { name: EXTERNAL_ID_PATH, type: environment } - { name: PARAMETER_ID, type: environment } - name: notify type: script - file: "$SERVICE_PATH/parameters/notify" + file: "$SERVICE_PATH/parameters/utils/dispatch" diff --git a/parameters/workflows/retrieve.yaml b/parameters/workflows/retrieve.yaml index a669885f..71f4011c 100644 --- a/parameters/workflows/retrieve.yaml +++ b/parameters/workflows/retrieve.yaml @@ -1,14 +1,18 @@ +configuration: + ACTION: retrieve steps: - name: build_context type: script - file: "$SERVICE_PATH/parameters/build_context" + file: "$SERVICE_PATH/parameters/utils/build_context" output: - { name: ACTIVE_PROVIDER, type: environment } - { name: PROVIDER_DIR, type: environment } - { name: PARAMETER_KIND, type: environment } - { name: EXTERNAL_ID, type: environment } + - { name: EXTERNAL_ID_PATH, type: environment } + - { name: EXTERNAL_ID_VERSION, type: environment } - { name: PARAMETER_NAME, type: environment } - { name: PARAMETER_ENCODING, type: environment } - name: retrieve type: script - file: "$SERVICE_PATH/parameters/retrieve" + file: "$SERVICE_PATH/parameters/utils/dispatch" diff --git a/parameters/workflows/store.yaml b/parameters/workflows/store.yaml index a63146ec..6b7c26e3 100644 --- a/parameters/workflows/store.yaml +++ b/parameters/workflows/store.yaml @@ -1,16 +1,20 @@ +configuration: + ACTION: store steps: - name: build_context type: script - file: "$SERVICE_PATH/parameters/build_context" + file: "$SERVICE_PATH/parameters/utils/build_context" output: - { name: ACTIVE_PROVIDER, type: environment } - { name: PROVIDER_DIR, type: environment } - { name: PARAMETER_KIND, type: environment } - { name: EXTERNAL_ID, type: environment } + - { name: EXTERNAL_ID_PATH, type: environment } + - { name: EXTERNAL_ID_VERSION, type: environment } - { name: PARAMETER_ID, type: environment } - { name: PARAMETER_VALUE, type: environment } - { name: PARAMETER_NAME, type: environment } - { name: PARAMETER_ENCODING, type: environment } - name: store type: script - file: "$SERVICE_PATH/parameters/store" + file: "$SERVICE_PATH/parameters/utils/dispatch" From 693370a9d4abf307cd576eab2740dab67f8d5e16 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 24 Jun 2026 12:05:09 -0300 Subject: [PATCH 11/41] docs(parameters): clarify optional scope entity and optional dimensions --- parameters/docs/adding_a_provider.md | 9 ++++- parameters/docs/architecture.md | 8 ++++- .../aws_secret_manager/docs/architecture.md | 33 +++++++++++++++---- .../azure_key_vault/docs/architecture.md | 2 ++ .../hashicorp_vault/docs/architecture.md | 4 ++- .../parameter_store/docs/architecture.md | 4 ++- 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/parameters/docs/adding_a_provider.md b/parameters/docs/adding_a_provider.md index 3a6ffa45..c6ee98c2 100644 --- a/parameters/docs/adding_a_provider.md +++ b/parameters/docs/adding_a_provider.md @@ -19,9 +19,16 @@ All names are grouped under a `nullplatform` top-level prefix (default — opera The shared helper `parameters/utils/build_external_id` constructs the canonical form. Your `store` calls it once at the top: ``` -nullplatform/organization=-/account=-/namespace=-/application=-/=/- +nullplatform/organization=-/account=-/namespace=-/application=-[/scope=-][/=...]/- ``` +Two segments are conditional: + +- **`scope` entity** is optional — appears only when the parameter is bound to a specific deployment scope, between `application` and dimensions. +- **Dimensions** are optional — a parameter may have zero, one, or many. Present dimensions are sorted alphabetically by key. + +The required entities (`organization`, `account`, `namespace`, `application`) are always present in nullplatform's NRN, in that canonical order. + The principle: an operator who opens the backend's UI (AWS Console, Vault UI, Azure Portal) must be able to navigate to any secret by knowing the parameter's nullplatform context, without consulting the platform's database. The path tells the story. If your backend has naming restrictions (e.g. Azure Key Vault disallows `/` and `=`), transform the canonical form deterministically inside your `store`/`retrieve`/`delete`. The `external_id` returned to nullplatform always uses the canonical (slash) form so it's portable across providers. diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md index fc039102..92fd9b92 100644 --- a/parameters/docs/architecture.md +++ b/parameters/docs/architecture.md @@ -81,9 +81,15 @@ Every provider composes its storage path from the parameter's NRN entities (slug The shared helper `parameters/utils/build_external_id` constructs the canonical form, fetching slugs from the np CLI in parallel: ``` -/organization=-/account=-/.../=/- +/organization=-/account=-/namespace=-/application=-[/scope=-][/=...]/- ``` +**Required entities** (always present, always in this order): `organization`, `account`, `namespace`, `application`. + +**Optional entity**: `scope` — appears as a segment between `application` and dimensions, only when the parameter is bound to a specific deployment scope. + +**Optional dimensions**: zero or more `key=value` segments, sorted alphabetically by key for determinism. A parameter without dimensions has no dimension segments in the path at all. + Each provider applies the prefix (default `nullplatform/`) and any backend-specific sanitization (Azure Key Vault flattens slashes and equals to dashes; everyone else uses the canonical form). The canonical `external_id` returned to nullplatform is the same across all providers, which makes parameter migration between backends mechanically possible. ### Version encoding in external_id diff --git a/parameters/providers/aws_secret_manager/docs/architecture.md b/parameters/providers/aws_secret_manager/docs/architecture.md index 2028629f..e96afa6d 100644 --- a/parameters/providers/aws_secret_manager/docs/architecture.md +++ b/parameters/providers/aws_secret_manager/docs/architecture.md @@ -36,20 +36,41 @@ nullplatform/organization=-/account=-/.../=-`): slugs are human-readable, IDs are stable. Combining both gives both readability and resilience to potential slug rename support in the future. diff --git a/parameters/providers/azure_key_vault/docs/architecture.md b/parameters/providers/azure_key_vault/docs/architecture.md index 5aebaf36..0f278fff 100644 --- a/parameters/providers/azure_key_vault/docs/architecture.md +++ b/parameters/providers/azure_key_vault/docs/architecture.md @@ -27,6 +27,8 @@ AKV name: nullplatform-organization-acme-1255165411-account-prod-95118862-...- The transformation is `/=` → `-`, deterministic. The canonical form (with `/` and `=`) is what nullplatform sees in `external_id`; the AKV-safe form is only used internally to address the secret. +The canonical path follows the standard convention: required entities `organization`, `account`, `namespace`, `application`, plus the optional `scope` entity (when the parameter is bound to a deployment scope), plus optional dimensions (zero or more, sorted alphabetically). See `parameters/docs/architecture.md` for the complete naming convention. + Max secret name length in AKV is 127 characters. The provider checks this and surfaces a helpful error if exceeded. --- diff --git a/parameters/providers/hashicorp_vault/docs/architecture.md b/parameters/providers/hashicorp_vault/docs/architecture.md index f516b37d..3ebba195 100644 --- a/parameters/providers/hashicorp_vault/docs/architecture.md +++ b/parameters/providers/hashicorp_vault/docs/architecture.md @@ -21,9 +21,11 @@ This document describes the `parameters/providers/hashicorp_vault/` implementati Every secret path is composed by `parameters/utils/build_external_id`: ``` -/=-/.../=/- +/organization=-/account=-/namespace=-/application=-[/scope=-][/=...]/- ``` +The `scope` entity is optional (only present when the parameter is bound to a deployment scope). Dimensions are also optional — a parameter may have zero of them. See `parameters/docs/architecture.md` for the complete naming convention. + Default `VAULT_PATH_PREFIX` is `secret/data/nullplatform` (KV v2 — note the `data/` segment is required by the v2 API). Example full path: diff --git a/parameters/providers/parameter_store/docs/architecture.md b/parameters/providers/parameter_store/docs/architecture.md index 9ecb4601..8aeb5621 100644 --- a/parameters/providers/parameter_store/docs/architecture.md +++ b/parameters/providers/parameter_store/docs/architecture.md @@ -23,9 +23,11 @@ Cheapest provider in the package — Standard tier is free up to 10,000 paramete Every parameter name is composed by `parameters/utils/build_external_id`: ``` -=-/.../=/- +organization=-/account=-/namespace=-/application=-[/scope=-][/=...]/- ``` +The `scope` entity is optional (only present when the parameter is bound to a deployment scope). Dimensions are also optional. See `parameters/docs/architecture.md` for the complete naming convention. + Default `PS_NAME_PREFIX` is `/nullplatform/`. SSM requires names to start with `/` for hierarchical organization. Example: From 24dba2a2bf888fd0ea896aa2b677176dc6ec13e7 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 24 Jun 2026 13:58:31 -0300 Subject: [PATCH 12/41] refactor(parameters): hardcode invariant prefixes; AWS_REGION from runtime --- .../aws_secret_manager_configuration.json.tpl | 17 +------ .../aws_secret_manager/docs/architecture.md | 4 +- .../providers/aws_secret_manager/retrieve | 1 - parameters/providers/aws_secret_manager/setup | 38 ++++----------- .../azure_key_vault_configuration.json.tpl | 7 --- parameters/providers/azure_key_vault/setup | 26 +++------- .../hashicorp_vault_configuration.json.tpl | 6 --- parameters/providers/hashicorp_vault/setup | 24 ++++------ .../parameter_store_configuration.json.tpl | 17 +------ parameters/providers/parameter_store/setup | 39 ++++----------- .../providers/aws_secret_manager/setup.bats | 40 +++++++--------- .../providers/azure_key_vault/setup.bats | 37 +++++--------- .../providers/hashicorp_vault/setup.bats | 48 +++++++------------ .../providers/parameter_store/setup.bats | 48 +++++-------------- parameters/utils/get_config_value | 34 +++++++------ 15 files changed, 120 insertions(+), 266 deletions(-) diff --git a/parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl b/parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl index 68ec3381..db165ffe 100644 --- a/parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl +++ b/parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl @@ -10,25 +10,12 @@ "allow_dimensions": true, "schema": { "type": "object", - "required": [ - "region" - ], "properties": { - "region": { - "type": "string", - "title": "AWS Region", - "description": "AWS region where secrets will be stored (e.g. us-east-1)" - }, - "name_prefix": { - "type": "string", - "title": "Secret Name Prefix", - "description": "Prefix prepended to every secret name. Acts as the IAM scoping anchor", - "default": "nullplatform/" - }, "kms_key_id": { "type": "string", "title": "KMS Key ID (optional)", - "description": "Customer-managed KMS key ARN or alias. If empty, the default aws/secretsmanager managed key is used" + "description": "Customer-managed KMS key ARN or alias. If empty, the default aws/secretsmanager managed key is used", + "default": "" } } } diff --git a/parameters/providers/aws_secret_manager/docs/architecture.md b/parameters/providers/aws_secret_manager/docs/architecture.md index e96afa6d..7eb3c0d9 100644 --- a/parameters/providers/aws_secret_manager/docs/architecture.md +++ b/parameters/providers/aws_secret_manager/docs/architecture.md @@ -188,10 +188,10 @@ For nullplatform's model — where the version history is the recovery mechanism | Error condition | `store` | `retrieve` | `delete` | |----------------------------------------|-------------------|--------------------------|--------------------| | Resource exists (on store) | New version added | N/A | N/A | -| ResourceNotFoundException | N/A | `{value: "value not found"}` | Idempotent success | +| ResourceNotFoundException | N/A | Exit 1 + troubleshooting | Idempotent success | | Any other error (IAM, network, region) | Exit 1 + troubleshooting | Exit 1 + troubleshooting | Exit 1 + troubleshooting | -Only `ResourceNotFoundException` is treated as idempotent success. Every other error — particularly IAM permission failures — propagates as a real error with troubleshooting guidance. Silent success on permission failures would lead to "the parameter says deleted but the secret is still there" bugs. +For `delete`, `ResourceNotFoundException` is treated as idempotent success — the resource is already in the desired state, the work is done. For `retrieve`, not-found is a real error: returning a sentinel like "value not found" as the value would mislead the platform into displaying that string as the parameter's actual value. Every other error — particularly IAM permission failures — propagates as a real error with troubleshooting guidance. ### Encryption at rest diff --git a/parameters/providers/aws_secret_manager/retrieve b/parameters/providers/aws_secret_manager/retrieve index 0fd1f028..bc1b1fb6 100755 --- a/parameters/providers/aws_secret_manager/retrieve +++ b/parameters/providers/aws_secret_manager/retrieve @@ -40,7 +40,6 @@ else log error "" log error "💡 Possible causes:" log error " • The secret was manually deleted from AWS" - log error " • The external_id is stale (parameter was deleted in nullplatform but referenced elsewhere)" if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then log error " • Requested version '$EXTERNAL_ID_VERSION' was pruned (AWS SM auto-removes oldest unlabeled versions past 100)" fi diff --git a/parameters/providers/aws_secret_manager/setup b/parameters/providers/aws_secret_manager/setup index 45e4c59e..d719711c 100755 --- a/parameters/providers/aws_secret_manager/setup +++ b/parameters/providers/aws_secret_manager/setup @@ -2,40 +2,22 @@ set -euo pipefail # Validates AWS Secrets Manager connection config. -# Sourced once by parameters/build_context before any operation script runs. # -# Reads, in priority order: PROVIDER_CONFIG, environment variables, defaults. -# Exports for operation scripts: AWS_REGION, SM_NAME_PREFIX, SM_KMS_KEY_ID +# AWS_REGION is injected by the agent's runtime (IRSA / instance profile / +# service account env), not by provider config. The name prefix is hardcoded +# to `nullplatform/` to guarantee a single invariant namespace for all platform +# secrets — changing it would break retrieval of historical external_ids. +# +# Only KMS key choice is operator-configurable, via PROVIDER_CONFIG.kms_key_id. +# +# Exports: AWS_REGION, SM_NAME_PREFIX, SM_KMS_KEY_ID -AWS_REGION=$(get_config_value \ - --env AWS_REGION \ - --env AWS_DEFAULT_REGION \ - --provider '.region') +: "${AWS_REGION:?AWS_REGION must be set by the agent runtime}" -SM_NAME_PREFIX=$(get_config_value \ - --env SM_NAME_PREFIX \ - --provider '.name_prefix' \ - --default 'nullplatform/') +SM_NAME_PREFIX="nullplatform/" SM_KMS_KEY_ID=$(get_config_value \ - --env SM_KMS_KEY_ID \ --provider '.kms_key_id' \ --default '') -if [ -z "$AWS_REGION" ]; then - log error "❌ AWS region not configured for aws_secret_manager" - log error "" - log error "💡 Possible causes:" - log error " • AWS_REGION (or AWS_DEFAULT_REGION) env var is not set" - log error " • .region is missing in the aws_secret_manager provider config" - log error "" - log error "🔧 How to fix:" - log error " • Set AWS_REGION= (e.g. us-east-1)" - log error " • Or populate PROVIDER_CONFIG.region via providers/aws_secret_manager/fetch_configuration" - exit 1 -fi - -# AWS credentials come from the SDK default chain (IRSA, instance profile, env vars). -# Not validated here — AWS CLI will surface auth errors on the first call. - export AWS_REGION SM_NAME_PREFIX SM_KMS_KEY_ID diff --git a/parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl b/parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl index 78cd747e..509d9e4e 100644 --- a/parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl +++ b/parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl @@ -18,13 +18,6 @@ "type": "string", "title": "Vault Name", "description": "Azure Key Vault name (without https:// or .vault.azure.net suffix)" - }, - "secret_prefix": { - "type": "string", - "title": "Secret Name Prefix", - "description": "Prefix prepended to every secret name. AKV only allows alphanumerics and dashes", - "default": "nullplatform-", - "pattern": "^[A-Za-z0-9-]*$" } } } diff --git a/parameters/providers/azure_key_vault/setup b/parameters/providers/azure_key_vault/setup index 89cc1fbc..5ba2842f 100755 --- a/parameters/providers/azure_key_vault/setup +++ b/parameters/providers/azure_key_vault/setup @@ -3,21 +3,20 @@ set -euo pipefail # Validates Azure Key Vault connection config. # -# Reads, in priority order: PROVIDER_CONFIG, environment variables, defaults. -# Exports: AZ_VAULT_NAME, AZ_SECRET_PREFIX +# The secret prefix is hardcoded to `nullplatform-` — invariant namespace for +# all platform secrets in this vault. Only the vault name is operator-configurable. # # Auth comes from the Azure CLI's default credential chain (managed identity, # az login, service principal env vars). Not validated here — az will surface # auth errors on the first call. +# +# Exports: AZ_VAULT_NAME, AZ_SECRET_PREFIX AZ_VAULT_NAME=$(get_config_value \ --env AZURE_KEY_VAULT_NAME \ --provider '.vault_name') -AZ_SECRET_PREFIX=$(get_config_value \ - --env AZURE_KEY_VAULT_SECRET_PREFIX \ - --provider '.secret_prefix' \ - --default 'nullplatform-') +AZ_SECRET_PREFIX="nullplatform-" if [ -z "$AZ_VAULT_NAME" ]; then log error "❌ Azure Key Vault name not configured" @@ -28,20 +27,7 @@ if [ -z "$AZ_VAULT_NAME" ]; then log error "" log error "🔧 How to fix:" log error " • Set AZURE_KEY_VAULT_NAME=" - log error " • Or populate PROVIDER_CONFIG.vault_name via providers/azure_key_vault/fetch_configuration" - exit 1 -fi - -# Azure Key Vault secret names are constrained: alphanumeric + dashes, max 127 chars. -# UUIDs satisfy this. Validate the prefix uses only valid chars. -if [[ ! "$AZ_SECRET_PREFIX" =~ ^[A-Za-z0-9-]*$ ]]; then - log error "❌ Invalid AZ_SECRET_PREFIX '$AZ_SECRET_PREFIX'" - log error "" - log error "💡 Possible causes:" - log error " • Azure Key Vault secret names allow only alphanumerics and dashes" - log error "" - log error "🔧 How to fix:" - log error " • Set AZURE_KEY_VAULT_SECRET_PREFIX to a string matching [A-Za-z0-9-]*" + log error " • Or set .vault_name in the provider config attributes" exit 1 fi diff --git a/parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl b/parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl index 138e85de..0384986f 100644 --- a/parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl +++ b/parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl @@ -18,12 +18,6 @@ "type": "string", "title": "Vault Address", "description": "Vault HTTP(S) endpoint (e.g. https://vault.example.com:8200)" - }, - "path_prefix": { - "type": "string", - "title": "Path Prefix", - "description": "KV v2 path prefix prepended to every secret. Format: secret/data/", - "default": "secret/data/nullplatform" } } } diff --git a/parameters/providers/hashicorp_vault/setup b/parameters/providers/hashicorp_vault/setup index 72cfec2a..f0214afb 100755 --- a/parameters/providers/hashicorp_vault/setup +++ b/parameters/providers/hashicorp_vault/setup @@ -2,19 +2,16 @@ set -euo pipefail # Validates HashiCorp Vault connection config. -# Sourced once by parameters/build_context before any operation script runs. # -# Reads, in priority order: PROVIDER_CONFIG (if populated by fetch_configuration), -# environment variables, defaults. +# The KV path prefix is hardcoded to `secret/data/nullplatform` — invariant +# namespace for all platform secrets. VAULT_ADDR is operator-configurable +# (per Vault instance). VAULT_TOKEN is sensitive and should come from env only. # -# Exports for operation scripts: VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX +# Exports: VAULT_ADDR, VAULT_TOKEN, VAULT_PATH_PREFIX VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') -VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') -VAULT_PATH_PREFIX=$(get_config_value \ - --env VAULT_PATH_PREFIX \ - --provider '.path_prefix' \ - --default 'secret/data/nullplatform') +VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN) +VAULT_PATH_PREFIX="secret/data/nullplatform" if [ -z "$VAULT_ADDR" ]; then log error "❌ Vault address not configured" @@ -25,7 +22,7 @@ if [ -z "$VAULT_ADDR" ]; then log error "" log error "🔧 How to fix:" log error " • Set VAULT_ADDR=https://your-vault-host" - log error " • Or populate PROVIDER_CONFIG.address via providers/hashicorp_vault/fetch_configuration" + log error " • Or set .address in the provider config attributes" exit 1 fi @@ -33,12 +30,11 @@ if [ -z "$VAULT_TOKEN" ]; then log error "❌ Vault token not configured" log error "" log error "💡 Possible causes:" - log error " • VAULT_TOKEN env var is not set in the workflow runtime" - log error " • .token is missing in the hashicorp_vault provider config" + log error " • VAULT_TOKEN env var is not set in the agent runtime" log error "" log error "🔧 How to fix:" - log error " • Set VAULT_TOKEN=" - log error " • Or populate PROVIDER_CONFIG.token via providers/hashicorp_vault/fetch_configuration" + log error " • Set VAULT_TOKEN= in the agent environment" + log error " • Tokens are sensitive — set via env, never via provider config" exit 1 fi diff --git a/parameters/providers/parameter_store/parameter_store_configuration.json.tpl b/parameters/providers/parameter_store/parameter_store_configuration.json.tpl index 1a83b7af..0a1c7129 100644 --- a/parameters/providers/parameter_store/parameter_store_configuration.json.tpl +++ b/parameters/providers/parameter_store/parameter_store_configuration.json.tpl @@ -10,25 +10,12 @@ "allow_dimensions": true, "schema": { "type": "object", - "required": [ - "region" - ], "properties": { - "region": { - "type": "string", - "title": "AWS Region", - "description": "AWS region where parameters will be stored (e.g. us-east-1)" - }, - "name_prefix": { - "type": "string", - "title": "Parameter Name Prefix", - "description": "Prefix prepended to every parameter name. Must start with a slash", - "default": "/nullplatform/" - }, "kms_key_id": { "type": "string", "title": "KMS Key ID (optional)", - "description": "Customer-managed KMS key for SecureString parameters. If empty, the default alias/aws/ssm key is used" + "description": "Customer-managed KMS key for SecureString parameters. If empty, the default alias/aws/ssm key is used", + "default": "" }, "tier": { "type": "string", diff --git a/parameters/providers/parameter_store/setup b/parameters/providers/parameter_store/setup index e0e883f6..cf3ff4a4 100755 --- a/parameters/providers/parameter_store/setup +++ b/parameters/providers/parameter_store/setup @@ -3,50 +3,29 @@ set -euo pipefail # Validates AWS Systems Manager Parameter Store connection config. # -# Required env (exported by parameters/build_context): +# AWS_REGION is injected by the agent's runtime, not by provider config. +# The name prefix is hardcoded to `/nullplatform/` — invariant namespace for +# all platform parameters. +# +# Operator-configurable: kms_key_id (for SecureString) and tier. +# +# Required env (from build_context): # PARAMETER_KIND — "secret" or "parameter"; determines String vs SecureString # -# Reads, in priority order: PROVIDER_CONFIG, environment variables, defaults. # Exports: AWS_REGION, PS_NAME_PREFIX, PS_KMS_KEY_ID, PS_TIER -AWS_REGION=$(get_config_value \ - --env AWS_REGION \ - --env AWS_DEFAULT_REGION \ - --provider '.region') +: "${AWS_REGION:?AWS_REGION must be set by the agent runtime}" -PS_NAME_PREFIX=$(get_config_value \ - --env PS_NAME_PREFIX \ - --provider '.name_prefix' \ - --default '/nullplatform/') +PS_NAME_PREFIX="/nullplatform/" PS_KMS_KEY_ID=$(get_config_value \ - --env PS_KMS_KEY_ID \ --provider '.kms_key_id' \ --default '') PS_TIER=$(get_config_value \ - --env PS_TIER \ --provider '.tier' \ --default 'Standard') -if [ -z "$AWS_REGION" ]; then - log error "❌ AWS region not configured for parameter_store" - log error "" - log error "💡 Possible causes:" - log error " • AWS_REGION (or AWS_DEFAULT_REGION) env var is not set" - log error " • .region is missing in the parameter_store provider config" - log error "" - log error "🔧 How to fix:" - log error " • Set AWS_REGION=" - log error " • Or populate PROVIDER_CONFIG.region via providers/parameter_store/fetch_configuration" - exit 1 -fi - -# Normalize the name prefix: must start with '/' (SSM hierarchical naming) and end with '/'. -[[ "$PS_NAME_PREFIX" != /* ]] && PS_NAME_PREFIX="/$PS_NAME_PREFIX" -[[ "$PS_NAME_PREFIX" != */ ]] && PS_NAME_PREFIX="$PS_NAME_PREFIX/" - -# Tier must be one of the valid SSM values. case "$PS_TIER" in Standard|Advanced|Intelligent-Tiering) ;; *) diff --git a/parameters/tests/providers/aws_secret_manager/setup.bats b/parameters/tests/providers/aws_secret_manager/setup.bats index 82c82394..94851790 100644 --- a/parameters/tests/providers/aws_secret_manager/setup.bats +++ b/parameters/tests/providers/aws_secret_manager/setup.bats @@ -14,54 +14,50 @@ setup() { } teardown() { - unset AWS_REGION AWS_DEFAULT_REGION SM_NAME_PREFIX SM_KMS_KEY_ID PROVIDER_CONFIG + unset AWS_REGION SM_NAME_PREFIX SM_KMS_KEY_ID PROVIDER_CONFIG } -@test "aws_secret_manager setup: fails when AWS_REGION is missing" { - unset AWS_REGION AWS_DEFAULT_REGION +@test "aws_secret_manager setup: fails fast when AWS_REGION is missing" { + unset AWS_REGION run bash -c "$DEPS; source $SCRIPT" [ "$status" -ne 0 ] - assert_contains "$output" "❌ AWS region not configured for aws_secret_manager" - assert_contains "$output" "💡 Possible causes:" - assert_contains "$output" "🔧 How to fix:" + assert_contains "$output" "AWS_REGION" } -@test "aws_secret_manager setup: AWS_DEFAULT_REGION is honored when AWS_REGION is unset" { - unset AWS_REGION - export AWS_DEFAULT_REGION="eu-west-1" +@test "aws_secret_manager setup: name_prefix is hardcoded to 'nullplatform/'" { + export AWS_REGION="us-east-1" + # Even if PROVIDER_CONFIG tries to set name_prefix, it's ignored (hardcoded invariant) + export PROVIDER_CONFIG='{"name_prefix":"custom/"}' - run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION" + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$SM_NAME_PREFIX" assert_equal "$status" "0" - assert_contains "$output" "REGION=eu-west-1" + assert_contains "$output" "PREFIX=nullplatform/" } -@test "aws_secret_manager setup: default name_prefix is 'nullplatform/'" { - export AWS_REGION="us-east-1" +@test "aws_secret_manager setup: AWS_REGION is taken from env (runtime-injected)" { + export AWS_REGION="eu-west-1" - run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$SM_NAME_PREFIX" + run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION" assert_equal "$status" "0" - assert_contains "$output" "PREFIX=nullplatform/" + assert_contains "$output" "REGION=eu-west-1" } -@test "aws_secret_manager setup: PROVIDER_CONFIG wins over env" { +@test "aws_secret_manager setup: kms_key_id from PROVIDER_CONFIG" { export AWS_REGION="us-east-1" - export PROVIDER_CONFIG='{"region":"eu-central-1","name_prefix":"custom/","kms_key_id":"alias/mykey"}' + export PROVIDER_CONFIG='{"kms_key_id":"alias/mykey"}' - run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION PREFIX=\$SM_NAME_PREFIX KMS=\$SM_KMS_KEY_ID" + run bash -c "$DEPS; source $SCRIPT && echo KMS=\$SM_KMS_KEY_ID" assert_equal "$status" "0" - assert_contains "$output" "REGION=eu-central-1" - assert_contains "$output" "PREFIX=custom/" assert_contains "$output" "KMS=alias/mykey" } -@test "aws_secret_manager setup: kms_key_id is optional (empty when unset)" { +@test "aws_secret_manager setup: kms_key_id is empty by default" { export AWS_REGION="us-east-1" - unset SM_KMS_KEY_ID run bash -c "$DEPS; source $SCRIPT && echo KMS=[\$SM_KMS_KEY_ID]" diff --git a/parameters/tests/providers/azure_key_vault/setup.bats b/parameters/tests/providers/azure_key_vault/setup.bats index cd2834cd..b7cff0ff 100644 --- a/parameters/tests/providers/azure_key_vault/setup.bats +++ b/parameters/tests/providers/azure_key_vault/setup.bats @@ -14,7 +14,7 @@ setup() { } teardown() { - unset AZURE_KEY_VAULT_NAME AZURE_KEY_VAULT_SECRET_PREFIX AZ_VAULT_NAME AZ_SECRET_PREFIX PROVIDER_CONFIG + unset AZURE_KEY_VAULT_NAME AZ_VAULT_NAME AZ_SECRET_PREFIX PROVIDER_CONFIG } @test "azure_key_vault setup: fails when vault name is missing" { @@ -25,7 +25,7 @@ teardown() { assert_contains "$output" "🔧 How to fix:" } -@test "azure_key_vault setup: succeeds with vault name from env" { +@test "azure_key_vault setup: vault name from env" { export AZURE_KEY_VAULT_NAME="my-vault" run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME PREFIX=\$AZ_SECRET_PREFIX" @@ -35,35 +35,22 @@ teardown() { assert_contains "$output" "PREFIX=nullplatform-" } -@test "azure_key_vault setup: PROVIDER_CONFIG wins over env" { - export AZURE_KEY_VAULT_NAME="env-vault" - export PROVIDER_CONFIG='{"vault_name":"cfg-vault","secret_prefix":"app-secret-"}' - - run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME PREFIX=\$AZ_SECRET_PREFIX" - - assert_equal "$status" "0" - assert_contains "$output" "VAULT=cfg-vault" - assert_contains "$output" "PREFIX=app-secret-" -} - -@test "azure_key_vault setup: rejects prefix with invalid characters" { +@test "azure_key_vault setup: secret_prefix is hardcoded to nullplatform-" { export AZURE_KEY_VAULT_NAME="my-vault" - export AZURE_KEY_VAULT_SECRET_PREFIX="invalid_prefix/" + # PROVIDER_CONFIG tries to override; ignored + export PROVIDER_CONFIG='{"secret_prefix":"app-secret-"}' - run bash -c "$DEPS; source $SCRIPT" + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$AZ_SECRET_PREFIX" - [ "$status" -ne 0 ] - assert_contains "$output" "❌ Invalid AZ_SECRET_PREFIX 'invalid_prefix/'" - assert_contains "$output" "alphanumerics and dashes" + assert_equal "$status" "0" + assert_contains "$output" "PREFIX=nullplatform-" } -@test "azure_key_vault setup: accepts empty prefix" { - export AZURE_KEY_VAULT_NAME="my-vault" - export AZURE_KEY_VAULT_SECRET_PREFIX="" +@test "azure_key_vault setup: vault_name from PROVIDER_CONFIG" { + export PROVIDER_CONFIG='{"vault_name":"cfg-vault"}' - run bash -c "$DEPS; source $SCRIPT && echo PREFIX=[\$AZ_SECRET_PREFIX]" + run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME" assert_equal "$status" "0" - # Empty env should fall through to default 'parameters-' - assert_contains "$output" "PREFIX=[nullplatform-]" + assert_contains "$output" "VAULT=cfg-vault" } diff --git a/parameters/tests/providers/hashicorp_vault/setup.bats b/parameters/tests/providers/hashicorp_vault/setup.bats index 64fa929a..77ea9b53 100644 --- a/parameters/tests/providers/hashicorp_vault/setup.bats +++ b/parameters/tests/providers/hashicorp_vault/setup.bats @@ -17,7 +17,7 @@ teardown() { unset VAULT_ADDR VAULT_TOKEN VAULT_PATH_PREFIX PROVIDER_CONFIG } -@test "vault setup: fails with troubleshooting when VAULT_ADDR is missing" { +@test "vault setup: fails when VAULT_ADDR is missing" { unset VAULT_ADDR export VAULT_TOKEN="hvs.xxx" @@ -25,11 +25,9 @@ teardown() { [ "$status" -ne 0 ] assert_contains "$output" "❌ Vault address not configured" - assert_contains "$output" "💡 Possible causes:" - assert_contains "$output" "🔧 How to fix:" } -@test "vault setup: fails with troubleshooting when VAULT_TOKEN is missing" { +@test "vault setup: fails when VAULT_TOKEN is missing" { export VAULT_ADDR="https://vault.example.com" unset VAULT_TOKEN @@ -39,46 +37,34 @@ teardown() { assert_contains "$output" "❌ Vault token not configured" } -@test "vault setup: succeeds with both env vars set, exports them" { +@test "vault setup: path_prefix is hardcoded to secret/data/nullplatform" { export VAULT_ADDR="https://vault.example.com" export VAULT_TOKEN="hvs.xxx" + export PROVIDER_CONFIG='{"path_prefix":"kv/data/custom"}' - run bash -c "$DEPS; source $SCRIPT && echo ADDR=\$VAULT_ADDR TOKEN=\$VAULT_TOKEN PREFIX=\$VAULT_PATH_PREFIX" + run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" assert_equal "$status" "0" - assert_contains "$output" "ADDR=https://vault.example.com" - assert_contains "$output" "TOKEN=hvs.xxx" assert_contains "$output" "PREFIX=secret/data/nullplatform" } -@test "vault setup: PROVIDER_CONFIG wins over env var" { - export VAULT_ADDR="https://env-vault.com" - export VAULT_TOKEN="env-token" - export PROVIDER_CONFIG='{"address":"https://provider-vault.com","token":"provider-token"}' - - run bash -c "$DEPS; source $SCRIPT && echo ADDR=\$VAULT_ADDR TOKEN=\$VAULT_TOKEN" - - assert_equal "$status" "0" - assert_contains "$output" "ADDR=https://provider-vault.com" - assert_contains "$output" "TOKEN=provider-token" -} - -@test "vault setup: custom path_prefix from PROVIDER_CONFIG" { - export PROVIDER_CONFIG='{"address":"https://v.com","token":"t","path_prefix":"kv/data/custom"}' +@test "vault setup: address from PROVIDER_CONFIG" { + export VAULT_TOKEN="hvs.xxx" + export PROVIDER_CONFIG='{"address":"https://cfg-vault.example.com"}' - run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + run bash -c "$DEPS; source $SCRIPT && echo ADDR=\$VAULT_ADDR" assert_equal "$status" "0" - assert_contains "$output" "PREFIX=kv/data/custom" + assert_contains "$output" "ADDR=https://cfg-vault.example.com" } -@test "vault setup: reads VAULT_PATH_PREFIX from env if PROVIDER_CONFIG has no path_prefix" { - export VAULT_ADDR="https://v.com" - export VAULT_TOKEN="t" - export VAULT_PATH_PREFIX="kv/data/from-env" +@test "vault setup: token must come from env (not PROVIDER_CONFIG)" { + export VAULT_ADDR="https://vault.example.com" + unset VAULT_TOKEN + export PROVIDER_CONFIG='{"token":"hvs.from-config"}' - run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$VAULT_PATH_PREFIX" + run bash -c "$DEPS; source $SCRIPT" - assert_equal "$status" "0" - assert_contains "$output" "PREFIX=kv/data/from-env" + [ "$status" -ne 0 ] + assert_contains "$output" "❌ Vault token not configured" } diff --git a/parameters/tests/providers/parameter_store/setup.bats b/parameters/tests/providers/parameter_store/setup.bats index 30f5bdca..02a1b36e 100644 --- a/parameters/tests/providers/parameter_store/setup.bats +++ b/parameters/tests/providers/parameter_store/setup.bats @@ -14,20 +14,21 @@ setup() { } teardown() { - unset AWS_REGION AWS_DEFAULT_REGION PS_NAME_PREFIX PS_KMS_KEY_ID PS_TIER PROVIDER_CONFIG + unset AWS_REGION PS_NAME_PREFIX PS_KMS_KEY_ID PS_TIER PROVIDER_CONFIG } -@test "parameter_store setup: fails when AWS_REGION is missing" { - unset AWS_REGION AWS_DEFAULT_REGION +@test "parameter_store setup: fails fast when AWS_REGION is missing" { + unset AWS_REGION run bash -c "$DEPS; source $SCRIPT" [ "$status" -ne 0 ] - assert_contains "$output" "❌ AWS region not configured for parameter_store" + assert_contains "$output" "AWS_REGION" } -@test "parameter_store setup: default name_prefix has leading and trailing slash" { +@test "parameter_store setup: name_prefix is hardcoded to '/nullplatform/'" { export AWS_REGION="us-east-1" + export PROVIDER_CONFIG='{"name_prefix":"/custom/"}' run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" @@ -35,26 +36,6 @@ teardown() { assert_contains "$output" "PREFIX=/nullplatform/" } -@test "parameter_store setup: normalizes prefix without leading slash" { - export AWS_REGION="us-east-1" - export PS_NAME_PREFIX="custom/path/" - - run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" - - assert_equal "$status" "0" - assert_contains "$output" "PREFIX=/custom/path/" -} - -@test "parameter_store setup: normalizes prefix without trailing slash" { - export AWS_REGION="us-east-1" - export PS_NAME_PREFIX="/custom/path" - - run bash -c "$DEPS; source $SCRIPT && echo PREFIX=\$PS_NAME_PREFIX" - - assert_equal "$status" "0" - assert_contains "$output" "PREFIX=/custom/path/" -} - @test "parameter_store setup: default tier is Standard" { export AWS_REGION="us-east-1" @@ -64,9 +45,9 @@ teardown() { assert_contains "$output" "TIER=Standard" } -@test "parameter_store setup: accepts Advanced tier" { +@test "parameter_store setup: accepts Advanced tier from PROVIDER_CONFIG" { export AWS_REGION="us-east-1" - export PS_TIER="Advanced" + export PROVIDER_CONFIG='{"tier":"Advanced"}' run bash -c "$DEPS; source $SCRIPT && echo TIER=\$PS_TIER" @@ -74,9 +55,9 @@ teardown() { assert_contains "$output" "TIER=Advanced" } -@test "parameter_store setup: rejects invalid tier with troubleshooting" { +@test "parameter_store setup: rejects invalid tier" { export AWS_REGION="us-east-1" - export PS_TIER="Bogus" + export PROVIDER_CONFIG='{"tier":"Bogus"}' run bash -c "$DEPS; source $SCRIPT" @@ -85,15 +66,12 @@ teardown() { assert_contains "$output" "Standard, Advanced, Intelligent-Tiering" } -@test "parameter_store setup: PROVIDER_CONFIG wins over env" { +@test "parameter_store setup: kms_key_id from PROVIDER_CONFIG" { export AWS_REGION="us-east-1" - export PROVIDER_CONFIG='{"region":"eu-west-1","name_prefix":"/cfg/path/","kms_key_id":"alias/cfg","tier":"Advanced"}' + export PROVIDER_CONFIG='{"kms_key_id":"alias/cfg"}' - run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION PREFIX=\$PS_NAME_PREFIX KMS=\$PS_KMS_KEY_ID TIER=\$PS_TIER" + run bash -c "$DEPS; source $SCRIPT && echo KMS=\$PS_KMS_KEY_ID" assert_equal "$status" "0" - assert_contains "$output" "REGION=eu-west-1" - assert_contains "$output" "PREFIX=/cfg/path/" assert_contains "$output" "KMS=alias/cfg" - assert_contains "$output" "TIER=Advanced" } diff --git a/parameters/utils/get_config_value b/parameters/utils/get_config_value index 7d173131..a4a1f7d7 100755 --- a/parameters/utils/get_config_value +++ b/parameters/utils/get_config_value @@ -27,24 +27,28 @@ get_config_value() { done # Priority 1: provider config - for jq_path in "${providers[@]}"; do - if [[ -n "$jq_path" && -n "${PROVIDER_CONFIG:-}" ]]; then - local v - v=$(echo "$PROVIDER_CONFIG" | jq -r "$jq_path // empty" 2>/dev/null || true) - if [[ -n "$v" && "$v" != "null" ]]; then - echo "$v" - return 0 + if [ "${#providers[@]}" -gt 0 ]; then + for jq_path in "${providers[@]}"; do + if [[ -n "$jq_path" && -n "${PROVIDER_CONFIG:-}" ]]; then + local v + v=$(echo "$PROVIDER_CONFIG" | jq -r "$jq_path // empty" 2>/dev/null || true) + if [[ -n "$v" && "$v" != "null" ]]; then + echo "$v" + return 0 + fi fi - fi - done + done + fi # Priority 2: env vars - for env_var in "${env_vars[@]}"; do - if [[ -n "$env_var" && -n "${!env_var:-}" ]]; then - echo "${!env_var}" - return 0 - fi - done + if [ "${#env_vars[@]}" -gt 0 ]; then + for env_var in "${env_vars[@]}"; do + if [[ -n "$env_var" && -n "${!env_var:-}" ]]; then + echo "${!env_var}" + return 0 + fi + done + fi # Priority 3: default if [[ -n "$default_value" ]]; then From e8f9a6a5a54254083c8817c131fd9653beca4f84 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 24 Jun 2026 14:12:04 -0300 Subject: [PATCH 13/41] feat(parameters): sts:AssumeRole step for AWS providers (Secrets Manager + Parameter Store) --- .../aws_secret_manager/docs/architecture.md | 11 + parameters/providers/aws_secret_manager/setup | 5 + parameters/providers/parameter_store/setup | 5 + .../providers/aws_secret_manager/setup.bats | 1 + .../providers/parameter_store/setup.bats | 1 + parameters/tests/utils/assume_role.bats | 112 +++++++++ parameters/tests/utils/assume_role_lib.bats | 104 +++++++++ parameters/tests/utils/assume_role_step.bats | 220 ++++++++++++++++++ parameters/utils/assume_role | 43 ++++ parameters/utils/assume_role_lib | 44 ++++ parameters/utils/assume_role_step | 95 ++++++++ 11 files changed, 641 insertions(+) create mode 100644 parameters/tests/utils/assume_role.bats create mode 100644 parameters/tests/utils/assume_role_lib.bats create mode 100644 parameters/tests/utils/assume_role_step.bats create mode 100644 parameters/utils/assume_role create mode 100644 parameters/utils/assume_role_lib create mode 100644 parameters/utils/assume_role_step diff --git a/parameters/providers/aws_secret_manager/docs/architecture.md b/parameters/providers/aws_secret_manager/docs/architecture.md index 7eb3c0d9..51cb9e9d 100644 --- a/parameters/providers/aws_secret_manager/docs/architecture.md +++ b/parameters/providers/aws_secret_manager/docs/architecture.md @@ -88,6 +88,17 @@ arn:aws:secretsmanager:::secret:nullplatform/* A single ARN pattern covers everything this provider creates, without granting account-wide access. See `iam-policy.md`. +### sts:AssumeRole + +Before any AWS call, this provider's `setup` sources `utils/assume_role_step`, which: + +1. Reads the scope's NRN and dimensions from `CONTEXT` (falling back to `np scope read` when dimensions are not in the payload). +2. Calls `np provider list --categories identity-access-control --nrn [--dimensions ...]` to fetch the IAM provider that the platform has dimension-resolved for this scope. +3. Picks the ARN from `.iam_role_arns.arns[]` whose `selector` is `secret_manager` (override with `SECRET_MANAGER_ASSUME_ROLE_SELECTOR`). +4. Calls `sts:AssumeRole` and exports `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`. + +Precedence: `SECRET_MANAGER_ASSUME_ROLE_ARN` env override → IAM provider selector → `SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT` env (per-account agent default) → agent credentials. The same step is shared with `parameter_store` since both providers act on the same IAM domain. + ### ARN suffix AWS SM appends a random 6-character suffix to every secret ARN: diff --git a/parameters/providers/aws_secret_manager/setup b/parameters/providers/aws_secret_manager/setup index d719711c..d45a43f3 100755 --- a/parameters/providers/aws_secret_manager/setup +++ b/parameters/providers/aws_secret_manager/setup @@ -14,6 +14,11 @@ set -euo pipefail : "${AWS_REGION:?AWS_REGION must be set by the agent runtime}" +# Resolve and assume the platform-configured IAM role (if any). After this, +# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN are set in the +# environment, so every subsequent aws-cli call inherits the assumed identity. +source "$PARAMETERS_ROOT/utils/assume_role_step" + SM_NAME_PREFIX="nullplatform/" SM_KMS_KEY_ID=$(get_config_value \ diff --git a/parameters/providers/parameter_store/setup b/parameters/providers/parameter_store/setup index cf3ff4a4..292b1f2a 100755 --- a/parameters/providers/parameter_store/setup +++ b/parameters/providers/parameter_store/setup @@ -16,6 +16,11 @@ set -euo pipefail : "${AWS_REGION:?AWS_REGION must be set by the agent runtime}" +# Resolve and assume the platform-configured IAM role (if any). After this, +# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN are set in the +# environment, so every subsequent aws-cli call inherits the assumed identity. +source "$PARAMETERS_ROOT/utils/assume_role_step" + PS_NAME_PREFIX="/nullplatform/" PS_KMS_KEY_ID=$(get_config_value \ diff --git a/parameters/tests/providers/aws_secret_manager/setup.bats b/parameters/tests/providers/aws_secret_manager/setup.bats index 94851790..3bebb7b4 100644 --- a/parameters/tests/providers/aws_secret_manager/setup.bats +++ b/parameters/tests/providers/aws_secret_manager/setup.bats @@ -9,6 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" + export PARAMETERS_ROOT="$PARAMETERS_DIR" export SCRIPT="$PARAMETERS_DIR/providers/aws_secret_manager/setup" export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" } diff --git a/parameters/tests/providers/parameter_store/setup.bats b/parameters/tests/providers/parameter_store/setup.bats index 02a1b36e..e2963cb2 100644 --- a/parameters/tests/providers/parameter_store/setup.bats +++ b/parameters/tests/providers/parameter_store/setup.bats @@ -9,6 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" + export PARAMETERS_ROOT="$PARAMETERS_DIR" export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/setup" export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" } diff --git a/parameters/tests/utils/assume_role.bats b/parameters/tests/utils/assume_role.bats new file mode 100644 index 00000000..bfb4d8e8 --- /dev/null +++ b/parameters/tests/utils/assume_role.bats @@ -0,0 +1,112 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/assume_role — sourceable sts:AssumeRole step. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/utils/assume_role" + # Each test runs under `bash -c` with a fresh PATH that puts our fakes first. + export BIN_DIR="$BATS_TEST_TMPDIR/bin" + mkdir -p "$BIN_DIR" +} + +teardown() { + unset SECRET_MANAGER_ASSUME_ROLE_ARN AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN +} + +@test "assume_role: no-op when SECRET_MANAGER_ASSUME_ROLE_ARN is empty" { + unset SECRET_MANAGER_ASSUME_ROLE_ARN + + run bash -c " + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo AKID=[\${AWS_ACCESS_KEY_ID:-}] + " + + assert_equal "$status" "0" + assert_contains "$output" "AKID=[]" +} + +@test "assume_role: success exports temp credentials and logs ✅" { + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +# Mock: only handle 'sts assume-role' +[ "$1 $2" = "sts assume-role" ] || { echo "unexpected: $*" >&2; exit 1; } +cat << 'JSON' +{ + "Credentials": { + "AccessKeyId": "AKIA-TEST", + "SecretAccessKey": "sk-test", + "SessionToken": "token-test", + "Expiration": "2026-01-01T00:00:00Z" + } +} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/test" + export SCOPE_ID="scope-42" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo AKID=\$AWS_ACCESS_KEY_ID + echo SECRET=\$AWS_SECRET_ACCESS_KEY + echo TOKEN=\$AWS_SESSION_TOKEN + " + + assert_equal "$status" "0" + assert_contains "$output" "AKID=AKIA-TEST" + assert_contains "$output" "SECRET=sk-test" + assert_contains "$output" "TOKEN=token-test" + assert_contains "$output" "🔑 Assuming role: arn:aws:iam::1:role/test" + assert_contains "$output" "✅ Role assumed successfully" +} + +@test "assume_role: returns 1 when aws sts assume-role fails" { + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "AccessDenied: not allowed" >&2 +exit 255 +EOF + chmod +x "$BIN_DIR/aws" + + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/test" + + # Source in a subshell — `return 1` from sourced script becomes exit 1 + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ sts:AssumeRole failed" + assert_contains "$output" "AccessDenied: not allowed" +} + +@test "assume_role: fails when aws returns incomplete credentials" { + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo '{"Credentials":{"AccessKeyId":"AKIA","SecretAccessKey":"","SessionToken":""}}' +EOF + chmod +x "$BIN_DIR/aws" + + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/test" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + [ "$status" -ne 0 ] + assert_contains "$output" "incomplete credentials" +} diff --git a/parameters/tests/utils/assume_role_lib.bats b/parameters/tests/utils/assume_role_lib.bats new file mode 100644 index 00000000..81cc2431 --- /dev/null +++ b/parameters/tests/utils/assume_role_lib.bats @@ -0,0 +1,104 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/assume_role_lib — pure helpers. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + source "$PARAMETERS_DIR/utils/assume_role_lib" +} + +teardown() { + unset SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT +} + +# ---- arn_for_selector ------------------------------------------------------- + +@test "arn_for_selector: returns matching ARN when selector exists" { + local json='{"iam_role_arns":{"arns":[ + {"selector":"containers","arn":"arn:aws:iam::1:role/containers"}, + {"selector":"secret_manager","arn":"arn:aws:iam::1:role/secret-mgr"} + ]}}' + + run arn_for_selector "$json" "secret_manager" + + assert_equal "$status" "0" + assert_equal "$output" "arn:aws:iam::1:role/secret-mgr" +} + +@test "arn_for_selector: returns first match when selector appears twice" { + local json='{"iam_role_arns":{"arns":[ + {"selector":"secret_manager","arn":"arn:1"}, + {"selector":"secret_manager","arn":"arn:2"} + ]}}' + + run arn_for_selector "$json" "secret_manager" + + assert_equal "$output" "arn:1" +} + +@test "arn_for_selector: returns empty string when selector not found" { + local json='{"iam_role_arns":{"arns":[{"selector":"containers","arn":"a"}]}}' + + run arn_for_selector "$json" "secret_manager" + + assert_equal "$status" "0" + assert_equal "$output" "" +} + +@test "arn_for_selector: empty input returns empty (no crash)" { + run arn_for_selector "" "secret_manager" + assert_equal "$output" "" + + run arn_for_selector "{}" "secret_manager" + assert_equal "$output" "" + + run arn_for_selector '{"iam_role_arns":{}}' "secret_manager" + assert_equal "$output" "" +} + +@test "arn_for_selector: malformed JSON returns empty (no crash)" { + run arn_for_selector "not-json-at-all" "secret_manager" + assert_equal "$output" "" +} + +# ---- resolve_assume_role_arn ----------------------------------------------- + +@test "resolve_assume_role_arn: env var SECRET_MANAGER_ASSUME_ROLE_ARN wins" { + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/from-env" + local json='{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:provider"}]}}' + + run resolve_assume_role_arn "$json" "secret_manager" + + assert_equal "$output" "arn:aws:iam::1:role/from-env" +} + +@test "resolve_assume_role_arn: empty env falls through to provider selector" { + export SECRET_MANAGER_ASSUME_ROLE_ARN="" + local json='{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:provider"}]}}' + + run resolve_assume_role_arn "$json" "secret_manager" + + assert_equal "$output" "arn:provider" +} + +@test "resolve_assume_role_arn: provider miss falls through to DEFAULT env" { + unset SECRET_MANAGER_ASSUME_ROLE_ARN + export SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT="arn:aws:iam::1:role/default" + local json='{"iam_role_arns":{"arns":[{"selector":"containers","arn":"a"}]}}' + + run resolve_assume_role_arn "$json" "secret_manager" + + assert_equal "$output" "arn:aws:iam::1:role/default" +} + +@test "resolve_assume_role_arn: no source set returns empty (agent creds)" { + unset SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT + + run resolve_assume_role_arn "{}" "secret_manager" + + assert_equal "$output" "" +} diff --git a/parameters/tests/utils/assume_role_step.bats b/parameters/tests/utils/assume_role_step.bats new file mode 100644 index 00000000..7faeaad2 --- /dev/null +++ b/parameters/tests/utils/assume_role_step.bats @@ -0,0 +1,220 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/assume_role_step — orchestrates: +# CONTEXT → resolve NRN + dimensions → np provider list → +# resolve ARN → source assume_role (sts:AssumeRole). +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/utils/assume_role_step" + export BIN_DIR="$BATS_TEST_TMPDIR/bin" + mkdir -p "$BIN_DIR" + + # A passthrough aws mock that records its sts call args. + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "$@" >> "$AWS_INVOKED_LOG" +cat << 'JSON' +{"Credentials":{"AccessKeyId":"AKIA","SecretAccessKey":"sk","SessionToken":"t","Expiration":"2026-01-01T00:00:00Z"}} +JSON +EOF + chmod +x "$BIN_DIR/aws" + export AWS_INVOKED_LOG="$BATS_TEST_TMPDIR/aws-calls.log" + : > "$AWS_INVOKED_LOG" + + # Default `np` mock — replaced per-test as needed. + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +echo "np $*" >> "$NP_INVOKED_LOG" +echo '{"results":[]}' +EOF + chmod +x "$BIN_DIR/np" + export NP_INVOKED_LOG="$BATS_TEST_TMPDIR/np-calls.log" + : > "$NP_INVOKED_LOG" +} + +teardown() { + unset SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT \ + SECRET_MANAGER_ASSUME_ROLE_SELECTOR CONTEXT SCOPE_ID \ + AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN +} + +# Build a CONTEXT with overridable NRN / dimensions / scope.id. +make_ctx() { + local nrn="$1" dims="$2" scope_id="$3" + jq -nc \ + --arg nrn "$nrn" \ + --argjson dims "${dims:-null}" \ + --arg scope_id "$scope_id" \ + '{scope:{nrn:$nrn, id:$scope_id}, dimensions:$dims}' +} + +@test "step: env override → np is not even called when ARN is preset" { + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/from-env" + export CONTEXT="$(make_ctx "" "" "")" # empty NRN → np lookup skipped + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$SECRET_MANAGER_ASSUME_ROLE_ARN + " + + assert_equal "$status" "0" + assert_contains "$output" "ARN=arn:aws:iam::1:role/from-env" + # aws sts assume-role WAS called with the env-set ARN + run cat "$AWS_INVOKED_LOG" + assert_contains "$output" "--role-arn arn:aws:iam::1:role/from-env" +} + +@test "step: NRN-only lookup (no dimensions) — np is called without --dimensions" { + # np mock that returns an IAM provider with secret_manager selector + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +echo "np $*" >> "$NP_INVOKED_LOG" +cat << 'JSON' +{"results":[{"id":"prov-1","attributes":{"iam_role_arns":{"arns":[ + {"selector":"secret_manager","arn":"arn:aws:iam::1:role/from-provider"} +]}}}]} +JSON +EOF + chmod +x "$BIN_DIR/np" + + export CONTEXT="$(make_ctx "organization=acme:account=prod" "" "")" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$SECRET_MANAGER_ASSUME_ROLE_ARN + " + + assert_equal "$status" "0" + assert_contains "$output" "ARN=arn:aws:iam::1:role/from-provider" + run cat "$NP_INVOKED_LOG" + assert_contains "$output" "provider list --categories identity-access-control --nrn organization=acme:account=prod --format json" + # No --dimensions flag + refute_contains() { case "$1" in *"$2"*) return 1 ;; *) return 0 ;; esac; } + refute_contains "$output" "--dimensions" +} + +@test "step: CONTEXT.dimensions are passed as dim1:val1,dim2:val2 to np" { + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +echo "np $*" >> "$NP_INVOKED_LOG" +echo '{"results":[]}' +EOF + chmod +x "$BIN_DIR/np" + + export CONTEXT="$(make_ctx "org=acme" '{"environment":"prod","region":"us-east-1"}' "")" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_INVOKED_LOG" + assert_contains "$output" "--dimensions environment:prod,region:us-east-1" + assert_contains "$output" "--nrn org=acme" +} + +@test "step: dimensions absent → fall back to np scope read" { + # First np call = scope read (returns dimensions), second = provider list. + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +echo "np $*" >> "$NP_INVOKED_LOG" +if [ "$1" = "scope" ] && [ "$2" = "read" ]; then + echo '{"environment":"staging"}' +else + echo '{"results":[]}' +fi +EOF + chmod +x "$BIN_DIR/np" + + export CONTEXT="$(make_ctx "org=acme" "" "scope-uuid-1")" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_INVOKED_LOG" + assert_contains "$output" "scope read --id scope-uuid-1 --format json --query .dimensions" + assert_contains "$output" "--dimensions environment:staging" +} + +@test "step: no NRN → skip np lookup, no ARN, no aws call (agent creds)" { + export CONTEXT='{}' + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=[\$SECRET_MANAGER_ASSUME_ROLE_ARN] + " + + assert_equal "$status" "0" + assert_contains "$output" "ARN=[]" + run cat "$NP_INVOKED_LOG" + [ -z "$output" ] + run cat "$AWS_INVOKED_LOG" + [ -z "$output" ] +} + +@test "step: selector defaults to 'secret_manager'" { + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +cat << 'JSON' +{"results":[{"id":"p","attributes":{"iam_role_arns":{"arns":[ + {"selector":"containers","arn":"arn:bad"}, + {"selector":"secret_manager","arn":"arn:good"} +]}}}]} +JSON +EOF + chmod +x "$BIN_DIR/np" + + export CONTEXT="$(make_ctx "org=acme" "" "")" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$SECRET_MANAGER_ASSUME_ROLE_ARN + " + + assert_contains "$output" "ARN=arn:good" +} + +@test "step: SECRET_MANAGER_ASSUME_ROLE_SELECTOR override changes which ARN is picked" { + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +cat << 'JSON' +{"results":[{"id":"p","attributes":{"iam_role_arns":{"arns":[ + {"selector":"secret_manager","arn":"arn:default"}, + {"selector":"custom","arn":"arn:custom"} +]}}}]} +JSON +EOF + chmod +x "$BIN_DIR/np" + + export CONTEXT="$(make_ctx "org=acme" "" "")" + export SECRET_MANAGER_ASSUME_ROLE_SELECTOR="custom" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$SECRET_MANAGER_ASSUME_ROLE_ARN + " + + assert_contains "$output" "ARN=arn:custom" +} diff --git a/parameters/utils/assume_role b/parameters/utils/assume_role new file mode 100644 index 00000000..9561a92e --- /dev/null +++ b/parameters/utils/assume_role @@ -0,0 +1,43 @@ +#!/bin/bash +# Sourceable helper — do NOT execute directly. +# Reads SECRET_MANAGER_ASSUME_ROLE_ARN from the environment. If set, calls +# sts:AssumeRole and exports temporary credentials so all subsequent AWS calls +# use that role. If empty, does nothing — the agent's credentials (pod IRSA) +# handle auth. +# +# Requires: aws CLI, jq, and the `log` function (loaded by the workflow). +# Expects: SECRET_MANAGER_ASSUME_ROLE_ARN (set by utils/assume_role_step), +# SCOPE_ID (optional, used for the session name). + +if [ -n "${SECRET_MANAGER_ASSUME_ROLE_ARN:-}" ]; then + log info " 🔑 Assuming role: $SECRET_MANAGER_ASSUME_ROLE_ARN" + + _ar_sts_error=$(mktemp) + if ! ASSUMED_CREDS=$(aws sts assume-role \ + --role-arn "$SECRET_MANAGER_ASSUME_ROLE_ARN" \ + --role-session-name "np-parameters-${SCOPE_ID:-workflow}" \ + --output json 2>"$_ar_sts_error"); then + log error " ❌ sts:AssumeRole failed for $SECRET_MANAGER_ASSUME_ROLE_ARN" + log error "$(cat "$_ar_sts_error")" + rm -f "$_ar_sts_error" + return 1 + fi + rm -f "$_ar_sts_error" + + _ar_access_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.AccessKeyId // ""') + _ar_secret_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SecretAccessKey // ""') + _ar_session_token=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SessionToken // ""') + + if [ -z "$_ar_access_key" ] || [ -z "$_ar_secret_key" ] || [ -z "$_ar_session_token" ]; then + log error " ❌ sts:AssumeRole returned incomplete credentials for $SECRET_MANAGER_ASSUME_ROLE_ARN" + return 1 + fi + + export AWS_ACCESS_KEY_ID="$_ar_access_key" + export AWS_SECRET_ACCESS_KEY="$_ar_secret_key" + export AWS_SESSION_TOKEN="$_ar_session_token" + + log info " ✅ Role assumed successfully" +else + log debug " ✅ assume_role=skipped (using agent credentials)" +fi diff --git a/parameters/utils/assume_role_lib b/parameters/utils/assume_role_lib new file mode 100644 index 00000000..8026424d --- /dev/null +++ b/parameters/utils/assume_role_lib @@ -0,0 +1,44 @@ +#!/bin/bash +# Sourceable library of PURE helpers for assume-role resolution. +# +# Input is the AWS IAM provider's `attributes` object as returned by +# `np provider list --categories identity-access-control` (see assume_role_step). +# These helpers only pick a selector, make NO np/aws calls, and have no source +# side effects — fully unit-testable. +# +# Requires (at call time): jq. + +# arn_for_selector +# Echoes the ARN whose entry in .iam_role_arns.arns[] matches , +# or "" if none. First match wins. Never crashes on empty/malformed input. +arn_for_selector() { + local json="$1" selector="$2" + [ -n "$json" ] || return 0 + [ -n "$selector" ] || return 0 + printf '%s' "$json" | jq -r --arg sel "$selector" ' + [ .iam_role_arns.arns[]? + | select(.selector == $sel) + | .arn ] + | first // ""' 2>/dev/null +} + +# resolve_assume_role_arn +# Echoes the ARN to assume ("" = use agent credentials): +# 1. $SECRET_MANAGER_ASSUME_ROLE_ARN env var (explicit override) +# 2. AWS IAM provider entry matching (caller pre-resolved the +# provider via `np provider list` for the scope's dimensions) +# 3. $SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT env var (per-account agent default) +# Note: SECRET_MANAGER_ASSUME_ROLE_ARN="" (explicitly empty) is treated the same +# as unset — the chain continues to the next source. +resolve_assume_role_arn() { + local iam_json="$1" selector="$2" arn="" + + arn="${SECRET_MANAGER_ASSUME_ROLE_ARN:-}" + + if [ -z "$arn" ] && [ -n "$iam_json" ] && [ -n "$selector" ]; then + arn=$(arn_for_selector "$iam_json" "$selector") + fi + + arn="${arn:-${SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT:-}}" + printf '%s' "$arn" +} diff --git a/parameters/utils/assume_role_step b/parameters/utils/assume_role_step new file mode 100644 index 00000000..ba19620e --- /dev/null +++ b/parameters/utils/assume_role_step @@ -0,0 +1,95 @@ +#!/bin/bash +# Dedicated workflow step: resolve the target IAM role and assume it, exporting +# temporary credentials so every subsequent step inherits them. +# +# Runs FIRST in each AWS-touching provider's setup (aws_secret_manager, +# parameter_store). Both providers share the same identity domain, so they use +# the same selector (`secret_manager`) and the same env var +# (SECRET_MANAGER_ASSUME_ROLE_ARN). +# +# Unlike the k8s flow — where the platform pre-resolves the IAM provider into +# CONTEXT.providers["identity-access-control"] using the scope's dimensions — +# parameter actions don't get the IAM provider in the payload. We fetch it +# ourselves via `np provider list --categories identity-access-control`, passing +# `--nrn` and (when present) `--dimensions` so the platform performs the same +# dimension-aware resolution it would do for k8s scopes. +# +# Dimension resolution precedence: +# 1. $CONTEXT.dimensions, if it's a non-empty object → pass to np +# 2. else if $CONTEXT.scope.id exists → `np scope read --id ... --query .dimensions` +# 3. else no `--dimensions` flag (NRN-only lookup) +# +# ARN resolution precedence (see resolve_assume_role_arn in assume_role_lib): +# $SECRET_MANAGER_ASSUME_ROLE_ARN -> IAM provider by selector +# -> $SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT -> agent credentials +# +# Requires: aws CLI, jq, np CLI. Expects: CONTEXT (engine-injected), +# SCOPE_ID (optional). + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/assume_role_lib" + +SECRET_MANAGER_ASSUME_ROLE_SELECTOR="${SECRET_MANAGER_ASSUME_ROLE_SELECTOR:-secret_manager}" + +# Used by assume_role only for the STS session name (informational). +if [ -z "${SCOPE_ID:-}" ]; then + SCOPE_ID=$(echo "${CONTEXT:-}" | jq -r '.scope.id // empty' 2>/dev/null) + export SCOPE_ID +fi + +# --- 1. Resolve NRN from CONTEXT ------------------------------------------- +NRN=$(echo "${CONTEXT:-}" | jq -r '.scope.nrn // .nrn // empty' 2>/dev/null) +if [ -z "$NRN" ]; then + log warn " ⚠️ No NRN in CONTEXT — skipping IAM provider lookup (agent credentials only)" + IAM_ATTRIBUTES="" +else + # --- 2. Resolve dimensions ---------------------------------------------- + DIMS_JSON=$(echo "${CONTEXT:-}" | jq -c '.dimensions // empty' 2>/dev/null) + if [ -z "$DIMS_JSON" ] || [ "$DIMS_JSON" = "null" ] || [ "$DIMS_JSON" = "{}" ]; then + SCOPE_ID_FROM_CTX=$(echo "${CONTEXT:-}" | jq -r '.scope.id // empty' 2>/dev/null) + if [ -n "$SCOPE_ID_FROM_CTX" ]; then + DIMS_JSON=$(np scope read --id "$SCOPE_ID_FROM_CTX" --format json --query '.dimensions' 2>/dev/null || echo "") + fi + fi + + # Convert {key:val,...} to "key:val,key2:val2" — empty/null → empty string + DIMS_ARG="" + if [ -n "$DIMS_JSON" ] && [ "$DIMS_JSON" != "null" ] && [ "$DIMS_JSON" != "{}" ]; then + DIMS_ARG=$(printf '%s' "$DIMS_JSON" | jq -r ' + to_entries + | map("\(.key):\(.value)") + | join(",")' 2>/dev/null) + fi + + # --- 3. Fetch the IAM provider (dimension-resolved by the platform) ----- + log debug " 🔍 Looking up IAM provider (NRN=$NRN${DIMS_ARG:+ dimensions=$DIMS_ARG})" + _ar_provider_err=$(mktemp) + if [ -n "$DIMS_ARG" ]; then + PROVIDER_JSON=$(np provider list --categories identity-access-control --nrn "$NRN" --dimensions "$DIMS_ARG" --format json 2>"$_ar_provider_err") || PROVIDER_JSON="" + else + PROVIDER_JSON=$(np provider list --categories identity-access-control --nrn "$NRN" --format json 2>"$_ar_provider_err") || PROVIDER_JSON="" + fi + if [ -z "$PROVIDER_JSON" ]; then + log warn " ⚠️ np provider list failed: $(cat "$_ar_provider_err")" + fi + rm -f "$_ar_provider_err" + + IAM_ATTRIBUTES=$(echo "${PROVIDER_JSON:-}" | jq -c '.results[0].attributes // {}' 2>/dev/null) +fi + +# --- 4. Resolve and export the ARN ----------------------------------------- +SECRET_MANAGER_ASSUME_ROLE_ARN=$(resolve_assume_role_arn "$IAM_ATTRIBUTES" "$SECRET_MANAGER_ASSUME_ROLE_SELECTOR") +export SECRET_MANAGER_ASSUME_ROLE_ARN + +# --- 5. Perform the assume-role (no-op if ARN is empty) -------------------- +if ! source "$SCRIPT_DIR/assume_role"; then + log error " ❌ assume_role step failed: could not assume $SECRET_MANAGER_ASSUME_ROLE_ARN" + log error "" + log error "💡 Possible causes:" + log error " • The agent's role is not allowed to sts:AssumeRole the target role" + log error " • The target role does not exist or does not trust the agent role" + log error " • No role ARN configured for selector=$SECRET_MANAGER_ASSUME_ROLE_SELECTOR" + log error " in the IAM provider for NRN=$NRN" + log error "" + exit 1 +fi From da5bac040c97186cee8ba3f01078f90d69475427 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 24 Jun 2026 14:53:05 -0300 Subject: [PATCH 14/41] refactor(parameters): assume_role step takes selector+env-var names as parameters (works for both AWS providers) --- .../aws_secret_manager/docs/architecture.md | 16 +- parameters/providers/aws_secret_manager/setup | 4 + parameters/providers/parameter_store/setup | 4 + parameters/tests/utils/assume_role.bats | 70 ++++++- parameters/tests/utils/assume_role_lib.bats | 82 ++++++-- parameters/tests/utils/assume_role_step.bats | 193 ++++++++++++------ parameters/utils/assume_role | 25 ++- parameters/utils/assume_role_lib | 34 +-- parameters/utils/assume_role_step | 50 +++-- 9 files changed, 352 insertions(+), 126 deletions(-) diff --git a/parameters/providers/aws_secret_manager/docs/architecture.md b/parameters/providers/aws_secret_manager/docs/architecture.md index 51cb9e9d..ddb6a2a9 100644 --- a/parameters/providers/aws_secret_manager/docs/architecture.md +++ b/parameters/providers/aws_secret_manager/docs/architecture.md @@ -90,14 +90,24 @@ A single ARN pattern covers everything this provider creates, without granting a ### sts:AssumeRole -Before any AWS call, this provider's `setup` sources `utils/assume_role_step`, which: +Before any AWS call, this provider's `setup` declares its IAM identity and sources the shared `utils/assume_role_step`: + +```bash +ASSUME_ROLE_SELECTOR="secret_manager" +ASSUME_ROLE_OVERRIDE_ENV="SECRET_MANAGER_ASSUME_ROLE_ARN" +ASSUME_ROLE_DEFAULT_ENV="SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" +ASSUME_ROLE_SESSION_PREFIX="np-secret-manager" +source "$PARAMETERS_ROOT/utils/assume_role_step" +``` + +The step is provider-agnostic — `parameter_store` does the same with its own selector (`parameter_store`) and env-var names (`PARAMETER_STORE_ASSUME_ROLE_ARN[_DEFAULT]`). The step: 1. Reads the scope's NRN and dimensions from `CONTEXT` (falling back to `np scope read` when dimensions are not in the payload). 2. Calls `np provider list --categories identity-access-control --nrn [--dimensions ...]` to fetch the IAM provider that the platform has dimension-resolved for this scope. -3. Picks the ARN from `.iam_role_arns.arns[]` whose `selector` is `secret_manager` (override with `SECRET_MANAGER_ASSUME_ROLE_SELECTOR`). +3. Picks the ARN from `.iam_role_arns.arns[]` whose `selector` matches `ASSUME_ROLE_SELECTOR`. 4. Calls `sts:AssumeRole` and exports `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`. -Precedence: `SECRET_MANAGER_ASSUME_ROLE_ARN` env override → IAM provider selector → `SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT` env (per-account agent default) → agent credentials. The same step is shared with `parameter_store` since both providers act on the same IAM domain. +Precedence: `${!ASSUME_ROLE_OVERRIDE_ENV}` (e.g. `SECRET_MANAGER_ASSUME_ROLE_ARN`) → IAM provider selector → `${!ASSUME_ROLE_DEFAULT_ENV}` (per-account agent default) → agent credentials. ### ARN suffix diff --git a/parameters/providers/aws_secret_manager/setup b/parameters/providers/aws_secret_manager/setup index d45a43f3..816b5243 100755 --- a/parameters/providers/aws_secret_manager/setup +++ b/parameters/providers/aws_secret_manager/setup @@ -17,6 +17,10 @@ set -euo pipefail # Resolve and assume the platform-configured IAM role (if any). After this, # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN are set in the # environment, so every subsequent aws-cli call inherits the assumed identity. +ASSUME_ROLE_SELECTOR="secret_manager" +ASSUME_ROLE_OVERRIDE_ENV="SECRET_MANAGER_ASSUME_ROLE_ARN" +ASSUME_ROLE_DEFAULT_ENV="SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" +ASSUME_ROLE_SESSION_PREFIX="np-secret-manager" source "$PARAMETERS_ROOT/utils/assume_role_step" SM_NAME_PREFIX="nullplatform/" diff --git a/parameters/providers/parameter_store/setup b/parameters/providers/parameter_store/setup index 292b1f2a..0a011f9a 100755 --- a/parameters/providers/parameter_store/setup +++ b/parameters/providers/parameter_store/setup @@ -19,6 +19,10 @@ set -euo pipefail # Resolve and assume the platform-configured IAM role (if any). After this, # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN are set in the # environment, so every subsequent aws-cli call inherits the assumed identity. +ASSUME_ROLE_SELECTOR="parameter_store" +ASSUME_ROLE_OVERRIDE_ENV="PARAMETER_STORE_ASSUME_ROLE_ARN" +ASSUME_ROLE_DEFAULT_ENV="PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT" +ASSUME_ROLE_SESSION_PREFIX="np-parameter-store" source "$PARAMETERS_ROOT/utils/assume_role_step" PS_NAME_PREFIX="/nullplatform/" diff --git a/parameters/tests/utils/assume_role.bats b/parameters/tests/utils/assume_role.bats index bfb4d8e8..513a747f 100644 --- a/parameters/tests/utils/assume_role.bats +++ b/parameters/tests/utils/assume_role.bats @@ -1,6 +1,7 @@ #!/usr/bin/env bats # ============================================================================= # Unit tests for parameters/utils/assume_role — sourceable sts:AssumeRole step. +# Reads ASSUME_ROLE_ARN_RESOLVED (provider-agnostic) set by assume_role_step. # ============================================================================= setup() { @@ -10,17 +11,17 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" export SCRIPT="$PARAMETERS_DIR/utils/assume_role" - # Each test runs under `bash -c` with a fresh PATH that puts our fakes first. export BIN_DIR="$BATS_TEST_TMPDIR/bin" mkdir -p "$BIN_DIR" } teardown() { - unset SECRET_MANAGER_ASSUME_ROLE_ARN AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN + unset ASSUME_ROLE_ARN_RESOLVED ASSUME_ROLE_SESSION_PREFIX \ + AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN } -@test "assume_role: no-op when SECRET_MANAGER_ASSUME_ROLE_ARN is empty" { - unset SECRET_MANAGER_ASSUME_ROLE_ARN +@test "assume_role: no-op when ASSUME_ROLE_ARN_RESOLVED is empty" { + unset ASSUME_ROLE_ARN_RESOLVED run bash -c " source $PARAMETERS_DIR/utils/log @@ -35,7 +36,6 @@ teardown() { @test "assume_role: success exports temp credentials and logs ✅" { cat > "$BIN_DIR/aws" << 'EOF' #!/bin/bash -# Mock: only handle 'sts assume-role' [ "$1 $2" = "sts assume-role" ] || { echo "unexpected: $*" >&2; exit 1; } cat << 'JSON' { @@ -50,8 +50,9 @@ JSON EOF chmod +x "$BIN_DIR/aws" - export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/test" + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/test" export SCOPE_ID="scope-42" + export ASSUME_ROLE_SESSION_PREFIX="np-secret-manager" run bash -c " export PATH=$BIN_DIR:\$PATH @@ -70,6 +71,58 @@ EOF assert_contains "$output" "✅ Role assumed successfully" } +@test "assume_role: session name uses ASSUME_ROLE_SESSION_PREFIX + SCOPE_ID" { + export AWS_ARGS_LOG="$BATS_TEST_TMPDIR/aws-args" + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "$*" > "$AWS_ARGS_LOG" +cat << 'JSON' +{"Credentials":{"AccessKeyId":"a","SecretAccessKey":"s","SessionToken":"t","Expiration":"2026-01-01T00:00:00Z"}} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:role/x" + export SCOPE_ID="scp-99" + export ASSUME_ROLE_SESSION_PREFIX="np-parameter-store" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$AWS_ARGS_LOG" + assert_contains "$output" "--role-session-name np-parameter-store-scp-99" +} + +@test "assume_role: session name falls back to 'np-parameters' when prefix unset" { + export AWS_ARGS_LOG="$BATS_TEST_TMPDIR/aws-args" + cat > "$BIN_DIR/aws" << 'EOF' +#!/bin/bash +echo "$*" > "$AWS_ARGS_LOG" +cat << 'JSON' +{"Credentials":{"AccessKeyId":"a","SecretAccessKey":"s","SessionToken":"t","Expiration":"2026-01-01T00:00:00Z"}} +JSON +EOF + chmod +x "$BIN_DIR/aws" + + export ASSUME_ROLE_ARN_RESOLVED="arn:role/x" + export SCOPE_ID="scp-1" + unset ASSUME_ROLE_SESSION_PREFIX + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$AWS_ARGS_LOG" + assert_contains "$output" "--role-session-name np-parameters-scp-1" +} + @test "assume_role: returns 1 when aws sts assume-role fails" { cat > "$BIN_DIR/aws" << 'EOF' #!/bin/bash @@ -78,9 +131,8 @@ exit 255 EOF chmod +x "$BIN_DIR/aws" - export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/test" + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/test" - # Source in a subshell — `return 1` from sourced script becomes exit 1 run bash -c " export PATH=$BIN_DIR:\$PATH source $PARAMETERS_DIR/utils/log @@ -99,7 +151,7 @@ echo '{"Credentials":{"AccessKeyId":"AKIA","SecretAccessKey":"","SessionToken":" EOF chmod +x "$BIN_DIR/aws" - export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/test" + export ASSUME_ROLE_ARN_RESOLVED="arn:aws:iam::1:role/test" run bash -c " export PATH=$BIN_DIR:\$PATH diff --git a/parameters/tests/utils/assume_role_lib.bats b/parameters/tests/utils/assume_role_lib.bats index 81cc2431..091fc980 100644 --- a/parameters/tests/utils/assume_role_lib.bats +++ b/parameters/tests/utils/assume_role_lib.bats @@ -1,6 +1,7 @@ #!/usr/bin/env bats # ============================================================================= # Unit tests for parameters/utils/assume_role_lib — pure helpers. +# Provider-agnostic: env var names are parameters, NOT hardcoded. # ============================================================================= setup() { @@ -12,7 +13,9 @@ setup() { } teardown() { - unset SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT + unset \ + SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT \ + PARAMETER_STORE_ASSUME_ROLE_ARN PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT } # ---- arn_for_selector ------------------------------------------------------- @@ -29,6 +32,17 @@ teardown() { assert_equal "$output" "arn:aws:iam::1:role/secret-mgr" } +@test "arn_for_selector: works with a different selector (parameter_store)" { + local json='{"iam_role_arns":{"arns":[ + {"selector":"secret_manager","arn":"arn:sm"}, + {"selector":"parameter_store","arn":"arn:ps"} + ]}}' + + run arn_for_selector "$json" "parameter_store" + + assert_equal "$output" "arn:ps" +} + @test "arn_for_selector: returns first match when selector appears twice" { local json='{"iam_role_arns":{"arns":[ {"selector":"secret_manager","arn":"arn:1"}, @@ -40,7 +54,7 @@ teardown() { assert_equal "$output" "arn:1" } -@test "arn_for_selector: returns empty string when selector not found" { +@test "arn_for_selector: returns empty when selector not found" { local json='{"iam_role_arns":{"arns":[{"selector":"containers","arn":"a"}]}}' run arn_for_selector "$json" "secret_manager" @@ -49,56 +63,84 @@ teardown() { assert_equal "$output" "" } -@test "arn_for_selector: empty input returns empty (no crash)" { +@test "arn_for_selector: empty/malformed input returns empty (no crash)" { run arn_for_selector "" "secret_manager" assert_equal "$output" "" run arn_for_selector "{}" "secret_manager" assert_equal "$output" "" - run arn_for_selector '{"iam_role_arns":{}}' "secret_manager" - assert_equal "$output" "" -} - -@test "arn_for_selector: malformed JSON returns empty (no crash)" { run arn_for_selector "not-json-at-all" "secret_manager" assert_equal "$output" "" } # ---- resolve_assume_role_arn ----------------------------------------------- +# Now takes 4 args: iam_json, selector, override_env_name, default_env_name. -@test "resolve_assume_role_arn: env var SECRET_MANAGER_ASSUME_ROLE_ARN wins" { - export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/from-env" +@test "resolve_assume_role_arn: override env (by name) wins over provider" { + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:from-env" local json='{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:provider"}]}}' - run resolve_assume_role_arn "$json" "secret_manager" + run resolve_assume_role_arn "$json" "secret_manager" \ + "SECRET_MANAGER_ASSUME_ROLE_ARN" "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" + + assert_equal "$output" "arn:from-env" +} + +@test "resolve_assume_role_arn: parameter_store uses ITS OWN env var (not secret_manager's)" { + export PARAMETER_STORE_ASSUME_ROLE_ARN="arn:ps-override" + # secret_manager's env is also set, but caller asked for parameter_store's + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:sm-IGNORED" + + run resolve_assume_role_arn "{}" "parameter_store" \ + "PARAMETER_STORE_ASSUME_ROLE_ARN" "PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT" - assert_equal "$output" "arn:aws:iam::1:role/from-env" + assert_equal "$output" "arn:ps-override" } -@test "resolve_assume_role_arn: empty env falls through to provider selector" { +@test "resolve_assume_role_arn: empty override falls through to provider selector" { export SECRET_MANAGER_ASSUME_ROLE_ARN="" local json='{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:provider"}]}}' - run resolve_assume_role_arn "$json" "secret_manager" + run resolve_assume_role_arn "$json" "secret_manager" \ + "SECRET_MANAGER_ASSUME_ROLE_ARN" "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" assert_equal "$output" "arn:provider" } @test "resolve_assume_role_arn: provider miss falls through to DEFAULT env" { unset SECRET_MANAGER_ASSUME_ROLE_ARN - export SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT="arn:aws:iam::1:role/default" + export SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT="arn:default" local json='{"iam_role_arns":{"arns":[{"selector":"containers","arn":"a"}]}}' - run resolve_assume_role_arn "$json" "secret_manager" + run resolve_assume_role_arn "$json" "secret_manager" \ + "SECRET_MANAGER_ASSUME_ROLE_ARN" "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" - assert_equal "$output" "arn:aws:iam::1:role/default" + assert_equal "$output" "arn:default" } -@test "resolve_assume_role_arn: no source set returns empty (agent creds)" { - unset SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT +@test "resolve_assume_role_arn: parameter_store DEFAULT env is independent of secret_manager's" { + export SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT="arn:sm-IGNORED" + export PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT="arn:ps-default" + + run resolve_assume_role_arn "{}" "parameter_store" \ + "PARAMETER_STORE_ASSUME_ROLE_ARN" "PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT" + + assert_equal "$output" "arn:ps-default" +} - run resolve_assume_role_arn "{}" "secret_manager" +@test "resolve_assume_role_arn: no source set returns empty (use agent creds)" { + run resolve_assume_role_arn "{}" "secret_manager" \ + "SECRET_MANAGER_ASSUME_ROLE_ARN" "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT" assert_equal "$output" "" } + +@test "resolve_assume_role_arn: empty override-env-name arg is treated as 'no override'" { + # Caller passes "" for the override env name → step 1 of precedence is skipped + local json='{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:p"}]}}' + + run resolve_assume_role_arn "$json" "secret_manager" "" "" + + assert_equal "$output" "arn:p" +} diff --git a/parameters/tests/utils/assume_role_step.bats b/parameters/tests/utils/assume_role_step.bats index 7faeaad2..9927e5b9 100644 --- a/parameters/tests/utils/assume_role_step.bats +++ b/parameters/tests/utils/assume_role_step.bats @@ -1,8 +1,15 @@ #!/usr/bin/env bats # ============================================================================= # Unit tests for parameters/utils/assume_role_step — orchestrates: -# CONTEXT → resolve NRN + dimensions → np provider list → -# resolve ARN → source assume_role (sts:AssumeRole). +# caller sets ASSUME_ROLE_SELECTOR + ASSUME_ROLE_OVERRIDE_ENV + +# ASSUME_ROLE_DEFAULT_ENV → +# resolve NRN + dimensions from CONTEXT → +# np provider list (identity-access-control) → +# resolve ARN via lib → +# source assume_role (sts:AssumeRole). +# +# Provider-agnostic — the same step is sourced by aws_secret_manager AND +# parameter_store with different selector + env names. # ============================================================================= setup() { @@ -15,7 +22,7 @@ setup() { export BIN_DIR="$BATS_TEST_TMPDIR/bin" mkdir -p "$BIN_DIR" - # A passthrough aws mock that records its sts call args. + # aws mock — captures sts args, returns valid creds cat > "$BIN_DIR/aws" << 'EOF' #!/bin/bash echo "$@" >> "$AWS_INVOKED_LOG" @@ -27,7 +34,7 @@ EOF export AWS_INVOKED_LOG="$BATS_TEST_TMPDIR/aws-calls.log" : > "$AWS_INVOKED_LOG" - # Default `np` mock — replaced per-test as needed. + # default np mock — replaced per-test cat > "$BIN_DIR/np" << 'EOF' #!/bin/bash echo "np $*" >> "$NP_INVOKED_LOG" @@ -39,12 +46,14 @@ EOF } teardown() { - unset SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT \ - SECRET_MANAGER_ASSUME_ROLE_SELECTOR CONTEXT SCOPE_ID \ + unset CONTEXT SCOPE_ID \ + ASSUME_ROLE_SELECTOR ASSUME_ROLE_OVERRIDE_ENV ASSUME_ROLE_DEFAULT_ENV \ + ASSUME_ROLE_SESSION_PREFIX ASSUME_ROLE_ARN_RESOLVED \ + SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT \ + PARAMETER_STORE_ASSUME_ROLE_ARN PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT \ AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN } -# Build a CONTEXT with overridable NRN / dimensions / scope.id. make_ctx() { local nrn="$1" dims="$2" scope_id="$3" jq -nc \ @@ -54,32 +63,99 @@ make_ctx() { '{scope:{nrn:$nrn, id:$scope_id}, dimensions:$dims}' } -@test "step: env override → np is not even called when ARN is preset" { - export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:aws:iam::1:role/from-env" - export CONTEXT="$(make_ctx "" "" "")" # empty NRN → np lookup skipped +# Standard caller config for "aws_secret_manager"-style tests. +SM_CALLER='ASSUME_ROLE_SELECTOR=secret_manager; ASSUME_ROLE_OVERRIDE_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN; ASSUME_ROLE_DEFAULT_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT' + +# ---- Contract: caller must set the three required vars --------------------- + +@test "step: fails fast when ASSUME_ROLE_SELECTOR is missing" { + export CONTEXT='{}' + unset ASSUME_ROLE_SELECTOR + + run -127 bash -c " + export PATH=$BIN_DIR:\$PATH + ASSUME_ROLE_OVERRIDE_ENV=X ASSUME_ROLE_DEFAULT_ENV=Y + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_contains "$output" "ASSUME_ROLE_SELECTOR must be set" +} + +@test "step: fails fast when ASSUME_ROLE_OVERRIDE_ENV is missing" { + export CONTEXT='{}' + + run -127 bash -c " + export PATH=$BIN_DIR:\$PATH + ASSUME_ROLE_SELECTOR=secret_manager ASSUME_ROLE_DEFAULT_ENV=Y + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_contains "$output" "ASSUME_ROLE_OVERRIDE_ENV must be set" +} + +@test "step: fails fast when ASSUME_ROLE_DEFAULT_ENV is missing" { + export CONTEXT='{}' + + run -127 bash -c " + export PATH=$BIN_DIR:\$PATH + ASSUME_ROLE_SELECTOR=secret_manager ASSUME_ROLE_OVERRIDE_ENV=X + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_contains "$output" "ASSUME_ROLE_DEFAULT_ENV must be set" +} + +# ---- Resolution flow ------------------------------------------------------- + +@test "step: secret_manager caller — override env wins, np is not called" { + export CONTEXT="$(make_ctx "" "" "")" # no NRN → no np lookup anyway + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:from-env-sm" run bash -c " export PATH=$BIN_DIR:\$PATH + $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT - echo ARN=\$SECRET_MANAGER_ASSUME_ROLE_ARN + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED " assert_equal "$status" "0" - assert_contains "$output" "ARN=arn:aws:iam::1:role/from-env" - # aws sts assume-role WAS called with the env-set ARN + assert_contains "$output" "ARN=arn:from-env-sm" run cat "$AWS_INVOKED_LOG" - assert_contains "$output" "--role-arn arn:aws:iam::1:role/from-env" + assert_contains "$output" "--role-arn arn:from-env-sm" } -@test "step: NRN-only lookup (no dimensions) — np is called without --dimensions" { - # np mock that returns an IAM provider with secret_manager selector +@test "step: parameter_store caller — uses ITS OWN env var (not secret_manager's)" { + export CONTEXT="$(make_ctx "" "" "")" + export PARAMETER_STORE_ASSUME_ROLE_ARN="arn:ps-env" + # secret_manager's env is also set — must be IGNORED + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:sm-MUST-NOT-BE-USED" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + ASSUME_ROLE_SELECTOR=parameter_store + ASSUME_ROLE_OVERRIDE_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN + ASSUME_ROLE_DEFAULT_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED + " + + assert_contains "$output" "ARN=arn:ps-env" + run cat "$AWS_INVOKED_LOG" + assert_contains "$output" "--role-arn arn:ps-env" +} + +@test "step: NRN-only lookup (no dimensions) — np called without --dimensions" { cat > "$BIN_DIR/np" << 'EOF' #!/bin/bash echo "np $*" >> "$NP_INVOKED_LOG" cat << 'JSON' {"results":[{"id":"prov-1","attributes":{"iam_role_arns":{"arns":[ - {"selector":"secret_manager","arn":"arn:aws:iam::1:role/from-provider"} + {"selector":"secret_manager","arn":"arn:from-provider"} ]}}}]} JSON EOF @@ -89,18 +165,43 @@ EOF run bash -c " export PATH=$BIN_DIR:\$PATH + $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT - echo ARN=\$SECRET_MANAGER_ASSUME_ROLE_ARN + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED " - assert_equal "$status" "0" - assert_contains "$output" "ARN=arn:aws:iam::1:role/from-provider" + assert_contains "$output" "ARN=arn:from-provider" run cat "$NP_INVOKED_LOG" assert_contains "$output" "provider list --categories identity-access-control --nrn organization=acme:account=prod --format json" - # No --dimensions flag - refute_contains() { case "$1" in *"$2"*) return 1 ;; *) return 0 ;; esac; } - refute_contains "$output" "--dimensions" + case "$output" in *"--dimensions"*) return 1 ;; esac +} + +@test "step: parameter_store selector picks parameter_store ARN from provider" { + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +cat << 'JSON' +{"results":[{"id":"p","attributes":{"iam_role_arns":{"arns":[ + {"selector":"secret_manager","arn":"arn:sm"}, + {"selector":"parameter_store","arn":"arn:ps"} +]}}}]} +JSON +EOF + chmod +x "$BIN_DIR/np" + + export CONTEXT="$(make_ctx "org=acme" "" "")" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + ASSUME_ROLE_SELECTOR=parameter_store + ASSUME_ROLE_OVERRIDE_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN + ASSUME_ROLE_DEFAULT_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED + " + + assert_contains "$output" "ARN=arn:ps" } @test "step: CONTEXT.dimensions are passed as dim1:val1,dim2:val2 to np" { @@ -115,18 +216,17 @@ EOF run bash -c " export PATH=$BIN_DIR:\$PATH + $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT " - assert_equal "$status" "0" run cat "$NP_INVOKED_LOG" assert_contains "$output" "--dimensions environment:prod,region:us-east-1" assert_contains "$output" "--nrn org=acme" } @test "step: dimensions absent → fall back to np scope read" { - # First np call = scope read (returns dimensions), second = provider list. cat > "$BIN_DIR/np" << 'EOF' #!/bin/bash echo "np $*" >> "$NP_INVOKED_LOG" @@ -142,11 +242,11 @@ EOF run bash -c " export PATH=$BIN_DIR:\$PATH + $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT " - assert_equal "$status" "0" run cat "$NP_INVOKED_LOG" assert_contains "$output" "scope read --id scope-uuid-1 --format json --query .dimensions" assert_contains "$output" "--dimensions environment:staging" @@ -157,9 +257,10 @@ EOF run bash -c " export PATH=$BIN_DIR:\$PATH + $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT - echo ARN=[\$SECRET_MANAGER_ASSUME_ROLE_ARN] + echo ARN=[\$ASSUME_ROLE_ARN_RESOLVED] " assert_equal "$status" "0" @@ -170,51 +271,27 @@ EOF [ -z "$output" ] } -@test "step: selector defaults to 'secret_manager'" { +@test "step: session prefix flows through to assume_role" { cat > "$BIN_DIR/np" << 'EOF' #!/bin/bash cat << 'JSON' {"results":[{"id":"p","attributes":{"iam_role_arns":{"arns":[ - {"selector":"containers","arn":"arn:bad"}, - {"selector":"secret_manager","arn":"arn:good"} + {"selector":"secret_manager","arn":"arn:x"} ]}}}]} JSON EOF chmod +x "$BIN_DIR/np" - export CONTEXT="$(make_ctx "org=acme" "" "")" + export CONTEXT="$(make_ctx "org=acme" "" "scope-7")" run bash -c " export PATH=$BIN_DIR:\$PATH + $SM_CALLER + ASSUME_ROLE_SESSION_PREFIX=np-secret-manager source $PARAMETERS_DIR/utils/log source $SCRIPT - echo ARN=\$SECRET_MANAGER_ASSUME_ROLE_ARN " - assert_contains "$output" "ARN=arn:good" -} - -@test "step: SECRET_MANAGER_ASSUME_ROLE_SELECTOR override changes which ARN is picked" { - cat > "$BIN_DIR/np" << 'EOF' -#!/bin/bash -cat << 'JSON' -{"results":[{"id":"p","attributes":{"iam_role_arns":{"arns":[ - {"selector":"secret_manager","arn":"arn:default"}, - {"selector":"custom","arn":"arn:custom"} -]}}}]} -JSON -EOF - chmod +x "$BIN_DIR/np" - - export CONTEXT="$(make_ctx "org=acme" "" "")" - export SECRET_MANAGER_ASSUME_ROLE_SELECTOR="custom" - - run bash -c " - export PATH=$BIN_DIR:\$PATH - source $PARAMETERS_DIR/utils/log - source $SCRIPT - echo ARN=\$SECRET_MANAGER_ASSUME_ROLE_ARN - " - - assert_contains "$output" "ARN=arn:custom" + run cat "$AWS_INVOKED_LOG" + assert_contains "$output" "--role-session-name np-secret-manager-scope-7" } diff --git a/parameters/utils/assume_role b/parameters/utils/assume_role index 9561a92e..50632afe 100644 --- a/parameters/utils/assume_role +++ b/parameters/utils/assume_role @@ -1,23 +1,30 @@ #!/bin/bash # Sourceable helper — do NOT execute directly. -# Reads SECRET_MANAGER_ASSUME_ROLE_ARN from the environment. If set, calls +# Reads ASSUME_ROLE_ARN_RESOLVED from the environment. If set, calls # sts:AssumeRole and exports temporary credentials so all subsequent AWS calls # use that role. If empty, does nothing — the agent's credentials (pod IRSA) # handle auth. # +# Provider-agnostic: the caller's setup goes through utils/assume_role_step, +# which writes the final ARN to ASSUME_ROLE_ARN_RESOLVED regardless of which +# provider-specific env var name was used as the override source. +# # Requires: aws CLI, jq, and the `log` function (loaded by the workflow). -# Expects: SECRET_MANAGER_ASSUME_ROLE_ARN (set by utils/assume_role_step), -# SCOPE_ID (optional, used for the session name). +# Expects: ASSUME_ROLE_ARN_RESOLVED (set by utils/assume_role_step), +# ASSUME_ROLE_SESSION_PREFIX (set by assume_role_step, defaults to +# "np-parameters"), SCOPE_ID (optional, used for the session name). + +if [ -n "${ASSUME_ROLE_ARN_RESOLVED:-}" ]; then + log info " 🔑 Assuming role: $ASSUME_ROLE_ARN_RESOLVED" -if [ -n "${SECRET_MANAGER_ASSUME_ROLE_ARN:-}" ]; then - log info " 🔑 Assuming role: $SECRET_MANAGER_ASSUME_ROLE_ARN" + _ar_session_name="${ASSUME_ROLE_SESSION_PREFIX:-np-parameters}-${SCOPE_ID:-workflow}" _ar_sts_error=$(mktemp) if ! ASSUMED_CREDS=$(aws sts assume-role \ - --role-arn "$SECRET_MANAGER_ASSUME_ROLE_ARN" \ - --role-session-name "np-parameters-${SCOPE_ID:-workflow}" \ + --role-arn "$ASSUME_ROLE_ARN_RESOLVED" \ + --role-session-name "$_ar_session_name" \ --output json 2>"$_ar_sts_error"); then - log error " ❌ sts:AssumeRole failed for $SECRET_MANAGER_ASSUME_ROLE_ARN" + log error " ❌ sts:AssumeRole failed for $ASSUME_ROLE_ARN_RESOLVED" log error "$(cat "$_ar_sts_error")" rm -f "$_ar_sts_error" return 1 @@ -29,7 +36,7 @@ if [ -n "${SECRET_MANAGER_ASSUME_ROLE_ARN:-}" ]; then _ar_session_token=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SessionToken // ""') if [ -z "$_ar_access_key" ] || [ -z "$_ar_secret_key" ] || [ -z "$_ar_session_token" ]; then - log error " ❌ sts:AssumeRole returned incomplete credentials for $SECRET_MANAGER_ASSUME_ROLE_ARN" + log error " ❌ sts:AssumeRole returned incomplete credentials for $ASSUME_ROLE_ARN_RESOLVED" return 1 fi diff --git a/parameters/utils/assume_role_lib b/parameters/utils/assume_role_lib index 8026424d..b2d004f4 100644 --- a/parameters/utils/assume_role_lib +++ b/parameters/utils/assume_role_lib @@ -1,12 +1,11 @@ #!/bin/bash # Sourceable library of PURE helpers for assume-role resolution. # -# Input is the AWS IAM provider's `attributes` object as returned by -# `np provider list --categories identity-access-control` (see assume_role_step). -# These helpers only pick a selector, make NO np/aws calls, and have no source -# side effects — fully unit-testable. +# Provider-agnostic: each caller passes its own selector and the names of the +# env vars to consult. Makes NO np/aws calls and has no source side effects — +# fully unit-testable. # -# Requires (at call time): jq. +# Requires (at call time): jq, bash >= 4 (uses ${!var} indirect expansion). # arn_for_selector # Echoes the ARN whose entry in .iam_role_arns.arns[] matches , @@ -22,23 +21,28 @@ arn_for_selector() { | first // ""' 2>/dev/null } -# resolve_assume_role_arn -# Echoes the ARN to assume ("" = use agent credentials): -# 1. $SECRET_MANAGER_ASSUME_ROLE_ARN env var (explicit override) -# 2. AWS IAM provider entry matching (caller pre-resolved the +# resolve_assume_role_arn +# Echoes the ARN to assume ("" = use agent credentials), in precedence order: +# 1. ${!override_env_name} — explicit per-run override +# 2. iam_attributes_json entry matching (caller pre-resolved the # provider via `np provider list` for the scope's dimensions) -# 3. $SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT env var (per-account agent default) -# Note: SECRET_MANAGER_ASSUME_ROLE_ARN="" (explicitly empty) is treated the same -# as unset — the chain continues to the next source. +# 3. ${!default_env_name} — per-account agent default +# Empty / are treated as unset (chain continues). +# An explicitly-empty ${!override_env_name}="" is also treated as unset. resolve_assume_role_arn() { - local iam_json="$1" selector="$2" arn="" + local iam_json="$1" selector="$2" override_env="$3" default_env="$4" arn="" - arn="${SECRET_MANAGER_ASSUME_ROLE_ARN:-}" + if [ -n "$override_env" ]; then + arn="${!override_env:-}" + fi if [ -z "$arn" ] && [ -n "$iam_json" ] && [ -n "$selector" ]; then arn=$(arn_for_selector "$iam_json" "$selector") fi - arn="${arn:-${SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT:-}}" + if [ -z "$arn" ] && [ -n "$default_env" ]; then + arn="${!default_env:-}" + fi + printf '%s' "$arn" } diff --git a/parameters/utils/assume_role_step b/parameters/utils/assume_role_step index ba19620e..5d8c2b0e 100644 --- a/parameters/utils/assume_role_step +++ b/parameters/utils/assume_role_step @@ -2,10 +2,28 @@ # Dedicated workflow step: resolve the target IAM role and assume it, exporting # temporary credentials so every subsequent step inherits them. # -# Runs FIRST in each AWS-touching provider's setup (aws_secret_manager, -# parameter_store). Both providers share the same identity domain, so they use -# the same selector (`secret_manager`) and the same env var -# (SECRET_MANAGER_ASSUME_ROLE_ARN). +# Provider-agnostic. The caller's setup MUST set these three variables BEFORE +# sourcing this script (none have defaults — being explicit is part of the +# contract): +# +# ASSUME_ROLE_SELECTOR — selector key under .iam_role_arns.arns[] in +# the IAM provider (e.g. "secret_manager", +# "parameter_store"). +# ASSUME_ROLE_OVERRIDE_ENV — name of the env var that, when set, overrides +# the IAM-provider lookup +# (e.g. "SECRET_MANAGER_ASSUME_ROLE_ARN"). +# ASSUME_ROLE_DEFAULT_ENV — name of the env var consulted as the agent's +# per-account default if no other source yields +# an ARN (e.g. "SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT"). +# +# Optional: +# ASSUME_ROLE_SESSION_PREFIX — prefix for the STS session name. Defaults to +# "np-parameters". +# +# Output (exported for downstream scripts): +# ASSUME_ROLE_ARN_RESOLVED — the final ARN, or empty for "use agent creds". +# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN — set when an +# ARN was resolved and sts:AssumeRole succeeded. # # Unlike the k8s flow — where the platform pre-resolves the IAM provider into # CONTEXT.providers["identity-access-control"] using the scope's dimensions — @@ -20,8 +38,8 @@ # 3. else no `--dimensions` flag (NRN-only lookup) # # ARN resolution precedence (see resolve_assume_role_arn in assume_role_lib): -# $SECRET_MANAGER_ASSUME_ROLE_ARN -> IAM provider by selector -# -> $SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT -> agent credentials +# ${!ASSUME_ROLE_OVERRIDE_ENV} -> IAM provider by selector +# -> ${!ASSUME_ROLE_DEFAULT_ENV} -> agent credentials # # Requires: aws CLI, jq, np CLI. Expects: CONTEXT (engine-injected), # SCOPE_ID (optional). @@ -29,9 +47,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/assume_role_lib" -SECRET_MANAGER_ASSUME_ROLE_SELECTOR="${SECRET_MANAGER_ASSUME_ROLE_SELECTOR:-secret_manager}" +# --- 0. Contract: caller must supply selector + env-var names --------------- +: "${ASSUME_ROLE_SELECTOR:?ASSUME_ROLE_SELECTOR must be set by the caller (e.g. 'secret_manager' or 'parameter_store')}" +: "${ASSUME_ROLE_OVERRIDE_ENV:?ASSUME_ROLE_OVERRIDE_ENV must be set by the caller (name of the override env var)}" +: "${ASSUME_ROLE_DEFAULT_ENV:?ASSUME_ROLE_DEFAULT_ENV must be set by the caller (name of the default-fallback env var)}" -# Used by assume_role only for the STS session name (informational). +# Used by assume_role for the STS session name (informational). +export ASSUME_ROLE_SESSION_PREFIX="${ASSUME_ROLE_SESSION_PREFIX:-np-parameters}" if [ -z "${SCOPE_ID:-}" ]; then SCOPE_ID=$(echo "${CONTEXT:-}" | jq -r '.scope.id // empty' 2>/dev/null) export SCOPE_ID @@ -78,17 +100,21 @@ else fi # --- 4. Resolve and export the ARN ----------------------------------------- -SECRET_MANAGER_ASSUME_ROLE_ARN=$(resolve_assume_role_arn "$IAM_ATTRIBUTES" "$SECRET_MANAGER_ASSUME_ROLE_SELECTOR") -export SECRET_MANAGER_ASSUME_ROLE_ARN +ASSUME_ROLE_ARN_RESOLVED=$(resolve_assume_role_arn \ + "$IAM_ATTRIBUTES" \ + "$ASSUME_ROLE_SELECTOR" \ + "$ASSUME_ROLE_OVERRIDE_ENV" \ + "$ASSUME_ROLE_DEFAULT_ENV") +export ASSUME_ROLE_ARN_RESOLVED # --- 5. Perform the assume-role (no-op if ARN is empty) -------------------- if ! source "$SCRIPT_DIR/assume_role"; then - log error " ❌ assume_role step failed: could not assume $SECRET_MANAGER_ASSUME_ROLE_ARN" + log error " ❌ assume_role step failed: could not assume $ASSUME_ROLE_ARN_RESOLVED" log error "" log error "💡 Possible causes:" log error " • The agent's role is not allowed to sts:AssumeRole the target role" log error " • The target role does not exist or does not trust the agent role" - log error " • No role ARN configured for selector=$SECRET_MANAGER_ASSUME_ROLE_SELECTOR" + log error " • No role ARN configured for selector=$ASSUME_ROLE_SELECTOR" log error " in the IAM provider for NRN=$NRN" log error "" exit 1 From cc8f7792649c695cf134a472d2241532ae4bb9f7 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Thu, 25 Jun 2026 12:38:12 -0300 Subject: [PATCH 15/41] chore: gitignore tofu state, plans, tfvars, tfbackend, overrides --- .gitignore | 16 ++++++++++++++++ .../aws-parameter-store-configuration.json.tpl} | 0 .../delete | 0 .../docs/architecture.md | 0 .../docs/iam-policy.md | 0 .../retrieve | 0 .../setup | 0 .../store | 0 .../aws-secrets-manager-configuration.json.tpl} | 0 .../delete | 0 .../docs/architecture.md | 0 .../docs/iam-policy.md | 0 .../retrieve | 0 .../setup | 0 .../store | 0 .../azure-key-vault-configuration.json.tpl} | 0 .../{azure_key_vault => azure-key-vault}/delete | 0 .../docs/architecture.md | 0 .../retrieve | 0 .../{azure_key_vault => azure-key-vault}/setup | 0 .../{azure_key_vault => azure-key-vault}/store | 0 .../{hashicorp_vault => hashicorp-vault}/delete | 0 .../docs/architecture.md | 0 .../hashicorp-vault-configuration.json.tpl} | 0 .../retrieve | 0 .../{hashicorp_vault => hashicorp-vault}/setup | 0 .../{hashicorp_vault => hashicorp-vault}/store | 0 .../delete.bats | 0 .../retrieve.bats | 0 .../setup.bats | 0 .../store.bats | 0 .../delete.bats | 0 .../retrieve.bats | 0 .../setup.bats | 0 .../store.bats | 0 .../delete.bats | 0 .../retrieve.bats | 0 .../setup.bats | 0 .../store.bats | 0 .../delete.bats | 0 .../retrieve.bats | 0 .../setup.bats | 0 .../store.bats | 0 43 files changed, 16 insertions(+) rename parameters/providers/{parameter_store/parameter_store_configuration.json.tpl => aws-parameter-store/aws-parameter-store-configuration.json.tpl} (100%) rename parameters/providers/{parameter_store => aws-parameter-store}/delete (100%) rename parameters/providers/{parameter_store => aws-parameter-store}/docs/architecture.md (100%) rename parameters/providers/{parameter_store => aws-parameter-store}/docs/iam-policy.md (100%) rename parameters/providers/{parameter_store => aws-parameter-store}/retrieve (100%) rename parameters/providers/{parameter_store => aws-parameter-store}/setup (100%) rename parameters/providers/{parameter_store => aws-parameter-store}/store (100%) rename parameters/providers/{aws_secret_manager/aws_secret_manager_configuration.json.tpl => aws-secrets-manager/aws-secrets-manager-configuration.json.tpl} (100%) rename parameters/providers/{aws_secret_manager => aws-secrets-manager}/delete (100%) rename parameters/providers/{aws_secret_manager => aws-secrets-manager}/docs/architecture.md (100%) rename parameters/providers/{aws_secret_manager => aws-secrets-manager}/docs/iam-policy.md (100%) rename parameters/providers/{aws_secret_manager => aws-secrets-manager}/retrieve (100%) rename parameters/providers/{aws_secret_manager => aws-secrets-manager}/setup (100%) rename parameters/providers/{aws_secret_manager => aws-secrets-manager}/store (100%) rename parameters/providers/{azure_key_vault/azure_key_vault_configuration.json.tpl => azure-key-vault/azure-key-vault-configuration.json.tpl} (100%) rename parameters/providers/{azure_key_vault => azure-key-vault}/delete (100%) rename parameters/providers/{azure_key_vault => azure-key-vault}/docs/architecture.md (100%) rename parameters/providers/{azure_key_vault => azure-key-vault}/retrieve (100%) rename parameters/providers/{azure_key_vault => azure-key-vault}/setup (100%) rename parameters/providers/{azure_key_vault => azure-key-vault}/store (100%) rename parameters/providers/{hashicorp_vault => hashicorp-vault}/delete (100%) rename parameters/providers/{hashicorp_vault => hashicorp-vault}/docs/architecture.md (100%) rename parameters/providers/{hashicorp_vault/hashicorp_vault_configuration.json.tpl => hashicorp-vault/hashicorp-vault-configuration.json.tpl} (100%) rename parameters/providers/{hashicorp_vault => hashicorp-vault}/retrieve (100%) rename parameters/providers/{hashicorp_vault => hashicorp-vault}/setup (100%) rename parameters/providers/{hashicorp_vault => hashicorp-vault}/store (100%) rename parameters/tests/providers/{parameter_store => aws-parameter-store}/delete.bats (100%) rename parameters/tests/providers/{parameter_store => aws-parameter-store}/retrieve.bats (100%) rename parameters/tests/providers/{parameter_store => aws-parameter-store}/setup.bats (100%) rename parameters/tests/providers/{parameter_store => aws-parameter-store}/store.bats (100%) rename parameters/tests/providers/{aws_secret_manager => aws-secrets-manager}/delete.bats (100%) rename parameters/tests/providers/{aws_secret_manager => aws-secrets-manager}/retrieve.bats (100%) rename parameters/tests/providers/{aws_secret_manager => aws-secrets-manager}/setup.bats (100%) rename parameters/tests/providers/{aws_secret_manager => aws-secrets-manager}/store.bats (100%) rename parameters/tests/providers/{azure_key_vault => azure-key-vault}/delete.bats (100%) rename parameters/tests/providers/{azure_key_vault => azure-key-vault}/retrieve.bats (100%) rename parameters/tests/providers/{azure_key_vault => azure-key-vault}/setup.bats (100%) rename parameters/tests/providers/{azure_key_vault => azure-key-vault}/store.bats (100%) rename parameters/tests/providers/{hashicorp_vault => hashicorp-vault}/delete.bats (100%) rename parameters/tests/providers/{hashicorp_vault => hashicorp-vault}/retrieve.bats (100%) rename parameters/tests/providers/{hashicorp_vault => hashicorp-vault}/setup.bats (100%) rename parameters/tests/providers/{hashicorp_vault => hashicorp-vault}/store.bats (100%) diff --git a/.gitignore b/.gitignore index 10d7b0f7..662f7b80 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,22 @@ frontend/deployment/tests/integration/volume/ # Terraform/OpenTofu .terraform/ .terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup +*.tfstate +*.tfstate.* +*.tfplan +*.tfvars +*.tfvars.json +*.tfbackend +override.tf +override.tf.json +*_override.tf +*_override.tf.json +backend-local.tf +!*.tfvars.example +!*.tfbackend.example +!*_override.tf.example # Generated test certificates testing/docker/certs/ diff --git a/parameters/providers/parameter_store/parameter_store_configuration.json.tpl b/parameters/providers/aws-parameter-store/aws-parameter-store-configuration.json.tpl similarity index 100% rename from parameters/providers/parameter_store/parameter_store_configuration.json.tpl rename to parameters/providers/aws-parameter-store/aws-parameter-store-configuration.json.tpl diff --git a/parameters/providers/parameter_store/delete b/parameters/providers/aws-parameter-store/delete similarity index 100% rename from parameters/providers/parameter_store/delete rename to parameters/providers/aws-parameter-store/delete diff --git a/parameters/providers/parameter_store/docs/architecture.md b/parameters/providers/aws-parameter-store/docs/architecture.md similarity index 100% rename from parameters/providers/parameter_store/docs/architecture.md rename to parameters/providers/aws-parameter-store/docs/architecture.md diff --git a/parameters/providers/parameter_store/docs/iam-policy.md b/parameters/providers/aws-parameter-store/docs/iam-policy.md similarity index 100% rename from parameters/providers/parameter_store/docs/iam-policy.md rename to parameters/providers/aws-parameter-store/docs/iam-policy.md diff --git a/parameters/providers/parameter_store/retrieve b/parameters/providers/aws-parameter-store/retrieve similarity index 100% rename from parameters/providers/parameter_store/retrieve rename to parameters/providers/aws-parameter-store/retrieve diff --git a/parameters/providers/parameter_store/setup b/parameters/providers/aws-parameter-store/setup similarity index 100% rename from parameters/providers/parameter_store/setup rename to parameters/providers/aws-parameter-store/setup diff --git a/parameters/providers/parameter_store/store b/parameters/providers/aws-parameter-store/store similarity index 100% rename from parameters/providers/parameter_store/store rename to parameters/providers/aws-parameter-store/store diff --git a/parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl b/parameters/providers/aws-secrets-manager/aws-secrets-manager-configuration.json.tpl similarity index 100% rename from parameters/providers/aws_secret_manager/aws_secret_manager_configuration.json.tpl rename to parameters/providers/aws-secrets-manager/aws-secrets-manager-configuration.json.tpl diff --git a/parameters/providers/aws_secret_manager/delete b/parameters/providers/aws-secrets-manager/delete similarity index 100% rename from parameters/providers/aws_secret_manager/delete rename to parameters/providers/aws-secrets-manager/delete diff --git a/parameters/providers/aws_secret_manager/docs/architecture.md b/parameters/providers/aws-secrets-manager/docs/architecture.md similarity index 100% rename from parameters/providers/aws_secret_manager/docs/architecture.md rename to parameters/providers/aws-secrets-manager/docs/architecture.md diff --git a/parameters/providers/aws_secret_manager/docs/iam-policy.md b/parameters/providers/aws-secrets-manager/docs/iam-policy.md similarity index 100% rename from parameters/providers/aws_secret_manager/docs/iam-policy.md rename to parameters/providers/aws-secrets-manager/docs/iam-policy.md diff --git a/parameters/providers/aws_secret_manager/retrieve b/parameters/providers/aws-secrets-manager/retrieve similarity index 100% rename from parameters/providers/aws_secret_manager/retrieve rename to parameters/providers/aws-secrets-manager/retrieve diff --git a/parameters/providers/aws_secret_manager/setup b/parameters/providers/aws-secrets-manager/setup similarity index 100% rename from parameters/providers/aws_secret_manager/setup rename to parameters/providers/aws-secrets-manager/setup diff --git a/parameters/providers/aws_secret_manager/store b/parameters/providers/aws-secrets-manager/store similarity index 100% rename from parameters/providers/aws_secret_manager/store rename to parameters/providers/aws-secrets-manager/store diff --git a/parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl b/parameters/providers/azure-key-vault/azure-key-vault-configuration.json.tpl similarity index 100% rename from parameters/providers/azure_key_vault/azure_key_vault_configuration.json.tpl rename to parameters/providers/azure-key-vault/azure-key-vault-configuration.json.tpl diff --git a/parameters/providers/azure_key_vault/delete b/parameters/providers/azure-key-vault/delete similarity index 100% rename from parameters/providers/azure_key_vault/delete rename to parameters/providers/azure-key-vault/delete diff --git a/parameters/providers/azure_key_vault/docs/architecture.md b/parameters/providers/azure-key-vault/docs/architecture.md similarity index 100% rename from parameters/providers/azure_key_vault/docs/architecture.md rename to parameters/providers/azure-key-vault/docs/architecture.md diff --git a/parameters/providers/azure_key_vault/retrieve b/parameters/providers/azure-key-vault/retrieve similarity index 100% rename from parameters/providers/azure_key_vault/retrieve rename to parameters/providers/azure-key-vault/retrieve diff --git a/parameters/providers/azure_key_vault/setup b/parameters/providers/azure-key-vault/setup similarity index 100% rename from parameters/providers/azure_key_vault/setup rename to parameters/providers/azure-key-vault/setup diff --git a/parameters/providers/azure_key_vault/store b/parameters/providers/azure-key-vault/store similarity index 100% rename from parameters/providers/azure_key_vault/store rename to parameters/providers/azure-key-vault/store diff --git a/parameters/providers/hashicorp_vault/delete b/parameters/providers/hashicorp-vault/delete similarity index 100% rename from parameters/providers/hashicorp_vault/delete rename to parameters/providers/hashicorp-vault/delete diff --git a/parameters/providers/hashicorp_vault/docs/architecture.md b/parameters/providers/hashicorp-vault/docs/architecture.md similarity index 100% rename from parameters/providers/hashicorp_vault/docs/architecture.md rename to parameters/providers/hashicorp-vault/docs/architecture.md diff --git a/parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl b/parameters/providers/hashicorp-vault/hashicorp-vault-configuration.json.tpl similarity index 100% rename from parameters/providers/hashicorp_vault/hashicorp_vault_configuration.json.tpl rename to parameters/providers/hashicorp-vault/hashicorp-vault-configuration.json.tpl diff --git a/parameters/providers/hashicorp_vault/retrieve b/parameters/providers/hashicorp-vault/retrieve similarity index 100% rename from parameters/providers/hashicorp_vault/retrieve rename to parameters/providers/hashicorp-vault/retrieve diff --git a/parameters/providers/hashicorp_vault/setup b/parameters/providers/hashicorp-vault/setup similarity index 100% rename from parameters/providers/hashicorp_vault/setup rename to parameters/providers/hashicorp-vault/setup diff --git a/parameters/providers/hashicorp_vault/store b/parameters/providers/hashicorp-vault/store similarity index 100% rename from parameters/providers/hashicorp_vault/store rename to parameters/providers/hashicorp-vault/store diff --git a/parameters/tests/providers/parameter_store/delete.bats b/parameters/tests/providers/aws-parameter-store/delete.bats similarity index 100% rename from parameters/tests/providers/parameter_store/delete.bats rename to parameters/tests/providers/aws-parameter-store/delete.bats diff --git a/parameters/tests/providers/parameter_store/retrieve.bats b/parameters/tests/providers/aws-parameter-store/retrieve.bats similarity index 100% rename from parameters/tests/providers/parameter_store/retrieve.bats rename to parameters/tests/providers/aws-parameter-store/retrieve.bats diff --git a/parameters/tests/providers/parameter_store/setup.bats b/parameters/tests/providers/aws-parameter-store/setup.bats similarity index 100% rename from parameters/tests/providers/parameter_store/setup.bats rename to parameters/tests/providers/aws-parameter-store/setup.bats diff --git a/parameters/tests/providers/parameter_store/store.bats b/parameters/tests/providers/aws-parameter-store/store.bats similarity index 100% rename from parameters/tests/providers/parameter_store/store.bats rename to parameters/tests/providers/aws-parameter-store/store.bats diff --git a/parameters/tests/providers/aws_secret_manager/delete.bats b/parameters/tests/providers/aws-secrets-manager/delete.bats similarity index 100% rename from parameters/tests/providers/aws_secret_manager/delete.bats rename to parameters/tests/providers/aws-secrets-manager/delete.bats diff --git a/parameters/tests/providers/aws_secret_manager/retrieve.bats b/parameters/tests/providers/aws-secrets-manager/retrieve.bats similarity index 100% rename from parameters/tests/providers/aws_secret_manager/retrieve.bats rename to parameters/tests/providers/aws-secrets-manager/retrieve.bats diff --git a/parameters/tests/providers/aws_secret_manager/setup.bats b/parameters/tests/providers/aws-secrets-manager/setup.bats similarity index 100% rename from parameters/tests/providers/aws_secret_manager/setup.bats rename to parameters/tests/providers/aws-secrets-manager/setup.bats diff --git a/parameters/tests/providers/aws_secret_manager/store.bats b/parameters/tests/providers/aws-secrets-manager/store.bats similarity index 100% rename from parameters/tests/providers/aws_secret_manager/store.bats rename to parameters/tests/providers/aws-secrets-manager/store.bats diff --git a/parameters/tests/providers/azure_key_vault/delete.bats b/parameters/tests/providers/azure-key-vault/delete.bats similarity index 100% rename from parameters/tests/providers/azure_key_vault/delete.bats rename to parameters/tests/providers/azure-key-vault/delete.bats diff --git a/parameters/tests/providers/azure_key_vault/retrieve.bats b/parameters/tests/providers/azure-key-vault/retrieve.bats similarity index 100% rename from parameters/tests/providers/azure_key_vault/retrieve.bats rename to parameters/tests/providers/azure-key-vault/retrieve.bats diff --git a/parameters/tests/providers/azure_key_vault/setup.bats b/parameters/tests/providers/azure-key-vault/setup.bats similarity index 100% rename from parameters/tests/providers/azure_key_vault/setup.bats rename to parameters/tests/providers/azure-key-vault/setup.bats diff --git a/parameters/tests/providers/azure_key_vault/store.bats b/parameters/tests/providers/azure-key-vault/store.bats similarity index 100% rename from parameters/tests/providers/azure_key_vault/store.bats rename to parameters/tests/providers/azure-key-vault/store.bats diff --git a/parameters/tests/providers/hashicorp_vault/delete.bats b/parameters/tests/providers/hashicorp-vault/delete.bats similarity index 100% rename from parameters/tests/providers/hashicorp_vault/delete.bats rename to parameters/tests/providers/hashicorp-vault/delete.bats diff --git a/parameters/tests/providers/hashicorp_vault/retrieve.bats b/parameters/tests/providers/hashicorp-vault/retrieve.bats similarity index 100% rename from parameters/tests/providers/hashicorp_vault/retrieve.bats rename to parameters/tests/providers/hashicorp-vault/retrieve.bats diff --git a/parameters/tests/providers/hashicorp_vault/setup.bats b/parameters/tests/providers/hashicorp-vault/setup.bats similarity index 100% rename from parameters/tests/providers/hashicorp_vault/setup.bats rename to parameters/tests/providers/hashicorp-vault/setup.bats diff --git a/parameters/tests/providers/hashicorp_vault/store.bats b/parameters/tests/providers/hashicorp-vault/store.bats similarity index 100% rename from parameters/tests/providers/hashicorp_vault/store.bats rename to parameters/tests/providers/hashicorp-vault/store.bats From c3036794baae624331ba96399c0e61f655049e78 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Thu, 25 Jun 2026 12:38:46 -0300 Subject: [PATCH 16/41] refactor(parameters): align provider slugs with platform-derived naming (hyphenated) --- parameters/PENDING.md | 28 +++++++++---------- parameters/docs/adding_a_provider.md | 4 +-- parameters/docs/architecture.md | 18 ++++++------ parameters/docs/configuration.md | 18 ++++++------ parameters/entrypoint | 2 +- parameters/providers/README.md | 6 ++-- ...aws-parameter-store-configuration.json.tpl | 2 +- .../aws-parameter-store/docs/architecture.md | 2 +- .../aws-parameter-store/docs/iam-policy.md | 2 +- ...aws-secrets-manager-configuration.json.tpl | 2 +- .../aws-secrets-manager/docs/architecture.md | 4 +-- .../aws-secrets-manager/docs/iam-policy.md | 2 +- .../azure-key-vault-configuration.json.tpl | 2 +- .../azure-key-vault/docs/architecture.md | 2 +- parameters/providers/azure-key-vault/setup | 2 +- .../hashicorp-vault/docs/architecture.md | 2 +- .../hashicorp-vault-configuration.json.tpl | 2 +- parameters/providers/hashicorp-vault/setup | 2 +- .../providers/aws-parameter-store/delete.bats | 14 +++++----- .../aws-parameter-store/retrieve.bats | 14 +++++----- .../providers/aws-parameter-store/setup.bats | 16 +++++------ .../providers/aws-parameter-store/store.bats | 18 ++++++------ .../providers/aws-secrets-manager/delete.bats | 14 +++++----- .../aws-secrets-manager/retrieve.bats | 14 +++++----- .../providers/aws-secrets-manager/setup.bats | 14 +++++----- .../providers/aws-secrets-manager/store.bats | 24 ++++++++-------- .../providers/azure-key-vault/delete.bats | 18 ++++++------ .../providers/azure-key-vault/retrieve.bats | 14 +++++----- .../providers/azure-key-vault/setup.bats | 12 ++++---- .../providers/azure-key-vault/store.bats | 14 +++++----- .../providers/hashicorp-vault/delete.bats | 4 +-- .../providers/hashicorp-vault/retrieve.bats | 4 +-- .../providers/hashicorp-vault/setup.bats | 4 +-- .../providers/hashicorp-vault/store.bats | 4 +-- parameters/tests/utils/assume_role_step.bats | 5 ++-- parameters/tests/utils/get_config_value.bats | 4 +-- parameters/utils/assume_role_step | 4 +-- parameters/utils/build_context | 2 +- parameters/utils/get_config_value | 2 +- 39 files changed, 161 insertions(+), 160 deletions(-) diff --git a/parameters/PENDING.md b/parameters/PENDING.md index f98303d0..86bba336 100644 --- a/parameters/PENDING.md +++ b/parameters/PENDING.md @@ -9,10 +9,10 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. | Componente | Estado | |---|---| | Skeleton (entrypoint, build_context, dispatch, utils, workflows) | ✅ Implementado | -| Provider `hashicorp_vault` | ✅ Implementado | -| Provider `aws_secret_manager` | ✅ Implementado | -| Provider `parameter_store` | ✅ Implementado | -| Provider `azure_key_vault` | ✅ Implementado | +| Provider `hashicorp-vault` | ✅ Implementado | +| Provider `aws-secrets-manager` | ✅ Implementado | +| Provider `aws-parameter-store` | ✅ Implementado | +| Provider `azure-key-vault` | ✅ Implementado | | Error handling (not_found → idempotent, otros → fail loud) | ✅ Aplicado a deletes y retrieves | | Tests BATS | ✅ 151 tests pasando | | Docs globales | ✅ architecture.md, configuration.md, adding_a_provider.md | @@ -21,7 +21,7 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. | **Resolución de provider via `provider.specification_id`** | **✅ Implementado** (era pendiente, hecho hoy) | | **`PROVIDER_CONFIG` desde `provider.attributes`** | **✅ Implementado** (era pendiente como `fetch_configuration`, ahora viene en payload) | | Naming NRN+slug-based | ✅ Implementado (utils/build_external_id + 4 providers refactorizados) | -| Rename `secret_manager` → `aws_secret_manager` | ✅ Implementado | +| Rename `secret_manager` → `aws-secrets-manager` | ✅ Implementado | --- @@ -31,7 +31,7 @@ Status snapshot del estado actual del paquete `parameters/` y trabajo pendiente. |---|---|---| | Estrategia de granularidad | 1:1 mapping (un secret por parámetro) | Review del equipo sobre el decision doc | | Naming convention | NRN entities con slugs+ids + dimensiones + parameter_id | Conversación de diseño | -| Provider AWS Secrets Manager | Nombre futuro: `aws_secret_manager` | Conversación de diseño | +| Provider AWS Secrets Manager | Nombre futuro: `aws-secrets-manager` | Conversación de diseño | | Provider selection | Via `provider.specification_id` → np CLI → slug | Cambio reciente con payload real | | Provider config source | `provider.attributes` en el payload (no env vars, no fetch script) | Cambio reciente | | Workflow YAMLs | 4 workflows unificados (store, retrieve, delete, notify) | Cleanup arquitectónico | @@ -109,10 +109,10 @@ bats $(find parameters/tests -name "*.bats") Distribución actual (151 tests): - Skeleton (entrypoint, build_context, dispatch, utils): 56 tests -- hashicorp_vault: 27 tests -- aws_secret_manager: 17 tests (renombrado desde `secret_manager`) -- parameter_store: 23 tests -- azure_key_vault: 15 tests +- hashicorp-vault: 27 tests +- aws-secrets-manager: 17 tests (renombrado desde `secret_manager`) +- aws-parameter-store: 23 tests +- azure-key-vault: 15 tests - utils/log + utils/get_config_value: 13 tests --- @@ -130,10 +130,10 @@ parameters/ │ └── log # todos los niveles a stderr ├── providers/ │ ├── README.md # contrato del provider -│ ├── hashicorp_vault/ -│ ├── aws_secret_manager/ -│ ├── parameter_store/ -│ └── azure_key_vault/ +│ ├── hashicorp-vault/ +│ ├── aws-secrets-manager/ +│ ├── aws-parameter-store/ +│ └── azure-key-vault/ ├── tests/ # 151 BATS tests └── docs/ # docs globales del paquete ``` diff --git a/parameters/docs/adding_a_provider.md b/parameters/docs/adding_a_provider.md index c6ee98c2..ee525be3 100644 --- a/parameters/docs/adding_a_provider.md +++ b/parameters/docs/adding_a_provider.md @@ -149,7 +149,7 @@ jq -n \ '{external_id: $external_id, metadata: {handle: $handle, name: $name}}' ``` -If your backend distinguishes types (like `parameter_store` does with String/SecureString), branch on `PARAMETER_KIND` here. +If your backend distinguishes types (like `aws-parameter-store` does with String/SecureString), branch on `PARAMETER_KIND` here. ### `retrieve` @@ -250,7 +250,7 @@ tests/providers// └── delete.bats # Always-success, CLI args, idempotency ``` -Use the patterns from existing providers (`hashicorp_vault`, `aws_secret_manager`, `parameter_store`, `azure_key_vault`): +Use the patterns from existing providers (`hashicorp-vault`, `aws-secrets-manager`, `aws-parameter-store`, `azure-key-vault`): - Mock the backend CLI as a script in `$BATS_TEST_TMPDIR/bin/`, export PATH to find it. - Capture CLI args to a log file, assert on them. diff --git a/parameters/docs/architecture.md b/parameters/docs/architecture.md index 92fd9b92..4494d6de 100644 --- a/parameters/docs/architecture.md +++ b/parameters/docs/architecture.md @@ -104,10 +104,10 @@ The `version_id` is **the native version identifier returned by each backend** | Provider | Version ID format | Example | |----------------------|----------------------------------|--------------------------------------------------| -| `aws_secret_manager` | UUID v4 (from `VersionId`) | `a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d` | -| `hashicorp_vault` | Integer (from `.data.version`) | `3` | -| `parameter_store` | Integer (from `.Version`) | `7` | -| `azure_key_vault` | 32-char hex (URL last segment) | `93a0b2eb12a64fa7b3acb18900a8d33d` | +| `aws-secrets-manager` | UUID v4 (from `VersionId`) | `a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d` | +| `hashicorp-vault` | Integer (from `.data.version`) | `3` | +| `aws-parameter-store` | Integer (from `.Version`) | `7` | +| `azure-key-vault` | 32-char hex (URL last segment) | `93a0b2eb12a64fa7b3acb18900a8d33d` | Because nullplatform already persists and re-sends `external_id` on every operation, this versioning works without any platform-side changes. On `retrieve`, the dispatcher's `build_context` splits the suffix; provider scripts use it to target a specific historical version via the backend's native version-fetching mechanism (`--version-id`, `?version=N`, `:N`, `--version`). @@ -121,7 +121,7 @@ For each parameter, nullplatform stores which provider should handle it. That ch ``` np provider specification read --id --format json -→ { "slug": "aws_secret_manager", ... } +→ { "slug": "aws-secrets-manager", ... } ``` The slug becomes `ACTIVE_PROVIDER`, which must match a directory under `parameters/providers/`. The match is exact, case-sensitive. @@ -146,10 +146,10 @@ parameters/ │ └── log # All levels route to stderr ├── providers/ │ ├── README.md # Contract every provider must satisfy -│ ├── hashicorp_vault/ # HTTP API -│ ├── aws_secret_manager/ # aws CLI -│ ├── parameter_store/ # aws CLI (only kind-branching provider) -│ └── azure_key_vault/ # az CLI +│ ├── hashicorp-vault/ # HTTP API +│ ├── aws-secrets-manager/ # aws CLI +│ ├── aws-parameter-store/ # aws CLI (only kind-branching provider) +│ └── azure-key-vault/ # az CLI ├── tests/ # BATS — mirrors source structure └── docs/ # This file, configuration.md, adding_a_provider.md ``` diff --git a/parameters/docs/configuration.md b/parameters/docs/configuration.md index 887c25d1..97d819cc 100644 --- a/parameters/docs/configuration.md +++ b/parameters/docs/configuration.md @@ -40,10 +40,10 @@ The response includes a `slug` field. That slug must match the name of a directo | Slug returned | Provider directory used | |---|---| -| `hashicorp_vault` | `parameters/providers/hashicorp_vault/` | -| `aws_secret_manager` | `parameters/providers/aws_secret_manager/` | -| `parameter_store` | `parameters/providers/parameter_store/` | -| `azure_key_vault` | `parameters/providers/azure_key_vault/` | +| `hashicorp-vault` | `parameters/providers/hashicorp-vault/` | +| `aws-secrets-manager` | `parameters/providers/aws-secrets-manager/` | +| `aws-parameter-store` | `parameters/providers/aws-parameter-store/` | +| `azure-key-vault` | `parameters/providers/azure-key-vault/` | If the slug doesn't match any installed provider, `build_context` fails with a list of available providers and instructions to either rename the spec slug or add the missing provider. @@ -73,7 +73,7 @@ Env vars take precedence ONLY when the provider attribute is missing. This lets The shape of `$CONTEXT.provider.attributes` for each provider: -### `hashicorp_vault` +### `hashicorp-vault` ```json { @@ -83,7 +83,7 @@ The shape of `$CONTEXT.provider.attributes` for each provider: } ``` -### `aws_secret_manager` (currently named `aws_secret_manager`) +### `aws-secrets-manager` (currently named `aws-secrets-manager`) ```json { @@ -95,7 +95,7 @@ The shape of `$CONTEXT.provider.attributes` for each provider: `kms_key_id` is optional (defaults to AWS-managed key). -### `parameter_store` +### `aws-parameter-store` ```json { @@ -108,7 +108,7 @@ The shape of `$CONTEXT.provider.attributes` for each provider: `kms_key_id` only matters for `kind=secret` (SecureString). `tier` ∈ {`Standard`, `Advanced`, `Intelligent-Tiering`}. -### `azure_key_vault` +### `azure-key-vault` ```json { @@ -129,7 +129,7 @@ For local testing without involving the platform, set the relevant env vars and # Stub np in PATH cat > /tmp/np << 'EOF' #!/bin/bash -echo '{"slug": "hashicorp_vault"}' +echo '{"slug": "hashicorp-vault"}' EOF chmod +x /tmp/np export PATH=/tmp:$PATH diff --git a/parameters/entrypoint b/parameters/entrypoint index 3e6c78fa..69615ae0 100755 --- a/parameters/entrypoint +++ b/parameters/entrypoint @@ -9,7 +9,7 @@ set -euo pipefail # # Kind discrimination (secret vs parameter) is NOT done here. It's derived at # the script layer: build_context reads $CONTEXT.secret and exports -# PARAMETER_KIND; providers that branch on it (e.g. parameter_store choosing +# PARAMETER_KIND; providers that branch on it (e.g. aws-parameter-store choosing # String vs SecureString) read PARAMETER_KIND directly. if [ -z "${NP_ACTION_CONTEXT:-}" ]; then diff --git a/parameters/providers/README.md b/parameters/providers/README.md index e783256b..93432aee 100644 --- a/parameters/providers/README.md +++ b/parameters/providers/README.md @@ -21,7 +21,7 @@ providers// └── docs/ # (recommended) architecture.md, iam-policy.md, etc. ``` -`` is the string users set in `SECRET_PROVIDER` / `PARAMETER_PROVIDER`. Use `snake_case` (e.g. `hashicorp_vault`, `azure_key_vault`, `parameter_store`). +`` is the string users set in `SECRET_PROVIDER` / `PARAMETER_PROVIDER`. Use `snake_case` (e.g. `hashicorp-vault`, `azure-key-vault`, `aws-parameter-store`). --- @@ -109,7 +109,7 @@ Example: ```bash #!/bin/bash -# providers/hashicorp_vault/setup +# providers/hashicorp-vault/setup VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') @@ -148,7 +148,7 @@ Output: { "value": "" } ``` -If not found, return `{"value": "value not found"}` rather than erroring (precedent: existing vault/aws_secret_manager impls). +If not found, return `{"value": "value not found"}` rather than erroring (precedent: existing vault/aws-secrets-manager impls). ### `delete` — required diff --git a/parameters/providers/aws-parameter-store/aws-parameter-store-configuration.json.tpl b/parameters/providers/aws-parameter-store/aws-parameter-store-configuration.json.tpl index 0a1c7129..12d5cb87 100644 --- a/parameters/providers/aws-parameter-store/aws-parameter-store-configuration.json.tpl +++ b/parameters/providers/aws-parameter-store/aws-parameter-store-configuration.json.tpl @@ -1,7 +1,7 @@ { "name": "AWS Parameter Store", "description": "Stores nullplatform parameter values in AWS SSM Parameter Store with native versioning. Cheapest option (Standard tier is free up to 10,000 parameters)", - "slug": "parameter_store", + "slug": "aws-parameter-store", "category": "parameters-storage", "icon": "mdi:aws", "visible_to": [ diff --git a/parameters/providers/aws-parameter-store/docs/architecture.md b/parameters/providers/aws-parameter-store/docs/architecture.md index 8aeb5621..4126e175 100644 --- a/parameters/providers/aws-parameter-store/docs/architecture.md +++ b/parameters/providers/aws-parameter-store/docs/architecture.md @@ -1,6 +1,6 @@ # AWS Systems Manager Parameter Store — Provider Architecture -This document describes the `parameters/providers/parameter_store/` implementation. It stores nullplatform parameters in AWS SSM Parameter Store, using `String` for plain parameters and `SecureString` (KMS-encrypted) for secrets. +This document describes the `parameters/providers/aws-parameter-store/` implementation. It stores nullplatform parameters in AWS SSM Parameter Store, using `String` for plain parameters and `SecureString` (KMS-encrypted) for secrets. Cheapest provider in the package — Standard tier is free up to 10,000 parameters. diff --git a/parameters/providers/aws-parameter-store/docs/iam-policy.md b/parameters/providers/aws-parameter-store/docs/iam-policy.md index f32978a7..17baf701 100644 --- a/parameters/providers/aws-parameter-store/docs/iam-policy.md +++ b/parameters/providers/aws-parameter-store/docs/iam-policy.md @@ -1,6 +1,6 @@ # IAM Policy — Parameter Store Provider, Least Privilege -Minimum IAM permissions required to operate the `parameters/providers/parameter_store/` provider, scoped to the configured `PS_NAME_PREFIX`. +Minimum IAM permissions required to operate the `parameters/providers/aws-parameter-store/` provider, scoped to the configured `PS_NAME_PREFIX`. --- diff --git a/parameters/providers/aws-secrets-manager/aws-secrets-manager-configuration.json.tpl b/parameters/providers/aws-secrets-manager/aws-secrets-manager-configuration.json.tpl index db165ffe..dede5b2c 100644 --- a/parameters/providers/aws-secrets-manager/aws-secrets-manager-configuration.json.tpl +++ b/parameters/providers/aws-secrets-manager/aws-secrets-manager-configuration.json.tpl @@ -1,7 +1,7 @@ { "name": "AWS Secrets Manager", "description": "Stores nullplatform parameter values in AWS Secrets Manager using native versioning", - "slug": "aws_secret_manager", + "slug": "aws-secrets-manager", "category": "parameters-storage", "icon": "mdi:aws", "visible_to": [ diff --git a/parameters/providers/aws-secrets-manager/docs/architecture.md b/parameters/providers/aws-secrets-manager/docs/architecture.md index ddb6a2a9..086e79a7 100644 --- a/parameters/providers/aws-secrets-manager/docs/architecture.md +++ b/parameters/providers/aws-secrets-manager/docs/architecture.md @@ -1,6 +1,6 @@ # AWS Secrets Manager — Architecture -This document describes how the `parameters/providers/aws_secret_manager/` provider stores, retrieves, and deletes nullplatform parameters using AWS Secrets Manager (SM). +This document describes how the `parameters/providers/aws-secrets-manager/` provider stores, retrieves, and deletes nullplatform parameters using AWS Secrets Manager (SM). --- @@ -100,7 +100,7 @@ ASSUME_ROLE_SESSION_PREFIX="np-secret-manager" source "$PARAMETERS_ROOT/utils/assume_role_step" ``` -The step is provider-agnostic — `parameter_store` does the same with its own selector (`parameter_store`) and env-var names (`PARAMETER_STORE_ASSUME_ROLE_ARN[_DEFAULT]`). The step: +The step is provider-agnostic — `aws-parameter-store` does the same with its own selector (`parameter_store`) and env-var names (`PARAMETER_STORE_ASSUME_ROLE_ARN[_DEFAULT]`). The step: 1. Reads the scope's NRN and dimensions from `CONTEXT` (falling back to `np scope read` when dimensions are not in the payload). 2. Calls `np provider list --categories identity-access-control --nrn [--dimensions ...]` to fetch the IAM provider that the platform has dimension-resolved for this scope. diff --git a/parameters/providers/aws-secrets-manager/docs/iam-policy.md b/parameters/providers/aws-secrets-manager/docs/iam-policy.md index 9fd848be..056e8160 100644 --- a/parameters/providers/aws-secrets-manager/docs/iam-policy.md +++ b/parameters/providers/aws-secrets-manager/docs/iam-policy.md @@ -1,6 +1,6 @@ # IAM Policy -Minimum IAM permissions for `parameters/providers/aws_secret_manager/`. Scoped to the `nullplatform/*` namespace so the agent cannot reach any secret outside this provider's domain. +Minimum IAM permissions for `parameters/providers/aws-secrets-manager/`. Scoped to the `nullplatform/*` namespace so the agent cannot reach any secret outside this provider's domain. --- diff --git a/parameters/providers/azure-key-vault/azure-key-vault-configuration.json.tpl b/parameters/providers/azure-key-vault/azure-key-vault-configuration.json.tpl index 509d9e4e..8908726e 100644 --- a/parameters/providers/azure-key-vault/azure-key-vault-configuration.json.tpl +++ b/parameters/providers/azure-key-vault/azure-key-vault-configuration.json.tpl @@ -1,7 +1,7 @@ { "name": "Azure Key Vault", "description": "Stores nullplatform parameter values in Azure Key Vault with native versioning", - "slug": "azure_key_vault", + "slug": "azure-key-vault", "category": "parameters-storage", "icon": "mdi:microsoft-azure", "visible_to": [ diff --git a/parameters/providers/azure-key-vault/docs/architecture.md b/parameters/providers/azure-key-vault/docs/architecture.md index 0f278fff..bd9f70ab 100644 --- a/parameters/providers/azure-key-vault/docs/architecture.md +++ b/parameters/providers/azure-key-vault/docs/architecture.md @@ -1,6 +1,6 @@ # Azure Key Vault — Provider Architecture -This document describes the `parameters/providers/azure_key_vault/` implementation. It stores nullplatform parameters as Azure Key Vault (AKV) secrets, exploiting AKV's native versioning. +This document describes the `parameters/providers/azure-key-vault/` implementation. It stores nullplatform parameters as Azure Key Vault (AKV) secrets, exploiting AKV's native versioning. --- diff --git a/parameters/providers/azure-key-vault/setup b/parameters/providers/azure-key-vault/setup index 5ba2842f..ba9d92fa 100755 --- a/parameters/providers/azure-key-vault/setup +++ b/parameters/providers/azure-key-vault/setup @@ -23,7 +23,7 @@ if [ -z "$AZ_VAULT_NAME" ]; then log error "" log error "💡 Possible causes:" log error " • AZURE_KEY_VAULT_NAME env var is not set" - log error " • .vault_name is missing in the azure_key_vault provider config" + log error " • .vault_name is missing in the azure-key-vault provider config" log error "" log error "🔧 How to fix:" log error " • Set AZURE_KEY_VAULT_NAME=" diff --git a/parameters/providers/hashicorp-vault/docs/architecture.md b/parameters/providers/hashicorp-vault/docs/architecture.md index 3ebba195..40b8c060 100644 --- a/parameters/providers/hashicorp-vault/docs/architecture.md +++ b/parameters/providers/hashicorp-vault/docs/architecture.md @@ -1,6 +1,6 @@ # HashiCorp Vault — Provider Architecture -This document describes the `parameters/providers/hashicorp_vault/` implementation. It stores nullplatform parameters as Vault KV v2 secrets, exploiting Vault's native versioning. +This document describes the `parameters/providers/hashicorp-vault/` implementation. It stores nullplatform parameters as Vault KV v2 secrets, exploiting Vault's native versioning. --- diff --git a/parameters/providers/hashicorp-vault/hashicorp-vault-configuration.json.tpl b/parameters/providers/hashicorp-vault/hashicorp-vault-configuration.json.tpl index 0384986f..9403a0d6 100644 --- a/parameters/providers/hashicorp-vault/hashicorp-vault-configuration.json.tpl +++ b/parameters/providers/hashicorp-vault/hashicorp-vault-configuration.json.tpl @@ -1,7 +1,7 @@ { "name": "HashiCorp Vault", "description": "Stores nullplatform parameter values in HashiCorp Vault KV v2 with native versioning", - "slug": "hashicorp_vault", + "slug": "hashicorp-vault", "category": "parameters-storage", "icon": "mdi:vault", "visible_to": [ diff --git a/parameters/providers/hashicorp-vault/setup b/parameters/providers/hashicorp-vault/setup index f0214afb..b6cced4f 100755 --- a/parameters/providers/hashicorp-vault/setup +++ b/parameters/providers/hashicorp-vault/setup @@ -18,7 +18,7 @@ if [ -z "$VAULT_ADDR" ]; then log error "" log error "💡 Possible causes:" log error " • VAULT_ADDR env var is not set in the workflow runtime" - log error " • .address is missing in the hashicorp_vault provider config" + log error " • .address is missing in the hashicorp-vault provider config" log error "" log error "🔧 How to fix:" log error " • Set VAULT_ADDR=https://your-vault-host" diff --git a/parameters/tests/providers/aws-parameter-store/delete.bats b/parameters/tests/providers/aws-parameter-store/delete.bats index aaf7ca09..4af340c3 100644 --- a/parameters/tests/providers/aws-parameter-store/delete.bats +++ b/parameters/tests/providers/aws-parameter-store/delete.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/parameter_store/delete +# Unit tests for parameters/providers/aws-parameter-store/delete # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/delete" + export SCRIPT="$PARAMETERS_DIR/providers/aws-parameter-store/delete" mkdir -p "$BATS_TEST_TMPDIR/bin" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" @@ -44,7 +44,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "parameter_store delete: success → {success: true}" { +@test "aws-parameter-store delete: success → {success: true}" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -52,7 +52,7 @@ EOF assert_equal "$success" "true" } -@test "parameter_store delete: ParameterNotFound is idempotent → success" { +@test "aws-parameter-store delete: ParameterNotFound is idempotent → success" { run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" assert_equal "$status" "0" @@ -60,7 +60,7 @@ EOF assert_equal "$success" "true" } -@test "parameter_store delete: AccessDenied fails with troubleshooting" { +@test "aws-parameter-store delete: AccessDenied fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" [ "$status" -ne 0 ] @@ -68,14 +68,14 @@ EOF assert_contains "$output" "lacks ssm:DeleteParameter" } -@test "parameter_store delete: unknown errors fail loud" { +@test "aws-parameter-store delete: unknown errors fail loud" { run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" [ "$status" -ne 0 ] assert_contains "$output" "❌ Failed to delete parameter" } -@test "parameter_store delete: calls aws ssm delete-parameter with name" { +@test "aws-parameter-store delete: calls aws ssm delete-parameter with name" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") diff --git a/parameters/tests/providers/aws-parameter-store/retrieve.bats b/parameters/tests/providers/aws-parameter-store/retrieve.bats index ff25feba..2b1ed1f4 100644 --- a/parameters/tests/providers/aws-parameter-store/retrieve.bats +++ b/parameters/tests/providers/aws-parameter-store/retrieve.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/parameter_store/retrieve +# Unit tests for parameters/providers/aws-parameter-store/retrieve # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/retrieve" + export SCRIPT="$PARAMETERS_DIR/providers/aws-parameter-store/retrieve" mkdir -p "$BATS_TEST_TMPDIR/bin" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" @@ -47,7 +47,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "parameter_store retrieve: success → returns value" { +@test "aws-parameter-store retrieve: success → returns value" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -55,7 +55,7 @@ EOF assert_equal "$value" "the-real-value" } -@test "parameter_store retrieve: ParameterNotFound fails with troubleshooting" { +@test "aws-parameter-store retrieve: ParameterNotFound fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" [ "$status" -ne 0 ] @@ -64,7 +64,7 @@ EOF assert_contains "$output" "🔧 How to fix:" } -@test "parameter_store retrieve: AccessDenied fails with troubleshooting" { +@test "aws-parameter-store retrieve: AccessDenied fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" [ "$status" -ne 0 ] @@ -72,14 +72,14 @@ EOF assert_contains "$output" "lacks ssm:GetParameter" } -@test "parameter_store retrieve: unknown errors fail loud" { +@test "aws-parameter-store retrieve: unknown errors fail loud" { run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" [ "$status" -ne 0 ] assert_contains "$output" "❌ Failed to retrieve parameter" } -@test "parameter_store retrieve: calls aws with --with-decryption" { +@test "aws-parameter-store retrieve: calls aws with --with-decryption" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") diff --git a/parameters/tests/providers/aws-parameter-store/setup.bats b/parameters/tests/providers/aws-parameter-store/setup.bats index e2963cb2..c0d1b5a6 100644 --- a/parameters/tests/providers/aws-parameter-store/setup.bats +++ b/parameters/tests/providers/aws-parameter-store/setup.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/parameter_store/setup +# Unit tests for parameters/providers/aws-parameter-store/setup # ============================================================================= setup() { @@ -10,7 +10,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" export PARAMETERS_ROOT="$PARAMETERS_DIR" - export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/setup" + export SCRIPT="$PARAMETERS_DIR/providers/aws-parameter-store/setup" export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" } @@ -18,7 +18,7 @@ teardown() { unset AWS_REGION PS_NAME_PREFIX PS_KMS_KEY_ID PS_TIER PROVIDER_CONFIG } -@test "parameter_store setup: fails fast when AWS_REGION is missing" { +@test "aws-parameter-store setup: fails fast when AWS_REGION is missing" { unset AWS_REGION run bash -c "$DEPS; source $SCRIPT" @@ -27,7 +27,7 @@ teardown() { assert_contains "$output" "AWS_REGION" } -@test "parameter_store setup: name_prefix is hardcoded to '/nullplatform/'" { +@test "aws-parameter-store setup: name_prefix is hardcoded to '/nullplatform/'" { export AWS_REGION="us-east-1" export PROVIDER_CONFIG='{"name_prefix":"/custom/"}' @@ -37,7 +37,7 @@ teardown() { assert_contains "$output" "PREFIX=/nullplatform/" } -@test "parameter_store setup: default tier is Standard" { +@test "aws-parameter-store setup: default tier is Standard" { export AWS_REGION="us-east-1" run bash -c "$DEPS; source $SCRIPT && echo TIER=\$PS_TIER" @@ -46,7 +46,7 @@ teardown() { assert_contains "$output" "TIER=Standard" } -@test "parameter_store setup: accepts Advanced tier from PROVIDER_CONFIG" { +@test "aws-parameter-store setup: accepts Advanced tier from PROVIDER_CONFIG" { export AWS_REGION="us-east-1" export PROVIDER_CONFIG='{"tier":"Advanced"}' @@ -56,7 +56,7 @@ teardown() { assert_contains "$output" "TIER=Advanced" } -@test "parameter_store setup: rejects invalid tier" { +@test "aws-parameter-store setup: rejects invalid tier" { export AWS_REGION="us-east-1" export PROVIDER_CONFIG='{"tier":"Bogus"}' @@ -67,7 +67,7 @@ teardown() { assert_contains "$output" "Standard, Advanced, Intelligent-Tiering" } -@test "parameter_store setup: kms_key_id from PROVIDER_CONFIG" { +@test "aws-parameter-store setup: kms_key_id from PROVIDER_CONFIG" { export AWS_REGION="us-east-1" export PROVIDER_CONFIG='{"kms_key_id":"alias/cfg"}' diff --git a/parameters/tests/providers/aws-parameter-store/store.bats b/parameters/tests/providers/aws-parameter-store/store.bats index f79a1a95..916c6d0d 100644 --- a/parameters/tests/providers/aws-parameter-store/store.bats +++ b/parameters/tests/providers/aws-parameter-store/store.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/parameter_store/store +# Unit tests for parameters/providers/aws-parameter-store/store # ============================================================================= setup() { @@ -10,7 +10,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/parameter_store/store" + export SCRIPT="$PARAMETERS_DIR/providers/aws-parameter-store/store" mkdir -p "$BATS_TEST_TMPDIR/bin" @@ -63,7 +63,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "parameter_store store: external_id composed from entities + parameter_id + version" { +@test "aws-parameter-store store: external_id composed from entities + parameter_id + version" { export PARAMETER_KIND="parameter" run bash -c "$DEPS; source $SCRIPT" @@ -75,7 +75,7 @@ EOF assert_equal "$external_id" "$expected" } -@test "parameter_store store: kind=secret uses SecureString" { +@test "aws-parameter-store store: kind=secret uses SecureString" { export PARAMETER_KIND="secret" run bash -c "$DEPS; source $SCRIPT" @@ -87,7 +87,7 @@ EOF assert_contains "$captured" "--type SecureString" } -@test "parameter_store store: kind=parameter uses String" { +@test "aws-parameter-store store: kind=parameter uses String" { export PARAMETER_KIND="parameter" run bash -c "$DEPS; source $SCRIPT" @@ -97,7 +97,7 @@ EOF [[ "$captured" != *"SecureString"* ]] } -@test "parameter_store store: includes --key-id for SecureString when PS_KMS_KEY_ID set" { +@test "aws-parameter-store store: includes --key-id for SecureString when PS_KMS_KEY_ID set" { export PARAMETER_KIND="secret" export PS_KMS_KEY_ID="alias/parameters-secure" @@ -107,7 +107,7 @@ EOF assert_contains "$captured" "--key-id alias/parameters-secure" } -@test "parameter_store store: parameter_name has PS_NAME_PREFIX + composite" { +@test "aws-parameter-store store: parameter_name has PS_NAME_PREFIX + composite" { export PARAMETER_KIND="parameter" run bash -c "$DEPS; source $SCRIPT" @@ -116,7 +116,7 @@ EOF assert_contains "$param_name" "/nullplatform/organization=acme-1255165411" } -@test "parameter_store store: passes tier flag" { +@test "aws-parameter-store store: passes tier flag" { export PARAMETER_KIND="parameter" export PS_TIER="Advanced" @@ -126,7 +126,7 @@ EOF assert_contains "$captured" "--tier Advanced" } -@test "parameter_store store: fails with troubleshooting on aws error" { +@test "aws-parameter-store store: fails with troubleshooting on aws error" { export PARAMETER_KIND="parameter" run bash -c "$DEPS; MOCK_AWS_EXIT=1 source $SCRIPT" diff --git a/parameters/tests/providers/aws-secrets-manager/delete.bats b/parameters/tests/providers/aws-secrets-manager/delete.bats index bac1c14b..e1916561 100644 --- a/parameters/tests/providers/aws-secrets-manager/delete.bats +++ b/parameters/tests/providers/aws-secrets-manager/delete.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/aws_secret_manager/delete +# Unit tests for parameters/providers/aws-secrets-manager/delete # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/aws_secret_manager/delete" + export SCRIPT="$PARAMETERS_DIR/providers/aws-secrets-manager/delete" mkdir -p "$BATS_TEST_TMPDIR/bin" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" @@ -44,7 +44,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "aws_secret_manager delete: success → {success: true}" { +@test "aws-secrets-manager delete: success → {success: true}" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -52,7 +52,7 @@ EOF assert_equal "$success" "true" } -@test "aws_secret_manager delete: ResourceNotFoundException is idempotent → success" { +@test "aws-secrets-manager delete: ResourceNotFoundException is idempotent → success" { run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" assert_equal "$status" "0" @@ -60,7 +60,7 @@ EOF assert_equal "$success" "true" } -@test "aws_secret_manager delete: AccessDenied fails with troubleshooting" { +@test "aws-secrets-manager delete: AccessDenied fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" [ "$status" -ne 0 ] @@ -69,7 +69,7 @@ EOF assert_contains "$output" "AccessDeniedException" } -@test "aws_secret_manager delete: unknown errors fail with troubleshooting" { +@test "aws-secrets-manager delete: unknown errors fail with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" [ "$status" -ne 0 ] @@ -77,7 +77,7 @@ EOF assert_contains "$output" "🔧 How to fix:" } -@test "aws_secret_manager delete: calls aws with force-delete flag" { +@test "aws-secrets-manager delete: calls aws with force-delete flag" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") diff --git a/parameters/tests/providers/aws-secrets-manager/retrieve.bats b/parameters/tests/providers/aws-secrets-manager/retrieve.bats index 32deadd6..058f9532 100644 --- a/parameters/tests/providers/aws-secrets-manager/retrieve.bats +++ b/parameters/tests/providers/aws-secrets-manager/retrieve.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/aws_secret_manager/retrieve +# Unit tests for parameters/providers/aws-secrets-manager/retrieve # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/aws_secret_manager/retrieve" + export SCRIPT="$PARAMETERS_DIR/providers/aws-secrets-manager/retrieve" mkdir -p "$BATS_TEST_TMPDIR/bin" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" @@ -47,7 +47,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "aws_secret_manager retrieve: success → extracts .value from envelope" { +@test "aws-secrets-manager retrieve: success → extracts .value from envelope" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -55,7 +55,7 @@ EOF assert_equal "$value" "the-real-value" } -@test "aws_secret_manager retrieve: ResourceNotFoundException fails with troubleshooting" { +@test "aws-secrets-manager retrieve: ResourceNotFoundException fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=not_found source $SCRIPT" [ "$status" -ne 0 ] @@ -65,7 +65,7 @@ EOF assert_contains "$output" "🔧 How to fix:" } -@test "aws_secret_manager retrieve: AccessDenied fails with troubleshooting" { +@test "aws-secrets-manager retrieve: AccessDenied fails with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=auth_error source $SCRIPT" [ "$status" -ne 0 ] @@ -73,14 +73,14 @@ EOF assert_contains "$output" "lacks secretsmanager:GetSecretValue" } -@test "aws_secret_manager retrieve: unknown errors fail loud" { +@test "aws-secrets-manager retrieve: unknown errors fail loud" { run bash -c "$DEPS; MOCK_AWS_MODE=other source $SCRIPT" [ "$status" -ne 0 ] assert_contains "$output" "❌ Failed to retrieve secret" } -@test "aws_secret_manager retrieve: calls aws with correct args" { +@test "aws-secrets-manager retrieve: calls aws with correct args" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") diff --git a/parameters/tests/providers/aws-secrets-manager/setup.bats b/parameters/tests/providers/aws-secrets-manager/setup.bats index 3bebb7b4..feb3ebdc 100644 --- a/parameters/tests/providers/aws-secrets-manager/setup.bats +++ b/parameters/tests/providers/aws-secrets-manager/setup.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/aws_secret_manager/setup +# Unit tests for parameters/providers/aws-secrets-manager/setup # ============================================================================= setup() { @@ -10,7 +10,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" export PARAMETERS_ROOT="$PARAMETERS_DIR" - export SCRIPT="$PARAMETERS_DIR/providers/aws_secret_manager/setup" + export SCRIPT="$PARAMETERS_DIR/providers/aws-secrets-manager/setup" export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" } @@ -18,7 +18,7 @@ teardown() { unset AWS_REGION SM_NAME_PREFIX SM_KMS_KEY_ID PROVIDER_CONFIG } -@test "aws_secret_manager setup: fails fast when AWS_REGION is missing" { +@test "aws-secrets-manager setup: fails fast when AWS_REGION is missing" { unset AWS_REGION run bash -c "$DEPS; source $SCRIPT" @@ -27,7 +27,7 @@ teardown() { assert_contains "$output" "AWS_REGION" } -@test "aws_secret_manager setup: name_prefix is hardcoded to 'nullplatform/'" { +@test "aws-secrets-manager setup: name_prefix is hardcoded to 'nullplatform/'" { export AWS_REGION="us-east-1" # Even if PROVIDER_CONFIG tries to set name_prefix, it's ignored (hardcoded invariant) export PROVIDER_CONFIG='{"name_prefix":"custom/"}' @@ -38,7 +38,7 @@ teardown() { assert_contains "$output" "PREFIX=nullplatform/" } -@test "aws_secret_manager setup: AWS_REGION is taken from env (runtime-injected)" { +@test "aws-secrets-manager setup: AWS_REGION is taken from env (runtime-injected)" { export AWS_REGION="eu-west-1" run bash -c "$DEPS; source $SCRIPT && echo REGION=\$AWS_REGION" @@ -47,7 +47,7 @@ teardown() { assert_contains "$output" "REGION=eu-west-1" } -@test "aws_secret_manager setup: kms_key_id from PROVIDER_CONFIG" { +@test "aws-secrets-manager setup: kms_key_id from PROVIDER_CONFIG" { export AWS_REGION="us-east-1" export PROVIDER_CONFIG='{"kms_key_id":"alias/mykey"}' @@ -57,7 +57,7 @@ teardown() { assert_contains "$output" "KMS=alias/mykey" } -@test "aws_secret_manager setup: kms_key_id is empty by default" { +@test "aws-secrets-manager setup: kms_key_id is empty by default" { export AWS_REGION="us-east-1" run bash -c "$DEPS; source $SCRIPT && echo KMS=[\$SM_KMS_KEY_ID]" diff --git a/parameters/tests/providers/aws-secrets-manager/store.bats b/parameters/tests/providers/aws-secrets-manager/store.bats index 9aa775cb..b0ee55d2 100644 --- a/parameters/tests/providers/aws-secrets-manager/store.bats +++ b/parameters/tests/providers/aws-secrets-manager/store.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/aws_secret_manager/store +# Unit tests for parameters/providers/aws-secrets-manager/store # # Verifies: # - external_id composed from entities + dimensions + parameter_name-id @@ -18,7 +18,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/aws_secret_manager/store" + export SCRIPT="$PARAMETERS_DIR/providers/aws-secrets-manager/store" mkdir -p "$BATS_TEST_TMPDIR/bin" @@ -95,7 +95,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "aws_secret_manager store: external_id includes entities + parameter_name-id + version" { +@test "aws-secrets-manager store: external_id includes entities + parameter_name-id + version" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -104,7 +104,7 @@ EOF assert_equal "$external_id" "$expected" } -@test "aws_secret_manager store: secret_name uses nullplatform/ prefix" { +@test "aws-secrets-manager store: secret_name uses nullplatform/ prefix" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -113,7 +113,7 @@ EOF assert_contains "$secret_name" "DB_PASSWORD-42" } -@test "aws_secret_manager store: first store uses CreateSecret with managed_by tag" { +@test "aws-secrets-manager store: first store uses CreateSecret with managed_by tag" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AWS_LOG") @@ -122,7 +122,7 @@ EOF [[ "$captured" != *"put-secret-value"* ]] } -@test "aws_secret_manager store: version_id is encoded as #suffix in external_id" { +@test "aws-secrets-manager store: version_id is encoded as #suffix in external_id" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -131,7 +131,7 @@ EOF assert_equal "$version" "v1-create-uuid" } -@test "aws_secret_manager store: PutSecretValue's version_id replaces create's in external_id" { +@test "aws-secrets-manager store: PutSecretValue's version_id replaces create's in external_id" { run bash -c "$DEPS; MOCK_AWS_MODE=exists source $SCRIPT" assert_equal "$status" "0" @@ -140,7 +140,7 @@ EOF assert_equal "$version" "v2-put-uuid" } -@test "aws_secret_manager store: ResourceExistsException falls through to PutSecretValue" { +@test "aws-secrets-manager store: ResourceExistsException falls through to PutSecretValue" { run bash -c "$DEPS; MOCK_AWS_MODE=exists source $SCRIPT" assert_equal "$status" "0" @@ -150,7 +150,7 @@ EOF assert_contains "$captured" "--secret-id nullplatform/organization=acme-1255165411" } -@test "aws_secret_manager store: PutSecretValue failure propagates with troubleshooting" { +@test "aws-secrets-manager store: PutSecretValue failure propagates with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=put_error source $SCRIPT" [ "$status" -ne 0 ] @@ -159,7 +159,7 @@ EOF assert_contains "$output" "🔧 How to fix:" } -@test "aws_secret_manager store: non-exists create errors propagate with troubleshooting" { +@test "aws-secrets-manager store: non-exists create errors propagate with troubleshooting" { run bash -c "$DEPS; MOCK_AWS_MODE=create_error source $SCRIPT" [ "$status" -ne 0 ] @@ -167,7 +167,7 @@ EOF assert_contains "$output" "secretsmanager:CreateSecret" } -@test "aws_secret_manager store: includes --kms-key-id when SM_KMS_KEY_ID set" { +@test "aws-secrets-manager store: includes --kms-key-id when SM_KMS_KEY_ID set" { export SM_KMS_KEY_ID="alias/my-key" run bash -c "$DEPS; source $SCRIPT" @@ -176,7 +176,7 @@ EOF assert_contains "$captured" "--kms-key-id alias/my-key" } -@test "aws_secret_manager store: dimensions sort alphabetically in external_id" { +@test "aws-secrets-manager store: dimensions sort alphabetically in external_id" { export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') run bash -c "$DEPS; source $SCRIPT" diff --git a/parameters/tests/providers/azure-key-vault/delete.bats b/parameters/tests/providers/azure-key-vault/delete.bats index 439be31d..f079ce32 100644 --- a/parameters/tests/providers/azure-key-vault/delete.bats +++ b/parameters/tests/providers/azure-key-vault/delete.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/azure_key_vault/delete +# Unit tests for parameters/providers/azure-key-vault/delete # Two-step: soft-delete + purge. Purge failures are warnings, not errors. # ============================================================================= @@ -12,7 +12,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/delete" + export SCRIPT="$PARAMETERS_DIR/providers/azure-key-vault/delete" mkdir -p "$BATS_TEST_TMPDIR/bin" export AZ_LOG="$BATS_TEST_TMPDIR/az.log" @@ -67,7 +67,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "azure_key_vault delete: both delete + purge succeed → {success: true}" { +@test "azure-key-vault delete: both delete + purge succeed → {success: true}" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -75,7 +75,7 @@ EOF assert_equal "$success" "true" } -@test "azure_key_vault delete: SecretNotFound on delete is idempotent → success" { +@test "azure-key-vault delete: SecretNotFound on delete is idempotent → success" { run bash -c "$DEPS; MOCK_DELETE_MODE=not_found source $SCRIPT" assert_equal "$status" "0" @@ -83,7 +83,7 @@ EOF assert_equal "$success" "true" } -@test "azure_key_vault delete: delete auth_error fails with troubleshooting" { +@test "azure-key-vault delete: delete auth_error fails with troubleshooting" { run bash -c "$DEPS; MOCK_DELETE_MODE=auth_error source $SCRIPT" [ "$status" -ne 0 ] @@ -91,7 +91,7 @@ EOF assert_contains "$output" "lacks 'Delete' permission" } -@test "azure_key_vault delete: purge forbidden is downgraded to warning, still returns success" { +@test "azure-key-vault delete: purge forbidden is downgraded to warning, still returns success" { run --separate-stderr bash -c "$DEPS; MOCK_PURGE_MODE=purge_forbidden source $SCRIPT" assert_equal "$status" "0" @@ -101,7 +101,7 @@ EOF assert_contains "$stderr" "Purge permission missing" } -@test "azure_key_vault delete: purge other failure is warning, still success" { +@test "azure-key-vault delete: purge other failure is warning, still success" { run --separate-stderr bash -c "$DEPS; MOCK_PURGE_MODE=other source $SCRIPT" assert_equal "$status" "0" @@ -110,7 +110,7 @@ EOF assert_contains "$stderr" "⚠️ Purge failed" } -@test "azure_key_vault delete: calls both delete and purge sub-commands" { +@test "azure-key-vault delete: calls both delete and purge sub-commands" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AZ_LOG") @@ -119,7 +119,7 @@ EOF assert_contains "$captured" "--name parameters-abc-123" } -@test "azure_key_vault delete: skips purge if delete returned not_found" { +@test "azure-key-vault delete: skips purge if delete returned not_found" { run bash -c "$DEPS; MOCK_DELETE_MODE=not_found source $SCRIPT" assert_equal "$status" "0" diff --git a/parameters/tests/providers/azure-key-vault/retrieve.bats b/parameters/tests/providers/azure-key-vault/retrieve.bats index 79e20a50..3c77fce1 100644 --- a/parameters/tests/providers/azure-key-vault/retrieve.bats +++ b/parameters/tests/providers/azure-key-vault/retrieve.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/azure_key_vault/retrieve +# Unit tests for parameters/providers/azure-key-vault/retrieve # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/retrieve" + export SCRIPT="$PARAMETERS_DIR/providers/azure-key-vault/retrieve" mkdir -p "$BATS_TEST_TMPDIR/bin" export AZ_LOG="$BATS_TEST_TMPDIR/az.log" @@ -47,7 +47,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "azure_key_vault retrieve: success → returns value" { +@test "azure-key-vault retrieve: success → returns value" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -55,7 +55,7 @@ EOF assert_equal "$value" "the-stored-value" } -@test "azure_key_vault retrieve: SecretNotFound fails with troubleshooting" { +@test "azure-key-vault retrieve: SecretNotFound fails with troubleshooting" { run bash -c "$DEPS; MOCK_AZ_MODE=not_found source $SCRIPT" [ "$status" -ne 0 ] @@ -64,7 +64,7 @@ EOF assert_contains "$output" "🔧 How to fix:" } -@test "azure_key_vault retrieve: auth_error fails with troubleshooting" { +@test "azure-key-vault retrieve: auth_error fails with troubleshooting" { run bash -c "$DEPS; MOCK_AZ_MODE=auth_error source $SCRIPT" [ "$status" -ne 0 ] @@ -72,14 +72,14 @@ EOF assert_contains "$output" "lacks 'Get' permission" } -@test "azure_key_vault retrieve: unknown errors fail loud" { +@test "azure-key-vault retrieve: unknown errors fail loud" { run bash -c "$DEPS; MOCK_AZ_MODE=other source $SCRIPT" [ "$status" -ne 0 ] assert_contains "$output" "❌ Failed to retrieve secret" } -@test "azure_key_vault retrieve: calls az keyvault secret show" { +@test "azure-key-vault retrieve: calls az keyvault secret show" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AZ_LOG") diff --git a/parameters/tests/providers/azure-key-vault/setup.bats b/parameters/tests/providers/azure-key-vault/setup.bats index b7cff0ff..0aef6461 100644 --- a/parameters/tests/providers/azure-key-vault/setup.bats +++ b/parameters/tests/providers/azure-key-vault/setup.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/azure_key_vault/setup +# Unit tests for parameters/providers/azure-key-vault/setup # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/setup" + export SCRIPT="$PARAMETERS_DIR/providers/azure-key-vault/setup" export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" } @@ -17,7 +17,7 @@ teardown() { unset AZURE_KEY_VAULT_NAME AZ_VAULT_NAME AZ_SECRET_PREFIX PROVIDER_CONFIG } -@test "azure_key_vault setup: fails when vault name is missing" { +@test "azure-key-vault setup: fails when vault name is missing" { run bash -c "$DEPS; source $SCRIPT" [ "$status" -ne 0 ] @@ -25,7 +25,7 @@ teardown() { assert_contains "$output" "🔧 How to fix:" } -@test "azure_key_vault setup: vault name from env" { +@test "azure-key-vault setup: vault name from env" { export AZURE_KEY_VAULT_NAME="my-vault" run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME PREFIX=\$AZ_SECRET_PREFIX" @@ -35,7 +35,7 @@ teardown() { assert_contains "$output" "PREFIX=nullplatform-" } -@test "azure_key_vault setup: secret_prefix is hardcoded to nullplatform-" { +@test "azure-key-vault setup: secret_prefix is hardcoded to nullplatform-" { export AZURE_KEY_VAULT_NAME="my-vault" # PROVIDER_CONFIG tries to override; ignored export PROVIDER_CONFIG='{"secret_prefix":"app-secret-"}' @@ -46,7 +46,7 @@ teardown() { assert_contains "$output" "PREFIX=nullplatform-" } -@test "azure_key_vault setup: vault_name from PROVIDER_CONFIG" { +@test "azure-key-vault setup: vault_name from PROVIDER_CONFIG" { export PROVIDER_CONFIG='{"vault_name":"cfg-vault"}' run bash -c "$DEPS; source $SCRIPT && echo VAULT=\$AZ_VAULT_NAME" diff --git a/parameters/tests/providers/azure-key-vault/store.bats b/parameters/tests/providers/azure-key-vault/store.bats index 41f1b663..7a65adb6 100644 --- a/parameters/tests/providers/azure-key-vault/store.bats +++ b/parameters/tests/providers/azure-key-vault/store.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/azure_key_vault/store +# Unit tests for parameters/providers/azure-key-vault/store # AKV transforms / and = to - in the secret name (canonical form has slashes). # ============================================================================= @@ -11,7 +11,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/azure_key_vault/store" + export SCRIPT="$PARAMETERS_DIR/providers/azure-key-vault/store" mkdir -p "$BATS_TEST_TMPDIR/bin" @@ -57,7 +57,7 @@ EOF export DEPS="source $PARAMETERS_DIR/utils/log" } -@test "azure_key_vault store: external_id is canonical slash form + version suffix" { +@test "azure-key-vault store: external_id is canonical slash form + version suffix" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -67,7 +67,7 @@ EOF assert_equal "$external_id" "$expected" } -@test "azure_key_vault store: secret_name uses dashes (AKV-safe)" { +@test "azure-key-vault store: secret_name uses dashes (AKV-safe)" { run bash -c "$DEPS; source $SCRIPT" assert_equal "$status" "0" @@ -80,7 +80,7 @@ EOF [[ "$secret_name" != *"="* ]] } -@test "azure_key_vault store: calls az with AKV-safe name" { +@test "azure-key-vault store: calls az with AKV-safe name" { run bash -c "$DEPS; source $SCRIPT" captured=$(cat "$AZ_LOG") @@ -90,7 +90,7 @@ EOF assert_contains "$captured" "--value my-secret" } -@test "azure_key_vault store: dimensions sorted alphabetically in external_id" { +@test "azure-key-vault store: dimensions sorted alphabetically in external_id" { export CONTEXT=$(echo "$CONTEXT" | jq '.dimensions = {environment: "prod", country: "arg"}') run bash -c "$DEPS; source $SCRIPT" @@ -103,7 +103,7 @@ EOF assert_contains "$secret_name" "country-arg-environment-prod-42" } -@test "azure_key_vault store: fails with troubleshooting on az error" { +@test "azure-key-vault store: fails with troubleshooting on az error" { run bash -c "$DEPS; MOCK_AZ_EXIT=1 source $SCRIPT" [ "$status" -ne 0 ] diff --git a/parameters/tests/providers/hashicorp-vault/delete.bats b/parameters/tests/providers/hashicorp-vault/delete.bats index 1f02ee3f..5853b874 100644 --- a/parameters/tests/providers/hashicorp-vault/delete.bats +++ b/parameters/tests/providers/hashicorp-vault/delete.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/hashicorp_vault/delete +# Unit tests for parameters/providers/hashicorp-vault/delete # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/delete" + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp-vault/delete" mkdir -p "$BATS_TEST_TMPDIR/bin" export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" diff --git a/parameters/tests/providers/hashicorp-vault/retrieve.bats b/parameters/tests/providers/hashicorp-vault/retrieve.bats index d3bcc7b5..d6a28a34 100644 --- a/parameters/tests/providers/hashicorp-vault/retrieve.bats +++ b/parameters/tests/providers/hashicorp-vault/retrieve.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/hashicorp_vault/retrieve +# Unit tests for parameters/providers/hashicorp-vault/retrieve # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/retrieve" + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp-vault/retrieve" mkdir -p "$BATS_TEST_TMPDIR/bin" export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" diff --git a/parameters/tests/providers/hashicorp-vault/setup.bats b/parameters/tests/providers/hashicorp-vault/setup.bats index 77ea9b53..7bd430e2 100644 --- a/parameters/tests/providers/hashicorp-vault/setup.bats +++ b/parameters/tests/providers/hashicorp-vault/setup.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/hashicorp_vault/setup +# Unit tests for parameters/providers/hashicorp-vault/setup # ============================================================================= setup() { @@ -9,7 +9,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/setup" + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp-vault/setup" export DEPS="source $PARAMETERS_DIR/utils/log; source $PARAMETERS_DIR/utils/get_config_value" } diff --git a/parameters/tests/providers/hashicorp-vault/store.bats b/parameters/tests/providers/hashicorp-vault/store.bats index 0ce7a640..35be5ff4 100644 --- a/parameters/tests/providers/hashicorp-vault/store.bats +++ b/parameters/tests/providers/hashicorp-vault/store.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/providers/hashicorp_vault/store +# Unit tests for parameters/providers/hashicorp-vault/store # external_id is now composed via parameters/utils/build_external_id. # ============================================================================= @@ -11,7 +11,7 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" - export SCRIPT="$PARAMETERS_DIR/providers/hashicorp_vault/store" + export SCRIPT="$PARAMETERS_DIR/providers/hashicorp-vault/store" mkdir -p "$BATS_TEST_TMPDIR/bin" diff --git a/parameters/tests/utils/assume_role_step.bats b/parameters/tests/utils/assume_role_step.bats index 9927e5b9..cc880366 100644 --- a/parameters/tests/utils/assume_role_step.bats +++ b/parameters/tests/utils/assume_role_step.bats @@ -1,4 +1,5 @@ #!/usr/bin/env bats +bats_require_minimum_version 1.5.0 # ============================================================================= # Unit tests for parameters/utils/assume_role_step — orchestrates: # caller sets ASSUME_ROLE_SELECTOR + ASSUME_ROLE_OVERRIDE_ENV + @@ -8,7 +9,7 @@ # resolve ARN via lib → # source assume_role (sts:AssumeRole). # -# Provider-agnostic — the same step is sourced by aws_secret_manager AND +# Provider-agnostic — the same step is sourced by aws-secrets-manager AND # parameter_store with different selector + env names. # ============================================================================= @@ -63,7 +64,7 @@ make_ctx() { '{scope:{nrn:$nrn, id:$scope_id}, dimensions:$dims}' } -# Standard caller config for "aws_secret_manager"-style tests. +# Standard caller config for "aws-secrets-manager"-style tests. SM_CALLER='ASSUME_ROLE_SELECTOR=secret_manager; ASSUME_ROLE_OVERRIDE_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN; ASSUME_ROLE_DEFAULT_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT' # ---- Contract: caller must set the three required vars --------------------- diff --git a/parameters/tests/utils/get_config_value.bats b/parameters/tests/utils/get_config_value.bats index f6fcbf10..9bd88eeb 100644 --- a/parameters/tests/utils/get_config_value.bats +++ b/parameters/tests/utils/get_config_value.bats @@ -82,8 +82,8 @@ teardown() { } @test "get_config_value: nested provider path resolves correctly" { - export PROVIDER_CONFIG='{"hashicorp_vault":{"address":"https://vault.example.com"}}' + export PROVIDER_CONFIG='{"connection":{"address":"https://vault.example.com"}}' - result=$(get_config_value --provider '.hashicorp_vault.address') + result=$(get_config_value --provider '.connection.address') assert_equal "$result" "https://vault.example.com" } diff --git a/parameters/utils/assume_role_step b/parameters/utils/assume_role_step index 5d8c2b0e..1c1d327b 100644 --- a/parameters/utils/assume_role_step +++ b/parameters/utils/assume_role_step @@ -8,7 +8,7 @@ # # ASSUME_ROLE_SELECTOR — selector key under .iam_role_arns.arns[] in # the IAM provider (e.g. "secret_manager", -# "parameter_store"). +# "aws-parameter-store"). # ASSUME_ROLE_OVERRIDE_ENV — name of the env var that, when set, overrides # the IAM-provider lookup # (e.g. "SECRET_MANAGER_ASSUME_ROLE_ARN"). @@ -48,7 +48,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/assume_role_lib" # --- 0. Contract: caller must supply selector + env-var names --------------- -: "${ASSUME_ROLE_SELECTOR:?ASSUME_ROLE_SELECTOR must be set by the caller (e.g. 'secret_manager' or 'parameter_store')}" +: "${ASSUME_ROLE_SELECTOR:?ASSUME_ROLE_SELECTOR must be set by the caller (e.g. 'secret_manager' or 'aws-parameter-store')}" : "${ASSUME_ROLE_OVERRIDE_ENV:?ASSUME_ROLE_OVERRIDE_ENV must be set by the caller (name of the override env var)}" : "${ASSUME_ROLE_DEFAULT_ENV:?ASSUME_ROLE_DEFAULT_ENV must be set by the caller (name of the default-fallback env var)}" diff --git a/parameters/utils/build_context b/parameters/utils/build_context index 9c261048..513625bd 100755 --- a/parameters/utils/build_context +++ b/parameters/utils/build_context @@ -9,7 +9,7 @@ set -euo pipefail # Flow: # 1. Extract notification fields into env vars # 2. Derive PARAMETER_KIND from $CONTEXT.secret (still useful for providers -# like parameter_store that choose String vs SecureString) +# like aws-parameter-store that choose String vs SecureString) # 3. Resolve ACTIVE_PROVIDER from $CONTEXT.provider.specification_id via # `np provider specification read` — its `.slug` IS the provider directory name # 4. Set PROVIDER_CONFIG from $CONTEXT.provider.attributes (config travels in diff --git a/parameters/utils/get_config_value b/parameters/utils/get_config_value index a4a1f7d7..2ae29408 100755 --- a/parameters/utils/get_config_value +++ b/parameters/utils/get_config_value @@ -8,7 +8,7 @@ # (each provider owns its own config-fetching mechanism). The shape of # PROVIDER_CONFIG is defined by each provider. # -# Example (inside providers/hashicorp_vault/setup): +# Example (inside providers/hashicorp-vault/setup): # VAULT_ADDR=$(get_config_value --env VAULT_ADDR --provider '.address') # VAULT_TOKEN=$(get_config_value --env VAULT_TOKEN --provider '.token') From 42961a6189f668bbf3ac5119b2d781ab638fe504 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Thu, 25 Jun 2026 12:38:56 -0300 Subject: [PATCH 17/41] feat(parameters): tofu install modules for all four providers (spec + per-instance for_each) --- .../aws-parameter-store/install/main.tf | 57 +++++++++++++++++ .../aws-parameter-store/install/outputs.tf | 4 ++ .../install/terraform.tfvars.example | 19 ++++++ .../aws-parameter-store/install/variables.tf | 27 ++++++++ .../aws-parameter-store/install/versions.tf | 10 +++ .../aws-secrets-manager/install/main.tf | 64 +++++++++++++++++++ .../aws-secrets-manager/install/outputs.tf | 4 ++ .../install/terraform.tfvars.example | 17 +++++ .../aws-secrets-manager/install/variables.tf | 26 ++++++++ .../aws-secrets-manager/install/versions.tf | 10 +++ .../providers/azure-key-vault/install/main.tf | 55 ++++++++++++++++ .../azure-key-vault/install/outputs.tf | 4 ++ .../install/terraform.tfvars.example | 17 +++++ .../azure-key-vault/install/variables.tf | 26 ++++++++ .../azure-key-vault/install/versions.tf | 10 +++ .../providers/hashicorp-vault/install/main.tf | 55 ++++++++++++++++ .../hashicorp-vault/install/outputs.tf | 4 ++ .../install/terraform.tfvars.example | 17 +++++ .../hashicorp-vault/install/variables.tf | 26 ++++++++ .../hashicorp-vault/install/versions.tf | 10 +++ 20 files changed, 462 insertions(+) create mode 100644 parameters/providers/aws-parameter-store/install/main.tf create mode 100644 parameters/providers/aws-parameter-store/install/outputs.tf create mode 100644 parameters/providers/aws-parameter-store/install/terraform.tfvars.example create mode 100644 parameters/providers/aws-parameter-store/install/variables.tf create mode 100644 parameters/providers/aws-parameter-store/install/versions.tf create mode 100644 parameters/providers/aws-secrets-manager/install/main.tf create mode 100644 parameters/providers/aws-secrets-manager/install/outputs.tf create mode 100644 parameters/providers/aws-secrets-manager/install/terraform.tfvars.example create mode 100644 parameters/providers/aws-secrets-manager/install/variables.tf create mode 100644 parameters/providers/aws-secrets-manager/install/versions.tf create mode 100644 parameters/providers/azure-key-vault/install/main.tf create mode 100644 parameters/providers/azure-key-vault/install/outputs.tf create mode 100644 parameters/providers/azure-key-vault/install/terraform.tfvars.example create mode 100644 parameters/providers/azure-key-vault/install/variables.tf create mode 100644 parameters/providers/azure-key-vault/install/versions.tf create mode 100644 parameters/providers/hashicorp-vault/install/main.tf create mode 100644 parameters/providers/hashicorp-vault/install/outputs.tf create mode 100644 parameters/providers/hashicorp-vault/install/terraform.tfvars.example create mode 100644 parameters/providers/hashicorp-vault/install/variables.tf create mode 100644 parameters/providers/hashicorp-vault/install/versions.tf diff --git a/parameters/providers/aws-parameter-store/install/main.tf b/parameters/providers/aws-parameter-store/install/main.tf new file mode 100644 index 00000000..aee2eb0a --- /dev/null +++ b/parameters/providers/aws-parameter-store/install/main.tf @@ -0,0 +1,57 @@ +################################################################################ +# AWS Parameter Store — install module +# +# Two responsibilities, one source of truth: +# +# 1. nullplatform_provider_specification.this +# Created from ../aws-parameter-store-configuration.json.tpl. The JSON +# file is the canonical declaration of the provider's metadata and its +# config schema (kms_key_id, tier). +# +# 2. module.scope_configuration (for_each = var.instances) +# One concrete instance per entry in var.instances, each with its own +# NRN, dimensions, KMS key, and tier — so operators can mix Standard and +# Advanced tiers across accounts, or install Parameter Store only on +# selected environments. +################################################################################ + +locals { + template_path = "${path.module}/../aws-parameter-store-configuration.json.tpl" + template_raw = file(local.template_path) + template_rendered = replace(local.template_raw, "{{ env.Getenv \"NRN\" }}", var.nrn) + config = jsondecode(local.template_rendered) + + instance_nrns = distinct([for _, inst in var.instances : inst.nrn]) + spec_visible_to = distinct(concat( + [var.nrn], + local.instance_nrns, + var.extra_visible_to_nrns, + )) +} + +resource "nullplatform_provider_specification" "this" { + name = local.config.name + icon = local.config.icon + description = local.config.description + category = local.config.category + allow_dimensions = local.config.allow_dimensions + visible_to = local.spec_visible_to + schema = jsonencode(local.config.schema) +} + +module "scope_configuration" { + for_each = var.instances + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_configuration?ref=v4.5.1" + + nrn = each.value.nrn + np_api_key = var.np_api_key + provider_specification_slug = local.config.slug + dimensions = each.value.dimensions + + attributes = { + kms_key_id = each.value.kms_key_id + tier = each.value.tier + } + + depends_on = [nullplatform_provider_specification.this] +} diff --git a/parameters/providers/aws-parameter-store/install/outputs.tf b/parameters/providers/aws-parameter-store/install/outputs.tf new file mode 100644 index 00000000..4d40fb58 --- /dev/null +++ b/parameters/providers/aws-parameter-store/install/outputs.tf @@ -0,0 +1,4 @@ +output "specification_id" { + description = "ID of the nullplatform_provider_specification created from aws-parameter-store-configuration.json.tpl." + value = nullplatform_provider_specification.this.id +} diff --git a/parameters/providers/aws-parameter-store/install/terraform.tfvars.example b/parameters/providers/aws-parameter-store/install/terraform.tfvars.example new file mode 100644 index 00000000..52838fb1 --- /dev/null +++ b/parameters/providers/aws-parameter-store/install/terraform.tfvars.example @@ -0,0 +1,19 @@ +nrn = "organization=acme-1255165411:account=prod-95118862" +np_api_key = "REPLACE_ME" + +extra_visible_to_nrns = [] + +instances = { + prod-billing = { + nrn = "organization=acme-1255165411:account=prod-95118862:namespace=billing-37094320" + dimensions = { environment = "production" } + kms_key_id = "alias/parameters-prod-billing" + tier = "Standard" + } + staging-billing = { + nrn = "organization=acme-1255165411:account=staging-95118863:namespace=billing-37094320" + dimensions = { environment = "staging" } + kms_key_id = "" + tier = "Standard" + } +} diff --git a/parameters/providers/aws-parameter-store/install/variables.tf b/parameters/providers/aws-parameter-store/install/variables.tf new file mode 100644 index 00000000..e4660a64 --- /dev/null +++ b/parameters/providers/aws-parameter-store/install/variables.tf @@ -0,0 +1,27 @@ +variable "nrn" { + description = "NRN where the provider specification is anchored (the top-level scope it belongs to)." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the upstream scope_configuration module to register provider instances." + type = string + sensitive = true +} + +variable "extra_visible_to_nrns" { + description = "Additional NRNs that should see the provider specification besides var.nrn and the per-instance NRNs." + type = list(string) + default = [] +} + +variable "instances" { + description = "Provider instances to create. Map key is a stable identifier (used in for_each). Each entry carries its own NRN, dimensions, KMS key (for SecureString), and tier." + type = map(object({ + nrn = string + dimensions = map(string) + kms_key_id = string + tier = string + })) + default = {} +} diff --git a/parameters/providers/aws-parameter-store/install/versions.tf b/parameters/providers/aws-parameter-store/install/versions.tf new file mode 100644 index 00000000..5a677bcb --- /dev/null +++ b/parameters/providers/aws-parameter-store/install/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + nullplatform = { + source = "nullplatform/nullplatform" + version = ">= 0.0.95" + } + } +} diff --git a/parameters/providers/aws-secrets-manager/install/main.tf b/parameters/providers/aws-secrets-manager/install/main.tf new file mode 100644 index 00000000..8e8c7d25 --- /dev/null +++ b/parameters/providers/aws-secrets-manager/install/main.tf @@ -0,0 +1,64 @@ +################################################################################ +# AWS Secrets Manager — install module +# +# Two responsibilities, one source of truth: +# +# 1. nullplatform_provider_specification.this +# Created from ../aws-secrets-manager-configuration.json.tpl. Mirrors the +# `from_scope_configuration` block in tofu-modules' scope_definition +# module — the JSON file is the canonical declaration of the provider's +# metadata (name, icon, category) and its config schema. +# +# 2. module.scope_configuration (for_each = var.instances) +# One concrete instance per entry in var.instances, each with its own +# NRN, dimensions, and KMS key. This is the per-account / per-environment +# knob: operators can give prod its own KMS key, install the provider only +# under selected accounts, or both. Delegates to the upstream +# `nullplatform/scope_configuration` module. +################################################################################ + +locals { + # The configuration template uses gomplate-style `{{ env.Getenv "NRN" }}` for + # `visible_to` because it's also consumed by non-tofu install paths. The only + # token in the file is NRN, so we replace it inline rather than pulling in + # gomplate as a build dependency. + template_path = "${path.module}/../aws-secrets-manager-configuration.json.tpl" + template_raw = file(local.template_path) + template_rendered = replace(local.template_raw, "{{ env.Getenv \"NRN\" }}", var.nrn) + config = jsondecode(local.template_rendered) + + # The spec must be visible to the anchor NRN and to every NRN where an + # instance lives — otherwise the instance can't reference its own spec. + instance_nrns = distinct([for _, inst in var.instances : inst.nrn]) + spec_visible_to = distinct(concat( + [var.nrn], + local.instance_nrns, + var.extra_visible_to_nrns, + )) +} + +resource "nullplatform_provider_specification" "this" { + name = local.config.name + icon = local.config.icon + description = local.config.description + category = local.config.category + allow_dimensions = local.config.allow_dimensions + visible_to = local.spec_visible_to + schema = jsonencode(local.config.schema) +} + +module "scope_configuration" { + for_each = var.instances + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_configuration?ref=v4.5.1" + + nrn = each.value.nrn + np_api_key = var.np_api_key + provider_specification_slug = local.config.slug + dimensions = each.value.dimensions + + attributes = { + kms_key_id = each.value.kms_key_id + } + + depends_on = [nullplatform_provider_specification.this] +} diff --git a/parameters/providers/aws-secrets-manager/install/outputs.tf b/parameters/providers/aws-secrets-manager/install/outputs.tf new file mode 100644 index 00000000..10ea891b --- /dev/null +++ b/parameters/providers/aws-secrets-manager/install/outputs.tf @@ -0,0 +1,4 @@ +output "specification_id" { + description = "ID of the nullplatform_provider_specification created from aws-secrets-manager-configuration.json.tpl." + value = nullplatform_provider_specification.this.id +} diff --git a/parameters/providers/aws-secrets-manager/install/terraform.tfvars.example b/parameters/providers/aws-secrets-manager/install/terraform.tfvars.example new file mode 100644 index 00000000..667b94ae --- /dev/null +++ b/parameters/providers/aws-secrets-manager/install/terraform.tfvars.example @@ -0,0 +1,17 @@ +nrn = "organization=1" +np_api_key = "REPLACE_ME" + +extra_visible_to_nrns = [] + +instances = { + prod-billing = { + nrn = "organization=1:account=2:namespace=3" + dimensions = { environment = "production" } + kms_key_id = "alias/parameters-prod-billing" + } + staging-billing = { + nrn = "organization=1:account=2:namespace=3" + dimensions = { environment = "staging" } + kms_key_id = "" + } +} diff --git a/parameters/providers/aws-secrets-manager/install/variables.tf b/parameters/providers/aws-secrets-manager/install/variables.tf new file mode 100644 index 00000000..d8374649 --- /dev/null +++ b/parameters/providers/aws-secrets-manager/install/variables.tf @@ -0,0 +1,26 @@ +variable "nrn" { + description = "NRN where the provider specification is anchored (the top-level scope it belongs to)." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the upstream scope_configuration module to register provider instances." + type = string + sensitive = true +} + +variable "extra_visible_to_nrns" { + description = "Additional NRNs that should see the provider specification besides var.nrn and the per-instance NRNs." + type = list(string) + default = [] +} + +variable "instances" { + description = "Provider instances to create. Map key is a stable identifier (used in for_each). Each entry carries its own NRN, dimensions, and KMS key — so operators can install Secrets Manager only on selected accounts/environments and use a different KMS key per instance." + type = map(object({ + nrn = string + dimensions = map(string) + kms_key_id = string + })) + default = {} +} diff --git a/parameters/providers/aws-secrets-manager/install/versions.tf b/parameters/providers/aws-secrets-manager/install/versions.tf new file mode 100644 index 00000000..5a677bcb --- /dev/null +++ b/parameters/providers/aws-secrets-manager/install/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + nullplatform = { + source = "nullplatform/nullplatform" + version = ">= 0.0.95" + } + } +} diff --git a/parameters/providers/azure-key-vault/install/main.tf b/parameters/providers/azure-key-vault/install/main.tf new file mode 100644 index 00000000..410e2e14 --- /dev/null +++ b/parameters/providers/azure-key-vault/install/main.tf @@ -0,0 +1,55 @@ +################################################################################ +# Azure Key Vault — install module +# +# Two responsibilities, one source of truth: +# +# 1. nullplatform_provider_specification.this +# Created from ../azure-key-vault-configuration.json.tpl. The JSON file +# is the canonical declaration of the provider's metadata and its config +# schema (vault_name). +# +# 2. module.scope_configuration (for_each = var.instances) +# One concrete instance per entry in var.instances, each with its own +# NRN, dimensions, and Key Vault name — so operators can route different +# accounts/environments to different Key Vaults. +################################################################################ + +locals { + template_path = "${path.module}/../azure-key-vault-configuration.json.tpl" + template_raw = file(local.template_path) + template_rendered = replace(local.template_raw, "{{ env.Getenv \"NRN\" }}", var.nrn) + config = jsondecode(local.template_rendered) + + instance_nrns = distinct([for _, inst in var.instances : inst.nrn]) + spec_visible_to = distinct(concat( + [var.nrn], + local.instance_nrns, + var.extra_visible_to_nrns, + )) +} + +resource "nullplatform_provider_specification" "this" { + name = local.config.name + icon = local.config.icon + description = local.config.description + category = local.config.category + allow_dimensions = local.config.allow_dimensions + visible_to = local.spec_visible_to + schema = jsonencode(local.config.schema) +} + +module "scope_configuration" { + for_each = var.instances + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_configuration?ref=v4.5.1" + + nrn = each.value.nrn + np_api_key = var.np_api_key + provider_specification_slug = local.config.slug + dimensions = each.value.dimensions + + attributes = { + vault_name = each.value.vault_name + } + + depends_on = [nullplatform_provider_specification.this] +} diff --git a/parameters/providers/azure-key-vault/install/outputs.tf b/parameters/providers/azure-key-vault/install/outputs.tf new file mode 100644 index 00000000..106a13a1 --- /dev/null +++ b/parameters/providers/azure-key-vault/install/outputs.tf @@ -0,0 +1,4 @@ +output "specification_id" { + description = "ID of the nullplatform_provider_specification created from azure-key-vault-configuration.json.tpl." + value = nullplatform_provider_specification.this.id +} diff --git a/parameters/providers/azure-key-vault/install/terraform.tfvars.example b/parameters/providers/azure-key-vault/install/terraform.tfvars.example new file mode 100644 index 00000000..d193cee2 --- /dev/null +++ b/parameters/providers/azure-key-vault/install/terraform.tfvars.example @@ -0,0 +1,17 @@ +nrn = "organization=acme-1255165411:account=prod-95118862" +np_api_key = "REPLACE_ME" + +extra_visible_to_nrns = [] + +instances = { + prod-billing = { + nrn = "organization=acme-1255165411:account=prod-95118862:namespace=billing-37094320" + dimensions = { environment = "production" } + vault_name = "acme-prod-billing-kv" + } + staging-billing = { + nrn = "organization=acme-1255165411:account=staging-95118863:namespace=billing-37094320" + dimensions = { environment = "staging" } + vault_name = "acme-staging-billing-kv" + } +} diff --git a/parameters/providers/azure-key-vault/install/variables.tf b/parameters/providers/azure-key-vault/install/variables.tf new file mode 100644 index 00000000..634c613e --- /dev/null +++ b/parameters/providers/azure-key-vault/install/variables.tf @@ -0,0 +1,26 @@ +variable "nrn" { + description = "NRN where the provider specification is anchored (the top-level scope it belongs to)." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the upstream scope_configuration module to register provider instances." + type = string + sensitive = true +} + +variable "extra_visible_to_nrns" { + description = "Additional NRNs that should see the provider specification besides var.nrn and the per-instance NRNs." + type = list(string) + default = [] +} + +variable "instances" { + description = "Provider instances to create. Map key is a stable identifier (used in for_each). Each entry carries its own NRN, dimensions, and Azure Key Vault name (without https:// or .vault.azure.net suffix)." + type = map(object({ + nrn = string + dimensions = map(string) + vault_name = string + })) + default = {} +} diff --git a/parameters/providers/azure-key-vault/install/versions.tf b/parameters/providers/azure-key-vault/install/versions.tf new file mode 100644 index 00000000..5a677bcb --- /dev/null +++ b/parameters/providers/azure-key-vault/install/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + nullplatform = { + source = "nullplatform/nullplatform" + version = ">= 0.0.95" + } + } +} diff --git a/parameters/providers/hashicorp-vault/install/main.tf b/parameters/providers/hashicorp-vault/install/main.tf new file mode 100644 index 00000000..506a54fe --- /dev/null +++ b/parameters/providers/hashicorp-vault/install/main.tf @@ -0,0 +1,55 @@ +################################################################################ +# HashiCorp Vault — install module +# +# Two responsibilities, one source of truth: +# +# 1. nullplatform_provider_specification.this +# Created from ../hashicorp-vault-configuration.json.tpl. The JSON file +# is the canonical declaration of the provider's metadata and its config +# schema (address). +# +# 2. module.scope_configuration (for_each = var.instances) +# One concrete instance per entry in var.instances, each with its own +# NRN, dimensions, and Vault address — so operators can point different +# accounts/environments at different Vault clusters. +################################################################################ + +locals { + template_path = "${path.module}/../hashicorp-vault-configuration.json.tpl" + template_raw = file(local.template_path) + template_rendered = replace(local.template_raw, "{{ env.Getenv \"NRN\" }}", var.nrn) + config = jsondecode(local.template_rendered) + + instance_nrns = distinct([for _, inst in var.instances : inst.nrn]) + spec_visible_to = distinct(concat( + [var.nrn], + local.instance_nrns, + var.extra_visible_to_nrns, + )) +} + +resource "nullplatform_provider_specification" "this" { + name = local.config.name + icon = local.config.icon + description = local.config.description + category = local.config.category + allow_dimensions = local.config.allow_dimensions + visible_to = local.spec_visible_to + schema = jsonencode(local.config.schema) +} + +module "scope_configuration" { + for_each = var.instances + source = "git::https://github.com/nullplatform/tofu-modules.git//nullplatform/scope_configuration?ref=v4.5.1" + + nrn = each.value.nrn + np_api_key = var.np_api_key + provider_specification_slug = local.config.slug + dimensions = each.value.dimensions + + attributes = { + address = each.value.address + } + + depends_on = [nullplatform_provider_specification.this] +} diff --git a/parameters/providers/hashicorp-vault/install/outputs.tf b/parameters/providers/hashicorp-vault/install/outputs.tf new file mode 100644 index 00000000..65391672 --- /dev/null +++ b/parameters/providers/hashicorp-vault/install/outputs.tf @@ -0,0 +1,4 @@ +output "specification_id" { + description = "ID of the nullplatform_provider_specification created from hashicorp-vault-configuration.json.tpl." + value = nullplatform_provider_specification.this.id +} diff --git a/parameters/providers/hashicorp-vault/install/terraform.tfvars.example b/parameters/providers/hashicorp-vault/install/terraform.tfvars.example new file mode 100644 index 00000000..fe975a8a --- /dev/null +++ b/parameters/providers/hashicorp-vault/install/terraform.tfvars.example @@ -0,0 +1,17 @@ +nrn = "organization=acme-1255165411:account=prod-95118862" +np_api_key = "REPLACE_ME" + +extra_visible_to_nrns = [] + +instances = { + prod-billing = { + nrn = "organization=acme-1255165411:account=prod-95118862:namespace=billing-37094320" + dimensions = { environment = "production" } + address = "https://vault.prod.example.com:8200" + } + staging-billing = { + nrn = "organization=acme-1255165411:account=staging-95118863:namespace=billing-37094320" + dimensions = { environment = "staging" } + address = "https://vault.staging.example.com:8200" + } +} diff --git a/parameters/providers/hashicorp-vault/install/variables.tf b/parameters/providers/hashicorp-vault/install/variables.tf new file mode 100644 index 00000000..81206484 --- /dev/null +++ b/parameters/providers/hashicorp-vault/install/variables.tf @@ -0,0 +1,26 @@ +variable "nrn" { + description = "NRN where the provider specification is anchored (the top-level scope it belongs to)." + type = string +} + +variable "np_api_key" { + description = "nullplatform API key used by the upstream scope_configuration module to register provider instances." + type = string + sensitive = true +} + +variable "extra_visible_to_nrns" { + description = "Additional NRNs that should see the provider specification besides var.nrn and the per-instance NRNs." + type = list(string) + default = [] +} + +variable "instances" { + description = "Provider instances to create. Map key is a stable identifier (used in for_each). Each entry carries its own NRN, dimensions, and Vault HTTP(S) endpoint." + type = map(object({ + nrn = string + dimensions = map(string) + address = string + })) + default = {} +} diff --git a/parameters/providers/hashicorp-vault/install/versions.tf b/parameters/providers/hashicorp-vault/install/versions.tf new file mode 100644 index 00000000..5a677bcb --- /dev/null +++ b/parameters/providers/hashicorp-vault/install/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + nullplatform = { + source = "nullplatform/nullplatform" + version = ">= 0.0.95" + } + } +} From 0d6ea9d676e5130afc848647820fa7bb78d63573 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Thu, 25 Jun 2026 14:14:24 -0300 Subject: [PATCH 18/41] fix(parameters): read action from $CONTEXT.action, not from NOTIFICATION_ACTION env var --- parameters/entrypoint | 5 ++- parameters/tests/entrypoint.bats | 63 +++++++++++++++----------------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/parameters/entrypoint b/parameters/entrypoint index 69615ae0..04dcb53d 100755 --- a/parameters/entrypoint +++ b/parameters/entrypoint @@ -21,11 +21,12 @@ CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//") export NP_ACTION_CONTEXT="$CLEAN_CONTEXT" export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification') -IFS=':' read -ra ACTION_PARTS <<< "${NOTIFICATION_ACTION:-}" +NOTIFICATION_ACTION=$(echo "$CONTEXT" | jq -r '.action // empty') +IFS=':' read -ra ACTION_PARTS <<< "$NOTIFICATION_ACTION" ACTION_TO_EXECUTE="${ACTION_PARTS[1]:-}" if [ -z "$ACTION_TO_EXECUTE" ]; then - echo "❌ NOTIFICATION_ACTION is missing the action part (expected 'parameter:')" >&2 + echo "❌ CONTEXT.action is missing the action part (got '$NOTIFICATION_ACTION', expected 'parameter:')" >&2 exit 1 fi diff --git a/parameters/tests/entrypoint.bats b/parameters/tests/entrypoint.bats index 7304b009..d0692166 100644 --- a/parameters/tests/entrypoint.bats +++ b/parameters/tests/entrypoint.bats @@ -1,7 +1,8 @@ #!/usr/bin/env bats # ============================================================================= -# Unit tests for parameters/entrypoint — workflow routing by action -# Kind discrimination is NOT done at this layer (see build_context.bats). +# Unit tests for parameters/entrypoint — the action router. +# Action comes from $CONTEXT.action (i.e. NP_ACTION_CONTEXT.notification.action), +# NOT from a separate env var. # ============================================================================= setup() { @@ -11,15 +12,15 @@ setup() { source "$PROJECT_ROOT/testing/assertions.sh" export SCRIPT="$PARAMETERS_DIR/entrypoint" - - # Build a fake SERVICE_PATH with a mirror of parameters/workflows/ export SERVICE_PATH="$BATS_TEST_TMPDIR/service" + + # Stage a fake parameters/workflows/ with the expected action workflows. mkdir -p "$SERVICE_PATH/parameters/workflows" - for wf in store retrieve delete notify; do - : > "$SERVICE_PATH/parameters/workflows/$wf.yaml" + for action in store retrieve delete notify; do + : > "$SERVICE_PATH/parameters/workflows/$action.yaml" done - # Mock `np` so eval $CMD echoes the command to stdout instead of calling the real CLI + # np mock — echo args so we can assert what entrypoint invoked. cat > "$BATS_TEST_TMPDIR/np" << 'EOF' #!/bin/bash echo "$@" @@ -29,7 +30,7 @@ EOF } teardown() { - unset NP_ACTION_CONTEXT NOTIFICATION_ACTION OVERRIDES_PATH SERVICE_PATH + unset NP_ACTION_CONTEXT OVERRIDES_PATH SERVICE_PATH } @test "entrypoint: fails when NP_ACTION_CONTEXT is empty" { @@ -41,19 +42,26 @@ teardown() { assert_contains "$output" "❌ NP_ACTION_CONTEXT is not set" } -@test "entrypoint: fails when NOTIFICATION_ACTION has no action part" { +@test "entrypoint: fails when CONTEXT.action has no action part" { + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter","secret":true}}' + + run bash "$SCRIPT" + + [ "$status" -ne 0 ] + assert_contains "$output" "❌ CONTEXT.action is missing the action part" +} + +@test "entrypoint: fails when CONTEXT.action is absent" { export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' - export NOTIFICATION_ACTION="parameter" run bash "$SCRIPT" [ "$status" -ne 0 ] - assert_contains "$output" "❌ NOTIFICATION_ACTION is missing the action part" + assert_contains "$output" "❌ CONTEXT.action is missing the action part" } @test "entrypoint: store action routes to store.yaml" { - export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' - export NOTIFICATION_ACTION="parameter:store" + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":true}}' run bash "$SCRIPT" @@ -62,8 +70,7 @@ teardown() { } @test "entrypoint: retrieve action routes to retrieve.yaml" { - export NP_ACTION_CONTEXT='{"notification":{"secret":true,"external_id":"abc"}}' - export NOTIFICATION_ACTION="parameter:retrieve" + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:retrieve","secret":true,"external_id":"abc"}}' run bash "$SCRIPT" @@ -72,8 +79,7 @@ teardown() { } @test "entrypoint: delete action routes to delete.yaml" { - export NP_ACTION_CONTEXT='{"notification":{"secret":false,"external_id":"abc"}}' - export NOTIFICATION_ACTION="parameter:delete" + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:delete","secret":false,"external_id":"abc"}}' run bash "$SCRIPT" @@ -82,8 +88,7 @@ teardown() { } @test "entrypoint: notify action routes to notify.yaml" { - export NP_ACTION_CONTEXT='{"notification":{"secret":true,"external_id":"abc"}}' - export NOTIFICATION_ACTION="parameter:notify" + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:notify","secret":true,"external_id":"abc"}}' run bash "$SCRIPT" @@ -92,25 +97,20 @@ teardown() { } @test "entrypoint: payload's .secret value does not affect routing" { - # Run with secret=true - export NOTIFICATION_ACTION="parameter:store" - export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":true}}' run bash "$SCRIPT" assert_equal "$status" "0" output_true="$output" - # Run with secret=false - export NP_ACTION_CONTEXT='{"notification":{"secret":false}}' + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":false}}' run bash "$SCRIPT" assert_equal "$status" "0" - # Both route to the same workflow path assert_equal "$output" "$output_true" } @test "entrypoint: fails when no matching workflow exists" { - export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' - export NOTIFICATION_ACTION="parameter:nonexistent" + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:nonexistent","secret":true}}' run bash "$SCRIPT" @@ -119,8 +119,7 @@ teardown() { } @test "entrypoint: strips surrounding single quotes from NP_ACTION_CONTEXT" { - export NP_ACTION_CONTEXT="'{\"notification\":{\"secret\":true}}'" - export NOTIFICATION_ACTION="parameter:store" + export NP_ACTION_CONTEXT="'{\"notification\":{\"action\":\"parameter:store\",\"secret\":true}}'" run bash "$SCRIPT" @@ -129,8 +128,7 @@ teardown() { } @test "entrypoint: OVERRIDES_PATH appends --overrides for matching path" { - export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' - export NOTIFICATION_ACTION="parameter:store" + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":true}}' mkdir -p "$BATS_TEST_TMPDIR/override1/parameters/workflows" : > "$BATS_TEST_TMPDIR/override1/parameters/workflows/store.yaml" @@ -143,8 +141,7 @@ teardown() { } @test "entrypoint: OVERRIDES_PATH skips paths without the workflow file" { - export NP_ACTION_CONTEXT='{"notification":{"secret":true}}' - export NOTIFICATION_ACTION="parameter:store" + export NP_ACTION_CONTEXT='{"notification":{"action":"parameter:store","secret":true}}' mkdir -p "$BATS_TEST_TMPDIR/empty_override" export OVERRIDES_PATH="$BATS_TEST_TMPDIR/empty_override" From 600fb691fdead32df6597fad03208205a5ff7de1 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Thu, 25 Jun 2026 14:27:15 -0300 Subject: [PATCH 19/41] fix(parameters): build NRN from CONTEXT.entities/value_entities, not from CONTEXT.scope.nrn --- parameters/tests/utils/assume_role_step.bats | 209 ++++++++++++------- parameters/utils/assume_role_step | 49 +++-- 2 files changed, 169 insertions(+), 89 deletions(-) diff --git a/parameters/tests/utils/assume_role_step.bats b/parameters/tests/utils/assume_role_step.bats index cc880366..60178c96 100644 --- a/parameters/tests/utils/assume_role_step.bats +++ b/parameters/tests/utils/assume_role_step.bats @@ -4,13 +4,14 @@ bats_require_minimum_version 1.5.0 # Unit tests for parameters/utils/assume_role_step — orchestrates: # caller sets ASSUME_ROLE_SELECTOR + ASSUME_ROLE_OVERRIDE_ENV + # ASSUME_ROLE_DEFAULT_ENV → -# resolve NRN + dimensions from CONTEXT → +# build NRN from CONTEXT.entities or CONTEXT.value_entities → +# resolve dimensions from CONTEXT.dimensions (fallback: np scope read) → # np provider list (identity-access-control) → # resolve ARN via lib → # source assume_role (sts:AssumeRole). # # Provider-agnostic — the same step is sourced by aws-secrets-manager AND -# parameter_store with different selector + env names. +# aws-parameter-store with different selector + env names. # ============================================================================= setup() { @@ -55,23 +56,38 @@ teardown() { AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN } +# Build a CONTEXT mimicking the real platform notification body: +# make_ctx [dims_json] [scope_id] +# dims_json — '{}' or '{"key":"val",...}'; empty/"" = no .dimensions +# scope_id — when non-empty, payload uses value_entities (with scope) +# instead of entities (so NRN gets the scope segment) make_ctx() { - local nrn="$1" dims="$2" scope_id="$3" - jq -nc \ - --arg nrn "$nrn" \ - --argjson dims "${dims:-null}" \ - --arg scope_id "$scope_id" \ - '{scope:{nrn:$nrn, id:$scope_id}, dimensions:$dims}' + local dims="$1" scope_id="$2" + local dims_arg="" + if [ -n "$dims" ]; then + dims_arg="$dims" + else + dims_arg="null" + fi + if [ -n "$scope_id" ]; then + jq -nc --arg s "$scope_id" --argjson dims "$dims_arg" \ + '{value_entities:{organization:"1255165411",account:"95118862",namespace:"1249051863",application:"2132488335",scope:$s}, dimensions:$dims}' + else + jq -nc --argjson dims "$dims_arg" \ + '{entities:{organization:"1255165411",account:"95118862",namespace:"1249051863",application:"2132488335"}, dimensions:$dims}' + fi } -# Standard caller config for "aws-secrets-manager"-style tests. +# Caller bindings for each AWS provider's setup. SM_CALLER='ASSUME_ROLE_SELECTOR=secret_manager; ASSUME_ROLE_OVERRIDE_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN; ASSUME_ROLE_DEFAULT_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT' +PS_CALLER='ASSUME_ROLE_SELECTOR=parameter_store; ASSUME_ROLE_OVERRIDE_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN; ASSUME_ROLE_DEFAULT_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT' + +APP_NRN='organization=1255165411:account=95118862:namespace=1249051863:application=2132488335' # ---- Contract: caller must set the three required vars --------------------- @test "step: fails fast when ASSUME_ROLE_SELECTOR is missing" { export CONTEXT='{}' - unset ASSUME_ROLE_SELECTOR run -127 bash -c " export PATH=$BIN_DIR:\$PATH @@ -109,151 +125,192 @@ SM_CALLER='ASSUME_ROLE_SELECTOR=secret_manager; ASSUME_ROLE_OVERRIDE_ENV=SECRET_ assert_contains "$output" "ASSUME_ROLE_DEFAULT_ENV must be set" } -# ---- Resolution flow ------------------------------------------------------- +# ---- NRN construction ------------------------------------------------------ -@test "step: secret_manager caller — override env wins, np is not called" { - export CONTEXT="$(make_ctx "" "" "")" # no NRN → no np lookup anyway - export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:from-env-sm" +@test "step: app-level — NRN from entities (no scope segment)" { + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +echo "np $*" >> "$NP_INVOKED_LOG" +echo '{"results":[]}' +EOF + chmod +x "$BIN_DIR/np" + + export CONTEXT="$(make_ctx '' '')" # entities only, no dimensions, no scope run bash -c " export PATH=$BIN_DIR:\$PATH $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT - echo ARN=\$ASSUME_ROLE_ARN_RESOLVED " assert_equal "$status" "0" - assert_contains "$output" "ARN=arn:from-env-sm" - run cat "$AWS_INVOKED_LOG" - assert_contains "$output" "--role-arn arn:from-env-sm" + run cat "$NP_INVOKED_LOG" + assert_contains "$output" "--nrn $APP_NRN" + case "$output" in *"--dimensions"*) return 1 ;; esac + case "$output" in *":scope="*) return 1 ;; esac } -@test "step: parameter_store caller — uses ITS OWN env var (not secret_manager's)" { - export CONTEXT="$(make_ctx "" "" "")" - export PARAMETER_STORE_ASSUME_ROLE_ARN="arn:ps-env" - # secret_manager's env is also set — must be IGNORED - export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:sm-MUST-NOT-BE-USED" +@test "step: dimension-level — NRN from entities + dimensions passed to np" { + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +echo "np $*" >> "$NP_INVOKED_LOG" +echo '{"results":[]}' +EOF + chmod +x "$BIN_DIR/np" + + export CONTEXT="$(make_ctx '{"country":"argentina","site":"aws-main"}' '')" run bash -c " export PATH=$BIN_DIR:\$PATH - ASSUME_ROLE_SELECTOR=parameter_store - ASSUME_ROLE_OVERRIDE_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN - ASSUME_ROLE_DEFAULT_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT + $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT - echo ARN=\$ASSUME_ROLE_ARN_RESOLVED " - assert_contains "$output" "ARN=arn:ps-env" - run cat "$AWS_INVOKED_LOG" - assert_contains "$output" "--role-arn arn:ps-env" + assert_equal "$status" "0" + run cat "$NP_INVOKED_LOG" + assert_contains "$output" "--nrn $APP_NRN" + assert_contains "$output" "--dimensions country:argentina,site:aws-main" + case "$output" in *":scope="*) return 1 ;; esac } -@test "step: NRN-only lookup (no dimensions) — np called without --dimensions" { +@test "step: scope-level — NRN from value_entities (includes :scope=… segment)" { cat > "$BIN_DIR/np" << 'EOF' #!/bin/bash echo "np $*" >> "$NP_INVOKED_LOG" -cat << 'JSON' -{"results":[{"id":"prov-1","attributes":{"iam_role_arns":{"arns":[ - {"selector":"secret_manager","arn":"arn:from-provider"} -]}}}]} -JSON +echo '{"results":[]}' EOF chmod +x "$BIN_DIR/np" - export CONTEXT="$(make_ctx "organization=acme:account=prod" "" "")" + export CONTEXT="$(make_ctx '' '601620319')" # scope-level, no dimensions run bash -c " export PATH=$BIN_DIR:\$PATH $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT - echo ARN=\$ASSUME_ROLE_ARN_RESOLVED " - assert_contains "$output" "ARN=arn:from-provider" + assert_equal "$status" "0" run cat "$NP_INVOKED_LOG" - assert_contains "$output" "provider list --categories identity-access-control --nrn organization=acme:account=prod --format json" - case "$output" in *"--dimensions"*) return 1 ;; esac + assert_contains "$output" "--nrn $APP_NRN:scope=601620319" } -@test "step: parameter_store selector picks parameter_store ARN from provider" { +@test "step: scope-level without top-level dimensions → np scope read fallback" { cat > "$BIN_DIR/np" << 'EOF' #!/bin/bash -cat << 'JSON' -{"results":[{"id":"p","attributes":{"iam_role_arns":{"arns":[ - {"selector":"secret_manager","arn":"arn:sm"}, - {"selector":"parameter_store","arn":"arn:ps"} -]}}}]} -JSON +echo "np $*" >> "$NP_INVOKED_LOG" +if [ "$1" = "scope" ] && [ "$2" = "read" ]; then + echo '{"environment":"staging"}' +else + echo '{"results":[]}' +fi EOF chmod +x "$BIN_DIR/np" - export CONTEXT="$(make_ctx "org=acme" "" "")" + export CONTEXT="$(make_ctx '' '601620319')" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + $SM_CALLER + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + run cat "$NP_INVOKED_LOG" + assert_contains "$output" "scope read --id 601620319 --format json --query .dimensions" + assert_contains "$output" "--dimensions environment:staging" +} + +# ---- ARN resolution -------------------------------------------------------- + +@test "step: secret_manager caller — override env wins, np is not called" { + export CONTEXT='{}' # no entities → no IAM lookup, only env override matters + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:from-env-sm" run bash -c " export PATH=$BIN_DIR:\$PATH - ASSUME_ROLE_SELECTOR=parameter_store - ASSUME_ROLE_OVERRIDE_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN - ASSUME_ROLE_DEFAULT_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT + $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT echo ARN=\$ASSUME_ROLE_ARN_RESOLVED " - assert_contains "$output" "ARN=arn:ps" + assert_equal "$status" "0" + assert_contains "$output" "ARN=arn:from-env-sm" + run cat "$AWS_INVOKED_LOG" + assert_contains "$output" "--role-arn arn:from-env-sm" } -@test "step: CONTEXT.dimensions are passed as dim1:val1,dim2:val2 to np" { +@test "step: parameter_store caller — uses ITS OWN env var (not secret_manager's)" { + export CONTEXT='{}' + export PARAMETER_STORE_ASSUME_ROLE_ARN="arn:ps-env" + export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:sm-MUST-NOT-BE-USED" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + $PS_CALLER + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED + " + + assert_contains "$output" "ARN=arn:ps-env" + run cat "$AWS_INVOKED_LOG" + assert_contains "$output" "--role-arn arn:ps-env" +} + +@test "step: provider lookup returns matching selector ARN" { cat > "$BIN_DIR/np" << 'EOF' #!/bin/bash -echo "np $*" >> "$NP_INVOKED_LOG" -echo '{"results":[]}' +cat << 'JSON' +{"results":[{"id":"prov-1","attributes":{"iam_role_arns":{"arns":[ + {"selector":"secret_manager","arn":"arn:from-provider"} +]}}}]} +JSON EOF chmod +x "$BIN_DIR/np" - export CONTEXT="$(make_ctx "org=acme" '{"environment":"prod","region":"us-east-1"}' "")" + export CONTEXT="$(make_ctx '' '')" run bash -c " export PATH=$BIN_DIR:\$PATH $SM_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED " - run cat "$NP_INVOKED_LOG" - assert_contains "$output" "--dimensions environment:prod,region:us-east-1" - assert_contains "$output" "--nrn org=acme" + assert_contains "$output" "ARN=arn:from-provider" } -@test "step: dimensions absent → fall back to np scope read" { +@test "step: parameter_store selector picks parameter_store ARN from provider" { cat > "$BIN_DIR/np" << 'EOF' #!/bin/bash -echo "np $*" >> "$NP_INVOKED_LOG" -if [ "$1" = "scope" ] && [ "$2" = "read" ]; then - echo '{"environment":"staging"}' -else - echo '{"results":[]}' -fi +cat << 'JSON' +{"results":[{"id":"p","attributes":{"iam_role_arns":{"arns":[ + {"selector":"secret_manager","arn":"arn:sm"}, + {"selector":"parameter_store","arn":"arn:ps"} +]}}}]} +JSON EOF chmod +x "$BIN_DIR/np" - export CONTEXT="$(make_ctx "org=acme" "" "scope-uuid-1")" + export CONTEXT="$(make_ctx '' '')" run bash -c " export PATH=$BIN_DIR:\$PATH - $SM_CALLER + $PS_CALLER source $PARAMETERS_DIR/utils/log source $SCRIPT + echo ARN=\$ASSUME_ROLE_ARN_RESOLVED " - run cat "$NP_INVOKED_LOG" - assert_contains "$output" "scope read --id scope-uuid-1 --format json --query .dimensions" - assert_contains "$output" "--dimensions environment:staging" + assert_contains "$output" "ARN=arn:ps" } -@test "step: no NRN → skip np lookup, no ARN, no aws call (agent creds)" { +@test "step: no entities → skip np lookup, no ARN, no aws call (agent creds)" { export CONTEXT='{}' run bash -c " @@ -272,7 +329,7 @@ EOF [ -z "$output" ] } -@test "step: session prefix flows through to assume_role" { +@test "step: session prefix flows through to assume_role with scope id" { cat > "$BIN_DIR/np" << 'EOF' #!/bin/bash cat << 'JSON' @@ -283,7 +340,7 @@ JSON EOF chmod +x "$BIN_DIR/np" - export CONTEXT="$(make_ctx "org=acme" "" "scope-7")" + export CONTEXT="$(make_ctx '' '601620319')" run bash -c " export PATH=$BIN_DIR:\$PATH @@ -294,5 +351,5 @@ EOF " run cat "$AWS_INVOKED_LOG" - assert_contains "$output" "--role-session-name np-secret-manager-scope-7" + assert_contains "$output" "--role-session-name np-secret-manager-601620319" } diff --git a/parameters/utils/assume_role_step b/parameters/utils/assume_role_step index 1c1d327b..6d9f0436 100644 --- a/parameters/utils/assume_role_step +++ b/parameters/utils/assume_role_step @@ -32,9 +32,13 @@ # `--nrn` and (when present) `--dimensions` so the platform performs the same # dimension-aware resolution it would do for k8s scopes. # +# NRN construction (organization/account/namespace/application are guaranteed): +# - value_entities present → NRN includes scope segment (`...:scope=`) +# - else → NRN is app-level (organization=…:account=…:namespace=…:application=…) +# # Dimension resolution precedence: # 1. $CONTEXT.dimensions, if it's a non-empty object → pass to np -# 2. else if $CONTEXT.scope.id exists → `np scope read --id ... --query .dimensions` +# 2. else if value_entities.scope is present → `np scope read --id ... --query .dimensions` # 3. else no `--dimensions` flag (NRN-only lookup) # # ARN resolution precedence (see resolve_assume_role_arn in assume_role_lib): @@ -52,25 +56,44 @@ source "$SCRIPT_DIR/assume_role_lib" : "${ASSUME_ROLE_OVERRIDE_ENV:?ASSUME_ROLE_OVERRIDE_ENV must be set by the caller (name of the override env var)}" : "${ASSUME_ROLE_DEFAULT_ENV:?ASSUME_ROLE_DEFAULT_ENV must be set by the caller (name of the default-fallback env var)}" -# Used by assume_role for the STS session name (informational). +# Session prefix for the STS session name (informational). export ASSUME_ROLE_SESSION_PREFIX="${ASSUME_ROLE_SESSION_PREFIX:-np-parameters}" -if [ -z "${SCOPE_ID:-}" ]; then - SCOPE_ID=$(echo "${CONTEXT:-}" | jq -r '.scope.id // empty' 2>/dev/null) - export SCOPE_ID -fi -# --- 1. Resolve NRN from CONTEXT ------------------------------------------- -NRN=$(echo "${CONTEXT:-}" | jq -r '.scope.nrn // .nrn // empty' 2>/dev/null) -if [ -z "$NRN" ]; then - log warn " ⚠️ No NRN in CONTEXT — skipping IAM provider lookup (agent credentials only)" +# --- 1. Resolve NRN from entities/value_entities --------------------------- +# Precedence: value_entities (scope-level — includes the scope segment) wins +# over entities (app/dimension-level). organization/account/namespace/application +# are guaranteed to be present in both shapes. +ENTITIES_JSON=$(echo "${CONTEXT:-}" | jq -c '.value_entities // .entities // empty' 2>/dev/null) + +if [ -z "$ENTITIES_JSON" ] || [ "$ENTITIES_JSON" = "null" ]; then + log warn " ⚠️ No entities/value_entities in CONTEXT — skipping IAM provider lookup (agent credentials only)" IAM_ATTRIBUTES="" + NRN="" else + NRN=$(printf '%s' "$ENTITIES_JSON" | jq -r ' + [ + ("organization=" + .organization), + ("account=" + .account), + ("namespace=" + .namespace), + ("application=" + .application) + ] + + (if .scope then [("scope=" + .scope)] else [] end) + | join(":")' 2>/dev/null) + + # SCOPE_ID (used by assume_role for the STS session name) only exists when + # the payload is scope-level — i.e. value_entities.scope is present. + if [ -z "${SCOPE_ID:-}" ]; then + SCOPE_ID=$(printf '%s' "$ENTITIES_JSON" | jq -r '.scope // empty' 2>/dev/null) + export SCOPE_ID + fi + # --- 2. Resolve dimensions ---------------------------------------------- + # Top-level .dimensions in the payload (dimension-level store). If absent but + # we have a scope_id, ask the platform via `np scope read`. DIMS_JSON=$(echo "${CONTEXT:-}" | jq -c '.dimensions // empty' 2>/dev/null) if [ -z "$DIMS_JSON" ] || [ "$DIMS_JSON" = "null" ] || [ "$DIMS_JSON" = "{}" ]; then - SCOPE_ID_FROM_CTX=$(echo "${CONTEXT:-}" | jq -r '.scope.id // empty' 2>/dev/null) - if [ -n "$SCOPE_ID_FROM_CTX" ]; then - DIMS_JSON=$(np scope read --id "$SCOPE_ID_FROM_CTX" --format json --query '.dimensions' 2>/dev/null || echo "") + if [ -n "${SCOPE_ID:-}" ]; then + DIMS_JSON=$(np scope read --id "$SCOPE_ID" --format json --query '.dimensions' 2>/dev/null || echo "") fi fi From 2af0da6988336a68f61444c6671bc26d6e28b9be Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Thu, 25 Jun 2026 14:44:48 -0300 Subject: [PATCH 20/41] fix(parameters): build_external_id reads value_entities for scope-level secrets --- .../providers/aws-secrets-manager/store.bats | 31 +++++++++++++++++++ parameters/utils/build_external_id | 5 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/parameters/tests/providers/aws-secrets-manager/store.bats b/parameters/tests/providers/aws-secrets-manager/store.bats index b0ee55d2..4c177e65 100644 --- a/parameters/tests/providers/aws-secrets-manager/store.bats +++ b/parameters/tests/providers/aws-secrets-manager/store.bats @@ -30,6 +30,7 @@ case "$entity_type" in account) echo "\"prod\"" ;; namespace) echo "\"billing\"" ;; application) echo "\"api\"" ;; + scope) echo "\"staging\"" ;; *) echo "\"unknown\"" ;; esac EOF @@ -104,6 +105,36 @@ EOF assert_equal "$external_id" "$expected" } +@test "aws-secrets-manager store: scope-level value uses value_entities and includes scope segment" { + # Scope-level payload: value_entities carries the scope id; .entities is the + # app-level shape and must be IGNORED when value_entities is present. + export CONTEXT='{ + "parameter_id": 42, + "parameter_name": "DB_PASSWORD", + "value": "my-secret", + "entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625" + }, + "value_entities": { + "organization": "1255165411", + "account": "95118862", + "namespace": "37094320", + "application": "321402625", + "scope": "601620319" + } + }' + + run bash -c "$DEPS; source $SCRIPT" + + assert_equal "$status" "0" + external_id=$(echo "$output" | jq -r '.external_id') + expected="organization=acme-1255165411/account=prod-95118862/namespace=billing-37094320/application=api-321402625/scope=staging-601620319/DB_PASSWORD-42#v1-create-uuid" + assert_equal "$external_id" "$expected" +} + @test "aws-secrets-manager store: secret_name uses nullplatform/ prefix" { run bash -c "$DEPS; source $SCRIPT" diff --git a/parameters/utils/build_external_id b/parameters/utils/build_external_id index 43195a5f..197cdb2a 100755 --- a/parameters/utils/build_external_id +++ b/parameters/utils/build_external_id @@ -29,8 +29,11 @@ sanitize_for_path() { } build_external_id() { + # value_entities is the scope-level shape (carries the scope segment). + # entities is the app/dimension-level shape (no scope). Falling back to {} + # only matters for tests; in production at least one is always present. local entities_json - entities_json=$(echo "$CONTEXT" | jq -c '.entities // {}') + entities_json=$(echo "$CONTEXT" | jq -c '.value_entities // .entities // {}') local tmp_dir tmp_dir=$(mktemp -d) From 523d65d5f5d6a107cf31972568d182365d47149e Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Thu, 25 Jun 2026 15:38:55 -0300 Subject: [PATCH 21/41] perf(parameters): parallel np-read prefetch + timing logs (debug) --- .../providers/aws-parameter-store/store.bats | 19 +- .../providers/aws-secrets-manager/store.bats | 23 +- .../providers/azure-key-vault/store.bats | 19 +- .../providers/hashicorp-vault/store.bats | 23 +- parameters/tests/utils/assume_role_step.bats | 228 +++--------------- parameters/tests/utils/prefetch_np.bats | 185 ++++++++++++++ parameters/utils/assume_role | 2 + parameters/utils/assume_role_step | 71 +----- parameters/utils/build_context | 16 +- parameters/utils/build_external_id | 41 ++-- parameters/utils/log | 28 +++ parameters/utils/prefetch_np | 126 ++++++++++ 12 files changed, 458 insertions(+), 323 deletions(-) create mode 100644 parameters/tests/utils/prefetch_np.bats create mode 100644 parameters/utils/prefetch_np diff --git a/parameters/tests/providers/aws-parameter-store/store.bats b/parameters/tests/providers/aws-parameter-store/store.bats index 916c6d0d..5d86cf25 100644 --- a/parameters/tests/providers/aws-parameter-store/store.bats +++ b/parameters/tests/providers/aws-parameter-store/store.bats @@ -14,18 +14,13 @@ setup() { mkdir -p "$BATS_TEST_TMPDIR/bin" - cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' -#!/bin/bash -entity_type="$1" -case "$entity_type" in - organization) echo "\"acme\"" ;; - account) echo "\"prod\"" ;; - namespace) echo "\"billing\"" ;; - application) echo "\"api\"" ;; - *) echo "\"unknown\"" ;; -esac -EOF - chmod +x "$BATS_TEST_TMPDIR/bin/np" + # Pre-populate the np cache that utils/prefetch_np would normally produce. + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" + echo '{"slug":"acme"}' > "$NP_CACHE_DIR/organization.json" + echo '{"slug":"prod"}' > "$NP_CACHE_DIR/account.json" + echo '{"slug":"billing"}' > "$NP_CACHE_DIR/namespace.json" + echo '{"slug":"api"}' > "$NP_CACHE_DIR/application.json" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" cat > "$BATS_TEST_TMPDIR/bin/aws" << EOF diff --git a/parameters/tests/providers/aws-secrets-manager/store.bats b/parameters/tests/providers/aws-secrets-manager/store.bats index 4c177e65..06ffc1a6 100644 --- a/parameters/tests/providers/aws-secrets-manager/store.bats +++ b/parameters/tests/providers/aws-secrets-manager/store.bats @@ -22,19 +22,16 @@ setup() { mkdir -p "$BATS_TEST_TMPDIR/bin" - cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' -#!/bin/bash -entity_type="$1" -case "$entity_type" in - organization) echo "\"acme\"" ;; - account) echo "\"prod\"" ;; - namespace) echo "\"billing\"" ;; - application) echo "\"api\"" ;; - scope) echo "\"staging\"" ;; - *) echo "\"unknown\"" ;; -esac -EOF - chmod +x "$BATS_TEST_TMPDIR/bin/np" + # Pre-populate the np cache that utils/prefetch_np would normally produce. + # The store path uses utils/build_external_id which reads .json from + # this cache instead of calling np. + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" + echo '{"slug":"acme"}' > "$NP_CACHE_DIR/organization.json" + echo '{"slug":"prod"}' > "$NP_CACHE_DIR/account.json" + echo '{"slug":"billing"}' > "$NP_CACHE_DIR/namespace.json" + echo '{"slug":"api"}' > "$NP_CACHE_DIR/application.json" + echo '{"slug":"staging"}' > "$NP_CACHE_DIR/scope.json" export AWS_LOG="$BATS_TEST_TMPDIR/aws.log" cat > "$BATS_TEST_TMPDIR/bin/aws" << 'EOF' diff --git a/parameters/tests/providers/azure-key-vault/store.bats b/parameters/tests/providers/azure-key-vault/store.bats index 7a65adb6..b136505a 100644 --- a/parameters/tests/providers/azure-key-vault/store.bats +++ b/parameters/tests/providers/azure-key-vault/store.bats @@ -15,18 +15,13 @@ setup() { mkdir -p "$BATS_TEST_TMPDIR/bin" - cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' -#!/bin/bash -entity_type="$1" -case "$entity_type" in - organization) echo "\"acme\"" ;; - account) echo "\"prod\"" ;; - namespace) echo "\"billing\"" ;; - application) echo "\"api\"" ;; - *) echo "\"unknown\"" ;; -esac -EOF - chmod +x "$BATS_TEST_TMPDIR/bin/np" + # Pre-populate the np cache that utils/prefetch_np would normally produce. + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" + echo '{"slug":"acme"}' > "$NP_CACHE_DIR/organization.json" + echo '{"slug":"prod"}' > "$NP_CACHE_DIR/account.json" + echo '{"slug":"billing"}' > "$NP_CACHE_DIR/namespace.json" + echo '{"slug":"api"}' > "$NP_CACHE_DIR/application.json" export AZ_LOG="$BATS_TEST_TMPDIR/az.log" cat > "$BATS_TEST_TMPDIR/bin/az" << EOF diff --git a/parameters/tests/providers/hashicorp-vault/store.bats b/parameters/tests/providers/hashicorp-vault/store.bats index 35be5ff4..f71eab40 100644 --- a/parameters/tests/providers/hashicorp-vault/store.bats +++ b/parameters/tests/providers/hashicorp-vault/store.bats @@ -15,21 +15,14 @@ setup() { mkdir -p "$BATS_TEST_TMPDIR/bin" - # Mock np CLI: handles ` read --id --format json --query .slug` - cat > "$BATS_TEST_TMPDIR/bin/np" << 'EOF' -#!/bin/bash -# Args: read --id --format json --query .slug -entity_type="$1" -case "$entity_type" in - organization) echo "\"acme\"" ;; - account) echo "\"prod\"" ;; - namespace) echo "\"billing\"" ;; - application) echo "\"api\"" ;; - scope) echo "\"main\"" ;; - *) echo "\"unknown\"" ;; -esac -EOF - chmod +x "$BATS_TEST_TMPDIR/bin/np" + # Pre-populate the np cache that utils/prefetch_np would normally produce. + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" + echo '{"slug":"acme"}' > "$NP_CACHE_DIR/organization.json" + echo '{"slug":"prod"}' > "$NP_CACHE_DIR/account.json" + echo '{"slug":"billing"}' > "$NP_CACHE_DIR/namespace.json" + echo '{"slug":"api"}' > "$NP_CACHE_DIR/application.json" + echo '{"slug":"main"}' > "$NP_CACHE_DIR/scope.json" # Mock curl export CURL_LOG="$BATS_TEST_TMPDIR/curl.log" diff --git a/parameters/tests/utils/assume_role_step.bats b/parameters/tests/utils/assume_role_step.bats index 60178c96..89729d23 100644 --- a/parameters/tests/utils/assume_role_step.bats +++ b/parameters/tests/utils/assume_role_step.bats @@ -1,17 +1,12 @@ #!/usr/bin/env bats bats_require_minimum_version 1.5.0 # ============================================================================= -# Unit tests for parameters/utils/assume_role_step — orchestrates: -# caller sets ASSUME_ROLE_SELECTOR + ASSUME_ROLE_OVERRIDE_ENV + -# ASSUME_ROLE_DEFAULT_ENV → -# build NRN from CONTEXT.entities or CONTEXT.value_entities → -# resolve dimensions from CONTEXT.dimensions (fallback: np scope read) → -# np provider list (identity-access-control) → -# resolve ARN via lib → -# source assume_role (sts:AssumeRole). +# Unit tests for parameters/utils/assume_role_step. # -# Provider-agnostic — the same step is sourced by aws-secrets-manager AND -# aws-parameter-store with different selector + env names. +# After the prefetch refactor, assume_role_step does NOT call np itself. +# It reads $NP_CACHE_DIR/iam.json (pre-populated by utils/prefetch_np) and +# resolves the ARN via assume_role_lib, then sources assume_role to call +# sts:AssumeRole. NRN/dimensions/np-list assertions live in prefetch_np.bats. # ============================================================================= setup() { @@ -24,7 +19,7 @@ setup() { export BIN_DIR="$BATS_TEST_TMPDIR/bin" mkdir -p "$BIN_DIR" - # aws mock — captures sts args, returns valid creds + # aws mock — records sts args, returns valid creds. cat > "$BIN_DIR/aws" << 'EOF' #!/bin/bash echo "$@" >> "$AWS_INVOKED_LOG" @@ -36,19 +31,13 @@ EOF export AWS_INVOKED_LOG="$BATS_TEST_TMPDIR/aws-calls.log" : > "$AWS_INVOKED_LOG" - # default np mock — replaced per-test - cat > "$BIN_DIR/np" << 'EOF' -#!/bin/bash -echo "np $*" >> "$NP_INVOKED_LOG" -echo '{"results":[]}' -EOF - chmod +x "$BIN_DIR/np" - export NP_INVOKED_LOG="$BATS_TEST_TMPDIR/np-calls.log" - : > "$NP_INVOKED_LOG" + # Pre-populate NP_CACHE_DIR so prefetch_np is bypassed (and so is any np call). + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/np-cache" + mkdir -p "$NP_CACHE_DIR" } teardown() { - unset CONTEXT SCOPE_ID \ + unset CONTEXT SCOPE_ID NP_CACHE_DIR \ ASSUME_ROLE_SELECTOR ASSUME_ROLE_OVERRIDE_ENV ASSUME_ROLE_DEFAULT_ENV \ ASSUME_ROLE_SESSION_PREFIX ASSUME_ROLE_ARN_RESOLVED \ SECRET_MANAGER_ASSUME_ROLE_ARN SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT \ @@ -56,35 +45,17 @@ teardown() { AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN } -# Build a CONTEXT mimicking the real platform notification body: -# make_ctx [dims_json] [scope_id] -# dims_json — '{}' or '{"key":"val",...}'; empty/"" = no .dimensions -# scope_id — when non-empty, payload uses value_entities (with scope) -# instead of entities (so NRN gets the scope segment) -make_ctx() { - local dims="$1" scope_id="$2" - local dims_arg="" - if [ -n "$dims" ]; then - dims_arg="$dims" - else - dims_arg="null" - fi - if [ -n "$scope_id" ]; then - jq -nc --arg s "$scope_id" --argjson dims "$dims_arg" \ - '{value_entities:{organization:"1255165411",account:"95118862",namespace:"1249051863",application:"2132488335",scope:$s}, dimensions:$dims}' - else - jq -nc --argjson dims "$dims_arg" \ - '{entities:{organization:"1255165411",account:"95118862",namespace:"1249051863",application:"2132488335"}, dimensions:$dims}' - fi +write_iam_cache() { + local arns_json="$1" + jq -n --argjson arns "$arns_json" \ + '{results:[{id:"prov-1", attributes:{iam_role_arns:{arns:$arns}}}]}' \ + > "$NP_CACHE_DIR/iam.json" } -# Caller bindings for each AWS provider's setup. SM_CALLER='ASSUME_ROLE_SELECTOR=secret_manager; ASSUME_ROLE_OVERRIDE_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN; ASSUME_ROLE_DEFAULT_ENV=SECRET_MANAGER_ASSUME_ROLE_ARN_DEFAULT' PS_CALLER='ASSUME_ROLE_SELECTOR=parameter_store; ASSUME_ROLE_OVERRIDE_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN; ASSUME_ROLE_DEFAULT_ENV=PARAMETER_STORE_ASSUME_ROLE_ARN_DEFAULT' -APP_NRN='organization=1255165411:account=95118862:namespace=1249051863:application=2132488335' - -# ---- Contract: caller must set the three required vars --------------------- +# ---- Contract ------------------------------------------------------------- @test "step: fails fast when ASSUME_ROLE_SELECTOR is missing" { export CONTEXT='{}' @@ -125,109 +96,12 @@ APP_NRN='organization=1255165411:account=95118862:namespace=1249051863:applicati assert_contains "$output" "ASSUME_ROLE_DEFAULT_ENV must be set" } -# ---- NRN construction ------------------------------------------------------ - -@test "step: app-level — NRN from entities (no scope segment)" { - cat > "$BIN_DIR/np" << 'EOF' -#!/bin/bash -echo "np $*" >> "$NP_INVOKED_LOG" -echo '{"results":[]}' -EOF - chmod +x "$BIN_DIR/np" - - export CONTEXT="$(make_ctx '' '')" # entities only, no dimensions, no scope - - run bash -c " - export PATH=$BIN_DIR:\$PATH - $SM_CALLER - source $PARAMETERS_DIR/utils/log - source $SCRIPT - " - - assert_equal "$status" "0" - run cat "$NP_INVOKED_LOG" - assert_contains "$output" "--nrn $APP_NRN" - case "$output" in *"--dimensions"*) return 1 ;; esac - case "$output" in *":scope="*) return 1 ;; esac -} - -@test "step: dimension-level — NRN from entities + dimensions passed to np" { - cat > "$BIN_DIR/np" << 'EOF' -#!/bin/bash -echo "np $*" >> "$NP_INVOKED_LOG" -echo '{"results":[]}' -EOF - chmod +x "$BIN_DIR/np" - - export CONTEXT="$(make_ctx '{"country":"argentina","site":"aws-main"}' '')" - - run bash -c " - export PATH=$BIN_DIR:\$PATH - $SM_CALLER - source $PARAMETERS_DIR/utils/log - source $SCRIPT - " - - assert_equal "$status" "0" - run cat "$NP_INVOKED_LOG" - assert_contains "$output" "--nrn $APP_NRN" - assert_contains "$output" "--dimensions country:argentina,site:aws-main" - case "$output" in *":scope="*) return 1 ;; esac -} - -@test "step: scope-level — NRN from value_entities (includes :scope=… segment)" { - cat > "$BIN_DIR/np" << 'EOF' -#!/bin/bash -echo "np $*" >> "$NP_INVOKED_LOG" -echo '{"results":[]}' -EOF - chmod +x "$BIN_DIR/np" - - export CONTEXT="$(make_ctx '' '601620319')" # scope-level, no dimensions - - run bash -c " - export PATH=$BIN_DIR:\$PATH - $SM_CALLER - source $PARAMETERS_DIR/utils/log - source $SCRIPT - " - - assert_equal "$status" "0" - run cat "$NP_INVOKED_LOG" - assert_contains "$output" "--nrn $APP_NRN:scope=601620319" -} - -@test "step: scope-level without top-level dimensions → np scope read fallback" { - cat > "$BIN_DIR/np" << 'EOF' -#!/bin/bash -echo "np $*" >> "$NP_INVOKED_LOG" -if [ "$1" = "scope" ] && [ "$2" = "read" ]; then - echo '{"environment":"staging"}' -else - echo '{"results":[]}' -fi -EOF - chmod +x "$BIN_DIR/np" - - export CONTEXT="$(make_ctx '' '601620319')" - - run bash -c " - export PATH=$BIN_DIR:\$PATH - $SM_CALLER - source $PARAMETERS_DIR/utils/log - source $SCRIPT - " - - run cat "$NP_INVOKED_LOG" - assert_contains "$output" "scope read --id 601620319 --format json --query .dimensions" - assert_contains "$output" "--dimensions environment:staging" -} - -# ---- ARN resolution -------------------------------------------------------- +# ---- ARN resolution from cache ------------------------------------------- -@test "step: secret_manager caller — override env wins, np is not called" { - export CONTEXT='{}' # no entities → no IAM lookup, only env override matters +@test "step: override env wins over cached IAM provider" { + export CONTEXT='{}' export SECRET_MANAGER_ASSUME_ROLE_ARN="arn:from-env-sm" + write_iam_cache '[{"selector":"secret_manager","arn":"arn:from-cache"}]' run bash -c " export PATH=$BIN_DIR:\$PATH @@ -257,22 +131,14 @@ EOF " assert_contains "$output" "ARN=arn:ps-env" - run cat "$AWS_INVOKED_LOG" - assert_contains "$output" "--role-arn arn:ps-env" } -@test "step: provider lookup returns matching selector ARN" { - cat > "$BIN_DIR/np" << 'EOF' -#!/bin/bash -cat << 'JSON' -{"results":[{"id":"prov-1","attributes":{"iam_role_arns":{"arns":[ - {"selector":"secret_manager","arn":"arn:from-provider"} -]}}}]} -JSON -EOF - chmod +x "$BIN_DIR/np" - - export CONTEXT="$(make_ctx '' '')" +@test "step: cached IAM provider — matching selector ARN is picked" { + export CONTEXT='{}' + write_iam_cache '[ + {"selector":"containers","arn":"arn:containers"}, + {"selector":"secret_manager","arn":"arn:from-cache"} + ]' run bash -c " export PATH=$BIN_DIR:\$PATH @@ -282,22 +148,15 @@ EOF echo ARN=\$ASSUME_ROLE_ARN_RESOLVED " - assert_contains "$output" "ARN=arn:from-provider" + assert_contains "$output" "ARN=arn:from-cache" } -@test "step: parameter_store selector picks parameter_store ARN from provider" { - cat > "$BIN_DIR/np" << 'EOF' -#!/bin/bash -cat << 'JSON' -{"results":[{"id":"p","attributes":{"iam_role_arns":{"arns":[ - {"selector":"secret_manager","arn":"arn:sm"}, - {"selector":"parameter_store","arn":"arn:ps"} -]}}}]} -JSON -EOF - chmod +x "$BIN_DIR/np" - - export CONTEXT="$(make_ctx '' '')" +@test "step: parameter_store selector picks parameter_store ARN from cache" { + export CONTEXT='{}' + write_iam_cache '[ + {"selector":"secret_manager","arn":"arn:sm"}, + {"selector":"parameter_store","arn":"arn:ps"} + ]' run bash -c " export PATH=$BIN_DIR:\$PATH @@ -310,8 +169,8 @@ EOF assert_contains "$output" "ARN=arn:ps" } -@test "step: no entities → skip np lookup, no ARN, no aws call (agent creds)" { - export CONTEXT='{}' +@test "step: no iam.json in cache → empty ARN, no aws call (agent creds)" { + export CONTEXT='{}' # no iam.json created run bash -c " export PATH=$BIN_DIR:\$PATH @@ -323,24 +182,13 @@ EOF assert_equal "$status" "0" assert_contains "$output" "ARN=[]" - run cat "$NP_INVOKED_LOG" - [ -z "$output" ] run cat "$AWS_INVOKED_LOG" [ -z "$output" ] } -@test "step: session prefix flows through to assume_role with scope id" { - cat > "$BIN_DIR/np" << 'EOF' -#!/bin/bash -cat << 'JSON' -{"results":[{"id":"p","attributes":{"iam_role_arns":{"arns":[ - {"selector":"secret_manager","arn":"arn:x"} -]}}}]} -JSON -EOF - chmod +x "$BIN_DIR/np" - - export CONTEXT="$(make_ctx '' '601620319')" +@test "step: session prefix flows through to assume_role with SCOPE_ID" { + export CONTEXT='{"value_entities":{"organization":"o","account":"a","namespace":"n","application":"ap","scope":"601620319"}}' + write_iam_cache '[{"selector":"secret_manager","arn":"arn:x"}]' run bash -c " export PATH=$BIN_DIR:\$PATH diff --git a/parameters/tests/utils/prefetch_np.bats b/parameters/tests/utils/prefetch_np.bats new file mode 100644 index 00000000..62d2a7d5 --- /dev/null +++ b/parameters/tests/utils/prefetch_np.bats @@ -0,0 +1,185 @@ +#!/usr/bin/env bats +# ============================================================================= +# Unit tests for parameters/utils/prefetch_np — the parallel np-cache builder. +# +# Verifies: +# - all expected `np` reads are fired in a single wave when possible +# - scope-level payloads do a 2-wave dance (scope.json → then iam.json) +# - NRN is built locally from entities/value_entities (no api call) +# - existing NP_CACHE_DIR is honored (test/script escape hatch) +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + export PARAMETERS_DIR="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)" + + source "$PROJECT_ROOT/testing/assertions.sh" + + export SCRIPT="$PARAMETERS_DIR/utils/prefetch_np" + export BIN_DIR="$BATS_TEST_TMPDIR/bin" + mkdir -p "$BIN_DIR" + + # `np` mock — records every invocation and returns a recognizable payload + # per subcommand. Each record carries a stable `.slug` so build_external_id + # works downstream too. + export NP_LOG="$BATS_TEST_TMPDIR/np-calls.log" + : > "$NP_LOG" + cat > "$BIN_DIR/np" << 'EOF' +#!/bin/bash +echo "$*" >> "$NP_LOG" +sub="$1 $2" +case "$sub" in + "provider specification") echo '{"slug":"aws-secrets-manager"}' ;; + "provider list") echo '{"results":[{"attributes":{"iam_role_arns":{"arns":[{"selector":"secret_manager","arn":"arn:x"}]}}}]}' ;; + "organization read") echo '{"slug":"acme"}' ;; + "account read") echo '{"slug":"prod"}' ;; + "namespace read") echo '{"slug":"billing"}' ;; + "application read") echo '{"slug":"api"}' ;; + "scope read") echo '{"slug":"staging","dimensions":{"environment":"production"}}' ;; + *) echo '{}' ;; +esac +EOF + chmod +x "$BIN_DIR/np" +} + +teardown() { + unset CONTEXT SPEC_ID NP_CACHE_DIR NRN +} + +@test "prefetch_np: app-level payload — wave 1 fires spec + 4 entities + iam (no scope read)" { + export CONTEXT='{ + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "provider":{"specification_id":"spec-123"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo NRN=\$NRN + echo CACHE=\$NP_CACHE_DIR + " + + assert_equal "$status" "0" + assert_contains "$output" "NRN=organization=O:account=A:namespace=N:application=AP" + + run cat "$NP_LOG" + assert_contains "$output" "provider specification read --id spec-123 --format json" + assert_contains "$output" "organization read --id O --format json" + assert_contains "$output" "account read --id A --format json" + assert_contains "$output" "namespace read --id N --format json" + assert_contains "$output" "application read --id AP --format json" + assert_contains "$output" "provider list --categories identity-access-control --nrn organization=O:account=A:namespace=N:application=AP --format json" + # No scope read in app-level + case "$output" in *"scope read"*) return 1 ;; esac +} + +@test "prefetch_np: dimension-level — iam call carries --dimensions in wave 1" { + export CONTEXT='{ + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "dimensions":{"country":"argentina","site":"aws-main"}, + "provider":{"specification_id":"spec-123"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_LOG" + assert_contains "$output" "--dimensions country:argentina,site:aws-main" +} + +@test "prefetch_np: scope-level w/o top dims — iam deferred to wave 2 (uses scope.json dims)" { + export CONTEXT='{ + "value_entities":{"organization":"O","account":"A","namespace":"N","application":"AP","scope":"S"}, + "provider":{"specification_id":"spec-123"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + echo NRN=\$NRN + " + + assert_equal "$status" "0" + assert_contains "$output" "NRN=organization=O:account=A:namespace=N:application=AP:scope=S" + + run cat "$NP_LOG" + # Wave 1 fires scope read but NOT iam (yet) + assert_contains "$output" "scope read --id S --format json" + # Wave 2 fires iam with dims pulled from scope.json + assert_contains "$output" "--dimensions environment:production" +} + +@test "prefetch_np: scope-level WITH top dims — iam fires in wave 1 with top dims" { + export CONTEXT='{ + "value_entities":{"organization":"O","account":"A","namespace":"N","application":"AP","scope":"S"}, + "dimensions":{"country":"ar"}, + "provider":{"specification_id":"spec-123"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_LOG" + # iam call uses top-level dims, not scope.json dims + assert_contains "$output" "--dimensions country:ar" + case "$output" in *"--dimensions environment:production"*) return 1 ;; esac +} + +@test "prefetch_np: pre-set NP_CACHE_DIR is honored — no np calls fired" { + export CONTEXT='{ + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "provider":{"specification_id":"spec-123"} + }' + export SPEC_ID="spec-123" + export NP_CACHE_DIR="$BATS_TEST_TMPDIR/preset-cache" + mkdir -p "$NP_CACHE_DIR" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + " + + assert_equal "$status" "0" + run cat "$NP_LOG" + [ -z "$output" ] +} + +@test "prefetch_np: cache files are written and readable" { + export CONTEXT='{ + "entities":{"organization":"O","account":"A","namespace":"N","application":"AP"}, + "provider":{"specification_id":"spec-123"} + }' + export SPEC_ID="spec-123" + + run bash -c " + export PATH=$BIN_DIR:\$PATH + source $PARAMETERS_DIR/utils/log + source $SCRIPT + for f in spec organization account namespace application iam; do + if [ -s \"\$NP_CACHE_DIR/\$f.json\" ]; then echo \"OK \$f\"; else echo \"MISS \$f\"; fi + done + " + + assert_equal "$status" "0" + assert_contains "$output" "OK spec" + assert_contains "$output" "OK organization" + assert_contains "$output" "OK account" + assert_contains "$output" "OK namespace" + assert_contains "$output" "OK application" + assert_contains "$output" "OK iam" +} diff --git a/parameters/utils/assume_role b/parameters/utils/assume_role index 50632afe..1d6ffb09 100644 --- a/parameters/utils/assume_role +++ b/parameters/utils/assume_role @@ -20,6 +20,7 @@ if [ -n "${ASSUME_ROLE_ARN_RESOLVED:-}" ]; then _ar_session_name="${ASSUME_ROLE_SESSION_PREFIX:-np-parameters}-${SCOPE_ID:-workflow}" _ar_sts_error=$(mktemp) + T_STS=$(timer_now 2>/dev/null || echo 0) if ! ASSUMED_CREDS=$(aws sts assume-role \ --role-arn "$ASSUME_ROLE_ARN_RESOLVED" \ --role-session-name "$_ar_session_name" \ @@ -30,6 +31,7 @@ if [ -n "${ASSUME_ROLE_ARN_RESOLVED:-}" ]; then return 1 fi rm -f "$_ar_sts_error" + log debug " ⏱ sts:AssumeRole $(timer_elapsed "$T_STS" 2>/dev/null || echo "?")" _ar_access_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.AccessKeyId // ""') _ar_secret_key=$(echo "$ASSUMED_CREDS" | jq -r '.Credentials.SecretAccessKey // ""') diff --git a/parameters/utils/assume_role_step b/parameters/utils/assume_role_step index 6d9f0436..128c58b3 100644 --- a/parameters/utils/assume_role_step +++ b/parameters/utils/assume_role_step @@ -59,67 +59,22 @@ source "$SCRIPT_DIR/assume_role_lib" # Session prefix for the STS session name (informational). export ASSUME_ROLE_SESSION_PREFIX="${ASSUME_ROLE_SESSION_PREFIX:-np-parameters}" -# --- 1. Resolve NRN from entities/value_entities --------------------------- -# Precedence: value_entities (scope-level — includes the scope segment) wins -# over entities (app/dimension-level). organization/account/namespace/application -# are guaranteed to be present in both shapes. -ENTITIES_JSON=$(echo "${CONTEXT:-}" | jq -c '.value_entities // .entities // empty' 2>/dev/null) +# SCOPE_ID is used by assume_role only for the STS session-name suffix. +# Pull it from the same source NRN was built from (entities/value_entities). +if [ -z "${SCOPE_ID:-}" ]; then + SCOPE_ID=$(echo "${CONTEXT:-}" | jq -r '(.value_entities // .entities // {}).scope // empty' 2>/dev/null) + export SCOPE_ID +fi -if [ -z "$ENTITIES_JSON" ] || [ "$ENTITIES_JSON" = "null" ]; then - log warn " ⚠️ No entities/value_entities in CONTEXT — skipping IAM provider lookup (agent credentials only)" - IAM_ATTRIBUTES="" - NRN="" +# IAM provider data is pre-fetched by utils/prefetch_np into $NP_CACHE_DIR/iam.json. +# No np call here — just read the cached result. +if [ -n "${NP_CACHE_DIR:-}" ] && [ -s "$NP_CACHE_DIR/iam.json" ]; then + IAM_ATTRIBUTES=$(jq -c '.results[0].attributes // {}' < "$NP_CACHE_DIR/iam.json" 2>/dev/null) else - NRN=$(printf '%s' "$ENTITIES_JSON" | jq -r ' - [ - ("organization=" + .organization), - ("account=" + .account), - ("namespace=" + .namespace), - ("application=" + .application) - ] - + (if .scope then [("scope=" + .scope)] else [] end) - | join(":")' 2>/dev/null) - - # SCOPE_ID (used by assume_role for the STS session name) only exists when - # the payload is scope-level — i.e. value_entities.scope is present. - if [ -z "${SCOPE_ID:-}" ]; then - SCOPE_ID=$(printf '%s' "$ENTITIES_JSON" | jq -r '.scope // empty' 2>/dev/null) - export SCOPE_ID - fi - - # --- 2. Resolve dimensions ---------------------------------------------- - # Top-level .dimensions in the payload (dimension-level store). If absent but - # we have a scope_id, ask the platform via `np scope read`. - DIMS_JSON=$(echo "${CONTEXT:-}" | jq -c '.dimensions // empty' 2>/dev/null) - if [ -z "$DIMS_JSON" ] || [ "$DIMS_JSON" = "null" ] || [ "$DIMS_JSON" = "{}" ]; then - if [ -n "${SCOPE_ID:-}" ]; then - DIMS_JSON=$(np scope read --id "$SCOPE_ID" --format json --query '.dimensions' 2>/dev/null || echo "") - fi - fi - - # Convert {key:val,...} to "key:val,key2:val2" — empty/null → empty string - DIMS_ARG="" - if [ -n "$DIMS_JSON" ] && [ "$DIMS_JSON" != "null" ] && [ "$DIMS_JSON" != "{}" ]; then - DIMS_ARG=$(printf '%s' "$DIMS_JSON" | jq -r ' - to_entries - | map("\(.key):\(.value)") - | join(",")' 2>/dev/null) - fi - - # --- 3. Fetch the IAM provider (dimension-resolved by the platform) ----- - log debug " 🔍 Looking up IAM provider (NRN=$NRN${DIMS_ARG:+ dimensions=$DIMS_ARG})" - _ar_provider_err=$(mktemp) - if [ -n "$DIMS_ARG" ]; then - PROVIDER_JSON=$(np provider list --categories identity-access-control --nrn "$NRN" --dimensions "$DIMS_ARG" --format json 2>"$_ar_provider_err") || PROVIDER_JSON="" - else - PROVIDER_JSON=$(np provider list --categories identity-access-control --nrn "$NRN" --format json 2>"$_ar_provider_err") || PROVIDER_JSON="" + if [ -n "${NP_CACHE_DIR:-}" ] && [ -s "$NP_CACHE_DIR/iam.err" ]; then + log warn " ⚠️ IAM provider lookup failed during prefetch: $(cat "$NP_CACHE_DIR/iam.err")" fi - if [ -z "$PROVIDER_JSON" ]; then - log warn " ⚠️ np provider list failed: $(cat "$_ar_provider_err")" - fi - rm -f "$_ar_provider_err" - - IAM_ATTRIBUTES=$(echo "${PROVIDER_JSON:-}" | jq -c '.results[0].attributes // {}' 2>/dev/null) + IAM_ATTRIBUTES="" fi # --- 4. Resolve and export the ARN ----------------------------------------- diff --git a/parameters/utils/build_context b/parameters/utils/build_context index 513625bd..d693bb91 100755 --- a/parameters/utils/build_context +++ b/parameters/utils/build_context @@ -68,7 +68,14 @@ if [ -z "$SPEC_ID" ]; then exit 1 fi -if ! SPEC_JSON=$(np provider specification read --id "$SPEC_ID" --format json 2>&1); then +# Pre-fetch every `np` read this workflow needs, in parallel. Downstream +# (assume_role_step, build_external_id) reads from $NP_CACHE_DIR instead of +# re-invoking np. +T_BUILD_CTX=$(timer_now) +export SPEC_ID +source "$SCRIPT_DIR/prefetch_np" + +if [ ! -s "$NP_CACHE_DIR/spec.json" ]; then log error "❌ Failed to read provider specification (id=$SPEC_ID)" log error "" log error "💡 Possible causes:" @@ -79,9 +86,10 @@ if ! SPEC_JSON=$(np provider specification read --id "$SPEC_ID" --format json 2> log error "🔧 How to fix:" log error " • Verify the spec exists: np provider specification read --id $SPEC_ID" log error " • Check np auth: np whoami" - log error "Underlying output: $SPEC_JSON" + [ -s "$NP_CACHE_DIR/spec.err" ] && log error "Underlying output: $(cat "$NP_CACHE_DIR/spec.err")" exit 1 fi +SPEC_JSON=$(cat "$NP_CACHE_DIR/spec.json") ACTIVE_PROVIDER=$(echo "$SPEC_JSON" | jq -r '.slug // empty') if [ -z "$ACTIVE_PROVIDER" ]; then @@ -114,5 +122,9 @@ log debug "📦 active_provider=$ACTIVE_PROVIDER kind=$PARAMETER_KIND spec_id=$S # --- Source provider's setup for validation + connection handles --- if [ -f "$PROVIDER_DIR/setup" ]; then log debug "📡 Sourcing $ACTIVE_PROVIDER/setup" + T_SETUP=$(timer_now) source "$PROVIDER_DIR/setup" + log debug "⏱ Provider setup $(timer_elapsed "$T_SETUP")" fi + +log debug "⏱ build_context total $(timer_elapsed "$T_BUILD_CTX")" diff --git a/parameters/utils/build_external_id b/parameters/utils/build_external_id index 197cdb2a..0f945b6c 100755 --- a/parameters/utils/build_external_id +++ b/parameters/utils/build_external_id @@ -29,47 +29,47 @@ sanitize_for_path() { } build_external_id() { + local t_start + t_start=$(timer_now) + # value_entities is the scope-level shape (carries the scope segment). # entities is the app/dimension-level shape (no scope). Falling back to {} # only matters for tests; in production at least one is always present. local entities_json entities_json=$(echo "$CONTEXT" | jq -c '.value_entities // .entities // {}') - local tmp_dir - tmp_dir=$(mktemp -d) - local entity_order=("organization" "account" "namespace" "application" "scope") - local present=() + local segments=() for entity_type in "${entity_order[@]}"; do local entity_id entity_id=$(echo "$entities_json" | jq -r ".$entity_type // empty") - if [ -n "$entity_id" ]; then - present+=("$entity_type:$entity_id") - ( np "$entity_type" read --id "$entity_id" --format json --query '.slug' 2>/dev/null \ - | jq -r '. // empty' > "$tmp_dir/$entity_type" || true ) & - fi - done - wait + [ -z "$entity_id" ] && continue - local segments=() - for pair in "${present[@]}"; do - local entity_type="${pair%%:*}" - local entity_id="${pair##*:}" - if [ ! -s "$tmp_dir/$entity_type" ]; then - log error "❌ Failed to fetch slug for $entity_type=$entity_id via np CLI" + # Entity slugs are pre-fetched by utils/prefetch_np into the cache. No + # np call here — just read the JSON record and pull .slug. + local entity_file="${NP_CACHE_DIR:-}/$entity_type.json" + if [ ! -s "$entity_file" ]; then + log error "❌ Missing pre-fetched record for $entity_type=$entity_id" log error "" log error "💡 Possible causes:" log error " • Entity does not exist in nullplatform" log error " • np CLI is not authenticated" + log error " • utils/prefetch_np was not sourced before build_external_id" log error "" log error "🔧 How to fix:" log error " • Verify: np $entity_type read --id $entity_id --format json" - rm -rf "$tmp_dir" + [ -s "${NP_CACHE_DIR:-}/$entity_type.err" ] && \ + log error " • Underlying error: $(cat "${NP_CACHE_DIR}/$entity_type.err")" exit 1 fi + local slug - slug=$(cat "$tmp_dir/$entity_type") + slug=$(jq -r '.slug // empty' < "$entity_file") + if [ -z "$slug" ]; then + log error "❌ Pre-fetched $entity_type record has no .slug (id=$entity_id)" + exit 1 + fi segments+=("$entity_type=${slug}-${entity_id}") done @@ -86,9 +86,8 @@ build_external_id() { segments+=("$param_id") fi - rm -rf "$tmp_dir" - local IFS=/ EXTERNAL_ID="${segments[*]}" export EXTERNAL_ID + log debug "⏱ build_external_id $(timer_elapsed "$t_start")" } diff --git a/parameters/utils/log b/parameters/utils/log index 09fd60c2..01d05a5f 100755 --- a/parameters/utils/log +++ b/parameters/utils/log @@ -22,3 +22,31 @@ log() { *) echo "$level $msg" >&2 ;; esac } + +# Timer helpers — used to measure where time goes in a workflow run. +# Precedence at source time: +# 1. bash 5+ $EPOCHREALTIME (sub-microsecond, no fork) +# 2. GNU `date +%s.%N` (nanosecond, one fork; Linux) +# 3. integer `date +%s` (second; macOS bash 3.2 default — coarse) +# Detection happens once so timer_now stays cheap (no test per call). +if [ -n "${EPOCHREALTIME:-}" ]; then + _NP_TIMER_KIND="bash" +elif [[ "$(date +%N 2>/dev/null)" =~ ^[0-9]+$ ]]; then + _NP_TIMER_KIND="gnudate" +else + _NP_TIMER_KIND="coarse" +fi + +timer_now() { + case "$_NP_TIMER_KIND" in + bash) echo "$EPOCHREALTIME" ;; + gnudate) date +%s.%N ;; + *) date +%s ;; + esac +} + +timer_elapsed() { + local start="$1" end + end=$(timer_now) + awk -v s="$start" -v e="$end" 'BEGIN { printf "%.3fs", e - s }' +} diff --git a/parameters/utils/prefetch_np b/parameters/utils/prefetch_np new file mode 100644 index 00000000..ddcbe9ac --- /dev/null +++ b/parameters/utils/prefetch_np @@ -0,0 +1,126 @@ +#!/bin/bash +# Sourceable. Fires every `np` read this workflow will need, in parallel, +# into a single tmpdir cache. Downstream scripts (build_context, +# assume_role_step, build_external_id) read from the cache instead of +# re-invoking `np`. +# +# Why this exists: each `np` CLI invocation is ~1–2s (startup + HTTPS call to +# the platform). The store/retrieve/delete flow chains 5–9 such calls in +# series across modules. Running them in parallel turns Σ(t) into max(t) and +# typically cuts wall time by 3–6×. +# +# Inputs: +# CONTEXT — JSON of the notification body (set by entrypoint) +# SPEC_ID — $CONTEXT.provider.specification_id (set by build_context) +# +# Escape hatch for tests: if NP_CACHE_DIR is pre-set and exists, prefetch is +# skipped — tests pre-populate the cache with fixtures and source the rest of +# the chain normally without touching `np` at all. +# +# Output: +# NP_CACHE_DIR — exported; contains: +# spec.json — provider specification +# {organization,account,namespace,application}.json — entity records (slug + metadata) +# scope.json — entity record AND dimensions source (when scope-level) +# iam.json — `np provider list --categories identity-access-control` +# NRN — exported; computed locally from entities/value_entities + +: "${CONTEXT:?CONTEXT must be set before sourcing prefetch_np}" +: "${SPEC_ID:?SPEC_ID must be set before sourcing prefetch_np}" + +if [ -n "${NP_CACHE_DIR:-}" ] && [ -d "$NP_CACHE_DIR" ]; then + log debug "📦 NP_CACHE_DIR pre-set ($NP_CACHE_DIR) — skipping prefetch" + return 0 2>/dev/null || exit 0 +fi + +NP_CACHE_DIR=$(mktemp -d) +export NP_CACHE_DIR + +# value_entities (scope-level) wins over entities (app/dimension-level). +ENTITIES_JSON=$(echo "$CONTEXT" | jq -c '.value_entities // .entities // {}') + +NRN=$(printf '%s' "$ENTITIES_JSON" | jq -r ' + if (.organization // null) == null then "" else + [ + ("organization=" + .organization), + ("account=" + .account), + ("namespace=" + .namespace), + ("application=" + .application) + ] + + (if .scope then [("scope=" + .scope)] else [] end) + | join(":") + end') +export NRN + +SCOPE_ID_PREFETCH=$(printf '%s' "$ENTITIES_JSON" | jq -r '.scope // empty') + +# Top-level CONTEXT.dimensions: if present, we can fire the IAM lookup in +# wave 1. If absent and there's a scope, we need to wait for scope.json so we +# can extract its dimensions, then fire IAM in wave 2. +TOP_DIMS_JSON=$(echo "$CONTEXT" | jq -c '.dimensions // empty') +DIMS_ARG="" +if [ -n "$TOP_DIMS_JSON" ] && [ "$TOP_DIMS_JSON" != "null" ] && [ "$TOP_DIMS_JSON" != "{}" ]; then + DIMS_ARG=$(printf '%s' "$TOP_DIMS_JSON" | jq -r ' + to_entries | map("\(.key):\(.value)") | join(",")') +fi + +CAN_FIRE_IAM_WAVE1="yes" +if [ -z "$DIMS_ARG" ] && [ -n "$SCOPE_ID_PREFETCH" ]; then + # No top-level dims, and we have a scope — defer IAM until scope.json lands. + CAN_FIRE_IAM_WAVE1="no" +fi + +# ── Wave 1: spec + entities + (maybe) IAM ─────────────────────────────────── +T_PREFETCH=$(timer_now) +log debug "🚀 Prefetch wave 1 (NRN=$NRN${DIMS_ARG:+ dims=$DIMS_ARG} iam_wave1=$CAN_FIRE_IAM_WAVE1)" + +( np provider specification read --id "$SPEC_ID" --format json \ + > "$NP_CACHE_DIR/spec.json" 2>"$NP_CACHE_DIR/spec.err" ) & + +for entity_type in organization account namespace application; do + entity_id=$(printf '%s' "$ENTITIES_JSON" | jq -r ".$entity_type // empty") + if [ -n "$entity_id" ]; then + ( np "$entity_type" read --id "$entity_id" --format json \ + > "$NP_CACHE_DIR/$entity_type.json" 2>"$NP_CACHE_DIR/$entity_type.err" ) & + fi +done + +if [ -n "$SCOPE_ID_PREFETCH" ]; then + ( np scope read --id "$SCOPE_ID_PREFETCH" --format json \ + > "$NP_CACHE_DIR/scope.json" 2>"$NP_CACHE_DIR/scope.err" ) & +fi + +if [ "$CAN_FIRE_IAM_WAVE1" = "yes" ] && [ -n "$NRN" ]; then + if [ -n "$DIMS_ARG" ]; then + ( np provider list --categories identity-access-control --nrn "$NRN" --dimensions "$DIMS_ARG" --format json \ + > "$NP_CACHE_DIR/iam.json" 2>"$NP_CACHE_DIR/iam.err" ) & + else + ( np provider list --categories identity-access-control --nrn "$NRN" --format json \ + > "$NP_CACHE_DIR/iam.json" 2>"$NP_CACHE_DIR/iam.err" ) & + fi +fi + +wait +log debug "⏱ Prefetch wave 1 $(timer_elapsed "$T_PREFETCH")" + +# ── Wave 2: IAM (only if deferred above) ──────────────────────────────────── +if [ "$CAN_FIRE_IAM_WAVE1" = "no" ] && [ -n "$NRN" ]; then + if [ -s "$NP_CACHE_DIR/scope.json" ]; then + SCOPE_DIMS_JSON=$(jq -c '.dimensions // empty' < "$NP_CACHE_DIR/scope.json" 2>/dev/null || echo "") + if [ -n "$SCOPE_DIMS_JSON" ] && [ "$SCOPE_DIMS_JSON" != "null" ] && [ "$SCOPE_DIMS_JSON" != "{}" ]; then + DIMS_ARG=$(printf '%s' "$SCOPE_DIMS_JSON" | jq -r ' + to_entries | map("\(.key):\(.value)") | join(",")') + fi + fi + + T_WAVE2=$(timer_now) + log debug "🚀 Prefetch wave 2 (iam${DIMS_ARG:+ dims=$DIMS_ARG})" + if [ -n "$DIMS_ARG" ]; then + np provider list --categories identity-access-control --nrn "$NRN" --dimensions "$DIMS_ARG" --format json \ + > "$NP_CACHE_DIR/iam.json" 2>"$NP_CACHE_DIR/iam.err" || true + else + np provider list --categories identity-access-control --nrn "$NRN" --format json \ + > "$NP_CACHE_DIR/iam.json" 2>"$NP_CACHE_DIR/iam.err" || true + fi + log debug "⏱ Prefetch wave 2 $(timer_elapsed "$T_WAVE2")" +fi From 5565ce89fa7cf7c600ba80c052c5058988a58815 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Thu, 25 Jun 2026 15:39:05 -0300 Subject: [PATCH 22/41] docs(parameters): add secretsmanager:TagResource to required IAM permissions --- parameters/providers/aws-secrets-manager/docs/iam-policy.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/parameters/providers/aws-secrets-manager/docs/iam-policy.md b/parameters/providers/aws-secrets-manager/docs/iam-policy.md index 056e8160..506544fe 100644 --- a/parameters/providers/aws-secrets-manager/docs/iam-policy.md +++ b/parameters/providers/aws-secrets-manager/docs/iam-policy.md @@ -30,7 +30,8 @@ Replace `` and `` before applying. The `nullplatform "secretsmanager:CreateSecret", "secretsmanager:PutSecretValue", "secretsmanager:GetSecretValue", - "secretsmanager:DeleteSecret" + "secretsmanager:DeleteSecret", + "secretsmanager:TagResource" ], "Resource": [ "arn:aws:secretsmanager:::secret:nullplatform/*" From cafad097cb7ea4d921b75aad354942f5b191d893 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Thu, 25 Jun 2026 16:03:06 -0300 Subject: [PATCH 23/41] perf(parameters): timing logs for AWS/Vault/Azure calls + dispatch + entrypoint wall clock --- parameters/entrypoint | 13 +++++++++++- .../providers/aws-parameter-store/delete | 2 ++ .../providers/aws-parameter-store/retrieve | 2 ++ .../providers/aws-parameter-store/store | 4 ++++ .../providers/aws-secrets-manager/delete | 2 ++ .../providers/aws-secrets-manager/retrieve | 2 ++ .../providers/aws-secrets-manager/store | 5 +++++ parameters/providers/azure-key-vault/delete | 4 ++++ parameters/providers/azure-key-vault/retrieve | 2 ++ parameters/providers/azure-key-vault/store | 2 ++ parameters/providers/hashicorp-vault/delete | 2 ++ parameters/providers/hashicorp-vault/retrieve | 2 ++ parameters/providers/hashicorp-vault/store | 2 ++ parameters/tests/utils/dispatch.bats | 20 +++++++++++-------- parameters/utils/dispatch | 2 ++ 15 files changed, 57 insertions(+), 9 deletions(-) diff --git a/parameters/entrypoint b/parameters/entrypoint index 04dcb53d..9bc9e874 100755 --- a/parameters/entrypoint +++ b/parameters/entrypoint @@ -12,6 +12,10 @@ set -euo pipefail # PARAMETER_KIND; providers that branch on it (e.g. aws-parameter-store choosing # String vs SecureString) read PARAMETER_KIND directly. +ENTRYPOINT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -f "$ENTRYPOINT_DIR/utils/log" ] && source "$ENTRYPOINT_DIR/utils/log" +T_TOTAL=$(timer_now 2>/dev/null || echo 0) + if [ -z "${NP_ACTION_CONTEXT:-}" ]; then echo "❌ NP_ACTION_CONTEXT is not set" >&2 exit 1 @@ -48,4 +52,11 @@ if [ -n "${OVERRIDES_PATH:-}" ]; then done fi -eval $CMD +T_WF=$(timer_now 2>/dev/null || echo 0) +WF_RC=0 +eval $CMD || WF_RC=$? +if type -t log >/dev/null 2>&1; then + log debug "⏱ np service workflow exec $(timer_elapsed "$T_WF" 2>/dev/null || echo "?")" + log debug "⏱ entrypoint total $(timer_elapsed "$T_TOTAL" 2>/dev/null || echo "?")" +fi +exit $WF_RC diff --git a/parameters/providers/aws-parameter-store/delete b/parameters/providers/aws-parameter-store/delete index f6538447..9312def1 100755 --- a/parameters/providers/aws-parameter-store/delete +++ b/parameters/providers/aws-parameter-store/delete @@ -13,9 +13,11 @@ set -euo pipefail PARAM_NAME="${PS_NAME_PREFIX}${EXTERNAL_ID_PATH}" err_file=$(mktemp) +T_AWS=$(timer_now) if aws ssm delete-parameter \ --region "$AWS_REGION" \ --name "$PARAM_NAME" >/dev/null 2>"$err_file"; then + log debug " ⏱ aws ssm delete-parameter $(timer_elapsed "$T_AWS")" rm -f "$err_file" echo '{ "success": true diff --git a/parameters/providers/aws-parameter-store/retrieve b/parameters/providers/aws-parameter-store/retrieve index 87029fc9..2b3e7b83 100755 --- a/parameters/providers/aws-parameter-store/retrieve +++ b/parameters/providers/aws-parameter-store/retrieve @@ -19,12 +19,14 @@ LOOKUP_NAME="$PARAM_NAME" [ -n "${EXTERNAL_ID_VERSION:-}" ] && LOOKUP_NAME="${PARAM_NAME}:${EXTERNAL_ID_VERSION}" err_file=$(mktemp) +T_AWS=$(timer_now) if VALUE=$(aws ssm get-parameter \ --region "$AWS_REGION" \ --name "$LOOKUP_NAME" \ --with-decryption \ --query Parameter.Value \ --output text 2>"$err_file"); then + log debug " ⏱ aws ssm get-parameter $(timer_elapsed "$T_AWS")" rm -f "$err_file" jq -n --arg value "$VALUE" '{value: $value}' else diff --git a/parameters/providers/aws-parameter-store/store b/parameters/providers/aws-parameter-store/store index 01d1e686..5e698b18 100755 --- a/parameters/providers/aws-parameter-store/store +++ b/parameters/providers/aws-parameter-store/store @@ -38,6 +38,7 @@ if [ "$SSM_TYPE" = "SecureString" ] && [ -n "${PS_KMS_KEY_ID:-}" ]; then fi err_file=$(mktemp) +T_AWS=$(timer_now) if ! PUT_RESULT=$(aws ssm put-parameter "${put_args[@]}" --output json 2>"$err_file"); then err=$(cat "$err_file") rm -f "$err_file" @@ -56,6 +57,7 @@ if ! PUT_RESULT=$(aws ssm put-parameter "${put_args[@]}" --output json 2>"$err_f log error "Underlying error: $err" exit 1 fi +log debug " ⏱ aws ssm put-parameter $(timer_elapsed "$T_AWS")" rm -f "$err_file" VERSION_ID=$(echo "$PUT_RESULT" | jq -r '.Version // empty') @@ -63,11 +65,13 @@ VERSION_ID=$(echo "$PUT_RESULT" | jq -r '.Version // empty') # Best-effort tagging — Parameter Store tags survive overwrites; we only need # to (re)apply on first creation. Suppress errors here: missing tags don't # break correctness. +T_TAG=$(timer_now) aws ssm add-tags-to-resource \ --region "$AWS_REGION" \ --resource-type Parameter \ --resource-id "$PARAM_NAME" \ --tags "Key=managed_by,Value=nullplatform" >/dev/null 2>&1 || true +log debug " ⏱ aws ssm add-tags-to-resource $(timer_elapsed "$T_TAG")" # Encode version into external_id (the canonical handle nullplatform persists). [ -n "$VERSION_ID" ] && EXTERNAL_ID="${EXTERNAL_ID}#${VERSION_ID}" diff --git a/parameters/providers/aws-secrets-manager/delete b/parameters/providers/aws-secrets-manager/delete index f8f6da87..91ee477d 100755 --- a/parameters/providers/aws-secrets-manager/delete +++ b/parameters/providers/aws-secrets-manager/delete @@ -16,10 +16,12 @@ set -euo pipefail SECRET_NAME="${SM_NAME_PREFIX}${EXTERNAL_ID_PATH}" err_file=$(mktemp) +T_AWS=$(timer_now) if aws secretsmanager delete-secret \ --region "$AWS_REGION" \ --secret-id "$SECRET_NAME" \ --force-delete-without-recovery >/dev/null 2>"$err_file"; then + log debug " ⏱ aws secretsmanager delete-secret $(timer_elapsed "$T_AWS")" rm -f "$err_file" echo '{ "success": true diff --git a/parameters/providers/aws-secrets-manager/retrieve b/parameters/providers/aws-secrets-manager/retrieve index bc1b1fb6..6e957995 100755 --- a/parameters/providers/aws-secrets-manager/retrieve +++ b/parameters/providers/aws-secrets-manager/retrieve @@ -28,7 +28,9 @@ if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then fi err_file=$(mktemp) +T_AWS=$(timer_now) if SECRET_STRING=$(aws secretsmanager get-secret-value "${get_args[@]}" 2>"$err_file"); then + log debug " ⏱ aws secretsmanager get-secret-value $(timer_elapsed "$T_AWS")" rm -f "$err_file" STORED_VALUE=$(echo "$SECRET_STRING" | jq -r '.value // empty') jq -n --arg value "$STORED_VALUE" '{value: $value}' diff --git a/parameters/providers/aws-secrets-manager/store b/parameters/providers/aws-secrets-manager/store index 573917d4..0757a58f 100755 --- a/parameters/providers/aws-secrets-manager/store +++ b/parameters/providers/aws-secrets-manager/store @@ -45,12 +45,16 @@ if [ -n "${SM_KMS_KEY_ID:-}" ]; then fi err_file=$(mktemp) +T_AWS=$(timer_now) if RESULT=$(aws secretsmanager create-secret "${create_args[@]}" 2>"$err_file"); then + log debug " ⏱ aws secretsmanager create-secret $(timer_elapsed "$T_AWS")" rm -f "$err_file" elif grep -q "ResourceExistsException" "$err_file"; then + log debug " ⏱ aws secretsmanager create-secret (exists) $(timer_elapsed "$T_AWS")" rm -f "$err_file" log debug "Secret '$SECRET_NAME' exists; adding new version (history preserved by AWS SM)" err_file=$(mktemp) + T_AWS=$(timer_now) if ! RESULT=$(aws secretsmanager put-secret-value \ --region "$AWS_REGION" \ --secret-id "$SECRET_NAME" \ @@ -69,6 +73,7 @@ elif grep -q "ResourceExistsException" "$err_file"; then log error "Underlying error: $err" exit 1 fi + log debug " ⏱ aws secretsmanager put-secret-value $(timer_elapsed "$T_AWS")" rm -f "$err_file" else err=$(cat "$err_file") diff --git a/parameters/providers/azure-key-vault/delete b/parameters/providers/azure-key-vault/delete index f3fab6d2..ad9361b4 100755 --- a/parameters/providers/azure-key-vault/delete +++ b/parameters/providers/azure-key-vault/delete @@ -11,9 +11,11 @@ AKV_SUFFIX=$(echo "$EXTERNAL_ID_PATH" | tr '/=' '--') SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" err_file=$(mktemp) +T_AZ=$(timer_now) if az keyvault secret delete \ --vault-name "$AZ_VAULT_NAME" \ --name "$SECRET_NAME" >/dev/null 2>"$err_file"; then + log debug " ⏱ az keyvault secret delete $(timer_elapsed "$T_AZ")" rm -f "$err_file" else err=$(cat "$err_file") @@ -40,6 +42,7 @@ fi # Best-effort purge to release name and stop retention billing. purge_err=$(mktemp) +T_AZ_PURGE=$(timer_now) if ! az keyvault secret purge \ --vault-name "$AZ_VAULT_NAME" \ --name "$SECRET_NAME" >/dev/null 2>"$purge_err"; then @@ -50,6 +53,7 @@ if ! az keyvault secret purge \ log warn "⚠️ Purge failed (secret remains in soft-delete window): $pe" fi fi +log debug " ⏱ az keyvault secret purge $(timer_elapsed "$T_AZ_PURGE")" rm -f "$purge_err" echo '{ diff --git a/parameters/providers/azure-key-vault/retrieve b/parameters/providers/azure-key-vault/retrieve index 1f821a4d..66ce3724 100755 --- a/parameters/providers/azure-key-vault/retrieve +++ b/parameters/providers/azure-key-vault/retrieve @@ -19,7 +19,9 @@ show_args=( [ -n "${EXTERNAL_ID_VERSION:-}" ] && show_args+=(--version "$EXTERNAL_ID_VERSION") err_file=$(mktemp) +T_AZ=$(timer_now) if VALUE=$(az keyvault secret show "${show_args[@]}" 2>"$err_file"); then + log debug " ⏱ az keyvault secret show $(timer_elapsed "$T_AZ")" rm -f "$err_file" jq -n --arg value "$VALUE" '{value: $value}' else diff --git a/parameters/providers/azure-key-vault/store b/parameters/providers/azure-key-vault/store index e3403ea4..960208f0 100755 --- a/parameters/providers/azure-key-vault/store +++ b/parameters/providers/azure-key-vault/store @@ -21,6 +21,7 @@ build_external_id AKV_SUFFIX=$(echo "$EXTERNAL_ID" | tr '/=' '--') SECRET_NAME="${AZ_SECRET_PREFIX}${AKV_SUFFIX}" +T_AZ=$(timer_now) if ! AKV_ID=$(az keyvault secret set \ --vault-name "$AZ_VAULT_NAME" \ --name "$SECRET_NAME" \ @@ -40,6 +41,7 @@ if ! AKV_ID=$(az keyvault secret set \ log error " • Check access policy: az keyvault show --name $AZ_VAULT_NAME --query properties.accessPolicies" exit 1 fi +log debug " ⏱ az keyvault secret set $(timer_elapsed "$T_AZ")" # AKV id URL format: https://.vault.azure.net/secrets// # The last path component is the version id (hex string). diff --git a/parameters/providers/hashicorp-vault/delete b/parameters/providers/hashicorp-vault/delete index 8b513069..515f9aa4 100755 --- a/parameters/providers/hashicorp-vault/delete +++ b/parameters/providers/hashicorp-vault/delete @@ -12,6 +12,7 @@ set -euo pipefail VAULT_PATH="$VAULT_PATH_PREFIX/$EXTERNAL_ID_PATH" +T_CURL=$(timer_now) if ! RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \ -H "X-Vault-Token: $VAULT_TOKEN" \ "$VAULT_ADDR/v1/$VAULT_PATH" 2>/dev/null); then @@ -25,6 +26,7 @@ if ! RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \ log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" exit 1 fi +log debug " ⏱ vault DELETE $VAULT_PATH $(timer_elapsed "$T_CURL")" HTTP_STATUS="${RESPONSE##*$'\n'}" diff --git a/parameters/providers/hashicorp-vault/retrieve b/parameters/providers/hashicorp-vault/retrieve index 25e62f5d..f5f80430 100755 --- a/parameters/providers/hashicorp-vault/retrieve +++ b/parameters/providers/hashicorp-vault/retrieve @@ -18,6 +18,7 @@ if [ -n "${EXTERNAL_ID_VERSION:-}" ]; then URL="${URL}?version=${EXTERNAL_ID_VERSION}" fi +T_CURL=$(timer_now) if ! RESPONSE=$(curl -s -w "\n%{http_code}" \ -H "X-Vault-Token: $VAULT_TOKEN" \ "$URL" 2>/dev/null); then @@ -30,6 +31,7 @@ if ! RESPONSE=$(curl -s -w "\n%{http_code}" \ log error " • Test connectivity: curl -s $VAULT_ADDR/v1/sys/health" exit 1 fi +log debug " ⏱ vault GET $URL $(timer_elapsed "$T_CURL")" HTTP_STATUS="${RESPONSE##*$'\n'}" HTTP_BODY="${RESPONSE%$'\n'*}" diff --git a/parameters/providers/hashicorp-vault/store b/parameters/providers/hashicorp-vault/store index 465ce6a4..0d8a9fe6 100755 --- a/parameters/providers/hashicorp-vault/store +++ b/parameters/providers/hashicorp-vault/store @@ -27,6 +27,7 @@ PAYLOAD=$(jq -nc \ --arg external_id "$EXTERNAL_ID" \ '{data: {parameter_id: $parameter_id, value: $value, stored_at: $stored_at, external_id: $external_id}}') +T_CURL=$(timer_now) if ! RESPONSE=$(curl -s -X POST \ -H "X-Vault-Token: $VAULT_TOKEN" \ "$VAULT_ADDR/v1/$VAULT_PATH" \ @@ -43,6 +44,7 @@ if ! RESPONSE=$(curl -s -X POST \ log error " • Verify token: curl -s -H \"X-Vault-Token: \$VAULT_TOKEN\" $VAULT_ADDR/v1/auth/token/lookup-self" exit 1 fi +log debug " ⏱ vault POST $VAULT_PATH $(timer_elapsed "$T_CURL")" VERSION_ID=$(echo "$RESPONSE" | jq -r '.data.version // empty') diff --git a/parameters/tests/utils/dispatch.bats b/parameters/tests/utils/dispatch.bats index e4140ce7..e6ba7c29 100644 --- a/parameters/tests/utils/dispatch.bats +++ b/parameters/tests/utils/dispatch.bats @@ -14,6 +14,10 @@ setup() { export SCRIPT="$PARAMETERS_DIR/utils/dispatch" export PROVIDER_DIR="$BATS_TEST_TMPDIR/fake_provider" mkdir -p "$PROVIDER_DIR" + + # dispatch logs a timing line via the log function; it's normally pre-loaded + # by the workflow's first step. Mirror that for tests. + export DISPATCH_PRELUDE="source $PARAMETERS_DIR/utils/log;" } @test "dispatch: ACTION=store sources provider's store script" { @@ -21,7 +25,7 @@ setup() { echo '{"external_id":"id-1","metadata":{}}' EOF - run bash -c "ACTION=store source $SCRIPT" + run bash -c "$DISPATCH_PRELUDE ACTION=store source $SCRIPT" assert_equal "$status" "0" assert_equal "$output" '{"external_id":"id-1","metadata":{}}' @@ -32,7 +36,7 @@ EOF echo '{"value":"v"}' EOF - run bash -c "ACTION=retrieve source $SCRIPT" + run bash -c "$DISPATCH_PRELUDE ACTION=retrieve source $SCRIPT" assert_equal "$status" "0" assert_equal "$output" '{"value":"v"}' @@ -43,7 +47,7 @@ EOF echo '{"success":true}' EOF - run bash -c "ACTION=delete source $SCRIPT" + run bash -c "$DISPATCH_PRELUDE ACTION=delete source $SCRIPT" assert_equal "$status" "0" assert_equal "$output" '{"success":true}' @@ -54,7 +58,7 @@ EOF echo '{"success":true,"provider":"fake"}' EOF - run bash -c "ACTION=notify source $SCRIPT" + run bash -c "$DISPATCH_PRELUDE ACTION=notify source $SCRIPT" assert_equal "$status" "0" assert_contains "$output" '"provider":"fake"' @@ -62,7 +66,7 @@ EOF @test "dispatch: ACTION=notify falls back to default ack when provider has no notify" { # Intentionally do NOT create $PROVIDER_DIR/notify - run bash -c "ACTION=notify source $SCRIPT" + run bash -c "$DISPATCH_PRELUDE ACTION=notify source $SCRIPT" assert_equal "$status" "0" assert_equal "$output" '{"success":true}' @@ -74,7 +78,7 @@ echo "fatal" >&2 exit 7 EOF - run bash -c "ACTION=store source $SCRIPT" + run bash -c "$DISPATCH_PRELUDE ACTION=store source $SCRIPT" assert_equal "$status" "7" assert_contains "$output" "fatal" @@ -85,7 +89,7 @@ EOF echo "{\"provider_dir\":\"$PROVIDER_DIR\"}" EOF - run bash -c "ACTION=store source $SCRIPT" + run bash -c "$DISPATCH_PRELUDE ACTION=store source $SCRIPT" assert_equal "$status" "0" assert_contains "$output" "$PROVIDER_DIR" @@ -93,7 +97,7 @@ EOF @test "dispatch: fails when provider's script doesn't exist (non-notify)" { # No store script exists - run bash -c "ACTION=store source $SCRIPT" + run bash -c "$DISPATCH_PRELUDE ACTION=store source $SCRIPT" [ "$status" -ne 0 ] } diff --git a/parameters/utils/dispatch b/parameters/utils/dispatch index e6c03431..949e6ace 100755 --- a/parameters/utils/dispatch +++ b/parameters/utils/dispatch @@ -19,4 +19,6 @@ if [ "$ACTION" = "notify" ] && [ ! -f "$PROVIDER_DIR/notify" ]; then exit 0 fi +T_DISPATCH=$(timer_now) source "$PROVIDER_DIR/$ACTION" +log debug "⏱ dispatch($ACTION) total $(timer_elapsed "$T_DISPATCH")" From 1fe7685b97e7a24c82032802e0cec76b17d222b9 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Fri, 26 Jun 2026 09:47:24 -0300 Subject: [PATCH 24/41] perf(parameters): per-np-call timings + entrypoint.prep + build_context.notification_parse + assume_role_step.resolve_arn markers --- parameters/entrypoint | 5 ++++ parameters/utils/assume_role_step | 3 +++ parameters/utils/build_context | 6 ++++- parameters/utils/prefetch_np | 38 +++++++++++++++++++++++-------- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/parameters/entrypoint b/parameters/entrypoint index 9bc9e874..6128fb6e 100755 --- a/parameters/entrypoint +++ b/parameters/entrypoint @@ -15,12 +15,15 @@ set -euo pipefail ENTRYPOINT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [ -f "$ENTRYPOINT_DIR/utils/log" ] && source "$ENTRYPOINT_DIR/utils/log" T_TOTAL=$(timer_now 2>/dev/null || echo 0) +T_ENTRY_PREP=$(timer_now 2>/dev/null || echo 0) if [ -z "${NP_ACTION_CONTEXT:-}" ]; then echo "❌ NP_ACTION_CONTEXT is not set" >&2 exit 1 fi +type -t log >/dev/null 2>&1 && log debug "🚀 entrypoint start (pid=$$)" + CLEAN_CONTEXT=$(echo "$NP_ACTION_CONTEXT" | sed "s/^'//;s/'$//") export NP_ACTION_CONTEXT="$CLEAN_CONTEXT" export CONTEXT=$(echo "$CLEAN_CONTEXT" | jq '.notification') @@ -52,6 +55,8 @@ if [ -n "${OVERRIDES_PATH:-}" ]; then done fi +type -t log >/dev/null 2>&1 && log debug "⏱ entrypoint.prep $(timer_elapsed "$T_ENTRY_PREP" 2>/dev/null || echo "?")" + T_WF=$(timer_now 2>/dev/null || echo 0) WF_RC=0 eval $CMD || WF_RC=$? diff --git a/parameters/utils/assume_role_step b/parameters/utils/assume_role_step index 128c58b3..d940ec4c 100644 --- a/parameters/utils/assume_role_step +++ b/parameters/utils/assume_role_step @@ -59,6 +59,8 @@ source "$SCRIPT_DIR/assume_role_lib" # Session prefix for the STS session name (informational). export ASSUME_ROLE_SESSION_PREFIX="${ASSUME_ROLE_SESSION_PREFIX:-np-parameters}" +T_AR_PREP=$(timer_now) + # SCOPE_ID is used by assume_role only for the STS session-name suffix. # Pull it from the same source NRN was built from (entities/value_entities). if [ -z "${SCOPE_ID:-}" ]; then @@ -84,6 +86,7 @@ ASSUME_ROLE_ARN_RESOLVED=$(resolve_assume_role_arn \ "$ASSUME_ROLE_OVERRIDE_ENV" \ "$ASSUME_ROLE_DEFAULT_ENV") export ASSUME_ROLE_ARN_RESOLVED +log debug " ⏱ assume_role_step.resolve_arn $(timer_elapsed "$T_AR_PREP")" # --- 5. Perform the assume-role (no-op if ARN is empty) -------------------- if ! source "$SCRIPT_DIR/assume_role"; then diff --git a/parameters/utils/build_context b/parameters/utils/build_context index d693bb91..bf95823a 100755 --- a/parameters/utils/build_context +++ b/parameters/utils/build_context @@ -30,6 +30,9 @@ export PARAMETERS_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" source "$SCRIPT_DIR/log" source "$SCRIPT_DIR/get_config_value" +T_BUILD_CTX=$(timer_now) +T_PARSE=$(timer_now) + # --- Notification fields --- export EXTERNAL_ID=$(echo "$CONTEXT" | jq -r '.external_id // empty') # external_id format: # @@ -54,6 +57,8 @@ case "$(echo "$CONTEXT" | jq -r '.secret | tostring')" in esac export PARAMETER_KIND +log debug "⏱ build_context.notification_parse $(timer_elapsed "$T_PARSE")" + # --- Resolve ACTIVE_PROVIDER from $.provider.specification_id --- SPEC_ID=$(echo "$CONTEXT" | jq -r '.provider.specification_id // empty') if [ -z "$SPEC_ID" ]; then @@ -71,7 +76,6 @@ fi # Pre-fetch every `np` read this workflow needs, in parallel. Downstream # (assume_role_step, build_external_id) reads from $NP_CACHE_DIR instead of # re-invoking np. -T_BUILD_CTX=$(timer_now) export SPEC_ID source "$SCRIPT_DIR/prefetch_np" diff --git a/parameters/utils/prefetch_np b/parameters/utils/prefetch_np index ddcbe9ac..349a51ef 100644 --- a/parameters/utils/prefetch_np +++ b/parameters/utils/prefetch_np @@ -71,37 +71,54 @@ if [ -z "$DIMS_ARG" ] && [ -n "$SCOPE_ID_PREFETCH" ]; then fi # ── Wave 1: spec + entities + (maybe) IAM ─────────────────────────────────── +# Each subshell writes its own duration to $NP_CACHE_DIR/timings.