From e1b107849e4f149f44b0bbaa9f59d731e12074d3 Mon Sep 17 00:00:00 2001 From: Rene Pfeuffer Date: Thu, 9 Feb 2023 10:29:05 +0100 Subject: [PATCH] Permission Overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an overview of the permissions of a user including its groups. To do so, a new cache is introduced that stores the groups of a user, when the user is authenticated. In doing so, SCM-Manager can also list groups assigned by external authentication plugins such as LDAP. On the other hand, the user has to have been logged in at least once to get external groups, and even then the cached groups may be out of date when the overview is created. Internal groups will always be added correctly, nonetheless. Due to the cache, another problem arised: On some logins, the xml dao for the cache failed to be read, because it was read and written at the same time. To fix this, a more thorough synchronization of the stores has been implemented. Committed-by: Konstantin Schaper Co-authored-by: René Pfeuffer --- .../user/assets/user-permission-overview.png | Bin 0 -> 47458 bytes docs/de/user/user/index.md | 16 + .../user/assets/user-permission-overview.png | Bin 0 -> 45435 bytes docs/en/user/user/index.md | 17 + .../java/sonia/scm/group/GroupCollector.java | 11 +- .../java/sonia/scm/group/GroupManager.java | 14 +- .../scm/group/GroupManagerDecorator.java | 8 +- .../scm/xml/XmlMapMultiStringAdapter.java | 74 +++++ .../scm/xml/XmlMapMultiStringElement.java | 63 ++++ .../scm/repository/xml/MetadataStore.java | 18 +- .../scm/repository/xml/PathDatabase.java | 36 ++- .../java/sonia/scm/store/CopyOnWrite.java | 47 ++- .../store/JAXBConfigurationEntryStore.java | 85 ++--- .../scm/store/JAXBConfigurationStore.java | 27 +- .../java/sonia/scm/store/JAXBDataStore.java | 14 +- scm-ui/ui-api/src/users.ts | 9 +- scm-ui/ui-types/src/User.ts | 17 + scm-ui/ui-webapp/public/locales/de/users.json | 28 +- scm-ui/ui-webapp/public/locales/en/users.json | 22 ++ .../src/groups/components/GroupForm.tsx | 8 +- .../src/groups/containers/CreateGroup.tsx | 13 +- .../users/components/PermissionOverview.tsx | 295 ++++++++++++++++++ .../src/users/components/table/Details.tsx | 51 ++- .../v2/resources/PermissionOverviewDto.java | 62 ++++ ...OverviewToPermissionOverviewDtoMapper.java | 98 ++++++ .../scm/api/v2/resources/ResourceLinks.java | 4 + .../scm/api/v2/resources/UserResource.java | 24 +- .../api/v2/resources/UserToUserDtoMapper.java | 4 + .../scm/group/DefaultGroupCollector.java | 40 ++- .../sonia/scm/group/DefaultGroupManager.java | 10 +- .../java/sonia/scm/group/UserGroupCache.java | 58 ++++ ...BetweenConfigAndConfigEntryUpdateStep.java | 4 +- .../sonia/scm/user/PermissionOverview.java | 77 +++++ .../scm/user/PermissionOverviewCollector.java | 114 +++++++ ...viewToPermissionOverviewDtoMapperTest.java | 166 ++++++++++ .../v2/resources/UserRootResourceTest.java | 55 ++-- .../v2/resources/UserToUserDtoMapperTest.java | 15 +- .../scm/group/DefaultGroupCollectorTest.java | 45 ++- .../user/PermissionOverviewCollectorTest.java | 250 +++++++++++++++ 39 files changed, 1748 insertions(+), 151 deletions(-) create mode 100644 docs/de/user/user/assets/user-permission-overview.png create mode 100644 docs/en/user/user/assets/user-permission-overview.png create mode 100644 scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringAdapter.java create mode 100644 scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringElement.java create mode 100644 scm-ui/ui-webapp/src/users/components/PermissionOverview.tsx create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapper.java create mode 100644 scm-webapp/src/main/java/sonia/scm/group/UserGroupCache.java create mode 100644 scm-webapp/src/main/java/sonia/scm/user/PermissionOverview.java create mode 100644 scm-webapp/src/main/java/sonia/scm/user/PermissionOverviewCollector.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapperTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/user/PermissionOverviewCollectorTest.java diff --git a/docs/de/user/user/assets/user-permission-overview.png b/docs/de/user/user/assets/user-permission-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..422ef7ec3cafb0a5c59b088ac9928eb7564af035 GIT binary patch literal 47458 zcmc$_WmKF^ur4}Ca18{vK!6b3ogqj_2pS0P1PJafgS!NG2oAv^xVuAecXu5IpEKn9 z_P+c4x#zBP_qn$hEY^Bg({FWEchyr*)f=K9CxwYdf(8HpFh6~i_yPbR!T|vANGM3K zC$uUFDX>3?_F|uuQDDOZ_?PwX~fDg`sLU)a0QA02St-2fI8yONx zrzJGtJxGU2amy9uBd!dmCO>H2+FGEg#!ZH0Q&9p@N(aF|U3#>z#mb34y7 z!VA^H$21oB{!EOTNG+I}B9rOyO%kQ!_*T&elf85Bc>gMutaQ+>9fA z)^%0so9TDIoPyv`EX#8jRC{BxaapE{`mq!oXARy#>BTxmu&dnUM3CB_%m`N12S!z} zXIJY|UMP)3v+S?Usdn8FBB&|qftcf`x8s&}$oIdb|JX~!)e&;>-_cnBZl^_eyvUEO z=A2{;)|brY^04)9tL^?c`c9+u_q^Mm4jJb_4a8?%_p6TXFTSKQGYk+%Tq(&rSPxio z*%bQ)$-65(W}B{aT8?ggXuY!VWns^sWrF;x|3&B9$>D)kcao7QKF#G zT6X}fbf|WUeCn9DML$Y!JpjhHbjQ~%PCPUrFbKC`K#nSo+cxJcnqL8A7)Y1ffFDzo z{z%F4oYsL|{zQN95e%bDvn|oQ!|Ki84>cz%ZMI>P{yf1+2{OApNn&CdCd!5n`+IPG z-ebTqdB~UMURV;&LhqcUh5Cx%xo4_(>JCl$%F`t4UTsq3N;kOgn0bMuymnWea-zVW z39I#;v&TFt5*Ausxv-L?9jAL?2u%(paeL+t*@g~{n#>roau)Ay;ti@jip_tFD+Fb? zqk7E@htS5!lTA%vl-JT6-EQnfKFk|pkEV@mwwV^IBfOML3o;TM~1>Cp{qK zY`cYZ|^$A zH|*$^8*fLtm}72EY+JZjAo{UQUzQn9m)(51t+O<^b#=6Gd3xn^f{7dXZQ}ypZhPdF zver8PfmKsYUEpBBuOd*BkHQ%yN~mtP+q*g+neoD_O})}c*0Q!jH*8o}q?FaBy*PqsxOkP~o!QAJic?-HFn_ySu`G zk3sPE|8p&HJ=cSet+;3^`0~?1YWioZx|3lZpIw5gGhAKjHy^)a;m#?_>Pay>+N)IG z&3HsoY>XJ}c3l>l$}~@Wn$@1^6;N5J3{QD8u{Ueh%aBtz4tcx!BSIFJ>V4;x%^QH7L z|GM&TC&JwR*#ko}N;RE*jD6C{DTnIZr!2pkT2y&vIsmPHa319|Uz{PELO*_|$G)1Y zPYO_(pa}50sV(V%eDFrj(2_f^&kc}Y(>l0%{|en28&3`+&6JOG6Uv!%Pll;zf9j!P zbttmwGfmW8=ZMg1=@_?Cmq5$u=87P)IuNQ^jKbq2jE{9!=;}h@E>^7WETzwy*@Sm# z`&qE%dd=E;mlgVbJ%=@o4_nGq?$@T&qM?KV8R)F7_AMp3Pqln%E9JAZuxlhu^MOmh zjaya!P)7<)&bi4&wqey zMJQCIe3%~OmwQs~Rtnb8dFiB(KRiTQbA6m%9*E~bfOLCYu-X_p`6E)!RBeD%$JH&X z4%Dl;7yxWCLWJ9!!ga-H-;RHMn~#0cK~6qks;l>)@(Bv1M%C!~`(8>~bz>oO3j3IBEr1vSgXKz&ugZmpK<5m>p=D!Bi z3p$TwKP8jElXExOU^1$_K?We@_kDhn;LV?;BRe_npPvF&W*2j$#meueysw6Wt0067P;Yo^~Av*Fh- z0KK$LS|~MkI^}D@k0aKq3X_YCB~%?B5N7>7F6bL$G-T!wb>~DaX>5$)^4d!DX1=cE z?rP&d{`L*kG*NsQX&pj2_HfEg+y}&jO~jA^`1*+r*)Er4>;w_KokXmT^BCSU^_mon zp!|3?Rty@Fr3^rIoO;SLg$sRUrW|%2cA3$@)eU2*^SfkdF(#r#g|n(sOHUHAJX`eg#otKQ z87(@TKBX-q3)*m{L}C$f)(_Wyo?K5~&(0Z3fi|O9*S3kxo7cv4`mSzLc3}9k0OMorYF~zqS$#$( zv;Kf%fXk@uI>~M)mxafohc+{Ietfl_o2rAJj)QPs`X)i(SqnL0=M*%t&7L@C&rMqJ zULh6#>Bucqi!R^PKp+(bJaM#g-?dRzMXD1TcF~w&VU@lA&4gp|uxqwKn^1K{Bf791 zUXDvFg`A!#3@R&S|&OxMi^hcmQq$p7LMA34-qI#_ZfGU zn?uwor~OlzS;nC<+uCbh-tzoA?5B#cLgDs#urZR7CDfYb7_GnpDAI3WP0dkuz;N-N0Du+$WOW>G}yqoaFD}df>C53KXfz|RA4B!YAnUj|7 z_uv~l#xs*q{JN&?+gN+B&1pmi93MBY&J8BWXBUjcMg6^H6G)z+?JhWfawJ8oUSj}; z(WT#}PkogP=i*&+Z^`&+gzHih6Yzvn%*&G1k0?m=GppK;EZc09>sMsWMgrTq@!mXs z`|yeSCp26XIxx6QcVK%`g`&DymXi1V?~uS;{;^9smnPN1zWB!Eh$1OFN37XM zt>;8M;h|`S`7mPF=k;9NBfv8IwOF6K3BouI<|jv)v0Sk`M<@OkE!UdFUo~(RRL!2R zfUAnc)^@<6AD=_R7+U9quaq8Ln_a42>%pn{lcWON*LGiKoM7jtCJFcbAnNwSLKgqD zD@n0Jk;?i2rqj811gcD_*UE6jmVLw(4C?A2mRtL%yXbszyafk1qG`U<_Yd4` z9ramPW(2@uJMQfP(G?CL5)To>79C(e0cQG`LXFeDQs>i9r`&rVNm-)*___M+T~TWA zD`L@YzKOP-ZbkH_ywe_mA%c*^cIkJ)`dHJ#U_29A)m{$wBDKhR6sHMxuD)w1k(<{! zpLsf?`gxOpFTr``o9h|81x@*5dviAW1O?k!>OmHgK}b)#%&4WtF`h``ongf_zXAPn z(HKutF9Y9pZnPoa+Sz&E3o3%mO>xU~=6lT&8c#%kcUOlx3BJN}uF)zM_y*Bi$Jbl` z6duP!=56+O4_Y6+hf{B$_oEVTa&q$d(?<7@ik;Mp#48R<-L$X=uMz#Er74o|fo-Pr zec|hg%@$GdD72qFin3TAl9HR1a7h{A)4seHrTZ|!{=1dvBd1;LLn?*dWX)@jMe`!} z$sp*lFd_*a+(@g2%Cf$`P*ci;t!K%B|NOGwPbR$JwDyK6UBBwF9a8 z%dm4eZ_3ved@Uj_8g%^@goRmgD$|JP+&l1Z9_oUrjsggwT6-V+s_k-Y|s_feTatM1theZb6!r z<_yH0X8l}u#iW`J6n?^y%B$L249QR9aUs4*|6u%n?FD_iagObe%6Iyxli#dNMp}`IJ zU#N55U*#=5lO~eOvgQjA;C$0}3q#z~P~|rLNApC|`$Z`IhBkX8H>PU*5U~MxjTzW2 zje)_iF@a7l8$-;4fX zm`^`K_19h69gt4>z`1X}`=tktjf#>1;9zdL|KbptR`Xqq-8&Vk^p80wk6M6%fbd@} zpPYYa`c8>(B_`K>-8glwwusFpPILh8Lv1)2DavT#91?k(i$eo|P0>c>P7VgnidXX5 z+C(LHW<1Vvs3v$f;B(Zl6lw&UfJ|WBpxNZ-CwI_)` zbWP3Px#`Nwu{5Mtvva|Gi8&ISxix0ZQwNFJh~)b3T*39GVzufT^B(~BRmQ24!)>E` zUi;bR+u=;!%-c4NPa_?X^cYK$s$Y_np%K67%>4W++e)^=U!%OF0jJ$XY_r!?z~V(*_n4WK=Slsf<(IK<5pCLE*J0TYlP9g1)X$6-slf z)|7(~tKl7m*$eOD+_=wG`}TV6n{g~&i4BIsx7x+^wmZgJCyeTMb4(Xe;Ho2o!SYLm zdu1Ge`zg|nj*?$rY?x<-kHRH8+SUO5>I-@Mh5+(zctPGzVYUF$jBisnpZb0#P6RwD+>Z>-Cc z%DY=Zw=3hlTj!{Nw&>J>+k)v=%FiC{OUI8oL?RWHq(4Dfqo8F3052_s=~P9LgH?NQ zL6>mzGq6h#x(l z?pVn@X>A3NWlY+|<<6Q|{o_J2CO@K!jtm0_C9pD`WoZ{{BiO9$B>^+DY)ZyNFB%fc z5w@YBo-z1>q#!Z$o=`Du4`Ri8SLB%=!yTpkr6y++tL@VhYa&OJVQ5F}3O(K!qVvN| z%jPKofUPWP(VFo@1M;j}ewc2t#$<=o9ZXd(;hNb09A8e}HVD>bZVM*Zhc(#QP1sg+ zAwrN*H4%biCMIru7T1mpwZPfB6zgEth0PYPY{hGji4UxtcJO+U16x&<1+&{qf7RNx z?@7rx)V+PJz}Ap7aadN+KUwW-yw-36myw|?1+M0vI=-01sjXnQjevaN%L$(cGWT-+ z5xFU&CJ?F#$!7klJbUtOY*ox((*k2sb|sfu8pM-Y71ji);h|^MTjmU~SgHRH8bl5c z;x86j#$a+i?4jlN=VC6&Lof>3`)%aZ?CcobkeKe|a(V0eR*ZEU8z{SCA727$$Y_T{ zw-dtG(va)f0yr-#El=YM6{VdVQhd`Mg=J1N8;g{kc8zBqkeAZd$&wwU=*jmoXIswi zI=DSi4>N{Mx|F7yFPsq8Khm9`2E?s=i9CPMnmH0^^l*`O)yqjtiTFD8G$6GmO2lZU z*SO?z!J2i|YbWjAtV&-aFUPC8QkU|;OPO~80V864pz4$DfKe^rAsBCg)$I(w`z2FO zFEgqRn1^lEdB1_g(_9}beDCXTi9iFJImO7()R4A;>W6z>&(H+o7E&D8IfjuEdW{!n z;iPko={Xo>PJJ%?4*pT@k=QuqyR68XH-~*#!9Bqgh8%E{!&@ok1(_O#hJQKbt5_O> zFfhAy!bh>G6LvNoEteq=NGLhaI!0Yy zqz80qbMOa9&`7yF{v6SzMyP+_C-b;8Paf`<1r8AK|Mhq@Qpf6fnM?;KZOIK$4wPw; zy#nue?;7tq-7f}!JIZ0=7OO}*E6qshP#C0RCY~_-YV;c%lAH5~g}nA9h{d4v%%^hIo(37up}a zh|SJRRr4G{;XtQU^vx+)h>bIrTDWol zESNdZuCZXaW~0YP{!F}*n((o|N_^H-zv;#m1)eBUMD%8_rP$naq;^7VVqN1JXxoQN zh?0Oo_2KUA(VjWd&R)7JIpHklMplC~7_>lU#0T^%h7UW8jvcx4KO?~Casim&Pw4kW zu33SNZj?`YC__AKecS8l$`S(+vr29qA2AXCbkO}adH%oSjbvd#@iW&N9Q=Q(R`GwP0`mX+szehwR5w#&$vl=Mlljmz z%=<>*F6f5-e0N6~mt;^3rqHyK<>Gp`h2J+2mA^S?bOn7KW=`q-ot;_znh$<4>Q3EW`@!98Jt6kS zX#&z|YU!c@KLdBMqNcqJ`Eam*?75#Rdn`QpEV(Lhi?p#bXF0lJJN1RjYqZUCLlhYSAS!s+8=~ZBN{4t#=jrM#uX{ z&%|n*;(+^5H0f-|sVU@&iDi7P8gI#2H4CqA2sKYQxRamH(`cDmMnCuEcY;aPz~3-i zA74|?TQvjUZe9xx4vZ0Jv=HJ(Pll}z7B~bZ4)S{1^h+PS)LRExVMeLU;_?x_w%4Kv zm{qRWOQ6;o%{{FuHdb6x>oNwhRP&0AFv$a3i;t)rq33T&b-?2|fy3u7;vO8}UU9a( zkvfXj!z{L179ClkF4qq>Vcw|)*$ zt*~eaoEqFON}Zm&W4MwlEi^B!(EAZ-v+LBjGOq~QhWI6yAtYb?s=b~*279;;%!db) zc*KwXw9Fw!X_Cqh3lY|a%1bNt&MIyv%HNxIo}2 zd#l#2usGjKH#nFvbHWvdM*`f{RxtOiTpAJTVZj3V#)h*v1O2*HrXr=MO+yl#`u?c8 z#YG!etjSExagoD}q=}WpttRFGLN#k-p!wQkjCH41Q{~HjWyqqDUJ$~RNOL$i#1^IG z?(6X5tn|TkU_}~|+Up0+S+>*JuZTI~^{X%{ccH4w%dEDi2`(uX4X+en0WUvlvO>ds z1N_|%IMnlQq$lLCF3sGsiqC?TSr%UPM#Aqu-j-Dr-j4_o0F~r_;<8zk{ERwS9W|Lx z*b2>-eT-!+TA4*uBt7hM`*ouIUUsgXJL|`TJsuEHy|g9I+&*c(%_nmldRww^K)+-r zu8G&W`>tpnl#N`RF&K3CokKfGF7<2$miX$%f4^SIv;;%ztC~W;vIg`!Y#S^PbR3(j zUB^Y~H2u~9moyp@PdACF@#wuMbS*&xU0D0UNXw?3>WdeY9c)BM{*YNE$1x!l%P1*e z)-ECEX|%?}2;GWlz!q*dr1Ra*suJEQn(LCIMC5HNQ9inAWJvOjiG%V9Chtjk@|Nto zBM)e}tqvNx@LphgKLa&M?sc*TJONRyWJPOe)5%GF24pj3=CmkNqNld^N=5yeLhyfduUMjJ z<|H`3$}ezr+q9Fm0~BX0+}YF_MT8NaU7^cgUR@q0akHP#ZApgosVF(YXweOjAO>QQ zS5=Avz7Vg_sQIFEh2fB$w3c3*lH_z*)`}}GF2Cg)%zCcJg9hbRQ+*jG!`pP)bHVe7 za)nz904XnRn`<)pqVw5;p;3K0jz?B#SsAdr?Fadciw^=V6Sqy7O`?8w)q*Bi6O+v z9~l85vkEm`@LKq*kM}oTLSG{x5>0}9%W=Y00kUn2-3iE_H-Ti3`?<-bF-y#?`bKFN zy-B)^l^b^oXaKVJpT#GgK3cRkjUGm1pAs2Pk5ed2)+x+0^E(@~dK;;-=Cmg+mb9!5ONyF9}oJ%T3Wtj`={ zP5JqP(kuLL@I+fsJBICDSBCo$Gh_M4#F?~K?><#kg+dhobpc9jb`xl<_E^xx)smQk ze(qFRsXEb1dlE^RxAABAw!D?WWSeuFQ)9WCcVb;zZzSR7D!m%4;D^bvb{Bm)H%kib zl|Jay)6(jgEI-!ktCeL+F%z>F{@Qjbq7w<3iLjWz;Y!WWmbN9(JJla^CvmiSgQ4jR zu>bI)TDQb-Iu)$^Bt;%p)dW0kH0GDrjlJort;}wy?}HoB^Q72sJ$p_X0fZ;jm~$qv zhvyrCE&?a;SiaL{@8^Q7DZNpV&DHHe%s8*N5K$&8SP_+`F(f4TTQ7XuDf}Z4|nUdZP8MG4Wv0=ln)=4dLyP+T;|+I~G~xht6qFFV%o*55(+t z{zL^K2CrE@s7aXG36`Fb8+(XD0{dyx?YV-12b>P!4fcnMl+v5K`wlm2Y)nNxEq0^X z?lzhc^ow&MobSTXn>%#Y9uIvv>rSzU{%p7<;*X07!rq(Gk)+NByvtU+IQ|6ajA&OV z?C?(j9zFbcThDBBU;0(Qkf_MaIUion^D>ISo{WfM6u=v=#5vHSIGaYOdo#LIz&Wrf z=BpEGq6~{ZIA1+o-?x0sF>YOWWjzdO{;Z9%$Ha#p?34hVnw*6rjE>%MP+}X1fFp{P zZrDmQ^Q8<#LF}BPgm}s_5{oVu+8Do}C8THZ7%%Ie(TnoudTwXIZRb!ENyP~AxuUHB7c7|6xxmLk5Q~o zhSx4TZ&_WGtn^9mkmMK3o?<0^_$iPwuc{FXWH=_VhlrQW^Rpo*Ghfn1YDAIdM1xs6 zQ$iv)GfijEiD0 z)rCM&-ri1@jyk8dp6?Rot&=dowK+tjlvuG{Ik9rTw7aGBlq##WO4P2ZE5AIHS~Rac z76`q3yRyo9IxEoGB7~%OTJ~-J6n?J$?GyNm^ceA;S7?85n1(-IypjFy`Ev6Gz1fJ) z5Vnbaj)CFYyD~~}@zC#(l>cFjvOf?mD@L#f8i_V0iD2zxQ=tkfa+Bk`?S zfodH)`)aUP9&VV#W-@=xHv?^D9y_esFfZr{4dAcKFqKPd01jiTVOoIqJ5R4;$rZOcqIV`JCEL_cX`{CoecqB?>3o< zF;p8{7@rrv|7Df_J|fwLBDV8T`dr{%mDnl5c@w@9xi}e^ld$2gyW^ALIE0!Pr>oHe ztJxl?JoxWNk<6(?b?a%p4%qCV#87gdot`rCS$2b$6}+!5wZ9W7Yd~1#C}SLKu*-=` z@e`R@67rMY-1B5JQD!p_i{fq)n_9ZZ({b&Gd0Rm=mg=|(L3D)u*7>^ussK2qiAnE}Onzq*(!Jp1PArIac@!jY-9I$wcEfV5I8c zs$!wF+|$#voEfL^!x82>rDjiJ$8`afM+NJ{>d^jRKUr zQJ-@NdDPy{zFDbZYAZsCLN6g;6Ap-5e&oZ};`N5JDp0A|D+s8sZtv%@H3HZE2#aZ= zG#UST8?w8MzpPA>X$qk6Lhsl&x_+0yZYU`sNaPyB?bB< zZ+1@IXiyg*RVTf0PS_ZMUt~L%VK%w7#+&u`(wdZOwAyk|x;uP5LvjJ+2kxLSc0!4udK3 z|0Jx!``f+$(uA<*5C4rlwf`$diQ1D2y$$+wFa8zlnM8vP;-8meK#=HAG3|f0``UCH zRL*@Q6#Iu=lL*Yv`0s1_{a*{1i+&bQ80OQ(K9Tp#bmHRfYaRm6PA@LU*O_{CxdPDJ zGWaKGWAt}|dkiY0S-MrD!TYaIKurNaFXFEBMDY>HyEB;eVXP%X33vA*^pPiPrhC7E z4gG=q-RU69ZLl~y2dowPsgr$JaB;rDAFPfp-#ptnv*ScfEa&C2^8Mt(ctcvqTC=po zvpk?QvliFNc};{fHkE~|LEl2CtHL+)%LCSujTecvr{1OFDDl!JU$-Y^pKhCpDouHF zYR}zJpTeJ6+52mrr<;r(yet~7xiu0?n`7q>oTrETqT%iC`hyTDxbH62(Lg?qdPrtl zZ;hv8PKbnB+dWRIh-sI!Gl!7}4#pIgIz|hd3WjB&qHAm?0 zoR&5xHZ3v{PLEi0LsV+H9l2SSDDL9S5ygcErtn=Ll%r+xHViUGY9h@BeHh>LU5 zlEUp0{mCWw-l>+%zqpx>u{C|*6`^Ouh*DL*JzxD+li~b>ALor+X6bd#!#PM~a};|q z*{*c}Pf2Dcjo7IAl=B2CCAdaqEr#`Vl{8>+eXT02x9V2Pu)bu>mTSRf1;TF*3?N=5 z?g~Y(9=Lz_Jmbmn!;;dyCCWc`=3bH!q+m|*e88ipW;g+RKTzPY!ZRe!fwJq(<7Q+T zW`V7=-dzKjv3+)Zvm4xJl=+nFQLQ{HRR&rdJiUzWnl3~A29I2^zFcdENXbDd^^blk z@T(l>F{3rHcJ-zfbgm&hj4wXKm6lWX&XR3bRlnqRmlCi?w@7t?n8>=@<*Fa8C3i4j*A$411M8CRH+S z!>tZmhL8OljWa0wYoj!*KbIfY9Hz`qI2Zl(8CwnxC4iseZUi~i93Pb+J8@hXl~L82 zFP7NSlDaFR0F^Y&V08>tE4o$NZYlylBK)mKqnu%j1SCX>Ww0Y{OZt=4R2NQkM_r50vPuJxTY)?1^)h|N@K{ZX>H%UL`m|z* zDwODM(nh>LHkGrhGV1D-X|sErz`#uSd zQ$(n)$Qvw{l@r&yYo12uB))pyPV}U*Ce3MU7C`jQ#Gf=qu;E})T?qOCy{_$*;fgC# znOa<($W6?M6Tx9Y6x~?-K#yElgnO&rZ+dd3)DcRF3LCu|aHSg!<|MA+n3V2C=%n7@ zY{aN3vO70Vs&2Y{lHjW4P8N-cf{?(6E-7XqTqm1j$QYlSUh5M{YL9H0$_eO{lR_Xy zHVgLuKCG_!FT&Ge=dS0NcCM;ccCm}~{|2v*gWpZ*N?;p3#dINs!(_o zzZhC?PfhI!9S8}vcH&`dr8K&2Dix|C9Ue#GB}nqPnpI&E9JF20O=PND zC=wDxP{lY}8C6j%Yg)c<-+}hiw5^Ei1^|!_O}BJ zCM|x}|5dnqi}+8B|8JNYE%GB^6$E|EdKdI}Kd_?DpWEF`;LmOHN;M`P38Q~x!i`o6 zZbbNZ#{c_|nEs#OnZHd%)YM(MV9Fck+uJ=Gf=$+x&H&vpvi+9>Z1(>X+6q*IVFT#B zrC4c5+hmok?gIi{dJYv;e45TOT#0yG+f@Gqh>U*rrapNiF-2{WOt;tS!FNKnv*b}m z9k!|dkD(q_iNNVcLXe0}@O3mxIIMVEn72QUqu=Q_0i+^5m`OPlqUBe%BY?bT>W8nN&PPd)Zu_F6Sz?0w_j6#N zN>Haje=0zxsa^8Jh5d053NJZHQOp3!4Qx@rp5dKQ2E%$L_gr+?LNO^DaP7!sp-w^@ z-TPz%FGGUUJ_s}d)O61}GCj$N(pQC1h#U!AH~YfBp&BJ&KDK*Dql`7%2k(;RaILQi znwZ$DJ`uYJjyBTpfLtIrV;)N%x^UU5Ct?1_u-?!^0I56m1`1-{a;sX~R9P__`Zj;7 zn9!P7FOFl8f9B_~^Vr{W6lqyYW_(PlTh+iI{YE&Q(rajI#hNs!CH{2NICRx)^NzAH zDG>p*1V(kPHLlz!c}ohcK%HBo5DXQhOV)DwAZ6}Dgo)g`mgR}-R3~`8qE|) zK5u-T@+GjARy1{F0q8lpU`)?>*;jFpt(YVmRCtrA(s};krmM{O1ls-n+`0Fx`#1X% zJ4^4(@<@rL^hpk-;cgI5!!zy)t4`wZ!bmMh)e=P0vWoq+Z({ejS7xM(>hY<4cxUA_ zd6=AZGs-FZ7-+6u z$M)&+M_T^@BgoPy7Wf_3#vW9cGw1FndL21M3UUb!C4uG7uKO-iVnJpeU!cs>+=IC`MmyGee-K!tU zIMf@H8fl)MLTVs)h9i?X-?6dQQC$>O{UB&e@{L5U*~Doz!uQhNk65e^4Iq$f!Y;t*$0bXY854MB8CAI( z%X2iYj-M)OKqq!-j^yewkg-FQ7X2Y(XkgSc^Rn1a>Sk!2DB7uFv4i|rC(FqQ%$sGm z#uF>>(l|Hz&|kp;@apP{%TADYw|&?~lvKh1Acf*V(RDm3g9r$@h>)g<-5WoA2}1~y za`fq|z2o8@XS=}n)F^x0fO?mAu}f$G*mrFj{}WnyjRBYKVfzM21$`FU#w;Z)*b^x) ze%_~@X=h@Objd>0MQ$R%Ua78WU1_q1=45BU-Wn`cmW3Swa|o5+(&ERaeBWB1ppL)H z;Y9fk3E!?>gL?s>rWSvO5nVWe0Ml>z2!>Uzzo}xED8^Dua^L611G#$Zw>mkKJ*_q* z!X=6A-;K@e+OYMax?7CoF~G`&Li4Lh-O76eh-7n_T$u4NawNJV=Q;ACjCpgR_2!Fz zCY8;>Xf_mlGFlsz>$Bwh$sG6!K`>!T89H>s1b0;p_WD+TxDkngHtK+L_ePAWD13d! ziMmS*9~L2$w6A&T*ue(8^TG&ET+gEV<(Eb~uPOkj9yxQx-*0WFchpdNjrv)08F~ta z`Gyl=Qr1;m(;#1ZaV40KvE8hHSG%7bQy+TUI=#|6(^hk#B3Qq0_YscFbc+P%>394t z(4WD?B!)LmaFQWnsFgdknO8&oj$oLdmmEuSt{#eV%{E*QJ3JR(V~^#`aR11u0&F6m z_xOH%LPqTweEpfxh)`U*N3dzRq$P!L;YAAAq$2Uk^EB|FQ~AvY7=B;y3?4bvkl>hX zIMOt%-<%|A+^g{gkuUtL}%#Uw!P_d^W(jq`(J!WF@Oqlet{LRKqw7 zMyNK=rxCmFj5~3@__NKgm0)|M`nT077$g7V8D?z@;mOlacmO|AjV=&wCMZ*>wl2Mx z?wjqv9#_ejrmGo<)C%(5s}S%HPZ6l(hD2;?suxaeuS}q>Zg|sM*HZrq_Qt7~YZBer z&ETikGaw>;NFqDi#O*ro*c#U9+oc06n9R?k;O&3;K=@ev|1%u$U(iJ^_P2G&DhO0K z_)OzG4+Mv&+gf3t5j6c>_{cM7xk#JED)jJIK&_%G#Yvo{uti~N zN&zinR@2X+pG~=z(P%{o%u+G=Yh4twm}dxC(M$S#SP>)}bE<+adpJo7I>F*0*j2!JzabxqioSjDub02AUY_WO;FM@vd#Gf0>_F z{hIBEOX$-l*V0`Gi!e$g$>3PIGWAc++xINUHVK{qp7O6iysTXVBN_zXKm%73N{Q=R zwOWPMuztOGxOvJ)6)F;A8wk;hUQD&|=jc{!gtxJN1^qjs- zvOVcv>D4YfP<2F{QUr9-d9{znPP-ma5N%XA&%qif}|8m&p3#tmu>Cx4R$IsmzgFe9L%m@9(Ee?lH|cOTX|q&n^k+cyXpzb@nFY>hQ%y5#XtAg>xafE96;}U z_ds_JwTQ(4n#J;P)k|8xp!?`Mx^>aA2&TWCOzm@RSV<(Fg&2GO20|RTT_27&n^jhy zhB|^Y%MQCP$Cvj{H^qm?;9a~5<9$Rj3P=ys?|nX<$v?PW?W$Ov7nwW))3>-;rkzzd zA<(qug`TK+-m8uACju<52X1tYl5OC~DzGG=*rEg)1)#tT2)V0!waf7I6lEy%1+Gr&>YFS~9wM;N(+qp250U}0gV!QcJ?Re`4?xd7muJ3*cV35$-`=9?FNB=y@;BD-GvdRJ$8(0u_5@6smL z$H(RJB80O_w(CHEF~k<=q%`ZM!^qd;WrDyChk3*-(&3dOco;Mnd<1{Rc~&ekSR!xE zK&(eY2B6IDa_5AYL5_T$aF-kQIq8vqY5l2VeDstzJcRcO3^>ypcKXFwZRo~15c{*97Kg6Y zYeQ&-{%<;VtHZAe|5I`HJ3MLbz@lCoz*SMI>}$ zMi`i7KfwXID)e$Fswut~nt;9{_&h!k?M(tQqR;zVwb`G(%CCPC`UF5ZI*BK)cIYtn z5ig6x&P$}^W`M9K*#^woHr}z{=ZmHhps$HTMbTX;y+*Fa4*6ZGd>SY9G6L7^(|65d zAM>#l-!E5im+qa>zH1Kx_Z{34dc~31`+%^m&NH&~)c%B0K1&}>S$O>t-~}}JgToBB zH_W2@@@H#g-(}Q!m9Dd~)Ggk84!Uxto>j+y^;vGls9l$78d%|Wo}DbS=H13*{K01@ z=QgA?Fy^1>Aki?$rU>l5I!3K*Zqzup8l14}8cSS#b7Dp3kx|(dhST1RhhLS62N&y_ z^Qxuj*S$ z06$i(axhxy@tW@sRei^s(?V5mOuAxSD6f+A!7{2Z5?hP)gu-U)=XU<`qJsC|W|6R_ zMWKLl^9ps8TF0LDpnd>4S@|aBsekV$!oIQ7i+}XdbTpON3{7|mm|#z|r1QqzlZxvZ z=tbx|$38}ri`@m5c7FJiH2dFe8 zWz_5H?O2L&0s=x)sqW41JcAG*B}gq)!qc~}BU}URIOi2M&bI9I7{6O+ml^*v{cZ;` zCTf%;1bh4zSivRT@qo7`llTB5=6C&v(+^u8ka`#aokDOgqkrA7=DkzS0%I>xijpoC zz%Dk4-qxg*CFT!{_a?n#=7Vouj*`GMrD^EXT_eU{{j}G@|tsY<`1yo z{jczK&>>U){$J@SMO9MazarTGKmAWl{;x~&?d%ZqAC|(=douqr;Tt<+r?&6G7o3D8 z7Qp^8XuZq0nZ8d1eMC2dK0ptTU)Mw?0ev+_9ZamO_AL%yzcSIvC6<`<1QFo@p*Zo^ z?~g;eo(DRGdn+f+N-I^}8y*o<65QRJqtHtnbX;|&!q_iW?H)@&gruS{9b3-xoV&*) zARx6;HQ?55j2e%8Zdb->p4QVHIg-xRv0+42D?l7fp|tvew&J^4Q>FD)z;I2Z{tP?) z)>;rKvD|bf6lvAGps&KW9>HUW0F;zO*AyUnGBJYVhGWAK=)`bm977E{Ig$46`Tc!C zyZyc}*DCX~jU*b_i=v;3>7qyk=K%!ebd6AMereNFLq@YhJS>|sIweeJvh zapA;Y0xJEbRW^rziuh-Ip4#YA`6-55g(8gHDI7D){md}a=CI6HZ0dtt1M=?eDD|Y~ zI>kOCB<#fWC^PoE6?;-sXtxKU@|wh%pENc`YX+JyXyac^o9<75nco^v8{&I@9TcVVP!@lDqlfn*$E@x zTFFv21G_JnpaAJW_U5yIP!MWN4f@#aedaq>Z?_!>2$I-;UU z)xd0(!-_3=i|Nn={4d`W0;nU>S7QbrSga>fg{pkcCLe1*h{S0wlVF8aW}h!(dJBr* zeI)cHa1@u?Wfl4bN)Na?2e`3$i?wDXw!~9;k=y7FylJ~<_XJ6JYJATw%$HQ0W1^|J z(@UGX)mHnW8bCrn_OsQkCCsnQ7F+6@?U;Xktg7_LJrq*uQ zsUMV@q-Ip(^G^*r{_tF)sDJW$Cfw6iq2KctGMZIf2)E?e42VcXb5W-MaS`%2(UqWq z@s7G%N=xt7bl)NVE4hG-spz#xv%|Q}V832f6L?I1=Es_NoCK!>Wrp_#c>-jEZb_^I znv|06CzXrz|56`RsPxb$U5aJrt9bX3Ux+}bO3ePJGpG1KY}3T21{0CVXq4(B<%NT^ zR~?L{LY?jzX1bI+Hy1Q}<8t%fk_=IyW)SbLOL%Ws+)4v;77p4zr>&&snv1q*EcaxHesdy-MX?odo7I$zgomf(jZUrnn{Utpyz?} z4TFy-t|p;~!`zM!9^{{32+s3)BNAZ89E`Bfgw*8rewO}?AYj|hm|)cIM)*M4SnJO$ z?Um875R2knf5bL_WS}tF*{c5psN8`9`>!PX0>D|L$snM{(ZMDf1Be{E73O7-uckE zFMHlxy;fct`p3m?2h>v}+_RtW!NHGlc1&AcxlNpx$7!*oe@G+`9JO+|nRBVpc37;Z zoB?vX360UUW9BmkK=s`QTz(_v;}o3MHqT!k@B<_XV9|+rVr>;@qwDxb z8X&o@(z;e@uPG9=lxjAoB=d2cQ+&G>)R0vI%TCS7-87wkr_MZ_Prg;Yw4py#CYY5m zd%9Da026z?S@%!IIAhmf8?L4c#DnrEhozcr$*n$?)9UvtSGnWyD8|5v{Rju6wn(Ba zA!U^oY#L{(nHljfeVK)0=cISs3XLspEANs&%^8tMi?vQ^0gkCA+Dpoo{Ygp+*ClCLf8dl?50Vk6BvFCB zCqV%ZK8^q8U=#MD`xw83+e?9&HnS{Eu;}K?Iz+tn^ZkwfB6Fgz$|KgFr3gxKmv8Nf z%Sr7&dE4EzB*MClI&iJd`bV@cG2m4{FW=3U*Pzeo^g_{0zg4G9k*g8Syfa#n$B3O5 zrI3GHED;g6vnM5b&hX37U5=|iRjdHJ`Bvl5hc|X_pAktyF@t!BBX4$(kNCV(6|K#p z=$vt_op^F(GCP2|MD686>hXr$uR|CEprKOGg-~b!fQW?ZH62V?MHlEwV|M5-Z;+v~ zs2}Uil&A(BblTB5>B_gi1mP6LMt(^rKj{gpDU%{+hQu3WPLDG`Bx)alhV4Kk+{*^# zex4Yt51E?#e1Q@NN7AKRcq%aJHww&rnOro8`M%M?>x7NA^X9UD@BMxgNS?EseLl3& z+7VL*hytrxPDzNVl;jPR-hHWzie5KIt{8m}4!r7FVIB4TL0m(X_}ykAi+qbe2{e;A zcic=e3GiUPW0Y3UXiHz9U}yB93Stfm?f$NyYA}YUFq>Ul``)poOj$*ol~R?ql0SE_ zO-aAq1+jkfvC;a*?v|{!j3w(3Z8_Ol)^$56z;# z1T?7@I|S(^eS7-s6!-XjZ^}WOlB-bPy%d)!z?#o6)gDd}s6_AhX!Eztk%f6|KKkOG zpQJHLcYt-pImT*j;7);(MEayz zrljk4tNQm)u?fNy*IvcRbj#E<+3U0zj%UWh(PtZCZK#jOi+~CA7F7 z1<%cEW!x7GVWG!7D<){U9EzKNMl<_u8%V`z7C(Y_Rco06fnTL zGiI|j1L5%s)^7vz(XNMe<~O9M>o4LJyVqp|*tc5VxaAqid3ZTN05*0_I-q5m|Hkyw z>KNVD%1ztZ(HPuACqeI@HnoP^G0VMIv@yLG!Bx4L@5&vH@I|p33I02omW75*sRwx& zsD&yP6JQ&ZDq5h5;NxQvee9rIR)a^*@$oGFu*{d@^^fK>o1Pw9jkuf+8^zkNA}6ZD^@Myd-&V9z zdZsj|r065r4>$^H7S)Y*ouJVymF|#}u1R8;kHf@r~ z`_8LUZ*dU;=dq0K_eWowE=FKKeq1Vx zD^_+FL%^Dt6PMG@laNqpoeFk9#*x~K?hz|o37MjZJhj7X2U%SFA=jKQ*`B&V-)K&( zdSM|i)L5_mfdx+JRs^N(8j42EQC=NbMhcZ%<|XvG>)#iwY% zv?kNclr}5%@_(QSPY*;InZ4PFpOY88-Q`B$P&<=p)qYLlG;!zP@c3P*C za(H=i&C8#*o6d!C`=7L#<2y*HbAcM;C5N2JT9gw%i;na%>4c_*Z+#VN#x`cAiInQM z8lq*iMDPDZmzvyLYGU#0c)ur^IT}!5p1Lull9stqe&}ok-ZXwUkkOI4G-+|En?Wwr z%l_l!F7@qNOf*6!$DgUooyCmm!t5B_y+UQ!>!Bv%f{rl|u;XnBuUCa&L?N5JNE9y~ zj*jdKcoV@^Iy+i%3Pdt+RaO{$+acw@jQ;WV74hBXzP^VEeq;n%B0P)|@jfKEzqL}d zueJLtL=0q!bY@&mbK0|%7Le89^i8S)v=fqW9^g)@(eld)XYsQx{2T0(znI;UUzsa9 z`jv`gm~3)>>dLQ$ic<>VVV>kcp%}quxbS4bV^+*JFARTUA?1MmxqSD`llh?a>p?P4 z$X;nP4v#JVL)P5Wj_EseJBwjCB=NkOi1CFd`9^ZcC?QaAP;@Z^RJj4BGB$J>dPZT!t|-|>0aBDr+ABal=C-Gck~VWlx*uAkwjVlBLc zS#3sqRV=Qgoy7>hYyTQbJ z7dn&AneKk@h|<50uT?dVmCfc>QsT*l5(%91Omo>~J73}9I@eM|W;c5s*yhEeqp{Oo z0Xb@O>GkmxVyP+a&2B4JnvQVU>4X3{-EH~!$I-xG@8ijmr%g4k+6IdrRF^hDRoFM; zUPgR>$DJjfOkSf@`{&N=IZ{>)sqbHYO$5W#ULKT1;Omk|3w^8e+l#=ap@sVis(YGL zAvU5?e+t5YT^89;;xi1M*s)15BPRHPPZd>>q*z`jjm7=9p0IF*Hx z?yrwex5&iWf>z*MxG}q&r9p-3Owp4ED=%Ji z;f=$>GR5}vb*iD3CkDgY-xJS7N;6M_MUJ!=D~q~C*D0LM zEY2w3j|4w2>x4NY!yb2N86lY$4^3t!F0_a|7nvmXcGoAr1pCOMJ2KW`4$eN?mw&J5 zsexVow6p;rvi~Ax6W+Wi{&Mr^J@S{}(i37xqITOLudIxDWz-idz`>R6)i&d}qyw~| zRjuXWSaT#Z9A#%%e|0EKVsQf+tJF+}V18@( zzkjwO3WLu+i`52w87ZE3Out!V`$xKR?Kp?3>*msPK5o|478i|yU+ZN|IRP)A*os=1 zE6)>c?ayY|2hQ&R0Q8FT=jZi=XY}n1LE*hLhb1dr93dz2S(Pv~C~L?YdTO~<@NWqo zs9}fS%@pkO~QP~SyzW&_mcs}&!0|f;TOu68YVR)eczDGXZ|peeuC{KqKH14 zGsJt-L9YOv+T%@UPoB?iQVp07-I&CY030iM3J;XyjF8zbHO7k!QWd(zN)|ORo()m- zUW>bPE7J0-zi|j=>R@>lb#E$qI~4a?4SmTN81DM6=eI1xPkR25oJiPzWP2Ni77A_3 z+=fT`*J=}V^-##L5jX=rx)OvVz{tXwg>5Wb;|HYqcLQV=mreiPjG)>mAoygQ$ge-$ zzI^`*Xg6JExyW7OeMCSlow2rN>Vtq}K zr~4lT0O)AM0)u#)jiFUWZPk0V)b`He1e=n`_VzRNm$QT0_|il6ta^8T@7@cH_J24#hz`9>~BHqTC>8? z);7)wuY}UuC6`um$<95DjtZ*R8gxNkPg4-H#xm*W4DB6>UN9+mmXj$Q1|F^kgLY7R z@~x$_J;9pKgu*$nPG|a`XYyl8LDiOLcCzb)UVt|hsz9|<{p#k>$gJxpcv@t)z+s!k z*Dr%)xC=?iJI%ZdW-XYE@o-R#>0>Rou^5!cRrN+(lX-8Vb+1hZDiN;e!pk`X`+~KQ zNr;a;r;eHCLz-Pbt-U&Use>rz-ZXt7h zm+{g(db3~Qn@aNrN#gqgjQhS7#>97u2j?a=jIDTOCyQkH$rgPlk#M8t1uD^2>p~?* zy>dTqA`<_~m~09u7#p&#_QCcRDmghl_~#uZN>EPQUlQC13sX50U&@-cyg>su1@Hv< zQTo=@^_kRl%jdhB?t*i_?W~_9^_NqP?Q*;qE%o@~ZK1vs;uZ^az`d*I*d&wfv6?X0 z!LoQKSFpG<&P{!I=dX-`@D!i3HYZqLr%18F>|n!&B;u8k!Gbnlx?p+)@cpOr$uCN4 z?u&zet4m$32pc5c)zn;Gr()6a}r<0|RYll0UTr`O1paB4DwXI5SOw_41>cPeXeF)rq6)W9#M z28A{k$2%egyn#xnk4M7&dnXRi-J7{=|FvTB5_33-WOUL-OW#t#vP2BI0y7odLV>?d zl=im1)W``JUBDGufYy9HVwLOz{s?DCfZzQqLd*o~c4ZmZnkfE-Rv{ye$vL zvtjMOUVxv$cCn96W@_(<;q#If7<;L1@1O`CIq{R%x6t*ZAD$+qo6na9eT5z~M-GqS zI2sZePs>Cp_2R(nwo8m14!T4%{;#6@7eZBf-PjO?@2=r`C&A{Xk|+E*6}?B2UPrM4Q4qTwP`e}88|oWPL%v6 zPkuUMb44T|v3$lTZRq*tSo?Yvh8dtU{hg9cC#?95!`P9Jtk=%NwU*`jzHjN>bs@&@ zA80*KDb9Ig@X*=kB!8L}h0Et!#%299j=EbKJ8Jd%l6ci z(M51xn!eTM_}X!U9{51VyUqR^NskNZ{`qnCv6!jg&9Z~lcbsj}z(+}9jlpel>4z<@ zNRersBt(tJGOkMNcO>h&>ZT|(G+v}*52DST+A~B9$LG|1CnBK-z9#iAoc?+(qU(W> zyQtE;p(U};y4tXxph&%ne%|bmke_2_q~!|699NdhSAT*K=Q;~YKYCp4HUGV{vJJ~Z)c!(=r=#go6v9Z#wh31Luq?oI4hu=e52t$flAU9&e&dU zwRzdZwrK>vgiYg=)3*nnIe&s74l8YNaD}l4ZVsF-pxYr+A3Xb{5`FNp3nk|E=H^qY z-HEujwe&`Nf19Z>2xv+ajdON)@TSKFn6j@{X5J0OOW+YWb^bp{isfY8eGPmK2r#dNpw09J=Fr1Z!2tz?Khb*{jNx$lWEAO&8LWM zU!Xj6LJjH-ASEUJWm7lm%Vn>Oeg$TrG#p896O!iK8vJ|4X<}$&wWMxQZ|8K|%eq@t^4i`78E?Dt!8f10AO?6e=^7P!n)f@xJ8 zlP{DD`tx;oTQnJlg~{iAn4vEIyt4+}#~^Bs<;M^;B1GfWFxWuVo7v^Er}G2bL?B#y z@Zz#$9Y7cH;aAaH&p*p*G72y$cQ_wH^&9O+SL!1UJ6L}{Vwm?PgY`aGE$BbWwRWO^ zD=p!ATSaeJ=Cm@(@Mn3t7HVv}Ds^^YTE-oI+MX#2+E?W%+>n&%;`{_}kzFX9fYW!; zV|d`d%LAYy4l*L~kkN&@8UJZ#SQ3-@Uthlm?skF!!oSYP=urXH|96*N_g+$AB5XH= zERm%m(alB+(8h$ktW!d*yxW0PJn>PDQ%d-yhT>KZPgB^D=pg!X@bqm^yPF>InJv+l zM4}mbhdbST5ODaxo26&7j+5KwO%XQ=Yd>qBlHqQ&#bU9$_|;0_ z!e%0PaI`-O`Bzi41lrwF)5n3D@A&8PHUsS#4lBu0IcIY~q2DRJ*IZ<>S1!1+gQgvS z%l=JWs+OF|bgi+O-9aoU zZ%e*n7FBUr6tC+vn-gQyU%tFm1DDa))<8im>nU@euMbBxFNQ%aD=~+ zzQeTGM-Ylc=)gIha>3Yv8M{bJ#+8H3YpL>W9HvGPoI}Vf3AsolvlbrlMu*kKXF?~N zR}6GI}NGL}Y;&@#)A`i8AKHmrO*&eBJyXn8Jet*S=d zDRalmJhQ3%+}wp9u;U~W@_zSnU-hdIkfO3Ib#Y%lpc$Pj+Mqq@zyizVc>mp0RQc52 zdpSaB`nx$^iw%W(3mMBAgPDgw8S>{jlxEXsh-e~rPmZ!=c=gH4*0O+mdBHcnyfIdH z+!hZiCBG84w{xdZ>fO8SKjHq)hGhxZ0ywTzOcSj zqQk>-dtas*xb77Exu^`M4kxx&RZqj-Iq**~x%-I~_7p@!QC~Zct%w!y-wjNO{@qbT z?d0eD(8Kl~AV&&v&bVITWgdJ7H*%}+IurH9>EVd2j1C}T>wM_GlhVZ2)^sQu)j&~R z;%g-5>xy~O!&&?igRV(_$)OFJz~a3~A!q3-jTZUm(+pVNIcHrjtXZBHZs{IYqUAh9 zv5j|4P%_h;a8sVhv=&g-?7ZB$Ygq!U@!7jpr>t=jwgN&XZWKBhEZ zyM?ReJ7CDLlCiAz&wdG|D|`cvg}2Pta?VT@k2c~{m(nVPHM;0fIzG3s7qw03zx59& z8!R;>vM8aA8XA4vLSn9Z)q-Q6W3d)L0gOJf7|Qk~ex_t{Z}3`_R-?#Cckqiw(rIe< znsIgC${m}ZVB|Lf|M*$PuYHvK3`83f-D^VYuZK3p5=hGh%FWI#mZ0170fyPA1B1j% zQB2{{Y!-JPRD^Qw%9=Jl*DWc4{DmBXIFmZ(%Jf9mYoz}T6*9W^`NI*vN11%_Yz1dE z9P8ItpPxraY)r;(ULpj$UJz3nv}9Dzlq6%H2DRTVdK5ygC$rvdB@bEvHWh~}Eca^u zit6dh`qu8!)sCB>UknSw?C;!Z9^&4TZjisY~`<3F^296}PI6d7DhDhFz-}ez)lW9nlj77!NF~r+EBK zwrqv|roxQBpp%m0_Ku&^y7Th)bTAG$D3gYD;q>l|`>nXW3Vp!&l{b3Rqq?AiG7GZY zUJHPK3?%UV_mh|D{(A?XrY`oQ@v%p?cXcCcBF*?kd|3*yh;MBMHTq06=%a z0;8BG{#TnxXcI*J^vOZ84Q`#I8oI6oVt8j|J0}UVLSa7}DJ_bdT|Z4-|C+8xQ(Q`A zv$4*S(iHDwk&_bfqTT4E?LVLK~h zbevx#-S*EB8{;RfSVx8Se>0R$$o@%iMem_a94%^Gv>#a)h*)3UQGh|%%9R{OgG?v^ z3?%!tFYhuOl^E~t8uR6#U;>KfTsZ86K0bx##QUDWHUpeAe{mO7iDdrj5(u7}9&Eeh zHd>$Ai|$xL4Cet&Us%>~!0HOo|6njYyC+p*LcvmHxcm~V&al;)SU=-fQdazlVLJpO3U%hbM-;pId=_MEC_aI8O5ka;bX6FRj3ktmP+cBa1!GoRd z!516UT@Grl)b*&gvXiJSZ;?EA0j)#@1qBl9_`4}(H_@@?nPKq!UK+9&EY>!@;MU2jY1fH5v@1Y#3~sf!KB0FH)U8yrvk4Z z1>yd^ATvyTFn{uM%XdMQwDyMKyowxkU8JX`d%MT-m)wzW7~etLK|w*pm$asZF5dCu z5=_Oex9+Q@w&<%1!?3Iq%(d_%30`pCmKrB47I%Q{0a!Wo2jF@=7TNS*<@6lLan=^q z`r5PaT9u>~)Ac7hGRBarH`7s;%)!B>)lh?5zAE0`N^vQseu4gRVr|JFZ__q)fQ!$a zS#9p9*7}}HA>05VdJUYSD20UGokEv8a=8tHwWhH#W{B7MGATZOUN)#ao-8ZE;wJ@o zhGH~%a)C_UShjBxpv!*2h(z3e-DrD6UW32#l!fS)(YmTykPjz2K+38pAk znu(WJQ3QC#1p0hiU+MG zaLz71o(&j?SdDnC>}iV1im^&Nm!n6^`fw;%&Vab7JKOCrB$2vxw^47&*;6oa4pG>E z)5xWZ%T@ReTHfxznVxL}qAqUJ9SYu@PS&K12!=z>D$~0r$4!*()E7poy~lOMuV<@D zQ~HY`9pxRTKixyvy zgYn8Hjy%a3<&wOM=zr-B&U`KWpis3Lq$boax3@lt1F~kje zWLA!!OZ+$IR4^pN2uq#qPn9cww1AP`HqOK%-WZxgk`IH5BzKF((d)L}PUcYra7>Si0 zB6?N}Ch;fgg3UYxig?m~N%-Qz=nkfIvgL%I!EwjVlL&Z9#ev7bAc0}K!j81<#jk}S z*x9}FoF%3b%O5UT$?3$J;$p1c9rUEA^@;c%bZST5>7nFIQ1&Blsobu5y+C-P@@eqe zM{orGii$*UwIGw9mzbVHkuHObfq>R^sP-xwqoVCGo92Lv2|FL={3njp>hXh ze2*40UWb(zv7Qc5g|t#7kG(28Hivmg?WAAH~yFF60*dm(>kik!4qGIV>8-W z0MG=CXs{qnx$egat?I!sLGq&)<-4H_K_z!RA);re!1&#+8DHdo_!AB&q;)3&wu=M@ zFUNCaRfL9KMy7Cc*>O-MJN{Kst*qic(mzZ7x~W*@`?L(F==KM`^}uO2H!p!pL=)Mi ziVvDuKT?}7u!~y~&2BeF^g1kP6$+cqtTkX|s)diO9gXJYIcsolw`}u6DHVMHJ7I9K zc4GB|YSxpUA%)#PeTR3vzWVITH>Rb2OFd#_;q`qg6luGg79nY=fQffOjN%i|8hOxV zH6R}WsWRwWl^$>BDHvobo;GA0SlB$36CQ+PHiCXl1cr|t8jTZl|csduCV+C zaTjN|8f*4Fh4Z$0!}?~o7S&7%kGY_Qb;W<*{4;UNdg9il^ zhtX|SA%=H=b>-R3F?4>@>rD&dPDgqfhV(0+Y@|~Lo?%LK6T;*+v9xa%yXlabFR9-; zlXR!=lqiRvJjq{;=q=y)M&?&B23;%tzsrI*QNpiQKE_NaOy5SyS1b2!O(^swVjL{* z)y#w=AQ}Y6RWSCN4=iooU9G`2LwrT1uU*XdqSCn73PN=Y)SGjnjt2%;-dw(N=C$~y zl7+a^4X%9!Obz4+Z*@!6YiHGYd*d)5Mxm39?UN&D$&TUJ%=01G5&nz1%UokpVR{Kr zCZAOp-c`mQlc_Y#pN?Zjn6V zqPlE$ZTK58oLuFoXUeQe>Dvr{ZMS(x%#YM~gkJUsOFw-%Go?wG;A)P4gw1-Wqhl|` zo0Iww$O~mZ=#9zrja8z zEur$~QV{Y3#*xb@dvuVDXVY6vFm>#?iurQ$N{X?;R27Gpa;A9hu*6bLxZJ|#hXbs%!Y{fnrzJ6*~_TL;Xt>ifeUR3B4Moauj9EQ?+JNzp|lohCzgJD@Gw5007` zj8S}TOroyPSNZ|9CUE6sDTMARGJwrvXulfmqgZI9!ZpU6h{d!Ge1 zhPNwpe*8Rqv6ke)<1dsfTwbV;g`Uij{}F6nejBD9!X!wJOz&;|<}YmJ36ZEa?w6Kh z3fY9EO;#Tv)+B^d!udW9=q3#%d5EMqc)riIYP{~h!me2zhzWZMIbFPG)&hhfLSzTnn zMpR}PZ^{!PWboCD-JVE=fn!`Nix7TYJB;KFn3Qs(43(DvWQlF|`XbKgiP5GGvhHoI zC{WVreyd*w(!HE0?nM}&l-r;uc@M2s|3x7Nrpm1VG|zJR+n>^6pBFl%-f!g2!pWL4 zJ>c*{8!*o+cQ!A62~c9PIHFI(CWxPclf6!#h5(M4`!E1G)4 zqlfjXcXT9XsyXCV0J!fLm;%;-@@+O;ef*F1@L`)|5FFx>R8M;ak~8WTMQ<>#Lti)+ znbdeLx~BPh4_a@VM{D3Cbnu@hu(e4f_UZbLlkkVudU9D$&pQNI`mDFy|10QG(E%f& z#{>nm+x0(+P=ddf#QqX}XS>G}*7bv(Di6S$u^il6Z|Xe(UNqrn(7$uG+O4!k;>Y>V z`*Bj-+dL*<50H9g`|-f?ts7?l84pllbuX3U-=1br)$odg{=bY*`ymw7cB0!L>O_J2To7!1t)g)}bD)R`pG-4uUl z?$PpX8*q0gc!4}zN(ff4^noEAfKAohjpX03KW6zf=!zJ)uw5|P?OnRVlotncu8Oo8 zxn_7nn&5*Q&GFlg^Ad>DD!}MB6?1 z#le;SbP1XY;WQp-05T*EJ@#xN$yK9i_ZSVt=4>BlQf*DNixJiMy6qa}r@(lzvjX+Y z;$Drd8V@fNt0r;Bop@%x)SM0xeEwkkD>5cpUc%JC%#L-r{-Ar64gCA^?8qZ+s?=n= zKS5w?6pKi=z=!X}mSC4K?m72MH*JdST1=;(Gmq)>5X)%?xcCk^2zo0;^_jUwgkB|? z70;FuZmJ`2C!R!(!ojb7iaMP?M7x()v_E$`o292~zx1%O^)6EgzhL(&yvdz{HM91|x6(rS4Z6>thkt>OxI91K zPJ10n1OmYVdf`D_abq{AOS((uzEb#VIRh_L`c+2lu_}CK=GO16c&W^H)zE~DwM*Ox zlnPDM77MxRKSuZGmGXFReTda0ntWI$sM2c>P0%iHuVps7ofMjEr$;cU-7W0M)R4kC zoWJ`S=#JKu349?d5%zs#~7O|sD+C}aq!k5&9& z)q{MZ*v$t1{;65IB?7elp0wT7@)NYlQ)sOF>7twX?LBmxim!xs0DZ;lCw^fw=jx({ z*`kA^n0!a_Uf12_th%F-$~JH=??nyEl4xV7=hZT9Gj+B}|3y#sL=`iL__&QTwdbCN zUuO}wkFMt{&pdSf3WQ9-2JKcJE41gu05#|iQ8}8k6Y|p~txq*oL0YV;d^>WVhd%?j z+&LPZM1DwQy9rn!Ej2bXB$04dA6>rGfVXPEgC%;eewi;9EK64oy%Htix4vjOT3<#| zsWvCt7pmVz?H}FtuO{^VF5%(7UBXfZ)Kki?zwCm_5nwxl@YFbs`$}^W&4nU1-WttM zGi!^7wZ^|mhz=bDvOcYH>T0N=+yNkLT45hwS>8srtBNvdjyj*=T6DK2upmvSvT7DV z8Wi>dpEaC8W2lTN8vpeIaCYTIKvpjEzg4y?3;F|`8&8&7Zbl9GK@tT67YTWKd8al? zZ`&ZYd?UqcbTIeJ15c)COJPN=dG>NT)a@Nd0MUpE93X$Y8?p^=uUAv+gy`_y6J|@H zF~5$n9jS5(X31RFu`Gr^u2aDJHJ)%13i zg+qvAB1H}8%B1h9yy{eb6{dVy#9azhPYTq3AJIp=i?!T%&@HD2D6*KnrqZpu)EY(0 zUD3&|(4{)C=?TbJ+0G3QMTGu?RUpsEtXgN-dKpTs$AIc0a~t9bi(rCM>j55X$Uv^8 z&Aj2zJODy%zg0bgnV~K`USekRu9C7fi~b@?de~kM(qU&Vg^KLQcDije1P{qxLnO?I zyE=R$V*P3Rfr&UJNkUU;DDKf6!du>PIi=0kaTTP~sjbvXg3i{zE>Q1q`C7FGZ>j}; z>6$cdFU+lF=jvV4RT{c` zYN!*e-&`Dts21HHfM>5pZ~tb?pVYPK`aw?Z@f{NX&FHrHXs)+?%nG9H#<#aHPtl-l z_^NI1htjxc`Kx*_N6YzbL+`MW$e7QtU~dNZW!KH1 zsvs-AN|P^x2jsmKmReRLO4=5G$#r^~a~Es7dpBt{YZLN-eizK8nD6%P7Xrv(PxT*P zg!^4bd(QOV{yubcf}d#^kovajBl)P{L|A{WARVMP{t=kDUAW_{b5RrrNnxBy)Y$8~ zC*Lxf>}G6N9o-*aGHk;?BzjhD=Tz0UPtyUUapvDZo;r{t0^_F+Ntn~^EqOw zW=dk79L69B$5r<|s{1g>Z_R<{W&538S)G?=!aVFW2neea{Lt}ol2aZost#ErWtskv z*I!e*_li86&BNeDl7*D>(-B$tKjpFJ{SrnRywuVx4zs4b3zzKm4=H>PcIkYvBo ziZ#k+Hmx*vbu%7Fe;gD-l0S)8+u47b^_FG>u@Q&aO|nP!ZmFUC`#2o_8Th6GohGaA0J;!9bu=CPV7&4dTEthNsE7RsUKqz?xsl3H2ceJTMw<_xnpjir;bTTB zK!&p3XzXf5+r*gXj=Atf|CT(ObwdAX5>J^7HiFk=2P^Mw5Xk=;Bhe)oXD$)^X?C04 za_w>4_v;DyxLay%#$-Td*-?h4y~p>_A9NoMQQoc}fIg3pV-^fL^k@7F^VRmbl=%bR zqShuw&uXrv7N69fGy8i2W0p<6OtJPD$?G$i>T11~Gw=ax1!eppI%*@W7)*rA77rm9w0K)`~?0qLS zqpD-~<4&1uKAcvbXrso+3c}uC`9oC7T7*Oh@7MAlUYyF^yMMQr-daQ1al-aYGCl1_ z^Kn>g-e#8X;*RZC|2kJP0euxi>`QMJl;%?*5>6KS$pRm+kS*lch0hZT&(YW_#8rc+ zdW85E*vDh|_BsX%0eK`L}3n22;6#g+)2!o~~3r2_~zca)k>d?Xkss zjJVUROm(#6)3giDit;2iv9@v)tsGDLQ%lZ}|Z8%rwFuw;3_ue9M`yj|eqYl_6xo6(4JfZ=1e#d4t1+l>xxJ5M^g#uRojdC@yI zRzy-p4`YtKT$uNzTLNMCyt?WgW+e8q1#V_);S~^{T!J%}pF%En?e2-TYGl8^{-DIN z)PP(*&+Gj7mFw7QP8vsvc{e)exzO1ZOYd9_m!zAoc1Gf^`g$wFI|A*yxwAjnchIz8=ewl3NlJ-(buk_oC0lnRJ!6);-k z?W1Sy8a;H)a)71x3M90c^i9Z3i6b0g7wF#%5Z-qmCy#T??C$5%Aa%}=6jCqc7*mx1icQ;CVr z5r9XSN6y+l%uJWZ{&IXIc9z1R{QLW3eVbl+&;IwN;t=2@)uwO#Z>*!ce#ge2kk-_S zanz-5olh5eNq3_r_>Iras^Zv5*a{KW)|n_%8EiR^f@fI72mjEBbyRNe>b;QTB&@C* z3+w;Y+gFE0^>*zJ4FUs5N|%7rAt8;Vw5Wi@&?qr<2@G9QDo6;DiXti9-JKFc4GbU+ zL-*O}?~U_a?>XPO&U?=L{KduW+4JmY@4eQ&?zNtK<=pnkn+;TrHeF|N7nr@psu^tO zBA}TMk=M_Nd#!1!L`eRDgz7=`NTv~`^l5E8-^NbQk=Zw;Gu|5xtLD6lMxZa-3;$I76c`Z+TRA zrRMcDyZ^p8{Ar?Zk$QX4;2ir3qb0= zS%i>c#%TR`y>CyrGyUr|cdM=MS?fXEc7~N{Jwy|`U2&&%4Q0g!zNg6pRGV9zo=B1M zNz8laVIlFy$QcFJe)%Vqy^1*z(k5=c>Mj9;2ya8by23b0&GG0Zt-3c?mr);g8X}Lv zn^=S1Czn3iub(c>Jrx!68A#=ez9W4xMVfocfTHg1Zt` zWw-Ia`JK`?_-!1YB8oWqI@PV#eIt{+4V)(pPI}!vm=^)l`TpAeS>e}3+a}Vi-5N$8 zBXu{-U9u;zK_RyJCvvbbr!DOEr8oSbji6||S87m)%f<^L*pY~|`H{nS{*HJpJJ55i zytE?L+c=9P-_!iwc8a*dl!EDKa%XT9EG-XQKdJ5GxFkK;J{XNtR#$g+ip{iCOOZ+* z8IkM5e^=vmi{nn>g|+cH@T_d9Ku4!b(>nd?-+WxzF9LMp2HrjEJ(JFnLzuiJ))FdY z6??YO`Yiv%Ty%<5=x6Z?LP@W3wJ&A8W@oELbF+|CGfm7XaosgUp__e7Z>Pw7i=L)9 zwp zJwEhjvg{(;-rKQ!eke;UU^+{VSC?CpS2_~&( zIoEyqbG%9k`<6X%O7k3kH!>6QCokoE%?W#9r=&lL%5eQYQEyXLMzk}DE<<}D1!hK< zZ1fk}cXK*hY|sSotM@za@|7j&OG5@XufKgc@;KgaolsNDM~(&BqLR;o^vTi$EDCe7 z-WPub+A9w(Yu~vs;nC%Sx^8;Iqg;LL^5lr33BL1A1Kf5U4=a$_s-VSu1~Sp*p7%M) zeP$qTOEK`A7AoeZ;rjvo)I%yg^ajWyQua^SCi!l>0NI_N49|W>eqBa-`@^lAjyIlh zo%8jbh55B~w6h5rOgiCyT#o1ESo5i!UGuJ8C#`j3=wggdZHhVk8RT>Q^Y+U1w!Vh_ zvt69gs>?4xyq#w@#S1kLL38iDS3mXk_T$3fk7u|k^e0T@$)DoaLZRai%IYGoV2ZID zSV0kff*)Ecb+v0<$d4}^3EA8`*CJRaUOwEAx(w@{fwjMQzG*m!;isdjlBsgCgcLLQ zl@mecNe~4+SZ?Nggc;D);5Oq8XX~NA!WfnEO?88?!%gdtMWvH3P9iE2FPAm|s23iX ziA`Ia`{Lpe)aw#zeA2(RD#e^wvKxwXiZEbPlsMhFT^gUOgKB=1*g1~EcLe)M9cpe_ zSZG>z2C~;kfQ`OEbOw;jv6#xYEuhSQ=L}n+G~MzvFgMReTL| z>A}-FnottUcq6A6>49&r`YyFQLCsW>iC?dNq-JHZxwA$+IB;LN7#u;)CO8_!?!0m3 zvk-_(^9!tX;AFO-p6n;C%Zbm$hnT#((Nc{ZsMxXqnIr`(eh{!nluGQ4K5bm+ww}I* z1t>`KesNSru|}IPSzhns87FxhjUU!-de?4})_&bBJng$rXiAN3r9oUex_A7g4^k7J zyY$|k=_?TXajSF7Z)oxez5R#}fjuH_#kexz*P)zISJC8^snyZdJU@pDDE2Q#_%)=A z_>i)IJHorwYPrjKFRv`djka_&nLK!n22$@SAgA1(K7UTyeLri{A$@V`9MBTk)PP8t z0!)>$90zHXzY5Pch15GY|UOKV_DEy8MNVZmtY{@T zy!B}_zEhfJsC@bs2{)qC9XzMKlGxZ8d)0|Pkmv9YzjawOW^^L@WZ?WY;qgp)6!op- zL-_d2r}+uO$5Yi&>HUU5ek+{$Y02{w1vd%ifszV`towQKCARPJjh^u|gq=h3!Y*p{ zFX8y!m8WJu$KR=M8Ibcq1wzF9pY^^u-!>iBNLh$YUp(~V8t*O#^s1X(LDi0m{fG}e zV!pGtZs>dAyuiEHC%7rh*eh=()M^79vk3tgxcrIoDC@%Pr`vZrppiS`L9o7@qw=~& z24RwdTCFM?K<%?0cDvqGoxh9h;4)EFBwNlDW3h=NhQgPmT@lu$rYcujTmZ~23fV8y?8oWc9MitH;nroUwp zKK?^)mF*wG^Z)jtyGNgQfo&z;d33)^ewdj@9<_!M?Pz;N|6Qh@l&8}vwE-IZCj%0G zAoWil{ax&ST$i6M|m zm4W15C)%$?G=Hhx&dUM^Nze4KhBKg35uLIpEi|nQPJ=&GRogg~sj8H@0)6Y~xbXrHIg?)Oba3e4`F( z-}8zJtInS#pgvAcNdsER%qTi?@e@1)>F-O9s_~cN6;J9L%ZmUUJ0f^9*ZS6<7_y47 zbQ;hw_!Ztqu;LK8!S&cG6UfRRYpFDu?AONcEMsb;1*kaj<;4jny+m4r-MesQqEAWa z#LK&d_bV9aXZUd)ajHrfgBvwz?yY{Gn7}LsGc0$nTL~M0uhe*TLeF1K-)W~7OZvJ$ zyKB#437@XoUtyFMcTMU}FH`L?GPb&Qdwa*qp+XBN@I$tUa)TVPaLF}I`uJwIh~L2$ z%N~G0KFAmjQ`c?Tifb{ol|xc0!Ka<&SKmF-!Nj(RVWO-$>yu>3X-$r~jt)tsshL}( z;`iw5m{ZDw?2PBE_U0JlEL#cX^y{bPZe9tfGV$fQxzseB`7YtzC~BWGXFWjO`}6#U z5D4^AJqDLbyJgZKZ0Y#u(%)Ikqn;W)xY5qaB_+{|)FmmcCMliFm4B--*8K{tRW*P& zU=6zwk?EL`W^%LIzC~_+4-7mu*;ix^vX51w>LWJbSZKe`E^ZEbyLj|Ytf>gJaC<1y zdk9h5>ePi2%)OlTX-uh`%e&bvfo$3L6y-0FrnlTt+S%`JMv`@CVE7N;oPc3X0HjPe zA%wN?ekihXnl8Tz119ougctbL%VY;f64EzIK3a4+QwVwDw_StFj+eS$aX?I;I3ks$ zGeL}72aC!e%A5(T0=)dWyqii&WEL&~O>6KXaln{PbcE8WO)Oyk*E-2SpT8IrqA9L; z@_jU`n}Ff@>!pT5eI6$KDsTq`=)+S}nu__=sAhR^MF$E1N$Rr{D#X?;(1>j?At!r?z?jz;_LsV>04ez=9D0J85_2tj9E2ug*HH-m?@NO5en56?NEcd)1 zgsn%rUsqNgib_PY%e?umAR8L+<(uytOQq?Dxj0e&GG@0uktyWfzHq~1Vn+ZqSTy=sM^DOFMVeP)-3BKZZ|R2 zN%x>;`73B6NcXGmh_S{&jMCdZFJ}Rb`I}J<2&z5&%W{VMh#YLU`2U#-GwRd-OY0=} zH*LfJh0U8wBb%%D_HZcwqa`da|N7<0E<*DDjx%lN+LwyIENYiWc+TIp@qGTbE&6}w zCtBbWgujkyaSRI^pnnNQ?9T+txFh3*TAR-^=_CpOeILC(Pg!X~K0Fn6eqTHzWF3R>p5UZxk*6>`{E3 zSCYu)T>cj#Rx^oXg!bIN#qMq}Eq$*Dk4Ak#f1mHZ0qjr#u79ii2WrdV-sSFO`>VL$ zYhqY(d-yV##(WEsnG2;c@DMrFPeEQ7PTQi(XZLyTgYa7^J&RklQ?~di<3dwroRZTQ zxzzt^HKwY}+%pF#Tpr529X1b(gCi>Z0&{{$%&gDyeZCq^Gs!r-!l2o;Rlxf5q>QEh z3tP)3f2>J~5ctXD%-0h*XyD1azCd(RwSQ)31z8c(Zh1J<=aMd5n#nKQw~AQFEAh-2 z(&RgAurDvZ7_}0nwPAVhNJ9O~`s|R+W0_5mAn%IUQhcEh0j=07KV`E9HM%RDeszdT zFS^nixNr5t9#xzt@OtJT zi2~Fg7rJ_5>|eymHih3-j1AfH1IKw?V!q+g#TgC}#H!25 zDP1w??DL|Q+_L$)+_QJfdaf!!;`suibXG~Piv3x}A(dvsd|T*qhSJ8w)}B56i%Hwk zlOQ(XO85NLLtnlxL&zVtmlq46viWrBDgnLiZ5z(|-lHHzcQG63ffZ>6tL6#34k8gR zXkB+-fYZ$BUMC@B}YZ2$z#j&*TTjH4_i==9N}Gd^tDnl z&F`}79E;iA(!FX`S+*+RV=Xkph0`p*UvlR+qv-xiFEpcYsk}EJ{8leBG(WOaD(=K$ zI(k*v8uIL8-z{o#II1YA{vK_g(As7g7A>AKWo1!b*e-fT3 z-ht$koY0iDIZ^pRV%_T9`OU^PNl4VqatGzCsR9=4i+e`NYL?`myq29IzJEj{vOgmg z7NH=)@lMUNKlVz+ngH}xx^T?SY!cWeY?XW7fxMzw#4w%fH9aB!t) zM-!1x(bsazWC&O_nF4#xVrrf~>m#6q-Ilj!EL}B#+PF0>Z95M8X}G1MjCD8{oIY9B z{<(7N-+py#IlDIm5fa#2>b3b@#{p8?O#?c*q7W|gJZ%rQ+ZL4?liqHr+Kp}&G8{@^ z9KkO0#te1ik|m(jU)6h6F^J_97ApL!4zc>&qav24;!i6vXKcWuKAG3}<#d&6+Mf1^wms`JTokqRl^0&_VjJan_JqI^J4qp{&HquWmNa z-fnIpRI;h_#DxkAMBI0%EyHQhV1In~_8zkR+-r=E)`EE5uD0OopH5;VV)qn1Ytj8( z$2u{(CDp2h<8&*91wB$)H}ePrHB#-pV@rMxSS)kvlVM&)9qH9Qot1yyK*^O-?rpAD zRXklYS%>Iw6?^LE5+6+;w3kT3PA@*0I|FK@ejSCCE>u$G2C~!#yUh#j8@9F6nd783 z@IgG5tpp?|Te8#Xt(xw>@$dw z?(o#knPt#o3X@XP_h(BS{NoQpo?H6@UUr7-Eh5AVKYln-_>Q!UY?)ZyxyYYHrVJVs zZ;D!9-fB>@GNv&{q-ArJ+|IA+QyN06Ep@LlTtM-yc~^5?jV)FWrg)Z<2kJ|0(~7^H z$}A=P=*Z|)vGuyzd`2dJOWSyQs%?XwXsM_RSvaXJoT?|dXSgn9S!)dZi;8$WLmUL6 znpKgPecBWi+}>;bs^GVi_*@O3kcYMY+J8{UXn!;65;9we>+#cF%BP9Su4IiDBk5Cv zH(CXJ-Q;|_0B9u^r8NIg?(kWVp7_Ybnl&I80gTrn<&hq9aP_jw@eFkTvY}NLG@hKm zJnRxH$E-SRuKzVvp76SSX^*FxG`sY`6YE#lGs5hU)_xJV~;cK>l! z5e*BobIfnx0*9=!zWfdcT3D(3{$vxAzJEvA)Q`)FtK@gZzhkn|ml4VZ9+$K-3qu3tY|ySMnRq-95&ajAV~r0-!?$xqq? zmek_d?FLki7z%(URfS*|a z9&5{;isd(=QC$CcXRo_ba#XYAf^oZU?@E@w-C|T7El~23Fjct1$L7bu#`E>*3`b?Q z%1;z5Vq-r=ejG$n!BgliV7#P7J&RW)SMG;y&pJ%^!*KLXPLtowcF>$J63~c+f0*#Z z4a=6$hqqK@gHI%)hzJ(~HU#nR4crEp9{*8vkgmVh9_AZtTRd)(HFgb5Q0LBmC0md` z$=xV=eE2mj+x{6InNcBbM&lFQA#VG+J#Cg);n&Ih_fz)n;MuIlmU!B-T#Per-(pn_ zX^cUkER*}VdV)JB5+?MyXibW(4 zDa$(yh~=tgNd*iJ*B4%H*75p&?xj$2g}NHrmG)vom zU^S%#fdDB|t@6*o`#LfU*zeVpWEv0&e+pE@y1f87&U7=HY!0Nn)arK^Jwd3| z{=qLbHGd{3HJD)~`i$xrX*INK8K0OdM$`SRN_&EDVU~fbi$gIx9SlLwTVq3l+(j%R(Dbz&2PsT z>CmO@s}v-+O6;*6Q-WBRok8pV(C1LaCbMAt)EG2*otp zm{CwL&#o@e(^0JO|BQ3Ukenn0U{!JN!V1$CjHf8wO9aQOX2BxH47^)LgWB#mxVyRz z2PxTAOz|i}5(3sl7exJIhpTy*qYHe39vwCdZ*m0*#39dPg2cKq{6)v3*^AcI8_z93 zjz8oLa_!ytVK2|cw|P(k(*-h~Z8_8KPA*;Zg}ywuLQn6xPEX!;s?l8$ElR0#m-ga# zsU^e7do^XY6!1JhIC8PYFzwt}=7zXD;E4?*9X(spK7t)crPHeM98J6e@x1^(I$$xv z2IVeIYtc>cU{T`@-NnfpM=Odn2~+SjOhR)&$yJDZ%o>miiGfqDef-}`18^-gXu0hb z0N`v^2Ci+Sww9K*q+nHAd5=hWPy@m%a~ebbDGRxyK^IVzWAP^DNsac3+SFJE*YlZQ zEA6w~5~s3)3BBr098Gl_*joAbQ~;hJ(kCb~NsD$hXq~Sg%pT}3590~jXnZIM!d<&z z9q_O~uauZ%5)G52D9gn3oCYo__iJ`*4b^I-!ji?l)~&khEI8||VwGS>7;Uoe#51q> zyHJ%O7F*QucdNTPG7)yFgj7(JGO^g<35n{Ef23(>t+(YsmxALih=PE=DNsIj8iSjw zC_-R^MbBdt1ngY}N?OUOghTO$m~c;QG!HD$ut3Zs^Bf>$)sNsI!pscWt{+n)L)&kr zf9PM$<0Nat`&X~5Xvn!ZoeC*G!e4g1JcbpK^yF_L%N>63zhYM*^x0Yw5+|^G?D!SLSr1d`*Z6xNQy0&}h?^U&H}AwVXgRQ<60Y zv*1cjkg^hRTj_!Xl9MM|vd6>lI?!{!VhHU!mxfJcLJQk=4h8qAc?E4^`Q{(^*M@p7 z=yB4xi|B}ZGTK6Sb+q{W#T%qr;tXhh6!w3fT-7?Z7wm0x;y*8Psb5DohSV3Qejcd+>_RPpWITFY&qAoACwt@kVg&qZTMsggFZfe*8zanDu&Jg-qX55$ zq{WikQp98(iYN&`&>SFP4g`E(+!fXUE1^z2;}oCxohG8qC|E5B5+xYNxpMpqS{WHJ zVJ%qnpeM5S{rvC_u!HPGW}8={I>Q9Gv<_O~x8(K|k;k-_2wlLFN724-9~Hrz z=I#Q>wvlu6Onx;-uWx4ueAN>lv_6ws0yl8ljCNxa2|;VRI|!sru%F-y329%!@DT%Z z`=?&7ox!ATl}5m#$RkHgwiZ7fyOY}cJ@^J<+2uZp>T-<*chW-PwBDPczv$Y<_&&6-94H%!pLWvmWvQak)31gec zzvwfcu$O=1`jr1aWl8=CoY(oz{zdwof>r-FwAr71ktCi04vbRI2q0Y_%p5 zA~8>hhaj&W%6Lq2b6qTMX+*XAT_m9BeiZhm^^-)ta-cUsT$Nfp`g%n_ zFZ|@l3Zs`MF8v3Qkah!q3#yfpAM)nGex699CDeDZ&+@DlMn>{ZRdI7cW3I?EEVki| zbHbboP|D9uGOJnj}e+=f#cl6?#pXGTZo8?{AcUi+DB0DumYUG zD0=bK8F1w#sSsV1V_GG{m)Mu;{@ac;GY@|L+Ia5c;d%d&UJXORmFU|bU5en{V>j{TR1VBvF+Z(V zpe4mc_^6q2V`jIoq>FofREV5))s(>(P0~%BpC*zok|+|kB|5x^F=35~Dgc6CxX*yi z8aCbN9TjLRSbWT|B+2>As;V0UOR*%1+;ZAaBrY)>Z@!|qpnvc^1A#?WZ)Qb9#1}~0 zUppExiWsF2ZQA!^T4F}-gsLz`l^TH9XhbsQ6ISSRE6+%ofon0&cm)2 zhd5qcKTrSY^c&`hQnO)H?#L}@+PC4u2l6marcsMg2sU5sV@Z#7JEl}Nq$+gV9t4|HdTz0DIux-wpC^b?V^&nk;Lw$ z;r4EjTUGL^l)c6kSc*sl@}V6g5XRm6iUleJWiH~85&rDczX8Su7#Sa0Yp1c$ z{+S*T(8>+K&O037z(sHM9SCnk+nbQv6PdsD^_kx!*;^O3)#^HFPVQ z%fa#CSmSA=6*V@|0s`m&Oph!^#C7OQPtx(Q&GLnp0~^*HtCKv9cx)Z0o4PjDasbNh zxILVfmpXdyr#7VsQU5Oi5aZ9579uX;veP8VX(YndM`%-0@i4cPF!j0dJdVySb^6+$ z8B0_k;HK8=y`zF_aFGzN);qu%e_6k0D9++KX{2zdzU-qBdE0qQ!l&XK7r#8mKU|sm zF@UC7=5jsLKjDZoO#9`4O!|tO+HG^fyH0^ToG1@DCM|v6q`~zY{zqmEVMJV9l_GJFu6Hyt;IK^eYOBB-QG;kQ& z5=OMwGG{I?7cfR=MY)9U;#tLbm@FTqBwfiLN@%f1m>Y>?!VVc=+Mc1aY#k4(VqwuA zqctm8^iIyjUn96>M%Z_f6LPG0mW*`K@#&X&j!p0e=kv->u}g zk(7e*mu(o{ud7V%w>Mr2~uHEJnWE^ijG75Fy2WiVxm z)+xy|8Gnq$fXMJz5+Ri6_kh0Tx7wtml&bTk~O2d0MwR*2IR?7!^!9&@K zzEwz2&vPM@U$De&ll|ym(Q?s*$X$mMQpQQqXIE((Gx-$G0|lSLXYYDw^Yv+Fj&>w; zbrXF=^9U1YPozn*w|}Zc*Q72x6m&RF0(<84wuBIehV!0f6yJZP3NA5M{ee{B^PQvHpe9hrt`8AXrMLC7Yi%C zc2`dahyZOdc+#%5^K-8xz&&miKjIr4R8GW*!2px3dhvfWn1bbpLG%D3K?FedlI9XU zbFp))abw~B|24CTzVJQ+;GqUW)?8#$g4}ZO?ZjmtSyPt5M7%`xj`Ty6Fz zLgfz^JUp`PbnDb^dFdN7WKZ)KH^J#|;zG|n zot2_Q8eD($o9|9q&fZB{WP`5;>Y?Kgh~wghTtv;dMfDT%=VMo$){MF0C*rr_VnNd(xEYUTMfh!uj2~lvy2u!(?X=Ogg2QNfH!LlFb`N7$j22#H zcYnon?4i5r8b0a1cATDc=8NGUGC@`cgF2;M9@ANI1f@t8)D_o~UzQgUQB7vsh%XcN`(JQPWvp8h53B~dTR%tscU#s?q#OI zO~fa{-vS5Hu`JP;RljH}HYNHN+dw_&;BpB4^0<|VPT`=$e&ByW>6^_qc8K;_r(1>8 zU9lLcRZSIO`Q*b@%*Lv|NBL`1ys!@$jM9Q$0b7DqV)zMuu;672N4KnKLP%LLH(GN= zj3&-xMczBC0yOAg=x&3Rad=I#fWXoWeYABIzY%zRp4KwE)8t}V(OJBK0LozZLyRCU z)M{wi8FVkR5!cb%h*Y)mff*!DmSe7`Ly~*!;oFI&4zFaPvDOhyI3JH7yg+5aw5L3| zuBJS>8cu5ku_Sg~w1a!cm%FoZI&^1cbh(eZ=M4=JbojZG36AaU1uLd?m*DZFo#ltH zS3WSWHY8iIL`_!R6iNVeA)`gA)=bPoDE7R%*5g}>qX zK;-|#q-W&aTf@LxZ;YUOzNA#th%8^(-Fd{nK(G?X)wNEZ$|OpG}{di z{T{pIR@JK#w(1;~zf!=!&Qj9XL&<-_ga1HC@b8vmEned+DNuwxo`KtdfT5zGAzvtG H68Jv=q^Y4A literal 0 HcmV?d00001 diff --git a/docs/de/user/user/index.md b/docs/de/user/user/index.md index d20309ed0a..cb49a0024d 100644 --- a/docs/de/user/user/index.md +++ b/docs/de/user/user/index.md @@ -25,3 +25,19 @@ Die Detailseite eines Benutzers zeigt die Informationen zu diesem an. Die Checkbox `Extern` zeigt an, ob es sich um einen internen Benutzer handelt oder der Benutzer von einem Fremdsystem verwaltet wird. ![Benutzer Informationen](assets/user-information.png) + +### Berechtigungsübersicht +Am unteren Ende der Detailseite kann für einen Benutzer eine Berechtigunsübersicht geladen werden. +Diese Übersicht listet alle Gruppen, denen der Benutzer im SCM-Manager zugewiesen ist. +Hat sich der Benutzer bereits mindestens einmal angemeldet, so werden darüber hinaus auch alle +Gruppen berücksichtigt, die durch externe Berechtigungssysteme (wie z. B. LDAP oder CAS) mitgegeben +wurden. Gruppen mit konfigurierten Berechtigungen sind mit einem Haken markiert. +Externe Gruppen, die im SCM-Manager noch nicht angelegt sind, können separat gelistet werden. + +Darunter werden alle Namespaces und Repositories gelistet, bei denen für den Benutzer oder +eine seiner Gruppen eine Berechtigung konfiguriert ist. + +Die einzelnen Einstellungsseiten für die Berechtigungen können direkt über die Stifte angesprungen +werden. Bei bisher noch nicht bekannten Gruppen können diese direkt erstellt werden. + +![Benutzer Informationen](assets/user-permission-overview.png) diff --git a/docs/en/user/user/assets/user-permission-overview.png b/docs/en/user/user/assets/user-permission-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..333ab36f58ff8279452e795d5147a432d2cb50a5 GIT binary patch literal 45435 zcmd?RWmMcxw=PH^c#s5l0t5-}PD2Qgpur)y6Rh#Z8+Qrr5JGSZ5Zr>hyEg9b(9ldL z|MxxT%*?s#ezE$wVV@LburNq55D*ZsK7M$wf`EW@g@AyF zgoXnDixjHZNBEEDPVYXdp}~t6nrSEk0yVN4Z5h&P@5d7qzvi3oS_R2)F;-g>gO~Fc;HtecGA0eei6rcsyXe zj`*?AA;PHb!)Mc2l4{QBL_ZobB-nA|7oNlG!Jg)uDIsA_pOTGr>JV?ucR=k=9j^b#K8e2 zY@8_~Q#tm3`- zKm%Wous>s@tNdYIgaez6X|UG&b`XS(#Jw2B0Fhf`1%2{rL9GUt%<|LtXsie&58G

VQsUQS-Z27UxoGkwS}e3)#Ed=H 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 MYu?Mh3w`~+0P?uMw*UYD literal 0 HcmV?d00001 diff --git a/docs/en/user/user/index.md b/docs/en/user/user/index.md index 927a656c5a..5019a3d614 100644 --- a/docs/en/user/user/index.md +++ b/docs/en/user/user/index.md @@ -22,3 +22,20 @@ The user details page shows the information about the user. The active box shows whether the user is able to use SCM-Manager. The external box shows if it is an internal user or whether it is managed by an external system. ![User-Information](assets/user-information.png) + +### Permission Overview +At the bottom of the detail page, a permission overview can be opened. + +This overview lists all groups, the user has been assigned to in SCM-Manager. If the user has +been logged in at least once, also groups assigned by external authorization systems (like LDAP or CAS) +will be listed. Groups with configured permissions are marked with a checkmark. +External groups that have not been created in SCM-Manager can be seen in an extra table. + +Below, all namespaces and repositories are listed, for whom permissions for the user or any of its groups +have been configured. + +The single permission configurations can be accessed directly using the edit icons. Currently unknown +groups can be created directly. + +![Benutzer Informationen](assets/user-permission-overview.png) + diff --git a/scm-core/src/main/java/sonia/scm/group/GroupCollector.java b/scm-core/src/main/java/sonia/scm/group/GroupCollector.java index 3feecb8f12..3f21d39685 100644 --- a/scm-core/src/main/java/sonia/scm/group/GroupCollector.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupCollector.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.group; import java.util.Set; @@ -31,4 +31,13 @@ public interface GroupCollector { String AUTHENTICATED = "_authenticated"; Set collect(String principal); + + /** + * Returns the groups of the user that had been assigned at the last login (including all + * external groups) and the current internal groups associated to the user. If the + * user had not logged in before, only the current internal groups will be returned. + * + * @since 2.42.0 + */ + Set fromLastLoginPlusInternal(String principal); } diff --git a/scm-core/src/main/java/sonia/scm/group/GroupManager.java b/scm-core/src/main/java/sonia/scm/group/GroupManager.java index e91e3d8bc4..c743a79c3c 100644 --- a/scm-core/src/main/java/sonia/scm/group/GroupManager.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupManager.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.group; //~--- non-JDK imports -------------------------------------------------------- @@ -30,13 +30,14 @@ import sonia.scm.Manager; import sonia.scm.search.Searchable; import java.util.Collection; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ /** * The central class for managing {@link Group}s. * This class is a singleton and is available via injection. - * + * * @author Sebastian Sdorra */ public interface GroupManager @@ -51,5 +52,12 @@ public interface GroupManager * * @return all groups assigned to the given member */ - public Collection getGroupsForMember(String member); + Collection getGroupsForMember(String member); + + /** + * Returns a {@link Set} of all group names. + * + * @since 2.42.0 + */ + Set getAllNames(); } diff --git a/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java b/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java index 41d4bea486..430f0348ca 100644 --- a/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.group; //~--- non-JDK imports -------------------------------------------------------- @@ -30,6 +30,7 @@ import sonia.scm.ManagerDecorator; import sonia.scm.search.SearchRequest; import java.util.Collection; +import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -100,7 +101,10 @@ public class GroupManagerDecorator return decorated.getGroupsForMember(member); } - //~--- fields --------------------------------------------------------------- + @Override + public Set getAllNames() { + return decorated.getAllNames(); + } /** Field description */ private final GroupManager decorated; diff --git a/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringAdapter.java b/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringAdapter.java new file mode 100644 index 0000000000..d8a41266cc --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringAdapter.java @@ -0,0 +1,74 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.xml; + +import sonia.scm.util.Util; + +import javax.xml.bind.annotation.adapters.XmlAdapter; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +public class XmlMapMultiStringAdapter + extends XmlAdapter>> { + + @Override + public XmlMapMultiStringElement[] marshal(Map> map) throws Exception { + XmlMapMultiStringElement[] elements; + + if (Util.isNotEmpty(map)) { + int i = 0; + int s = map.size(); + + elements = new XmlMapMultiStringElement[s]; + + for (Map.Entry> e : map.entrySet()) { + elements[i] = new XmlMapMultiStringElement(e.getKey(), e.getValue()); + i++; + } + } else { + elements = new XmlMapMultiStringElement[0]; + } + + return elements; + } + + @Override + public Map> unmarshal(XmlMapMultiStringElement[] elements) + throws Exception + { + Map> map = new HashMap<>(); + + if (elements != null) + { + for (XmlMapMultiStringElement e : elements) + { + map.put(e.getKey(), e.getValue()); + } + } + + return map; + } +} diff --git a/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringElement.java b/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringElement.java new file mode 100644 index 0000000000..1ffe490f9e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/xml/XmlMapMultiStringElement.java @@ -0,0 +1,63 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.xml; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import java.util.Set; + +@XmlRootElement(name = "element") +@XmlAccessorType(XmlAccessType.FIELD) +public class XmlMapMultiStringElement { + + private String key; + @XmlJavaTypeAdapter(XmlSetStringAdapter.class) + private Set value; + + public XmlMapMultiStringElement() {} + + public XmlMapMultiStringElement(String key, Set value) { + this.key = key; + this.value = value; + } + + public String getKey() { + return key; + } + + public Set getValue() { + return value; + } + + public void setKey(String key) { + this.key = key; + } + + public void setValue(Set value) { + this.value = value; + } +} diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java index 486003d8be..d461f2d3c7 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/MetadataStore.java @@ -38,6 +38,8 @@ import javax.xml.bind.JAXBException; import javax.xml.bind.Marshaller; import java.nio.file.Path; +import static sonia.scm.store.CopyOnWrite.compute; + public class MetadataStore implements UpdateStepRepositoryMetadataAccess { private static final Logger LOG = LoggerFactory.getLogger(MetadataStore.class); @@ -54,13 +56,15 @@ public class MetadataStore implements UpdateStepRepositoryMetadataAccess { public Repository read(Path path) { LOG.trace("read repository metadata from {}", path); - try { - return (Repository) jaxbContext.createUnmarshaller().unmarshal(resolveDataPath(path).toFile()); - } catch (JAXBException ex) { - throw new InternalRepositoryException( - ContextEntry.ContextBuilder.entity(Path.class, path.toString()).build(), "failed read repository metadata", ex - ); - } + return compute(() -> { + try { + return (Repository) jaxbContext.createUnmarshaller().unmarshal(resolveDataPath(path).toFile()); + } catch (JAXBException ex) { + throw new InternalRepositoryException( + ContextEntry.ContextBuilder.entity(Path.class, path.toString()).build(), "failed read repository metadata", ex + ); + } + }).withLockedFile(path); } void write(Path path, Repository repository) { diff --git a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java index 79210fb0cb..b8a27d5279 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java +++ b/scm-dao-xml/src/main/java/sonia/scm/repository/xml/PathDatabase.java @@ -43,6 +43,8 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Map; +import static sonia.scm.store.CopyOnWrite.execute; + class PathDatabase { private static final Logger LOG = LoggerFactory.getLogger(PathDatabase.class); @@ -122,27 +124,29 @@ class PathDatabase { void read(OnRepositories onRepositories, OnRepository onRepository) { LOG.trace("read repository path database from {}", storePath); - try (AutoCloseableXMLReader reader = XmlStreams.createReader(storePath)) { + execute(() -> { + try (AutoCloseableXMLReader reader = XmlStreams.createReader(storePath)) { - while (reader.hasNext()) { - int eventType = reader.next(); + while (reader.hasNext()) { + int eventType = reader.next(); - if (eventType == XMLStreamConstants.START_ELEMENT) { - String element = reader.getLocalName(); - if (ELEMENT_REPOSITORIES.equals(element)) { - readRepositories(reader, onRepositories); - } else if (ELEMENT_REPOSITORY.equals(element)) { - readRepository(reader, onRepository); + if (eventType == XMLStreamConstants.START_ELEMENT) { + String element = reader.getLocalName(); + if (ELEMENT_REPOSITORIES.equals(element)) { + readRepositories(reader, onRepositories); + } else if (ELEMENT_REPOSITORY.equals(element)) { + readRepository(reader, onRepository); + } } } + } catch (XMLStreamException | IOException ex) { + throw new InternalRepositoryException( + ContextEntry.ContextBuilder.entity(Path.class, storePath.toString()).build(), + "failed to read repository path database", + ex + ); } - } catch (XMLStreamException | IOException ex) { - throw new InternalRepositoryException( - ContextEntry.ContextBuilder.entity(Path.class, storePath.toString()).build(), - "failed to read repository path database", - ex - ); - } + }).withLockedFile(storePath); } private void readRepository(XMLStreamReader reader, OnRepository onRepository) throws XMLStreamException { diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java b/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java index 03807e3fb0..3539406597 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/CopyOnWrite.java @@ -28,11 +28,13 @@ import com.google.common.util.concurrent.Striped; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; import java.util.concurrent.locks.Lock; +import java.util.function.Supplier; /** * CopyOnWrite creates a copy of the target file, before it is modified. This should prevent empty or incomplete files @@ -46,21 +48,54 @@ public final class CopyOnWrite { private static final Logger LOG = LoggerFactory.getLogger(CopyOnWrite.class); - private static final Striped concurrencyLock = Striped.lock(10); + private static final Striped concurrencyLock = Striped.lock(20); private CopyOnWrite() { } public static void withTemporaryFile(FileWriter writer, Path targetFile) { validateInput(targetFile); - Lock lock = concurrencyLock.get(targetFile.toString()); - try { - lock.lock(); + execute(() -> { Path temporaryFile = createTemporaryFile(targetFile); executeCallback(writer, targetFile, temporaryFile); replaceOriginalFile(targetFile, temporaryFile); - } finally { - lock.unlock(); + }).withLockedFile(targetFile); + } + + public static FileLocker compute(Supplier supplier) { + return new FileLocker<>(supplier); + } + + public static FileLocker execute(Runnable runnable) { + return new FileLocker<>(() -> { + runnable.run(); + return null; + }); + } + + public static class FileLocker { + private final Supplier supplier; + + public FileLocker(Supplier supplier) { + this.supplier = supplier; + } + + public R withLockedFile(Path file) { + return withLockedFile(file.toAbsolutePath().toString()); + } + + public R withLockedFile(File file) { + return withLockedFile(file.getPath()); + } + + public R withLockedFile(String file) { + Lock lock = concurrencyLock.get(file); + lock.lock(); + try { + return supplier.get(); + } finally { + lock.unlock(); + } } } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java index 9ae042a3b8..bbb1b5dcaa 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationEntryStore.java @@ -44,6 +44,7 @@ import java.util.Map.Entry; import java.util.function.Predicate; import static javax.xml.stream.XMLStreamConstants.END_ELEMENT; +import static sonia.scm.store.CopyOnWrite.execute; public class JAXBConfigurationEntryStore implements ConfigurationEntryStore { @@ -70,19 +71,21 @@ public class JAXBConfigurationEntryStore implements ConfigurationEntryStore { + if (file.exists()) { + load(); + } + }).withLockedFile(file); } @Override public void clear() { LOG.debug("clear configuration store"); - synchronized (file) { + execute(() -> { entries.clear(); store(); - } + }).withLockedFile(file); } @Override @@ -98,20 +101,20 @@ public class JAXBConfigurationEntryStore implements ConfigurationEntryStore { entries.put(id, item); store(); - } + }).withLockedFile(file); } @Override public void remove(String id) { LOG.debug("remove item {} from configuration store", id); - synchronized (file) { + execute(() -> { entries.remove(id); store(); - } + }).withLockedFile(file); } @Override @@ -135,47 +138,47 @@ public class JAXBConfigurationEntryStore implements ConfigurationEntryStore + context.withUnmarshaller(u -> { + try (AutoCloseableXMLReader reader = XmlStreams.createReader(file)) { - context.withUnmarshaller(u -> { - try (AutoCloseableXMLReader reader = XmlStreams.createReader(file)) { - - // configuration - reader.nextTag(); - - // entry start - reader.nextTag(); - - while (reader.isStartElement() && reader.getLocalName().equals(TAG_ENTRY)) { - - // read key + // configuration reader.nextTag(); - String key = reader.getElementText(); - - // read value + // entry start reader.nextTag(); - JAXBElement element = u.unmarshal(reader, type); + while (reader.isStartElement() && reader.getLocalName().equals(TAG_ENTRY)) { - if (!element.isNil()) { - V v = element.getValue(); - - LOG.trace("add element {} to configuration entry store", v); - - entries.put(key, v); - } else { - LOG.warn("could not unmarshall object of entry store"); - } - - // closed or new entry tag - if (reader.nextTag() == END_ELEMENT) { - - // fixed format, start new entry + // read key reader.nextTag(); + + String key = reader.getElementText(); + + // read value + reader.nextTag(); + + JAXBElement element = u.unmarshal(reader, type); + + if (!element.isNil()) { + V v = element.getValue(); + + LOG.trace("add element {} to configuration entry store", v); + + entries.put(key, v); + } else { + LOG.warn("could not unmarshall object of entry store"); + } + + // closed or new entry tag + if (reader.nextTag() == END_ELEMENT) { + + // fixed format, start new entry + reader.nextTag(); + } } } - } - }); + })).withLockedFile(file); } private void store() { diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java index caf55ea046..c2e3fad1f0 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java @@ -32,6 +32,9 @@ import java.io.File; import java.io.IOException; import java.util.function.BooleanSupplier; +import static sonia.scm.store.CopyOnWrite.compute; +import static sonia.scm.store.CopyOnWrite.execute; + /** * JAXB implementation of {@link ConfigurationStore}. * @@ -69,10 +72,14 @@ public class JAXBConfigurationStore extends AbstractStore { protected T readObject() { LOG.debug("load {} from store {}", type, configFile); - if (configFile.exists()) { - return context.unmarshall(configFile); - } - return null; + return compute( + () -> { + if (configFile.exists()) { + return context.unmarshall(configFile); + } + return null; + } + ).withLockedFile(configFile); } @Override @@ -87,10 +94,12 @@ public class JAXBConfigurationStore extends AbstractStore { @Override protected void deleteObject() { LOG.debug("deletes {}", configFile.getPath()); - try { - IOUtil.delete(configFile); - } catch (IOException e) { - throw new StoreException("Failed to delete store object " + configFile.getPath(), e); - } + execute(() -> { + try { + IOUtil.delete(configFile); + } catch (IOException e) { + throw new StoreException("Failed to delete store object " + configFile.getPath(), e); + } + }).withLockedFile(configFile); } } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStore.java index e25a66ff43..6a392545ec 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBDataStore.java @@ -35,6 +35,8 @@ import javax.xml.bind.Marshaller; import java.io.File; import java.util.Map; +import static sonia.scm.store.CopyOnWrite.compute; + /** * Jaxb implementation of {@link DataStore}. * @@ -106,10 +108,12 @@ public class JAXBDataStore extends FileBasedStore implements DataStore @Override protected T read(File file) { - if (file.exists()) { - LOG.trace("try to read {}", file); - return context.unmarshall(file); - } - return null; + return compute(() -> { + if (file.exists()) { + LOG.trace("try to read {}", file); + return context.unmarshall(file); + } + return null; + }).withLockedFile(file); } } diff --git a/scm-ui/ui-api/src/users.ts b/scm-ui/ui-api/src/users.ts index ca736ef080..81bc979221 100644 --- a/scm-ui/ui-api/src/users.ts +++ b/scm-ui/ui-api/src/users.ts @@ -24,7 +24,7 @@ import { ApiResult, useRequiredIndexLink } from "./base"; import { useMutation, useQuery, useQueryClient } from "react-query"; -import { Link, Me, User, UserCollection, UserCreation } from "@scm-manager/ui-types"; +import { Link, Me, PermissionOverview, User, UserCollection, UserCreation } from "@scm-manager/ui-types"; import { apiClient } from "./apiclient"; import { createQueryString } from "./utils"; import { concat } from "./urls"; @@ -65,6 +65,13 @@ export const useUser = (name: string): ApiResult => { ); }; +export const useUserPermissionOverview = (user: User): ApiResult => { + const overviewLink = user._links.permissionOverview as Link; + return useQuery(["user", user.name, "permissionOverview"], () => + apiClient.get(overviewLink.href).then((response) => response.json()) + ); +}; + const createUser = (link: string) => { return (user: UserCreation) => { return apiClient diff --git a/scm-ui/ui-types/src/User.ts b/scm-ui/ui-types/src/User.ts index fe6a3dbcb3..799fc8f659 100644 --- a/scm-ui/ui-types/src/User.ts +++ b/scm-ui/ui-types/src/User.ts @@ -48,3 +48,20 @@ export type UserCreation = User; export type UserCollection = PagedCollection<{ users: User[]; }>; + +export type PermissionOverview = HalRepresentation & { + relevantGroups: PermissionOverviewGroupEntry[]; + relevantNamespaces: string[]; + relevantRepositories: PermissionOverviewRepositoryEntry[]; +}; + +export type PermissionOverviewGroupEntry = { + name: string; + permissions: boolean; + externalOnly: boolean; +}; + +export type PermissionOverviewRepositoryEntry = { + namespace: string; + name: string; +}; diff --git a/scm-ui/ui-webapp/public/locales/de/users.json b/scm-ui/ui-webapp/public/locales/de/users.json index d8b3f2945e..5f222c8e9b 100644 --- a/scm-ui/ui-webapp/public/locales/de/users.json +++ b/scm-ui/ui-webapp/public/locales/de/users.json @@ -30,9 +30,6 @@ "noUsers": "Keine Benutzer gefunden.", "createButton": "Benutzer erstellen" }, - "overview": { - "filterUser": "Benutzer filtern" - }, "singleUser": { "errorTitle": "Fehler", "errorSubtitle": "Unbekannter Benutzer Fehler", @@ -47,6 +44,9 @@ "setApiKeyNavLink": "API Schlüssel" } }, + "overview": { + "filterUser": "Benutzer filtern" + }, "createUser": { "title": "Benutzer erstellen", "subtitle": "Erstellen eines neuen Benutzers", @@ -172,5 +172,27 @@ "submit": "Ja", "cancel": "Nein" } + }, + "permissionOverview": { + "title": "Berechtigungsübersicht", + "help": "Nach einer Anmeldung dieses Kontos basiert diese Übersicht auf den Gruppen, die diesem Konto bei der letzten Anmeldung zugeordnet wurden. Daher beinhaltet sie auch solche Gruppen, die nicht im SCM-Manager eingerichtet sind, sondern von externen Systemen bereitgestellt wurden (wie z. B. LDAP). Da solche externen Daten auf der letzten Anmeldung basieren, stellt diese Übersicht unter Umständen nicht den aktuellen Stand dar. Gab es noch keine Anmeldung mit diesem Konto, so werden nur die intern zugewiesenen Gruppen aufgelistet.", + "groups": { + "noRepositoriesFound": "Keine Namespaces oder Repositories mit Berechtigungen vorhanden.", + "showGroupsWithoutPermission": "Noch nicht in SCM-Manager angelegte fremde Gruppen anzeigen", + "showGroupsWithoutPermissionHelp": "Diese Option zeigt fremde Gruppen an. Dies sind Gruppen, die im SCM-Manager nicht bekannt sind, sondern von externen Systemen stammen. Um diese Gruppen zu berechtigen, müssen sie zunächst im SCM-Manager (als externe Gruppen) angelegt werden.", + "noGroupsFound": "Keine Gruppen vorhanden.", + "noUnknownGroupsFound": "Keine weiteren Gruppen vorhanden.", + "groupName": "Name", + "permissionsConfigured": "Berechtigungen gesetzt", + "createGroup": "Erstellen", + "editPermissions": "Bearbeiten" + }, + "repositories": { + "subtitle": "Namespaces / Repositories", + "namespaceName": "Namespace / Name", + "permissionsConfigured": "Berechtigungen gesetzt", + "editPermissions": "Bearbeiten" + }, + "edit": "bearbeiten" } } diff --git a/scm-ui/ui-webapp/public/locales/en/users.json b/scm-ui/ui-webapp/public/locales/en/users.json index 2f68df6d88..278d0966d2 100644 --- a/scm-ui/ui-webapp/public/locales/en/users.json +++ b/scm-ui/ui-webapp/public/locales/en/users.json @@ -172,5 +172,27 @@ "submit": "Yes", "cancel": "No" } + }, + "permissionOverview": { + "title": "Permission Overview", + "help": "If the user has logged in at leas once, this overview is based on the groups assigned to the user at the last login. Therefor this also includes groups, that have not been set up in SCM-Manager but were provided by external systems (such as LDAP). Because this view is based on the last login of the user, this might not reflect the current state of the external groups if the user would log in now. If the user has never logged in before, only the internal groups for this user will be listed.", + "groups": { + "noRepositoriesFound": "No namespaces/repositories available.", + "showGroupsWithoutPermission": "Show external groups not yet created in SCM-Manager", + "showGroupsWithoutPermissionHelp": "This option shows foreign groups. These are groups, that have come from external systems and are unknown to SCM-Manager. The assign permissions for these groups, they have to be created in SCM-Manager first (as external groups).", + "noGroupsFound": "No groups available.", + "noUnknownGroupsFound": "No further groups available.", + "groupName": "Name", + "permissionsConfigured": "Permissions set", + "createGroup": "Create", + "editPermissions": "Edit" + }, + "repositories": { + "subtitle": "Namespaces / Repositories", + "namespaceName": "Namespace / Name", + "permissionsConfigured": "Permissions set", + "editPermissions": "Edit" + }, + "edit": "edit" } } diff --git a/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx b/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx index 5fdbbc3407..c59b58679a 100644 --- a/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx +++ b/scm-ui/ui-webapp/src/groups/components/GroupForm.tsx @@ -41,12 +41,14 @@ type Props = { loading?: boolean; group?: Group; loadUserSuggestions: (p: string) => Promise; + transmittedName?: string; + transmittedExternal?: boolean; }; -const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions }) => { +const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions, transmittedName = "", transmittedExternal = false }) => { const [t] = useTranslation("groups"); const [groupState, setGroupState] = useState({ - name: "", + name: transmittedName, description: "", _embedded: { members: [] as Member[] @@ -54,7 +56,7 @@ const GroupForm: FC = ({ submitForm, loading, group, loadUserSuggestions _links: {}, members: [] as string[], type: "", - external: false + external: transmittedExternal }); const [nameValidationError, setNameValidationError] = useState(false); diff --git a/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx index 62f841fee1..0f69700691 100644 --- a/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx @@ -22,9 +22,9 @@ * SOFTWARE. */ import React, { FC } from "react"; -import { Redirect } from "react-router-dom"; +import { Redirect, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { useCreateGroup, useUserSuggestions } from "@scm-manager/ui-api"; +import { useCreateGroup, useUserSuggestions, urls } from "@scm-manager/ui-api"; import { Page } from "@scm-manager/ui-components"; import GroupForm from "../components/GroupForm"; @@ -32,6 +32,7 @@ const CreateGroup: FC = () => { const [t] = useTranslation("groups"); const { isLoading, create, error, group } = useCreateGroup(); const userSuggestions = useUserSuggestions(); + const location = useLocation(); if (group) { return ; @@ -40,7 +41,13 @@ const CreateGroup: FC = () => { return (

- +
); diff --git a/scm-ui/ui-webapp/src/users/components/PermissionOverview.tsx b/scm-ui/ui-webapp/src/users/components/PermissionOverview.tsx new file mode 100644 index 0000000000..0c0bd67b8c --- /dev/null +++ b/scm-ui/ui-webapp/src/users/components/PermissionOverview.tsx @@ -0,0 +1,295 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC, useState } from "react"; +import { Checkbox, ErrorNotification, Icon, Loading, Notification } from "@scm-manager/ui-components"; +import { + Group, + Link as HalLink, + Links, + Namespace, + PermissionOverview as Data, + PermissionOverviewGroupEntry, + Repository, + User, +} from "@scm-manager/ui-types"; +import styled from "styled-components"; +import { useUserPermissionOverview } from "@scm-manager/ui-api"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +const NamespaceColumn = styled.th` + width: 1rem; +`; + +const EditIcon: FC = () => { + const [t] = useTranslation("users"); + return ; +}; + +const EditLink: FC<{ links?: Links; to: string }> = ({ links, to }) => { + if (!links?.permissions) { + return null; + } + return ( + + + + ); +}; + +const ElementLink: FC<{ link?: HalLink; to: string }> = ({ link, to, children }) => { + if (!link) { + return <>{children}; + } + return {children}; +}; + +const GroupRow: FC<{ entry: PermissionOverviewGroupEntry; group?: Group }> = ({ entry, group }) => ( + + + + {entry.name} + + + {entry.permissions && } + + + + +); + +const NotCreatedGroupRow: FC<{ entry: PermissionOverviewGroupEntry }> = ({ entry }) => ( + + {entry.name} + + + + + + +); + +const RepositoryNamespaceRows: FC<{ + entry: { namespace: Namespace; repositories: Repository[] }; + relevant: boolean; +}> = ({ entry, relevant }) => ( + <> + + {entry.repositories.map((repository) => ( + + ))} + +); + +const NamespaceRow: FC<{ namespace: Namespace; relevant: boolean }> = ({ namespace, relevant }) => ( + + + {namespace.namespace} + + {relevant && } + + + + +); + +const RepositoryRow: FC<{ entry: Repository }> = ({ entry }) => ( + + + + {entry.name} + + + + + + + + +); + +const GroupTable: FC<{ data: Data }> = ({ data }) => { + const [t] = useTranslation("users"); + + if (data.relevantGroups.find((entry) => !entry.externalOnly)) { + return ( + + + + + + + + + + {data.relevantGroups + .filter((entry) => !entry.externalOnly) + .map((entry) => ( + group.name === entry.name)} + /> + ))} + +
{t("permissionOverview.groups.groupName")}{t("permissionOverview.groups.permissionsConfigured")}{t("permissionOverview.groups.editPermissions")}
+ ); + } else { + return {t("permissionOverview.groups.noGroupsFound")}; + } +}; + +const GroupsWithoutPermissionTable: FC<{ data: Data }> = ({ data }) => { + const [t] = useTranslation("users"); + const [external, setExternal] = useState(false); + + let content; + if (!external) { + content = null; + } else if (data.relevantGroups.find((entry) => entry.externalOnly)) { + content = ( + + + + + + + + + {data.relevantGroups + .filter((entry) => entry.externalOnly) + .map((entry) => ( + + ))} + +
{t("permissionOverview.groups.groupName")}{t("permissionOverview.groups.createGroup")}
+ ); + } else { + content = {t("permissionOverview.groups.noUnknownGroupsFound")}; + } + + return ( + <> + + {content} + + ); +}; + +const RepositoryTable: FC<{ data: Data }> = ({ data }) => { + const [t] = useTranslation("users"); + + if ((!data.relevantNamespaces || data.relevantNamespaces.length === 0) && !data.relevantRepositories) { + return {t("permissionOverview.groups.noRepositoriesFound")}; + } + + data.relevantRepositories.sort((r1, r2) => + r1.namespace === r2.namespace ? (r1.name < r2.name ? -1 : +1) : r1.namespace < r2.namespace ? -1 : +1 + ); + + const findRelevantNamespace = (namespace: string) => + (data._embedded?.relevantNamespaces as Namespace[]).find((n: Namespace) => n.namespace === namespace); + const findOtherNamespace = (namespace: string) => + (data._embedded?.otherNamespaces as Namespace[]).find((n: Namespace) => n.namespace === namespace); + + const allNamespaces = new Set(); + data.relevantRepositories.forEach((repo) => allNamespaces.add(repo.namespace)); + data.relevantNamespaces.forEach((namespace) => allNamespaces.add(namespace)); + const sortedNamespaces: string[] = Array.from(allNamespaces).sort(); + + const repositoriesForNamespace = (namespace: string) => + data.relevantRepositories + .filter((repo) => repo.namespace === namespace) + .map( + (repo) => + (data._embedded?.repositories as Repository[]).find( + (r: Repository) => r.namespace === repo.namespace && r.name === repo.name + ) || ({ ...repo, _links: {} } as Repository) + ); + + const reposInNamespaces = sortedNamespaces.map((namespace) => { + return { + namespace: findRelevantNamespace(namespace) || + findOtherNamespace(namespace) || { namespace: namespace, _links: {} }, + repositories: repositoriesForNamespace(namespace), + }; + }); + + return ( + + + + {t("permissionOverview.repositories.namespaceName")} + + + + + + {reposInNamespaces.map((entry) => ( + + ))} + +
{t("permissionOverview.repositories.permissionsConfigured")}{t("permissionOverview.repositories.editPermissions")}
+ ); +}; + +const PermissionOverview: FC<{ user: User }> = ({ user }) => { + const { data, isLoading, error } = useUserPermissionOverview(user); + const [t] = useTranslation("users"); + + if (isLoading || !data) { + return ; + } + + if (error) { + return ; + } + + // To test the table with the "not created" groups, you can mock such data + // with the following statement and assign this in the GroupsWithoutPermissionTable: + // const mockedData = { + // ...data, + // relevantGroups: [...data.relevantGroups, { name: "hitchhiker", permissions: false, externalOnly: true }], + // }; + + return ( + <> + + +

{t("permissionOverview.repositories.subtitle")}

+ + + ); +}; + +export default PermissionOverview; diff --git a/scm-ui/ui-webapp/src/users/components/table/Details.tsx b/scm-ui/ui-webapp/src/users/components/table/Details.tsx index 89c3c7642c..96f6204c0b 100644 --- a/scm-ui/ui-webapp/src/users/components/table/Details.tsx +++ b/scm-ui/ui-webapp/src/users/components/table/Details.tsx @@ -21,19 +21,47 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; +import React, { FC, useState } from "react"; +import { useTranslation, WithTranslation } from "react-i18next"; import { User } from "@scm-manager/ui-types"; -import { Checkbox, createAttributesForTesting, DateFromNow, InfoTable, MailLink } from "@scm-manager/ui-components"; +import { + Checkbox, + createAttributesForTesting, + DateFromNow, + Help, + InfoTable, + MailLink +} from "@scm-manager/ui-components"; +import { Icon } from "@scm-manager/ui-components"; +import PermissionOverview from "../PermissionOverview"; type Props = WithTranslation & { user: User; }; -class Details extends React.Component { - render() { - const { user, t } = this.props; - return ( +const Details: FC = ({ user }) => { + const [t] = useTranslation("users"); + const [collapsed, setCollapsed] = useState(true); + const toggleCollapse = () => setCollapsed(!collapsed); + + let permissionOverview; + if (user._links.permissionOverview) { + let icon = ; + if (!collapsed) { + icon = ; + } + permissionOverview = ( +
+

+ {icon} {t("permissionOverview.title")} +

+ {!collapsed && } +
+ ); + } + + return ( + <> @@ -76,8 +104,9 @@ class Details extends React.Component { - ); - } -} + {permissionOverview} + + ); +}; -export default withTranslation("users")(Details); +export default Details; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewDto.java new file mode 100644 index 0000000000..3d6abfb7e3 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewDto.java @@ -0,0 +1,62 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import lombok.Getter; +import lombok.Setter; + +import java.util.Collection; + +@Getter +@Setter +@SuppressWarnings("java:S2160") // no equals needed in dto +class PermissionOverviewDto extends HalRepresentation { + + private Collection relevantGroups; + private Collection relevantNamespaces; + private Collection relevantRepositories; + + PermissionOverviewDto(Links links, Embedded embedded) { + super(links, embedded); + } + + @Getter + @Setter + static class GroupEntryDto { + private String name; + private boolean permissions; + private boolean externalOnly; + } + + @Getter + @Setter + static class RepositoryEntry { + private String namespace; + private String name; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapper.java new file mode 100644 index 0000000000..ef6a0a9732 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapper.java @@ -0,0 +1,98 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Embedded; +import org.mapstruct.Context; +import org.mapstruct.Mapper; +import org.mapstruct.ObjectFactory; +import sonia.scm.group.GroupManager; +import sonia.scm.repository.Repository; +import sonia.scm.user.PermissionOverview; + +import javax.inject.Inject; +import java.util.List; + +import static de.otto.edison.hal.Links.linkingTo; +import static java.util.stream.Collectors.toList; + +@Mapper +abstract class PermissionOverviewToPermissionOverviewDtoMapper { + + @Inject + private ResourceLinks resourceLinks; + @Inject + private RepositoryToRepositoryDtoMapper repositoryToRepositoryDtoMapper; + @Inject + private NamespaceToNamespaceDtoMapper namespaceToNamespaceDtoMapper; + @Inject + private GroupManager groupManager; + @Inject + private GroupToGroupDtoMapper groupToGroupDtoMapper; + + abstract PermissionOverviewDto toDto(PermissionOverview permissionOverview, @Context String userName); + + abstract PermissionOverviewDto.GroupEntryDto toDto(PermissionOverview.GroupEntry groupEntry); + + abstract PermissionOverviewDto.RepositoryEntry toDto(Repository repository); + + @ObjectFactory + PermissionOverviewDto createDto(PermissionOverview permissionOverview, @Context String userName) { + List relevantNamespaces = permissionOverview + .getRelevantNamespaces() + .stream() + .map(namespaceToNamespaceDtoMapper::map) + .collect(toList()); + List otherNamespaces = permissionOverview + .getRelevantRepositories() + .stream() + .map(Repository::getNamespace) + .distinct() + .filter(namespace -> !permissionOverview.getRelevantNamespaces().contains(namespace)) + .map(namespaceToNamespaceDtoMapper::map) + .collect(toList()); + List repositories = permissionOverview + .getRelevantRepositories() + .stream() + .map(repositoryToRepositoryDtoMapper::map) + .collect(toList()); + List groups = permissionOverview + .getRelevantGroups() + .stream() + .map(PermissionOverview.GroupEntry::getName) + .map(groupManager::get) + .map(groupToGroupDtoMapper::map) + .collect(toList()); + Embedded.Builder embedded = new Embedded.Builder() + .with("relevantNamespaces", relevantNamespaces) + .with("otherNamespaces", otherNamespaces) + .with("repositories", repositories) + .with("groups", groups); + return new PermissionOverviewDto( + linkingTo().self(resourceLinks.user().permissionOverview(userName)).build(), + embedded.build() + ); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 8b230a8853..a66fefc751 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -148,6 +148,10 @@ class ResourceLinks { return userLinkBuilder.method("getUserResource").parameters(name).method("toInternal").parameters().href(); } + public String permissionOverview(String name) { + return userLinkBuilder.method("getUserResource").parameters(name).method("permissionOverview").parameters().href(); + } + public String publicKeys(String name) { return publicKeyLinkBuilder.method("findAll").parameters(name).href(); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java index 76902505d7..8066341308 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserResource.java @@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import org.apache.shiro.authc.credential.PasswordService; +import sonia.scm.user.PermissionOverviewCollector; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.VndMediaType; @@ -44,30 +45,36 @@ import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; public class UserResource { private final UserDtoToUserMapper dtoToUserMapper; private final UserToUserDtoMapper userToDtoMapper; + private final PermissionOverviewToPermissionOverviewDtoMapper permissionOverviewMapper; private final IdResourceManagerAdapter adapter; private final UserManager userManager; private final PasswordService passwordService; private final UserPermissionResource userPermissionResource; + private final PermissionOverviewCollector permissionOverviewCollector; @Inject - public UserResource( - UserDtoToUserMapper dtoToUserMapper, - UserToUserDtoMapper userToDtoMapper, - UserManager manager, - PasswordService passwordService, UserPermissionResource userPermissionResource) { + public UserResource(UserDtoToUserMapper dtoToUserMapper, + UserToUserDtoMapper userToDtoMapper, + PermissionOverviewToPermissionOverviewDtoMapper permissionOverviewMapper, UserManager manager, + PasswordService passwordService, + UserPermissionResource userPermissionResource, + PermissionOverviewCollector permissionOverviewCollector) { this.dtoToUserMapper = dtoToUserMapper; this.userToDtoMapper = userToDtoMapper; + this.permissionOverviewMapper = permissionOverviewMapper; this.adapter = new IdResourceManagerAdapter<>(manager, User.class); this.userManager = manager; this.passwordService = passwordService; this.userPermissionResource = userPermissionResource; + this.permissionOverviewCollector = permissionOverviewCollector; } /** @@ -298,6 +305,13 @@ public class UserResource { return Response.noContent().build(); } + @GET + @Path("permissionOverview") + @Produces(MediaType.APPLICATION_JSON) + public PermissionOverviewDto permissionOverview(@PathParam("id") String name) { + return permissionOverviewMapper.toDto(permissionOverviewCollector.create(name), name); + } + @Path("permissions") public UserPermissionResource permissions() { return userPermissionResource; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java index a969dc74b0..1847fec79b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java @@ -29,6 +29,7 @@ import de.otto.edison.hal.Links; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.ObjectFactory; +import sonia.scm.group.GroupPermissions; import sonia.scm.security.PermissionPermissions; import sonia.scm.user.User; import sonia.scm.user.UserManager; @@ -76,6 +77,9 @@ public abstract class UserToUserDtoMapper extends BaseMapper { } if (PermissionPermissions.read().isPermitted()) { linksBuilder.single(link("permissions", resourceLinks.userPermissions().permissions(user.getName()))); + if (GroupPermissions.list().isPermitted()) { + linksBuilder.single(link("permissionOverview", resourceLinks.user().permissionOverview(user.getName()))); + } } Embedded.Builder embeddedBuilder = embeddedBuilder(); diff --git a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java index 91e6b58944..98e205a61a 100644 --- a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java +++ b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupCollector.java @@ -34,11 +34,15 @@ import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.security.Authentications; import sonia.scm.security.LogoutEvent; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.user.UserEvent; import javax.inject.Inject; import javax.inject.Singleton; +import java.util.HashSet; import java.util.Set; +import java.util.stream.Stream; /** * Collect groups for a certain principal. @@ -56,11 +60,14 @@ public class DefaultGroupCollector implements GroupCollector { private final Cache> cache; private final Set groupResolvers; + private final ConfigurationStoreFactory configurationStoreFactory; + @Inject - public DefaultGroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set groupResolvers) { + public DefaultGroupCollector(GroupDAO groupDAO, CacheManager cacheManager, Set groupResolvers, ConfigurationStoreFactory configurationStoreFactory) { this.groupDAO = groupDAO; this.cache = cacheManager.getCache(CACHE_NAME); this.groupResolvers = groupResolvers; + this.configurationStoreFactory = configurationStoreFactory; } @Override @@ -79,9 +86,30 @@ public class DefaultGroupCollector implements GroupCollector { Set groups = builder.build(); LOG.debug("collected following groups for principal {}: {}", principal, groups); + + ConfigurationStore store = createStore(); + UserGroupCache persistentCache = getPersistentCache(store); + persistentCache.put(principal, groups); + store.set(persistentCache); + return groups; } + @Override + public Set fromLastLoginPlusInternal(String principal) { + Set cached = new HashSet<>(getPersistentCache(createStore()).get(principal)); + computeInternalGroups(principal).forEach(cached::add); + return cached; + } + + private static UserGroupCache getPersistentCache(ConfigurationStore store) { + return store.getOptional().orElseGet(UserGroupCache::new); + } + + private ConfigurationStore createStore() { + return configurationStoreFactory.withType(UserGroupCache.class).withName("user-group-cache").build(); + } + @Subscribe(async = false) public void clearCacheOnLogOut(LogoutEvent event) { String principal = event.getPrimaryPrincipal(); @@ -95,12 +123,12 @@ public class DefaultGroupCollector implements GroupCollector { } } + private Stream computeInternalGroups(String principal) { + return groupDAO.getAll().stream().filter(group -> group.isMember(principal)).map(Group::getName); + } + private void appendInternalGroups(String principal, ImmutableSet.Builder builder) { - for (Group group : groupDAO.getAll()) { - if (group.isMember(principal)) { - builder.add(group.getName()); - } - } + computeInternalGroups(principal).forEach(builder::add); } private Set resolveExternalGroups(String principal) { diff --git a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java index b19cc14e50..ec83256af8 100644 --- a/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java +++ b/scm-webapp/src/main/java/sonia/scm/group/DefaultGroupManager.java @@ -37,7 +37,6 @@ import sonia.scm.HandlerEventType; import sonia.scm.ManagerDaoAdapter; import sonia.scm.NotFoundException; import sonia.scm.SCMContextProvider; -import sonia.scm.TransformFilter; import sonia.scm.search.SearchRequest; import sonia.scm.search.SearchUtil; import sonia.scm.util.CollectionAppender; @@ -50,8 +49,11 @@ import java.util.Collections; import java.util.Comparator; import java.util.LinkedList; import java.util.List; +import java.util.Set; import java.util.function.Predicate; +import static java.util.stream.Collectors.toSet; + //~--- JDK imports ------------------------------------------------------------ /** @@ -346,7 +348,11 @@ public class DefaultGroupManager extends AbstractGroupManager return groupDAO.getLastModified(); } - //~--- methods -------------------------------------------------------------- + @Override + public Set getAllNames() { + GroupPermissions.list().check(); + return groupDAO.getAll().stream().map(Group::getName).collect(toSet()); + } /** * Remove duplicate members from group. diff --git a/scm-webapp/src/main/java/sonia/scm/group/UserGroupCache.java b/scm-webapp/src/main/java/sonia/scm/group/UserGroupCache.java new file mode 100644 index 0000000000..b55ac503a0 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/group/UserGroupCache.java @@ -0,0 +1,58 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.group; + +import sonia.scm.xml.XmlMapMultiStringAdapter; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static java.util.Collections.emptySet; + +@XmlRootElement(name = "user-group-cache") +@XmlAccessorType(XmlAccessType.FIELD) +class UserGroupCache { + @XmlJavaTypeAdapter(XmlMapMultiStringAdapter.class) + private Map> cache; + + Set get(String user) { + if (cache == null) { + return emptySet(); + } + return cache.getOrDefault(user, emptySet()); + } + + void put(String user, Set groups) { + if (cache == null) { + cache = new HashMap<>(); + } + cache.put(user, groups); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/update/store/DifferentiateBetweenConfigAndConfigEntryUpdateStep.java b/scm-webapp/src/main/java/sonia/scm/update/store/DifferentiateBetweenConfigAndConfigEntryUpdateStep.java index 9ffcc652b1..afb52219b1 100644 --- a/scm-webapp/src/main/java/sonia/scm/update/store/DifferentiateBetweenConfigAndConfigEntryUpdateStep.java +++ b/scm-webapp/src/main/java/sonia/scm/update/store/DifferentiateBetweenConfigAndConfigEntryUpdateStep.java @@ -49,6 +49,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.stream.Stream; +import static sonia.scm.store.CopyOnWrite.compute; + abstract class DifferentiateBetweenConfigAndConfigEntryUpdateStep { private static final Logger LOG = LoggerFactory.getLogger(DifferentiateBetweenConfigAndConfigEntryUpdateStep.class); @@ -90,7 +92,7 @@ abstract class DifferentiateBetweenConfigAndConfigEntryUpdateStep { private void updateSingleFile(Path configFile) { LOG.info("Updating config entry file: {}", configFile); - Document configEntryDocument = readAsXmlDocument(configFile); + Document configEntryDocument = compute(() -> readAsXmlDocument(configFile)).withLockedFile(configFile); configEntryDocument.getDocumentElement().setAttribute("type", "config-entry"); diff --git a/scm-webapp/src/main/java/sonia/scm/user/PermissionOverview.java b/scm-webapp/src/main/java/sonia/scm/user/PermissionOverview.java new file mode 100644 index 0000000000..89d760180a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/user/PermissionOverview.java @@ -0,0 +1,77 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.user; + +import lombok.Getter; +import sonia.scm.repository.Repository; + +import java.util.Collection; + +import static java.util.Collections.unmodifiableCollection; + +/** + * The permission overview aggregates groups a user is a member of and all namespaces + * and repositories that have permissions configured for this user or one of its groups. + * This is the result of {@link PermissionOverviewCollector#create(String)}. + * + * @since 2.42.0 + */ +public class PermissionOverview { + + private final Collection relevantGroups; + private final Collection relevantNamespaces; + private final Collection relevantRepositories; + + public PermissionOverview(Collection relevantGroups, Collection relevantNamespaces, Collection relevantRepositories) { + this.relevantGroups = relevantGroups; + this.relevantNamespaces = relevantNamespaces; + this.relevantRepositories = relevantRepositories; + } + + public Collection getRelevantGroups() { + return unmodifiableCollection(relevantGroups); + } + + public Collection getRelevantNamespaces() { + return unmodifiableCollection(relevantNamespaces); + } + + public Collection getRelevantRepositories() { + return unmodifiableCollection(relevantRepositories); + } + + @Getter + public static class GroupEntry { + private final String name; + private final boolean permissions; + private final boolean externalOnly; + + public GroupEntry(String name, boolean permissions, boolean externalOnly) { + this.name = name; + this.permissions = permissions; + this.externalOnly = externalOnly; + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/user/PermissionOverviewCollector.java b/scm-webapp/src/main/java/sonia/scm/user/PermissionOverviewCollector.java new file mode 100644 index 0000000000..c231a8bfc6 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/user/PermissionOverviewCollector.java @@ -0,0 +1,114 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.user; + +import sonia.scm.group.GroupCollector; +import sonia.scm.group.GroupManager; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceManager; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.RepositoryPermissionHolder; +import sonia.scm.security.PermissionAssigner; +import sonia.scm.security.PermissionDescriptor; +import sonia.scm.security.PermissionPermissions; +import sonia.scm.user.PermissionOverview.GroupEntry; + +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import static java.util.stream.Collectors.toList; + +public class PermissionOverviewCollector { + + private final GroupCollector groupCollector; + private final PermissionAssigner permissionAssigner; + private final GroupManager groupManager; + private final RepositoryManager repositoryManager; + private final NamespaceManager namespaceManager; + + @Inject + public PermissionOverviewCollector(GroupCollector groupCollector, PermissionAssigner permissionAssigner, GroupManager groupManager, RepositoryManager repositoryManager, NamespaceManager namespaceManager) { + this.groupCollector = groupCollector; + this.permissionAssigner = permissionAssigner; + this.groupManager = groupManager; + this.repositoryManager = repositoryManager; + this.namespaceManager = namespaceManager; + } + + public PermissionOverview create(String userId) { + PermissionPermissions.read().check(); + Collection groupsFromLastLogin = groupCollector.fromLastLoginPlusInternal(userId); + + return new PermissionOverview( + collectGroups(groupsFromLastLogin), + collectNamespaces(userId, groupsFromLastLogin), + collectRepositories(userId, groupsFromLastLogin) + ); + } + + private Collection collectGroups(Collection groupsFromLastLogin) { + Collection groupEntries = new ArrayList<>(); + Collection allGroups = groupManager.getAllNames(); + groupsFromLastLogin.forEach(groupName -> { + Collection permissionDescriptors = permissionAssigner.readPermissionsForGroup(groupName); + groupEntries.add( + new GroupEntry( + groupName, + !permissionDescriptors.isEmpty(), + !allGroups.contains(groupName))); + }); + return groupEntries; + } + + private Collection collectNamespaces(String userId, Collection groupsFromLastLogin) { + return namespaceManager + .getAll() + .stream() + .filter(namespace -> isRelevant(userId, groupsFromLastLogin, namespace)) + .map(Namespace::getNamespace) + .collect(toList()); + } + + private List collectRepositories(String userId, Collection groupsFromLastLogin) { + return repositoryManager + .getAll() + .stream() + .filter(repo -> isRelevant(userId, groupsFromLastLogin, repo)) + .collect(toList()); + } + + private static boolean isRelevant(String userId, Collection groupsFromLastLogin, RepositoryPermissionHolder permissionHolder) { + return permissionHolder.getPermissions().stream().anyMatch(permission -> isRelevant(userId, groupsFromLastLogin, permission)); + } + + private static boolean isRelevant(String userId, Collection groupsFromLastLogin, RepositoryPermission permission) { + return permission.isGroupPermission() && groupsFromLastLogin.contains(permission.getName()) + || !permission.isGroupPermission() && userId.equals(permission.getName()); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapperTest.java new file mode 100644 index 0000000000..d15c7e97cd --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PermissionOverviewToPermissionOverviewDtoMapperTest.java @@ -0,0 +1,166 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Links; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.group.Group; +import sonia.scm.group.GroupManager; +import sonia.scm.repository.Repository; +import sonia.scm.user.PermissionOverview; + +import java.net.URI; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PermissionOverviewToPermissionOverviewDtoMapperTest { + + public static final Repository REPOSITORY_1 = new Repository("1", "git", "hog", "marvin"); + public static final Repository REPOSITORY_2 = new Repository("1", "git", "vogon", "jeltz"); + + public static final PermissionOverview PERMISSION_OVERVIEW = new PermissionOverview( + asList( + new PermissionOverview.GroupEntry("hitchhiker", true, false), + new PermissionOverview.GroupEntry("vogons", false, true) + ), + asList("hog", "earth"), + asList( + REPOSITORY_1, + REPOSITORY_2 + ) + ); + + @Mock + private ResourceLinks resourceLinks; + @Mock + private NamespaceToNamespaceDtoMapper namespaceToNamespaceDtoMapper; + @Mock + private RepositoryToRepositoryDtoMapper repositoryToRepositoryDtoMapper; + @Mock + private GroupManager groupManager; + @Mock + private GroupToGroupDtoMapper groupToGroupDtoMapper; + + @InjectMocks + private PermissionOverviewToPermissionOverviewDtoMapperImpl permissionOverviewToPermissionOverviewDtoMapper; + + @BeforeEach + void initResourceLinks() { + when(resourceLinks.user()) + .thenReturn(new ResourceLinks.UserLinks(() -> URI.create("/"))); + } + + @BeforeEach + void initRepositoryMapper() { + when(repositoryToRepositoryDtoMapper.map(any())) + .thenAnswer(invocation -> { + Repository repository = invocation.getArgument(0, Repository.class); + RepositoryDto repositoryDto = new RepositoryDto(); + repositoryDto.setNamespace(repository.getNamespace()); + repositoryDto.setName(repository.getName()); + return repositoryDto; + }); + } + + @BeforeEach + void initNamespaceMapper() { + when(namespaceToNamespaceDtoMapper.map(any())) + .thenAnswer(invocation -> new NamespaceDto(invocation.getArgument(0, String.class), Links.emptyLinks())); + } + + @BeforeEach + void initGroupMapper() { + when(groupManager.get(anyString())) + .thenAnswer(invocation -> new Group("xml", invocation.getArgument(0, String.class))); + when(groupToGroupDtoMapper.map(any())) + .thenAnswer(invocation -> { + GroupDto groupDto = new GroupDto(); + groupDto.setName(invocation.getArgument(0, Group.class).getName()); + return groupDto; + }); + } + + @Test + void shouldMapRepositories() { + PermissionOverviewDto dto = permissionOverviewToPermissionOverviewDtoMapper + .toDto(PERMISSION_OVERVIEW, "Neo"); + + assertThat(dto.getRelevantRepositories()) + .extracting("namespace") + .contains("hog", "vogon"); + assertThat(dto.getRelevantRepositories()) + .extracting("name") + .contains("marvin", "jeltz"); + + assertThat( + dto. + getEmbedded() + .getItemsBy("repositories") + ).hasSize(2); + } + + @Test + void shouldMapNamespaces() { + PermissionOverviewDto dto = permissionOverviewToPermissionOverviewDtoMapper + .toDto(PERMISSION_OVERVIEW, "Neo"); + + assertThat(dto.getRelevantNamespaces()) + .contains("hog", "earth"); + + assertThat(dto.getEmbedded().getItemsBy("relevantNamespaces")) + .hasSize(2) + .extracting("namespace") + .contains("hog", "earth"); + assertThat(dto.getEmbedded().getItemsBy("otherNamespaces")) + .hasSize(1) + .extracting("namespace") + .contains("vogon"); + } + + @Test + void shouldMapGroups() { + PermissionOverviewDto dto = permissionOverviewToPermissionOverviewDtoMapper + .toDto(PERMISSION_OVERVIEW, "Neo"); + + assertThat(dto.getRelevantGroups()) + .extracting("name") + .contains("hitchhiker", "vogons"); + + assertThat(dto.getEmbedded().getItemsBy("groups")) + .hasSize(2) + .extracting("name") + .contains("hitchhiker", "vogons"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java index 486465dc79..04e2b861d3 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserRootResourceTest.java @@ -41,10 +41,13 @@ import org.mockito.Mock; import sonia.scm.ContextEntry; import sonia.scm.NotFoundException; import sonia.scm.PageResult; +import sonia.scm.group.GroupManager; import sonia.scm.security.ApiKeyService; import sonia.scm.security.PermissionAssigner; import sonia.scm.security.PermissionDescriptor; import sonia.scm.user.ChangePasswordNotAllowedException; +import sonia.scm.user.PermissionOverview; +import sonia.scm.user.PermissionOverviewCollector; import sonia.scm.user.User; import sonia.scm.user.UserManager; import sonia.scm.web.RestDispatcher; @@ -58,6 +61,8 @@ import java.net.URL; import java.util.Collection; import java.util.function.Predicate; +import static de.otto.edison.hal.Links.emptyLinks; +import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; @@ -95,6 +100,16 @@ public class UserRootResourceTest { private ApiKeyService apiKeyService; @Mock private PermissionAssigner permissionAssigner; + @Mock + private PermissionOverviewCollector permissionOverviewCollector; + @Mock + private RepositoryToRepositoryDtoMapper repositoryToRepositoryDtoMapper; + @Mock + private NamespaceToNamespaceDtoMapper namespaceToNamespaceDtoMapper; + @Mock + private GroupManager groupManager; + @Mock + private GroupToGroupDtoMapper groupToGroupDtoMapper; @InjectMocks private UserDtoToUserMapperImpl dtoToUserMapper; @InjectMocks @@ -103,6 +118,8 @@ public class UserRootResourceTest { private PermissionCollectionToDtoMapper permissionCollectionToDtoMapper; @InjectMocks private ApiKeyToApiKeyDtoMapperImpl apiKeyMapper; + @InjectMocks + private PermissionOverviewToPermissionOverviewDtoMapperImpl permissionOverviewMapper; @Captor private ArgumentCaptor userCaptor; @@ -110,6 +127,7 @@ public class UserRootResourceTest { private ArgumentCaptor> filterCaptor; private User originalUser; + private MockHttpResponse response = new MockHttpResponse(); @Before public void prepareEnvironment() { @@ -125,7 +143,7 @@ public class UserRootResourceTest { UserCollectionResource userCollectionResource = new UserCollectionResource(userManager, dtoToUserMapper, userCollectionToDtoMapper, resourceLinks, passwordService); UserPermissionResource userPermissionResource = new UserPermissionResource(permissionAssigner, permissionCollectionToDtoMapper); - UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, userManager, passwordService, userPermissionResource); + UserResource userResource = new UserResource(dtoToUserMapper, userToDtoMapper, permissionOverviewMapper, userManager, passwordService, userPermissionResource, permissionOverviewCollector); ApiKeyCollectionToDtoMapper apiKeyCollectionToDtoMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks); UserApiKeyResource userApiKeyResource = new UserApiKeyResource(apiKeyService, apiKeyCollectionToDtoMapper, apiKeyMapper, resourceLinks); UserRootResource userRootResource = new UserRootResource(Providers.of(userCollectionResource), @@ -137,7 +155,6 @@ public class UserRootResourceTest { @Test public void shouldCreateFullResponseForAdmin() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -155,7 +172,6 @@ public class UserRootResourceTest { .post("/" + UserRootResource.USERS_PATH_V2) .contentType(VndMediaType.USER) .content(userJson.getBytes()); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -177,7 +193,6 @@ public class UserRootResourceTest { @SubjectAware(username = "unpriv") public void shouldCreateLimitedResponseForSimpleUser() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -195,7 +210,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .contentType(VndMediaType.PASSWORD_OVERWRITE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123"); dispatcher.invoke(request, response); @@ -213,7 +227,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .contentType(VndMediaType.PASSWORD_OVERWRITE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); doThrow(new ChangePasswordNotAllowedException(ContextEntry.ContextBuilder.entity("passwordChange", "-"), "xml")).when(userManager).overwritePassword(any(), any()); @@ -231,7 +244,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .contentType(VndMediaType.PASSWORD_OVERWRITE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); doThrow(new NotFoundException("Test", "x")).when(userManager).overwritePassword(any(), any()); @@ -249,7 +261,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/password") .contentType(VndMediaType.PASSWORD_OVERWRITE) .content(content.getBytes()); - MockHttpResponse response = new MockHttpResponse(); when(passwordService.encryptPassword(newPassword)).thenReturn("encrypted123"); dispatcher.invoke(request, response); @@ -267,7 +278,6 @@ public class UserRootResourceTest { .post("/" + UserRootResource.USERS_PATH_V2) .contentType(VndMediaType.USER) .content(userJson); - MockHttpResponse response = new MockHttpResponse(); when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123"); dispatcher.invoke(request, response); @@ -287,7 +297,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo") .contentType(VndMediaType.USER) .content(userJson); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -303,7 +312,6 @@ public class UserRootResourceTest { .post("/" + UserRootResource.USERS_PATH_V2) .contentType(VndMediaType.USER) .content(new byte[]{}); - MockHttpResponse response = new MockHttpResponse(); when(passwordService.encryptPassword("pwd123")).thenReturn("encrypted123"); dispatcher.invoke(request, response); @@ -314,7 +322,6 @@ public class UserRootResourceTest { @Test public void shouldGetNotFoundForNotExistentUser() throws URISyntaxException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "nosuchuser"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -324,7 +331,6 @@ public class UserRootResourceTest { @Test public void shouldDeleteUser() throws Exception { MockHttpRequest request = MockHttpRequest.delete("/" + UserRootResource.USERS_PATH_V2 + "Neo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -342,7 +348,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Other") .contentType(VndMediaType.USER) .content(userJson); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -360,7 +365,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo") .contentType(VndMediaType.USER) .content(userJson); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -373,7 +377,6 @@ public class UserRootResourceTest { PageResult singletonPageResult = createSingletonPageResult(1); when(userManager.getPage(any(), any(), eq(0), eq(10))).thenReturn(singletonPageResult); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -389,7 +392,6 @@ public class UserRootResourceTest { PageResult singletonPageResult = createSingletonPageResult(3); when(userManager.getPage(any(), any(), eq(1), eq(1))).thenReturn(singletonPageResult); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?page=1&pageSize=1"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -407,7 +409,6 @@ public class UserRootResourceTest { PageResult singletonPageResult = createSingletonPageResult(1); when(userManager.getPage(filterCaptor.capture(), any(), eq(0), eq(10))).thenReturn(singletonPageResult); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "?q=One"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -427,7 +428,6 @@ public class UserRootResourceTest { @Test public void shouldGetPermissionLink() throws URISyntaxException, UnsupportedEncodingException { MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -440,7 +440,6 @@ public class UserRootResourceTest { public void shouldGetPermissions() throws URISyntaxException, UnsupportedEncodingException { when(permissionAssigner.readPermissionsForUser("Neo")).thenReturn(singletonList(new PermissionDescriptor("something:*"))); MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -455,7 +454,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissions") .contentType(VndMediaType.PERMISSION_COLLECTION) .content("{\"permissions\":[\"other:*\"]}".getBytes()); - MockHttpResponse response = new MockHttpResponse(); ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); doNothing().when(permissionAssigner).setPermissionsForUser(eq("Neo"), captor.capture()); @@ -466,6 +464,19 @@ public class UserRootResourceTest { assertEquals("other:*", captor.getValue().iterator().next().getValue()); } + @Test + public void shouldGetPermissionsOverviewWithNamespaces() throws URISyntaxException, UnsupportedEncodingException { + when(permissionOverviewCollector.create("Neo")).thenReturn(new PermissionOverview(emptyList(), singletonList("hog"), emptyList())); + when(namespaceToNamespaceDtoMapper.map("hog")).thenReturn(new NamespaceDto("hog", emptyLinks())); + MockHttpRequest request = MockHttpRequest.get("/" + UserRootResource.USERS_PATH_V2 + "Neo/permissionOverview"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.getContentAsString()).contains("hog"); + assertThat(response.getContentAsString()).contains("\"self\":{\"href\":\"/v2/users/Neo/permissionOverview\"}"); + } + @Test public void shouldConvertUserToInternalAndSetNewPassword() throws URISyntaxException { when(passwordService.encryptPassword(anyString())).thenReturn("abc"); @@ -474,7 +485,6 @@ public class UserRootResourceTest { .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-internal") .contentType(VndMediaType.USER) .content("{\"newPassword\":\"trillian\"}".getBytes()); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -492,7 +502,6 @@ public class UserRootResourceTest { MockHttpRequest request = MockHttpRequest .put("/" + UserRootResource.USERS_PATH_V2 + "Neo/convert-to-external") .contentType(VndMediaType.USER); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java index 3e82c92cbe..d5390ba31a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UserToUserDtoMapperTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import org.apache.shiro.subject.Subject; @@ -43,7 +43,6 @@ import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; @@ -177,4 +176,16 @@ public class UserToUserDtoMapperTest { assertEquals("http://trillian", userDto.getLinks().getLinkBy("sample").get().getHref()); } + + @Test + public void shouldMapLinks_forPermissionOverview() { + User user = createDefaultUser(); + when(subject.isPermitted("permission:read")).thenReturn(true); + when(subject.isPermitted("group:list")).thenReturn(true); + + UserDto userDto = mapper.map(user); + + assertEquals("expected permissions link", expectedBaseUri.resolve("abc/permissions").toString(), userDto.getLinks().getLinkBy("permissions").get().getHref()); + assertEquals("expected permission overview link", expectedBaseUri.resolve("abc/permissionOverview").toString(), userDto.getLinks().getLinkBy("permissionOverview").get().getHref()); + } } diff --git a/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java index c23cce618a..4eae81abd7 100644 --- a/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/group/DefaultGroupCollectorTest.java @@ -30,17 +30,21 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.HandlerEventType; import sonia.scm.cache.MapCache; import sonia.scm.cache.MapCacheManager; import sonia.scm.security.LogoutEvent; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.user.User; import sonia.scm.user.UserEvent; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -59,6 +63,11 @@ class DefaultGroupCollectorTest { @Mock private GroupResolver groupResolver; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private ConfigurationStoreFactory configurationStoreFactory; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private ConfigurationStore configurationStore; + private MapCacheManager mapCacheManager; private Set groupResolvers; @@ -69,7 +78,14 @@ class DefaultGroupCollectorTest { void initCollector() { groupResolvers = new HashSet<>(); mapCacheManager = new MapCacheManager(); - collector = new DefaultGroupCollector(groupDAO, mapCacheManager, groupResolvers); + collector = new DefaultGroupCollector(groupDAO, mapCacheManager, groupResolvers, configurationStoreFactory); + } + + @BeforeEach + void initStore() { + when(configurationStoreFactory.withType(UserGroupCache.class).withName("user-group-cache").build()) + .thenReturn(configurationStore); + when(configurationStore.getOptional()).thenReturn(Optional.empty()); } @Test @@ -141,7 +157,16 @@ class DefaultGroupCollectorTest { verify(groupDAO, never()).getAll(); } + @Test + void shouldGetCachedGroupsFromLastLogin() { + UserGroupCache cache = new UserGroupCache(); + cache.put("trillian", Set.of("hog")); + when(configurationStore.getOptional()).thenReturn(Optional.of(cache)); + Set cachedGroups = collector.fromLastLoginPlusInternal("trillian"); + + assertThat(cachedGroups).contains("hog"); + } @Nested class WithGroupsFromDao { @@ -169,5 +194,23 @@ class DefaultGroupCollectorTest { Iterable groupNames = collector.collect("trillian"); assertThat(groupNames).containsOnly("_authenticated", "heartOfGold", "fjordsOfAfrican", "awesome", "incredible"); } + + @Test + void shouldGetScmGroupsForLastLoginWhenNothingCached() { + Set cachedGroups = collector.fromLastLoginPlusInternal("trillian"); + + assertThat(cachedGroups).contains("heartOfGold", "fjordsOfAfrican"); + } + + @Test + void shouldGetCachedGroupsFromLastLoginWithInternalGroups() { + UserGroupCache cache = new UserGroupCache(); + cache.put("trillian", Set.of("earth")); + when(configurationStore.getOptional()).thenReturn(Optional.of(cache)); + + Set cachedGroups = collector.fromLastLoginPlusInternal("trillian"); + + assertThat(cachedGroups).contains("earth", "heartOfGold", "fjordsOfAfrican"); + } } } diff --git a/scm-webapp/src/test/java/sonia/scm/user/PermissionOverviewCollectorTest.java b/scm-webapp/src/test/java/sonia/scm/user/PermissionOverviewCollectorTest.java new file mode 100644 index 0000000000..cd402f9886 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/user/PermissionOverviewCollectorTest.java @@ -0,0 +1,250 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.user; + +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.group.Group; +import sonia.scm.group.GroupCollector; +import sonia.scm.group.GroupManager; +import sonia.scm.repository.Namespace; +import sonia.scm.repository.NamespaceManager; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.security.PermissionAssigner; +import sonia.scm.security.PermissionDescriptor; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PermissionOverviewCollectorTest { + + @Mock + private GroupCollector groupCollector; + @Mock + private PermissionAssigner permissionAssigner; + @Mock + private GroupManager groupManager; + @Mock + private RepositoryManager repositoryManager; + @Mock + private NamespaceManager namespaceManager; + + @InjectMocks + private PermissionOverviewCollector permissionOverviewCollector; + + private final String unknownGroupName = "hog"; + private final String knownGroupName = "earth"; + + @BeforeEach + void mockGroups() { + when(groupCollector.fromLastLoginPlusInternal("trillian")) + .thenReturn(Set.of(unknownGroupName, knownGroupName)); + } + + @BeforeEach + void mockSubject() { + Subject subject = mock(Subject.class); + ThreadContext.bind(subject); + } + + @AfterEach + void clearContext() { + ThreadContext.unbindSubject(); + } + + @Nested + class WithGroups { + @Test + void shouldCollectGroupsFromGroupCollector() { + when(groupManager.getAllNames()).thenReturn(singleton(knownGroupName)); + mockUnknownGroup(unknownGroupName); + mockKnownGroup(knownGroupName); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + Collection relevantGroups = permissionOverview.getRelevantGroups(); + assertThat(relevantGroups) + .extracting("name") + .contains(unknownGroupName, knownGroupName); + assertThat(relevantGroups) + .extracting("permissions") + .contains(false, true); + assertThat(relevantGroups) + .extracting("externalOnly") + .contains(false, true); + } + + private void mockKnownGroup(String knownGroupName) { + when(permissionAssigner.readPermissionsForGroup(knownGroupName)) + .thenReturn(singleton(new PermissionDescriptor())); + } + + private void mockUnknownGroup(String unknownGroupName) { + when(permissionAssigner.readPermissionsForGroup(unknownGroupName)) + .thenReturn(Collections.emptyList()); + } + } + + @Nested + class WithNamespaces { + + private Namespace namespace = new Namespace("git"); + + @BeforeEach + void mockNamespace() { + when(namespaceManager.getAll()) + .thenReturn(singletonList(namespace)); + } + + @Test + void shouldFindNamespaces() { + namespace.addPermission(new RepositoryPermission("trillian", "read", false)); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + assertThat(permissionOverview.getRelevantNamespaces()) + .contains("git"); + } + + @Test + void shouldFindNamespacesWithPermissionForUser() { + namespace.addPermission(new RepositoryPermission("trillian", "read", false)); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + assertThat(permissionOverview.getRelevantNamespaces()) + .contains("git"); + } + + @Test + void shouldFindNamespaceWithPermissionForGroupOfUser() { + namespace.addPermission(new RepositoryPermission(knownGroupName, "read", true)); + when(groupCollector.fromLastLoginPlusInternal("trillian")) + .thenReturn(singleton(knownGroupName)); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + assertThat(permissionOverview.getRelevantNamespaces()) + .contains("git"); + } + + @Test + void shouldIgnoreNamespaceWithPermissionForOtherUser() { + namespace.addPermission(new RepositoryPermission("arthur", "read", false)); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + assertThat(permissionOverview.getRelevantNamespaces()) + .doesNotContain("git"); + } + + @Test + void shouldIgnoreRepositoryWithPermissionForOtherGroups() { + namespace.addPermission(new RepositoryPermission("vogons", "read", true)); + when(groupCollector.fromLastLoginPlusInternal("trillian")) + .thenReturn(singleton(knownGroupName)); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + assertThat(permissionOverview.getRelevantNamespaces()) + .doesNotContain("git"); + } + } + + @Nested + class WithRepositories { + + private final Repository repository = RepositoryTestData.create42Puzzle(); + + @BeforeEach + void mockRepository() { + when(repositoryManager.getAll()) + .thenReturn(singletonList(repository)); + } + + @Test + void shouldFindRepositoryWithPermissionForUser() { + repository.addPermission(new RepositoryPermission("trillian", "read", false)); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + assertThat(permissionOverview.getRelevantRepositories()) + .contains(repository); + } + + @Test + void shouldFindRepositoryWithPermissionForGroupOfUser() { + repository.addPermission(new RepositoryPermission(knownGroupName, "read", true)); + when(groupCollector.fromLastLoginPlusInternal("trillian")) + .thenReturn(singleton(knownGroupName)); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + assertThat(permissionOverview.getRelevantRepositories()) + .contains(repository); + } + + @Test + void shouldIgnoreRepositoryWithPermissionForOtherUser() { + repository.addPermission(new RepositoryPermission("arthur", "read", false)); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + assertThat(permissionOverview.getRelevantRepositories()) + .doesNotContain(repository); + } + + @Test + void shouldIgnoreRepositoryWithPermissionForOtherGroups() { + repository.addPermission(new RepositoryPermission("vogons", "read", true)); + when(groupCollector.fromLastLoginPlusInternal("trillian")) + .thenReturn(singleton(knownGroupName)); + + PermissionOverview permissionOverview = permissionOverviewCollector.create("trillian"); + + assertThat(permissionOverview.getRelevantRepositories()) + .doesNotContain(repository); + } + } +}