From 07afb64292f5dd1ccde11b39070668724f2c87c9 Mon Sep 17 00:00:00 2001 From: florindragos Date: Wed, 23 Apr 2025 14:42:46 +0300 Subject: [PATCH 1/5] update docs --- README.md | 45 +++++++++++++++++++++++++------------- docs/entra-id.md | 31 ++++++++++++++++++++++++++ docs/img/credentials.png | Bin 0 -> 11979 bytes docs/img/image.png | Bin 0 -> 8677 bytes docs/img/role-mapping.png | Bin 0 -> 22019 bytes docs/okta.md | 34 ++++++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 docs/entra-id.md create mode 100644 docs/img/credentials.png create mode 100644 docs/img/image.png create mode 100644 docs/img/role-mapping.png create mode 100644 docs/okta.md diff --git a/README.md b/README.md index d59eac5..4f19fa7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ The Aserto SCIM service uses the SCIM 2.0 protocol to import data into the Asert ```yaml --- logging: - prod: true log_level: info server: listen_address: ":8080" @@ -18,17 +17,30 @@ server: enabled: true token: "scim" directory: - address: "directory.prod.aserto.com:8443" - tenant_id: "your_tenant_id" - api_key: "your_directory_rw_api_key" + address: "localhost:9292" + no_tls: true scim: - create_email_identities: true - create_role_groups: true - group_mappings: - - subject_id: app-admin + user: + object_type: user + identity_object_type: identity + identity_relation: user#identifier + property_mapping: + enabled: active + source_object_type: scim_user + manager_relation: manager + group: + object_type: group + group_member_relation: member + source_object_type: scim_group + role: + object_type: group + role_relation: member + relations: + - object_id: system object_type: system - object_id: administrators - relation: member + relation: admin + subject_id: admins + subject_type: group subject_relation: member ``` @@ -78,6 +90,8 @@ curl -X POST \ }' ``` +The create operation will return a user ID, which will be used to identify the user from now on + ### get a user `curl -X 'GET' 'http://127.0.0.1:8080/Users/{user id}' ` @@ -139,13 +153,14 @@ curl -X PATCH \ ]}' ``` -### create a relation from an imported group to a aserto user (e.g. giving admin permission to users that are port of an imported group) +### create a relation from an imported group to a user (e.g. giving admin permission to users that are port of an imported group) ``` - group_mappings: - - subject_id: app-admin + relations: + - object_id: system object_type: system - object_id: administrators relation: admin + subject_id: admins + subject_type: group subject_relation: member ``` -This will create a `admin` relation with `member` subject relation between the imported `add-admin` group and the already created object with id `administrators` ant type `system` \ No newline at end of file +This will create a `admin` relation with `member` subject relation between the `admins` group and the object with id `system` and type `system` \ No newline at end of file diff --git a/docs/entra-id.md b/docs/entra-id.md new file mode 100644 index 0000000..c0efb78 --- /dev/null +++ b/docs/entra-id.md @@ -0,0 +1,31 @@ +# Sync users from Entra ID (AzureAD) + +## Create the SCIM application +To setup SCIM provisoning from Entra ID to Aserto, you need to create a new application in Entra ID. Please follow instructions on how to setup a new application: https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups#getting-started + +When creating the application, set Tenant URL to https://{scim-endpoint}/?aadOptscim062020. The `aadOptscim062020` feature flag is required for SCIM 2.0 compliance (see https://learn.microsoft.com/en-us/entra/identity/app-provisioning/application-provisioning-config-problem-scim-compatibility#flags-to-alter-the-scim-behavior) + +For the secret token, enter the value configured in the `auth.bearer` config section. +![Provisioning credentials](./img/credentials.png) + +## Provisioning users and groups +Once the application was created, users and groups can be assigned to this application. Once a user/group was assigned, it becomes available for provisioning. +To test the provisioning works, go to your SCIM app => Manage => Provisioning => Provision on demand, search for your user/group and click `Provision` +Please note that automatic provisioning might take some time to trigger, see https://learn.microsoft.com/en-us/entra/identity/app-provisioning/application-provisioning-when-will-provisioning-finish-specific-user#how-long-will-it-take-to-provision-users + +## Provisioning roles +Only application specific roles can be provisioned. For this, the provisioning scope needs to be set to `Sync only assigned users and groups` +![Sync only assigned users and groups](./img/image.png) + +By default, roles are not mapped to any SCIM property. To add the mapping: +1. open your SCIM app, go to Manage => Provisioning => Mappings => open Provision Microsoft Entra ID Users => on the bottom, toggle Show advanced options => Edit attribute list for customappsso +2. on the bottom, add a new attribute called `roles`, type `String` and make sure `Multi-Value` is checked +3. back on the Attribute Mapping page, Add New Mapping: + - Mapping type: `Expression` + - Expression: `AssertiveAppRoleAssignmentsComplex([appRoleAssignments])` + - Target attribute: select the new created attribute `roles` + ![attribute mapping](./img/role-mapping.png) + - click OK +4. Save attribute mappings + +For more info on mappings, see https://learn.microsoft.com/en-us/entra/identity/app-provisioning/customize-application-attributes#provisioning-a-role-to-a-scim-app diff --git a/docs/img/credentials.png b/docs/img/credentials.png new file mode 100644 index 0000000000000000000000000000000000000000..4e10cd4be64b15c96ef3cfba4d743148e3a76b52 GIT binary patch literal 11979 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
{`(@xfOX)) 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 literal 0 HcmV?d00001 diff --git a/docs/img/image.png b/docs/img/image.png new file mode 100644 index 0000000000000000000000000000000000000000..26c09d09a42318fe39970f841c4bc9c35f5f8c1f GIT binary patch literal 8677 zcmdUUc|4Tw`|q@ogi2Ay@+lt`g-Xmvl2EjfEXg`8j2MgzCdQH_Dn-f?V@a~_%gkWR zAX~DpgISR5#$XuBn3?nVe9s@}yk6&=-|M{2U+0gRxu55`=f1A{zMuPjy|4H6*2LIQ zIp5NJ0EXy@%00KV@+@AUvbyO5@adZ40C=~>`nuamBkE(lZ_Exi3? zA8;+?amxw`0v++%`Rt;@2w&FGn*PjU5DU?LGbY8Pwvl zgz=HbD47wr`|$}+Rl!n5BaN}WM?zlhX?ys6?r+i!b6S|g(bo=AR-TdDc{i|oU+g=% zU!>_ThI;Px#-ot;Pa#=1t=@jWeoA=HrS|@-o6$q>q=p8yyV}(VYMjWs@4i_ktqTu1 zOeLm3y9(OH(l!^Dvd)DAp1uN8I~Mu7b{bOklE979>r224uAe{T58ON?ycY`GYux=` zJ~A1vM;RNA3_hogj16STm%H~I`7+{!t3v$h0LB?JcvxSLx`QUWcemrpuLLv`n@xM~ zx-VaK&2f`UL?Yv9_Lu)TbEREF>xjE3U=UKKAVR9$8Yo_!!XT=v@-L(9`&=I6b#~Yc zV6ci;#egoc&aENL$+m>G+!oos>1*r5nL@P#>p2fYrBb|DukS4?nh1<17`mSL*qZ|0 zW}7v*Kq;;`jiN;X79ID5rNZir!I|WSf)A%wFhy`xEfwKm>rS|f$E-%L7r46J%R2+spiC=^F5i#Cl?N6{d_S(;;h+Iqu;lRcEjj#RlZb&`tR$Vut5Vu91 z&t1VQEnLTVE7GKGaj(0tA4S_8un$tIw-AJa{p9%V*=wcI0r!Gd&UB3Q^rUkZ30=aq zh??baH2IVTrOqYf&&P_UsBhthmMsECC2s1=L%DE^Xan^?3uvHP{Y%uLcSf)1gQrro zm>;N9j4o*#1eAz*(c4!&b~=CV$ju+>fHAtL1?^?qmUDiLBxiN)%RH#-TQA_9-J)(bl`tyNJPKvx9eFS)`|MD6!p5vP zFkq!$q$p{;8*Ye3y7B&;85kPMX#u9v8wRz=C6(IGp3us&&KwyqZTommYd_J)zCpFt zsP=f_2=Rirl6OhXEBB~p=QDc*ey9Skqk@&!5@T*VR08u0@9@#I2X}H&2-& z3TtQ;5Mx?mI-Iv>DnF^Ce35U%B}CknO*m+y%5IwGM_QUDY0n<_kuTuk&mz@yNfzLo z$!!Tae?o1Jp{#FX$FcRE=#eqXCTuUFObVc(X4ASOU<~;4xvK|D(S)c7s5FN$lT2r2oGky~Y$Bcd!2K@8odfzBcy0 zq^iA_vT(WQLPPr4x{$rhL|=y%??P-b| zTD+>vn=uvC>LmNca@AP=JahQKGqfd~T;aij4EM*N3GI#S8#wt(oYh5eZ}vcx8^ zeOFBvRw7pIcd|@y1?2)K`Jl3@x|GbYu}@l{)sGTL^{g_>z?|FDx#0hBN&?kc=Gg|` zrqIlW8PlmjotpyODYu~cud$1!y%lKgq1AFNy7Hdhv4<$zM91K6%}IAM)-k$?%>d54 zK7}>%EmJ&(w4Zry2-Pz$SJHBnp2e?vV?VK^X}do~fA!I+>BH5cEH>(q@u*X@d}FGa zenRi~{8*IMBUNhkQu8S1a@{#j-Z=xSW?4au7e2WfeGu_GGPJRj&z5(dbV6J0 zGds#=7%U~XIXvZ)^+L6;f+VgB^1PxaUU!Hgt3e(>$fe!(r9{d`5^o|pRjpjnFe7mf zTrw-Vi2`#^lOXb*SmYsM4WbEHSQmzsPhO354_wI+umN}UY<%Owint%=A>z%S4E3ke zr*2(1Pal1dsRK8riPJa#ZXnl7JF!P~OObXtx-6@C7S4Hnd<`{IF6pnbGDMmwSZ=#e zGx3&3sGz)Ejhp40GFZR1DZYAdljlvU5AFrvZ$C*^GUwjMpWymLX%oEPz>&Qdz@I*= zC3SAsA$ros1k>=YwrZ-0v(z=$pJl;CZ}PIvlzHcQe}LsbS(_V{2`dXTJ-y+mXZDl& zGf@01&Jh1Ob#Nafzx@)pagH_Br&h1kQ3?zELReSPD=St-o_{NVG??CAIn4h@EJCkv zmhX1@)rVuEE{+&&n4WpDA0}iYsrxiy#I;r)>_U8|*cL0$!4w!KLFTMGRCi}cr7^dr zbF3{gR(qcDHsv5E^LlQqJmxi>z+QG}FdGk4OXrP!wZ`4-l1F#ETSpc3reTlVJKkxl z#hg!JOSR`fb>5+4;7>Ci8P_#H5r%72hCP2Ui>Pk@4|PbcADd76j@?RK3C!{Pfsesh z_`5!;&d<7WNXBDayFA6?mMV1Pp?evUinZSdmY{x*D`?G?#q!t@sb;p*6;I>n1Vr|<;o;Fy1Fg0SU=wCpvJ+sl!qU1oGUI10>is^0 zwq2lOkz+R3zctY5Vjt$P0(krK;u+>%%9u6(bMe?r-x#PZTb`p=&VWJ?+e+p$jx+kpwD-D z)UBi?>`1)&(8q--qrIZ!1WC){)NaWOL~=-4N$F5m64c;Qx$>hf)x%bPi;73aE$dA~ zwxQQl+TTZzRv#@$lA5@uzO=-L3_WW*7qAOtekr8bG33GbsBZctF@y4jTXsE3BS&fu zlT<`tgXQ=ak?)f`Q^{U09eM`+t6N{^Sf^Qks=f>Ut4zMk=Z2?{fxrI3i_`J}bMAH# z?(3@W=2;$vD~Ht>+#iPz446HeWEY9#Pt4Je9ni2Bx?82I{tFy)wG!G_gmBXgfs9s` zzF(Rn3mHZ^UXK7OT-gL>vkeJ>upQtsLEB=vH1&33qYf(xT zuHQ@j%KbPnYM~iRs(ivea*P&I_?D_<-wx>%mx-{TRMop>n5pF_ylf=PI(jxQIwj4X10&&VO( z2JMML;T60J4`>(Gmj%R#rWrO=j4&j-s4LWYoMSV;Df7nP*3D$v29p+3LyKevg?&EP3${%VvBJ-v~;o=*zQ za7U8~RIOd0>zX;sH=C)qFoReTcxT6SSsTUhowA)DeR%=niDV`A(y{1ve&;ivM~ulD z)o2<|ijm?~P98c}yq)bw+E!3q3Y=4mo)@;GZRt7TaXM^8JB{Ft;@Vy~o%Ie40fEj6 z)|1r$dIGQM8E?;DsPkqRF>K1u2bbsNLbExL{9heV@kr6swhsP1hfsGtt*+#O&MOeK z2yt@?vo%GoEz&_yilVvWa?VZfj=8^GOXa7Hj0Jey<{^LKyxuG~563B*(cwq-zx!*%7ZAyN%ucL9R}nrIkDD=NWpgn6SWVdo)10`tvydWDZ(#$9h2nJgW9#AhUO$!Zt`Ee!NB`8M(|UtD zU*Ov>P;SYJFf4lxuXs%dI^%65q|IpJSF{K7zgyP?$UQIPn*QFnTk^y=Qk2sIv(GJs ziD&O&ONPUoe?ooy&O2`5Ounl47`a+Cg!z3kjiea_uZSV|(|&3#DJX5XN;$X}hZ%Q8 zD>8y9ouEQ2Le*;7&>yF~3H6t$~MfHbEYzHcaF)D+y@u->s%mBrb3Acba`xc4!0jd@=MuBI;Uw+38!b=7vr}4kdFNXy>uMLR3%9<{LWWG&kiVbB z^J(eVZOM7ENNkH5=fXItK_(5)f6-C%r>k>1g;_IT)X{|=0VZ`;xE}J%mL^i~1`nzz z`6HPqkfeq$WpD&11#HBSXiZXYczOI_QbjxVl}En&xk;v$-CGQtsuh2i>aWQigk@oC5u+ z=&taHyHS~YK(7vkFrS(u27}X+Oq|1fUz0vg?#m0-9SC;`o3_c0ZG6a?%=vNOJI7^? zB}$1qy7ek|W#e3z^-9@qs1q~=tdomB*G<}5M&-;|RTJGEt}L)`7(P$}hC`&&JGc7Oi$=QC@~V8j>Mc>0Srt=LL_-~buX@5b~?K%W8Z$D26{ zjt)P;LcA0iumQhE*;;x*0+l~%R(=J|!Gk!EEh4|vzLu|}Q*i9u!hQ0e45H}sgvQ5A*DyuV8W zu+i;RY(<8*8y!9x3x~kpSNO8bIAv|?#?`NlUt0NKSe%~5Fxi(&^|xlUU;Oq>xtd7h z(@^9YeQ&XuVW+egJ51$~N*vffAEK10qM%=|-WE<5k#bA!o>g<|m@n}~bkhrFNng1I zqPdz)w24Rk)j0eGqG2yPpTf5R9kMSxR$&Uead5D+u6jY za04PM1z(-J>3bgyI9*p(?M`|UH}k~=kSG^_9r0T9_c;nVj}KtiWPJwYj*hA73u;9p z7P{u{Jtr+s#rVc;9(`+rAXp@**m)wGh+A{{)-TXr4#$D9$o7*0qpjLKTh=!8Ls!WK zMGur`RlHx5x_ugrr?D6XeLK^M=7qCMb-a5<#k7|AV2R9{-iR>M!q5@ZlB}13V%n=E zBt9|78%bIxF^N-a*C5jzJTvJ2JXz<@6kwplR%t_%#FHGVRufw;w7fsl1LO69lhkLX ztiwC2M`+K{We7b%+m2%HinZtI%v7YEJE4k_j7V!Dt_|AEEpFFbTb? zx%@)r1P@2r`g4uu*`{1{46x94@?lU_hZu=cJS*i%1>G9_7+T&GZ|X^HJ+H zE$|@j8hLe9ED+#Do29b+1ySBBK&7vvyc#1ksz6BvJ7=#dZM*Rv|IRu0S{giPELhN@ zwp15S^T9v>x%}1S&Hu>yjY|K@homA*OX3b9Scd*js-hCEdA(m)^{n=_hl5akAl6;? zFML$0*SaM@Lyra&WtO}XA&qV|lPjEAa`{`3zy^S$fk2xuXrPs0IRAEZz}5@0>7%Cj zD?>5%dOND+)HAhbnijkJ|4*W&3Lb!jVp@hGgpi!YF6^leXd_N5G6FE2sH%<+6}bTp zoj?)c)g2^P5UP4B>AGZ1y!Mct&Jh_W?`rw+olW{Zxg|={Sk-v1qz>CnJHhwPnW{OC z1l-TDg>Gy^XBURm+>0)Au5oyo9^B}S(`I?aK79t?oXhhL#EK75y6Mk7N?bkf!(#tg zXIHG#AMdSltMvHD#!b*amy6h#c3-=cDeqRlqKDs_u9*aM!UBu2%02dpdL^|>7jH(% z6Wlkr^`)CFaoU|rzFRF6p#H%t+w45qYp9vAFdksoscLBN{7O5ayXqLlu+)eDB?3#v z5g143B5cRk!#IPK=;1&gUtG}DaS~+S&ZFb?@Tcg7nDRmxgFyMs$}Bdq?^MJN}Ze z{adoOa;3EcQ*_VBs46}ujklU@sey2*Ug^E7S{2{>t2K(e`3CWCHRhx`NB&V_Pp#4b zWDve}6OyEkzL#+z`XGMB#AM!@_#pRBCCt7|4JoCgw6;KZ#f`BHKGM7%yF1hh*oeWM zQs5;mrG+;`pAGl4I6%`hs<;3Z{f^vJIKIPAVI$mt?7`{9jyuntvNXs#kWst5R%!IZ zfxM&;PFqr6OlYXfaJ)7?FYDUc-?aB;8`3LeY+J9IPYv=;Xi63z2b+HbWcfx2)gLL? zJ6YLCg9mpRl!OJiI&l(HD_8I|U_Pv<97UvF+)N8*B=R1t!PeN51>i6L%66YNYrX`C zPO|Srj$lH{J-NrXr1Od_w5QCj%F4b_IqA=+@%{{y#LnaI0|goOwWVOO8O-RGR)Ft% z!sSgWvS`4`Fr;>fmRTIY1#gWB>daUd(?yoe1(|nka3r$8W0dev_)!_{DWfhKxi~2Z zO#jA>I2TL8ZEk`>YWA=+0HdW{H)ug$ zp#cFzKX#&14es-q`+n)`;L+{DPvQ3S;rmI!H(GA^n78lEW)JvO_uq0ksg|Vj%v|~E zuNhxw>)Cw~U@cSIjGhUkLPM1ZtTmC5sJfIXFCQtySRHW#bq;zyRA1vTu_w=%3u5BH z-&|)h!S&cyyz6OBUU?h1m)c1zMMeDZ*F(h@fH%AVG-Vs>x`x^kt-Ew znf2|{P4SmvNAB~cyHyiBPj~%@t?Q0Kz7qsaH`B67&m>yKwL>sz=H7Vx=Y(>(Zarrm|7D88;0h#YawT)<|0@6*oha z){&Kqj|+PfC+_EDc5Uw-W82M)C~#cV=LU^@1|2$y$=7y)#u$$qnEcI_pqkV2#9+(< zgi7_gIZT)?#bt|ZBB+v-)jHGwhK(x#K1H<%Z}&>(end^eJpp81r&a=Z2I}!uKIK zJ#!@8j!lJInwhGi_FyDVlBn zQg?RwqFuFD>wjp0>x|JLWQNXJL@K|waNQvwp!g%||96GUpI2Mf+Y{C7cbxFZup;;< r2h{=e9khJ?U%&qAs_5~LJe?2e9fG`j7lC&W1iF3G_(qZ5PjW|`YoEK;zH_g=&))mo^PJ~7fA9>_#`x;5yzl$_-aXS&VZ6w3k%oqb z@u})#T^gFxGr*tDh4a8WtKs&Gz~z*uu8JZ}c`xTO@Zzl9Bh5!NG?g**C(q9Tuj$-W zO+0C6u6R-ZPEFeK_|niABtL!pNZ;3zFv09)FrPs=j@e~qtIWw*it21&cz1H%?cJjt zy9nPG)pq@p5(D;hbrBJtZEZZlUJuJSKYtc?)&BjxbA-ZQ;Q96Sda*opO372*Cr{E6 z@A%N@J&BN1+F<&6gD)0+(sFo1DPKE%Fi*aC!LT0xfXiyd8W7xI|xW zIR#uW7l7fQd2Jo+23(p&{{5d>cnw=8VIn6p48p4)U&A;4c34YL+&&hw8ywy%X5xPM zL0jSQYd}G+x*+OtTa{f&?hhBuyG=q}&Gfze(bU?v2{*u>;+XAsuwNlnK1#68MTd)>3L8)0a@-nNDN)R zk77nzoYdTeAtIV8E>&_UH#*}W$tLh^F!IgiDICq*_8~rW&oPa5tG(Q!fv|^{u<5*t zzeSirYtu6Q8jvhg^Q^A7UNc~Sy;1;ZBAa?B#z| zc{aI``UMYe7~88O1@0u_Bv)J-?hz} z^evn*wr_V7?Mo&wGom%AA2J@1PkJWV?&YdBy_l@Mzwo0ZxAfQft|!EG!=fzRHnJ&v zZd=jcz9icjf;P)jUcM0*7P1h-7U(B!Ql0j61d-Acm;F?Hv%Bp>vD|FCr$DT<-ri{-1~v&~f3uahxJvuONm z8QY%tK&n?YxC*0gy38$%_1nE)=CM)tJa(WrGJ~umd!u6R z9v{3F`n5|DW0)UqoD@AeL{3*ARSKfzOy0g&o2moT%IxE7nscAVd94icBY} zy1U&7w8pi~XHG5a)z6j~HoN4UOrGR6Ow`)GugJECh{V0UVepdmIB6JLeS_tZv>o5~ z5(9#*Vbh|7D}V8O2F#K=4|uNSt2;Km@|os}q0v%Okdt83jwn)Gm{QJ9oioo+&X%51 z4`rn~$&`k5feCioFF`ci%a*q4kD~7;8VY3=8(FBR>-z>-XyUd}4Y!n2os4J&MB=!X zNFVTmj89OVNmq-#eO#Y~>q30?enUv2R9nlxy3@w=u%mNxkxnf(GSH)Hi>~z_9dVt= zTi)%|&2HT)9lY_uTxp2jAk8N9z}N?QD`eByp;KDduElyN>fP4+$&4X;QguN*QoN#g z8FbceHt9_;@y826Zi1_%!kLPg?8GM$HCG+%JDpjpVuT(FZ8q$TkNYiJ_#fuMYaHK{ zl%qNW%$;$kLGVM~o{WG33&5N-8+&NoG(SgQU9DSe{QeL%*49;5Qs|#4&{1f(U%ivM zajS+;&ER5{W>oZr0aCH6oid6r9%J)ds8_e4|44 zKE>G8?*fb=` zd12z(-*!wd9-VCOqVu^(7!O*pb~*J~C&W4E{>--!{@5ZNC9OA;ku^1g<05U6k<$l} zQzr{J|Dz)BhPh~=;_4l4&4w*CG-)nIdwIynvTst}b!CPa7y~Z_ET~naOkK)GWxidQ zLvgFmL-w;oiL~fY;z!Xf)J6zMUz2dp!hbpM2oIRBR19|?^Np3A?kTb{3!la_Lh;a9 zz|g)HSv!rOlOiR{#{FvG#E5;hk0G;=Dz6OfvSYsWQqA*p3uN-AEf%(ns_?i9%&XPa zU;e>vdlb{b7pEV!jp5Jl3dx+N&b+dvW#%p3yavDF2YAs>9v5eBWClE$r$-gNT`jMo zT9M`voaf2sRG$+KW`&)P&g>@A2|3B^Zl|ffLQtGxF``y4O(Lt*#7ZEA zxN@ow5sdup0@1*8$g3y)vVUa%hcpnpYH|W_2@t9YaC;tX+oG?)?KxA^(emMmj+7-;r99TTGflEm#YG+_qRm)-J>^n$Orx)*xaP{yFNZQy zjLP8$SUc7oc8(yN(cu-Mx{{gbD-}gBN4Wa^+#Dip)fRQ0de3g|-pIr;8-mKC>!xbU zPO&tj!DeFj&U~K9%=NlZe-+Ll3j9K!GbV1ev$O)m3i`oLovGt`f&|ILvy=m zx#TZebQH_Nsh750B5o`Muh5jPTwOY|Jtk2}l6+jiLP z&Dn}i5e>Ez`3&N1i+O-u-~8$d;xb%*uVJ`i>cb5e)e<*0oAklpW8PmDdT&P{4HlEK zev|}ykF_NwSbns$sguIjlxYmU-k(WIx|+Yh!p%!g5q=&_{a!Us(8Q5KR`u0TEr}3Jz z)UW#8PVg3Uw`8Hlc9A}R25^-+ms_?MUGuL}M`Z52*q_0jbjU_yqueeh$OQ+y{hB_g z<1^oC!~1~=ok?lmXO(-B84A#`THqrZYXseN?0ek_Imbk*c?h9Kg*1!NssGXF@IM(; z{*#}LDBHM4YDPHnA`_eS$6=vK#_$37bGONXzKAmc);Pfl$T5NcR3L>6m2vxJv}>>^q`|w_c?{_j;|w&s*;hj2 zW~1@_TEjwn^-I~Zb;GKRj2)v}?I8{V^4UJj0Wr1y-U3?ZU)?sG$Sd^g6>{8s3;7w} z9hddr0;Q*!9E9{6;$b7b?LMV+4rr1jv9j^ul6c5+)f44+xOLrX8_Uk5H^#e9?7cTm zYCHJ^F47*HTJx9B7gEWA2nc(x=nKVDVlC)To{kz-0p}*z0`s?FiU;@#IIM) zIn6L9KSQ>9`Bl@(Vp#Fl`*n_OoJjMJzp3BH4>ib2vvAoL?z{;6Kn>{)1l7+UNZScoCVUdYN*#_Etg3dgJIGcou5^O8q=RbxB>x-9E%?hh$|4yQGC6 z8z+Ie2VY|de;EmGI$XHf_}F{X#DQ4Jf3-G(6<1QyV9+;20vAfxfBY(F)Aw2U@|In% zaUq{~NeN~Z?k60SafRIU4BgS4c#O9dy8Z5|R;_v>9dYz+tzWMoQ+r;LrESvrg(-=j zTX5{7<;_=}&%*D~8z4J}A$IPiNDF1XPnaDblzaS*nR}D|(Y66qVt1?hZSQCzqa*!b=$5O})$bGyx=a&%mf)FmgY^%szZstpBsH{uv+v6VNQa+oKQ4 zM9LBy)rHVI6%YZpp&=u`m=c2N^r8?tx89Rj?MR)mir#v@aiLQpKHMhbWin_6UhsU$ zfFP=L=k4x2WYcK5vrYE-MrL$luCO-`S^51!OZQ9sEoWx6=Z()0BfaPkVoFo_+LX87 z4^|h80y<)!MIF?q_~0za(B`S5HX)}7@qoRuhWW%Fs(X#~_$k{-i~6e#z;mY+#b+rm z@+m?_5TPy)+FrZ;- zSfY5~CgZsfY3UeX;ELW&tUHmNw{Q=cg5jO(N`9>CY9^tI{y@9$8pEkhG4}MwX^`>U1+aJVgz%SUKX{Eer#Gh_j&n#l$C$PBsM)uFzOj zf1Zn$UAsc_OWK#06EiI#DZuytRgyk(j;94l**qn`5iGuPp znHEl5e7EZ$&T`Y93^Z(rC*r}By&gvY={L4SZ0UXU(2X11&peYXcCW(b{NyQ7ibx2P z7AlL8Id>1b{&rJgdNk6a>{l5NQr3rc4lJ{B22u`-${n*AQId1B;0D6`O;z>0!R&O!4#6Mj$b zo1fea9#%PvnA=|6ZrJ{^xch@76*8PuG95B}`DE)H%|q7PtS!jB)r2Ld1Z?M3^B(Q* znu1*e5$Jd_z6aK!buOEf!@ z5F> z)l0m+V{)MH~YC+zs!3FXF>15R3!&GH?C zEZYFEp(PX79U7}0w#rMB(2Z*ovL)Z?MfpCXIFGCy$iJ1vp;!?TRbMd2bChqW7xL_g zkVuMt`Y_6TRhR6`?VqJm)#J$wv!LL&wO3GSxdEC}%#%e}ymO!}XPC^c)(cKa!y zKI_9FXY3pcpuMtomR^BB-mvNp$B7l$Tc2cB3v#-c;_NA<)Ae_VNtWZbnfLYm5Ll%| zYx({nCGv4vl;1?eJ=H+l<|=F%j+ zTZPy#YZ_o#5h9V;vappDlcW9PE60RwGH))463T$^QNWCXFe$lk_R} zj?SDK8G8JY`xQ^on-;jslxznm9TiX`rvpCnn>b7ubavUgqW2=S=I#pV5hEoY_KjX_ zlf7&@#z*+rVc)Se0ZR`C@1Fi`3Nh28z0(i%xr1@rE|cQLx}%9IsEaNaGgw!*ao1HT zFge1*)}zciEPNUEb2KN?k?{&S;2P|?IZbd3nidV+wjnl!gBCZ@&q5UL_szd!U&U$* z^=?RqcLkQVYo0rV6jfMJI-tp$Df#43DWaVf3LfAeT#B=@bO z*jY_PU4?7zUXsJF3YfjB`n&aDO3g+1&Ds-_ljEyVJY7RlB?tN3TrsYg#gR_3^m^+F zWe66-L*s^uRK1{$FnQ2;tiZp6Y23K+MI-w#C?nZ^`}9JPt?k`bYzZ0r5r*9NT}e1a zF0{2H`=0;OaWX@o3mJy(xo>fKbB9xA_FvLpdcMGnuvoi)aOHh`X`aP;NVpY5Y@P2~ z;1@yD!*f|~8Mip!a(}-q#HmK!B<_7XODf`6k)OVKlx7b8F>ZwTLm_=8AF=(OROPDZ@y2rJw2SCN ziXnzFA!s{r?VgkfY1eK<0duZFkXsJAP-CL_u$b>6#={j+nY;d zv!*KIvlX7S6;|(W$8)KT-+E3ii@-k)z=rq9R(qrr$=$a?lvqm#QDd_9$nlW#TW_@q zr#!dlj+53ux#HaC~WX6!==p6T4;RcOi2 zzl(b{PTo%DTJTK2w_SOfragLwd1mj<{8t|TV__r0e$#KBANsFud%84AdOSQUF>wrD zjlJSsMIyA4<1@K~Io8$boo8e=T_ivW55OW!A?wdJ@KWH7UhP`?#YRwR{_rr5`i9=# zeeDp~HPu6Dg$EDI2ahc87wq3oLhKL@V3~5RWGn&V_b}y;QdUd0g%6iy49ym*1WBi* zgsM}8et)D2W=(ak=4Hs*e<($QTb?Gt_ej{jU9iES6e{()uhp4fOKxm^>|mPdd-z@O2}ukefFG#Sos|y@o8_1 z(f(42iC~K;ufl?2!@fEJj;hG%@koq#M~d1$I@C*v|bDlHmz^e zouYXum0T)o{$yeOE?ZIq#XX~Gj-i)F%rP~cegDvV&^}M{n}ZW9Ita|xb8fW5d)u+y zipYJA#x3en^iB1{qN#v`{kPn&+OOTlqMN0YN#c3eACY!O(uTa}TMjreLXdrK@<`C` z=4RJ9nn=y~9Gd2bze!)DVd%Dz?)(@=O_r@5WdB6cKEM7ax$XY~dglK=?Z1oY`mc>7 zfW892!iqF(yd20BXjsq3xoZx4&u3S0BjrB$FmdabHfa`UBWnMIW$jM;=H}{4VN-3) zo)~KVB<-5(bN&ew`(JC^U!^zyd@W*L1B73yNsUs|PiWT~PeoNhi;0KL&aLe1r;XXUJ%GlDRA=0o`z*HJdI126G1!C&mBtCYQFf2u zaep+LSiic1_1geI6rgcz*EFO%r!yWRtvlXV$wuRc{$S}*@9j+{W9;BNkXO(SkZ;9> zHSjUUt|WENaqqC^c+Sxh-GNJWw$p%bIh~Ap-AP4gM9&cJ#NoLqVO0i`Y*$D}t0|aO z!u(oH3pNF47o$KJCIc3^Oxox2sfFfpyDIuJ+$>t`++l|~=X)ke=6`jh;!GS2&-ofH zYVuO-o*Phc-m?*ho;l%P8@wJEYYr6UI~2LNS6X0=o|?RCP*B~wW?!feJpFLnsJOAY zyqjMy#DYQp72e~x^DXjPRCUR5tCd9Q-+&Xs9fe8h`!&v{QK^vi@A)~q#mu*IyDkjW+v$uatM_lGCICk*sS9snrpOt^mmX(A!0u?AWyFtezA zkn8K-)KF}8&0+%HbNr*_3Nl_f5qec_3os50=lNT$Wj>mdLmi2f`_{vB(z-*t8)>Yhg6<&jKD76sqfP>Z5tP@}X+&^Q3) zD<^~$TMSXkPQ`fFmAq2hOU{Jy9NwF98V(Z9EEGTh@NsYO=7T!kwQZaEzlV<_xfl6^ zS?`;U<|@xbFw&MRLcNQgDQ{khiB5#swp5ADix!t_nO{oD{zo7=LTKo-$RS6Sg>^{w zyZ%4)N$a<7*7aXScNe%nUwZr9Qo_|`CHG~b>4v*ReaE=%ch4yye!Ps1<<6J@u!BOee#vyi!7=s$iGhGp!beSMF(`n#V#=A2mzU6XVTt)W z(wvJ~6;%7=Wmkb7r!rYM@1riQUFqQ&DNYPz_G^Xyeh`MVK`L_vm0}zmc8$Hu66FsB zGB#TdSwN-xwbIY)0QQ(oK{|B9Qo`um7m<^1ypQ+R#|!g23I6*Vm&=l^Z(wyE`bL`q zn`?Q)Hi7kQ$!<51U^6$Kai43BZ819(x(vV`Q>J*$zOY9lbL6o(aidNR+gUfNBB` zV~>5g^M1u3j(oCu6cLUd6a&H35YW98DDYe>U#g9G?Cq>_vcV=-s~+J=?@8S&_JOh$ zi(lv~(xQj&*EWtwM(XfwxC-bKhR0DFjg^i`E=UqYGexwmR`qzFIPm672P|JpV6e>7 zWdH!1oB+8<^9Ea}NBoPjjkmtabM29N*Ts~o;%yfsbSKJmAP-uOD@AybaCpJwM(t~%b;$$g%*KdweJXmTJ%-?OzF zsxy$Z*Qvdfri1E~O2hFF+7-|CUu-kAR1d9-zAj;qc!=L!U)L1}#3TS=7;$`l_LScj z@%}3^=LI-FisuvkzI|}d@|k?AV9lsYD1LCtOI2oQ%s6{#-1jW=WR)OXZZx_zz; zZ5yfYBkrpJC)-lS`E`kiAXJ`&*08m} zaQJrx_0BcB2WPeqSC6bd^!*A@;x-U_E3$a!#(k3Y+sfT4 zSIrOs4BSJbR+bjc&(5XK#JODv7NC}xNFN%JQ-SI_+3~GHj%=rFA!FT*d+RQYLyyvE zP&y&+A~folpYx5YqMr=~IBE4p#;BzzLjCVbK9bkXElrAsjpP~oyJ54jLT7>k%K$EI z-R9l4gPLz>SBB4IUK04o2zmYU%1TKiW(Zif+ezU5@p_*z2>{%kabLeVZtk^+I~g0Z zrQE6y32J!uaX)B1p50jQ-jyU!p0NJ<_mTwziSWZNrRkU=WzEU;E8gG9PHvxnZ4HQkmw#YQZP(GY0)vQLHYK_Ik5^MEdU8mnPcq zldbph*dT!!_OFgT756(#b}Vb^ULxqU4|#1bElhYPQMFceuuXdWt)5J1iAz1Nbh*6i z`d%G?;9D)8051q_k#t)@=rlz`xnv*XT;9ie{*x#PL@Ancfe($>^X#C+Q)I}gXrm{z0-tL8C@*HNs zZ~=UQ!%k3RTvEUs7I&PX<}pC=J%5L!*gnl9rA(G)IGWJ^teyrCrweP34V)3og0mWp zi&^`{*vd4s3;BWE3jpOXy&h%RC?}Wi;H}*y$--^^$`p{>a0O$b>z+rG;pjdPX#lhW zCLbcaR&rw2#QHHjh%G!Bk3Oj)FT`TJHrrV7cGMM1OK~Fk+Q7aT^A0iRd$m()S_4Nr zJ?myr;tyV&pD$WGaHnduXw2C(+9gbI2@$>iYg@1X4&KlX_oDi&;Dqjj~?;X z7l=Aa`EIWi=LUFuu}ooLz~a^DSf%EU;9Uyd`EXR&j9pJZfe=2B!AT^dfV<>5U!xNoq60feHrb_ zGou@EpwS4{nn?=rTJBM@U_WN=lvTKJ6d5xFkD3^69G()LCSnwl?&7{xLj%#|^O^C^ zy6S4Pc{e*ywD+t#AlyCm%d(RPLRY?2+VYFg#b9Nb(bYZf>@oN$L>0NyOA6_b{uO->+=pUnF|?U)19zl_#)`-Z~lcw`}Xf8$G2(8CAng$ z0n_2r61GnAMI@pV>GA#nDW%f?%hvK#(Fqd56OmUhnV$Ah&ItbMxc+GmjAD-m>EsF> zHif1*Yb_5Sd~Onn_u*C-!1*oW{C5+l59f52QLLYm&WB*oE~6FVjda~H29QwoPS^66|4M(;#I`uIWnK>-&1fjU$N$J z{t*IRxN`8Bz~;ad?0-=n?6y76-V4_~kXy+QB+_HqoBWw}_o2~;sr+M?nnucZ3M&o# zMN;M9`&YrGPzv}&y}K=I5m-^}F#tfjkgX_oW-W;)xA)T#FJJA0nd~w<(cu0^qK~Eo zIfB;rzw%qKPW1`}(4DhlQ4P>4s(*M@SL`X?hlh9~Bb}%MFXI^f0N0C+W~B&@z_YvC z=?AAsN*&Q4d|Tl*y7?!ho)V(S9meFwR&q1wwG@J8&ek7}azK~?6vPxQwZ64Dqfm`Ic94`|+f zkUlNG&})2fobBQ~iWw1KfF*;!I<8I`NYsaP1 zD|!42-Xz>TW=$qiYX2WTune2pQO;Ii4*PByq^7R;Hq{d&heL$;x*?_9dKa_bC^p_n z0y4wKUv0)~&)(@`JO8PwMrw2QF}4Kbji{RnI1!?=rDXNTD_Nu-M_a6`2rHOryQUxf zwtA)F{&#sPMBg|}C=n19u)#e9RYa=lk@PJ#TCDxBK~#F72UlIiQ`uoJ7=LvAh)6Z_ zjo{~Cx6H*d#Y9@&A{?IAYzv+{tm9i%I-`KQ?juZ9`Eu6 zPQcDx0Iu{ur8L^|xTj(dKwv3iM+%ASYc*z(rM=!t<4JUbV#9?4A|> z#J%!an}o)A?G0ePR)Q!PLHG*A=cB@IEts-abyQMpb2I=tHof)whLNRT9%^q9j05YR z$NEn-EA@Kg-#6G{7kp;ab=1=D!UH=)hH!Dl$N!4hvCM zL5=~N@Zn4h$`!#iasvrjuEo3J;`#NQBC6pO1+IsGoXCiJJ7l?Jc&6>GPgt7)Qz_gZ zy4a!t&zF`KL)Es{cE!8v7x^uw#?y)eamk<)e?Rkrjv8E zBy+K*pw|Hta;)hzFhf9AKM|3zkDZ!A+pOJBE}NW*!UZ@FG40wW%j3eb=EU(Mwv>a` zRkCl7hz*ZVr!me(hJ(LhmjcLlWd@ENN#%7oFnNof5(b4>AuVH;yZQ<(=ruBAhUn+v zFL9^gx_j3Md#7cA_Kve90@_xjdY`1MI*OX+wa(O7)J)iPuzLz!RWZ#DA2r$Wch-0A zc3Bab@rtdbg>p^iz3P$^8eBZC+Q8q-Q*9NjfaZl0_MQT1rPdy@te<{@Z@kweMbr~* zPlf|jS?QQ8)Amsh{h$xP??wygLK`Xe65%iDuuo0eGayX0_RnYO)f##W5PcN$>jpZ9 zIPZ-VKoaWqr-CW-!6T79BEI5G#xcRvsz3LsjQ2|XPvy1hjl@ftAh^vZh`&|}t52(% ze%(WE&w!!EyrG{0u6RTLkp7Urb(*;K65W)D#MzgO<$!B|~yh2dz9ywW%t*w93jy zJKaP=)fwbYf7e=n^9eE^7{=yzGyE!>*@fWt8PN6Oj#^v8?3NpAFP;#J1)}yPEMiyo zB2P)&*7Lu@gPA3(xc6QJ5=;xu@cgz8>m7{$4(XY+`topTrVT|g5l za`~k!J~V&|>6*>Wtr%<Jr@(22P(Pyhl1ce6FHv<#rl|H|{oy2ujRMI>vx$M#12egu?QVZF?Z?nt|Fd zmmcHN@bf~(c+pe^Onhy{j;9rOhioSU;(s<(67GNU1rXkMnwVbg@x`z(go)YpF4fc; z?e3Ym`gNi8Ca)PC5b7o%NVRprpbQaRs3W z+?%F798kY1tEqW!mzvbtyqv$SWkD=UC>LxOma!X3EuoGPBb zry?RYP1Wktn7`6!_ckFs(xhf#e_c^^Qmm=&E6pz2N*@N|HeipaWhR!be@0Hxz*+#KeTkU zx(s+Gn=AQ9c50}YSka%Q6y~IE3uxIh9UL8Y%ED02W>3N{r_#9^Is2qc$_GA9TUb=* za=cn3I4J2lQQd^UZs54gAymF^^L7uA<<-hIs`n-Onc`>UpC7$?ckepU+RuIC=V;2} zECb3i&Cr%5Jm9=Dv`izz`g|X7`k;KfOsEzP3~zGg;(Bs7IH}OZUGCTYVn6k`d$r~I zwAlY3!_qgLBU$LkG`y6hBo)` zwGvjR5B41$e@efHfSYcdN50Y&te$Czfea<`b?Mw1B7}Pf^x^5((`wZsPWHO1mZ$>( z*0?nF*}jT{W2MFi%Z={oj!dH=j~_ASh`zoNt>}wv098i}SFS^@X?D~(KEKxSDPgBS zPWpb3H%og7ui@yZZ^73j%C{^JA zim9klu}>NzpJm(!NLx9Uvenh7z}2OH(10g9A=oq0yMq_8Q>DgAbppJb+<6wJ{txwU zbL%z+CapvZYQ*RKx}MvAdEA-T7YYOnDPU$`v2Avo-9%HCwN-W*e8+zhX}>w*S`MlQpb=!?n@cTSRnziO@%ytR#Or9s z9{+P*p?t4Z{(j|C!>@E%hyPsd-~Uelz<({A`&%QQz%LL-v+{n%PMHyZL;Wq(aX`G5 z@8}$6!PVC7-fb?$X_pHpWeQQCMY^6b7Cr(tl!YeQ7;9jdzUf5YY&7aIu%sy+n-Q?N za&~m>kyNg=CWw^I+-B;2@VjAmWr;L>Z&bu3aO27ElNOdvxKM2hShxS`gYWsRKx}A* z!zzagnVyNZVcSOxxKrWjpqt&nNq4h3By29EQR_B31gR7F%}I6sZr`Vi5;b{AN?!k? z{%|@;tNSCf?Dhnes$dOpmp9B&RijfCj^mzEurVC%PRAft3$C^jW&2d}uY_2Cae^Th z2h=%Qte+C^K+|oZnGV*v11~BB6k3VcjrO`Ob6x9RZNwY{7x>_$&Can^tnQ14-BNP@ z9t(GnRJj)MWG7>n(RPzn!r%4jZez8=r}T>I(N&E(jijT=5nSq=K$%YW@lowJI00?L zsqU68`*z!<*EMw16PoN_`c5a=83@HTQ>{B-z&YICo6x#(C+7^(&DU+vhc(<~=~S9e)p&YgR*p()?Q{i^}tU9#0Qt(Osy6f@k|MX>xLXl91&!_v_3_NCHTOG`KE|Y z&^8-x^;c;O#HR<_g9Vg$tm(?46coIS=uyA|y-ClxX_=f-@m{nD;`n0n89_jIVs=Mn z{zaMUpkNv^Pn4cK*^xc2HCZlcwZ$pwDU&*Ugh12 zVb~Gayh7DleMRH&!w9zT{a|H#L(C`@boP|`fz0`V1?PwReiO;^HodzR5)2;($Xxs| z${)03nCte*VpSUzoITdLLL-ZW3oLw<*H$nL!Uv}y> zDujA{Sx8R^PpCORr^;WjgaIm$MwG4HthsWlQ7q?Wd)=PK@~dz>JttNCO^_)BY z^%oQau-zDj|4{m@Um70j;w-xR&8$JJ`z`G$x&9^%BDcaU=*5}tmds-(>#3B*pS-~p z#DE5_q&x>2gskugcrm`I61W$zF~_j}d29VSOZ&iTC+!`8Xn{|`zHuo!dmI_}9J0}! zi5@t+A)p*S>Up?6JZ{(G+5fZZl4H#14?v*6JLN;E1o9O?TklrhdowuTi=&Hvv1OsD zvcdc3*T>JIerS~Pn{;r~Xo6$gLps>!GPxXj?e`wu^|3eQY%s>ITI&HEaF$1`vA6Zo zEOMpW8ljgTzG18wD%rc1$8Vdj;1}yyFl?K0liSH&%~*Iiil0@_#i6?E_OJMd0idPC z{E6fXpE8_V%8npXuEo?8Ph`?kpfTsVprg7a20X|h)%-Yc8mFecQufUQ=M*v3! zPm1~wL&fWhFYiC*2p@Dn#OSj+n6VY{zk1vPmgoaPQ6sytdH3F6TcNWBA$#;u^+RCC<@%GH$AIO0XtXHF0=p^V zBhT)^xXIR6`6Qyh6DZfGndTOoS$O*h&4f3Pl+vIu9KU)_j^&{6w7f&o!~nJORt&fP z#2R#dMw1W!u{QqyjUktJmIrF7l^krxTU%RK`T2)>s%%FSV-xf)Ol^#d-2-oEUNiT} zpqAXww4*ZTFhh-VcK#b#Va{FOI*T~lx?i(21E0BNiLm2rYG@^9fbab)A^$%rZzNCu z^g06)RSscw@x?xN35n|(uGAK^CjpV3kj`U?;+t-OHL^M@x3_d##^c9Fi&eyEuM;RW z(pXx}YZr3Oz^J4iBl)tQT6m;^(>S(Dq|*OEoBy;VGSW)Zx6;rF;Ejt>HvDh2$^dKM zH+hf^I`aBKdDEV$d>yRG5QI{rW#ar;R;2M;MG;H`D|eh;jwbu# zWn?1PLQ;O#FLvAT|5=@g1e_&fr_teiz{h!yc>~S)MV93&&ehivWK8-jGYP}jp4XtE zz?^#u8K&F6g}P~PZmrqy|CEAIBgq`1U5Yd{me&O$$$zVg@QGY-SeI_}XyRvMAJeWd zi*QY(&LQL)@GG|8>1NX;UHf8S)lWc&9aHwZE12Ci&m7P5a=ni(?2mkkR~{nNJ_4!$ z4RYO|7oG?FzFDfij;Rxl+T}0ru)m|Z11wf?uHPA@at+KQMO_p>W8(AlM{K}fPCGkL z>z5BEbf|n2P~t+(6NG*Fey5PM;WEW`&2vA63};M{3v^}1hlPKJY@C8szB4E9c!mS;=^xFg+vop# zY(yk8wZLx}bX-aeC_4g-7tN=$-`_mYaGXQ|m>+}fD#;n=Yxe8maSik=o)?_f65pJRvmGxW*iA4Y5eEt_*4jj= zF89OZcXj(x!`8JD{DGEnXt|N?zRN38;x4zxT@R9e?MOh%d@PsiJHVc|s$zzesp>@NI0MjUI;2)rikuM(ZcYBESGPew% zqN%melIpwM;k0Zebo~*EixPk!VkaaI08n!MDfNV6pyb%Mw^4AMSo`T^z#jRi?w<-l ztiJiEbQry3opz{ex0C(z017R%*C*rqi5Pshqwa10@aXG8UtP6_eE>kkFMAlNh@T~0 z9WQ&8YcaudzGV3pBjdXWRzUXl0louu^|Dl*?9UD7rEkwb9|bq zxq1a!Kcluy=ihIRpKY}%e&s}G8=!e1ORYPNuzmh4t+EMv)KGy>2r!r*{h{tJ|M`* zlWl$Ad#M>HPj*Uaz1-Hn(l)>-dBCW7v{p}-2XPlxSkrYHi+X4BL*d@sJZA?GuF2xv zl{p}JO?)u=mN)IDS%t@U=3M&cmBHUQP?s==|a-h;*-3UkYWRUUUpk! zKuwX-Pif?+`yU1j@3 zD+jfwE!NB(5c4nfCB4t^3g3-2-#slHhJNo}9Wy}Q^AcIms*)QdpZ@ni!S*`Oi!=Es zKl?o0m8912Bv&JyT{*LbXJza5St%RR3FMH}cgP%H=uao{!tA=v*u4z(Lsbq>k)>Mu zj`y(~0yjSL$rU@u?uD#4j0|B(-y&*m$w9V%2fs`qeAYM~ZiyHD;~+!hJcr}v*0f^Z z*VUu(DBdbS%A|W_G04@u##{rkp0x0>2V?O5tE}jc0{v^Fbt{Qx@4?3ZQd3FF1pLfv zck3NWM9S^<@tR!aJ`KthJ2XY@PCscu3OziaT{ghw6R4AyxBsd?!RUy<0}oWIKy8KZ zt^4UQ!xsY@7E&EgAizyFUB*M*Ji$(s+Q1YC1l^3Z7)5l!(Ya!UP&c`i2rp>7U%r8N zMG_zh;}*qqEPMy6fSF-axjBwj6%X{e?isVylhXC-C$Yo!bnY`JHLiIG>Ls*j0RPZP z|6}G)J>r70gg)F4ko0MxelB>G<&M@*ry*CArVgh$tdg}!@>W4+{0MwX5j4CcSk)PasS}oSu=J==1(=jcc!W<=A1L6 zJ=_0BSw8hRAStb5?LQGrpr`+vah#t`-L3$Xm5C7h<(Y(gwVL&-fd2A^!APyi{JC2G zTp)J6C7{zLDfA5Gd}H@yyTABVqBRE{8sz!J8CWvYZ_XV3h|_M=4*x4X=N~0Eu>jgG z>)FmBw3X@FL{*2^a6!Cj4!5ONCn~QtN4-5a<~3qL1Nx78{9lZe7-ljYiUswRZQ@L6 z*)rhiA8r2;WNAOcD-vMj0~9pZLbufdD#U?=ek$7^E848V|kh*V#i?90yY z6fL`3_Ds)g#r!i!3H4`H|3c(+YptZW%+I{?cjF6S3Yn{m4N@dS;2)c*d4fjA!C%r~h(DV4@lbDtpb2$S-omns} zsuN+Ounn~FUy3=o|CN{%XkzZA$}f)c())IIKG*(O3Qp4{l}XEwB^@p$?9 zLm_v2%;N41<{iE@A+^Yv-?N?RoBEh1%W>969^wwT(|}LgRXpSwt!YNYLzR(D&D7HN z@p2rTO)OVb5NWkC##ePL~HDHv+}lsCQsb+DlmWu61$%G z%NDwQ(Ow>I!@~9^C~q8i{Qx-NknQ!1+J|Wm$Qa4y|=kHaIMIt zt}KOdx$)&6W7=8ay)&nK)rFe61sS{1j#Q10N22uwDY1Ubk50hBE2(1Yj#s;k*JHhY z9wspB*=X-SX=IeE8b@%@r5+k}B-RGwR~wypXEHl8N3gEq$6shUup)kZAN8jqQTGpH zCD2X0SoOB(A1>Y@T|pWkz(xPHM6!AM$MqIjDlXOL^o=2`C!Sus)q9r9vHGqK>P2-4 z#4!+WDN#|}!ToVDzNj!yM^hl=-BwI&th&7#gil}P;#}n@>^a?y^6mEq&$#2)R941U zpie(_1-UO~_$<#Sk%!99l{XySBf8fBXGNUMCyD#4XZ$|{7l}JhXtvDeHd2;sS{N@b z%Qs_cVCK}hn(=qm?%MXjeqZpSU5CW1C)MgGc6ZK-MxG%?NIyMJt^s4Ij{QE>Q`pyo?WX} zgS8e_z6+g!a|NxG24=1||C7=dsVEmVCe7Fx8~3gLtdVf>Zrft_iAPv_ldlxapYCY9 z-8@G1>hll$BBWKW3{TX)?_Rtk<=v5@EpejBHVyObowc)n9uof-V7hg639#+;D!=~E zmMpny7yFzQmyPRxCj*Z%aNN2&3_Qee?w|(m!)+l;wuD~1ko()f;PYw6Jt9wIPDQEt z%~`R&=4*Z6+3(>i|F!%7`k%A6a>noG*$SY%wov*T_INt5Cp z;G|RQsuejw+3mTY`Hr@OXa`K4Jkk0Ayox*1H50gd^b~OP$;KZcaOM zull|1)oWK?MDy3)-qypX49p$3L1P6vXMx8rJ$5twenb5b*SoNgwOQNGf2~t+Rl56X z)9X&VJu~jzlMQ|IL2&PD+ik!V=My<^J^MQGgU{~mvE3otciiBA+`Z-Q{d+cMXU}iH z4YW65&F*ivvcKN>^)>464=q`Xf&<%@88Pl%_RG-r^PA#b|9-vBef`3~NO4bkyBYAf z53A#AM1Y%DfagNZOk4Ch)I|4RjKG4$sa|W!WqZF*T>t4Q_n!aK8hUFtc>mPW)DFDP zzU}?I>w7%pLp>Y6pPAzH^2P6yk-!EXLrj2=K>YrPm46eyK7Lj4JNLGf0pp+FTTMYz zY|281s>`=)0gJn%?;Soqs#^JUYfs}1mRAwqH!3*zYX|?lvqB_3e4^Uzw@pE!(Q%u0 zoHVOSyq%N(+w6Df&AW~f^EU@=+!W?|=G((b;vlOG@J#0cr%2ECl#Vw+UfovqZ-ORj+G_3ybS<+LT;a=bKWK`Edu4fcjoO`H;OQ~TXHEhY4Caw# zlYqyO?cXbZY^N%4An^bzaMlgS)Svku#XXAyx32PMOZ(knIwfVNL-8gq<-51GERAk8 zpHUjTPiW0?=F<1DN#6x*kX)w9UWptiOqcu>`%{K^0<`>|gQ@@k literal 0 HcmV?d00001 diff --git a/docs/okta.md b/docs/okta.md new file mode 100644 index 0000000..4124f2b --- /dev/null +++ b/docs/okta.md @@ -0,0 +1,34 @@ +# Sync users from Okta + +## Setup Okta SCIM application + +1. Login to your okta admin console, go to Applications => Browse App Catalog, search for `SCIM 2.0 Test App (Basic Auth)` => Add Integration +2. On the General Settings tab, set application label and click Next +3. On the Sign-On Options you can leave all default values, scroll down and click Done +4. On the application page, go to the Provisioning tab => Configure API Integration => Enable API Integration + - Set the SCIM 2.0 Base Url to https://{scim-endpoint}/ + - Set Username to your configured username + - Set Password to your configured password + - Test API Credentials + - Save +5. Back on the Provisioning tab, on the To App Settings, click Edit, enable Create Users, Update User Attributes and Deactivate Users and Save + +## Provision users + +For provisoning users, a user needs to be assigned to the SCIM application. +1. Go to the Assignments tab +2. Click on Assign => Assign to People => Assign wanted users and click Done. +Your user should show up in the Directory +Any updates to a property that is mapped to a SCIM attribute, should trigger a user update in Aserto. + +## Provision groups + +For provisoning groups, a group needs to be assigned to the SCIM application. +1. Go to the Assignments tab +2. Click on Assign => Assign to People => Assign your group and click Done. +3. Go to Push Groups tab => Push Groups => Find groups by name => search for your group and click Save + +Groups and group membership should be provisioned now. + +## Troubleshooting +Please note that any errors on provisioning groups will pause the group provisioning. If a group was provisioned, Okta does keep a state for that provisioned group, so removing it from Aserto before attempting to unlink it from the Okta app can cause issues. If this happens, the group needs to be unlinked and reassigned to the app. From 0062e0e9f928c5dbdbc9e12e30397cc65f61cbc9 Mon Sep 17 00:00:00 2001 From: florindragos Date: Wed, 23 Apr 2025 14:43:03 +0300 Subject: [PATCH 2/5] use base64 encoded ids --- common/assets/users-groups-roles.tmpl | 42 +++++++++---------------- common/assets/users-groups.tmpl | 38 +++++++--------------- common/assets/users.tmpl | 34 ++++++-------------- common/convert/convert.go | 8 +++-- common/convert/converter_test.go | 17 ++++++---- common/handlers/groups/create.go | 2 +- common/handlers/groups/patch.go | 2 +- common/handlers/users/create.go | 2 +- common/handlers/users/patch.go | 2 +- pkg/test/assets/assets.go | 9 +++++- pkg/test/assets/data/morty.json | 2 +- pkg/test/assets/data/rick.json | 2 +- pkg/test/scim_test.go | 45 ++++++++++++++++++++------- 13 files changed, 99 insertions(+), 106 deletions(-) diff --git a/common/assets/users-groups-roles.tmpl b/common/assets/users-groups-roles.tmpl index c4ab3df..efac962 100644 --- a/common/assets/users-groups-roles.tmpl +++ b/common/assets/users-groups-roles.tmpl @@ -2,7 +2,7 @@ "objects": [ {{- if eq .objectType "user" }} { - "id": "{{ $.input.userName }}", + "id": "{{ $.objectId }}", "type": "{{ $.vars.user.object_type }}", "displayName": "{{ $.input.displayName }}" }, @@ -50,7 +50,7 @@ {{- end }} {{- else }} { - "id": "{{ $.input.displayName }}", + "id": "{{ $.objectId }}", "type": "{{ $.vars.group.object_type }}", "displayName": "{{ $.input.displayName }}" } @@ -61,14 +61,10 @@ {{- $idRelationMap := splitn "#" 2 $.vars.user.identity_relation }} {{- $idObjType := $idRelationMap._0 }} {{- $idRelation := $idRelationMap._1 }} - {{- $idSubjType := $.vars.user.object_type }} - {{- $objId := $.input.userName }} - {{- $subjId := $.input.userName }} - - {{- if eq $idObjType $.vars.user.object_type }} - {{- $idSubjType = $.vars.user.identity_object_type }} - {{- $subjId = $.input.userName }} - {{- end }} + {{- $idSubjType := ternary $.vars.user.identity_object_type $.vars.user.object_type (eq $idObjType $.vars.user.object_type) }} + + {{- $objId := ternary $.objectId $.input.userName (eq $idObjType $.vars.user.object_type) }} + {{- $subjId := ternary $.input.userName $.objectId (eq $idObjType $.vars.user.object_type) }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -77,14 +73,9 @@ "subject_id": "{{ $subjId }}" }, {{- range $i, $element := $.input.emails }} - {{- if $i }},{{ end }} - {{- if eq $idObjType $.vars.user.object_type }} - {{- $subjId = $element.value }} - {{- $objId := $.input.userName }} - {{- else }} - {{- $subjId := $.input.userName }} - {{- $objId = $element.value }} - {{- end }} + {{- $objId := ternary $.objectId $element.value (eq $idObjType $.vars.user.object_type) }} + {{- $subjId := ternary $element.value $.objectId (eq $idObjType $.vars.user.object_type) }} + {{ if $i }},{{ end }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -95,13 +86,8 @@ {{- end }} {{- if $.input.externalId }} , - {{- if eq $idObjType $.vars.user.object_type }} - {{- $objId := $.input.userName }} - {{- $subjId = $.input.externalId }} - {{- else }} - {{- $objId = $.input.externalId }} - {{- $subjId = $.input.userName }} - {{- end }} + {{- $objId := ternary $.objectId $.input.externalId (eq $idObjType $.vars.user.object_type) }} + {{- $subjId := ternary $.input.externalId $.objectId (eq $idObjType $.vars.user.object_type) }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -117,7 +103,7 @@ , { "object_type": "{{ $.vars.user.object_type }}", - "object_id": "{{ $.input.userName }}", + "object_id": "{{ $.objectId }}", "relation": "{{ $.vars.user.manager_relation }}", "subject_type": "{{ $.vars.user.object_type }}", "subject_id": "{{ $manager.manager.value }}" @@ -133,7 +119,7 @@ "object_id": "{{ $element.value }}", "relation": "{{ $.vars.role.role_relation }}", "subject_type": "{{ $.vars.user.object_type }}", - "subject_id": "{{ $.input.userName }}" + "subject_id": "{{ $.objectId }}" } {{- end }} {{- end }} @@ -144,7 +130,7 @@ {{ if $i }},{{ end }} { "object_type": "{{ $.vars.group.object_type }}", - "object_id": "{{ $.input.displayName }}", + "object_id": "{{ $.objectId }}", "relation": "{{ $.vars.group.group_member_relation }}", "subject_type": "{{ $.vars.user.object_type }}", "subject_id": "{{ $member.value }}" diff --git a/common/assets/users-groups.tmpl b/common/assets/users-groups.tmpl index feb0acc..df6a7d2 100644 --- a/common/assets/users-groups.tmpl +++ b/common/assets/users-groups.tmpl @@ -2,7 +2,7 @@ "objects": [ {{ if eq .objectType "user" }} { - "id": "{{ $.input.userName }}", + "id": "{{ $.objectId }}", "type": "{{ $.vars.user.object_type }}", "displayName": "{{ $.input.displayName }}" }, @@ -36,7 +36,7 @@ {{ end }} {{ else }} { - "id": "{{ $.input.displayName }}", + "id": "{{ $.objectId }}", "type": "{{ $.vars.group.object_type }}", "displayName": "{{ $.input.displayName }}" } @@ -47,14 +47,10 @@ {{ $idRelationMap := splitn "#" 2 $.vars.user.identity_relation }} {{ $idObjType := $idRelationMap._0 }} {{ $idRelation := $idRelationMap._1 }} - {{ $idSubjType := $.vars.user.object_type }} - {{ $objId := $.input.userName }} - {{ $subjId := $.input.userName }} - - {{ if eq $idObjType $.vars.user.object_type }} - {{ $idSubjType = $.vars.user.identity_object_type }} - {{ $subjId = $.input.userName }} - {{ end }} + {{ $idSubjType := ternary $.vars.user.identity_object_type $.vars.user.object_type (eq $idObjType $.vars.user.object_type) }} + + {{ $objId := ternary $.objectId $.input.userName (eq $idObjType $.vars.user.object_type) }} + {{ $subjId := ternary $.input.userName $.objectId (eq $idObjType $.vars.user.object_type) }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -63,14 +59,9 @@ "subject_id": "{{ $subjId }}" }, {{ range $i, $element := $.input.emails }} + {{ $objId := ternary $.objectId $element.value (eq $idObjType $.vars.user.object_type) }} + {{ $subjId := ternary $element.value $.objectId (eq $idObjType $.vars.user.object_type) }} {{ if $i }},{{ end }} - {{ if eq $idObjType $.vars.user.object_type }} - {{ $subjId = $element.value }} - {{ $objId := $.input.userName }} - {{ else }} - {{ $subjId := $.input.userName }} - {{ $objId = $element.value }} - {{ end }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -81,13 +72,8 @@ {{ end }} {{ if and ($.input.externalId) (ne $.input.externalId "") }} , - {{ if eq $idObjType $.vars.user.object_type }} - {{ $objId := $.input.userName }} - {{ $subjId = $.input.externalId }} - {{ else }} - {{ $objId = $.input.externalId }} - {{ $subjId = $.input.userName }} - {{ end }} + {{ $objId := ternary $.objectId $.input.externalId (eq $idObjType $.vars.user.object_type) }} + {{ $subjId := ternary $.input.externalId $.objectId (eq $idObjType $.vars.user.object_type) }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -103,7 +89,7 @@ , { "object_type": "{{ $.vars.user.object_type }}", - "object_id": "{{ $.input.userName }}", + "object_id": "{{ $.objectId }}", "relation": "{{ $.vars.user.manager_relation }}", "subject_type": "{{ $.vars.user.object_type }}", "subject_id": "{{ $manager.manager.value }}" @@ -118,7 +104,7 @@ {{ if $i }},{{ end }} { "object_type": "{{ $.vars.group.object_type }}", - "object_id": "{{ $.input.displayName }}", + "object_id": "{{ b64enc $.input.displayName }}", "relation": "{{ $.vars.group.group_member_relation }}", "subject_type": "{{ $.vars.user.object_type }}", "subject_id": "{{ $member.value }}" diff --git a/common/assets/users.tmpl b/common/assets/users.tmpl index 42016b0..bec770e 100644 --- a/common/assets/users.tmpl +++ b/common/assets/users.tmpl @@ -2,7 +2,7 @@ "objects": [ {{ if eq .objectType "user" }} { - "id": "{{ $.input.userName }}", + "id": "{{ $.objectId }}", "type": "{{ $.vars.user.object_type }}", "displayName": "{{ $.input.displayName }}" }, @@ -41,14 +41,10 @@ {{ $idRelationMap := splitn "#" 2 $.vars.user.identity_relation }} {{ $idObjType := $idRelationMap._0 }} {{ $idRelation := $idRelationMap._1 }} - {{ $idSubjType := $.vars.user.object_type }} - {{ $objId := $.input.userName }} - {{ $subjId := $.input.userName }} - - {{ if eq $idObjType $.vars.user.object_type }} - {{ $idSubjType = $.vars.user.identity_object_type }} - {{ $subjId = $.input.userName }} - {{ end }} + {{ $idSubjType := ternary $.vars.user.identity_object_type $.vars.user.object_type (eq $idObjType $.vars.user.object_type) }} + + {{ $objId := ternary $.objectId $.input.userName (eq $idObjType $.vars.user.object_type) }} + {{ $subjId := ternary $.input.userName $.objectId (eq $idObjType $.vars.user.object_type) }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -57,14 +53,9 @@ "subject_id": "{{ $subjId }}" }, {{ range $i, $element := $.input.emails }} + {{ $objId := ternary $.objectId $element.value (eq $idObjType $.vars.user.object_type) }} + {{ $subjId := ternary $element.value $.objectId (eq $idObjType $.vars.user.object_type) }} {{ if $i }},{{ end }} - {{ if eq $idObjType $.vars.user.object_type }} - {{ $subjId = $element.value }} - {{ $objId := $.input.userName }} - {{ else }} - {{ $subjId := $.input.userName }} - {{ $objId = $element.value }} - {{ end }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -75,13 +66,8 @@ {{ end }} {{ if and ($.input.externalId) (ne $.input.externalId "") }} , - {{ if eq $idObjType $.vars.user.object_type }} - {{ $objId := $.input.userName }} - {{ $subjId = $.input.externalId }} - {{ else }} - {{ $objId = $.input.externalId }} - {{ $subjId = $.input.userName }} - {{ end }} + {{ $objId := ternary $.objectId $.input.externalId (eq $idObjType $.vars.user.object_type) }} + {{ $subjId := ternary $.input.externalId $.objectId (eq $idObjType $.vars.user.object_type) }} { "object_type": "{{ $idObjType }}", "object_id": "{{ $objId }}", @@ -97,7 +83,7 @@ , { "object_type": "{{ $.vars.user.object_type }}", - "object_id": "{{ $.input.userName }}", + "object_id": "{{ $.objectId }}", "relation": "{{ $.vars.user.manager_relation }}", "subject_type": "{{ $.vars.user.object_type }}", "subject_id": "{{ $manager.manager.value }}" diff --git a/common/convert/convert.go b/common/convert/convert.go index ea08673..9ce2094 100644 --- a/common/convert/convert.go +++ b/common/convert/convert.go @@ -1,6 +1,7 @@ package convert import ( + "encoding/base64" "encoding/json" "github.com/aserto-dev/ds-load/sdk/common/msg" @@ -91,7 +92,7 @@ func (c *Converter) SCIMUserToObject(user *model.User) (*dsc.Object, error) { return nil, err } - userID := lo.Ternary(user.ID != "", user.ID, user.UserName) + userID := lo.Ternary(user.ID != "", user.ID, base64.StdEncoding.EncodeToString([]byte(user.UserName))) displayName := lo.Ternary(user.DisplayName != "", user.DisplayName, userID) object := &dsc.Object{ @@ -120,7 +121,7 @@ func (c *Converter) SCIMGroupToObject(group *model.Group) (*dsc.Object, error) { return nil, err } - objID := lo.Ternary(group.ID != "", group.ID, group.DisplayName) + objID := lo.Ternary(group.ID != "", group.ID, base64.StdEncoding.EncodeToString([]byte(group.DisplayName))) displayName := lo.Ternary(group.DisplayName != "", group.DisplayName, objID) object := &dsc.Object{ @@ -133,7 +134,7 @@ func (c *Converter) SCIMGroupToObject(group *model.Group) (*dsc.Object, error) { return object, nil } -func (c *Converter) TransformResource(resource map[string]any, objType string) (*msg.Transform, error) { +func (c *Converter) TransformResource(resource map[string]any, id, objType string) (*msg.Transform, error) { template, err := c.cfg.Template() if err != nil { return nil, err @@ -148,6 +149,7 @@ func (c *Converter) TransformResource(resource map[string]any, objType string) ( "input": resource, "vars": vars, "objectType": objType, + "objectId": id, } transformer := transform.NewGoTemplateTransform(template) diff --git a/common/convert/converter_test.go b/common/convert/converter_test.go index 6d5aca0..b522cbd 100644 --- a/common/convert/converter_test.go +++ b/common/convert/converter_test.go @@ -1,6 +1,7 @@ package convert_test import ( + "encoding/base64" "testing" "github.com/aserto-dev/scim/common/config" @@ -42,11 +43,13 @@ func TestTransform(t *testing.T) { }, } + userID := base64.StdEncoding.EncodeToString([]byte("foobar")) + sCfg, err := convert.NewTransformConfig(&cfg) assert.NoError(err) cvt := convert.NewConverter(sCfg) - msg, err := cvt.TransformResource(ScimUser, "user") + msg, err := cvt.TransformResource(ScimUser, userID, "user") assert.NoError(err) assert.NotNil(msg) @@ -57,10 +60,10 @@ func TestTransform(t *testing.T) { assert.Equal("foo@bar.com", msg.GetRelations()[1].GetObjectId()) assert.Equal("identity", msg.GetRelations()[1].GetObjectType()) assert.Equal("identitifier", msg.GetRelations()[1].GetRelation()) - assert.Equal("foobar", msg.GetRelations()[1].GetSubjectId()) + assert.Equal(userID, msg.GetRelations()[1].GetSubjectId()) assert.Equal("user", msg.GetRelations()[1].GetSubjectType()) - assert.Equal("foobar", msg.GetRelations()[0].GetSubjectId()) + assert.Equal(userID, msg.GetRelations()[0].GetSubjectId()) assert.Equal("user", msg.GetRelations()[0].GetSubjectType()) assert.Equal("fooooo", msg.GetRelations()[2].GetObjectId()) @@ -80,11 +83,13 @@ func TestTransformUserIdentifier(t *testing.T) { }, } + userID := base64.StdEncoding.EncodeToString([]byte("foobar")) + sCfg, err := convert.NewTransformConfig(&cfg) assert.NoError(err) cvt := convert.NewConverter(sCfg) - msg, err := cvt.TransformResource(ScimUser, "user") + msg, err := cvt.TransformResource(ScimUser, userID, "user") assert.NoError(err) assert.NotNil(msg) @@ -93,10 +98,10 @@ func TestTransformUserIdentifier(t *testing.T) { assert.Equal("foo@bar.com", msg.GetRelations()[1].GetSubjectId()) assert.Equal("identity", msg.GetRelations()[1].GetSubjectType()) assert.Equal("identitifier", msg.GetRelations()[1].GetRelation()) - assert.Equal("foobar", msg.GetRelations()[1].GetObjectId()) + assert.Equal(userID, msg.GetRelations()[1].GetObjectId()) assert.Equal("user", msg.GetRelations()[1].GetObjectType()) - assert.Equal("foobar", msg.GetRelations()[0].GetObjectId()) + assert.Equal(userID, msg.GetRelations()[0].GetObjectId()) assert.Equal("user", msg.GetRelations()[0].GetObjectType()) assert.Equal("fooooo", msg.GetRelations()[2].GetSubjectId()) diff --git a/common/handlers/groups/create.go b/common/handlers/groups/create.go index 11c04a0..da2dc4a 100644 --- a/common/handlers/groups/create.go +++ b/common/handlers/groups/create.go @@ -45,7 +45,7 @@ func (g GroupResourceHandler) Create(ctx context.Context, attributes scim.Resour return scim.Resource{}, err } - transformResult, err := converter.TransformResource(attributes, "group") + transformResult, err := converter.TransformResource(attributes, sourceGroupResp.GetResult().GetId(), "group") if err != nil { logger.Err(err).Msg("failed to transform group") return scim.Resource{}, serrors.ScimErrorInvalidSyntax diff --git a/common/handlers/groups/patch.go b/common/handlers/groups/patch.go index 2916ff1..23f6f2d 100644 --- a/common/handlers/groups/patch.go +++ b/common/handlers/groups/patch.go @@ -84,7 +84,7 @@ func (g GroupResourceHandler) updateGroup( converter *convert.Converter, logger zerolog.Logger, ) (scim.Resource, error) { - transformResult, err := converter.TransformResource(attr, "group") + transformResult, err := converter.TransformResource(attr, groupObj.GetId(), "group") if err != nil { logger.Err(err).Msg("failed to convert group to object") return scim.Resource{}, serrors.ScimErrorInvalidSyntax diff --git a/common/handlers/users/create.go b/common/handlers/users/create.go index ed7b672..a12206b 100644 --- a/common/handlers/users/create.go +++ b/common/handlers/users/create.go @@ -85,7 +85,7 @@ func (u UsersResourceHandler) processUserResponse( return scim.Resource{}, err } - transformResult, err := converter.TransformResource(userMap, "user") + transformResult, err := converter.TransformResource(userMap, sourceUserResp.GetResult().GetId(), "user") if err != nil { logger.Err(err).Msg("failed to convert user to object") return scim.Resource{}, serrors.ScimErrorInvalidSyntax diff --git a/common/handlers/users/patch.go b/common/handlers/users/patch.go index ae0f9d8..db5d237 100644 --- a/common/handlers/users/patch.go +++ b/common/handlers/users/patch.go @@ -82,7 +82,7 @@ func (u UsersResourceHandler) updateUser( converter *convert.Converter, logger zerolog.Logger, ) (scim.Resource, error) { - transformResult, err := converter.TransformResource(attr, "user") + transformResult, err := converter.TransformResource(attr, userObj.GetId(), "user") if err != nil { logger.Err(err).Msg("failed to convert user to object") return scim.Resource{}, serrors.ScimErrorInvalidSyntax diff --git a/pkg/test/assets/assets.go b/pkg/test/assets/assets.go index 44e025d..eab914a 100644 --- a/pkg/test/assets/assets.go +++ b/pkg/test/assets/assets.go @@ -20,6 +20,9 @@ var patch []byte //go:embed data/manifest.yaml var manifest []byte +//go:embed data/group.json +var group []byte + func TopazConfigReader() *bytes.Reader { return bytes.NewReader(topazConfig) } @@ -32,10 +35,14 @@ func Morty() []byte { return mortyJson } -func Patch() []byte { +func PatchOp() []byte { return patch } +func Group() []byte { + return group +} + func Manifest() []byte { return manifest } diff --git a/pkg/test/assets/data/morty.json b/pkg/test/assets/data/morty.json index 2a52677..e9ef510 100644 --- a/pkg/test/assets/data/morty.json +++ b/pkg/test/assets/data/morty.json @@ -1,6 +1,6 @@ { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName": "morty@the-citadel.com", + "userName": "mortysmith", "name": { "givenName": "Morty", "familyName": "Smith" diff --git a/pkg/test/assets/data/rick.json b/pkg/test/assets/data/rick.json index 833b3d0..8298815 100644 --- a/pkg/test/assets/data/rick.json +++ b/pkg/test/assets/data/rick.json @@ -1,6 +1,6 @@ { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName": "rick@the-citadel.com", + "userName": "ricksanchez", "name": { "givenName": "Rick", "familyName": "Sanchez" diff --git a/pkg/test/scim_test.go b/pkg/test/scim_test.go index ed52f4b..c8385b3 100644 --- a/pkg/test/scim_test.go +++ b/pkg/test/scim_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/require" ) +const scimMediaType = "application/scim+json" + func TestScim(t *testing.T) { // Setup test containers tst := common_test.TestSetup(t) @@ -21,32 +23,51 @@ func TestScim(t *testing.T) { rick := map[string]any{} err := json.Unmarshal(assets_test.Rick(), &rick) require.NoError(t, err) + e.GET("/Users").WithBasicAuth("scim", "scim").Expect().Status(200) - e.POST("/Users").WithBasicAuth("scim", "scim").WithJSON(rick).Expect().Status(201).Body().Contains("Rick Sanchez") + + rickID := e.POST("/Users").WithBasicAuth("scim", "scim").WithJSON(rick).Expect(). + Status(201).JSON(httpexpect.ContentOpts{MediaType: scimMediaType}).Object().Value("id").String() + + rickID.NotEmpty() e.GET("/Users").WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Rick Sanchez") - e.GET("/Users/rick@the-citadel.com").WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Rick Sanchez") + e.GET("/Users/"+rickID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Rick Sanchez") // Create user for Morty morty := map[string]any{} err = json.Unmarshal(assets_test.Morty(), &morty) require.NoError(t, err) - e.POST("/Users").WithBasicAuth("scim", "scim").WithJSON(morty).Expect().Status(201).Body().Contains("Morty Smith") - e.GET("/Users/morty@the-citadel.com").WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Morty Smith") - require.True(t, tst.UserHasIdentity(t.Context(), "morty@the-citadel.com", "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs")) - require.True(t, tst.UserHasManager(t.Context(), "morty@the-citadel.com", "rick@the-citadel.com")) - require.Equal(t, true, tst.ReadUserProperty(t.Context(), "morty@the-citadel.com", "enabled")) + mortyID := e.POST("/Users").WithBasicAuth("scim", "scim").WithJSON(morty).Expect(). + Status(201).JSON(httpexpect.ContentOpts{MediaType: scimMediaType}).Object().Value("id").String() + + mortyID.NotEmpty() + e.GET("/Users/"+mortyID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Morty Smith") + + require.True(t, tst.UserHasIdentity(t.Context(), mortyID.Raw(), "CiRmZDE2MTRkMy1jMzlhLTQ3ODEtYjdiZC04Yjk2ZjVhNTEwMGQSBWxvY2Fs")) + require.True(t, tst.UserHasManager(t.Context(), mortyID.Raw(), "rick@the-citadel.com")) + require.Equal(t, true, tst.ReadUserProperty(t.Context(), mortyID.Raw(), "enabled")) // Update Morty patchMorty := map[string]any{} - err = json.Unmarshal(assets_test.Patch(), &patchMorty) + err = json.Unmarshal(assets_test.PatchOp(), &patchMorty) require.NoError(t, err) - e.PATCH("/Users/morty@the-citadel.com").WithBasicAuth("scim", "scim").WithJSON(patchMorty).Expect().Status(200).Body().Contains("Morty Smith") - require.Equal(t, false, tst.ReadUserProperty(t.Context(), "morty@the-citadel.com", "enabled")) + e.PATCH("/Users/"+mortyID.Raw()).WithBasicAuth("scim", "scim").WithJSON(patchMorty).Expect().Status(200).Body().Contains("Morty Smith") + require.Equal(t, false, tst.ReadUserProperty(t.Context(), mortyID.Raw(), "enabled")) // Delete Morty - e.DELETE("/Users/morty@the-citadel.com").WithBasicAuth("scim", "scim").Expect().Status(204) - e.GET("/Users/morty@the-citadel.com").WithBasicAuth("scim", "scim").Expect().Status(404) + e.DELETE("/Users/"+mortyID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(204) + e.GET("/Users/"+mortyID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(404) + + group := map[string]any{} + err = json.Unmarshal(assets_test.Group(), &group) + require.NoError(t, err) + + groupID := e.POST("/Groups").WithBasicAuth("scim", "scim").WithJSON(group).Expect(). + Status(201).JSON(httpexpect.ContentOpts{MediaType: scimMediaType}).Object().Value("id").String() + + groupID.NotEmpty() + e.GET("/Groups/"+groupID.Raw()).WithBasicAuth("scim", "scim").Expect().Status(200).Body().Contains("Evil Genius") t.Logf("topaz log:\n%s", tst.ContainerLogs(t.Context(), t)) } From 62fb7108c76e628085d86a72b2752b85133aa354 Mon Sep 17 00:00:00 2001 From: florindragos Date: Thu, 24 Apr 2025 16:00:47 +0300 Subject: [PATCH 3/5] use one default template --- common/assets.go | 12 +- ...{users-groups-roles.tmpl => template.tmpl} | 0 common/assets/users-groups.tmpl | 116 ------------------ common/assets/users.tmpl | 96 --------------- common/convert/config.go | 46 ++----- common/convert/convert.go | 8 +- pkg/app/run.go | 10 ++ pkg/config/config.go | 3 +- 8 files changed, 31 insertions(+), 260 deletions(-) rename common/assets/{users-groups-roles.tmpl => template.tmpl} (100%) delete mode 100644 common/assets/users-groups.tmpl delete mode 100644 common/assets/users.tmpl diff --git a/common/assets.go b/common/assets.go index 6943707..94559bc 100644 --- a/common/assets.go +++ b/common/assets.go @@ -1,14 +1,12 @@ package common import ( - "embed" - "fmt" + _ "embed" ) -//go:embed assets/* -var staticAssets embed.FS +//go:embed assets/template.tmpl +var template []byte -func LoadTemplate(templateName string) ([]byte, error) { - templateFile := fmt.Sprintf("assets/%s.tmpl", templateName) - return staticAssets.ReadFile(templateFile) +func LoadDefaultTemplate() []byte { + return template } diff --git a/common/assets/users-groups-roles.tmpl b/common/assets/template.tmpl similarity index 100% rename from common/assets/users-groups-roles.tmpl rename to common/assets/template.tmpl diff --git a/common/assets/users-groups.tmpl b/common/assets/users-groups.tmpl deleted file mode 100644 index df6a7d2..0000000 --- a/common/assets/users-groups.tmpl +++ /dev/null @@ -1,116 +0,0 @@ -{ - "objects": [ - {{ if eq .objectType "user" }} - { - "id": "{{ $.objectId }}", - "type": "{{ $.vars.user.object_type }}", - "displayName": "{{ $.input.displayName }}" - }, - { - "id": "{{ $.input.userName }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties": { - "verified": true - } - }, - {{ range $i, $element := $.input.emails }} - {{ if $i }},{{ end }} - { - "id": "{{ $element.value }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties":{ - "type": "{{ $element.type }}", - "verified": true - } - } - {{ end }} - {{ if and ($.input.externalId) (ne $.input.externalId "") }} - , - { - "id": "{{ $.input.externalId }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties": { - "verified": true - } - } - {{ end }} - {{ else }} - { - "id": "{{ $.objectId }}", - "type": "{{ $.vars.group.object_type }}", - "displayName": "{{ $.input.displayName }}" - } - {{ end }} - ], - "relations":[ - {{ if eq .objectType "user" }} - {{ $idRelationMap := splitn "#" 2 $.vars.user.identity_relation }} - {{ $idObjType := $idRelationMap._0 }} - {{ $idRelation := $idRelationMap._1 }} - {{ $idSubjType := ternary $.vars.user.identity_object_type $.vars.user.object_type (eq $idObjType $.vars.user.object_type) }} - - {{ $objId := ternary $.objectId $.input.userName (eq $idObjType $.vars.user.object_type) }} - {{ $subjId := ternary $.input.userName $.objectId (eq $idObjType $.vars.user.object_type) }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - }, - {{ range $i, $element := $.input.emails }} - {{ $objId := ternary $.objectId $element.value (eq $idObjType $.vars.user.object_type) }} - {{ $subjId := ternary $element.value $.objectId (eq $idObjType $.vars.user.object_type) }} - {{ if $i }},{{ end }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - } - {{ end }} - {{ if and ($.input.externalId) (ne $.input.externalId "") }} - , - {{ $objId := ternary $.objectId $.input.externalId (eq $idObjType $.vars.user.object_type) }} - {{ $subjId := ternary $.input.externalId $.objectId (eq $idObjType $.vars.user.object_type) }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - } - {{ end }} - {{ if and ($.vars.user.manager_relation) (ne $.vars.user.manager_relation "") }} - {{ $manager := index .input "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" }} - {{ if $manager }} - {{ if and ($manager.manager.value) (ne $manager.manager.value "") }} - , - { - "object_type": "{{ $.vars.user.object_type }}", - "object_id": "{{ $.objectId }}", - "relation": "{{ $.vars.user.manager_relation }}", - "subject_type": "{{ $.vars.user.object_type }}", - "subject_id": "{{ $manager.manager.value }}" - } - {{ end }} - {{ end }} - {{ end }} - {{ else }} - {{ $members := index .input "members" }} - {{ if $members }} - {{ range $i, $member := $members }} - {{ if $i }},{{ end }} - { - "object_type": "{{ $.vars.group.object_type }}", - "object_id": "{{ b64enc $.input.displayName }}", - "relation": "{{ $.vars.group.group_member_relation }}", - "subject_type": "{{ $.vars.user.object_type }}", - "subject_id": "{{ $member.value }}" - } - {{ end }} - {{ end }} - {{ end }} - ] -} diff --git a/common/assets/users.tmpl b/common/assets/users.tmpl deleted file mode 100644 index bec770e..0000000 --- a/common/assets/users.tmpl +++ /dev/null @@ -1,96 +0,0 @@ -{ - "objects": [ - {{ if eq .objectType "user" }} - { - "id": "{{ $.objectId }}", - "type": "{{ $.vars.user.object_type }}", - "displayName": "{{ $.input.displayName }}" - }, - { - "id": "{{ $.input.userName }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties": { - "verified": true - } - }, - {{ range $i, $element := $.input.emails }} - {{ if $i }},{{ end }} - { - "id": "{{ $element.value }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties":{ - "type": "{{ $element.type }}", - "verified": true - } - } - {{ end }} - {{ if and ($.input.externalId) (ne $.input.externalId "") }} - , - { - "id": "{{ $.input.externalId }}", - "type": "{{ $.vars.user.identity_object_type }}", - "properties": { - "verified": true - } - } - {{ end }} - {{ end }} - ], - "relations":[ - {{ if eq .objectType "user" }} - {{ $idRelationMap := splitn "#" 2 $.vars.user.identity_relation }} - {{ $idObjType := $idRelationMap._0 }} - {{ $idRelation := $idRelationMap._1 }} - {{ $idSubjType := ternary $.vars.user.identity_object_type $.vars.user.object_type (eq $idObjType $.vars.user.object_type) }} - - {{ $objId := ternary $.objectId $.input.userName (eq $idObjType $.vars.user.object_type) }} - {{ $subjId := ternary $.input.userName $.objectId (eq $idObjType $.vars.user.object_type) }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - }, - {{ range $i, $element := $.input.emails }} - {{ $objId := ternary $.objectId $element.value (eq $idObjType $.vars.user.object_type) }} - {{ $subjId := ternary $element.value $.objectId (eq $idObjType $.vars.user.object_type) }} - {{ if $i }},{{ end }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - } - {{ end }} - {{ if and ($.input.externalId) (ne $.input.externalId "") }} - , - {{ $objId := ternary $.objectId $.input.externalId (eq $idObjType $.vars.user.object_type) }} - {{ $subjId := ternary $.input.externalId $.objectId (eq $idObjType $.vars.user.object_type) }} - { - "object_type": "{{ $idObjType }}", - "object_id": "{{ $objId }}", - "relation": "{{ $idRelation }}", - "subject_type": "{{ $idSubjType }}", - "subject_id": "{{ $subjId }}" - } - {{ end }} - {{ if and ($.vars.user.manager_relation) (ne $.vars.user.manager_relation "") }} - {{ $manager := index .input "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" }} - {{ if $manager }} - {{ if and ($manager.manager.value) (ne $manager.manager.value "") }} - , - { - "object_type": "{{ $.vars.user.object_type }}", - "object_id": "{{ $.objectId }}", - "relation": "{{ $.vars.user.manager_relation }}", - "subject_type": "{{ $.vars.user.object_type }}", - "subject_id": "{{ $manager.manager.value }}" - } - {{ end }} - {{ end }} - {{ end }} - {{ end }} - ] -} diff --git a/common/convert/config.go b/common/convert/config.go index faf489e..2f0f9e1 100644 --- a/common/convert/config.go +++ b/common/convert/config.go @@ -10,46 +10,16 @@ import ( "github.com/pkg/errors" ) -type TemplateKind int - -const ( - Users TemplateKind = iota - UsersGroups - UsersGroupsRoles -) - var ErrInvalidConfig = errors.New("invalid config") -func (t TemplateKind) String() string { - switch t { - case Users: - return "users" - case UsersGroups: - return "users-groups" - case UsersGroupsRoles: - return "users-groups-roles" - } - - return "unknown" -} - type TransformConfig struct { *config.Config - template TemplateKind + template []byte IdentityObjectType string `json:"identity_object_type,omitempty"` IdentityRelation string `json:"identity_relation,omitempty"` } func NewTransformConfig(cfg *config.Config) (*TransformConfig, error) { - template := Users - - if cfg.HasGroups() { - template = UsersGroups - if cfg.Role != nil { - template = UsersGroupsRoles - } - } - object, relation, found := strings.Cut(cfg.User.IdentityRelation, "#") if !found { return nil, errors.Wrap(ErrInvalidConfig, "identity relation must be in the format object#relation") @@ -65,7 +35,6 @@ func NewTransformConfig(cfg *config.Config) (*TransformConfig, error) { return &TransformConfig{ Config: cfg, - template: template, IdentityObjectType: object, IdentityRelation: relation, }, nil @@ -86,8 +55,17 @@ func (c *TransformConfig) ToTemplateVars() (map[string]any, error) { return result, nil } -func (c *TransformConfig) Template() ([]byte, error) { - return common.LoadTemplate(c.template.String()) +func (c *TransformConfig) Template() []byte { + if c.template == nil { + return common.LoadDefaultTemplate() + } + + return c.template +} + +func (c *TransformConfig) WithTemplate(template []byte) *TransformConfig { + c.template = template + return c } func (c *TransformConfig) ParseIdentityRelation(userID, identity string) (*dsc.Relation, error) { diff --git a/common/convert/convert.go b/common/convert/convert.go index 9ce2094..8ede330 100644 --- a/common/convert/convert.go +++ b/common/convert/convert.go @@ -135,23 +135,19 @@ func (c *Converter) SCIMGroupToObject(group *model.Group) (*dsc.Object, error) { } func (c *Converter) TransformResource(resource map[string]any, id, objType string) (*msg.Transform, error) { - template, err := c.cfg.Template() - if err != nil { - return nil, err - } - vars, err := c.cfg.ToTemplateVars() if err != nil { return nil, err } + transformer := transform.NewGoTemplateTransform(c.cfg.Template()) + transformInput := map[string]any{ "input": resource, "vars": vars, "objectType": objType, "objectId": id, } - transformer := transform.NewGoTemplateTransform(template) return transformer.TransformObject(transformInput) } diff --git a/pkg/app/run.go b/pkg/app/run.go index 18b8c39..bf2e006 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "fmt" "net/http" + "os" "strings" "github.com/aserto-dev/go-aserto/ds/v3" @@ -160,6 +161,15 @@ func (s *SCIMServer) resourceTypes() ([]scim.ResourceType, error) { return nil, err } + if s.cfg.TemplateFile != "" { + templateContent, err := os.ReadFile(s.cfg.TemplateFile) + if err != nil { + return nil, err + } + + transformCfg = transformCfg.WithTemplate(templateContent) + } + userHandler, err := s.userHandler(transformCfg) if err != nil { return nil, err diff --git a/pkg/config/config.go b/pkg/config/config.go index 9ac2506..e8ec95a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -39,7 +39,8 @@ type Config struct { IdleTimeout time.Duration `json:"idle_timeout"` } `json:"server"` - SCIM config.Config `json:"scim"` + SCIM config.Config `json:"scim"` + TemplateFile string `json:"template_file"` } type AuthConfig struct { From 7a55a3406661caa5eca2a186f3caa6b33af6b9bb Mon Sep 17 00:00:00 2001 From: florindragos Date: Tue, 29 Apr 2025 18:12:33 +0300 Subject: [PATCH 4/5] fix typos --- docs/entra-id.md | 2 +- docs/okta.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/entra-id.md b/docs/entra-id.md index c0efb78..34f9157 100644 --- a/docs/entra-id.md +++ b/docs/entra-id.md @@ -1,7 +1,7 @@ # Sync users from Entra ID (AzureAD) ## Create the SCIM application -To setup SCIM provisoning from Entra ID to Aserto, you need to create a new application in Entra ID. Please follow instructions on how to setup a new application: https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups#getting-started +To setup SCIM provisioning from Entra ID to Aserto, you need to create a new application in Entra ID. Please follow instructions on how to setup a new application: https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups#getting-started When creating the application, set Tenant URL to https://{scim-endpoint}/?aadOptscim062020. The `aadOptscim062020` feature flag is required for SCIM 2.0 compliance (see https://learn.microsoft.com/en-us/entra/identity/app-provisioning/application-provisioning-config-problem-scim-compatibility#flags-to-alter-the-scim-behavior) diff --git a/docs/okta.md b/docs/okta.md index 4124f2b..6075d23 100644 --- a/docs/okta.md +++ b/docs/okta.md @@ -15,7 +15,7 @@ ## Provision users -For provisoning users, a user needs to be assigned to the SCIM application. +For provisioning users, a user needs to be assigned to the SCIM application. 1. Go to the Assignments tab 2. Click on Assign => Assign to People => Assign wanted users and click Done. Your user should show up in the Directory @@ -23,7 +23,7 @@ Any updates to a property that is mapped to a SCIM attribute, should trigger a u ## Provision groups -For provisoning groups, a group needs to be assigned to the SCIM application. +For provisioning groups, a group needs to be assigned to the SCIM application. 1. Go to the Assignments tab 2. Click on Assign => Assign to People => Assign your group and click Done. 3. Go to Push Groups tab => Push Groups => Find groups by name => search for your group and click Save From 6cfcc557aa737fccd6e426914fe99b44d99061aa Mon Sep 17 00:00:00 2001 From: florindragos Date: Wed, 30 Apr 2025 18:28:25 +0300 Subject: [PATCH 5/5] update ci --- .github/workflows/ci.yaml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1605892..df2477b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,6 +57,7 @@ jobs: gotestsum --format short-verbose -- -count=1 -v ${{ matrix.package }}/... push: + needs: [test] runs-on: ubuntu-latest # when on a branch only push if the branch is main # always push when ref is a tag @@ -107,18 +108,6 @@ jobs: git config --global user.name "Aserto Bot" eval `ssh-agent` ssh-add $HOME/.ssh/id_rsa - - - name: Wait for tests to succeed - uses: fountainhead/action-wait-for-check@v1.1.0 - id: wait-for-tests - with: - token: ${{ env.READ_WRITE_TOKEN }} - checkName: test - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - - name: Stop if tests fail - if: steps.wait-for-tests.outputs.conclusion != 'success' - run: exit 1 - name: Push image to GitHub Container Registry uses: goreleaser/goreleaser-action@v6