From 8286268047cbbca4e7f5fbb63ff0890c1ea37cf5 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 23 Oct 2026 14:07:16 +0800 Subject: [PATCH 1/4] fix(deps): bump dompurify to 3.4.17 for GHSA-cg2g-xh64-v4q7 / GHSA-mrv2-3pqr-j5jr DOMPurify <=3.4.16 is affected by two advisories published 2026-10-22: - GHSA-cg2g-xh64-v4q7 (critical): SAFE_FOR_TEMPLATES bypass via split template expressions in attached shadow root content on DOM output modes - GHSA-mrv2-3pqr-j5jr (high): IN_PLACE hook removing a parent/sibling leaves detached subtree event handlers armed Bump the direct dependency and the pnpm override floor to 3.4.17 so the mermaid-transitive copy dedupes to the same patched version. Lockfile-only regen via pnpm 9 (matches CI); no other package changes. --- frontend/package.json | 4 ++-- frontend/pnpm-lock.yaml | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 177259daa3c6..fa12747fa719 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,7 @@ "@vueuse/core": "^10.7.0", "axios": "^1.20.0", "chart.js": "^4.4.1", - "dompurify": "^3.4.14", + "dompurify": "^3.4.17", "driver.js": "^1.4.0", "file-saver": "^2.0.5", "marked": "^17.0.1", @@ -65,7 +65,7 @@ "js-cookie": "3.0.7", "form-data@<4.0.6": ">=4.0.6", "postcss@<8.5.18": ">=8.5.18", - "dompurify@<3.4.14": ">=3.4.14", + "dompurify@<3.4.17": ">=3.4.17", "source-map-js@<1.2.2": ">=1.2.2", "follow-redirects@<1.16.1": ">=1.16.1" } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5f02f3f2a8dc..d2714896a99f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,7 +8,7 @@ overrides: js-cookie: 3.0.7 form-data@<4.0.6: '>=4.0.6' postcss@<8.5.18: '>=8.5.18' - dompurify@<3.4.14: '>=3.4.14' + dompurify@<3.4.17: '>=3.4.17' source-map-js@<1.2.2: '>=1.2.2' follow-redirects@<1.16.1: '>=1.16.1' @@ -38,8 +38,8 @@ importers: specifier: ^4.4.1 version: 4.5.1 dompurify: - specifier: ^3.4.14 - version: 3.4.14 + specifier: ^3.4.17 + version: 3.4.17 driver.js: specifier: ^1.4.0 version: 1.4.0 @@ -2327,8 +2327,8 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} - dompurify@3.4.14: - resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} + dompurify@3.4.17: + resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==} driver.js@1.4.0: resolution: {integrity: sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==} @@ -6052,7 +6052,7 @@ snapshots: '@types/dompurify@3.2.0': dependencies: - dompurify: 3.4.14 + dompurify: 3.4.17 '@types/estree-jsx@1.0.5': dependencies: @@ -7001,7 +7001,7 @@ snapshots: dependencies: esutils: 2.0.3 - dompurify@3.4.14: + dompurify@3.4.17: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -8102,7 +8102,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.4.14 + dompurify: 3.4.17 katex: 0.16.45 khroma: 2.1.0 lodash-es: 4.18.1 From ac1b8f1271be0d674fa6f074403c1410f30a3a81 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 23 Oct 2026 15:24:04 +0800 Subject: [PATCH 2/4] chore: update sponsors --- README.md | 5 +++++ README_CN.md | 5 +++++ README_JA.md | 5 +++++ assets/partners/logos/ooko.ai.png | Bin 0 -> 9566 bytes 4 files changed, 15 insertions(+) create mode 100644 assets/partners/logos/ooko.ai.png diff --git a/README.md b/README.md index 11955a37b6e9..38b70781cf8f 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,11 @@ Please read the following carefully before using this project: Thanks to Chenju for sponsoring this project! This is probably the only ad slot in this section that doesn't ask you to sign up for anything. Good open-source projects deserve to be paid for, so Chenju is chipping in a gift and raising a glass to the author. Find Chenju on X to chat about API relays, upstream providers, or just shoot the breeze. + +ooko.ai +🔑 Buying AI accounts at top prices | Gemini · Claude · GPT | Main and alt accounts all welcome, any channel wanted | Pay-per-usage supported | Seeking first-hand resources from major providers, I'll bring the KEY.
WeChat: Liuyou-zawd | Telegram: AACC8888888 + + ## Overview diff --git a/README_CN.md b/README_CN.md index 9064b68eacea..d34649c84556 100644 --- a/README_CN.md +++ b/README_CN.md @@ -159,6 +159,11 @@ 感谢 陈局 赞助了本项目!这大概是本栏唯一一个不让你注册的广告位。好的开源项目值得被付费,陈局来随个份子,敬作者一杯。X 上找 陈局,聊中转、聊上游、吹吹水。 + +ooko.ai +🔑 高价收 AI 账号|Gemini · Claude · GPT|大小号通收,各种渠道都要|支持按量跑付|寻一手大厂资源,我上 KEY 。
微信:Liuyou-zawd|TG:AACC8888888 + + ## 项目概述 diff --git a/README_JA.md b/README_JA.md index 367cce6373db..58c5f379f79a 100644 --- a/README_JA.md +++ b/README_JA.md @@ -158,6 +158,11 @@ 陈局(Chenju)様、本プロジェクトへのご支援ありがとうございます!これはおそらく、この欄で唯一「登録しなくていい」広告枠です。良いオープンソースには対価が払われるべき——というわけで、陈局がご祝儀を包んで作者に一杯おごります。X で 陈局 を見つけて、中継サービスや上流の話、雑談でもどうぞ。 + +ooko.ai +🔑 AI アカウント高価買取|Gemini · Claude · GPT|メイン・サブアカウントすべて買取、チャネル問わず歓迎|従量制での支払いに対応|大手の一次リソースを求めています、KEY は私が用意します。
WeChat:Liuyou-zawd|Telegram:AACC8888888 + + ## 概要 diff --git a/assets/partners/logos/ooko.ai.png b/assets/partners/logos/ooko.ai.png new file mode 100644 index 0000000000000000000000000000000000000000..8884583403521912e778111f71ac6f497238bab1 GIT binary patch literal 9566 zcmbtacRbX8{68ZuyE>5wb(J#X>=im(oLn4EB+1O)TbUVAC+qBymB`+tOP8*QBiWgk zy|UNu{r&s*c>MnP-Q!_AJ`X-CD(*VAHU;$eb7Agn0uJNF?F8bk2AE0h*| z?{4Dv20u=_YU4a05ax6LebGP&8Qc(v00ebM&CoYvInyr?h8*5)ez%{xk|mW$Cjl4G zriWo}(;&sHy^u6&7UoDAb1{q$XaAVb7N-%?$lTa1Bvhz5hF0w6hbghp7h)pcuG&5m z;3ywW%cuU$Y6}U0U9h6HluUc|s$|vn7)sRj!roKeQrha@R6_c<;^-N|P^!0ed>~;R zH7q8(w^9+Om{l1P3m;9A9`(UQ4!Ly+p%MIHD1#4cRucA1=Yv_Up*ub$bQIk9zy7$G z+2aBpF8)1&=VMh_@(L{jCB{y3k`eL>ZjVd0i^1BbgNKKDN92W`?d3IaikEeo5ll|@oOJbPX`tJHUYwOubS2>=SbxZhkI7*F!mrd(nds)Hj zn@_X3MM&%9y9$|bb-@IZ72(=8^iki-eyA$s_TZK_T+-(I-;2)NhB#cU(@6Yn0SM%_ zuoLt_@PiHsU7~~`RvJy2UN;MHnpj#Y`;jjD-mLMPbG-p9f{!M(=JKge?1FFL(bj3C zO97)c(}V9Hh{s&+*@Pi+$pgcPn%dYK!xJy}6(e8k{i96iIFh2AlVuZ|1jhjX}L7I7e<*UAnd1( zj}E?($@1|}ovJw*D7pyLE)Aqg#Ao5nNuQ2~amDfD)$!H~7cOv-_b+(e=Lo-|70(cs zOX3e%isJX)4^)Y?6pJ~wl0d`#CaJjOXM{&KpMpfxC6lONpM3Jv|~ z^<#S7ajfa3bz^V7)+t&VsAbG5f`Rf)`~EH~(N!T+cGQ(PT)UW|7gfGUuf`GX)!PO& zAI#JvfrHKslMF?a=&|EenR}8p;I_D7TJeWh(bW+VjB@c(Kk6RAHK52_{pIG038dDi zgO}9)RIVr36XE%L;M~ob#7NeWI;UoVoYwX8#$z{pFGLDL59^#pqF67^d0WjU*iR!& z!2D9im5*84Ce>K-Wd7E`7ZlE)I+en&?9T$} zLp(5Nu`rN8Geb81_F93%E~C3nPlYs|bs)~Y{Nmpd zC5!H5cas;ty4BSoDl8?u>}uv@S;DA(=`YVA&p>`}f2M;7#pM0?vaVFIs9nM&p z@UtbYBK0C*OZbkp!R@5xbX#mrH&u#|t3*a*B9oypc{v?;xEh6Jd#t}GLK-|uOrCDK zOs!VJrCz)K$qz2WHGcDb)~wWl0qwS{s0||fy{7$zr;jVSQTRgprm^ySsz*y?@xdWS z`&LX8T=mKotF=EDz08Y*ni?8*KS!TKpo@r3f~YDFsu%)&F5H@6gX*$xaWlv+h&Zvu z-9mwPX3sO$?87hdXINio%5zxf?p;swD_xP{{O`}I=dR@^bf+Th^GY4QmT}UrB;Hdw zm`isdoPYKz)U8$y0zu&!*e|s>4A@rjPRosC$cJPo2k03Y8KKV`rFT||KuelenyyPp zkr8NI@`Ud+C)$7}r_XXqCj%bQu6Nh5FX#LdTylX4g2$Ts+Ia(LsACSO_tT-U&Yy7? z5_D4~I#$^)8;s4mw;ilZwI9x+o5$E7+WWS8T@9vm+Ivq4m!j{OBxA7B?+vp{OJnj< z3rk8Jbavh*^}(uDuk!R=hB8CW_UljYDF%?G6)Xa0UeBMqt_K5KsjmuJKT_+vK5?=? z*shQne=UW*;@c$9IZx)Wke)f=bYE0V+{l@Qm_Ly`s%bpKw--rCIkENJQR(sRl1@4W4XtF!DE;mBUm5Z=ANc z9qWXqJi-0);**9#P7BryIoh==tMwu=U#djjl{#=oTH}h{A}t+d9^CEbxx@N}6j;7q zL(t42-tB>A#>JeEIxZ8c|P_fkF>+ajK$78g#_-AL-rtA8-Cx! zq~S>y{;f_*=i|zm0ycFaa@EN4!w-L!%gRJ>$(edo0E^jvb3TC4vxeV*M}ea7{}sm1 zd=jU5vp2cB`wYDz+Q4q>5?d6way+km;L`726I~lzdE03Sq+$JwATKF@@A1L*Di-VX zjlng&04sOsP>)H(gc&`3BEcQFI~jG!Z|1eDT%6>xNpjF+1efa3KZ69)xsXwV-F%tO z$*7xz1Rm*ytw9U&j~_{J`SY=9g(wW{CP!ImDQP-Q&h69hN-Ju2(B4bg@jrf-A|`%0 zGeUBNm`H9ety{l>cE+9(&MVBPT5=aGBG6JS`S-F_fY9;jQQlG7{yiPV#^>PJYY7!3 zNGFTnBz@NeumjwY+Xr+fJ0U#>N5S#aukND{8vBIPLEureU^m>0hf{*LHsb$9j~S`BBQUGziWEm=OBG@ zAyd`9uRx1G2sR%WlP45IP!8x`7%~bl2|sj<{LFPl8VIkvdqG|v zUf@Mp*>^GXCMfOd4x=3*OL3?3@UZ22c1AS*%v4U^X{8=wQxF~= zo7eO*S{`>+Wh&N_ic011aAf`&3c{+A+i2JJ>V-)yKj|A@yEw1$D=0}0s4fAqzE0w2 zjUvKPK*)#s9`6lhhaCH9YHDtF7JrEvC{Cgemw+*i`A+B5j}s~Y$qGHJ@L3+Nt!Udf zwY0QE@YIT^bL^IzH|u38%#H`FCc(Q_*>y7&-d0s@L~uPUQ%=bZjEZ39V!NbNT>6db z`uoc3uYeECTK&DS*rd`@1UjH~+>#iny!oB7x3@Pi&<3jas!nno_pQ9VJX$B`?D~2j zk*$Y7QLtT*Y$z=qUfjB*8VvTe8xo0pSivr9Giqg{iSZqvQhMw)ihea!60aYTxWJxpy$UhaojXMH=w2IURomu^E4L|8hd-IcL zl^G(y&7zkoF)7>br^FlI4`Z7-KBNvBmz&LR$>h&mXJjg<9f|PUJpL@9D{`Lvb?xwI zHH2Sku3Pj$ogLwe8*h(CQ~BypHMN8By3)?5M<@`^fH2E&QWb#owBeyyn3|^Jfr?|*0{|%Sz zB`POUzT$wcafLf_?{7YP6g$5u7Q4Jj1+_&rrIHYUbQjH{{j2CrdgPU;~IUAa~| zk4*Y<^Qw%v$=aN;MNp&5L{?s2SVY7@o#RkV;2s_sdz?M7YK1exVw=Z;J2_kXM1AG2 z!bJ~ns7PFAL*Y9f{Q-=yyX+Ejj!kQ-TZw{TWcj=5senMY>n9zaYm0`2tLHYawl&|& zeix?JJL$SKRPFPttjISDSQw$Pp9pk~a#~W7w99z*@p?i?gP9l>D<7Y(62$XOUSng< zJK$2t;jK6CKW~n1_VgSB6`|nSK!0^ckS;Z^I3putnv|fr@s;ICYn1vVCSP-Faj?Sj zwZ=F-w1i#N%pJG}tdWsl>o_qggjd=BJ1|94`0P(=SAE%^_HncVMEK8M0noe`wBG3u zDzNOWNOF_8NCIz(IrNWQaLX=P{3K%gP?z$p%ig%XjBc(4j`^}!q2@|+nSEjeei@?)YaCVhlWYd3g`DWsj+!q zC3N|&oJ#B9CAIF&9csfrN$4VOvhyj_cu>15O<2^qqwhqr^AA_sCTPQ_T;;r1Mr#B2 zHoku^^Y!*elSHssQ0_q59AMuW4K0V3mMZm(xsmu-g+d?@cYkr*Mu&Ltt-R7w1!3XM zpSc=VUq*3eW=oz5Pj_UnVRucAQRaiK8k|x$bD=?p*>N;p>F>SL(k^y ztaYy)4Ex3kS9gsbN`Vp{Zx@-6@}_S)x2%7`RoiIhV`?VjL~j3Ux9h2}Xm5M14ORc? z-`^%eK6UC8a71``8g*}KYHDk--CeGGB|wTLY`TGtkqN-URcL75=c?-JipIGG6OqUk z+!f8Z+hRe4LY0FhioTF%c_K!HObaQ)ZJtsv8kLBnq1$H;b^Rr+3i*g&#luy1{~TEy zZzL}`SlHOuSWZ6g%_n6j`BlZw6nX1Mk7+Zj3&nH*OI&7gvf86+;q~WZaHQpwRY~+s z`gLD=$2r;8KtIa&i%1^2ySwYFTqC(vnk>E6via|N!=Kehbb6FFv7%XfBW2%C zS_DqafGf<@iz+RZx!CM9QWp~w!|=@p0)eA|hmj5ch(JTW0?^=&d>*gbx={KK=w^FU z{LGK^laXh1JqUENHTxwcZYVu>BnBp}s;Vk1yjX^e>Mh-M2{{<~`nrBEFt~_P`E;-x z?A?^)Om`VR!X6A0#6wo>=TGm&KvS(SEH(;CfF?jC-WzwA<%MOFIHjY!`s; z%ai5i;?sNm^su+0qGsRo^_aDfdwU_d`gU{Q(?H1sj0;+>JyP&q;*RZvqnZvk$oxT` zUjV>*Hk(KG@>T}DyvQ|t@b}kpm2X2wThPHd+)9?5o8uww9`91&sIAD1VT-D7NSKGx`A3-qd9(cq$}xOhd7SIw**<$LHUX3hA+w>?}r zLNNl`L<#lW4?k?)iK_#8%Aay@h-v7BpN4+A4?8tr7siqITTShQBClQ%Whhr;1#4{n zlKGwN>*NV)#ipY`*UkZ5`_Zxs@MI~OnFi+ON8f}i+VR!>Y2eDkB-TDy>wM5)Rr^^g1ZZXiR&EzF$<{#~Efm0uieNU^Fl#*JsJ2qr3tsHUu z+Ad5!3;BcBao^ljw6>)-m|cn@3PNeg!*&GCU==jQ^nu9^Qj3{ut&9?BPY4w-G%`{- z-gRN)Q#y*!nL_(g>wY=A!<_Q*z>P$f?RKG||I7+=+6^ZGtjZCm2*u3YaaW!ImZjBR5VaB3F zZrs@NRpPim>H1`2rt9R8N(Dd^l=(LY##V}vJ%@nXs~EY@LouVkj%&w%r_o!~p}d0pa9X|&>E zl+~@yX*ZXljWK{oK1;tRU7>`+N|DW}Sn=PEOE}izLiSROJr^8x^R6R^bJ&#;vsL6K zkSdU?tkZL{w{Kh)fh8d)#|ShaBLH1Mx;l-ws1C>;r>hiyekoa#K2kB^ zRX?F(iiZ;=9A(l=OBvbEXDRsstiBHmd#!DPhocQRk@KT_eT4<=j|i|C0|T63(t$&= zbuS{qd-vzXzagx|4;sCEmJjC)T|2 zP1tMf8OK7C8_>>BK_n(ADJi%;0lXuuqUT@NuB&`JP&4F3cV+*7o(g<~!doonfcSQ< z*dE_YEksfN?QdCtm@JDbjrNe^1qyYz(yCLbM>u9`-521cftF6u6MY!+YF;|#ALil1 zw!!og`7UwDnzFrg^|PP&D|Q~6-pE@y)lG9> zOT7KiT?m$EPk?u6!~5PDi@#Zmjr$0*{B^-mqn=nL6a!ejqSOI~EGdoC41_eXl$wh6@fR`Pf1B1-PdwP~8od`6wTqYl5S{Magu*}h57HjiJC8MX&y+5! z^m~m>uZujW`!AbP%+#YGtmU_(R1aN>^fL8P*#O77X+Ia|W+{GroGO*Ht&P8P{ZxIy{ItnlH+B zo@i5!fxGzoEc{z&sRz6OcpZqTY7r|!p=D~d%@0q7Ogy}E=k0XF&K=AWphw0_+~O!n zB!*BJ0xYohxPBawApI27ynH-!6mif(_0RFa>bug?9D5#hZsT+gb!e^-L!oY3Ut5qO z*!gS+^%KXo)b32`7*E;Qro6}8oqTXEt4b46d?WC-ypp{4<%)-mL4u+rzNXZ(8T!NK zz|Th;mn=%YjG~oiLsKq?ur(D6F;p}bu5udxi|Guma_e?LKjN8t+;=E#0zX@CK z6(OIc&lRfF5o??Q5BjSnSw6i}LbrLW&8|0(Qlvw#28|+us*J_EAw+TZw)qINHOJhD0(|+9uGzt&&NT=c| zukpi&AoDQ94j|AN7={IFXsDZ|WWE2-K36}r&>l^B7;?1rnJPj-@Uf9V2s7bFu zt+_G_WNwynz~LHEFa4#{@Q_@w!%GGC87DtKzhYOc<>awT+tBaZWVg37n|mOCFhXMV z1)GG+8$PY>xQx_cIp~SnDvNZG`V?RmCNGet zSy<@*6ooOl@ydEHr2=pUnHlapQ38j<0Ra-UJ!EU!oe>THIqh~bEAG;=Gaj(+UTBB; z3A}E=O5^qOvFl3pKnH>D%BB)Omv;Yfcc!9C^Kuxi2R)Q!zid z+O)kaE(i^!p2D!K4A)*+NiPuMW?^XZUM3_Zy)V5P4icy=r5+vp9S(TUro8+zfFq9) zr~LB9Lh=1hFLNy#Hps`RseBkRmzpR5g*=*Q8_Zko8kRo92VNT%LPxJXHKWxHeF?{# z<}XH)XO9mzdl*@`CA^n@Kazk#Ufj8ypuGJXNHkDv4ysdKUL}>>k(1uds>i$2qoboK z5B&wt8w9FY05q8E36X*^XChzp}~&UIM#zs zu1#JkZ$H+Y*#ISqKW6vfAQP}^o%eA$*rROm}*6eA99p2>>aYP3fXC%P=k7!Ys}3=bS)skdbl?} z3|OuO&$hi+DkargT*ivR19`{!@x>i@`j5FS8^@8l<+>3~=h24BwpXczU2bM5R2QGm z==zYYu(?>u$a0M4Ra~-PmQVATQV+SROz5s=Bnk0@>!Gjxw>LMwURkTQ_*!L0u)$rE zl1j`guGXCY+qB}{Y+PniY4fAKWjwB|OcxKg?GD`NR$RT)d#;2B7CJ2#1-O5NkByP! z=H*YWT3VkbrHCEf#=ty>OW~-mTQ3}3@{d=?nge?C+QIA0B}TD>z6PYk=J?tW4?+R-`&0O)yG$ z;DT&@7=HKz9oKfWogT)(+PGBZ?A=Uu zBRi&VCZ69HbjX0t6{06T>-fAYy^H@nSHV2LWg|UBfk!(i#a)!$20a7$!Jg4#25Iy~ zdG{v(YyCBMRww@RKKlp6t@ap)U9$TU$dgz5Z&+aZ8%RE#F`UH=nrAoI1z|`x-~zG( zH|#N(GbFc{PV}5zv3D-(6-mj;0!epq=vv1kfU9LyRcB|Hvv&$uXF4>C&d$xu%*@R- znd#x_t3*I_0t8lc-d8u?7&nE*L1{E|#3x7w>Vm?ciG#|8%cy4=%Od1FL`*;1T@0*7 zEzC&vtHlFG$g{!{LLs2MAqxO_f#QbUoaFd~A#mgl7=0?qggfsVCK+l+iYIDa2B#5hM>a-Z}Sge(;@ zt1fUd^!bD7w)m--EAtaA|mC;_H6`T@PBPm`+7wOel>f7<|mYN zvh^9ZsQhl~)0mK}S59tRfK|!#Vhm~r-=Q*WqTr>4-#UAy1iFPC*PY?xlXp}S|K*%z zzkS<-H=HBQm)gW+den-@wuzXep{XfEFqGvzu0qhwM}?~0#!SrCt2QC;Z{6EN5c^0< zwf+^^_i&1{l5l#asil%L*4Ecob$A$v#0;mJ>*B-ngh0sp_N_JbZXD^+FawHr&>92t z+FhFr4W%{w+G;Z!(%Y`z%3(gp%*4dTuVO~+H)bh(*=si`i>5f&gRBJbqGE8ehevjX zkL5$5Ut^0~HrCeQIN7=0DxDvgVzaxeX{)zEBtqZuQ2(dX>UfY+kMym|`Xq=TXy3g! zBg@6fcRU$o9xCwY$AA6i#N}s_tul_k0qJq2kl)(-#|kgMX+fYtdT8F{IrPC&Bj$3- zt<+yS_!2!KNH8fr6Ew@27{Xx4f!)Ig!Smz0gJ}!5J07|I`-=ez#<9fx8Ta=HIBHUs zg*d$)xIJ_?xt5hz?n6`4{*+gOAeW7o*Vf19bhuxDeT9`Tu^S!y*CBs;EC)j_|E>&L z=q~|_T-^7-%H7@c^m%0O^V>N&IggfpSAy*Btq{-Znq}YfYo}FjzBMxR{996F!w1Yr ze@P-&W)v@Zgzs)(U@j*9spC`=T3JfA(8dIyPs+0q^^|F+3$+H?!Y=9r2 zHzj}=Z(H(S= z*+^-$y6@4+`pZ7%gT%=XV1Po(hJ~~a#Emg7dBmZPmchg5zQCb&9>^6m^TEx`kJR_P zzM^_eRZTw(Rt6x&1+SYhCgkNahFkNWDhe?Rw;&&#>lHJ* zpzpMvKj+%JiL z4DkeKX9ov$OibRJp5Rc(e3cf3XCZ!O z_ac!4Sg!3Yv{K)03%=Znf8s=Qw#+xSqu}%pp~Kpa6$DFR?eiXqPydf`q5m~obiyxN XHW>|3SFr;FQxFtV?@qb;qtO2WoZG(* literal 0 HcmV?d00001 From 5c185c5c5d4f5e409e2139806e0964ae944e03e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:44:16 +0000 Subject: [PATCH 3/4] chore: sync VERSION to 0.2.18 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 0fcb3bba7e0b..51e8fdc95c2b 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.17 +0.2.18 From e445cc3e2d9c9f330f2af069d6693bfdeb2245ad Mon Sep 17 00:00:00 2001 From: lyen1688 Date: Fri, 23 Oct 2026 19:16:57 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8C=89=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E4=B8=8E=E8=B5=84=E6=BA=90=E8=8C=83=E5=9B=B4=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E7=9A=84=E7=AE=A1=E7=90=86=E5=91=98=20API=20=E5=AF=86?= =?UTF-8?q?=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + backend/cmd/server/wire_gen.go | 3 +- .../internal/handler/admin/account_handler.go | 33 +- .../handler/admin/announcement_handler.go | 2 +- .../admin/custom_admin_key_accounts.go | 284 ++++++++++++ .../admin/custom_admin_key_accounts_test.go | 150 +++++++ .../admin/custom_admin_key_codex_import.go | 189 ++++++++ .../custom_admin_key_codex_import_test.go | 249 +++++++++++ .../admin/custom_admin_key_commit_test.go | 199 +++++++++ .../admin/custom_admin_key_group_models.go | 167 +++++++ .../handler/admin/custom_admin_key_proxies.go | 163 +++++++ .../admin/custom_admin_key_proxies_test.go | 198 +++++++++ .../handler/admin/custom_admin_key_users.go | 47 ++ .../admin/custom_admin_key_users_test.go | 113 +++++ .../handler/admin/custom_admin_keys.go | 102 +++++ .../handler/admin/custom_admin_keys_test.go | 78 ++++ .../admin/custom_legacy_admin_key_test.go | 141 ++++++ .../handler/admin/idempotency_helper.go | 16 +- .../internal/handler/admin/promo_handler.go | 2 +- .../internal/handler/admin/proxy_handler.go | 9 + .../admin/scoped_admin_content_response.go | 47 ++ .../scoped_admin_content_response_test.go | 94 ++++ .../scoped_admin_subscription_response.go | 65 +++ ...scoped_admin_subscription_response_test.go | 164 +++++++ .../admin/scoped_admin_usage_response.go | 97 ++++ .../admin/scoped_admin_usage_response_test.go | 92 ++++ .../handler/admin/setting_handler_runtime.go | 20 +- .../handler/admin/subscription_handler.go | 20 +- .../internal/handler/admin/usage_handler.go | 6 +- .../internal/handler/admin/user_handler.go | 34 +- backend/internal/pkg/adminaccess/catalog.go | 106 +++++ backend/internal/pkg/adminaccess/policy.go | 154 +++++++ .../internal/pkg/adminaccess/policy_test.go | 144 ++++++ backend/internal/repository/account_repo.go | 43 ++ .../custom_account_proxy_selection.go | 46 ++ .../custom_account_proxy_selection_test.go | 129 ++++++ .../custom_admin_key_group_models.go | 98 ++++ .../custom_admin_key_group_models_test.go | 105 +++++ .../custom_admin_key_review_test.go | 225 ++++++++++ .../repository/custom_admin_key_scope.go | 247 ++++++++++ .../repository/custom_admin_key_scope_test.go | 257 +++++++++++ .../custom_admin_key_transaction.go | 19 + .../internal/repository/custom_admin_keys.go | 112 +++++ .../custom_admin_keys_postgres_test.go | 146 ++++++ .../repository/custom_admin_keys_test.go | 50 +++ .../repository/custom_proxy_options.go | 56 +++ .../repository/custom_proxy_sample_test.go | 111 +++++ .../repository/custom_proxy_search.go | 20 + backend/internal/repository/setting_repo.go | 14 + backend/internal/repository/wire.go | 1 + .../internal/server/middleware/admin_auth.go | 64 ++- .../custom_admin_key_rate_limit_test.go | 170 +++++++ .../middleware/custom_admin_keys_test.go | 117 +++++ .../server/middleware/panel_rate_limit.go | 55 ++- backend/internal/server/routes/admin.go | 12 +- .../routes/custom_admin_key_access_test.go | 420 ++++++++++++++++++ .../custom_admin_key_group_models_test.go | 203 +++++++++ ...ustom_admin_key_payment_rate_limit_test.go | 54 +++ .../custom_admin_key_user_responses_test.go | 134 ++++++ .../server/routes/custom_admin_keys.go | 18 + .../server/routes/custom_admin_keys_test.go | 35 ++ backend/internal/server/routes/payment.go | 1 + backend/internal/service/admin_account.go | 56 ++- backend/internal/service/admin_service.go | 2 + .../service/custom_admin_key_accounts.go | 366 +++++++++++++++ .../service/custom_admin_key_accounts_test.go | 325 ++++++++++++++ .../service/custom_admin_key_codex_import.go | 164 +++++++ .../custom_admin_key_codex_import_test.go | 174 ++++++++ .../service/custom_admin_key_group_models.go | 56 +++ .../custom_admin_key_group_models_test.go | 48 ++ .../custom_admin_key_proxy_selection.go | 72 +++ .../custom_admin_key_proxy_selection_test.go | 174 ++++++++ .../custom_admin_key_recovery_review_test.go | 77 ++++ .../service/custom_admin_key_testdb_test.go | 47 ++ .../service/custom_admin_key_transaction.go | 29 ++ .../custom_admin_key_transaction_test.go | 53 +++ backend/internal/service/custom_admin_keys.go | 253 +++++++++++ .../service/custom_admin_keys_test.go | 229 ++++++++++ .../internal/service/custom_proxy_options.go | 35 ++ backend/internal/service/setting_features.go | 18 +- backend/internal/service/setting_service.go | 1 + backend/internal/service/wire.go | 3 +- .../migrations/247_scoped_admin_api_keys.sql | 22 + docs/admin-api-keys.md | 145 ++++++ frontend/src/api/admin/adminAccessKeys.ts | 37 ++ .../settings/AdminAccessKeysSettings.vue | 193 ++++++++ .../__tests__/AdminAccessKeysSettings.spec.ts | 247 ++++++++++ .../src/i18n/locales/en/adminAccessKeys.ts | 73 +++ frontend/src/i18n/locales/en/index.ts | 2 + .../src/i18n/locales/zh/adminAccessKeys.ts | 73 +++ frontend/src/i18n/locales/zh/index.ts | 2 + frontend/src/views/admin/SettingsView.vue | 58 +-- 92 files changed, 9054 insertions(+), 131 deletions(-) create mode 100644 backend/internal/handler/admin/custom_admin_key_accounts.go create mode 100644 backend/internal/handler/admin/custom_admin_key_accounts_test.go create mode 100644 backend/internal/handler/admin/custom_admin_key_codex_import.go create mode 100644 backend/internal/handler/admin/custom_admin_key_codex_import_test.go create mode 100644 backend/internal/handler/admin/custom_admin_key_commit_test.go create mode 100644 backend/internal/handler/admin/custom_admin_key_group_models.go create mode 100644 backend/internal/handler/admin/custom_admin_key_proxies.go create mode 100644 backend/internal/handler/admin/custom_admin_key_proxies_test.go create mode 100644 backend/internal/handler/admin/custom_admin_key_users.go create mode 100644 backend/internal/handler/admin/custom_admin_key_users_test.go create mode 100644 backend/internal/handler/admin/custom_admin_keys.go create mode 100644 backend/internal/handler/admin/custom_admin_keys_test.go create mode 100644 backend/internal/handler/admin/custom_legacy_admin_key_test.go create mode 100644 backend/internal/handler/admin/scoped_admin_content_response.go create mode 100644 backend/internal/handler/admin/scoped_admin_content_response_test.go create mode 100644 backend/internal/handler/admin/scoped_admin_subscription_response.go create mode 100644 backend/internal/handler/admin/scoped_admin_subscription_response_test.go create mode 100644 backend/internal/handler/admin/scoped_admin_usage_response.go create mode 100644 backend/internal/handler/admin/scoped_admin_usage_response_test.go create mode 100644 backend/internal/pkg/adminaccess/catalog.go create mode 100644 backend/internal/pkg/adminaccess/policy.go create mode 100644 backend/internal/pkg/adminaccess/policy_test.go create mode 100644 backend/internal/repository/custom_account_proxy_selection.go create mode 100644 backend/internal/repository/custom_account_proxy_selection_test.go create mode 100644 backend/internal/repository/custom_admin_key_group_models.go create mode 100644 backend/internal/repository/custom_admin_key_group_models_test.go create mode 100644 backend/internal/repository/custom_admin_key_review_test.go create mode 100644 backend/internal/repository/custom_admin_key_scope.go create mode 100644 backend/internal/repository/custom_admin_key_scope_test.go create mode 100644 backend/internal/repository/custom_admin_key_transaction.go create mode 100644 backend/internal/repository/custom_admin_keys.go create mode 100644 backend/internal/repository/custom_admin_keys_postgres_test.go create mode 100644 backend/internal/repository/custom_admin_keys_test.go create mode 100644 backend/internal/repository/custom_proxy_options.go create mode 100644 backend/internal/repository/custom_proxy_sample_test.go create mode 100644 backend/internal/repository/custom_proxy_search.go create mode 100644 backend/internal/server/middleware/custom_admin_key_rate_limit_test.go create mode 100644 backend/internal/server/middleware/custom_admin_keys_test.go create mode 100644 backend/internal/server/routes/custom_admin_key_access_test.go create mode 100644 backend/internal/server/routes/custom_admin_key_group_models_test.go create mode 100644 backend/internal/server/routes/custom_admin_key_payment_rate_limit_test.go create mode 100644 backend/internal/server/routes/custom_admin_key_user_responses_test.go create mode 100644 backend/internal/server/routes/custom_admin_keys.go create mode 100644 backend/internal/server/routes/custom_admin_keys_test.go create mode 100644 backend/internal/service/custom_admin_key_accounts.go create mode 100644 backend/internal/service/custom_admin_key_accounts_test.go create mode 100644 backend/internal/service/custom_admin_key_codex_import.go create mode 100644 backend/internal/service/custom_admin_key_codex_import_test.go create mode 100644 backend/internal/service/custom_admin_key_group_models.go create mode 100644 backend/internal/service/custom_admin_key_group_models_test.go create mode 100644 backend/internal/service/custom_admin_key_proxy_selection.go create mode 100644 backend/internal/service/custom_admin_key_proxy_selection_test.go create mode 100644 backend/internal/service/custom_admin_key_recovery_review_test.go create mode 100644 backend/internal/service/custom_admin_key_testdb_test.go create mode 100644 backend/internal/service/custom_admin_key_transaction.go create mode 100644 backend/internal/service/custom_admin_key_transaction_test.go create mode 100644 backend/internal/service/custom_admin_keys.go create mode 100644 backend/internal/service/custom_admin_keys_test.go create mode 100644 backend/internal/service/custom_proxy_options.go create mode 100644 backend/migrations/247_scoped_admin_api_keys.sql create mode 100644 docs/admin-api-keys.md create mode 100644 frontend/src/api/admin/adminAccessKeys.ts create mode 100644 frontend/src/components/admin/settings/AdminAccessKeysSettings.vue create mode 100644 frontend/src/components/admin/settings/__tests__/AdminAccessKeysSettings.spec.ts create mode 100644 frontend/src/i18n/locales/en/adminAccessKeys.ts create mode 100644 frontend/src/i18n/locales/zh/adminAccessKeys.ts diff --git a/.gitignore b/.gitignore index 0c977fdfa310..93d301f15714 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,7 @@ deploy/docker-compose.override.yml .gocache/ vite.config.js docs/* +!docs/admin-api-keys.md !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 53cef320529d..49e42b4c7d59 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -52,7 +52,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { settingRepository := repository.NewSettingRepository(client) groupRepository := repository.NewGroupRepository(client, db) proxyRepository := repository.NewProxyRepository(client, db) - settingService := service.ProvideSettingService(settingRepository, groupRepository, proxyRepository, configConfig) + scopedAdminKeyRepository := repository.NewScopedAdminKeyRepository(db) + settingService := service.ProvideSettingService(scopedAdminKeyRepository, settingRepository, groupRepository, proxyRepository, configConfig) emailCache := repository.NewEmailCache(redisClient) emailService := service.NewEmailService(settingRepository, emailCache) turnstileVerifier := repository.NewTurnstileVerifier() diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 4c2fbd37ca76..c48a9f99cd95 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -1419,7 +1419,9 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(ctx, account) if err != nil { // 刷新失败但 access_token 可能仍有效,尝试设置隐私 - h.adminService.EnsureOpenAIPrivacy(ctx, account) + service.AfterAccountCommit(ctx, func(committed context.Context) { + h.adminService.EnsureOpenAIPrivacy(committed, account) + }) return nil, "", err } @@ -1471,7 +1473,10 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv if updateErr != nil { return nil, "", fmt.Errorf("failed to update credentials: %w", updateErr) } - h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount) + h.invalidateScopedAccountToken(ctx, updatedAccount) + service.AfterAccountCommit(ctx, func(committed context.Context) { + h.adminService.EnsureAntigravityPrivacy(committed, updatedAccount) + }) return updatedAccount, "missing_project_id_temporary", nil } @@ -1527,17 +1532,19 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv return nil, "", err } - // 刷新成功后,清除 token 缓存,确保下次请求使用新 token - if h.tokenCacheInvalidator != nil { - if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(ctx, updatedAccount); invalidateErr != nil { - log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", updatedAccount.ID, invalidateErr) + service.AfterAccountCommit(ctx, func(ctx context.Context) { + // 刷新成功后,清除 token 缓存,确保下次请求使用新 token + if h.tokenCacheInvalidator != nil { + if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(ctx, updatedAccount); invalidateErr != nil { + log.Printf("[WARN] Failed to invalidate token cache for account %d: %v", updatedAccount.ID, invalidateErr) + } } - } - // OpenAI OAuth: 刷新成功后检查并设置 privacy_mode - h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount) - // Antigravity OAuth: 刷新成功后检查并设置 privacy_mode - h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount) + // OpenAI OAuth: 刷新成功后检查并设置 privacy_mode + h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount) + // Antigravity OAuth: 刷新成功后检查并设置 privacy_mode + h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount) + }) return updatedAccount, "", nil } @@ -2804,6 +2811,10 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { return } + h.respondAvailableModels(c, account) +} + +func (h *AccountHandler) respondAvailableModels(c *gin.Context, account *service.Account) { // Handle OpenAI accounts if account.IsOpenAI() { // Prefer the shared, account-keyed upstream catalog. If discovery fails, diff --git a/backend/internal/handler/admin/announcement_handler.go b/backend/internal/handler/admin/announcement_handler.go index d3b9d173731c..acd084813ebb 100644 --- a/backend/internal/handler/admin/announcement_handler.go +++ b/backend/internal/handler/admin/announcement_handler.go @@ -252,5 +252,5 @@ func (h *AnnouncementHandler) ListReadStatus(c *gin.Context) { return } - response.Paginated(c, items, paginationResult.Total, page, pageSize) + response.Paginated(c, adminAnnouncementReadResponse(c, items), paginationResult.Total, page, pageSize) } diff --git a/backend/internal/handler/admin/custom_admin_key_accounts.go b/backend/internal/handler/admin/custom_admin_key_accounts.go new file mode 100644 index 000000000000..dc4a2e21a65d --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_accounts.go @@ -0,0 +1,284 @@ +package admin + +import ( + "context" + "encoding/json" + "slices" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func scopedAdminAccountDTO(a *service.Account, p adminaccess.Policy) gin.H { + resource := adminaccess.Account{ID: a.ID, Type: a.Type, Platform: a.Platform, GroupIDs: a.GroupIDs} + groups := []int64{} + for _, g := range a.GroupIDs { + if p.AllowsGroup("accounts.read", resource, g) { + groups = append(groups, g) + } + } + result := gin.H{"id": a.ID, "name": a.Name, "notes": a.Notes, "platform": a.Platform, "type": a.Type, "status": a.Status, "schedulable": a.Schedulable, "concurrency": a.Concurrency, "priority": a.Priority, "group_ids": groups} + // 兼容原账户读取结构,仅返回模型映射,凭证及扩展字段继续脱敏。 + if p.AllowsAccount("accounts.read", resource) { + mapping := map[string]string{} + switch raw := a.Credentials["model_mapping"].(type) { + case map[string]string: + for source, target := range raw { + mapping[source] = target + } + case map[string]any: + for source, target := range raw { + if value, ok := target.(string); ok { + mapping[source] = value + } + } + } + result["credentials"] = gin.H{"model_mapping": mapping} + } + return result +} + +// ScopedAdminKeyAccounts 在原处理器前完成独立授权和安全输出。 +func (h *AccountHandler) ScopedAdminKeyAccounts(c *gin.Context) { + principal := adminaccess.FromContext(c.Request.Context()) + if principal == nil { + return + } + path := c.FullPath() + if !strings.HasPrefix(path, "/api/v1/admin/accounts") { + return + } + c.Abort() + base := "/api/v1/admin/accounts" + // 静态默认映射不读取任何账户;权限独立于账户查看及凭证写入。 + if path == base+"/antigravity/default-model-mapping" && c.Request.Method == "GET" { + if !adminaccess.RouteAllowed(principal.Policy, c.Request.Method, path) { + response.Error(c, 403, "默认模型读取未授权") + return + } + h.GetAntigravityDefaultModelMapping(c) + return + } + if path == base+"/import/codex-session" && c.Request.Method == "POST" { + h.scopedAdminImportCodexSession(c) + return + } + api, ok := h.adminService.(service.ScopedAdminAccounts) + if !ok { + response.Error(c, 503, "受限账户服务不可用") + return + } + ctx := c.Request.Context() + if path == base && c.Request.Method == "GET" { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + accounts, total, err := api.ScopedAdminList(ctx, pagination.PaginationParams{Page: page, PageSize: size}, c.Query("platform"), c.Query("type"), c.Query("status"), c.Query("search")) + if err != nil { + response.Error(c, 503, "账户查询失败") + return + } + items := make([]gin.H, 0, len(accounts)) + for i := range accounts { + if principal.Policy.AllowsAccount("accounts.read", adminaccess.Account{ID: accounts[i].ID, GroupIDs: accounts[i].GroupIDs, Type: accounts[i].Type, Platform: accounts[i].Platform}) { + items = append(items, scopedAdminAccountDTO(&accounts[i], principal.Policy)) + } + } + response.Success(c, gin.H{"items": items, "total": total, "page": page, "page_size": size}) + return + } + id, _ := strconv.ParseInt(c.Param("id"), 10, 64) + if (path == base+"/:id" || path == base+"/:id/models") && c.Request.Method == "GET" { + a, err := h.adminService.GetAccount(ctx, id) + if err != nil || a == nil || !principal.Policy.AllowsAccount("accounts.read", adminaccess.Account{ID: id, GroupIDs: accountGroupIDs(a), Type: accountType(a), Platform: accountPlatform(a)}) { + response.Error(c, 404, "账户不存在") + return + } + if path == base+"/:id/models" { + h.respondAvailableModels(c, a) + return + } + response.Success(c, scopedAdminAccountDTO(a, principal.Policy)) + return + } + if path == base+"/bulk-update" && c.Request.Method == "POST" { + h.scopedAdminBulkUpdate(c, api) + return + } + + fields := map[string]json.RawMessage{} + action := "" + var run func(context.Context, *service.Account) (*service.Account, error) + switch { + case path == base && c.Request.Method == "POST": + id = 0 + if c.ShouldBindJSON(&fields) != nil || fields == nil { + response.Error(c, 400, "请求格式无效") + return + } + case path == base+"/:id" && c.Request.Method == "PUT": + if c.ShouldBindJSON(&fields) != nil || len(fields) == 0 { + response.Error(c, 400, "请求格式无效") + return + } + case path == base+"/:id" && c.Request.Method == "DELETE": + action = "accounts.delete" + case path == base+"/:id/schedulable" && c.Request.Method == "POST": + if c.ShouldBindJSON(&fields) != nil || len(fields) != 1 || fields["schedulable"] == nil { + response.Error(c, 400, "仅支持 schedulable 字段") + return + } + case path == base+"/:id/clear-error" && c.Request.Method == "POST": + action = "accounts.recover" + case path == base+"/:id/refresh" && c.Request.Method == "POST": + action = "accounts.refresh" + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + a, err := h.adminService.GetAccount(ctx, id) + if err != nil || a == nil || !principal.Policy.AllowsAccount(action, adminaccess.Account{ID: id, GroupIDs: accountGroupIDs(a), Type: accountType(a), Platform: accountPlatform(a)}) { + response.Error(c, 404, "账户不存在") + return + } + + run = func(txctx context.Context, a *service.Account) (*service.Account, error) { + updated, _, err := h.refreshSingleAccount(txctx, a) + if err != nil { + code := infraerrors.Code(err) + if code != 400 && code != 403 && code != 404 && code != 409 && code != 503 { + return nil, infraerrors.New(502, "SCOPED_REFRESH_FAILED", "上游刷新失败") + } + } + return updated, err + } + default: + response.Error(c, 403, "此账户操作未开放给受限管理密钥") + return + } + if id <= 0 && path != base { + response.Error(c, 400, "账户 ID 无效") + return + } + a, err := api.ScopedAdminWrite(ctx, id, action, fields, run) + if err != nil { + scopedAdminAccountError(c, err) + return + } + if id > 0 && fields["credentials"] != nil { + h.invalidateScopedAccountToken(ctx, a) + } + response.Success(c, gin.H{"id": a.ID, "status": a.Status}) +} +func accountGroupIDs(a *service.Account) []int64 { + if a == nil { + return nil + } + return a.GroupIDs +} +func accountType(a *service.Account) string { + if a == nil { + return "" + } + return a.Type +} +func accountPlatform(a *service.Account) string { + if a == nil { + return "" + } + return a.Platform +} + +func scopedAdminAccountError(c *gin.Context, err error) { + code, message := scopedAdminAccountErrorDetails(err) + response.Error(c, code, message) +} + +func scopedAdminAccountErrorDetails(err error) (int, string) { + code := infraerrors.Code(err) + message := "账户服务暂不可用" + switch code { + case 400: + message = "请求参数无效" + case 403: + message = "操作未授权" + case 404: + message = "账户不存在" + case 409: + message = "账户正在处理,请稍后重试" + case 502: + message = "上游刷新失败" + default: + code = 503 + } + return code, message +} + +// scopedAdminBulkUpdate 沿用旧批量接口与结果结构,每个账户仍走锁内逐字段验权。 +func (h *AccountHandler) scopedAdminBulkUpdate(c *gin.Context, api service.ScopedAdminAccounts) { + var fields map[string]json.RawMessage + if c.ShouldBindJSON(&fields) != nil || fields == nil { + response.Error(c, 400, "请求格式无效") + return + } + var ids []int64 + if json.Unmarshal(fields["account_ids"], &ids) != nil || len(ids) == 0 || len(ids) > 100 { + response.Error(c, 400, "请明确指定 1 至 100 个账户 ID") + return + } + delete(fields, "account_ids") + // 不把全站筛选条件传给原批量服务,以免绕开账户范围。 + if _, exists := fields["filters"]; exists || len(fields) == 0 { + response.Error(c, 400, "受限批量更新须明确指定账户及修改字段") + return + } + uniqueIDs := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + response.Error(c, 400, "账户 ID 无效") + return + } + if !slices.Contains(uniqueIDs, id) { + uniqueIDs = append(uniqueIDs, id) + } + } + result := service.BulkUpdateAccountsResult{SuccessIDs: []int64{}, FailedIDs: []int64{}, Results: []service.BulkUpdateAccountResult{}} + for _, id := range uniqueIDs { + account, err := api.ScopedAdminWrite(c.Request.Context(), id, "", fields, nil) + item := service.BulkUpdateAccountResult{AccountID: id, Success: err == nil} + if err != nil { + _, item.Error = scopedAdminAccountErrorDetails(err) + result.Failed++ + result.FailedIDs = append(result.FailedIDs, id) + } else { + if fields["credentials"] != nil { + h.invalidateScopedAccountToken(c.Request.Context(), account) + } + result.Success++ + result.SuccessIDs = append(result.SuccessIDs, id) + } + result.Results = append(result.Results, item) + } + response.Success(c, result) +} + +// 凭证写入必须在数据库提交成功后清理令牌缓存,回滚不能发布旧状态。 +func (h *AccountHandler) invalidateScopedAccountToken(ctx context.Context, account *service.Account) { + if h.tokenCacheInvalidator == nil || account == nil { + return + } + service.AfterAccountCommit(ctx, func(committed context.Context) { + _ = h.tokenCacheInvalidator.InvalidateToken(committed, account) + }) +} diff --git a/backend/internal/handler/admin/custom_admin_key_accounts_test.go b/backend/internal/handler/admin/custom_admin_key_accounts_test.go new file mode 100644 index 000000000000..08be6e6d31c7 --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_accounts_test.go @@ -0,0 +1,150 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminAccountDTORedactsSecretsAndForeignGroups(t *testing.T) { + p := adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.read"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}}} + a := &service.Account{ID: 9, Type: "oauth", GroupIDs: []int64{1, 2}, Credentials: map[string]any{"access_token": "TOP_SECRET", "refresh_token": "REFRESH_SECRET", "base_url": "URL_SECRET", "model_mapping": map[string]any{"alias": "target", "invalid": map[string]any{"token": "NESTED_SECRET"}}}, Extra: map[string]any{"private": "EXTRA_SECRET"}} + body, _ := json.Marshal(scopedAdminAccountDTO(a, p)) + s := string(body) + if strings.Contains(s, "SECRET") || strings.Contains(s, "extra") { + t.Fatalf("泄漏敏感字段: %s", s) + } + require.Contains(t, s, `"credentials":{"model_mapping":{"alias":"target"}}`) + if !strings.Contains(s, `"group_ids":[1]`) { + t.Fatalf("泄漏越权组: %s", s) + } +} + +func TestScopedAdminAccountErrorsAreClassifiedAndRedacted(t *testing.T) { + for _, code := range []int{400, 403, 404, 409, 502, 503, 500} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + scopedAdminAccountError(c, infraerrors.New(code, "secret", "TOP_SECRET")) + want := code + if code == 500 { + want = 503 + } + if w.Code != want || strings.Contains(w.Body.String(), "TOP_SECRET") { + t.Fatalf("code=%d body=%s", w.Code, w.Body.String()) + } + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + scopedAdminAccountError(c, errors.New("TOP_SECRET")) + if w.Code != 503 || strings.Contains(w.Body.String(), "TOP_SECRET") { + t.Fatal(w.Body.String()) + } +} +func TestScopedAdminAccountMiddlewareKeepsJWTAndDeniesUnavailableService(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, restricted := range []bool{false, true} { + r := gin.New() + h := &AccountHandler{} + r.Use(func(c *gin.Context) { + if restricted { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{})) + } + }) + r.Use(h.ScopedAdminKeyAccounts) + r.GET("/api/v1/admin/accounts", func(c *gin.Context) { c.Status(204) }) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts", nil)) + want := 204 + if restricted { + want = 503 + } + if w.Code != want { + t.Fatalf("restricted=%v status=%d", restricted, w.Code) + } + } +} + +// 批量入口必须使用受限服务,不得回落到嵌入的全权 BulkUpdateAccounts。 +type scopedBulkAccountService struct { + service.AdminService + calls []int64 + fields map[string]json.RawMessage +} + +func (s *scopedBulkAccountService) ScopedAdminList(context.Context, pagination.PaginationParams, string, string, string, string) ([]service.Account, int64, error) { + panic("不应读取全站账户") +} +func (s *scopedBulkAccountService) ScopedAdminWrite(_ context.Context, id int64, _ string, fields map[string]json.RawMessage, _ func(context.Context, *service.Account) (*service.Account, error)) (*service.Account, error) { + s.calls = append(s.calls, id) + s.fields = fields + switch id { + case 2: + return nil, infraerrors.NotFound("SECRET", "TOP_SECRET") + case 3: + return nil, infraerrors.Forbidden("SECRET", "TOP_SECRET") + case 4: + return nil, errors.New("TOP_SECRET") + } + return &service.Account{ID: id}, nil +} + +func TestScopedAdminBulkUpdateValidatesTargetsAndRedactsResults(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + body string + status int + ids []int64 + }{ + {`{"account_ids":[1,2,1,3,4],"credentials":{"model_mapping":{"alias":"target"}}}`, 200, []int64{1, 2, 3, 4}}, + {`null`, 400, nil}, + {`{"credentials":{"model_mapping":{}}}`, 400, nil}, + {`{"account_ids":[],"credentials":{"model_mapping":{}}}`, 400, nil}, + {`{"account_ids":[1,0],"credentials":{"model_mapping":{}}}`, 400, nil}, + {`{"account_ids":[1,-1],"credentials":{"model_mapping":{}}}`, 400, nil}, + {`{"account_ids":[1]}`, 400, nil}, + {`{"account_ids":[1],"filters":{},"credentials":{"model_mapping":{}}}`, 400, nil}, + {`{"account_ids":[1.5],"credentials":{"model_mapping":{}}}`, 400, nil}, + {`{"account_ids":[` + strings.Repeat("1,", 100) + `1],"credentials":{"model_mapping":{}}}`, 400, nil}, + } { + t.Run(tc.body, func(t *testing.T) { + svc := &scopedBulkAccountService{} + h := &AccountHandler{adminService: svc} + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/admin/accounts/bulk-update", strings.NewReader(tc.body)) + h.scopedAdminBulkUpdate(c, svc) + require.Equal(t, tc.status, w.Code, w.Body.String()) + require.Equal(t, tc.ids, svc.calls) + require.NotContains(t, w.Body.String(), "SECRET") + if tc.status == 200 { + body, err := json.Marshal(svc.fields) + require.NoError(t, err) + require.JSONEq(t, `{"credentials":{"model_mapping":{"alias":"target"}}}`, string(body)) + var result struct { + Data service.BulkUpdateAccountsResult `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + require.Equal(t, 1, result.Data.Success) + require.Equal(t, 3, result.Data.Failed) + require.Equal(t, []int64{1}, result.Data.SuccessIDs) + require.Equal(t, []int64{2, 3, 4}, result.Data.FailedIDs) + require.Equal(t, "账户不存在", result.Data.Results[1].Error) + require.Equal(t, "操作未授权", result.Data.Results[2].Error) + require.Equal(t, "账户服务暂不可用", result.Data.Results[3].Error) + } + }) + } +} diff --git a/backend/internal/handler/admin/custom_admin_key_codex_import.go b/backend/internal/handler/admin/custom_admin_key_codex_import.go new file mode 100644 index 000000000000..a7c49b568327 --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_codex_import.go @@ -0,0 +1,189 @@ +package admin + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type scopedCodexSessionImportRequest struct { + CodexSessionImportRequest + ProxyMode string `json:"proxy_mode"` + RandomProxy bool `json:"random_proxy"` +} + +func validateScopedCodexRequest(req scopedCodexSessionImportRequest) error { + if _, err := service.NormalizeAccountProxyMode(req.ProxyMode, req.ProxyID, req.RandomProxy); err != nil { + return err + } + if req.GroupIDs == nil || (req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk) { + return infraerrors.BadRequest("SCOPED_CODEX_INPUT", "须明确提供 group_ids,不能跳过分组风险检查") + } + if (req.Concurrency != nil && *req.Concurrency < 0) || (req.Priority != nil && *req.Priority < 0) || (req.LoadFactor != nil && (*req.LoadFactor < 1 || *req.LoadFactor > 10000)) || (req.RateMultiplier != nil && *req.RateMultiplier < 0) { + return infraerrors.BadRequest("SCOPED_CODEX_INPUT", "导入参数无效") + } + for key := range req.CredentialExtras { + if key != "base_url" && key != "model_mapping" { + return infraerrors.BadRequest("SCOPED_CODEX_FIELD", "credential_extras 仅支持独立授权的 base_url 和 model_mapping") + } + } + for key, value := range req.Extra { + valid := false + switch key { + case "codex_fingerprint_mode", "openai_oauth_responses_websockets_v2_mode": + _, valid = value.(string) + case "openai_oauth_responses_websockets_v2_enabled": + _, valid = value.(bool) + } + if !valid { + return infraerrors.BadRequest("SCOPED_CODEX_FIELD", "extra 包含不支持的字段或值类型") + } + } + return nil +} + +func (h *AccountHandler) scopedAdminImportCodexSession(c *gin.Context) { + var req scopedCodexSessionImportRequest + if c.ShouldBindJSON(&req) != nil { + response.BadRequest(c, "导入请求格式无效") + return + } + if err := validateScopedCodexRequest(req); err != nil { + response.ErrorFrom(c, err) + return + } + entries, err := parseCodexSessionImportEntries(req.CodexSessionImportRequest) + if err != nil || len(entries) == 0 { + response.BadRequest(c, "请输入有效的 accessToken 或 Codex session JSON") + return + } + api, ok := h.adminService.(service.ScopedAdminCodexAccounts) + if !ok { + response.Error(c, 503, "受限导入服务不可用") + return + } + principal := adminaccess.FromContext(c.Request.Context()) + if principal == nil || !adminaccess.RouteAllowed(principal.Policy, c.Request.Method, c.FullPath()) { + response.Error(c, 403, "导入未授权") + return + } + // 隔离完整管理员、不同 Key 和权限修改前后的幂等结果。 + policy, _ := json.Marshal(principal.Policy) + digest := sha256.Sum256(policy) + scope := "scoped.codex:" + principal.KeyID + ":" + hex.EncodeToString(digest[:]) + executeAdminIdempotentJSON(c, scope, req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { + result, err := h.importScopedCodexSessions(ctx, api, req, entries) + if err != nil { + code, message := scopedAdminAccountErrorDetails(err) + return nil, infraerrors.New(code, "SCOPED_CODEX_FAILED", message) + } + return result, nil + }) +} + +func (h *AccountHandler) importScopedCodexSessions(ctx context.Context, api service.ScopedAdminCodexAccounts, req scopedCodexSessionImportRequest, entries []codexImportEntry) (CodexSessionImportResult, error) { + proxyMode, err := service.NormalizeAccountProxyMode(req.ProxyMode, req.ProxyID, req.RandomProxy) + if err != nil { + return CodexSessionImportResult{}, err + } + result := CodexSessionImportResult{Total: len(entries), Items: make([]CodexSessionImportItem, 0, len(entries))} + updateExisting := req.UpdateExisting == nil || *req.UpdateExisting + seen := map[string]codexSeenIdentity{} + fail := func(entry int, err error) { + _, message := scopedAdminAccountErrorDetails(err) + result.Failed++ + result.Items = append(result.Items, CodexSessionImportItem{Index: entry, Action: "failed", Message: message}) + result.Errors = append(result.Errors, CodexSessionImportMessage{Index: entry, Message: message}) + } + for _, entry := range entries { + item, err := normalizeCodexImportEntry(entry) + if err != nil { + fail(entry.Index, infraerrors.BadRequest("SCOPED_CODEX_PARSE", "认证内容无效")) + continue + } + expires, credentialExpiry, autoPause, warnings, err := resolveCodexImportExpiry(req.CodexSessionImportRequest, item) + if err != nil { + fail(entry.Index, infraerrors.BadRequest("SCOPED_CODEX_EXPIRY", "认证有效期无效")) + continue + } + if credentialExpiry != nil { + item.Credentials["expires_at"] = credentialExpiry.Format(time.RFC3339) + } + if _, duplicate := firstSeenCodexIdentity(seen, item.IdentityKeys, item.UserID); duplicate { + result.Skipped++ + result.Items = append(result.Items, CodexSessionImportItem{Index: entry.Index, Action: "skipped", Message: "同批次重复导入项"}) + continue + } + markCodexIdentitySeen(seen, item.IdentityKeys, entry.Index, item.UserID) + credentials := mergeCodexImportMap(item.Credentials, req.CredentialExtras) + extra := mergeCodexImportMap(req.Extra, item.Extra) + name := buildCodexCreateAccountName(req.Name, item, entry.Index, len(entries)) + var account *service.Account + action := "created" + var existing *service.Account + if updateExisting { + candidates, lookupErr := api.ScopedAdminCodexCandidates(ctx, service.ScopedCodexIdentity{ + AccountID: item.AccountID, UserID: item.UserID, Email: item.Email, AccessToken: item.AccessToken, + AccessTokenOnly: item.RefreshToken == "" && item.AccessToken != "", AgentIdentity: item.IsAgentIdentity, + }) + if lookupErr != nil { + return result, lookupErr + } + existing, _ = buildCodexAccountIndex(candidates).Find(item.IdentityKeys, item.UserID) + } + if updateExisting && existing != nil { + action = "updated" + input := &service.UpdateAccountInput{ProxyMode: proxyMode, Credentials: credentials, Extra: extra, ProxyID: req.ProxyID, Concurrency: req.Concurrency, Priority: req.Priority, RateMultiplier: req.RateMultiplier, LoadFactor: req.LoadFactor, ExpiresAt: expires, AutoPauseOnExpired: autoPause} + if len(req.GroupIDs) > 0 { + input.GroupIDs = &req.GroupIDs + } + account, err = api.ScopedAdminUpdateCodex(ctx, existing.ID, input, func(current *service.Account, safe *service.UpdateAccountInput) error { + // 锁内重新匹配,不能将候选快照匹配结果应用到已被其他操作换绑的身份。 + fresh := buildCodexAccountIndex([]service.Account{*current}) + if matched, _ := fresh.Find(item.IdentityKeys, item.UserID); matched == nil { + return infraerrors.Conflict("SCOPED_CODEX_CHANGED", "账户身份已变更") + } + safe.Credentials = mergeCodexImportCredentials(current.Credentials, credentials, item) + safe.Extra = mergeCodexImportMap(current.Extra, extra) + if item.RefreshToken == "" && codexCredentialString(current.Credentials, "refresh_token") != "" { + safe.ExpiresAt = nil + safe.AutoPauseOnExpired = nil + } + return nil + }) + } else { + concurrency, priority := 3, 50 + if req.Concurrency != nil { + concurrency = *req.Concurrency + } + if req.Priority != nil { + priority = *req.Priority + } + account, err = api.ScopedAdminCreateCodex(ctx, &service.CreateAccountInput{ProxyMode: proxyMode, Name: name, Notes: req.Notes, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: credentials, Extra: extra, GroupIDs: req.GroupIDs, ProxyID: req.ProxyID, Concurrency: concurrency, Priority: priority, RateMultiplier: req.RateMultiplier, LoadFactor: req.LoadFactor, ExpiresAt: expires, AutoPauseOnExpired: autoPause, SkipDefaultGroupBind: true}) + } + if err != nil { + fail(entry.Index, err) + continue + } + if action == "updated" { + result.Updated++ + h.invalidateScopedAccountToken(ctx, account) + } else { + result.Created++ + } + // 不返回候选名称或任何已有凭证,仅返回本次操作结果。 + result.Items = append(result.Items, CodexSessionImportItem{Index: entry.Index, Action: action, AccountID: account.ID}) + for _, warning := range append(item.WarningTexts, warnings...) { + result.Warnings = append(result.Warnings, CodexSessionImportMessage{Index: entry.Index, Message: warning}) + } + } + return result, nil +} diff --git a/backend/internal/handler/admin/custom_admin_key_codex_import_test.go b/backend/internal/handler/admin/custom_admin_key_codex_import_test.go new file mode 100644 index 000000000000..614bda23f359 --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_codex_import_test.go @@ -0,0 +1,249 @@ +package admin + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type scopedCodexMemoryService struct { + *codexImportMemoryAdminService + fresh *service.Account + err error + lookups []service.ScopedCodexIdentity +} + +func (s *scopedCodexMemoryService) ScopedAdminCodexCandidates(_ context.Context, identity service.ScopedCodexIdentity) ([]service.Account, error) { + s.lookups = append(s.lookups, identity) + return s.accounts, nil +} +func (s *scopedCodexMemoryService) ScopedAdminCreateCodex(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) { + if s.err != nil { + return nil, s.err + } + return s.CreateAccount(ctx, input) +} +func (s *scopedCodexMemoryService) ScopedAdminUpdateCodex(ctx context.Context, id int64, input *service.UpdateAccountInput, prepare func(*service.Account, *service.UpdateAccountInput) error) (*service.Account, error) { + current := s.fresh + if current == nil { + var err error + current, err = s.GetAccount(ctx, id) + if err != nil { + return nil, err + } + } + if err := prepare(current, input); err != nil { + return nil, err + } + return s.UpdateAccount(ctx, id, input) +} + +func TestScopedCodexImportCapturedRequestAndSafeResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + memory := &scopedCodexMemoryService{codexImportMemoryAdminService: newCodexImportMemoryAdminService(nil)} + h := &AccountHandler{adminService: memory} + ctx := adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{KeyID: "fixture", Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.create", "accounts.credentials", "accounts.schedule", "accounts.network"}, AllGroups: true, AccountTypes: []string{"oauth"}}}}}) + r := gin.New() + r.Use(func(c *gin.Context) { c.Request = c.Request.WithContext(ctx) }) + r.Use(h.ScopedAdminKeyAccounts) + r.POST("/api/v1/admin/accounts/import/codex-session", func(c *gin.Context) { t.Fatal("不能回退到完整管理员导入") }) + content := `{"access_token":"private-access-fixture","refresh_token":"private-refresh-fixture","email":"test@example.com"}` + req := map[string]any{"content": content, "name": "推送账户", "notes": "测试", "group_ids": []int64{2, 14}, "concurrency": 40, "priority": 0, "expires_at": nil, "auto_pause_on_expired": true, "proxy_id": nil, "rate_multiplier": 1, "update_existing": true, "extra": map[string]any{"codex_fingerprint_mode": "off", "openai_oauth_responses_websockets_v2_enabled": true, "openai_oauth_responses_websockets_v2_mode": "shared"}} + raw, err := json.Marshal(req) + require.NoError(t, err) + call := func() *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("POST", "/api/v1/admin/accounts/import/codex-session", strings.NewReader(string(raw)))) + return w + } + w := call() + require.Equal(t, 200, w.Code, w.Body.String()) + require.Contains(t, w.Body.String(), `"created":1`) + require.NotContains(t, w.Body.String(), "private-") + require.Len(t, memory.createdAccounts, 1) + input := memory.createdAccounts[0] + require.Equal(t, []int64{2, 14}, input.GroupIDs) + require.Equal(t, 40, input.Concurrency) + require.True(t, input.SkipDefaultGroupBind) + require.Equal(t, "private-refresh-fixture", input.Credentials["refresh_token"]) + require.Equal(t, "off", input.Extra["codex_fingerprint_mode"]) + memory.err = infraerrors.BadRequest("UPSTREAM_SECRET", "private-refresh-fixture") + // update_existing=false 沿用原接口语义:新建;错误也必须脱敏。 + req["update_existing"] = false + raw, err = json.Marshal(req) + require.NoError(t, err) + w = call() + require.Equal(t, 200, w.Code) + require.Contains(t, w.Body.String(), `"failed":1`) + require.NotContains(t, w.Body.String(), "private-") +} + +func TestScopedCodexImportRevalidatesIdentityAndMergesFreshCredentials(t *testing.T) { + token := buildCodexAccessToken(t, "account", "user", time.Now().Add(time.Hour)) + stale := service.Account{ID: 1, Credentials: map[string]any{"access_token": token, "refresh_token": "stale"}, Extra: map[string]any{"quota_used": 1}} + fresh := stale + fresh.Credentials = map[string]any{"access_token": token, "refresh_token": "latest", "base_url": "https://example.com"} + fresh.Extra = map[string]any{"quota_used": 9} + for _, changed := range []bool{false, true} { + t.Run(map[bool]string{false: "锁内合并", true: "身份已更换"}[changed], func(t *testing.T) { + current := fresh + if changed { + current.Credentials = map[string]any{"access_token": "different", "refresh_token": "other"} + } + memory := &scopedCodexMemoryService{codexImportMemoryAdminService: newCodexImportMemoryAdminService([]service.Account{stale}), fresh: ¤t} + h := &AccountHandler{adminService: memory} + req := scopedCodexSessionImportRequest{CodexSessionImportRequest: CodexSessionImportRequest{Content: token, GroupIDs: []int64{1}}} + entries, err := parseCodexSessionImportEntries(req.CodexSessionImportRequest) + require.NoError(t, err) + result, err := h.importScopedCodexSessions(context.Background(), memory, req, entries) + require.NoError(t, err) + if changed { + require.Equal(t, 1, result.Failed) + require.Empty(t, memory.updatedAccounts) + return + } + require.Equal(t, 1, result.Updated) + require.Len(t, memory.updatedAccounts, 1) + input := memory.updatedAccounts[0].input + require.Equal(t, "latest", input.Credentials["refresh_token"]) + require.Equal(t, "https://example.com", input.Credentials["base_url"]) + require.Equal(t, 9, input.Extra["quota_used"]) + require.Nil(t, input.ExpiresAt) + require.Nil(t, input.AutoPauseOnExpired) + }) + } +} + +func TestScopedCodexRequestRejectsUnopenedFields(t *testing.T) { + for _, body := range []string{`{"content":"x"}`, `{"group_ids":[1],"confirm_mixed_channel_risk":true}`, `{"group_ids":[1],"extra":{"quota_used":0}}`, `{"group_ids":[1],"credential_extras":{"refresh_token":"x"}}`, `{"group_ids":[1],"extra":{"openai_oauth_responses_websockets_v2_enabled":"true"}}`} { + var req scopedCodexSessionImportRequest + require.NoError(t, json.Unmarshal([]byte(body), &req)) + require.Error(t, validateScopedCodexRequest(req), body) + } +} + +func TestScopedCodexImportIdempotencySeparatesKeysAndPolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + previous := service.DefaultIdempotencyCoordinator() + t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previous) }) + repo := newMemoryIdempotencyRepoStub() + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig())) + memory := &scopedCodexMemoryService{codexImportMemoryAdminService: newCodexImportMemoryAdminService(nil)} + h := &AccountHandler{adminService: memory} + principal := &adminaccess.Principal{KeyID: "aaaaaaaaaaaaaaaaaaaaaaaa", Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.create", "accounts.credentials", "accounts.schedule", "accounts.network"}, GroupIDs: []int64{2}, AccountTypes: []string{"oauth"}}}}} + r := gin.New() + r.Use(func(c *gin.Context) { + if principal != nil { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), principal)) + } + }) + r.Use(h.ScopedAdminKeyAccounts) + r.POST("/api/v1/admin/accounts/import/codex-session", h.ImportCodexSession) + call := func() *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/api/v1/admin/accounts/import/codex-session", strings.NewReader(`{"content":"{\"access_token\":\"fixture-access\",\"refresh_token\":\"fixture-refresh\"}","group_ids":[2],"update_existing":false}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", "same-import") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 200, w.Code, w.Body.String()) + require.NotContains(t, w.Body.String(), "fixture-") + return w + } + first := call() + require.Len(t, memory.createdAccounts, 1) + replay := call() + require.Equal(t, "true", replay.Header().Get("X-Idempotency-Replayed")) + require.JSONEq(t, first.Body.String(), replay.Body.String()) + require.Len(t, memory.createdAccounts, 1) + principal.KeyID = "bbbbbbbbbbbbbbbbbbbbbbbb" + other := call() + require.Empty(t, other.Header().Get("X-Idempotency-Replayed")) + require.Len(t, memory.createdAccounts, 2) + principal.Policy.Rules[0].Permissions = []string{"accounts.create"} + memory.err = infraerrors.Forbidden("SCOPED_CODEX_FORBIDDEN", "权限已收回") + restricted := call() + require.Empty(t, restricted.Header().Get("X-Idempotency-Replayed")) + require.Contains(t, restricted.Body.String(), `"failed":1`) + require.Len(t, memory.createdAccounts, 2) + principal = nil + memory.err = nil + full := call() + require.Empty(t, full.Header().Get("X-Idempotency-Replayed")) + require.Len(t, memory.createdAccounts, 3) + for _, record := range repo.data { + require.LessOrEqual(t, len(record.Scope), 128, "生产数据库 scope 最大长度") + } +} + +func TestScopedCodexImportProxyModeForwarding(t *testing.T) { + for _, tc := range []struct { + body, mode string + id int64 + }{ + {`"proxy_id":7`, "", 7}, {`"proxy_mode":"specified","proxy_id":7`, "specified", 7}, + {`"random_proxy":true`, "random", 0}, {`"proxy_mode":"random"`, "random", 0}, + {`"proxy_mode":"least_used"`, "least_used", 0}, {`"proxy_mode":"direct"`, "direct", 0}, + } { + t.Run(tc.body, func(t *testing.T) { + memory := &scopedCodexMemoryService{codexImportMemoryAdminService: newCodexImportMemoryAdminService(nil)} + h := &AccountHandler{adminService: memory} + var req scopedCodexSessionImportRequest + require.NoError(t, json.Unmarshal([]byte(`{"content":"{\"access_token\":\"fixture\",\"refresh_token\":\"refresh\"}","group_ids":[1],`+tc.body+`}`), &req)) + require.NoError(t, validateScopedCodexRequest(req)) + entries, err := parseCodexSessionImportEntries(req.CodexSessionImportRequest) + require.NoError(t, err) + result, err := h.importScopedCodexSessions(context.Background(), memory, req, entries) + require.NoError(t, err) + require.Equal(t, 1, result.Created) + require.Len(t, memory.createdAccounts, 1) + require.Equal(t, tc.mode, memory.createdAccounts[0].ProxyMode) + if tc.id > 0 { + require.Equal(t, tc.id, *memory.createdAccounts[0].ProxyID) + } + }) + } +} + +func TestScopedCodexImportLooksUpOnlyValidatedEntryIdentity(t *testing.T) { + token := buildCodexAccessToken(t, "workspace", "member", time.Now().Add(time.Hour)) + for _, tc := range []struct { + name string + content string + update bool + wantLookups int + wantSkipped int + onlyAccess bool + }{ + {"有效身份与批内重复", token + "\n" + token, true, 1, 1, true}, + {"无效内容无需读取候选", `{"refresh_token":"fixture"}`, true, 0, 0, false}, + {"禁用更新无需读取候选", token, false, 0, 0, true}, + {"有refresh保留身份匹配", `{"access_token":"` + token + `","refresh_token":"fixture"}`, true, 1, 0, false}, + } { + t.Run(tc.name, func(t *testing.T) { + memory := &scopedCodexMemoryService{codexImportMemoryAdminService: newCodexImportMemoryAdminService(nil)} + req := scopedCodexSessionImportRequest{CodexSessionImportRequest: CodexSessionImportRequest{Content: tc.content, GroupIDs: []int64{1}, UpdateExisting: &tc.update}} + entries, err := parseCodexSessionImportEntries(req.CodexSessionImportRequest) + require.NoError(t, err) + result, err := (&AccountHandler{adminService: memory}).importScopedCodexSessions(context.Background(), memory, req, entries) + require.NoError(t, err) + require.Equal(t, tc.wantSkipped, result.Skipped) + require.Len(t, memory.lookups, tc.wantLookups) + if tc.wantLookups > 0 { + lookup := memory.lookups[0] + require.Equal(t, "workspace", lookup.AccountID) + require.Equal(t, "member", lookup.UserID) + require.Equal(t, token, lookup.AccessToken) + require.Equal(t, tc.onlyAccess, lookup.AccessTokenOnly) + } + }) + } +} diff --git a/backend/internal/handler/admin/custom_admin_key_commit_test.go b/backend/internal/handler/admin/custom_admin_key_commit_test.go new file mode 100644 index 000000000000..ded090ed2d21 --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_commit_test.go @@ -0,0 +1,199 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http/httptest" + "strings" + "testing" + "time" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type scopedCommitInvalidator struct { + ids []int64 + transactionContexts int +} + +func (s *scopedCommitInvalidator) InvalidateToken(ctx context.Context, account *service.Account) error { + s.ids = append(s.ids, account.ID) + if dbent.TxFromContext(ctx) != nil { + s.transactionContexts++ + } + return nil +} + +type scopedCommitAccounts struct { + service.AdminService + fail bool +} + +func (*scopedCommitAccounts) ScopedAdminList(context.Context, pagination.PaginationParams, string, string, string, string) ([]service.Account, int64, error) { + panic("不应查询列表") +} +func (s *scopedCommitAccounts) ScopedAdminWrite(_ context.Context, id int64, _ string, _ map[string]json.RawMessage, _ func(context.Context, *service.Account) (*service.Account, error)) (*service.Account, error) { + if s.fail { + return nil, errors.New("模拟写入失败") + } + return &service.Account{ID: id, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, nil +} + +func TestScopedAdminCredentialWritesInvalidateAfterCommit(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, endpoint := range []string{"single", "bulk"} { + for _, mode := range []string{"commit", "rollback", "commit_failure", "write_failure", "name_only"} { + t.Run(endpoint+"/"+mode, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + mock.ExpectBegin() + tx, err := client.Tx(context.Background()) + require.NoError(t, err) + ctx := dbent.NewTxContext(context.Background(), tx) + ctx = adminaccess.WithPrincipal(ctx, &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.credentials"}, AllGroups: true}}}}) + cache := &scopedCommitInvalidator{} + h := &AccountHandler{adminService: &scopedCommitAccounts{fail: mode == "write_failure"}, tokenCacheInvalidator: cache} + r := gin.New() + r.Use(func(c *gin.Context) { c.Request = c.Request.WithContext(ctx) }) + r.Use(h.ScopedAdminKeyAccounts) + r.PUT("/api/v1/admin/accounts/:id", func(*gin.Context) { t.Fatal("不得进入完整管理员处理器") }) + r.POST("/api/v1/admin/accounts/bulk-update", func(*gin.Context) { t.Fatal("不得进入完整管理员处理器") }) + method, path, body := "PUT", "/api/v1/admin/accounts/7", `{"credentials":{"access_token":"fixture"}}` + if mode == "name_only" { + body = `{"name":"新名称"}` + } + if endpoint == "bulk" { + method, path = "POST", "/api/v1/admin/accounts/bulk-update" + body = `{"account_ids":[7],` + body[1:] + } + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + want := 200 + if mode == "write_failure" && endpoint == "single" { + want = 503 + } + require.Equal(t, want, w.Code, w.Body.String()) + require.Empty(t, cache.ids, "事务提交前不得使令牌缓存失效") + if mode == "rollback" || mode == "write_failure" { + mock.ExpectRollback() + require.NoError(t, tx.Rollback()) + } else if mode == "commit_failure" { + mock.ExpectCommit().WillReturnError(errors.New("提交失败")) + require.Error(t, tx.Commit()) + } else { + mock.ExpectCommit() + require.NoError(t, tx.Commit()) + } + if mode == "commit" { + require.Equal(t, []int64{7}, cache.ids) + } else { + require.Empty(t, cache.ids) + } + require.Zero(t, cache.transactionContexts, "提交后的缓存清理不能携带已关闭事务") + require.NoError(t, mock.ExpectationsWereMet()) + }) + } + } +} + +type scopedRefreshPrivacyService struct { + *stubAdminService + privacy int +} + +func (s *scopedRefreshPrivacyService) EnsureOpenAIPrivacy(context.Context, *service.Account) string { + s.privacy++ + return "" +} +func (s *scopedRefreshPrivacyService) EnsureAntigravityPrivacy(context.Context, *service.Account) string { + s.privacy++ + return "" +} +func TestScopedAdminRefreshFailureDoesNotTriggerPrivacyBeforeRollback(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + mock.ExpectBegin() + tx, err := client.Tx(context.Background()) + require.NoError(t, err) + svc := &scopedRefreshPrivacyService{stubAdminService: newStubAdminService()} + h := &AccountHandler{adminService: svc, openaiOAuthService: service.NewOpenAIOAuthService(nil, nil)} + _, _, err = h.refreshSingleAccount(dbent.NewTxContext(context.Background(), tx), &service.Account{ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}) + require.Error(t, err) + require.Zero(t, svc.privacy, "刷新失败导致回滚时,不应提前调用上游隐私接口") + mock.ExpectRollback() + require.NoError(t, tx.Rollback()) + require.Zero(t, svc.privacy) + require.NoError(t, mock.ExpectationsWereMet()) + // 完整管理员没有外层事务,沿用原刷新失败后的隐私检查行为。 + _, _, err = h.refreshSingleAccount(context.Background(), &service.Account{ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}) + require.Error(t, err) + require.Equal(t, 1, svc.privacy) +} + +func TestScopedAdminCodexUpdatesInvalidateOnlyAfterCommit(t *testing.T) { + for _, mode := range []string{"commit", "rollback", "commit_failure", "identity_changed"} { + t.Run(mode, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + mock.ExpectBegin() + tx, err := client.Tx(context.Background()) + require.NoError(t, err) + token := buildCodexAccessToken(t, "account", "user", time.Now().Add(time.Hour)) + existing := service.Account{ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: map[string]any{"access_token": token, "auth_mode": service.OpenAIAuthModePersonalAccessToken}} + memory := &scopedCodexMemoryService{codexImportMemoryAdminService: newCodexImportMemoryAdminService([]service.Account{existing})} + if mode == "identity_changed" { + memory.fresh = &service.Account{ID: 7, Credentials: map[string]any{"access_token": "changed-private"}} + } + cache := &scopedCommitInvalidator{} + h := &AccountHandler{adminService: memory, tokenCacheInvalidator: cache} + req := scopedCodexSessionImportRequest{CodexSessionImportRequest: CodexSessionImportRequest{Content: token, GroupIDs: []int64{1}}} + entries, err := parseCodexSessionImportEntries(req.CodexSessionImportRequest) + require.NoError(t, err) + result, err := h.importScopedCodexSessions(dbent.NewTxContext(context.Background(), tx), memory, req, entries) + require.NoError(t, err) + require.Empty(t, cache.ids) + if mode == "rollback" || mode == "identity_changed" { + mock.ExpectRollback() + require.NoError(t, tx.Rollback()) + } else if mode == "commit_failure" { + mock.ExpectCommit().WillReturnError(errors.New("提交失败")) + require.Error(t, tx.Commit()) + } else { + mock.ExpectCommit() + require.NoError(t, tx.Commit()) + } + if mode == "commit" { + require.Equal(t, []int64{7}, cache.ids) + } else { + require.Empty(t, cache.ids) + } + if mode == "identity_changed" { + require.Equal(t, 1, result.Failed) + } else { + require.Equal(t, 1, result.Updated) + require.Equal(t, service.OpenAIAuthModePersonalAccessToken, memory.updatedAccounts[0].input.Credentials["auth_mode"]) + } + require.Zero(t, cache.transactionContexts) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/backend/internal/handler/admin/custom_admin_key_group_models.go b/backend/internal/handler/admin/custom_admin_key_group_models.go new file mode 100644 index 000000000000..7b3b5b18ef41 --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_group_models.go @@ -0,0 +1,167 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func scopedAdminGroupModelsDTO(g *service.Group) gin.H { + return gin.H{"id": g.ID, "name": g.Name, "platform": g.Platform, "model_allowlist": g.ModelAllowlist} +} + +// ScopedAdminKeyGroupModels 接管分组模型接口,禁止复用可修改费率等字段的完整处理器。 +func (h *GroupHandler) ScopedAdminKeyGroupModels(c *gin.Context) { + p := adminaccess.FromContext(c.Request.Context()) + if p == nil { + return + } + base := "/api/v1/admin/groups/:id" + path := c.FullPath() + fullRead := slices.Contains(p.Policy.GlobalPermissions, "groups.read") + if c.Request.Method == http.MethodGet && (path == "/api/v1/admin/groups" || path == "/api/v1/admin/groups/all") { + if fullRead { + return + } + c.Abort() + h.scopedAdminListGroupModels(c) + return + } + // 完整分组读取继续沿用原处理器的简易模式响应。 + if fullRead && path == base && c.Request.Method == http.MethodGet && h.isSimpleMode() { + return + } + candidates := path == base+"/model-allowlist-candidates" + if !candidates && path != base { + return + } + c.Abort() + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id < 0 || (id == 0 && !candidates) { + response.BadRequest(c, "分组 ID 无效") + return + } + if candidates && c.Request.Method == http.MethodGet && id == 0 { + if !slices.Contains(p.Policy.GlobalPermissions, "models.defaults.read") { + response.Error(c, 403, "默认模型读取未授权") + return + } + h.GetGroupModelAllowlistCandidates(c) + return + } + g, err := h.adminService.GetGroup(c.Request.Context(), id) + if err != nil || g == nil { + response.Error(c, 404, "分组不存在") + return + } + permission := "groups.models.read" + if c.Request.Method == http.MethodPut { + permission = "groups.models.write" + } + allowed := p.Policy.AllowsGroupConfig(permission, g.ID, g.Platform) || (c.Request.Method == http.MethodGet && fullRead) + if !allowed { + response.Error(c, 404, "分组不存在") + return + } + if c.Request.Method == http.MethodGet { + if candidates { + if platform := strings.TrimSpace(c.Query("platform")); platform != "" && platform != g.Platform { + response.BadRequest(c, "候选模型平台必须与分组一致") + return + } + h.GetGroupModelAllowlistCandidates(c) + return + } + if fullRead { + response.Success(c, dto.GroupFromServiceAdmin(g)) + } else { + response.Success(c, scopedAdminGroupModelsDTO(g)) + } + return + } + if c.Request.Method != http.MethodPut || candidates { + response.Error(c, 403, "此分组操作未开放给受限管理密钥") + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 65536) + var fields map[string]json.RawMessage + if c.ShouldBindJSON(&fields) != nil || len(fields) != 1 { + response.BadRequest(c, "仅支持单独提交 model_allowlist") + return + } + raw := fields["model_allowlist"] + var cfg *service.GroupModelAllowlist + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&cfg) != nil || cfg == nil { + response.BadRequest(c, "模型白名单格式无效") + return + } + api, ok := h.adminService.(interface { + ScopedAdminUpdateGroupModels(context.Context, int64, service.GroupModelAllowlist) (*service.Group, error) + }) + if !ok { + response.Error(c, 503, "受限分组模型服务不可用") + return + } + updated, err := api.ScopedAdminUpdateGroupModels(c.Request.Context(), id, *cfg) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, scopedAdminGroupModelsDTO(updated)) +} + +func (h *GroupHandler) scopedAdminListGroupModels(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } + api, ok := h.adminService.(interface { + ScopedAdminListGroupModels(context.Context, pagination.PaginationParams, bool, string, string, string) ([]service.Group, int64, error) + }) + if !ok { + response.Error(c, 503, "受限分组模型服务不可用") + return + } + page, size := response.ParsePagination(c) + all := strings.HasSuffix(c.FullPath(), "/all") + status := c.Query("status") + if all { + status = service.StatusActive + if c.Query("include_inactive") == "true" { + status = "" + } + } + search := strings.TrimSpace(c.Query("search")) + if len(search) > 100 { + search = search[:100] + } + groups, total, err := api.ScopedAdminListGroupModels(c.Request.Context(), pagination.PaginationParams{Page: page, PageSize: size}, all, c.Query("platform"), status, search) + if err != nil { + response.ErrorFrom(c, err) + return + } + out := make([]gin.H, 0, len(groups)) + for i := range groups { + out = append(out, scopedAdminGroupModelsDTO(&groups[i])) + } + if all { + response.Success(c, out) + } else { + response.Paginated(c, out, total, page, size) + } +} diff --git a/backend/internal/handler/admin/custom_admin_key_proxies.go b/backend/internal/handler/admin/custom_admin_key_proxies.go new file mode 100644 index 000000000000..970c5b29f7b5 --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_proxies.go @@ -0,0 +1,163 @@ +package admin + +import ( + "context" + "fmt" + "slices" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// scopedProxyOption 显式列出可公开字段,不能复用含用户名、密码的管理 DTO。 +type scopedProxyOption struct { + ID int64 `json:"id"` + Name string `json:"name"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at"` +} + +func scopedProxyDTO(p service.Proxy) scopedProxyOption { + return scopedProxyOption{ID: p.ID, Name: p.Name, Protocol: p.Protocol, Host: p.Host, Port: p.Port, Status: p.Status, ExpiresAt: p.ExpiresAt} +} + +// scopedAdminProxyRead 对代理读取限定数量;无分页的 /all 从全池抽样。 +func (h *ProxyHandler) scopedAdminProxyRead(c *gin.Context) bool { + principal := adminaccess.FromContext(c.Request.Context()) + if principal == nil { + return false + } + if !slices.Contains(principal.Policy.GlobalPermissions, "proxies.read") { + response.Error(c, 403, "代理读取未授权") + return true + } + if raw := c.Param("id"); raw != "" { + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id < 1 { + response.BadRequest(c, "代理 ID 无效") + return true + } + p, err := h.adminService.GetProxy(c.Request.Context(), id) + if err != nil || p == nil { + response.Error(c, 404, "代理不存在") + return true + } + response.Success(c, scopedProxyDTO(*p)) + return true + } + page, size := 1, 50 + for key, dest := range map[string]*int{"page": &page, "page_size": &size} { + if raw, ok := c.GetQuery(key); ok { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 1000000 { + response.BadRequest(c, "分页参数必须为正整数且不超过 1000000") + return true + } + *dest = n + } + } + if size > 100 { + size = 100 + } + var proxies []service.Proxy + var total int64 + var err error + if raw, ok := c.Request.URL.Query()["ids"]; ok { + ids := []int64{} + for _, part := range strings.Split(strings.Join(raw, ","), ",") { + if strings.TrimSpace(part) == "" { + continue + } + id, e := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if e != nil || id < 1 { + response.BadRequest(c, "代理 ID 无效") + return true + } + if !slices.Contains(ids, id) { + ids = append(ids, id) + } + if len(ids) > 100 { + response.BadRequest(c, "一次最多读取 100 个已选代理") + return true + } + } + if len(ids) > 0 { + proxies, err = h.adminService.GetProxiesByIDs(c.Request.Context(), ids) + } + // 最多 100 个 ID 的回显按实际存在的记录分页,保留请求顺序。 + byID := make(map[int64]service.Proxy, len(proxies)) + for _, p := range proxies { + byID[p.ID] = p + } + proxies = nil + for _, id := range ids { + if p, ok := byID[id]; ok { + proxies = append(proxies, p) + } + } + total = int64(len(proxies)) + start := (page - 1) * size + if start < len(proxies) { + proxies = proxies[start:min(start+size, len(proxies))] + } else { + proxies = nil + } + } else { + search := strings.TrimSpace(c.Query("search")) + if len(search) > 100 { + response.BadRequest(c, "搜索关键词不能超过 100 字节") + return true + } + query := c.Request.URL.Query() + if strings.HasSuffix(c.FullPath(), "/proxies/all") && !query.Has("page") && !query.Has("page_size") { + sampler, ok := h.adminService.(interface { + SampleProxyOptions(context.Context, string) ([]service.Proxy, int64, error) + }) + if !ok { + response.Error(c, 503, "代理查询暂不可用") + return true + } + proxies, total, err = sampler.SampleProxyOptions(c.Request.Context(), search) + // 旧客户端重复加载时必须重新抽样,不能复用上一次缓存的候选。 + c.Header("Cache-Control", "no-store") + } else { + proxies, total, err = scopedProxyOptions(c.Request.Context(), h.adminService, page, size, search) + } + } + if err != nil { + response.Error(c, 503, "代理查询暂不可用") + return true + } + out := make([]scopedProxyOption, 0, min(len(proxies), size)) + for _, p := range proxies[:min(len(proxies), size)] { + out = append(out, scopedProxyDTO(p)) + } + if strings.HasSuffix(c.FullPath(), "/proxies/all") { + c.Header("X-Total-Count", strconv.FormatInt(total, 10)) + c.Header("X-Page", strconv.Itoa(page)) + c.Header("X-Page-Size", strconv.Itoa(size)) + c.Header("X-Total-Pages", strconv.FormatInt((total+int64(size)-1)/int64(size), 10)) + response.Success(c, out) + } else { + response.Paginated(c, out, total, page, size) + } + return true +} + +func scopedProxyOptions(ctx context.Context, admin service.AdminService, page, size int, search string) ([]service.Proxy, int64, error) { + api, ok := admin.(interface { + ListProxyOptions(context.Context, int, int, string) ([]service.Proxy, int64, error) + }) + if !ok { + return nil, 0, fmt.Errorf("代理查询不可用") + } + return api.ListProxyOptions(ctx, page, size, search) +} diff --git a/backend/internal/handler/admin/custom_admin_key_proxies_test.go b/backend/internal/handler/admin/custom_admin_key_proxies_test.go new file mode 100644 index 000000000000..8182cb058e9a --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_proxies_test.go @@ -0,0 +1,198 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type scopedProxyReadTestService struct { + service.AdminService + page, size, calls int + ids []int64 +} + +func (s *scopedProxyReadTestService) ListProxyOptions(_ context.Context, page, size int, _ string) ([]service.Proxy, int64, error) { + s.page = page + s.size = size + s.calls++ + items := make([]service.Proxy, size) + for i := range items { + items[i] = service.Proxy{ID: int64((page-1)*size + i + 1), Host: "192.0.2.1", Username: "secret-user", Password: "secret-password", Status: service.StatusActive} + } + return items, 20001, nil +} +func (s *scopedProxyReadTestService) GetProxiesByIDs(_ context.Context, ids []int64) ([]service.Proxy, error) { + s.ids = ids + s.calls++ + items := []service.Proxy{} + for _, id := range ids { + if id == 999 { + continue + } + items = append(items, service.Proxy{ID: id, Username: "secret-user", Password: "secret-password"}) + } + return items, nil +} +func (s *scopedProxyReadTestService) GetProxy(_ context.Context, id int64) (*service.Proxy, error) { + s.calls++ + return &service.Proxy{ID: id, Username: "secret-user", Password: "secret-password"}, nil +} + +func TestScopedProxyReadBoundedPaginationAndProjection(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + query string + status, page, size, count int + }{ + {"", 200, 1, 50, 50}, {"?page=2&page_size=50000", 200, 2, 100, 100}, {"?page=201&page_size=100", 200, 201, 100, 100}, + {"?page=-1", 400, 0, 0, 0}, {"?page_size=0", 400, 0, 0, 0}, {"?page_size=no", 400, 0, 0, 0}, {"?page=999999999999999999999", 400, 0, 0, 0}, + {"?ids=1,2,3&page=2&page_size=2", 200, 2, 2, 1}, {"?ids=", 200, 1, 50, 0}, + {"?ids=999,1&page_size=1", 200, 1, 1, 1}, {"?ids=1,2,2&page=4&page_size=1", 200, 4, 1, 0}, + } { + t.Run(tc.query, func(t *testing.T) { + svc := &scopedProxyReadTestService{} + h := NewProxyHandler(svc) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{Policy: adminaccess.Policy{GlobalPermissions: []string{"proxies.read"}}})) + }) + r.GET("/proxies", h.List) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/proxies"+tc.query, nil)) + require.Equal(t, tc.status, w.Code, w.Body.String()) + require.NotContains(t, w.Body.String(), "secret-") + require.NotContains(t, w.Body.String(), "username") + require.NotContains(t, w.Body.String(), "password") + if tc.status != 200 { + require.Zero(t, svc.calls) + return + } + var body struct { + Data struct { + Items []map[string]any `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` + } + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, tc.page, body.Data.Page) + require.Equal(t, tc.size, body.Data.PageSize) + require.Len(t, body.Data.Items, tc.count) + if strings.Contains(tc.query, "ids=999,1") { + require.EqualValues(t, 1, body.Data.Total) + require.EqualValues(t, 1, body.Data.Items[0]["id"]) + } + if strings.Contains(tc.query, "ids=1,2,3") { + require.Equal(t, []int64{1, 2, 3}, svc.ids) + } + }) + } +} + +func TestScopedProxyReadRejectsLargeIDBatchAndRequiresPermission(t *testing.T) { + for _, allowed := range []bool{false, true} { + svc := &scopedProxyReadTestService{} + h := NewProxyHandler(svc) + r := gin.New() + p := &adminaccess.Principal{} + if allowed { + p.Policy.GlobalPermissions = []string{"proxies.read"} + } + r.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(context.Background(), p)) + }) + r.GET("/proxies", h.GetAll) + r.GET("/proxies/:id", h.GetByID) + ids := []string{} + for i := 1; i <= 101; i++ { + ids = append(ids, strconv.Itoa(i)) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/proxies?ids="+strings.Join(ids, ","), nil)) + if allowed { + require.Equal(t, 400, w.Code) + } else { + require.Equal(t, 403, w.Code) + } + require.Zero(t, svc.calls) + w = httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/proxies/1", nil)) + if allowed { + require.Equal(t, 200, w.Code) + require.NotContains(t, w.Body.String(), "secret-") + require.NotContains(t, w.Body.String(), "username") + } else { + require.Equal(t, 403, w.Code) + } + } +} + +type scopedProxySampleTestService struct { + scopedProxyReadTestService + samples int + search string +} + +func (s *scopedProxySampleTestService) SampleProxyOptions(_ context.Context, search string) ([]service.Proxy, int64, error) { + s.samples++ + s.search = search + return []service.Proxy{{ID: 12345, Host: "192.0.2.1", Username: "secret-user", Password: "secret-password"}}, 20000, nil +} +func TestScopedProxyAllSamplesOnlyWithoutPagination(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + path, query string + sample bool + status int + }{ + {"/api/v1/admin/proxies/all", "", true, 200}, + {"/api/v1/admin/proxies/all", "?search=region", true, 200}, + {"/api/v1/admin/proxies/all", "?page=1", false, 200}, + {"/api/v1/admin/proxies/all", "?page_size=100", false, 200}, + {"/api/v1/admin/proxies/all", "?page=", false, 400}, + {"/api/v1/admin/proxies/all", "?ids=1,2", false, 200}, + {"/api/v1/admin/proxies", "", false, 200}, + } { + t.Run(tc.path+tc.query, func(t *testing.T) { + svc := &scopedProxySampleTestService{} + h := NewProxyHandler(svc) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{Policy: adminaccess.Policy{GlobalPermissions: []string{"proxies.read"}}})) + }) + r.GET("/api/v1/admin/proxies/all", h.GetAll) + r.GET("/api/v1/admin/proxies", h.List) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", tc.path+tc.query, nil)) + require.Equal(t, tc.status, w.Code, w.Body.String()) + if tc.sample { + require.Equal(t, 1, svc.samples) + require.Zero(t, svc.calls) + require.Contains(t, w.Body.String(), `"data":[`) + require.Contains(t, w.Body.String(), `"id":12345`) + require.Equal(t, "20000", w.Header().Get("X-Total-Count")) + require.Equal(t, "50", w.Header().Get("X-Page-Size")) + require.Equal(t, "no-store", w.Header().Get("Cache-Control")) + if tc.query != "" { + require.Equal(t, "region", svc.search) + } + } else { + require.Zero(t, svc.samples) + } + require.NotContains(t, w.Body.String(), "secret-") + require.NotContains(t, w.Body.String(), "password") + }) + } +} diff --git a/backend/internal/handler/admin/custom_admin_key_users.go b/backend/internal/handler/admin/custom_admin_key_users.go new file mode 100644 index 000000000000..5a78d7c39337 --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_users.go @@ -0,0 +1,47 @@ +package admin + +import ( + "slices" + + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// adminUserResponse 对受限密钥使用字段白名单,避免完整管理员 DTO 新增字段时隐式扩大授权。 +// 订阅及用量由各自的独立接口提供,用户资料接口不嵌入这些关联数据。 +func adminUserResponse(c *gin.Context, user *service.User) any { + principal := adminaccess.FromContext(c.Request.Context()) + if principal == nil { + return dto.UserFromServiceAdmin(user) + } + if user == nil { + return nil + } + out := gin.H{"id": user.ID} + permissions := principal.Policy.GlobalPermissions + if slices.Contains(permissions, "users.read") { + out["email"] = user.Email + out["username"] = user.Username + out["role"] = user.Role + out["status"] = user.Status + out["concurrency"] = user.Concurrency + out["rpm_limit"] = user.RPMLimit + out["last_active_at"] = user.LastActiveAt + out["created_at"] = user.CreatedAt + out["updated_at"] = user.UpdatedAt + out["deleted_at"] = user.DeletedAt + } + if slices.Contains(permissions, "users.balance.read") { + out["balance"] = user.Balance + out["frozen_balance"] = user.FrozenBalance + out["total_recharged"] = user.TotalRecharged + } + if slices.Contains(permissions, "groups.read") { + out["allowed_groups"] = user.AllowedGroups + out["restrict_public_groups"] = user.RestrictPublicGroups + out["group_rates"] = user.GroupRates + } + return out +} diff --git a/backend/internal/handler/admin/custom_admin_key_users_test.go b/backend/internal/handler/admin/custom_admin_key_users_test.go new file mode 100644 index 000000000000..497cae2822ad --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_key_users_test.go @@ -0,0 +1,113 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type scopedUserResponseService struct { + service.AdminService + filters service.UserListFilters + writes int +} + +func (*scopedUserResponseService) user() *service.User { + return &service.User{ID: 7, Email: "user@example.com", Notes: "内部备注", Balance: 123, + APIKeys: []service.APIKey{{Key: "内部密钥"}}, Subscriptions: []service.UserSubscription{{ID: 8, DailyUsageUSD: 10}}} +} +func (s *scopedUserResponseService) ListUsers(_ context.Context, _, _ int, filters service.UserListFilters, _, _ string) ([]service.User, int64, error) { + s.filters = filters + return []service.User{*s.user()}, 1, nil +} +func (s *scopedUserResponseService) GetUser(context.Context, int64) (*service.User, error) { + return s.user(), nil +} +func (s *scopedUserResponseService) UpdateUserBalance(context.Context, int64, float64, string, string) (*service.User, error) { + s.writes++ + return s.user(), nil +} + +func TestScopedAdminUserResponsePreservesFullAdminResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := &scopedUserResponseService{} + h := NewUserHandler(svc, nil, nil, nil, nil, nil, nil) + r := gin.New() + r.GET("/users", h.List) + r.GET("/users/:id", h.GetByID) + for _, path := range []string{"/users", "/users?include_subscriptions=true", "/users/7"} { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + require.Equal(t, 200, w.Code) + for _, field := range []string{`"notes":"内部备注"`, `"balance":123`, `"subscriptions":`, `"daily_usage_usd":10`, `"api_keys":`} { + require.Contains(t, w.Body.String(), field) + } + if path == "/users" { + require.Nil(t, svc.filters.IncludeSubscriptions) + } + if strings.Contains(path, "include_subscriptions") { + require.NotNil(t, svc.filters.IncludeSubscriptions) + require.True(t, *svc.filters.IncludeSubscriptions) + } + } +} + +func TestScopedAdminUserBalanceResponseRedactsCachedFullAdminResult(t *testing.T) { + gin.SetMode(gin.TestMode) + previous := service.DefaultIdempotencyCoordinator() + t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previous) }) + for _, scopedFirst := range []bool{false, true} { + t.Run(map[bool]string{false: "完整管理员先执行", true: "受限Key先执行"}[scopedFirst], func(t *testing.T) { + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig())) + svc := &scopedUserResponseService{} + h := NewUserHandler(svc, nil, nil, nil, nil, nil, nil) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 9}) + if c.GetHeader("X-Test-Scoped") == "true" { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{ + KeyID: "test-key", Policy: adminaccess.Policy{GlobalPermissions: []string{"users.balance.write"}}, + })) + } + c.Next() + }) + r.POST("/users/:id/balance", h.UpdateBalance) + for i, scoped := range []bool{scopedFirst, !scopedFirst, true} { + req := httptest.NewRequest("POST", "/users/7/balance", strings.NewReader(`{"balance":1,"operation":"add"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", "same-operation") + if scoped { + req.Header.Set("X-Test-Scoped", "true") + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 200, w.Code, w.Body.String()) + if i > 0 { + require.Equal(t, "true", w.Header().Get("X-Idempotency-Replayed")) + } + var result struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + if scoped { + require.Len(t, result.Data, 1) + require.JSONEq(t, `7`, string(result.Data["id"])) + } else { + require.Contains(t, result.Data, "notes") + require.Contains(t, result.Data, "balance") + } + } + require.Equal(t, 1, svc.writes, "重放只改变当前响应的权限投影,不能重复修改余额") + }) + } +} diff --git a/backend/internal/handler/admin/custom_admin_keys.go b/backend/internal/handler/admin/custom_admin_keys.go new file mode 100644 index 000000000000..8abedc09fb3a --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_keys.go @@ -0,0 +1,102 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func requireAdminKeyJWT(c *gin.Context) bool { + if c.GetString("auth_method") != service.AuditAuthMethodJWT { + response.Error(c, 403, "仅管理员 JWT 会话可管理密钥") + return false + } + role, ok := middleware.GetUserRoleFromContext(c) + if !ok || role != "admin" { + response.Error(c, 403, "需要管理员权限") + return false + } + return true +} +func (h *SettingHandler) ListScopedAdminKeys(c *gin.Context) { + if !requireAdminKeyJWT(c) { + return + } + items, e := h.settingService.ListScopedAdminKeys(c.Request.Context()) + if e != nil { + response.ErrorFrom(c, e) + return + } + response.Success(c, gin.H{"items": items, "permissions": adminaccess.PermissionCatalog()}) +} +func (h *SettingHandler) CreateScopedAdminKey(c *gin.Context) { + if !requireAdminKeyJWT(c) { + return + } + var in service.ScopedAdminKeyInput + if e := c.ShouldBindJSON(&in); e != nil { + response.Error(c, 400, "请求格式无效") + return + } + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + response.Error(c, 403, "需要管理员身份") + return + } + key, item, e := h.settingService.CreateScopedAdminKey(c.Request.Context(), in, subject.UserID) + if e != nil { + response.ErrorFrom(c, e) + return + } + response.Success(c, gin.H{"key": key, "item": item}) +} +func (h *SettingHandler) UpdateScopedAdminKey(c *gin.Context) { + if !requireAdminKeyJWT(c) { + return + } + var in service.ScopedAdminKeyInput + if e := c.ShouldBindJSON(&in); e != nil { + response.Error(c, 400, "请求格式无效") + return + } + item, e := h.settingService.UpdateScopedAdminKey(c.Request.Context(), c.Param("id"), in) + if e != nil { + response.ErrorFrom(c, e) + return + } + response.Success(c, item) +} +func (h *SettingHandler) RotateScopedAdminKey(c *gin.Context) { + if !requireAdminKeyJWT(c) { + return + } + key, item, e := h.settingService.RotateScopedAdminKey(c.Request.Context(), c.Param("id")) + if e != nil { + response.ErrorFrom(c, e) + return + } + response.Success(c, gin.H{"key": key, "item": item}) +} +func (h *SettingHandler) RevokeScopedAdminKey(c *gin.Context) { + if !requireAdminKeyJWT(c) { + return + } + if e := h.settingService.RevokeScopedAdminKey(c.Request.Context(), c.Param("id")); e != nil { + response.ErrorFrom(c, e) + return + } + response.Success(c, gin.H{"message": "管理密钥已永久撤销"}) +} + +func (h *SettingHandler) DeleteScopedAdminKeyRecord(c *gin.Context) { + if !requireAdminKeyJWT(c) { + return + } + if e := h.settingService.DeleteScopedAdminKeyRecord(c.Request.Context(), c.Param("id")); e != nil { + response.ErrorFrom(c, e) + return + } + response.Success(c, gin.H{"message": "密钥已从列表移除,历史操作记录仍会继续保留"}) +} diff --git a/backend/internal/handler/admin/custom_admin_keys_test.go b/backend/internal/handler/admin/custom_admin_keys_test.go new file mode 100644 index 000000000000..301257aaa043 --- /dev/null +++ b/backend/internal/handler/admin/custom_admin_keys_test.go @@ -0,0 +1,78 @@ +//go:build unit + +package admin + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminKeyHandlersRejectNonJWT(t *testing.T) { + h := &SettingHandler{} + for _, fn := range []gin.HandlerFunc{h.ListScopedAdminKeys, h.CreateScopedAdminKey, h.UpdateScopedAdminKey, h.RotateScopedAdminKey, h.RevokeScopedAdminKey, h.DeleteScopedAdminKeyRecord, h.GetAdminAPIKey, h.RegenerateAdminAPIKey, h.DeleteAdminAPIKey} { + for _, method := range []string{"", "admin_api_key"} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("auth_method", method) + fn(c) + if w.Code != 403 { + t.Fatalf("%s 应拒绝,返回 %d", method, w.Code) + } + } + } +} + +type deleteScopedKeyRecordRepo struct { + service.ScopedAdminKeyRepository + err error + id string +} + +func (r *deleteScopedKeyRecordRepo) DeleteScopedAdminKeyRecord(_ context.Context, id string, at time.Time) error { + r.id = id + if at.IsZero() { + panic("删除时间不能为空") + } + return r.err +} + +func TestScopedAdminKeyDeleteRecordHandler(t *testing.T) { + for _, tc := range []struct { + name, role string + err error + status int + }{ + {"仅列表移除", "admin", nil, 200}, + {"未撤销或已移除", "admin", service.ErrScopedAdminKeyNotFound, 404}, + {"普通用户拒绝", "user", nil, 403}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := &deleteScopedKeyRecordRepo{err: tc.err} + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(repo) + h := &SettingHandler{settingService: settings} + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("DELETE", "/api/v1/admin/settings/admin-api-keys/key-1/record", nil) + c.Params = gin.Params{{Key: "id", Value: "key-1"}} + c.Set("auth_method", service.AuditAuthMethodJWT) + c.Set("user_role", tc.role) + h.DeleteScopedAdminKeyRecord(c) + require.Equal(t, tc.status, w.Code) + if tc.role != "admin" { + require.Empty(t, repo.id) + } else { + require.Equal(t, "key-1", repo.id) + } + if tc.status == 200 { + require.Contains(t, w.Body.String(), "历史操作记录仍会继续保留") + } + }) + } +} diff --git a/backend/internal/handler/admin/custom_legacy_admin_key_test.go b/backend/internal/handler/admin/custom_legacy_admin_key_test.go new file mode 100644 index 000000000000..2ee229e86ced --- /dev/null +++ b/backend/internal/handler/admin/custom_legacy_admin_key_test.go @@ -0,0 +1,141 @@ +//go:build unit + +package admin + +import ( + "context" + "database/sql" + "fmt" + "net/http/httptest" + "os" + "sync" + "testing" + "time" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + dbent "github.com/Wei-Shaw/sub2api/ent" + _ "github.com/Wei-Shaw/sub2api/ent/runtime" + "github.com/Wei-Shaw/sub2api/internal/repository" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" + "github.com/stretchr/testify/require" +) + +// 在轮换读取旧值后暂停,让删除在恢复轮换之前完整提交。 +type legacyKeyReadBarrier struct { + service.SettingRepository + read chan struct{} + resume chan struct{} + once sync.Once +} + +func (r *legacyKeyReadBarrier) GetValue(ctx context.Context, key string) (string, error) { + value, err := r.SettingRepository.GetValue(ctx, key) + if key == service.SettingKeyAdminAPIKey && err == nil { + r.once.Do(func() { close(r.read); <-r.resume }) + } + return value, err +} + +func (r *legacyKeyReadBarrier) ReplaceExistingAdminAPIKey(ctx context.Context, value string) (bool, error) { + return r.SettingRepository.(interface { + ReplaceExistingAdminAPIKey(context.Context, string) (bool, error) + }).ReplaceExistingAdminAPIKey(ctx, value) +} + +func legacyKeyRequest(handler gin.HandlerFunc) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/api/v1/admin/settings/admin-api-key/regenerate", nil) + c.Set("auth_method", service.AuditAuthMethodJWT) + c.Set("user_role", "admin") + handler(c) + return w +} + +func TestLegacyAdminKeyRotationPostgres(t *testing.T) { + dsn := os.Getenv("SCOPED_ADMIN_TEST_DSN") + if dsn == "" { + t.Skip("需要独立 SCOPED_ADMIN_TEST_DSN") + } + base, err := sql.Open("postgres", dsn) + require.NoError(t, err) + schema := fmt.Sprintf("legacy_key_test_%d", time.Now().UnixNano()) + _, err = base.Exec(`CREATE SCHEMA ` + schema) + require.NoError(t, err) + db, err := sql.Open("postgres", dsn+" search_path="+schema) + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close(); _, _ = base.Exec(`DROP SCHEMA ` + schema + ` CASCADE`); _ = base.Close() }) + _, err = db.Exec(`CREATE TABLE settings(id BIGSERIAL PRIMARY KEY, key VARCHAR(100) UNIQUE NOT NULL, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`) + require.NoError(t, err) + repo := repository.NewSettingRepository(client) + settings := service.NewSettingService(repo, nil) + h := &SettingHandler{settingService: settings} + ctx := context.Background() + + t.Run("删除先完成不得被轮换重建", func(t *testing.T) { + require.NoError(t, repo.Set(ctx, service.SettingKeyAdminAPIKey, "admin-test-old-key")) + barrier := &legacyKeyReadBarrier{SettingRepository: repo, read: make(chan struct{}), resume: make(chan struct{})} + var release sync.Once + unblock := func() { release.Do(func() { close(barrier.resume) }) } + defer unblock() + rotating := &SettingHandler{settingService: service.NewSettingService(barrier, nil)} + done := make(chan *httptest.ResponseRecorder, 1) + go func() { done <- legacyKeyRequest(rotating.RegenerateAdminAPIKey) }() + select { + case <-barrier.read: + case <-time.After(5 * time.Second): + t.Fatal("轮换未进入读取屏障") + } + require.Equal(t, 200, legacyKeyRequest(h.DeleteAdminAPIKey).Code) + var afterDelete int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM settings WHERE key=$1`, service.SettingKeyAdminAPIKey).Scan(&afterDelete)) + require.Zero(t, afterDelete, "恢复轮换前删除必须已经落库") + unblock() + var response *httptest.ResponseRecorder + select { + case response = <-done: + case <-time.After(5 * time.Second): + t.Fatal("轮换未结束") + } + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM settings WHERE key=$1`, service.SettingKeyAdminAPIKey).Scan(&count)) + require.Zero(t, count, "已删除旧版密钥被轮换重新创建") + require.Equal(t, 403, response.Code) + require.NotContains(t, response.Body.String(), `"key":`) + }) + + t.Run("已有密钥可轮换且随后可删除", func(t *testing.T) { + require.NoError(t, repo.Set(ctx, service.SettingKeyAdminAPIKey, "admin-test-old-key")) + response := legacyKeyRequest(h.RegenerateAdminAPIKey) + require.Equal(t, 200, response.Code) + key, err := settings.GetAdminAPIKey(ctx) + require.NoError(t, err) + require.NotEqual(t, "admin-test-old-key", key) + require.NotEmpty(t, key) + require.Contains(t, response.Body.String(), key) + require.Equal(t, 200, legacyKeyRequest(h.DeleteAdminAPIKey).Code) + key, err = settings.GetAdminAPIKey(ctx) + require.NoError(t, err) + require.Empty(t, key) + }) + + t.Run("缺失与空值不能新建", func(t *testing.T) { + for _, emptyRow := range []bool{false, true} { + require.NoError(t, settings.DeleteAdminAPIKey(ctx)) + if emptyRow { + require.NoError(t, repo.Set(ctx, service.SettingKeyAdminAPIKey, "")) + } + require.Equal(t, 403, legacyKeyRequest(h.RegenerateAdminAPIKey).Code) + key, err := settings.GenerateAdminAPIKey(ctx) + require.Error(t, err, "服务层也必须拒绝从无到有创建") + require.Empty(t, key) + stored, err := settings.GetAdminAPIKey(ctx) + require.NoError(t, err) + require.Empty(t, stored) + } + }) +} diff --git a/backend/internal/handler/admin/idempotency_helper.go b/backend/internal/handler/admin/idempotency_helper.go index 8017e7211e4b..db104a7d6c1a 100644 --- a/backend/internal/handler/admin/idempotency_helper.go +++ b/backend/internal/handler/admin/idempotency_helper.go @@ -115,6 +115,18 @@ func executeAdminIdempotentJSONWithMode( mode idempotencyStoreUnavailableMode, execute func(context.Context) (any, error), ) { + executeAdminIdempotentJSONWithProjection(c, scope, payload, ttl, executionTimeout, mode, execute, nil) +} + +// executeAdminIdempotentJSONWithResponse 在实际执行及缓存重放之后统一约束响应。 +func executeAdminIdempotentJSONWithResponse(c *gin.Context, scope string, payload any, ttl time.Duration, execute func(context.Context) (any, error), present func(any) any) { + executeAdminIdempotentJSONWithProjection(c, scope, payload, ttl, 0, idempotencyStoreUnavailableFailClose, execute, present) +} + +func executeAdminIdempotentJSONWithProjection(c *gin.Context, scope string, payload any, ttl time.Duration, executionTimeout time.Duration, mode idempotencyStoreUnavailableMode, execute func(context.Context) (any, error), present func(any) any) { + if present == nil { + present = func(data any) any { return data } + } result, err := executeAdminIdempotentWithTimeout(c, scope, payload, ttl, executionTimeout, execute) if err != nil { if infraerrors.Code(err) == infraerrors.Code(service.ErrIdempotencyStoreUnavail) { @@ -131,7 +143,7 @@ func executeAdminIdempotentJSONWithMode( return } c.Header("X-Idempotency-Degraded", "store-unavailable") - response.Success(c, data) + response.Success(c, present(data)) return } } @@ -144,5 +156,5 @@ func executeAdminIdempotentJSONWithMode( if result != nil && result.Replayed { c.Header("X-Idempotency-Replayed", "true") } - response.Success(c, result.Data) + response.Success(c, present(result.Data)) } diff --git a/backend/internal/handler/admin/promo_handler.go b/backend/internal/handler/admin/promo_handler.go index 34a44a81f1dc..3e8be8641c24 100644 --- a/backend/internal/handler/admin/promo_handler.go +++ b/backend/internal/handler/admin/promo_handler.go @@ -207,5 +207,5 @@ func (h *PromoHandler) GetUsages(c *gin.Context) { for i := range usages { out = append(out, *dto.PromoCodeUsageFromService(&usages[i])) } - response.Paginated(c, out, paginationResult.Total, page, pageSize) + response.Paginated(c, adminPromoUsagesResponse(c, out), paginationResult.Total, page, pageSize) } diff --git a/backend/internal/handler/admin/proxy_handler.go b/backend/internal/handler/admin/proxy_handler.go index 3902eaf2f844..d16453d3b2ad 100644 --- a/backend/internal/handler/admin/proxy_handler.go +++ b/backend/internal/handler/admin/proxy_handler.go @@ -57,6 +57,9 @@ type UpdateProxyRequest struct { // List handles listing all proxies with pagination // GET /api/v1/admin/proxies func (h *ProxyHandler) List(c *gin.Context) { + if h.scopedAdminProxyRead(c) { + return + } page, pageSize := response.ParsePagination(c) protocol := c.Query("protocol") status := c.Query("status") @@ -86,6 +89,9 @@ func (h *ProxyHandler) List(c *gin.Context) { // GET /api/v1/admin/proxies/all // Optional query param: with_count=true to include account count per proxy func (h *ProxyHandler) GetAll(c *gin.Context) { + if h.scopedAdminProxyRead(c) { + return + } withCount := c.Query("with_count") == "true" if withCount { @@ -118,6 +124,9 @@ func (h *ProxyHandler) GetAll(c *gin.Context) { // GetByID handles getting a proxy by ID // GET /api/v1/admin/proxies/:id func (h *ProxyHandler) GetByID(c *gin.Context) { + if h.scopedAdminProxyRead(c) { + return + } proxyID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid proxy ID") diff --git a/backend/internal/handler/admin/scoped_admin_content_response.go b/backend/internal/handler/admin/scoped_admin_content_response.go new file mode 100644 index 000000000000..69e037f8be05 --- /dev/null +++ b/backend/internal/handler/admin/scoped_admin_content_response.go @@ -0,0 +1,47 @@ +package admin + +import ( + "time" + + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// 受限密钥仅接收记录自身字段;不嵌入完整用户资料或实时财务数据。 +type scopedPromoUsageResponse struct { + ID int64 `json:"id"` + PromoCodeID int64 `json:"promo_code_id"` + UserID int64 `json:"user_id"` + BonusAmount float64 `json:"bonus_amount"` + UsedAt time.Time `json:"used_at"` +} + +func adminPromoUsagesResponse(c *gin.Context, items []dto.PromoCodeUsage) any { + if adminaccess.FromContext(c.Request.Context()) == nil { + return items + } + out := make([]scopedPromoUsageResponse, 0, len(items)) + for _, item := range items { + out = append(out, scopedPromoUsageResponse{ID: item.ID, PromoCodeID: item.PromoCodeID, UserID: item.UserID, BonusAmount: item.BonusAmount, UsedAt: item.UsedAt}) + } + return out +} + +type scopedAnnouncementReadResponse struct { + UserID int64 `json:"user_id"` + Eligible bool `json:"eligible"` + ReadAt *time.Time `json:"read_at,omitempty"` +} + +func adminAnnouncementReadResponse(c *gin.Context, items []service.AnnouncementUserReadStatus) any { + if adminaccess.FromContext(c.Request.Context()) == nil { + return items + } + out := make([]scopedAnnouncementReadResponse, 0, len(items)) + for _, item := range items { + out = append(out, scopedAnnouncementReadResponse{UserID: item.UserID, Eligible: item.Eligible, ReadAt: item.ReadAt}) + } + return out +} diff --git a/backend/internal/handler/admin/scoped_admin_content_response_test.go b/backend/internal/handler/admin/scoped_admin_content_response_test.go new file mode 100644 index 000000000000..2055ea1f9e15 --- /dev/null +++ b/backend/internal/handler/admin/scoped_admin_content_response_test.go @@ -0,0 +1,94 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "net/http/httptest" + "testing" + "time" +) + +type scopedContentPromoRepo struct{ service.PromoCodeRepository } + +func (scopedContentPromoRepo) ListUsagesByPromoCode(context.Context, int64, pagination.PaginationParams) ([]service.PromoCodeUsage, *pagination.PaginationResult, error) { + return []service.PromoCodeUsage{{ID: 1, PromoCodeID: 2, UserID: 3, BonusAmount: 4, User: &service.User{ID: 3, Email: "private@example.com", Balance: 999, FrozenBalance: 888, TotalRecharged: 777}}}, &pagination.PaginationResult{Total: 1}, nil +} + +type scopedContentUsers struct{ service.UserRepository } + +func (scopedContentUsers) ListWithFilters(context.Context, pagination.PaginationParams, service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) { + return []service.User{{ID: 3, Email: "private@example.com", Balance: 999}}, &pagination.PaginationResult{Total: 1}, nil +} + +type scopedContentSubs struct { + service.UserSubscriptionRepository +} + +func (scopedContentSubs) ListActiveByUserID(context.Context, int64) ([]service.UserSubscription, error) { + return nil, nil +} + +type scopedContentReads struct { + service.AnnouncementReadRepository +} + +func (scopedContentReads) GetReadMapByUsers(context.Context, int64, []int64) (map[int64]time.Time, error) { + return map[int64]time.Time{3: time.Unix(100, 0).UTC()}, nil +} +func TestScopedAdminContentHandlersResponseBoundary(t *testing.T) { + gin.SetMode(gin.TestMode) + promo := NewPromoHandler(service.NewPromoService(scopedContentPromoRepo{}, nil, nil, nil, nil)) + announcement := NewAnnouncementHandler(service.NewAnnouncementService(&announcementRepoCapture{}, scopedContentReads{}, scopedContentUsers{}, scopedContentSubs{})) + for _, endpoint := range []struct { + name, path, request string + handler gin.HandlerFunc + }{{"优惠码", "/promo-codes/:id/usages", "/promo-codes/2/usages", promo.GetUsages}, {"公告", "/announcements/:id/read-status", "/announcements/2/read-status", announcement.ListReadStatus}} { + for _, scoped := range []bool{false, true} { + t.Run(endpoint.name+map[bool]string{true: "受限", false: "普通"}[scoped], func(t *testing.T) { + r := gin.New() + r.Use(func(c *gin.Context) { + if scoped { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{Policy: adminaccess.Policy{GlobalPermissions: []string{"promo.read", "announcements.read"}}})) + } + c.Next() + }) + r.GET(endpoint.path, endpoint.handler) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", endpoint.request, nil)) + require.Equal(t, 200, w.Code) + var body struct { + Data struct { + Items []map[string]json.RawMessage `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Len(t, body.Data.Items, 1) + item := body.Data.Items[0] + if scoped { + require.NotContains(t, w.Body.String(), "private@example.com") + require.NotContains(t, w.Body.String(), "999") + require.NotContains(t, item, "user") + require.NotContains(t, item, "balance") + require.Contains(t, item, "user_id") + if endpoint.name == "优惠码" { + require.Contains(t, item, "bonus_amount") + require.Contains(t, item, "used_at") + } else { + require.Contains(t, item, "eligible") + require.Contains(t, item, "read_at") + } + } else { + require.Contains(t, w.Body.String(), "private@example.com") + require.Contains(t, w.Body.String(), "999") + } + }) + } + } +} diff --git a/backend/internal/handler/admin/scoped_admin_subscription_response.go b/backend/internal/handler/admin/scoped_admin_subscription_response.go new file mode 100644 index 000000000000..8c467fbba1ee --- /dev/null +++ b/backend/internal/handler/admin/scoped_admin_subscription_response.go @@ -0,0 +1,65 @@ +package admin + +import ( + "encoding/json" + "slices" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/gin-gonic/gin" +) + +func scopedAdminWriteOnly(c *gin.Context, readPermission string) bool { + p := adminaccess.FromContext(c.Request.Context()) + return p != nil && !slices.Contains(p.Policy.GlobalPermissions, readPermission) +} + +// adminSubscriptionResponse 在缓存重放后也应用字段白名单,不嵌入用户、分配人或分组配置。 +// 写权限只确认订阅 ID;订阅用量等字段必须另有读取权限。 +func adminSubscriptionResponse(c *gin.Context, data any) any { + if adminaccess.FromContext(c.Request.Context()) == nil { + return data + } + raw, err := json.Marshal(data) + if err != nil { + return nil + } + return projectSubscriptionJSON(raw, !scopedAdminWriteOnly(c, "subscriptions.read")) +} + +func projectSubscriptionJSON(raw json.RawMessage, readable bool) any { + var list []json.RawMessage + if len(raw) > 0 && raw[0] == '[' && json.Unmarshal(raw, &list) == nil { + out := make([]any, 0, len(list)) + for _, item := range list { + out = append(out, projectSubscriptionJSON(item, readable)) + } + return out + } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil || fields == nil { + return nil + } + out := make(map[string]any) + if subscriptions, ok := fields["subscriptions"]; ok { + for _, key := range []string{"success_count", "created_count", "reused_count", "failed_count", "errors", "statuses"} { + if value, exists := fields[key]; exists { + out[key] = value + } + } + out["subscriptions"] = projectSubscriptionJSON(subscriptions, readable) + return out + } + keys := []string{"id"} + if readable { + keys = append(keys, "user_id", "group_id", "starts_at", "expires_at", "status", + "daily_window_start", "weekly_window_start", "monthly_window_start", + "daily_usage_usd", "weekly_usage_usd", "monthly_usage_usd", + "created_at", "updated_at", "revoked_at", "assigned_by", "assigned_at", "notes") + } + for _, key := range keys { + if value, exists := fields[key]; exists { + out[key] = value + } + } + return out +} diff --git a/backend/internal/handler/admin/scoped_admin_subscription_response_test.go b/backend/internal/handler/admin/scoped_admin_subscription_response_test.go new file mode 100644 index 000000000000..0756d674caa0 --- /dev/null +++ b/backend/internal/handler/admin/scoped_admin_subscription_response_test.go @@ -0,0 +1,164 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type scopedSubscriptionRepo struct { + service.UserSubscriptionRepository + writes int +} + +func (*scopedSubscriptionRepo) GetByID(context.Context, int64) (*service.UserSubscription, error) { + return &service.UserSubscription{ID: 1, UserID: 2, GroupID: 3, DailyUsageUSD: 4, ExpiresAt: time.Now().Add(time.Hour), + User: &service.User{ID: 2, Email: "private-user@example.com", Balance: 987}, + AssignedByUser: &service.User{ID: 8, Email: "private-admin@example.com", Balance: 654}, + Group: &service.Group{ID: 3, Name: "group", RateMultiplier: 42}}, nil +} +func TestScopedAdminSubscriptionDoesNotExposeAssociatedUserAndPricing(t *testing.T) { + gin.SetMode(gin.TestMode) + s := service.NewSubscriptionService(nil, &scopedSubscriptionRepo{}, nil, nil, nil) + defer s.Stop() + h := NewSubscriptionHandler(s) + for _, scoped := range []bool{false, true} { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/subscriptions/1", nil) + c.Params = gin.Params{{Key: "id", Value: "1"}} + if scoped { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{Policy: adminaccess.Policy{GlobalPermissions: []string{"subscriptions.read"}}})) + } + h.GetByID(c) + require.Equal(t, 200, w.Code) + require.Contains(t, w.Body.String(), `"daily_usage_usd":4`) + for _, field := range []string{"private-user@example.com", "private-admin@example.com", `"balance"`, `"rate_multiplier"`} { + if scoped { + require.NotContains(t, w.Body.String(), field) + } else { + require.Contains(t, w.Body.String(), field) + } + } + } +} +func TestScopedAdminQuotaWriteDoesNotGrantQuotaRead(t *testing.T) { + repo := &upsertCapturingQuotaRepo{listRecords: []service.UserPlatformQuotaRecord{{UserID: 42, Platform: "anthropic"}}} + h := buildTestHandler(repo, &billingCacheStub{}) + c, w := putReq(t, `{"quotas":[{"platform":"anthropic","daily_limit_usd":10}]}`) + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{Policy: adminaccess.Policy{GlobalPermissions: []string{"users.quotas.write"}}})) + h.UpdateUserPlatformQuotas(c) + require.Equal(t, 200, w.Code) + require.NotContains(t, w.Body.String(), "platform_quotas") + require.NotContains(t, w.Body.String(), "usage") + require.Len(t, repo.upsertCalls, 1) +} + +func (r *scopedSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) { + return r.GetByID(ctx, id) +} +func (r *scopedSubscriptionRepo) ExtendExpiry(context.Context, int64, time.Time) error { + r.writes++ + return nil +} + +func TestScopedAdminSubscriptionExtendReplayRespectsReadPermission(t *testing.T) { + gin.SetMode(gin.TestMode) + previous := service.DefaultIdempotencyCoordinator() + t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previous) }) + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig())) + repo := &scopedSubscriptionRepo{} + svc := service.NewSubscriptionService(nil, repo, nil, nil, nil) + defer svc.Stop() + h := NewSubscriptionHandler(svc) + r := gin.New() + r.Use(func(c *gin.Context) { + if c.GetHeader("X-Test-Mode") != "admin" { + permissions := []string{"subscriptions.extend"} + if c.GetHeader("X-Test-Mode") == "read" { + permissions = append(permissions, "subscriptions.read") + } + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{Policy: adminaccess.Policy{GlobalPermissions: permissions}})) + } + }) + r.POST("/subscriptions/:id/extend", h.Extend) + for i, mode := range []string{"admin", "write", "read"} { + req := httptest.NewRequest("POST", "/subscriptions/1/extend", strings.NewReader(`{"days":1}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", "same-extension") + req.Header.Set("X-Test-Mode", mode) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 200, w.Code, w.Body.String()) + if i > 0 { + require.Equal(t, "true", w.Header().Get("X-Idempotency-Replayed")) + } + var result struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + if mode == "admin" { + require.Contains(t, result.Data, "user") + } else { + require.NotContains(t, result.Data, "user") + require.NotContains(t, result.Data, "group") + require.NotContains(t, result.Data, "assigned_by_user") + if mode == "write" { + require.Len(t, result.Data, 1) + } else { + require.Contains(t, result.Data, "daily_usage_usd") + } + } + } + require.Equal(t, 1, repo.writes) +} + +func TestScopedAdminSubscriptionBulkProjectionPreservesIDs(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{})) + sub, _ := (&scopedSubscriptionRepo{}).GetByID(context.Background(), 1) + sub.ID = 9007199254740993 + data := &dto.BulkAssignResult{SuccessCount: 1, Subscriptions: []dto.AdminUserSubscription{*dto.UserSubscriptionFromServiceAdmin(sub)}} + raw, err := json.Marshal(adminSubscriptionResponse(c, data)) + require.NoError(t, err) + require.Contains(t, string(raw), `"id":9007199254740993`) + require.Contains(t, string(raw), `"success_count":1`) + for _, field := range []string{"daily_usage_usd", "user_id", "balance", "group", "private-user"} { + require.NotContains(t, string(raw), field) + } +} + +func TestScopedAdminQuotaResetRequiresReadToReturnUsage(t *testing.T) { + for _, readable := range []bool{false, true} { + repo := &upsertCapturingQuotaRepo{listRecords: []service.UserPlatformQuotaRecord{{UserID: 42, Platform: "anthropic"}}} + cache := &billingCacheStub{} + h := buildTestHandler(repo, cache) + c, w := postReq(t, `{"platform":"anthropic","window":"daily"}`) + permissions := []string{"users.quotas.write"} + if readable { + permissions = append(permissions, "users.quotas.read") + } + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{Policy: adminaccess.Policy{GlobalPermissions: permissions}})) + h.ResetUserPlatformQuotaWindow(c) + require.Equal(t, 200, w.Code, w.Body.String()) + require.Len(t, repo.resetCalls, 1) + require.Len(t, cache.deleteCalls, 1) + if readable { + require.Contains(t, w.Body.String(), "platform_quotas") + } else { + require.NotContains(t, w.Body.String(), "platform_quotas") + } + } +} diff --git a/backend/internal/handler/admin/scoped_admin_usage_response.go b/backend/internal/handler/admin/scoped_admin_usage_response.go new file mode 100644 index 000000000000..902ae310a7fa --- /dev/null +++ b/backend/internal/handler/admin/scoped_admin_usage_response.go @@ -0,0 +1,97 @@ +package admin + +import ( + "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// scopedAdminUsageLogResponse 仅公开该次请求的用量、计费快照和请求元数据。 +// 关联资源只返回 ID;新增通用 DTO 字段不会自动获得受限密钥的读取权限。 +func scopedAdminUsageLogResponse(record *service.UsageLog) gin.H { + if record == nil { + return nil + } + // 在转换前移除关联,避免构造含原始密钥、用户余额等内容的嵌套 DTO。 + flat := *record + flat.User, flat.APIKey, flat.Group, flat.Subscription, flat.Account = nil, nil, nil, nil, nil + usage := dto.UsageLogFromServiceAdmin(&flat) + return gin.H{ + "id": usage.ID, + "user_id": usage.UserID, + "api_key_id": usage.APIKeyID, + "account_id": usage.AccountID, + "request_id": usage.RequestID, + "model": usage.Model, + "service_tier": usage.ServiceTier, + "reasoning_effort": usage.ReasoningEffort, + "inbound_endpoint": usage.InboundEndpoint, + "upstream_endpoint": usage.UpstreamEndpoint, + "group_id": usage.GroupID, + "subscription_id": usage.SubscriptionID, + "input_tokens": usage.InputTokens, + "output_tokens": usage.OutputTokens, + "cache_creation_tokens": usage.CacheCreationTokens, + "cache_read_tokens": usage.CacheReadTokens, + "cache_creation_5m_tokens": usage.CacheCreation5mTokens, + "cache_creation_1h_tokens": usage.CacheCreation1hTokens, + "input_cost": usage.InputCost, + "output_cost": usage.OutputCost, + "cache_creation_cost": usage.CacheCreationCost, + "cache_read_cost": usage.CacheReadCost, + "total_cost": usage.TotalCost, + "actual_cost": usage.ActualCost, + "rate_multiplier": usage.RateMultiplier, + "long_context_billing_applied": usage.LongContextBillingApplied, + "billing_type": usage.BillingType, + "request_type": usage.RequestType, + "stream": usage.Stream, + "openai_ws_mode": usage.OpenAIWSMode, + "native_compaction_v2": usage.NativeCompactionV2, + "duration_ms": usage.DurationMs, + "first_token_ms": usage.FirstTokenMs, + "image_count": usage.ImageCount, + "image_size": usage.ImageSize, + "image_input_size": usage.ImageInputSize, + "image_output_size": usage.ImageOutputSize, + "image_input_tokens": usage.ImageInputTokens, + "image_input_cost": usage.ImageInputCost, + "image_output_tokens": usage.ImageOutputTokens, + "image_output_cost": usage.ImageOutputCost, + "image_size_source": usage.ImageSizeSource, + "image_size_breakdown": usage.ImageSizeBreakdown, + "media_type": usage.MediaType, + "user_agent": usage.UserAgent, + "ip_address": usage.IPAddress, + "session_id": usage.SessionID, + "cache_ttl_overridden": usage.CacheTTLOverridden, + "billing_mode": usage.BillingMode, + "created_at": usage.CreatedAt, + "upstream_model": usage.UpstreamModel, + "upstream_reasoning_effort": usage.UpstreamReasoningEffort, + "upstream_response_model": usage.UpstreamResponseModel, + "upstream_model_mismatch": usage.UpstreamModelMismatch, + "channel_id": usage.ChannelID, + "model_mapping_chain": usage.ModelMappingChain, + "upstream_request_id": usage.UpstreamRequestID, + "billing_tier": usage.BillingTier, + "account_rate_multiplier": usage.AccountRateMultiplier, + "account_stats_cost": usage.AccountStatsCost, + } +} + +func adminUsageLogResponses(c *gin.Context, records []service.UsageLog) any { + if adminaccess.FromContext(c.Request.Context()) != nil { + out := make([]gin.H, 0, len(records)) + for i := range records { + out = append(out, scopedAdminUsageLogResponse(&records[i])) + } + return out + } + out := make([]dto.AdminUsageLog, 0, len(records)) + for i := range records { + out = append(out, *dto.UsageLogFromServiceAdmin(&records[i])) + } + return out +} diff --git a/backend/internal/handler/admin/scoped_admin_usage_response_test.go b/backend/internal/handler/admin/scoped_admin_usage_response_test.go new file mode 100644 index 000000000000..c43e9015f40b --- /dev/null +++ b/backend/internal/handler/admin/scoped_admin_usage_response_test.go @@ -0,0 +1,92 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type scopedUsageResponseRepo struct { + service.UsageLogRepository + records []service.UsageLog +} + +func (r *scopedUsageResponseRepo) ListWithFilters(context.Context, pagination.PaginationParams, usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) { + return r.records, &pagination.PaginationResult{Total: int64(len(r.records))}, nil +} +func TestScopedAdminUsageListDoesNotGrantNestedResourceRead(t *testing.T) { + gin.SetMode(gin.TestMode) + privateUser := &service.User{ID: 7, Email: "private-email@example.invalid", Balance: 97531, Notes: "private-user-notes"} + privateKey := &service.APIKey{ID: 8, Key: "private-api-key-secret", Name: "private-key-name", User: privateUser} + privateGroup := &service.Group{ID: 9, Name: "private-group-name", Description: "private-group-description", RateMultiplier: 7} + privateSub := &service.UserSubscription{ID: 10, User: privateUser, Group: privateGroup, DailyUsageUSD: 100} + privateAccount := &service.Account{ID: 11, Name: "private-account-name", Credentials: map[string]any{"access_token": "private-account-token"}} + for _, scoped := range []bool{true, false} { + t.Run(map[bool]string{true: "受限用量读取", false: "完整管理员保留响应"}[scoped], func(t *testing.T) { + repo := &scopedUsageResponseRepo{records: []service.UsageLog{{ID: 12, UserID: 7, APIKeyID: 8, AccountID: 11, RequestID: "request-public", RequestedModel: "requested-model", Model: "effective-model", InputTokens: 123, OutputTokens: 456, TotalCost: 1.25, ActualCost: 1, User: privateUser, APIKey: privateKey, Group: privateGroup, Subscription: privateSub, Account: privateAccount}}} + h := NewUsageHandler(service.NewUsageService(repo, nil, nil, nil), nil, nil, nil) + r := gin.New() + r.Use(func(c *gin.Context) { + if scoped { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{Policy: adminaccess.Policy{GlobalPermissions: []string{"usage.read"}}})) + } + }) + r.GET("/api/v1/admin/usage", h.List) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/api/v1/admin/usage", nil)) + require.Equal(t, 200, w.Code, w.Body.String()) + var result struct { + Data struct { + Items []map[string]json.RawMessage `json:"items"` + Total int64 `json:"total"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + require.Len(t, result.Data.Items, 1) + require.EqualValues(t, 1, result.Data.Total) + item := result.Data.Items[0] + require.JSONEq(t, `123`, string(item["input_tokens"])) + require.JSONEq(t, `456`, string(item["output_tokens"])) + require.JSONEq(t, `1.25`, string(item["total_cost"])) + require.JSONEq(t, `"requested-model"`, string(item["model"])) + require.JSONEq(t, `8`, string(item["api_key_id"])) + if scoped { + require.NotContains(t, w.Body.String(), "private-") + require.NotContains(t, w.Body.String(), "97531") + for _, field := range []string{"user", "api_key", "group", "subscription", "account", "balance", "credentials"} { + require.NotContains(t, item, field) + } + } else { + require.Contains(t, w.Body.String(), "private-api-key-secret") + require.Contains(t, w.Body.String(), "private-email@example.invalid") + require.Contains(t, item, "user") + require.Contains(t, item, "api_key") + require.Contains(t, item, "account") + } + require.Equal(t, "private-api-key-secret", repo.records[0].APIKey.Key, "输出投影不得修改服务返回对象") + }) + } +} +func TestScopedAdminUsageListEmptyRemainsArray(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewUsageHandler(service.NewUsageService(&scopedUsageResponseRepo{}, nil, nil, nil), nil, nil, nil) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{})) + }) + r.GET("/usage", h.List) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("GET", "/usage", nil)) + require.Equal(t, 200, w.Code) + require.Contains(t, w.Body.String(), `"items":[]`) +} diff --git a/backend/internal/handler/admin/setting_handler_runtime.go b/backend/internal/handler/admin/setting_handler_runtime.go index 972604e0a801..3cc4ef4b6064 100644 --- a/backend/internal/handler/admin/setting_handler_runtime.go +++ b/backend/internal/handler/admin/setting_handler_runtime.go @@ -13,6 +13,9 @@ import ( // GetAdminAPIKey 获取管理员 API Key 状态 // GET /api/v1/admin/settings/admin-api-key func (h *SettingHandler) GetAdminAPIKey(c *gin.Context) { + if !requireAdminKeyJWT(c) { + return + } maskedKey, exists, err := h.settingService.GetAdminAPIKeyStatus(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -25,9 +28,21 @@ func (h *SettingHandler) GetAdminAPIKey(c *gin.Context) { }) } -// RegenerateAdminAPIKey 生成/重新生成管理员 API Key +// RegenerateAdminAPIKey 轮换已存在的旧版管理员 API Key // POST /api/v1/admin/settings/admin-api-key/regenerate func (h *SettingHandler) RegenerateAdminAPIKey(c *gin.Context) { + if !requireAdminKeyJWT(c) { + return + } + _, exists, err := h.settingService.GetAdminAPIKeyStatus(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + if !exists { + response.Error(c, 403, "旧版密钥已停止新建,请创建权限管理密钥") + return + } key, err := h.settingService.GenerateAdminAPIKey(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -42,6 +57,9 @@ func (h *SettingHandler) RegenerateAdminAPIKey(c *gin.Context) { // DeleteAdminAPIKey 删除管理员 API Key // DELETE /api/v1/admin/settings/admin-api-key func (h *SettingHandler) DeleteAdminAPIKey(c *gin.Context) { + if !requireAdminKeyJWT(c) { + return + } if err := h.settingService.DeleteAdminAPIKey(c.Request.Context()); err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index 2174e18840a7..2de88893e782 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -94,7 +94,7 @@ func (h *SubscriptionHandler) List(c *gin.Context) { for i := range subscriptions { out = append(out, *dto.UserSubscriptionFromServiceAdmin(&subscriptions[i])) } - response.PaginatedWithResult(c, out, toResponsePagination(pagination)) + response.PaginatedWithResult(c, adminSubscriptionResponse(c, out), toResponsePagination(pagination)) } // GetByID handles getting a subscription by ID @@ -112,7 +112,7 @@ func (h *SubscriptionHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription)) + response.Success(c, adminSubscriptionResponse(c, dto.UserSubscriptionFromServiceAdmin(subscription))) } // GetProgress handles getting subscription usage progress @@ -157,7 +157,7 @@ func (h *SubscriptionHandler) Assign(c *gin.Context) { return } - response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription)) + response.Success(c, adminSubscriptionResponse(c, dto.UserSubscriptionFromServiceAdmin(subscription))) } // BulkAssign handles bulk assigning subscriptions to multiple users @@ -184,7 +184,7 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) { return } - response.Success(c, dto.BulkAssignResultFromService(result)) + response.Success(c, adminSubscriptionResponse(c, dto.BulkAssignResultFromService(result))) } // BulkAction applies one operation to selected subscriptions, returning each outcome. @@ -226,13 +226,13 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) { SubscriptionID: subscriptionID, Body: req, } - executeAdminIdempotentJSON(c, "admin.subscriptions.extend", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { + executeAdminIdempotentJSONWithResponse(c, "admin.subscriptions.extend", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { subscription, execErr := h.subscriptionService.ExtendSubscription(ctx, subscriptionID, req.Days) if execErr != nil { return nil, execErr } return dto.UserSubscriptionFromServiceAdmin(subscription), nil - }) + }, func(data any) any { return adminSubscriptionResponse(c, data) }) } // ResetSubscriptionQuotaRequest represents the reset quota request @@ -264,7 +264,7 @@ func (h *SubscriptionHandler) ResetQuota(c *gin.Context) { response.ErrorFrom(c, err) return } - response.Success(c, dto.UserSubscriptionFromServiceAdmin(sub)) + response.Success(c, adminSubscriptionResponse(c, dto.UserSubscriptionFromServiceAdmin(sub))) } // Revoke handles revoking a subscription. @@ -301,7 +301,7 @@ func (h *SubscriptionHandler) Restore(c *gin.Context) { return } - response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription)) + response.Success(c, adminSubscriptionResponse(c, dto.UserSubscriptionFromServiceAdmin(subscription))) } // ListByGroup handles listing subscriptions for a specific group @@ -325,7 +325,7 @@ func (h *SubscriptionHandler) ListByGroup(c *gin.Context) { for i := range subscriptions { out = append(out, *dto.UserSubscriptionFromServiceAdmin(&subscriptions[i])) } - response.PaginatedWithResult(c, out, toResponsePagination(pagination)) + response.PaginatedWithResult(c, adminSubscriptionResponse(c, out), toResponsePagination(pagination)) } // ListByUser handles listing subscriptions for a specific user @@ -347,7 +347,7 @@ func (h *SubscriptionHandler) ListByUser(c *gin.Context) { for i := range subscriptions { out = append(out, *dto.UserSubscriptionFromServiceAdmin(&subscriptions[i])) } - response.Success(c, out) + response.Success(c, adminSubscriptionResponse(c, out)) } // Helper function to get admin ID from context diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 4fe37f087f44..eb06d59cd497 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -213,11 +213,7 @@ func (h *UsageHandler) List(c *gin.Context) { return } - out := make([]dto.AdminUsageLog, 0, len(records)) - for i := range records { - out = append(out, *dto.UsageLogFromServiceAdmin(&records[i])) - } - response.Paginated(c, out, result.Total, page, pageSize) + response.Paginated(c, adminUsageLogResponses(c, records), result.Total, page, pageSize) } // Stats handles getting usage statistics with filters diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 817bfa9117bb..ceffd279473c 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -12,6 +12,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/quotaview" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -150,6 +151,11 @@ func (h *UserHandler) List(c *gin.Context) { includeSubscriptions := parseBoolQueryWithDefault(raw, true) filters.IncludeSubscriptions = &includeSubscriptions } + principal := adminaccess.FromContext(c.Request.Context()) + if principal != nil { + includeSubscriptions := false + filters.IncludeSubscriptions = &includeSubscriptions + } users, total, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, filters, sortBy, sortOrder) if err != nil { @@ -157,6 +163,15 @@ func (h *UserHandler) List(c *gin.Context) { return } + if principal != nil { + out := make([]any, len(users)) + for i := range users { + out[i] = adminUserResponse(c, &users[i]) + } + response.Paginated(c, out, total, page, pageSize) + return + } + // Batch get current concurrency (nil map if unavailable) var loadInfo map[int64]*service.UserLoadInfo if len(users) > 0 && h.concurrencyService != nil { @@ -227,7 +242,7 @@ func (h *UserHandler) GetByID(c *gin.Context) { return } - response.Success(c, dto.UserFromServiceAdmin(user)) + response.Success(c, adminUserResponse(c, user)) } // BindAuthIdentity manually binds a canonical auth identity to a user. @@ -407,12 +422,18 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) { UserID: userID, Body: req, } - executeAdminIdempotentJSON(c, "admin.users.balance.update", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { + executeAdminIdempotentJSONWithResponse(c, "admin.users.balance.update", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { user, execErr := h.adminService.UpdateUserBalance(ctx, userID, req.Balance, req.Operation, req.Notes) if execErr != nil { return nil, execErr } return dto.UserFromServiceAdmin(user), nil + }, func(data any) any { + // 成功确认不隐式授予用户资料或余额读取权限;缓存重放也必须经过此边界。 + if adminaccess.FromContext(c.Request.Context()) != nil { + return gin.H{"id": userID} + } + return data }) } @@ -884,6 +905,10 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) { } // 返回最新状态 + if scopedAdminWriteOnly(c, "users.quotas.read") { + response.Success(c, gin.H{"id": userID}) + return + } now := time.Now().UTC() records2, err := h.userPlatformQuotaRepo.ListByUser(ctx, userID) if err != nil { @@ -966,6 +991,11 @@ func (h *UserHandler) ResetUserPlatformQuotaWindow(c *gin.Context) { } } + if scopedAdminWriteOnly(c, "users.quotas.read") { + response.Success(c, gin.H{"id": userID}) + return + } + records, err := h.userPlatformQuotaRepo.ListByUser(ctx, userID) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/pkg/adminaccess/catalog.go b/backend/internal/pkg/adminaccess/catalog.go new file mode 100644 index 000000000000..d8dbe44b5593 --- /dev/null +++ b/backend/internal/pkg/adminaccess/catalog.go @@ -0,0 +1,106 @@ +package adminaccess + +import "slices" + +type Permission struct { + ID string `json:"id"` + Label string `json:"label"` + Category string `json:"category"` + Scope string `json:"scope"` +} + +type permissionDefinition struct { + Permission + Routes []string +} + +// 接口按方法和 Gin 完整路径精确登记;新增接口不会继承同目录权限。 +// 全站权限与账户规则独立,选中分组绝不会隐式授予全站操作。 +var definitions = []permissionDefinition{ + {Permission{"accounts.read", "查看账户(脱敏)", "账户", "account"}, []string{"GET /accounts", "GET /accounts/:id", "GET /accounts/:id/models"}}, + {Permission{"accounts.create", "创建账户", "账户", "account"}, []string{"POST /accounts", "POST /accounts/import/codex-session"}}, + {Permission{"accounts.update", "编辑名称与备注", "账户", "account"}, []string{"PUT /accounts/:id"}}, + {Permission{"accounts.credentials", "写入凭证(不包含读取)", "账户", "account"}, []string{"PUT /accounts/:id"}}, + {Permission{"accounts.models.write", "编辑账户白名单模型 ID 与映射", "账户", "account"}, []string{"PUT /accounts/:id", "POST /accounts/bulk-update"}}, + {Permission{"accounts.network", "修改接入地址、代理与连接选项", "账户", "account"}, []string{"PUT /accounts/:id"}}, + {Permission{"accounts.status", "启用、停用与调度开关", "账户", "account"}, []string{"PUT /accounts/:id", "POST /accounts/:id/schedulable"}}, + {Permission{"accounts.schedule", "修改并发、优先级与有效期", "账户", "account"}, []string{"PUT /accounts/:id"}}, + {Permission{"accounts.refresh", "刷新 OAuth 凭证", "账户", "account"}, []string{"POST /accounts/:id/refresh"}}, + {Permission{"accounts.recover", "清除账户错误与限流状态", "账户", "account"}, []string{"POST /accounts/:id/clear-error"}}, + {Permission{"accounts.delete", "删除账户(影响共享分组)", "账户", "account"}, []string{"DELETE /accounts/:id"}}, + {Permission{"accounts.groups", "调整授权分组关联", "账户", "account"}, []string{"PUT /accounts/:id"}}, + {Permission{"groups.models.read", "读取所选分组模型白名单与候选 ID", "分组模型", "group"}, []string{"GET /groups", "GET /groups/all", "GET /groups/:id", "GET /groups/:id/model-allowlist-candidates"}}, + {Permission{"groups.models.write", "编辑所选分组模型白名单(影响整个分组)", "分组模型", "group"}, []string{"PUT /groups/:id"}}, + {Permission{"proxies.read", "读取代理 IP 候选(分页,不含认证凭证)", "代理", "global"}, []string{"GET /proxies", "GET /proxies/all", "GET /proxies/:id"}}, + {Permission{"models.defaults.read", "读取默认白名单模型 ID(含默认映射)", "模型", "global"}, []string{"GET /accounts/antigravity/default-model-mapping", "GET /groups/:id/model-allowlist-candidates"}}, + {Permission{"dashboard.read", "读取全站经营报表", "报表", "global"}, []string{"GET /dashboard/snapshot-v2", "GET /dashboard/stats", "GET /dashboard/realtime", "GET /dashboard/trend", "GET /dashboard/models", "GET /dashboard/groups", "GET /dashboard/api-keys-trend", "GET /dashboard/users-trend", "GET /dashboard/users-ranking", "POST /dashboard/users-usage", "POST /dashboard/api-keys-usage", "GET /dashboard/user-breakdown"}}, + {Permission{"users.read", "读取全站用户资料(含联系方式)", "用户", "global"}, []string{"GET /users", "GET /users/:id", "GET /users/:id/rpm-status", "GET /users/:id/attributes"}}, + {Permission{"users.balance.read", "读取全站用户余额及流水", "财务", "global"}, []string{"GET /users/:id/balance-history"}}, + {Permission{"users.balance.write", "增减任意用户余额", "财务", "global"}, []string{"POST /users/:id/balance"}}, + {Permission{"users.quotas.read", "读取全站用户平台额度", "用户", "global"}, []string{"GET /users/:id/platform-quotas"}}, + {Permission{"users.quotas.write", "修改与重置全站用户平台额度", "用户", "global"}, []string{"PUT /users/:id/platform-quotas", "POST /users/:id/platform-quotas/reset"}}, + {Permission{"groups.read", "读取所有分组配置", "分组", "global"}, []string{"GET /groups", "GET /groups/all", "GET /groups/:id", "GET /groups/:id/rate-multipliers", "GET /groups/:id/composite-routes", "GET /groups/:id/model-allowlist-candidates"}}, + {Permission{"usage.read", "读取全站用量明细及用户用量", "报表", "global"}, []string{"GET /usage", "GET /usage/stats", "GET /users/:id/usage"}}, + {Permission{"payment.read", "读取全站支付订单与报表", "财务", "global"}, []string{"GET /payment/dashboard", "GET /payment/orders", "GET /payment/orders/:id"}}, + {Permission{"payment.cancel", "取消支付订单", "财务", "global"}, []string{"POST /payment/orders/:id/cancel"}}, + {Permission{"payment.fulfill", "重新履约支付订单", "财务", "global"}, []string{"POST /payment/orders/:id/retry"}}, + {Permission{"payment.refund", "发起退款及确认退款结果", "财务", "global"}, []string{"POST /payment/orders/:id/refund", "POST /payment/orders/:id/refund/query"}}, + {Permission{"payment.plans.read", "读取全部支付套餐", "财务", "global"}, []string{"GET /payment/plans"}}, + {Permission{"payment.plans.write", "创建、修改和删除支付套餐", "财务", "global"}, []string{"POST /payment/plans", "PUT /payment/plans/:id", "DELETE /payment/plans/:id"}}, + {Permission{"subscriptions.read", "读取全站订阅记录", "订阅", "global"}, []string{"GET /subscriptions", "GET /subscriptions/:id", "GET /subscriptions/:id/progress", "GET /groups/:id/subscriptions", "GET /users/:id/subscriptions"}}, + {Permission{"subscriptions.assign", "分配全站用户订阅", "订阅", "global"}, []string{"POST /subscriptions/assign", "POST /subscriptions/bulk-assign"}}, + {Permission{"subscriptions.extend", "延长订阅有效期", "订阅", "global"}, []string{"POST /subscriptions/:id/extend"}}, + {Permission{"subscriptions.revoke", "撤销订阅", "订阅", "global"}, []string{"POST /subscriptions/:id/revoke", "DELETE /subscriptions/:id"}}, + {Permission{"subscriptions.restore", "恢复订阅", "订阅", "global"}, []string{"POST /subscriptions/:id/restore"}}, + {Permission{"subscriptions.reset", "重置订阅额度", "订阅", "global"}, []string{"POST /subscriptions/:id/reset-quota"}}, + {Permission{"announcements.read", "读取所有公告与阅读记录", "内容", "global"}, []string{"GET /announcements", "GET /announcements/:id", "GET /announcements/:id/read-status"}}, + {Permission{"announcements.write", "创建、发布与修改公告", "内容", "global"}, []string{"POST /announcements", "PUT /announcements/:id"}}, + {Permission{"announcements.delete", "删除公告", "内容", "global"}, []string{"DELETE /announcements/:id"}}, + {Permission{"promo.read", "读取优惠码及使用记录", "财务", "global"}, []string{"GET /promo-codes", "GET /promo-codes/:id", "GET /promo-codes/:id/usages"}}, + {Permission{"promo.write", "创建与修改优惠码权益", "财务", "global"}, []string{"POST /promo-codes", "PUT /promo-codes/:id"}}, + {Permission{"promo.delete", "删除优惠码", "财务", "global"}, []string{"DELETE /promo-codes/:id"}}, + {Permission{"ops.read", "读取全站运维指标", "监控", "global"}, []string{"GET /ops/concurrency", "GET /ops/user-concurrency", "GET /ops/account-availability", "GET /ops/realtime-traffic", "GET /ops/ingress-rejections", "GET /ops/ingress-rejections/health", "GET /ops/auth-cache-invalidation/health", "GET /ops/dashboard/snapshot-v2", "GET /ops/dashboard/overview", "GET /ops/dashboard/throughput-trend", "GET /ops/dashboard/latency-histogram", "GET /ops/dashboard/error-trend", "GET /ops/dashboard/error-distribution", "GET /ops/dashboard/openai-token-stats"}}, + {Permission{"ops.errors.read", "读取全站请求错误详情(敏感)", "监控", "global"}, []string{"GET /ops/errors", "GET /ops/errors/:id", "GET /ops/request-errors", "GET /ops/request-errors/:id", "GET /ops/request-errors/:id/upstream-errors", "GET /ops/upstream-errors", "GET /ops/upstream-errors/:id", "GET /ops/requests"}}, + {Permission{"ops.errors.resolve", "标记请求错误处理状态", "监控", "global"}, []string{"PUT /ops/errors/:id/resolve", "PUT /ops/request-errors/:id/resolve", "PUT /ops/upstream-errors/:id/resolve"}}, + {Permission{"ops.alerts.read", "读取全站告警规则与事件", "监控", "global"}, []string{"GET /ops/alert-rules", "GET /ops/alert-events", "GET /ops/alert-events/:id"}}, + {Permission{"ops.alerts.write", "修改告警规则、状态与静默", "监控", "global"}, []string{"POST /ops/alert-rules", "PUT /ops/alert-rules/:id", "DELETE /ops/alert-rules/:id", "PUT /ops/alert-events/:id/status", "POST /ops/alert-silences"}}, + {Permission{"system.read", "读取系统版本与可更新版本", "系统", "global"}, []string{"GET /system/version", "GET /system/check-updates", "GET /system/rollback-versions"}}, +} + +func PermissionCatalog() []Permission { + result := make([]Permission, 0, len(definitions)) + for _, definition := range definitions { + result = append(result, definition.Permission) + } + return result +} + +// RouteAllowed 只检查动作准入;账户资源及字段必须再由专用处理器检查。 +func RouteAllowed(policy Policy, method, path string) bool { + target := method + " " + path + for _, definition := range definitions { + matched := false + for _, route := range definition.Routes { + // route 的方法前缀与资源路径固定,不做前缀通配或按 HTTP 方法推断读写。 + for i, ch := range route { + if ch == ' ' { + matched = target == route[:i]+" /api/v1/admin"+route[i+1:] + break + } + } + if matched { + break + } + } + if !matched { + continue + } + if definition.Scope == "global" && slices.Contains(policy.GlobalPermissions, definition.ID) { + return true + } + if (definition.Scope == "account" || definition.Scope == "group") && len(policy.RulesFor(definition.ID)) > 0 { + return true + } + } + return false +} diff --git a/backend/internal/pkg/adminaccess/policy.go b/backend/internal/pkg/adminaccess/policy.go new file mode 100644 index 000000000000..2eaf07b5ed72 --- /dev/null +++ b/backend/internal/pkg/adminaccess/policy.go @@ -0,0 +1,154 @@ +// Package adminaccess 定义管理密钥的显式授权,不依赖 HTTP 或数据库实现。 +package adminaccess + +import ( + "context" + "fmt" + "slices" + "strings" +) + +type Rule struct { + Permissions []string `json:"permissions"` + GroupIDs []int64 `json:"group_ids"` + AllGroups bool `json:"all_groups"` + IncludeUngrouped bool `json:"include_ungrouped"` + AccountTypes []string `json:"account_types"` + Platforms []string `json:"platforms"` +} + +type Policy struct { + Rules []Rule `json:"rules"` + GlobalPermissions []string `json:"global_permissions"` +} + +type Principal struct { + KeyID string + KeyName string + CreatorID int64 + Policy Policy +} + +type Account struct { + ID int64 + GroupIDs []int64 + Type string + Platform string +} + +type principalContextKey struct{} + +func WithPrincipal(ctx context.Context, principal *Principal) context.Context { + return context.WithValue(ctx, principalContextKey{}, principal) +} + +func FromContext(ctx context.Context) *Principal { + principal, _ := ctx.Value(principalContextKey{}).(*Principal) + return principal +} + +// Matches 保持分组、类型和平台在同一规则中匹配,不拆开合并授权条件。 +func (r Rule) Matches(a Account) bool { + if !slices.Contains(r.AccountTypes, a.Type) || (len(r.Platforms) > 0 && !slices.Contains(r.Platforms, a.Platform)) { + return false + } + if len(a.GroupIDs) == 0 { + return r.IncludeUngrouped + } + if r.AllGroups { + return true + } + for _, id := range a.GroupIDs { + if id > 0 && slices.Contains(r.GroupIDs, id) { + return true + } + } + return false +} + +func (p Policy) RulesFor(permission string) []Rule { + rules := make([]Rule, 0) + for _, r := range p.Rules { + if slices.Contains(r.Permissions, permission) { + rules = append(rules, r) + } + } + return rules +} + +func (p Policy) AllowsAccount(permission string, a Account) bool { + for _, r := range p.RulesFor(permission) { + if r.Matches(a) { + return true + } + } + return false +} + +// AllowsGroup 只有当前账户及目标分组在同一授权规则内,才能调整该关联。 +func (p Policy) AllowsGroup(permission string, a Account, groupID int64) bool { + if groupID <= 0 { + return false + } + for _, r := range p.RulesFor(permission) { + if r.Matches(a) && (r.AllGroups || slices.Contains(r.GroupIDs, groupID)) { + return true + } + } + return false +} + +// AllowsGroupConfig 只授权显式勾选的分组配置动作;账户类型不适用于整个分组的配置。 +// 分组与平台仍须命中同一规则,不能从不同规则拼接授权。 +func (p Policy) AllowsGroupConfig(permission string, id int64, platform string) bool { + if id <= 0 { + return false + } + for _, r := range p.RulesFor(permission) { + if (r.AllGroups || slices.Contains(r.GroupIDs, id)) && (len(r.Platforms) == 0 || slices.Contains(r.Platforms, platform)) { + return true + } + } + return false +} + +func ValidatePolicy(p Policy) error { + if len(p.Rules) > 32 || len(p.GlobalPermissions) > 128 { + return fmt.Errorf("授权规则数量超过限制") + } + catalog := make(map[string]string) + for _, permission := range PermissionCatalog() { + catalog[permission.ID] = permission.Scope + } + for _, permission := range p.GlobalPermissions { + if catalog[permission] != "global" { + return fmt.Errorf("无效的全站权限:%s", permission) + } + } + for _, r := range p.Rules { + if len(r.Permissions) > 64 || len(r.GroupIDs) > 1000 || len(r.AccountTypes) > 32 || len(r.Platforms) > 64 { + return fmt.Errorf("授权范围超过限制") + } + for _, permission := range r.Permissions { + if catalog[permission] != "account" && catalog[permission] != "group" { + return fmt.Errorf("无效的资源权限:%s", permission) + } + } + for _, id := range r.GroupIDs { + if id <= 0 { + return fmt.Errorf("分组 ID 必须是正整数") + } + } + for _, kind := range r.AccountTypes { + if !slices.Contains([]string{"oauth", "setup-token", "apikey", "upstream", "bedrock", "service_account"}, kind) { + return fmt.Errorf("无效的账户类型:%s", kind) + } + } + for _, platform := range r.Platforms { + if strings.TrimSpace(platform) != platform || platform == "" || len(platform) > 64 || platform == "*" { + return fmt.Errorf("无效的平台") + } + } + } + return nil +} diff --git a/backend/internal/pkg/adminaccess/policy_test.go b/backend/internal/pkg/adminaccess/policy_test.go new file mode 100644 index 000000000000..ef415487c0bd --- /dev/null +++ b/backend/internal/pkg/adminaccess/policy_test.go @@ -0,0 +1,144 @@ +//go:build unit + +package adminaccess + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestScopedAdminPolicyDefaultDeny(t *testing.T) { + selectedGroupsOnly := Policy{Rules: []Rule{{GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}}} + for _, policy := range []Policy{{}, selectedGroupsOnly} { + for _, route := range []struct{ method, path string }{ + {"GET", "/api/v1/admin/accounts"}, {"GET", "/api/v1/admin/accounts/:id"}, + {"PUT", "/api/v1/admin/settings"}, {"GET", "/api/v1/admin/users"}, + {"POST", "/api/v1/admin/payment/orders/:id/refund"}, + {"POST", "/api/v1/admin/settings/admin-api-keys"}, + } { + require.False(t, RouteAllowed(policy, route.method, route.path), route.path) + } + } +} + +func TestScopedAdminModelPermissionsAreIndependent(t *testing.T) { + write := Policy{Rules: []Rule{{Permissions: []string{"accounts.models.write"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}}} + read := Policy{GlobalPermissions: []string{"models.defaults.read"}} + require.NoError(t, ValidatePolicy(write)) + require.NoError(t, ValidatePolicy(read)) + require.True(t, RouteAllowed(write, "PUT", "/api/v1/admin/accounts/:id")) + for _, path := range []string{"/api/v1/admin/accounts/antigravity/default-model-mapping"} { + require.True(t, RouteAllowed(read, "GET", path)) + require.False(t, RouteAllowed(write, "GET", path)) + require.False(t, RouteAllowed(Policy{}, "GET", path)) + } + for _, policy := range []Policy{write, read} { + require.False(t, RouteAllowed(policy, "PUT", "/api/v1/admin/model-catalog/:platform")) + require.False(t, RouteAllowed(policy, "DELETE", "/api/v1/admin/model-catalog/:platform")) + require.False(t, RouteAllowed(policy, "GET", "/api/v1/admin/accounts/:id/models")) + require.False(t, RouteAllowed(policy, "POST", "/api/v1/admin/accounts/:id/models/sync-upstream")) + } +} + +func TestScopedAdminPolicyAccountRulesNeverCrossProduct(t *testing.T) { + p := Policy{Rules: []Rule{ + {Permissions: []string{"accounts.read", "accounts.refresh"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}, Platforms: []string{"openai"}}, + {Permissions: []string{"accounts.read"}, GroupIDs: []int64{2}, AccountTypes: []string{"apikey"}}, + }} + for _, tc := range []struct { + name, permission string + account Account + allowed bool + }{ + {"组内OAuth", "accounts.refresh", Account{GroupIDs: []int64{1}, Type: "oauth", Platform: "openai"}, true}, + {"共享账户任一组匹配", "accounts.refresh", Account{GroupIDs: []int64{1, 9}, Type: "oauth", Platform: "openai"}, true}, + {"其他类型拒绝", "accounts.refresh", Account{GroupIDs: []int64{1}, Type: "apikey", Platform: "openai"}, false}, + {"其他组拒绝", "accounts.refresh", Account{GroupIDs: []int64{9}, Type: "oauth", Platform: "openai"}, false}, + {"其他平台拒绝", "accounts.refresh", Account{GroupIDs: []int64{1}, Type: "oauth", Platform: "anthropic"}, false}, + {"不能借另一规则提权", "accounts.refresh", Account{GroupIDs: []int64{2}, Type: "apikey", Platform: "openai"}, false}, + {"setup-token不能冒充OAuth", "accounts.read", Account{GroupIDs: []int64{1}, Type: "setup-token", Platform: "openai"}, false}, + {"未分组默认拒绝", "accounts.read", Account{Type: "oauth", Platform: "openai"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.allowed, p.AllowsAccount(tc.permission, tc.account)) }) + } + require.True(t, RouteAllowed(p, "GET", "/api/v1/admin/accounts")) + require.False(t, RouteAllowed(p, "DELETE", "/api/v1/admin/accounts/:id")) + require.False(t, RouteAllowed(p, "GET", "/api/v1/admin/groups")) + require.False(t, RouteAllowed(p, "POST", "/api/v1/admin/openai/refresh-token")) +} + +func TestScopedAdminPolicyGroupChanges(t *testing.T) { + a := Account{GroupIDs: []int64{1, 2}, Type: "oauth", Platform: "openai"} + p := Policy{Rules: []Rule{{Permissions: []string{"accounts.groups"}, GroupIDs: []int64{1, 3}, AccountTypes: []string{"oauth"}}}} + require.True(t, p.AllowsGroup("accounts.groups", a, 1)) + require.True(t, p.AllowsGroup("accounts.groups", a, 3)) + require.False(t, p.AllowsGroup("accounts.groups", a, 2)) + require.False(t, p.AllowsGroup("accounts.groups", Account{GroupIDs: []int64{2}, Type: "oauth"}, 1)) + require.False(t, p.AllowsGroup("accounts.groups", a, 0)) +} + +func TestScopedAdminPolicyExplicitAllAndUngrouped(t *testing.T) { + p := Policy{Rules: []Rule{{Permissions: []string{"accounts.read"}, AllGroups: true, AccountTypes: []string{"oauth"}}}} + require.True(t, p.AllowsAccount("accounts.read", Account{GroupIDs: []int64{999}, Type: "oauth"})) + require.False(t, p.AllowsAccount("accounts.read", Account{Type: "oauth"})) + p.Rules[0].IncludeUngrouped = true + require.True(t, p.AllowsAccount("accounts.read", Account{Type: "oauth"})) + p.Rules[0].AccountTypes = nil + require.False(t, p.AllowsAccount("accounts.read", Account{Type: "oauth"})) +} + +func TestScopedAdminPolicyValidationAndGlobalIsolation(t *testing.T) { + require.NoError(t, ValidatePolicy(Policy{})) + for _, p := range []Policy{ + {GlobalPermissions: []string{"*"}}, {GlobalPermissions: []string{"accounts.read"}}, + {Rules: []Rule{{Permissions: []string{"users.read"}}}}, + {Rules: []Rule{{Permissions: []string{"accounts.read"}, GroupIDs: []int64{-1}}}}, + {Rules: []Rule{{AccountTypes: []string{"future_type"}}}}, + } { + require.Error(t, ValidatePolicy(p)) + } + p := Policy{GlobalPermissions: []string{"users.read"}} + require.NoError(t, ValidatePolicy(p)) + require.True(t, RouteAllowed(p, "GET", "/api/v1/admin/users")) + require.False(t, RouteAllowed(p, "PUT", "/api/v1/admin/users/:id")) + require.False(t, RouteAllowed(p, "POST", "/api/v1/admin/users/:id/balance")) + require.False(t, RouteAllowed(p, "GET", "/api/v1/admin/accounts/:id")) + all := Policy{} + for _, perm := range PermissionCatalog() { + if perm.Scope == "global" { + all.GlobalPermissions = append(all.GlobalPermissions, perm.ID) + } else { + all.Rules = append(all.Rules, Rule{Permissions: []string{perm.ID}, AllGroups: true, IncludeUngrouped: true, AccountTypes: []string{"oauth", "apikey"}}) + } + } + for _, path := range []string{"/api/v1/admin/future-endpoint", "/api/v1/admin/settings/admin-api-key/regenerate", "/api/v1/admin/settings/admin-api-keys", "/api/v1/admin/system/restart", "/api/v1/admin/accounts/data", "/api/v1/admin/backups/:id/download-url"} { + for _, method := range []string{"GET", "POST", "PUT", "DELETE"} { + require.False(t, RouteAllowed(all, method, path), method+" "+path) + } + } +} + +func TestScopedAdminPrincipalContext(t *testing.T) { + require.Nil(t, FromContext(context.Background())) + p := &Principal{KeyID: "key-1", KeyName: "只读"} + require.Same(t, p, FromContext(WithPrincipal(context.Background(), p))) +} + +func TestScopedAdminGroupModelsAreExplicitAndScoped(t *testing.T) { + p := Policy{Rules: []Rule{ + {Permissions: []string{"groups.models.read"}, GroupIDs: []int64{2}, Platforms: []string{"openai"}}, + {Permissions: []string{"groups.models.write"}, GroupIDs: []int64{3}, Platforms: []string{"anthropic"}}, + }} + require.NoError(t, ValidatePolicy(p)) + require.True(t, p.AllowsGroupConfig("groups.models.read", 2, "openai")) + require.False(t, p.AllowsGroupConfig("groups.models.write", 2, "openai")) + require.False(t, p.AllowsGroupConfig("groups.models.read", 3, "openai")) + require.False(t, p.AllowsGroupConfig("groups.models.read", 2, "anthropic")) + require.False(t, p.AllowsGroupConfig("groups.models.read", 0, "openai")) + require.False(t, p.AllowsAccount("accounts.read", Account{GroupIDs: []int64{2}, Type: "oauth", Platform: "openai"})) + require.True(t, RouteAllowed(p, "GET", "/api/v1/admin/groups")) + require.False(t, RouteAllowed(p, "DELETE", "/api/v1/admin/groups/:id")) + require.Error(t, ValidatePolicy(Policy{GlobalPermissions: []string{"groups.models.write"}})) +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 781928aea280..d3f8b5d9a92f 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -25,6 +25,7 @@ import ( dbgroup "github.com/Wei-Shaw/sub2api/ent/group" dbpredicate "github.com/Wei-Shaw/sub2api/ent/predicate" dbproxy "github.com/Wei-Shaw/sub2api/ent/proxy" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" @@ -125,6 +126,9 @@ func newAccountRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor, schedul } func (r *accountRepository) Create(ctx context.Context, account *service.Account) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.Create(ctx, account) + } if err := createAccountRecord(ctx, r.client, account); err != nil { return err } @@ -263,6 +267,9 @@ func (r *accountRepository) CreateWithAccountGroups(ctx context.Context, account } func (r *accountRepository) GetByID(ctx context.Context, id int64) (*service.Account, error) { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.GetByID(ctx, id) + } m, err := r.client.Account.Query().Where(dbaccount.IDEQ(id)).Only(ctx) if err != nil { return nil, translatePersistenceError(err, service.ErrAccountNotFound, nil) @@ -439,6 +446,9 @@ func (r *accountRepository) ListCRSAccountIDs(ctx context.Context) (map[string]i } func (r *accountRepository) Update(ctx context.Context, account *service.Account) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.Update(ctx, account) + } return r.updateAccount(ctx, account, nil, nil, account.RateMultiplier) } @@ -958,6 +968,9 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre } func (r *accountRepository) Delete(ctx context.Context, id int64) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.Delete(ctx, id) + } groupIDs, err := r.loadAccountGroupIDs(ctx, id) if err != nil { return err @@ -1846,6 +1859,9 @@ func (r *accountRepository) syncSchedulerAccountSnapshots(ctx context.Context, a } func (r *accountRepository) ClearError(ctx context.Context, id int64) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.ClearError(ctx, id) + } _, err := r.client.Account.Update(). Where(dbaccount.IDEQ(id)). SetStatus(service.StatusActive). @@ -1929,6 +1945,12 @@ func (r *accountRepository) GetGroups(ctx context.Context, accountID int64) ([]s } func (r *accountRepository) BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.BindGroups(ctx, accountID, groupIDs) + } + if adminaccess.FromContext(ctx) != nil { + return r.scopedAdminBindGroups(ctx, accountID, groupIDs) + } existingGroupIDs, err := r.loadAccountGroupIDs(ctx, accountID) if err != nil { return err @@ -2551,6 +2573,9 @@ func (r *accountRepository) SetGrokCredentialTempUnschedulableIfMatch( } func (r *accountRepository) ClearTempUnschedulable(ctx context.Context, id int64) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.ClearTempUnschedulable(ctx, id) + } _, err := r.sql.ExecContext(ctx, ` UPDATE accounts SET temp_unschedulable_until = NULL, @@ -2570,6 +2595,9 @@ func (r *accountRepository) ClearTempUnschedulable(ctx context.Context, id int64 } func (r *accountRepository) ClearRateLimit(ctx context.Context, id int64) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.ClearRateLimit(ctx, id) + } _, err := r.client.Account.Update(). Where(dbaccount.IDEQ(id)). ClearRateLimitedAt(). @@ -2587,6 +2615,9 @@ func (r *accountRepository) ClearRateLimit(ctx context.Context, id int64) error } func (r *accountRepository) ClearAntigravityQuotaScopes(ctx context.Context, id int64) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.ClearAntigravityQuotaScopes(ctx, id) + } client := clientFromContext(ctx, r.client) result, err := client.ExecContext( ctx, @@ -2611,6 +2642,9 @@ func (r *accountRepository) ClearAntigravityQuotaScopes(ctx context.Context, id } func (r *accountRepository) ClearModelRateLimits(ctx context.Context, id int64) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.ClearModelRateLimits(ctx, id) + } client := clientFromContext(ctx, r.client) result, err := client.ExecContext( ctx, @@ -2673,6 +2707,9 @@ func (r *accountRepository) UpdateSessionWindowEnd(ctx context.Context, id int64 } func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedulable bool) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.SetSchedulable(ctx, id, schedulable) + } _, err := r.client.Account.Update(). Where(dbaccount.IDEQ(id)). SetSchedulable(schedulable). @@ -2731,6 +2768,9 @@ func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now ti } func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.UpdateExtra(ctx, id, updates) + } updates = stripCodexFingerprintSeedFromExtraUpdate(updates) if len(updates) == 0 { return nil @@ -4072,6 +4112,9 @@ func (r *accountRepository) RevertProxyFallback(ctx context.Context, accountID i // ⚠️ 新增影子维度时:须更新此函数(或新增维度专用列举),并检查所有调用点(级联删除/一母一影校验/type 守卫),否则会静默漏掉新维度。 // 软删除行由 SoftDeleteMixin 拦截器自动排除,无需手写 deleted_at IS NULL。 func (r *accountRepository) ListShadowsByParent(ctx context.Context, parentID int64) ([]*service.Account, error) { + if scoped := r.accountTransactionScope(ctx); scoped != r { + return scoped.ListShadowsByParent(ctx, parentID) + } rows, err := r.client.Account.Query(). Where(dbaccount.ParentAccountIDEQ(parentID), dbaccount.QuotaDimensionEQ(dbaccount.QuotaDimensionSpark)). All(ctx) diff --git a/backend/internal/repository/custom_account_proxy_selection.go b/backend/internal/repository/custom_account_proxy_selection.go new file mode 100644 index 000000000000..c4d27de49456 --- /dev/null +++ b/backend/internal/repository/custom_account_proxy_selection.go @@ -0,0 +1,46 @@ +package repository + +import ( + "context" + "fmt" + + dbent "github.com/Wei-Shaw/sub2api/ent" +) + +// SelectAccountProxy 在数据库内选出单个 ID,不把整个代理池或密码载入应用。 +// 最空闲选择持有事务级锁到绑定提交,防止并发推送全部观察到同一份空闲计数。 +func (r *proxyRepository) SelectAccountProxy(ctx context.Context, mode string) (int64, error) { + tx := dbent.TxFromContext(ctx) + if tx == nil { + return 0, fmt.Errorf("代理选择需要写事务") + } + client := tx.Client() + query := `SELECT p.id FROM proxies p WHERE p.deleted_at IS NULL AND p.status='active' AND (p.expires_at IS NULL OR p.expires_at>NOW()) ORDER BY random() LIMIT 1` + switch mode { + case "random": + case "least_used": + if _, err := client.ExecContext(ctx, `SELECT pg_advisory_xact_lock(742319830125)`); err != nil { + return 0, err + } + query = `WITH bindings AS ( + SELECT a.id AS account_id,a.proxy_id FROM accounts a WHERE a.deleted_at IS NULL AND a.proxy_id IS NOT NULL + ), counts AS (SELECT proxy_id,COUNT(*) AS n FROM bindings GROUP BY proxy_id) + SELECT p.id FROM proxies p LEFT JOIN counts c ON c.proxy_id=p.id + WHERE p.deleted_at IS NULL AND p.status='active' AND (p.expires_at IS NULL OR p.expires_at>NOW()) + ORDER BY COALESCE(c.n,0),p.id LIMIT 1` + default: + return 0, fmt.Errorf("不支持的代理选择模式") + } + rows, err := client.QueryContext(ctx, query) + if err != nil { + return 0, err + } + defer func() { _ = rows.Close() }() + var id int64 + if rows.Next() { + if err = rows.Scan(&id); err != nil { + return 0, err + } + } + return id, rows.Err() +} diff --git a/backend/internal/repository/custom_account_proxy_selection_test.go b/backend/internal/repository/custom_account_proxy_selection_test.go new file mode 100644 index 000000000000..29317f9a93cd --- /dev/null +++ b/backend/internal/repository/custom_account_proxy_selection_test.go @@ -0,0 +1,129 @@ +//go:build unit + +package repository + +import ( + "context" + "testing" + "time" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/stretchr/testify/require" +) + +func TestAccountProxySelectionLargePoolAndTransactionalLeastUsed(t *testing.T) { + db := scopedAdminKeyTestDB(t) + _, err := db.Exec(`CREATE TABLE proxies( + id BIGINT PRIMARY KEY,name TEXT DEFAULT '',protocol TEXT DEFAULT 'http',host TEXT DEFAULT '192.0.2.1',port INT DEFAULT 8080, + username TEXT DEFAULT 'SECRET_USER',password TEXT DEFAULT 'SECRET_PASSWORD',status TEXT DEFAULT 'active', + expires_at TIMESTAMPTZ,deleted_at TIMESTAMPTZ,created_at TIMESTAMPTZ DEFAULT NOW(),updated_at TIMESTAMPTZ DEFAULT NOW(), + fallback_mode TEXT DEFAULT 'none',backup_proxy_id BIGINT,expiry_warn_days INT DEFAULT 7); + CREATE TABLE accounts(id BIGINT PRIMARY KEY,proxy_id BIGINT,deleted_at TIMESTAMPTZ); + INSERT INTO proxies(id) SELECT generate_series(1,20005); + UPDATE proxies SET status='inactive' WHERE id=1; + UPDATE proxies SET status='inactive' WHERE id=2; + UPDATE proxies SET expires_at=NOW()-INTERVAL '1 hour' WHERE id=3; + UPDATE proxies SET deleted_at=NOW() WHERE id=4; + INSERT INTO accounts VALUES(1,5,NULL),(2,6,NULL),(3,7,NULL),(4,8,NOW()); +`) + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + repo := newProxyRepositoryWithSQL(client, db) + // 真实 2 万条池只返回指定页;不用账户聚合管理列表。 + items, page, err := repo.ListOptions(context.Background(), pagination.PaginationParams{Page: 2, PageSize: 100}, "") + require.NoError(t, err) + require.Len(t, items, 100) + require.EqualValues(t, 20002, page.Total) + require.EqualValues(t, 19905, items[0].ID) + _, err = repo.SelectAccountProxy(context.Background(), "random") + require.Error(t, err) + tx, err := client.Tx(context.Background()) + require.NoError(t, err) + defer tx.Rollback() + ctx := dbent.NewTxContext(context.Background(), tx) + selected, err := repo.SelectAccountProxy(ctx, "least_used") + require.NoError(t, err) + require.EqualValues(t, 8, selected, "软删账户不占用") + _, err = tx.Client().ExecContext(ctx, `INSERT INTO accounts VALUES(10,$1,NULL)`, selected) + require.NoError(t, err) + // 第二个事务必须等待第一个事务绑定提交,随后观察到新的计数。 + started := make(chan struct{}) + done := make(chan struct { + id int64 + err error + }, 1) + go func() { + other, err := client.Tx(context.Background()) + if err != nil { + done <- struct { + id int64 + err error + }{0, err} + return + } + defer other.Rollback() + c, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + c = dbent.NewTxContext(c, other) + close(started) + id, err := repo.SelectAccountProxy(c, "least_used") + done <- struct { + id int64 + err error + }{id, err} + }() + <-started + select { + case result := <-done: + t.Fatalf("选择不应越过尚未提交的分配: %+v", result) + case <-time.After(30 * time.Millisecond): + } + require.NoError(t, tx.Commit()) + result := <-done + require.NoError(t, result.err) + require.EqualValues(t, 9, result.id) + // 候选查询不能通过用户名搜索探测认证字段。 + scopedCtx := adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{Policy: adminaccess.Policy{GlobalPermissions: []string{"proxies.read"}}}) + matches, meta, err := repo.ListOptions(scopedCtx, pagination.PaginationParams{Page: 1, PageSize: 100}, "SECRET_USER") + require.NoError(t, err) + require.Empty(t, matches) + require.Zero(t, meta.Total) + matches, meta, err = repo.ListOptions(context.Background(), pagination.PaginationParams{Page: 1, PageSize: 100}, "SECRET_USER") + require.NoError(t, err) + require.Empty(t, matches) + require.Zero(t, meta.Total) + // 限定唯一可用候选,确定性验证随机排除过期、停用及已删除。 + _, err = db.Exec(`UPDATE proxies SET status='inactive' WHERE id>9;UPDATE proxies SET status='inactive' WHERE id IN (5,6,7,8)`) + require.NoError(t, err) + tx, err = client.Tx(context.Background()) + require.NoError(t, err) + ctx = dbent.NewTxContext(context.Background(), tx) + selected, err = repo.SelectAccountProxy(ctx, "random") + require.NoError(t, err) + require.EqualValues(t, 9, selected) + require.NoError(t, tx.Rollback()) + // 绑定数相同时按 ID 稳定选择。 + _, err = db.Exec(`UPDATE proxies SET status='active' WHERE id IN(5,6,7);UPDATE proxies SET status='inactive' WHERE id=9`) + require.NoError(t, err) + tx, err = client.Tx(context.Background()) + require.NoError(t, err) + ctx = dbent.NewTxContext(context.Background(), tx) + selected, err = repo.SelectAccountProxy(ctx, "least_used") + require.NoError(t, err) + require.EqualValues(t, 5, selected) + require.NoError(t, tx.Rollback()) + _, err = db.Exec(`UPDATE proxies SET status='inactive'`) + require.NoError(t, err) + tx, err = client.Tx(context.Background()) + require.NoError(t, err) + ctx = dbent.NewTxContext(context.Background(), tx) + selected, err = repo.SelectAccountProxy(ctx, "random") + require.NoError(t, err) + require.Zero(t, selected) + require.NoError(t, tx.Rollback()) + require.NoError(t, db.Ping(), "测试结束连接可用") +} diff --git a/backend/internal/repository/custom_admin_key_group_models.go b/backend/internal/repository/custom_admin_key_group_models.go new file mode 100644 index 000000000000..f7d219f987e5 --- /dev/null +++ b/backend/internal/repository/custom_admin_key_group_models.go @@ -0,0 +1,98 @@ +package repository + +import ( + "context" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/group" + "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func (r *groupRepository) ScopedAdminUpdateGroupModels(ctx context.Context, id int64, cfg service.GroupModelAllowlist) (*service.Group, error) { + p := adminaccess.FromContext(ctx) + if p == nil { + return nil, infraerrors.Forbidden("SCOPED_GROUP_MODELS_FORBIDDEN", "缺少受限身份") + } + tx, err := r.client.Tx(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + txctx := dbent.NewTxContext(ctx, tx) + row, err := tx.Client().Group.Query().Where(group.IDEQ(id), group.DeletedAtIsNil()). + Select(group.FieldID, group.FieldName, group.FieldPlatform, group.FieldModelAllowlist).ForUpdate().Only(txctx) + if err != nil { + return nil, translatePersistenceError(err, service.ErrGroupNotFound, nil) + } + if !p.Policy.AllowsGroupConfig("groups.models.write", row.ID, row.Platform) { + return nil, infraerrors.NotFound("SCOPED_GROUP_NOT_FOUND", "分组不存在") + } + // 只写这一个业务字段,避免完整分组 Update 覆盖并发变化或修改费率等敏感配置。 + if _, err = tx.Client().Group.Update().Where(group.IDEQ(id), group.DeletedAtIsNil()).SetModelAllowlist(service.DomainGroupModelAllowlist(cfg)).SetUpdatedAt(time.Now()).Save(txctx); err != nil { + return nil, err + } + if err = enqueueSchedulerOutbox(txctx, tx.Client(), service.SchedulerOutboxEventGroupChanged, nil, &id, nil); err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return &service.Group{ID: row.ID, Name: row.Name, Platform: row.Platform, ModelAllowlist: cfg}, nil +} + +// ScopedAdminListGroupModels 在数据库内过滤范围,分页和总数均只包含已授权分组。 +func (r *groupRepository) ScopedAdminListGroupModels(ctx context.Context, params pagination.PaginationParams, all bool, platform, status, search string) ([]service.Group, int64, error) { + p := adminaccess.FromContext(ctx) + if p == nil { + return nil, 0, infraerrors.Forbidden("SCOPED_GROUP_MODELS_FORBIDDEN", "缺少受限身份") + } + alternatives := []predicate.Group{} + for _, rule := range p.Policy.RulesFor("groups.models.read") { + terms := []predicate.Group{group.IDGT(0)} + if !rule.AllGroups { + if len(rule.GroupIDs) == 0 { + continue + } + terms = append(terms, group.IDIn(rule.GroupIDs...)) + } + if len(rule.Platforms) > 0 { + terms = append(terms, group.PlatformIn(rule.Platforms...)) + } + alternatives = append(alternatives, group.And(terms...)) + } + if len(alternatives) == 0 { + return []service.Group{}, 0, nil + } + q := r.client.Group.Query().Where(group.DeletedAtIsNil(), group.Or(alternatives...)) + if platform != "" { + q.Where(group.PlatformEQ(platform)) + } + if status != "" { + q.Where(group.StatusEQ(status)) + } + if search != "" { + q.Where(group.NameContainsFold(search)) + } + total, err := q.Clone().Count(ctx) + if err != nil { + return nil, 0, err + } + q.Select(group.FieldID, group.FieldName, group.FieldPlatform, group.FieldModelAllowlist).Order(group.ByID()) + if !all { + q.Offset(params.Offset()).Limit(params.Limit()) + } + rows, err := q.All(ctx) + if err != nil { + return nil, 0, err + } + result := make([]service.Group, 0, len(rows)) + for _, row := range rows { + result = append(result, service.Group{ID: row.ID, Name: row.Name, Platform: row.Platform, ModelAllowlist: service.GroupModelAllowlistFromDomain(row.ModelAllowlist)}) + } + return result, int64(total), nil +} diff --git a/backend/internal/repository/custom_admin_key_group_models_test.go b/backend/internal/repository/custom_admin_key_group_models_test.go new file mode 100644 index 000000000000..71554d06964a --- /dev/null +++ b/backend/internal/repository/custom_admin_key_group_models_test.go @@ -0,0 +1,105 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "fmt" + "os" + "testing" + "time" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminGroupModelsPostgresScopeAndAtomicWrite(t *testing.T) { + dsn := os.Getenv("SCOPED_ADMIN_TEST_DSN") + if dsn == "" { + t.Skip("需要独立 SCOPED_ADMIN_TEST_DSN") + } + base, err := sql.Open("postgres", dsn) + require.NoError(t, err) + schema := fmt.Sprintf("scoped_models_test_%d", time.Now().UnixNano()) + _, err = base.Exec(`CREATE SCHEMA ` + schema) + require.NoError(t, err) + db, err := sql.Open("postgres", dsn+" search_path="+schema) + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { + _ = client.Close() + _, _ = base.Exec(`DROP SCHEMA ` + schema + ` CASCADE`) + _ = base.Close() + }) + _, err = db.Exec(`CREATE TABLE groups(id BIGINT PRIMARY KEY, name TEXT DEFAULT '模型组', platform TEXT DEFAULT 'openai', + model_allowlist JSONB DEFAULT '{}', rate_multiplier NUMERIC DEFAULT 9, status TEXT DEFAULT 'active', updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ); + CREATE TABLE scheduler_outbox(id BIGSERIAL PRIMARY KEY, event_type TEXT, account_id BIGINT, group_id BIGINT, payload JSONB, dedup_key TEXT); + CREATE UNIQUE INDEX outbox_dedup ON scheduler_outbox(dedup_key) WHERE dedup_key IS NOT NULL; + INSERT INTO groups(id,platform,deleted_at) VALUES (2,'openai',NULL),(3,'openai',NULL),(4,'anthropic',NULL),(5,'openai',NOW());`) + require.NoError(t, err) + repo := newGroupRepositoryWithSQL(client, db) + principal := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"groups.models.write"}, GroupIDs: []int64{2, 4, 5}, Platforms: []string{"openai"}}}}} + ctx := adminaccess.WithPrincipal(context.Background(), principal) + // 列表范围与总数必须一起过滤,不能将不同规则的平台和分组交叉拼接。 + readPrincipal := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{ + {Permissions: []string{"groups.models.read"}, GroupIDs: []int64{2, 4, 5}, Platforms: []string{"openai"}}, + {Permissions: []string{"groups.models.read"}, GroupIDs: []int64{3}, Platforms: []string{"anthropic"}}, + }}} + readctx := adminaccess.WithPrincipal(context.Background(), readPrincipal) + params := pagination.PaginationParams{Page: 1, PageSize: 1} + rows, total, err := repo.ScopedAdminListGroupModels(readctx, params, false, "", "", "") + require.NoError(t, err) + require.Equal(t, int64(1), total) + require.Len(t, rows, 1) + require.Equal(t, int64(2), rows[0].ID) + rows, total, err = repo.ScopedAdminListGroupModels(readctx, pagination.PaginationParams{Page: 2, PageSize: 1}, false, "", "", "") + require.NoError(t, err) + require.Equal(t, int64(1), total) + require.Empty(t, rows) + for _, filter := range []struct{ platform, status, search string }{{"anthropic", "", ""}, {"", "inactive", ""}, {"", "", "未匹配"}} { + rows, total, err = repo.ScopedAdminListGroupModels(readctx, params, true, filter.platform, filter.status, filter.search) + require.NoError(t, err) + require.Zero(t, total) + require.Empty(t, rows) + } + rows, total, err = repo.ScopedAdminListGroupModels(ctx, params, true, "", "", "") + require.NoError(t, err) + require.Zero(t, total) + require.Empty(t, rows, "只写权限不能读取列表") + _, _, err = repo.ScopedAdminListGroupModels(context.Background(), params, true, "", "", "") + require.Equal(t, 403, infraerrors.Code(err)) + cfg := service.GroupModelAllowlist{Enabled: true, Models: []string{"custom-model", "other-model"}} + for _, id := range []int64{3, 4, 5, 99} { + _, err = repo.ScopedAdminUpdateGroupModels(ctx, id, cfg) + require.Equal(t, 404, infraerrors.Code(err)) + } + _, err = repo.ScopedAdminUpdateGroupModels(context.Background(), 2, cfg) + require.Equal(t, 403, infraerrors.Code(err)) + updated, err := repo.ScopedAdminUpdateGroupModels(ctx, 2, cfg) + require.NoError(t, err) + require.Equal(t, cfg, updated.ModelAllowlist) + var configJSON, status string + var rate float64 + require.NoError(t, db.QueryRow(`SELECT model_allowlist::text,rate_multiplier,status FROM groups WHERE id=2`).Scan(&configJSON, &rate, &status)) + require.JSONEq(t, `{"enabled":true,"models":["custom-model","other-model"]}`, configJSON) + require.Equal(t, 9.0, rate) + require.Equal(t, "active", status) + var events int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM scheduler_outbox WHERE group_id=2`).Scan(&events)) + require.Equal(t, 1, events) + // outbox 写入失败必须一起回滚模型配置。 + _, err = db.Exec(`DROP TABLE scheduler_outbox`) + require.NoError(t, err) + _, err = repo.ScopedAdminUpdateGroupModels(ctx, 2, service.GroupModelAllowlist{Models: []string{"must-rollback"}}) + require.Error(t, err) + var after string + require.NoError(t, db.QueryRow(`SELECT model_allowlist::text FROM groups WHERE id=2`).Scan(&after)) + require.JSONEq(t, configJSON, after) +} diff --git a/backend/internal/repository/custom_admin_key_review_test.go b/backend/internal/repository/custom_admin_key_review_test.go new file mode 100644 index 000000000000..fabf36b07392 --- /dev/null +++ b/backend/internal/repository/custom_admin_key_review_test.go @@ -0,0 +1,225 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "sync" + "testing" + "time" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/schema" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/migrate" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminReviewGroupPriorityAndStatus(t *testing.T) { + dsn := os.Getenv("SCOPED_ADMIN_TEST_DSN") + if dsn == "" { + t.Skip("需要独立 SCOPED_ADMIN_TEST_DSN") + } + base, err := sql.Open("postgres", dsn) + require.NoError(t, err) + name := fmt.Sprintf("scoped_review_%d", time.Now().UnixNano()) + _, err = base.Exec(`CREATE SCHEMA ` + name) + require.NoError(t, err) + db, err := sql.Open("postgres", dsn+" search_path="+name) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close(); _, _ = base.Exec(`DROP SCHEMA ` + name + ` CASCADE`); _ = base.Close() }) + driver := entsql.OpenDB(dialect.Postgres, db) + client := dbent.NewClient(dbent.Driver(driver)) + require.NoError(t, migrate.Create(context.Background(), migrate.NewSchema(driver), []*schema.Table{migrate.ProxiesTable, migrate.AccountsTable, migrate.GroupsTable, migrate.AccountGroupsTable}, migrate.WithForeignKeys(false))) + _, err = db.Exec(`CREATE TABLE scheduler_outbox(id BIGSERIAL PRIMARY KEY,event_type TEXT,account_id BIGINT,group_id BIGINT,payload JSONB,dedup_key TEXT,created_at TIMESTAMPTZ DEFAULT NOW());CREATE UNIQUE INDEX review_outbox_dedup ON scheduler_outbox(dedup_key) WHERE dedup_key IS NOT NULL`) + require.NoError(t, err) + cache := &scopedReviewSchedulerCache{accounts: make(map[int64]*service.Account)} + repo := NewAdminAccountRepository(client, db, cache) + groups := NewAdminGroupRepository(client, db) + admin := service.NewAdminService(nil, nil, groups, repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, client, nil, nil, nil, nil, nil, nil, nil, nil, nil) + scoped := admin.(service.ScopedAdminAccounts) + ctx := context.Background() + a, err := client.Group.Create().SetName("授权组").Save(ctx) + require.NoError(t, err) + b, err := client.Group.Create().SetName("未授权组").Save(ctx) + require.NoError(t, err) + c, err := client.Group.Create().SetName("新增授权组").Save(ctx) + require.NoError(t, err) + account, err := admin.CreateAccount(ctx, &service.CreateAccountInput{Name: "共享账户", Platform: "openai", Type: "apikey", Credentials: map[string]any{"api_key": "test"}, GroupIDs: []int64{a.ID, b.ID}, Concurrency: 1, Priority: 1}) + require.NoError(t, err) + originalTime := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + _, err = db.Exec(`UPDATE account_groups SET priority=73,created_at=$2 WHERE group_id=$1`, b.ID, originalTime) + require.NoError(t, err) + principal := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.groups", "accounts.status"}, GroupIDs: []int64{a.ID, c.ID}, AccountTypes: []string{"apikey"}}}}} + ctx = adminaccess.WithPrincipal(ctx, principal) + t.Run("非法状态不得落库", func(t *testing.T) { + for _, raw := range []string{`"garbage"`, `""`, `null`, `12`, `" active "`} { + _, err := scoped.ScopedAdminWrite(ctx, account.ID, "", map[string]json.RawMessage{"status": json.RawMessage(raw)}, nil) + require.Equal(t, 400, infraerrors.Code(err), raw) + } + stored, err := repo.GetByID(ctx, account.ID) + require.NoError(t, err) + require.Equal(t, "active", stored.Status) + for _, status := range []string{"inactive", "error", "active"} { + raw, _ := json.Marshal(status) + _, err := scoped.ScopedAdminWrite(ctx, account.ID, "", map[string]json.RawMessage{"status": raw}, nil) + require.NoError(t, err) + } + }) + t.Run("修改授权关联保留其他关联元数据", func(t *testing.T) { + for _, ids := range [][]int64{{a.ID, c.ID}, {c.ID}, {}} { + raw, _ := json.Marshal(ids) + _, err := scoped.ScopedAdminWrite(ctx, account.ID, "", map[string]json.RawMessage{"group_ids": raw}, nil) + require.NoError(t, err) + var priority int + var created time.Time + require.NoError(t, db.QueryRow(`SELECT priority,created_at FROM account_groups WHERE account_id=$1 AND group_id=$2`, account.ID, b.ID).Scan(&priority, &created)) + require.Equal(t, 73, priority, "未授权分组调度优先级不能变化") + require.True(t, originalTime.Equal(created), "未授权关联不应被重建") + } + }) + t.Run("受限创建绑定与失败回滚", func(t *testing.T) { + createCtx := adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.create", "accounts.credentials"}, GroupIDs: []int64{a.ID, 999999}, AccountTypes: []string{"apikey"}}}}}) + for _, groupID := range []int64{a.ID, 999999} { + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(fmt.Sprintf(`{"name":"创建验收-%d","platform":"openai","type":"apikey","credentials":{"api_key":"test"},"group_ids":[%d]}`, groupID, groupID)), &fields)) + created, createErr := scoped.ScopedAdminWrite(createCtx, 0, "", fields, nil) + if groupID == a.ID { + require.NoError(t, createErr) + stored, getErr := repo.GetByID(createCtx, created.ID) + require.NoError(t, getErr) + require.Equal(t, []int64{a.ID}, stored.GroupIDs) + } else { + require.Error(t, createErr) + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM accounts WHERE name=$1`, fmt.Sprintf("创建验收-%d", groupID)).Scan(&count)) + require.Zero(t, count) + } + } + }) + t.Run("删除提交后立即清缓存且失败保留缓存", func(t *testing.T) { + _, err := db.Exec(`CREATE TABLE scheduled_test_plans(account_id BIGINT); + CREATE FUNCTION reject_review_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION '模拟提交失败'; RETURN NULL; END $$; + CREATE CONSTRAINT TRIGGER reject_review_delete AFTER UPDATE ON accounts DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (NEW.name='删除回滚' AND NEW.deleted_at IS NOT NULL) EXECUTE FUNCTION reject_review_delete();`) + require.NoError(t, err) + deleteCtx := adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.delete"}, GroupIDs: []int64{a.ID}, AccountTypes: []string{"apikey"}}}}}) + for _, name := range []string{"删除回滚", "删除成功"} { + created, err := admin.CreateAccount(context.Background(), &service.CreateAccountInput{Name: name, Platform: "openai", Type: "apikey", Credentials: map[string]any{"api_key": "test"}, GroupIDs: []int64{a.ID}, Concurrency: 1, Priority: 1}) + require.NoError(t, err) + cache.accounts[created.ID] = created + cache.beforeDelete = func(id int64) { + var deleted bool + require.NoError(t, db.QueryRow(`SELECT deleted_at IS NOT NULL FROM accounts WHERE id=$1`, id).Scan(&deleted)) + require.True(t, deleted, "缓存只能在提交后删除") + } + _, err = scoped.ScopedAdminWrite(deleteCtx, created.ID, "accounts.delete", nil, nil) + if name == "删除回滚" { + require.ErrorContains(t, err, "模拟提交失败") + require.Contains(t, cache.accounts, created.ID) + _, err = repo.GetByID(context.Background(), created.ID) + require.NoError(t, err) + } else { + require.NoError(t, err) + require.NotContains(t, cache.accounts, created.ID, "返回成功前应清除已删除账户的调度快照") + require.Contains(t, cache.deleted, created.ID) + } + } + }) + +} + +type scopedRotationBarrier struct { + service.ScopedAdminKeyRepository + arrived chan struct{} + release chan struct{} +} + +func (r *scopedRotationBarrier) GetScopedAdminKey(ctx context.Context, id string) (*service.ScopedAdminKey, error) { + item, err := r.ScopedAdminKeyRepository.GetScopedAdminKey(ctx, id) + r.arrived <- struct{}{} + select { + case <-r.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return item, err +} +func TestScopedAdminReviewConcurrentRotation(t *testing.T) { + db := scopedAdminKeyTestDB(t) + repo := NewScopedAdminKeyRepository(db) + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(repo) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, item, err := settings.CreateScopedAdminKey(ctx, service.ScopedAdminKeyInput{Name: "并发轮换", Enabled: true}, 9) + require.NoError(t, err) + barrier := &scopedRotationBarrier{ScopedAdminKeyRepository: repo, arrived: make(chan struct{}, 2), release: make(chan struct{})} + settings.SetScopedAdminKeyRepository(barrier) + type result struct { + key string + err error + } + results := make(chan result, 2) + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + key, _, e := settings.RotateScopedAdminKey(ctx, item.ID) + results <- result{key, e} + }() + } + for i := 0; i < 2; i++ { + select { + case <-barrier.arrived: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + close(barrier.release) + wg.Wait() + close(results) + settings.SetScopedAdminKeyRepository(repo) + successes, conflicts := 0, 0 + for result := range results { + if result.err == nil { + successes++ + _, e := settings.AuthenticateScopedAdminKey(ctx, result.key, "192.0.2.1") + require.NoError(t, e, "成功返回的密钥必须仍有效") + } else { + conflicts++ + require.Equal(t, 409, infraerrors.Code(result.err)) + require.Empty(t, result.key) + } + } + require.Equal(t, 1, successes) + require.Equal(t, 1, conflicts) +} + +type scopedReviewSchedulerCache struct { + service.SchedulerCache + accounts map[int64]*service.Account + deleted []int64 + beforeDelete func(int64) +} + +func (c *scopedReviewSchedulerCache) SetAccount(_ context.Context, a *service.Account) error { + c.accounts[a.ID] = a + return nil +} +func (c *scopedReviewSchedulerCache) DeleteAccount(_ context.Context, id int64) error { + if c.beforeDelete != nil { + c.beforeDelete(id) + } + delete(c.accounts, id) + c.deleted = append(c.deleted, id) + return nil +} diff --git a/backend/internal/repository/custom_admin_key_scope.go b/backend/internal/repository/custom_admin_key_scope.go new file mode 100644 index 000000000000..79e9fd19fe21 --- /dev/null +++ b/backend/internal/repository/custom_admin_key_scope.go @@ -0,0 +1,247 @@ +package repository + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + entsql "entgo.io/ent/dialect/sql" + dbent "github.com/Wei-Shaw/sub2api/ent" + dbaccount "github.com/Wei-Shaw/sub2api/ent/account" + dbaccountgroup "github.com/Wei-Shaw/sub2api/ent/accountgroup" + dbpredicate "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func (r *accountRepository) ScopedAdminList(ctx context.Context, p pagination.PaginationParams, platform, kind, status, search string) ([]service.Account, int64, error) { + principal := adminaccess.FromContext(ctx) + if principal == nil { + return nil, 0, fmt.Errorf("缺少受限身份") + } + alternatives := scopedAdminAccountPredicates(principal.Policy, "accounts.read") + if len(alternatives) == 0 { + return []service.Account{}, 0, nil + } + q := r.accountListFilteredQuery(platform, kind, status, search, 0, "").Where(dbaccount.Or(alternatives...)) + total, err := q.Clone().Count(ctx) + if err != nil { + return nil, 0, err + } + // SQL 只投影模型映射子字段,不加载 Token、API Key、接入地址或其他凭证。 + var rows []struct { + ID int64 + Name string + Notes *string + Platform string + Type string + Status string + Schedulable bool + Concurrency int + Priority int + ModelMapping json.RawMessage `json:"model_mapping"` + } + err = q.Offset(p.Offset()).Limit(p.Limit()).Order(dbaccount.ByID()). + Select(dbaccount.FieldID, dbaccount.FieldName, dbaccount.FieldNotes, dbaccount.FieldPlatform, dbaccount.FieldType, dbaccount.FieldStatus, dbaccount.FieldSchedulable, dbaccount.FieldConcurrency, dbaccount.FieldPriority). + Aggregate(func(s *entsql.Selector) string { + return s.C(dbaccount.FieldCredentials) + "->'model_mapping' AS model_mapping" + }).Scan(ctx, &rows) + if err != nil { + return nil, 0, err + } + accounts := make([]service.Account, 0, len(rows)) + for _, row := range rows { + groups, e := r.loadAccountGroupIDs(ctx, row.ID) + if e != nil { + return nil, 0, e + } + var rawMapping map[string]any + mapping := map[string]string{} + if json.Unmarshal(row.ModelMapping, &rawMapping) == nil { + for source, target := range rawMapping { + if value, ok := target.(string); ok { + mapping[source] = value + } + } + } + var credentials map[string]any + if len(mapping) > 0 { + credentials = map[string]any{"model_mapping": mapping} + } + accounts = append(accounts, service.Account{ID: row.ID, Name: row.Name, Notes: row.Notes, Platform: row.Platform, Type: row.Type, Status: row.Status, Schedulable: row.Schedulable, Concurrency: row.Concurrency, Priority: row.Priority, GroupIDs: groups, Credentials: credentials}) + } + return accounts, int64(total), nil +} + +// ScopedAdminCommitted 提交后按实际操作删除或更新缓存,避免对已删除账户读取快照。 +func (r *accountRepository) ScopedAdminCommitted(ctx context.Context, id int64, deleted bool) { + if deleted { + r.deleteSchedulerAccountSnapshot(ctx, id) + return + } + r.syncSchedulerAccountSnapshot(ctx, id) +} + +// scopedAdminBindGroups 只增删变化的关联,保留现有关联的优先级和创建时间。 +// 调用者已在受限事务内验权并合并不可操作的分组,不得回退到全量重建。 +func (r *accountRepository) scopedAdminBindGroups(ctx context.Context, accountID int64, groupIDs []int64) error { + if dbent.TxFromContext(ctx) == nil { + return fmt.Errorf("受限分组写入需要数据库事务") + } + existing, err := r.loadAccountGroupIDs(ctx, accountID) + if err != nil { + return err + } + added, removed := []int64{}, []int64{} + for _, id := range groupIDs { + if !slices.Contains(existing, id) && !slices.Contains(added, id) { + added = append(added, id) + } + } + for _, id := range existing { + if !slices.Contains(groupIDs, id) { + removed = append(removed, id) + } + } + if len(added) == 0 && len(removed) == 0 { + return nil + } + if err := lockLiveGroups(ctx, r.client, added); err != nil { + return err + } + if len(removed) > 0 { + if _, err := r.client.AccountGroup.Delete().Where(dbaccountgroup.AccountIDEQ(accountID), dbaccountgroup.GroupIDIn(removed...)).Exec(ctx); err != nil { + return err + } + } + for _, id := range added { + if _, err := r.client.AccountGroup.Create().SetAccountID(accountID).SetGroupID(id).SetPriority(slices.Index(groupIDs, id) + 1).Save(ctx); err != nil { + return err + } + } + return enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountGroupsChanged, &accountID, nil, buildSchedulerGroupPayload(mergeGroupIDs(existing, groupIDs))) +} + +// scopedAdminAccountPredicates 保证读取和导入候选查询使用相同的范围语义。 +func scopedAdminAccountPredicates(policy adminaccess.Policy, permission string) []dbpredicate.Account { + alternatives := []dbpredicate.Account{} + for _, rule := range policy.RulesFor(permission) { + if len(rule.AccountTypes) == 0 { + continue + } + terms := []dbpredicate.Account{dbaccount.TypeIn(rule.AccountTypes...)} + if len(rule.Platforms) > 0 { + terms = append(terms, dbaccount.PlatformIn(rule.Platforms...)) + } + if rule.AllGroups && !rule.IncludeUngrouped { + terms = append(terms, dbaccount.HasAccountGroups()) + } + if !rule.AllGroups { + groups := []dbpredicate.Account{} + if len(rule.GroupIDs) > 0 { + groups = append(groups, dbaccount.HasAccountGroupsWith(dbaccountgroup.GroupIDIn(rule.GroupIDs...))) + } + if rule.IncludeUngrouped { + groups = append(groups, dbaccount.Not(dbaccount.HasAccountGroups())) + } + if len(groups) == 0 { + continue + } + terms = append(terms, dbaccount.Or(groups...)) + } + alternatives = append(alternatives, dbaccount.And(terms...)) + } + return alternatives +} + +// ScopedAdminCodexCandidates 按导入身份逐级匹配,最多返回一个候选的身份字段。 +// 不向应用加载其他账户的凭证;真正合并凭证仍在写事务锁内进行。 +func (r *accountRepository) ScopedAdminCodexCandidates(ctx context.Context, identity service.ScopedCodexIdentity) ([]service.Account, error) { + principal := adminaccess.FromContext(ctx) + if principal == nil { + return nil, fmt.Errorf("缺少受限身份") + } + scope := scopedAdminAccountPredicates(principal.Policy, "accounts.credentials") + if len(scope) == 0 { + return []service.Account{}, nil + } + for _, match := range scopedCodexIdentityPredicates(identity) { + var rows []struct { + ID int64 + Credentials json.RawMessage + } + err := r.accountListFilteredQuery(service.PlatformOpenAI, service.AccountTypeOAuth, "", "", 0, ""). + Where(dbaccount.Or(scope...), match).Order(dbaccount.ByID(entsql.OrderDesc())).Limit(1). + Select(dbaccount.FieldID).Aggregate(func(s *entsql.Selector) string { + c := s.C(dbaccount.FieldCredentials) + return "jsonb_build_object('chatgpt_account_id', " + c + "->'chatgpt_account_id', 'chatgpt_user_id', " + c + "->'chatgpt_user_id', 'email', " + c + "->'email', 'access_token', " + c + "->'access_token') AS credentials" + }).Scan(ctx, &rows) + if err != nil { + return nil, err + } + if len(rows) != 0 { + var credentials map[string]any + if err := json.Unmarshal(rows[0].Credentials, &credentials); err != nil { + return nil, err + } + return []service.Account{{ID: rows[0].ID, Credentials: credentials}}, nil + } + } + return []service.Account{}, nil +} + +// 与导入器的 account_user / user / email / access / account 优先级保持一致。 +// account 回退必须排除明确属于其他用户的账户;仅 access token 和 Agent Identity 各有独立匹配规则。 +func scopedCodexIdentityPredicates(identity service.ScopedCodexIdentity) []dbpredicate.Account { + accountID, userID := strings.TrimSpace(identity.AccountID), strings.TrimSpace(identity.UserID) + email, token := strings.ToLower(strings.TrimSpace(identity.Email)), strings.TrimSpace(identity.AccessToken) + // 与 Go strings.TrimSpace 的 Unicode 空白集合一致,兼容旧凭证中的首尾空白。 + const whitespace = "\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000" + field := func(key string) string { + // 字段名均为下方固定常量;非法 JSON 类型按导入器规则视为空值。 + raw := `"accounts"."credentials"->'` + key + `'` + value := `"accounts"."credentials"->>'` + key + `'` + // 旧 JSON 数值经 Go 解码为 float64:保留同样的精度,展开指数并去掉尾随小数零。 + number := "trim_scale(((" + value + ")::double precision)::text::numeric)::text" + return "btrim(CASE jsonb_typeof(" + raw + ") WHEN 'string' THEN " + value + " WHEN 'number' THEN " + number + " ELSE '' END, '" + whitespace + "')" + } + a, u, e, access := field("chatgpt_account_id"), field("chatgpt_user_id"), field("email"), field("access_token") + match := func(expr string, args ...any) dbpredicate.Account { + parts := strings.Split(expr, "?") + return func(s *entsql.Selector) { + s.Where(entsql.P(func(b *entsql.Builder) { + for i, part := range parts { + b.WriteString(part) + if i < len(args) { + b.Arg(args[i]) + } + } + })) + } + } + predicates := []dbpredicate.Account{} + if !identity.AgentIdentity && !identity.AccessTokenOnly { + switch { + case accountID != "" && userID != "": + predicates = append(predicates, match(a+" = ? AND "+u+" = ?", accountID, userID)) + case userID != "": + predicates = append(predicates, match(a+" = '' AND "+u+" = ?", userID)) + case accountID == "" && email != "": + predicates = append(predicates, match(a+" = '' AND "+u+" = '' AND lower("+e+") = ?", email)) + } + } + if !identity.AgentIdentity && token != "" { + predicates = append(predicates, match(access+" = ?", token)) + } + if accountID != "" && (identity.AgentIdentity || !identity.AccessTokenOnly) { + if userID == "" { + predicates = append(predicates, match(a+" = ?", accountID)) + } else { + predicates = append(predicates, match(a+" = ? AND ("+u+" = '' OR "+u+" = ?)", accountID, userID)) + } + } + return predicates +} diff --git a/backend/internal/repository/custom_admin_key_scope_test.go b/backend/internal/repository/custom_admin_key_scope_test.go new file mode 100644 index 000000000000..06c192c9a08f --- /dev/null +++ b/backend/internal/repository/custom_admin_key_scope_test.go @@ -0,0 +1,257 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "fmt" + "os" + "strings" + "testing" + "time" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + _ "github.com/Wei-Shaw/sub2api/ent/runtime" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminAccountListScopesCountAndPageInSQL(t *testing.T) { + var countScope string + matcher := sqlmock.QueryMatcherFunc(func(expected, actual string) error { + if !strings.Contains(actual, `"type"`) || !strings.Contains(actual, `"account_groups"`) || !strings.Contains(actual, " OR ") { + return fmt.Errorf("查询缺少完整规则: %s", actual) + } + where := actual[strings.Index(actual, " WHERE "):] + if i := strings.Index(where, " ORDER BY "); i >= 0 { + where = where[:i] + } + if i := strings.Index(where, " LIMIT "); i >= 0 { + where = where[:i] + } + if expected == "count" { + countScope = where + } else { + if where != countScope { + return fmt.Errorf("分页和统计范围不同") + } + // 允许模型映射子字段投影,禁止读取完整 credentials。 + projected := strings.ReplaceAll(actual, `"accounts"."credentials"->'model_mapping' AS model_mapping`, "model_mapping") + if strings.Contains(projected, `"credentials"`) || strings.Contains(projected, `"extra"`) { + return fmt.Errorf("读取了敏感字段") + } + } + return nil + }) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(matcher)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + defer client.Close() + repo := newAccountRepositoryWithSQL(client, db, nil) + mock.ExpectQuery("count").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectQuery("page").WillReturnRows(sqlmock.NewRows([]string{"id"})) + principal := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{ + {Permissions: []string{"accounts.read"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}, + {Permissions: []string{"accounts.read"}, GroupIDs: []int64{2}, AccountTypes: []string{"apikey"}}, + }}} + _, total, err := repo.ScopedAdminList(adminaccess.WithPrincipal(context.Background(), principal), pagination.PaginationParams{Page: 1, PageSize: 20}, "", "", "", "") + if err != nil { + t.Fatal(err) + } + if total != 0 { + t.Fatal(total) + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestScopedAdminAccountListPostgresRulesAndPagination(t *testing.T) { + dsn := os.Getenv("SCOPED_ADMIN_TEST_DSN") + if dsn == "" { + t.Skip("需要独立 SCOPED_ADMIN_TEST_DSN") + } + base, err := sql.Open("postgres", dsn) + require.NoError(t, err) + schema := fmt.Sprintf("scoped_list_test_%d", time.Now().UnixNano()) + _, err = base.Exec(`CREATE SCHEMA ` + schema) + require.NoError(t, err) + db, err := sql.Open("postgres", dsn+" search_path="+schema) + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { + _ = client.Close() + _, _ = base.Exec(`DROP SCHEMA ` + schema + ` CASCADE`) + _ = base.Close() + }) + // 最小数据集刻意交叉分组、类型和平台,防止不同规则被拆开组合。 + _, err = db.Exec(`CREATE TABLE accounts ( + id BIGINT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', notes TEXT, platform TEXT NOT NULL, + type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', schedulable BOOLEAN NOT NULL DEFAULT true, + concurrency INT NOT NULL DEFAULT 1, priority INT NOT NULL DEFAULT 1, credentials JSONB DEFAULT '{}', deleted_at TIMESTAMPTZ + ); + CREATE TABLE account_groups(account_id BIGINT, group_id BIGINT, priority INT DEFAULT 50, created_at TIMESTAMPTZ DEFAULT NOW()); + INSERT INTO accounts(id,platform,type) VALUES + (1,'openai','oauth'), (2,'openai','apikey'), (3,'openai','oauth'), + (4,'openai','apikey'), (5,'anthropic','oauth'), (6,'openai','oauth'); + INSERT INTO account_groups(account_id,group_id) VALUES (1,1),(1,2),(2,1),(3,2),(4,2),(5,1);`) + require.NoError(t, err) + _, err = db.Exec(`UPDATE accounts SET credentials = '{"model_mapping":{"alias":"target","invalid":{"token":"NESTED_SECRET"}},"access_token":"TOKEN_SECRET","refresh_token":"REFRESH_SECRET","api_key":"KEY_SECRET","base_url":"URL_SECRET"}' WHERE id=1`) + require.NoError(t, err) + repo := newAccountRepositoryWithSQL(client, db, nil) + principal := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{ + {Permissions: []string{"accounts.read"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}, Platforms: []string{"openai"}}, + {Permissions: []string{"accounts.read"}, GroupIDs: []int64{2}, AccountTypes: []string{"apikey"}, Platforms: []string{"openai"}}, + }}} + ctx := adminaccess.WithPrincipal(context.Background(), principal) + for page, wantID := range []int64{1, 4} { + rows, total, listErr := repo.ScopedAdminList(ctx, pagination.PaginationParams{Page: page + 1, PageSize: 1}, "", "", "", "") + require.NoError(t, listErr) + require.EqualValues(t, 2, total) + require.Len(t, rows, 1) + require.Equal(t, wantID, rows[0].ID) + if wantID == 1 { + require.Equal(t, map[string]any{"model_mapping": map[string]string{"alias": "target"}}, rows[0].Credentials) + } else { + require.Empty(t, rows[0].Credentials) + } + } + // 缺失、清空及错误形状都应保留可读取列表,不回传嵌套对象或凭证。 + for _, credentials := range []string{`{}`, `{"model_mapping":null}`, `{"model_mapping":{}}`, `{"model_mapping":[]}`, `{"model_mapping":"invalid"}`, `{"model_mapping":{"bad":{"token":"SECRET"},"number":1},"access_token":"SECRET"}`} { + _, err = db.Exec(`UPDATE accounts SET credentials=$1::jsonb WHERE id=4`, credentials) + require.NoError(t, err) + rows, total, listErr := repo.ScopedAdminList(ctx, pagination.PaginationParams{Page: 2, PageSize: 1}, "", "", "", "") + require.NoError(t, listErr) + require.EqualValues(t, 2, total) + require.Len(t, rows, 1) + require.EqualValues(t, 4, rows[0].ID) + require.Empty(t, rows[0].Credentials) + } + principal.Policy.Rules = []adminaccess.Rule{{Permissions: []string{"accounts.read"}, AllGroups: true, AccountTypes: []string{"oauth"}}} + rows, total, err := repo.ScopedAdminList(ctx, pagination.DefaultPagination(), "", "", "", "") + require.NoError(t, err) + require.EqualValues(t, 3, total, "全部分组不包含无分组账户") + require.Len(t, rows, 3) + principal.Policy.Rules[0].IncludeUngrouped = true + _, total, err = repo.ScopedAdminList(ctx, pagination.DefaultPagination(), "", "", "", "") + require.NoError(t, err) + require.EqualValues(t, 4, total) +} + +func TestScopedAdminCodexCandidatesQueryIsBounded(t *testing.T) { + matcher := sqlmock.QueryMatcherFunc(func(_, actual string) error { + where := actual[strings.Index(actual, " WHERE "):] + if !strings.Contains(where, "credentials") || !strings.Contains(actual, " LIMIT ") { + return fmt.Errorf("导入候选未按身份筛选并限制数量: %s", actual) + } + return nil + }) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(matcher)) + require.NoError(t, err) + defer db.Close() + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + defer client.Close() + repo := newAccountRepositoryWithSQL(client, db, nil) + ctx := adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.credentials"}, AccountTypes: []string{"oauth"}, AllGroups: true, IncludeUngrouped: true}}}}) + mock.ExpectQuery("候选身份").WillReturnRows(sqlmock.NewRows([]string{"id", "credentials"})) + _, err = repo.ScopedAdminCodexCandidates(ctx, service.ScopedCodexIdentity{AccessToken: "inside", AccessTokenOnly: true}) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestScopedAdminCodexCandidatesUseCredentialWriteScope(t *testing.T) { + db := scopedAdminKeyTestDB(t) + _, err := db.Exec(`CREATE TABLE accounts(id BIGINT PRIMARY KEY,platform TEXT,type TEXT,credentials JSONB DEFAULT '{}',deleted_at TIMESTAMPTZ); + CREATE TABLE account_groups(account_id BIGINT,group_id BIGINT); + INSERT INTO accounts(id,platform,type,credentials) VALUES + (1,'openai','oauth','{"access_token":"inside"}'),(2,'openai','oauth','{"access_token":"outside"}'), + (3,'openai','apikey','{}'),(4,'anthropic','oauth','{}'),(5,'openai','oauth','{}'),(6,'openai','oauth','{}'); + INSERT INTO account_groups VALUES(1,1),(1,2),(2,2),(3,1),(4,1),(6,1); + UPDATE accounts SET deleted_at=NOW() WHERE id=6; + UPDATE accounts SET credentials='{"access_token":"inside"}';`) + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + repo := newAccountRepositoryWithSQL(client, db, nil) + principal := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.credentials"}, AccountTypes: []string{"oauth"}, GroupIDs: []int64{1}}, {Permissions: []string{"accounts.read"}, AccountTypes: []string{"oauth"}, GroupIDs: []int64{2}}}}} + ctx := adminaccess.WithPrincipal(context.Background(), principal) + rows, err := repo.ScopedAdminCodexCandidates(ctx, service.ScopedCodexIdentity{AccessToken: "inside", AccessTokenOnly: true}) + require.NoError(t, err) + require.Len(t, rows, 1) + require.EqualValues(t, 1, rows[0].ID) + require.Equal(t, "inside", rows[0].Credentials["access_token"]) + principal.Policy.Rules[0].IncludeUngrouped = true + rows, err = repo.ScopedAdminCodexCandidates(ctx, service.ScopedCodexIdentity{AccessToken: "inside", AccessTokenOnly: true}) + require.NoError(t, err) + require.Len(t, rows, 1) + require.EqualValues(t, 5, rows[0].ID) + principal.Policy.Rules[0].Permissions = []string{"accounts.read"} + rows, err = repo.ScopedAdminCodexCandidates(ctx, service.ScopedCodexIdentity{AccessToken: "inside", AccessTokenOnly: true}) + require.NoError(t, err) + require.Empty(t, rows) +} + +func TestScopedAdminCodexCandidatesMatchIdentityWithoutLoadingUnrelatedCredentials(t *testing.T) { + db := scopedAdminKeyTestDB(t) + _, err := db.Exec(`CREATE TABLE accounts(id BIGINT PRIMARY KEY,platform TEXT,type TEXT,credentials JSONB DEFAULT '{}',deleted_at TIMESTAMPTZ); + CREATE TABLE account_groups(account_id BIGINT,group_id BIGINT); + INSERT INTO accounts(id,platform,type,credentials) VALUES + (1,'openai','oauth','{"chatgpt_account_id":"workspace","chatgpt_user_id":"member","access_token":"old","refresh_token":"must-not-load","large_extra":"must-not-load"}'), + (2,'openai','oauth','{"chatgpt_account_id":"workspace","chatgpt_user_id":"other","access_token":"shared"}'), + (3,'openai','oauth','{"chatgpt_account_id":"workspace","access_token":"legacy"}'), + (4,'openai','oauth','{"chatgpt_user_id":"member","access_token":"user-only"}'), + (5,'openai','oauth','{"email":" Test@Example.COM ","access_token":"email-only"}'), + (6,'openai','oauth','{"chatgpt_account_id":"elsewhere","chatgpt_user_id":"member","email":"test@example.com","access_token":"different-workspace"}'), + (7,'openai','oauth','{"chatgpt_account_id":123.00,"chatgpt_user_id":9007199254740993,"access_token":"numeric-legacy"}'); + INSERT INTO accounts SELECT id,'openai','oauth',jsonb_build_object('access_token','unrelated-'||id,'refresh_token',repeat('x',8192)),NULL FROM generate_series(100,1100) AS id;`) + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + repo := newAccountRepositoryWithSQL(client, db, nil) + ctx := adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.credentials"}, AccountTypes: []string{"oauth"}, AllGroups: true, IncludeUngrouped: true}}}}) + for _, tc := range []struct { + name string + identity service.ScopedCodexIdentity + want int64 + }{ + {"精确组合优先于token及account回退", service.ScopedCodexIdentity{AccountID: "workspace", UserID: "member", AccessToken: "shared"}, 1}, + {"仅token不能借用相同workspace", service.ScopedCodexIdentity{AccountID: "workspace", UserID: "member", AccessToken: "missing", AccessTokenOnly: true}, 0}, + {"token命中不施加account用户冲突", service.ScopedCodexIdentity{AccountID: "workspace", UserID: "member", AccessToken: "shared", AccessTokenOnly: true}, 2}, + {"account回退允许缺失用户", service.ScopedCodexIdentity{AccountID: "workspace", UserID: "new-member", AccessToken: "missing"}, 3}, + {"仅user不匹配其他workspace", service.ScopedCodexIdentity{UserID: "member", AccessToken: "missing"}, 4}, + {"email忽略大小写空格但不借用workspace", service.ScopedCodexIdentity{Email: "test@example.com", AccessToken: "missing"}, 5}, + {"AgentIdentity仅account", service.ScopedCodexIdentity{AccountID: "workspace", AccessToken: "different-workspace", AgentIdentity: true}, 3}, + {"AgentIdentity不回退token", service.ScopedCodexIdentity{AccountID: "missing", AccessToken: "shared", AgentIdentity: true}, 0}, + {"空身份不查询全量", service.ScopedCodexIdentity{}, 0}, + {"数值身份遵循原float64归一化", service.ScopedCodexIdentity{AccountID: "123", UserID: "9007199254740992", AccessToken: "rotated-token"}, 7}, + {"不同workspace不能以user合并", service.ScopedCodexIdentity{AccountID: "new-workspace", UserID: "member"}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + rows, err := repo.ScopedAdminCodexCandidates(ctx, tc.identity) + require.NoError(t, err) + if tc.want == 0 { + require.Empty(t, rows) + return + } + require.Len(t, rows, 1) + require.Equal(t, tc.want, rows[0].ID) + require.NotContains(t, rows[0].Credentials, "refresh_token") + require.NotContains(t, rows[0].Credentials, "large_extra") + }) + } + // 同一身份有大量旧副本时仍只取 ID 最大者;首尾 Unicode 空白与导入器一致。 + _, err = db.Exec(`INSERT INTO accounts SELECT id,'openai','oauth',jsonb_build_object('chatgpt_account_id', E'\tworkspace ','chatgpt_user_id','member','access_token','duplicate','refresh_token',repeat('x',8192)),NULL FROM generate_series(2000,3000) AS id;`) + require.NoError(t, err) + rows, err := repo.ScopedAdminCodexCandidates(ctx, service.ScopedCodexIdentity{AccountID: "workspace", UserID: "member"}) + require.NoError(t, err) + require.Len(t, rows, 1) + require.EqualValues(t, 3000, rows[0].ID) + require.Len(t, rows[0].Credentials, 4) +} diff --git a/backend/internal/repository/custom_admin_key_transaction.go b/backend/internal/repository/custom_admin_key_transaction.go new file mode 100644 index 000000000000..e3eaee837464 --- /dev/null +++ b/backend/internal/repository/custom_admin_key_transaction.go @@ -0,0 +1,19 @@ +package repository + +import ( + "context" + dbent "github.com/Wei-Shaw/sub2api/ent" +) + +// accountTransactionScope 同时切换 ORM、原生 SQL 和关联查询,禁止在提交前发布缓存。 +func (r *accountRepository) accountTransactionScope(ctx context.Context) *accountRepository { + tx := dbent.TxFromContext(ctx) + if tx == nil || r.client == tx.Client() { + return r + } + scoped := *r + scoped.client = tx.Client() + scoped.sql = tx.Client() + scoped.schedulerCache = nil + return &scoped +} diff --git a/backend/internal/repository/custom_admin_keys.go b/backend/internal/repository/custom_admin_keys.go new file mode 100644 index 000000000000..18d459ed5bb0 --- /dev/null +++ b/backend/internal/repository/custom_admin_keys.go @@ -0,0 +1,112 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type scopedAdminKeyRepository struct{ db *sql.DB } + +func NewScopedAdminKeyRepository(db *sql.DB) service.ScopedAdminKeyRepository { + return &scopedAdminKeyRepository{db: db} +} + +const scopedAdminKeyColumns = `id,name,description,key_prefix,key_hash,enabled,expires_at,allowed_ips,policy,created_by,created_at,updated_at,last_used_at,last_used_ip,revoked_at` + +func scanScopedAdminKey(row interface{ Scan(...any) error }) (*service.ScopedAdminKey, error) { + k := &service.ScopedAdminKey{} + var ips, policy []byte + err := row.Scan(&k.ID, &k.Name, &k.Description, &k.KeyPrefix, &k.Hash, &k.Enabled, &k.ExpiresAt, &ips, &policy, &k.CreatedBy, &k.CreatedAt, &k.UpdatedAt, &k.LastUsedAt, &k.LastUsedIP, &k.RevokedAt) + if err == sql.ErrNoRows { + return nil, service.ErrScopedAdminKeyNotFound + } + if err != nil { + return nil, err + } + if err = json.Unmarshal(ips, &k.AllowedIPs); err != nil { + return nil, err + } + if err = json.Unmarshal(policy, &k.Policy); err != nil { + return nil, err + } + return k, nil +} +func (r *scopedAdminKeyRepository) ListScopedAdminKeys(ctx context.Context) ([]*service.ScopedAdminKey, error) { + rows, e := r.db.QueryContext(ctx, `SELECT `+scopedAdminKeyColumns+` FROM scoped_admin_api_keys WHERE deleted_at IS NULL ORDER BY created_at DESC`) + if e != nil { + return nil, e + } + defer func() { _ = rows.Close() }() + items := []*service.ScopedAdminKey{} + for rows.Next() { + k, e := scanScopedAdminKey(rows) + if e != nil { + return nil, e + } + items = append(items, k) + } + return items, rows.Err() +} +func (r *scopedAdminKeyRepository) GetScopedAdminKey(ctx context.Context, id string) (*service.ScopedAdminKey, error) { + return scanScopedAdminKey(r.db.QueryRowContext(ctx, `SELECT `+scopedAdminKeyColumns+` FROM scoped_admin_api_keys WHERE id=$1`, id)) +} +func (r *scopedAdminKeyRepository) InsertScopedAdminKey(ctx context.Context, k *service.ScopedAdminKey) error { + ips, e := json.Marshal(k.AllowedIPs) + if e != nil { + return e + } + policy, e := json.Marshal(k.Policy) + if e != nil { + return e + } + _, e = r.db.ExecContext(ctx, `INSERT INTO scoped_admin_api_keys (id,name,description,key_prefix,key_hash,enabled,expires_at,allowed_ips,policy,created_by,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, k.ID, k.Name, k.Description, k.KeyPrefix, k.Hash, k.Enabled, k.ExpiresAt, string(ips), string(policy), k.CreatedBy, k.CreatedAt, k.UpdatedAt) + return e +} +func scopedAdminKeyResult(result sql.Result, e error) error { + if e != nil { + return e + } + n, e := result.RowsAffected() + if e != nil { + return e + } + if n == 0 { + return service.ErrScopedAdminKeyNotFound + } + return nil +} +func (r *scopedAdminKeyRepository) UpdateScopedAdminKey(ctx context.Context, k *service.ScopedAdminKey) error { + ips, e := json.Marshal(k.AllowedIPs) + if e != nil { + return e + } + policy, e := json.Marshal(k.Policy) + if e != nil { + return e + } + return scopedAdminKeyResult(r.db.ExecContext(ctx, `UPDATE scoped_admin_api_keys SET name=$2,description=$3,enabled=$4,expires_at=$5,allowed_ips=$6,policy=$7,updated_at=$8 WHERE id=$1 AND revoked_at IS NULL`, k.ID, k.Name, k.Description, k.Enabled, k.ExpiresAt, string(ips), string(policy), k.UpdatedAt)) +} +func (r *scopedAdminKeyRepository) TouchScopedAdminKey(ctx context.Context, id, expectedHash string, expectedUpdatedAt time.Time, ip string, now time.Time) error { + return scopedAdminKeyResult(r.db.ExecContext(ctx, `UPDATE scoped_admin_api_keys SET last_used_at=$5,last_used_ip=$4 WHERE id=$1 AND key_hash=$2 AND updated_at=$3 AND revoked_at IS NULL AND enabled=true AND (expires_at IS NULL OR expires_at>$5)`, id, expectedHash, expectedUpdatedAt, ip, now)) +} +func (r *scopedAdminKeyRepository) RevokeScopedAdminKey(ctx context.Context, id string, now time.Time) error { + return scopedAdminKeyResult(r.db.ExecContext(ctx, `UPDATE scoped_admin_api_keys SET revoked_at=$2,enabled=false,updated_at=$2 WHERE id=$1 AND revoked_at IS NULL`, id, now)) +} + +// 删除仅隐藏列表项;原子检查撤销状态,不删除密钥记录或历史审计。 +func (r *scopedAdminKeyRepository) DeleteScopedAdminKeyRecord(ctx context.Context, id string, now time.Time) error { + return scopedAdminKeyResult(r.db.ExecContext(ctx, `UPDATE scoped_admin_api_keys SET deleted_at=$2,updated_at=$2 WHERE id=$1 AND revoked_at IS NOT NULL AND deleted_at IS NULL`, id, now)) +} + +// 轮换按读取时的摘要进行 CAS,同一旧版本只能成功轮换一次。 +func (r *scopedAdminKeyRepository) RotateScopedAdminKeyHash(ctx context.Context, id, expectedHash, hash string, at time.Time) error { + err := scopedAdminKeyResult(r.db.ExecContext(ctx, `UPDATE scoped_admin_api_keys SET key_hash=$2,updated_at=$3 WHERE id=$1 AND key_hash=$4 AND revoked_at IS NULL`, id, hash, at, expectedHash)) + if err == service.ErrScopedAdminKeyNotFound { + return service.ErrScopedAdminKeyConflict + } + return err +} diff --git a/backend/internal/repository/custom_admin_keys_postgres_test.go b/backend/internal/repository/custom_admin_keys_postgres_test.go new file mode 100644 index 000000000000..5fe0f3d7af1e --- /dev/null +++ b/backend/internal/repository/custom_admin_keys_postgres_test.go @@ -0,0 +1,146 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// 只使用显式指定的隔离测试数据库,每次创建并清理独立 schema。 +func scopedAdminKeyTestDB(t *testing.T) *sql.DB { + t.Helper() + dsn := os.Getenv("SCOPED_ADMIN_TEST_DSN") + if dsn == "" { + t.Skip("需要独立 SCOPED_ADMIN_TEST_DSN") + } + base, err := sql.Open("postgres", dsn) + require.NoError(t, err) + schema := fmt.Sprintf("scoped_admin_test_%d", time.Now().UnixNano()) + _, err = base.Exec(`CREATE SCHEMA ` + schema) + require.NoError(t, err) + db, err := sql.Open("postgres", dsn+" search_path="+schema) + require.NoError(t, err) + t.Cleanup(func() { + _ = db.Close() + _, _ = base.Exec(`DROP SCHEMA ` + schema + ` CASCADE`) + _ = base.Close() + }) + _, err = db.Exec(`CREATE TABLE users(id BIGINT PRIMARY KEY); INSERT INTO users(id) VALUES(9)`) + require.NoError(t, err) + migration, err := os.ReadFile(filepath.Join("..", "..", "migrations", "247_scoped_admin_api_keys.sql")) + require.NoError(t, err) + _, err = db.Exec(string(migration)) + require.NoError(t, err) + return db +} + +func TestScopedAdminKeyPostgresDeleteRecordPreservesHistory(t *testing.T) { + db := scopedAdminKeyTestDB(t) + repo := NewScopedAdminKeyRepository(db) + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(repo) + ctx := context.Background() + key, item, err := settings.CreateScopedAdminKey(ctx, service.ScopedAdminKeyInput{Name: "待移除记录", Enabled: true}, 9) + require.NoError(t, err) + + // 使用真实审计表,检查列表移除不会删除或改写历史日志。 + migration, err := os.ReadFile(filepath.Join("..", "..", "migrations", "180_audit_logs.sql")) + require.NoError(t, err) + _, err = db.Exec(string(migration)) + require.NoError(t, err) + _, err = db.Exec(`INSERT INTO audit_logs(actor_user_id, auth_method, action, extra) VALUES(9, 'admin_api_key', 'admin.accounts.update', jsonb_build_object('admin_key_id', $1::text))`, item.ID) + require.NoError(t, err) + var historyBefore, historyAfter string + require.NoError(t, db.QueryRow(`SELECT row_to_json(audit_logs)::text FROM audit_logs`).Scan(&historyBefore)) + + // 未撤销的 Key 即使直接调用接口也不能被隐藏,停用也不能替代撤销。 + require.ErrorIs(t, settings.DeleteScopedAdminKeyRecord(ctx, item.ID), service.ErrScopedAdminKeyNotFound) + _, err = settings.AuthenticateScopedAdminKey(ctx, key, "192.0.2.1") + require.NoError(t, err) + input := item.ScopedAdminKeyInput + input.Enabled = false + _, err = settings.UpdateScopedAdminKey(ctx, item.ID, input) + require.NoError(t, err) + require.ErrorIs(t, settings.DeleteScopedAdminKeyRecord(ctx, item.ID), service.ErrScopedAdminKeyNotFound) + require.NoError(t, settings.RevokeScopedAdminKey(ctx, item.ID)) + items, err := settings.ListScopedAdminKeys(ctx) + require.NoError(t, err) + require.Len(t, items, 1, "撤销仍保留在列表") + + require.NoError(t, settings.DeleteScopedAdminKeyRecord(ctx, item.ID)) + items, err = settings.ListScopedAdminKeys(ctx) + require.NoError(t, err) + require.Empty(t, items, "重新读取列表仍应隐藏") + stored, err := repo.GetScopedAdminKey(ctx, item.ID) + require.NoError(t, err, "底层记录保留以供追溯") + require.NotNil(t, stored.RevokedAt) + require.Equal(t, item.Name, stored.Name) + require.NoError(t, db.QueryRow(`SELECT row_to_json(audit_logs)::text FROM audit_logs`).Scan(&historyAfter)) + require.Equal(t, historyBefore, historyAfter) + _, err = settings.AuthenticateScopedAdminKey(ctx, key, "192.0.2.1") + require.ErrorIs(t, err, service.ErrScopedAdminKeyInvalid) + _, err = settings.UpdateScopedAdminKey(ctx, item.ID, input) + require.ErrorIs(t, err, service.ErrScopedAdminKeyNotFound) + _, _, err = settings.RotateScopedAdminKey(ctx, item.ID) + require.ErrorIs(t, err, service.ErrScopedAdminKeyNotFound) + require.ErrorIs(t, settings.DeleteScopedAdminKeyRecord(ctx, item.ID), service.ErrScopedAdminKeyNotFound) + require.ErrorIs(t, settings.DeleteScopedAdminKeyRecord(ctx, "不存在的Key"), service.ErrScopedAdminKeyNotFound) +} + +func TestScopedAdminKeyPostgresMigrationAndLifecycle(t *testing.T) { + db := scopedAdminKeyTestDB(t) + repo := NewScopedAdminKeyRepository(db) + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(repo) + ctx := context.Background() + input := service.ScopedAdminKeyInput{Name: "隔离数据库验收", Enabled: true, AllowedIPs: []string{"192.0.2.0/24"}, Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.read"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}}}} + key, item, err := settings.CreateScopedAdminKey(ctx, input, 9) + require.NoError(t, err) + stored, err := repo.GetScopedAdminKey(ctx, item.ID) + require.NoError(t, err) + require.NotEqual(t, key, stored.Hash) + require.Len(t, stored.Hash, 64) + _, err = settings.AuthenticateScopedAdminKey(ctx, key, "192.0.2.9") + require.NoError(t, err, "真实 PostgreSQL 时间精度下 CAS 应能成功") + _, err = settings.AuthenticateScopedAdminKey(ctx, key, "198.51.100.9") + require.Error(t, err) + + // 读出策略之后发生收权,旧读取不得通过准入 CAS。 + observed, err := repo.GetScopedAdminKey(ctx, item.ID) + require.NoError(t, err) + input.Policy = adminaccess.Policy{} + _, err = settings.UpdateScopedAdminKey(ctx, item.ID, input) + require.NoError(t, err) + require.Error(t, repo.TouchScopedAdminKey(ctx, observed.ID, observed.Hash, observed.UpdatedAt, "192.0.2.9", time.Now().UTC())) + authenticated, err := settings.AuthenticateScopedAdminKey(ctx, key, "192.0.2.9") + require.NoError(t, err) + require.False(t, adminaccess.RouteAllowed(authenticated.Policy, "GET", "/api/v1/admin/accounts")) + + rotated, _, err := settings.RotateScopedAdminKey(ctx, item.ID) + require.NoError(t, err) + _, err = settings.AuthenticateScopedAdminKey(ctx, key, "192.0.2.9") + require.Error(t, err) + _, err = settings.AuthenticateScopedAdminKey(ctx, rotated, "192.0.2.9") + require.NoError(t, err) + input.Enabled = false + _, err = settings.UpdateScopedAdminKey(ctx, item.ID, input) + require.NoError(t, err) + _, err = settings.AuthenticateScopedAdminKey(ctx, rotated, "192.0.2.9") + require.Error(t, err) + require.NoError(t, settings.RevokeScopedAdminKey(ctx, item.ID)) + input.Enabled = true + _, err = settings.UpdateScopedAdminKey(ctx, item.ID, input) + require.ErrorIs(t, err, service.ErrScopedAdminKeyNotFound) + _, _, err = settings.RotateScopedAdminKey(ctx, item.ID) + require.ErrorIs(t, err, service.ErrScopedAdminKeyNotFound) +} diff --git a/backend/internal/repository/custom_admin_keys_test.go b/backend/internal/repository/custom_admin_keys_test.go new file mode 100644 index 000000000000..1d7684035f64 --- /dev/null +++ b/backend/internal/repository/custom_admin_keys_test.go @@ -0,0 +1,50 @@ +//go:build unit + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminKeyRepositoryRotationAndRevocation(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewScopedAdminKeyRepository(db) + now := time.Now().UTC() + ctx := context.Background() + mock.ExpectExec(`UPDATE scoped_admin_api_keys SET key_hash=\$2,updated_at=\$3 WHERE id=\$1 AND key_hash=\$4 AND revoked_at IS NULL`).WithArgs("id", "hash", now, "old-hash").WillReturnResult(sqlmock.NewResult(0, 1)) + require.NoError(t, repo.RotateScopedAdminKeyHash(ctx, "id", "old-hash", "hash", now)) + mock.ExpectExec(`UPDATE scoped_admin_api_keys SET revoked_at=\$2,enabled=false,updated_at=\$2 WHERE id=\$1 AND revoked_at IS NULL`).WithArgs("id", now).WillReturnResult(sqlmock.NewResult(0, 0)) + require.ErrorIs(t, repo.RevokeScopedAdminKey(ctx, "id", now), service.ErrScopedAdminKeyNotFound) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestScopedAdminKeyTouchUsesCASAgainstRotationAndRevocation(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewScopedAdminKeyRepository(db) + now := time.Now().UTC() + mock.ExpectExec(`UPDATE scoped_admin_api_keys SET last_used_at=\$5,last_used_ip=\$4 WHERE id=\$1 AND key_hash=\$2 AND updated_at=\$3 AND revoked_at IS NULL AND enabled=true AND \(expires_at IS NULL OR expires_at>\$5\)`). + WithArgs("id", "observed-hash", now.Add(-time.Minute), "192.0.2.1", now). + WillReturnResult(sqlmock.NewResult(0, 0)) + require.ErrorIs(t, repo.TouchScopedAdminKey(context.Background(), "id", "observed-hash", now.Add(-time.Minute), "192.0.2.1", now), service.ErrScopedAdminKeyNotFound) + require.NoError(t, mock.ExpectationsWereMet()) +} +func TestScopedAdminKeyRepositoryEditDoesNotRestoreOldHash(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + repo := NewScopedAdminKeyRepository(db) + k := &service.ScopedAdminKey{ID: "id", Hash: "旧摘要", ScopedAdminKeyInput: service.ScopedAdminKeyInput{Name: "更新", AllowedIPs: []string{}}, UpdatedAt: time.Now().UTC()} + mock.ExpectExec(`UPDATE scoped_admin_api_keys SET name=\$2,description=\$3,enabled=\$4,expires_at=\$5,allowed_ips=\$6,policy=\$7,updated_at=\$8 WHERE id=\$1 AND revoked_at IS NULL`).WithArgs(k.ID, k.Name, k.Description, k.Enabled, k.ExpiresAt, "[]", sqlmock.AnyArg(), k.UpdatedAt).WillReturnResult(sqlmock.NewResult(0, 1)) + require.NoError(t, repo.UpdateScopedAdminKey(context.Background(), k)) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/custom_proxy_options.go b/backend/internal/repository/custom_proxy_options.go new file mode 100644 index 000000000000..116421ccc77f --- /dev/null +++ b/backend/internal/repository/custom_proxy_options.go @@ -0,0 +1,56 @@ +package repository + +import ( + "context" + entsql "entgo.io/ent/dialect/sql" + "strings" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/proxy" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// ListOptions 为受限管理密钥在数据库内分页并只投影公开字段。 +func (r *proxyRepository) ListOptions(ctx context.Context, params pagination.PaginationParams, search string) ([]service.Proxy, *pagination.PaginationResult, error) { + q := r.client.Proxy.Query().Where(proxy.StatusEQ(service.StatusActive)) + if search = strings.TrimSpace(search); search != "" { + q = q.Where(proxyOptionSearchPredicate(search)) + } + total, err := q.Count(ctx) + if err != nil { + return nil, nil, err + } + records, err := q.Select(proxy.FieldID, proxy.FieldName, proxy.FieldProtocol, proxy.FieldHost, proxy.FieldPort, proxy.FieldStatus, proxy.FieldExpiresAt).Order(dbent.Desc(proxy.FieldID)).Offset(params.Offset()).Limit(params.Limit()).All(ctx) + if err != nil { + return nil, nil, err + } + items := make([]service.Proxy, 0, len(records)) + for _, record := range records { + items = append(items, *proxyEntityToService(record)) + } + return items, paginationResultFromTotal(int64(total), params), nil +} + +// SampleOptions 先过滤全池,再由数据库随机排序取 50 条,禁止先固定分页再洗牌。 +func (r *proxyRepository) SampleOptions(ctx context.Context, search string) ([]service.Proxy, int64, error) { + q := r.client.Proxy.Query().Where(proxy.StatusEQ(service.StatusActive), proxy.Or(proxy.ExpiresAtIsNil(), proxy.ExpiresAtGT(time.Now()))) + if search = strings.TrimSpace(search); search != "" { + q = q.Where(proxyOptionSearchPredicate(search)) + } + total, err := q.Clone().Count(ctx) + if err != nil { + return nil, 0, err + } + records, err := q.Select(proxy.FieldID, proxy.FieldName, proxy.FieldProtocol, proxy.FieldHost, proxy.FieldPort, proxy.FieldStatus, proxy.FieldExpiresAt). + Order(func(s *entsql.Selector) { s.OrderExpr(entsql.Expr("RANDOM()")) }).Limit(50).All(ctx) + if err != nil { + return nil, 0, err + } + items := make([]service.Proxy, 0, len(records)) + for _, record := range records { + items = append(items, *proxyEntityToService(record)) + } + return items, int64(total), nil +} diff --git a/backend/internal/repository/custom_proxy_sample_test.go b/backend/internal/repository/custom_proxy_sample_test.go new file mode 100644 index 000000000000..aa7dfedbc663 --- /dev/null +++ b/backend/internal/repository/custom_proxy_sample_test.go @@ -0,0 +1,111 @@ +//go:build unit + +package repository + +import ( + "context" + "fmt" + "strings" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/stretchr/testify/require" +) + +func TestProxySampleSQLRandomizesWholePoolBeforeLimit(t *testing.T) { + matcher := sqlmock.QueryMatcherFunc(func(expected, actual string) error { + if !strings.Contains(actual, `"status"`) || !strings.Contains(actual, `"expires_at"`) { + return fmt.Errorf("缺少可用池过滤: %s", actual) + } + if strings.Contains(actual, `"username"`) || strings.Contains(actual, `"password"`) { + return fmt.Errorf("抽样不应查询认证字段: %s", actual) + } + if expected == "sample" { + if !strings.Contains(actual, "ORDER BY RANDOM() LIMIT 50") || strings.Contains(actual, "OFFSET") || strings.Contains(actual, "ORDER BY \"proxies\".\"id\"") { + return fmt.Errorf("未在全池随机后限制50: %s", actual) + } + if strings.Count(actual, "SELECT") != 1 { + return fmt.Errorf("不得从预先截断的子查询抽样: %s", actual) + } + } + return nil + }) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(matcher)) + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + mock.ExpectQuery("count").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(20000)) + mock.ExpectQuery("sample").WillReturnRows(sqlmock.NewRows([]string{"id", "name", "host", "port", "status"}).AddRow(1234, "候选", "192.0.2.1", 8080, "active")) + items, total, err := newProxyRepositoryWithSQL(client, db).SampleOptions(context.Background(), "region") + require.NoError(t, err) + require.EqualValues(t, 20000, total) + require.Len(t, items, 1) + require.EqualValues(t, 1234, items[0].ID) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestProxySamplePostgresLargePoolAndEligibility(t *testing.T) { + db := scopedAdminKeyTestDB(t) + _, err := db.Exec(`CREATE TABLE proxies( + id BIGINT PRIMARY KEY,name TEXT DEFAULT 'pool',protocol TEXT DEFAULT 'http',host TEXT DEFAULT '192.0.2.1',port INT DEFAULT 8080, + username TEXT DEFAULT 'SECRET_USER',password TEXT DEFAULT 'SECRET_PASSWORD',status TEXT DEFAULT 'active', + expires_at TIMESTAMPTZ,deleted_at TIMESTAMPTZ,created_at TIMESTAMPTZ DEFAULT NOW(),updated_at TIMESTAMPTZ DEFAULT NOW(), + fallback_mode TEXT DEFAULT 'none',backup_proxy_id BIGINT,expiry_warn_days INT DEFAULT 7); + INSERT INTO proxies(id) SELECT generate_series(1,20005); + UPDATE proxies SET status='inactive' WHERE id=1; + UPDATE proxies SET status='inactive' WHERE id=2; + UPDATE proxies SET expires_at=NOW()-INTERVAL '1 day' WHERE id=3; + UPDATE proxies SET deleted_at=NOW() WHERE id=4;`) + require.NoError(t, err) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + repo := newProxyRepositoryWithSQL(client, db) + for i := 0; i < 2; i++ { + items, total, err := repo.SampleOptions(context.Background(), "") + require.NoError(t, err) + require.EqualValues(t, 20001, total) + require.Len(t, items, 50) + seen := map[int64]bool{} + for _, p := range items { + require.Greater(t, p.ID, int64(4)) + require.False(t, seen[p.ID]) + seen[p.ID] = true + require.Empty(t, p.Username) + require.Empty(t, p.Password) + } + } + // 用唯一匹配 ID 证明老记录在全池内可达,不依赖随机概率作断言。 + _, err = db.Exec(`UPDATE proxies SET name='only-old-record' WHERE id=5`) + require.NoError(t, err) + items, total, err := repo.SampleOptions(context.Background(), "only-old-record") + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, items, 1) + require.EqualValues(t, 5, items[0].ID) + items, total, err = repo.SampleOptions(context.Background(), "SECRET_USER") + require.NoError(t, err) + require.Zero(t, total) + require.Empty(t, items) + page1, _, err := repo.ListOptions(context.Background(), pagination.PaginationParams{Page: 1, PageSize: 50}, "") + require.NoError(t, err) + page2, _, err := repo.ListOptions(context.Background(), pagination.PaginationParams{Page: 2, PageSize: 50}, "") + require.NoError(t, err) + require.EqualValues(t, 20005, page1[0].ID) + require.EqualValues(t, 19955, page2[0].ID) + // 小池全部返回,空池返回空切片,均不填入无效代理。 + _, err = db.Exec(`UPDATE proxies SET status='inactive' WHERE id>10`) + require.NoError(t, err) + items, total, err = repo.SampleOptions(context.Background(), "") + require.NoError(t, err) + require.EqualValues(t, 6, total) + require.Len(t, items, 6) + _, err = db.Exec(`UPDATE proxies SET status='inactive'`) + require.NoError(t, err) + items, total, err = repo.SampleOptions(context.Background(), "") + require.NoError(t, err) + require.Zero(t, total) + require.Empty(t, items) +} diff --git a/backend/internal/repository/custom_proxy_search.go b/backend/internal/repository/custom_proxy_search.go new file mode 100644 index 000000000000..25570cd31610 --- /dev/null +++ b/backend/internal/repository/custom_proxy_search.go @@ -0,0 +1,20 @@ +package repository + +import ( + "strconv" + + "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/proxy" +) + +// proxyOptionSearchPredicate 数字关键词同时匹配端口和 ID;文本搜索保持参数化。 +func proxyOptionSearchPredicate(search string) predicate.Proxy { + filters := []predicate.Proxy{proxy.NameContainsFold(search), proxy.HostContainsFold(search)} + if n, err := strconv.ParseInt(search, 10, 64); err == nil && n > 0 { + if n <= 65535 { + filters = append(filters, proxy.PortEQ(int(n))) + } + filters = append(filters, proxy.IDEQ(n)) + } + return proxy.Or(filters...) +} diff --git a/backend/internal/repository/setting_repo.go b/backend/internal/repository/setting_repo.go index 815daa54be7a..468d4e66c137 100644 --- a/backend/internal/repository/setting_repo.go +++ b/backend/internal/repository/setting_repo.go @@ -82,6 +82,20 @@ func (r *settingRepository) Set(ctx context.Context, key, value string) error { Exec(ctx) } +// ReplaceExistingAdminAPIKey 原子轮换仍存在的旧版管理员密钥。 +// 受影响行数为零表示密钥已被删除或不存在,绝不能通过 upsert 重新创建。 +func (r *settingRepository) ReplaceExistingAdminAPIKey(ctx context.Context, value string) (bool, error) { + n, err := r.client.Setting.Update(). + Where(setting.KeyEQ(service.SettingKeyAdminAPIKey), setting.ValueNEQ("")). + SetValue(value). + SetUpdatedAt(time.Now()). + Save(ctx) + if err != nil { + return false, err + } + return n == 1, nil +} + func (r *settingRepository) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { if len(keys) == 0 { return map[string]string{}, nil diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index ba13b0f7670b..f176e56eeab8 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -87,6 +87,7 @@ var ProviderSet = wire.NewSet( NewUsageCleanupRepository, NewDashboardAggregationRepository, NewSettingRepository, + NewScopedAdminKeyRepository, NewOpsRepository, NewAuditLogRepository, NewPasskeyRepository, diff --git a/backend/internal/server/middleware/admin_auth.go b/backend/internal/server/middleware/admin_auth.go index 741cbcf6c030..fd0f27053477 100644 --- a/backend/internal/server/middleware/admin_auth.go +++ b/backend/internal/server/middleware/admin_auth.go @@ -5,9 +5,10 @@ import ( "crypto/subtle" "errors" "strings" + "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" ) @@ -49,6 +50,16 @@ func adminAuth( // 检查 x-api-key header(Admin API Key 认证) apiKey := c.GetHeader("x-api-key") if apiKey != "" { + if strings.HasPrefix(apiKey, "adm_sc_") { + authenticateScopedAdminRequest(c, apiKey, settingService, userService, auditService) + return + } + if strings.Contains(c.FullPath(), "/settings/admin-api-key") { + AbortWithError(c, 403, "FORBIDDEN", "仅 JWT 管理员可管理密钥") + recordAdminKeyRequest(c, auditService, nil) + return + } + if !validateAdminAPIKey(c, apiKey, settingService, userService) { return } @@ -218,3 +229,54 @@ func validateJWTForAdmin( return true } + +// 每次请求查库,未登记的管理动作默认拒绝。 +func authenticateScopedAdminRequest(c *gin.Context, key string, settings *service.SettingService, users *service.UserService, audit *service.AuditLogService) { + if settings == nil || users == nil { + AbortWithError(c, 401, "INVALID_ADMIN_KEY", "Invalid admin API key") + recordAdminKeyRequest(c, audit, nil) + return + } + item, err := settings.AuthenticateScopedAdminKey(c.Request.Context(), key, SecurityClientIP(c)) + defer func() { recordAdminKeyRequest(c, audit, item) }() + if err != nil { + AbortWithError(c, 401, "INVALID_ADMIN_KEY", "Invalid admin API key") + return + } + user, err := users.GetByID(c.Request.Context(), item.CreatedBy) + if err != nil || user == nil || !user.IsActive() || !user.IsAdmin() { + AbortWithError(c, 401, "INVALID_ADMIN_KEY", "Invalid admin API key") + return + } + principal := &adminaccess.Principal{KeyID: item.ID, KeyName: item.Name, CreatorID: item.CreatedBy, Policy: item.Policy} + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), principal)) + c.Set(string(ContextKeyUser), AuthSubject{UserID: user.ID, Concurrency: user.Concurrency}) + c.Set(string(ContextKeyUserRole), user.Role) + c.Set(ContextKeyAuthEmail, user.Email) + c.Set("auth_method", service.AuditAuthMethodAdminAPIKey) + c.Set(auditCtxKeySkip, true) + if strings.Contains(c.FullPath(), "/settings/admin-api-key") || !adminaccess.RouteAllowed(item.Policy, c.Request.Method, c.FullPath()) { + c.Set("admin_key_denial_reason", "permission_denied") + AbortWithError(c, 403, "FORBIDDEN", "管理密钥未获此操作权限") + return + } + c.Next() +} +func recordAdminKeyRequest(c *gin.Context, audit *service.AuditLogService, item *service.ScopedAdminKey) { + if audit == nil { + return + } + entry := &service.AuditLog{CreatedAt: time.Now().UTC(), AuthMethod: service.AuditAuthMethodAdminAPIKey, Action: "admin.api_key.request", Method: c.Request.Method, Path: c.Request.URL.Path, StatusCode: c.Writer.Status(), ClientIP: SecurityClientIP(c), UserAgent: c.Request.UserAgent()} + if item != nil { + entry.Extra = map[string]any{"admin_key_id": item.ID, "admin_key_name": item.Name} + uid := item.CreatedBy + entry.ActorUserID = &uid + } + if reason := c.GetString("admin_key_denial_reason"); reason != "" { + if entry.Extra == nil { + entry.Extra = map[string]any{} + } + entry.Extra["denial_reason"] = reason + } + audit.Record(entry) +} diff --git a/backend/internal/server/middleware/custom_admin_key_rate_limit_test.go b/backend/internal/server/middleware/custom_admin_key_rate_limit_test.go new file mode 100644 index 000000000000..305b7b34deaa --- /dev/null +++ b/backend/internal/server/middleware/custom_admin_key_rate_limit_test.go @@ -0,0 +1,170 @@ +//go:build unit + +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminKeyPanelRateLimitAuthenticatedRead(t *testing.T) { + gin.SetMode(gin.TestMode) + settings := newPanelRateLimitTestService(t, `{"enabled":true,"user_rpm":1,"heavy_rpm":1,"exempt_admin":true,"public_ip_rpm":0}`) + settings.SetScopedAdminKeyRepository(&adminAuthKeyRepo{}) + key, item, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{ + Name: "读取用户", Enabled: true, Policy: adminaccess.Policy{GlobalPermissions: []string{"users.read"}}, + }, 9) + require.NoError(t, err) + users := service.NewUserService(&stubUserRepo{getByID: func(context.Context, int64) (*service.User, error) { + return &service.User{ID: 9, Role: service.RoleAdmin, Status: service.StatusActive}, nil + }}, nil, nil, nil) + allower := &fakePanelAllower{} + p := &PanelRateLimiter{limiter: allower, settingService: settings} + r := gin.New() + r.Use(gin.HandlerFunc(NewAdminAuthMiddleware(nil, users, settings, nil)), p.Global()) + r.GET("/api/v1/admin/users", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + request := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + require.Equal(t, http.StatusNoContent, request().Code) + w := request() + require.Equal(t, http.StatusTooManyRequests, w.Code, "受限 Key 不应继承管理员限流豁免") + require.Equal(t, "60", w.Header().Get("Retry-After")) + require.Equal(t, int64(2), allower.counts["panel:global:admin-key:"+item.ID]) +} + +func TestScopedAdminKeyPanelRateLimitWritesAndIndependentKeys(t *testing.T) { + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + allower := &fakePanelAllower{} + p := &PanelRateLimiter{limiter: allower, settingService: newPanelRateLimitTestService(t, + `{"enabled":true,"user_rpm":10,"heavy_rpm":1,"exempt_admin":true}`)} + newRouter := func(keyID string) *gin.Engine { + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set(string(ContextKeyUser), AuthSubject{UserID: 9}) + c.Set(string(ContextKeyUserRole), service.RoleAdmin) + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{KeyID: keyID})) + c.Next() + }, p.Global(), p.Heavy()) + r.Handle(method, "/test", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + return r + } + request := func(r *gin.Engine) int { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(method, "/test", nil)) + return w.Code + } + a, b := newRouter("key-a"), newRouter("key-b") + require.Equal(t, http.StatusNoContent, request(a), "Global 与 Heavy 叠加时严格档只计数一次") + require.Equal(t, http.StatusTooManyRequests, request(a)) + require.Equal(t, http.StatusNoContent, request(b), "同一创建者的另一把 Key 配额独立") + require.Equal(t, int64(2), allower.counts["panel:heavy:admin-key:key-a"]) + require.Equal(t, int64(1), allower.counts["panel:heavy:admin-key:key-b"]) + require.NotContains(t, allower.counts, "panel:global:user:9") + }) + } +} + +func TestScopedAdminKeyPanelRateLimitAuthenticatedSensitiveWrite(t *testing.T) { + for _, tc := range []struct { + name string + settings string + redisErr error + wantCodes []int + }{ + {"严格档", `{"enabled":true,"user_rpm":10,"heavy_rpm":1,"exempt_admin":true}`, nil, []int{204, 429}}, + {"只开严格档", `{"enabled":true,"user_rpm":0,"heavy_rpm":1,"exempt_admin":true}`, nil, []int{204, 429}}, + {"严格档关闭", `{"enabled":true,"user_rpm":10,"heavy_rpm":0,"exempt_admin":true}`, nil, []int{204, 204}}, + {"总开关关闭", `{"enabled":false,"user_rpm":1,"heavy_rpm":1,"exempt_admin":true}`, nil, []int{204, 204}}, + {"Redis故障放行", `{"enabled":true,"user_rpm":1,"heavy_rpm":1,"exempt_admin":true}`, errors.New("redis down"), []int{204, 204}}, + } { + t.Run(tc.name, func(t *testing.T) { + settings := newPanelRateLimitTestService(t, tc.settings) + settings.SetScopedAdminKeyRepository(&adminAuthKeyRepo{}) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{ + Name: "调整余额", Enabled: true, Policy: adminaccess.Policy{GlobalPermissions: []string{"users.balance.write"}}, + }, 9) + require.NoError(t, err) + users := service.NewUserService(&stubUserRepo{getByID: func(context.Context, int64) (*service.User, error) { + return &service.User{ID: 9, Role: service.RoleAdmin, Status: service.StatusActive}, nil + }}, nil, nil, nil) + p := &PanelRateLimiter{limiter: &fakePanelAllower{err: tc.redisErr}, settingService: settings} + r := gin.New() + r.Use(gin.HandlerFunc(NewAdminAuthMiddleware(nil, users, settings, nil)), p.Global()) + r.POST("/api/v1/admin/users/:id/balance", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + for _, want := range tc.wantCodes { + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/12/balance", nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, want, w.Code) + } + }) + } +} + +func TestScopedAdminKeyPanelRateLimitSafeMethods(t *testing.T) { + for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} { + t.Run(method, func(t *testing.T) { + allower := &fakePanelAllower{} + p := &PanelRateLimiter{limiter: allower, settingService: newPanelRateLimitTestService(t, + `{"enabled":true,"user_rpm":2,"heavy_rpm":1,"exempt_admin":true}`)} + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set(string(ContextKeyUser), AuthSubject{UserID: 9}) + c.Set(string(ContextKeyUserRole), service.RoleAdmin) + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{KeyID: "read-key"})) + c.Next() + }, p.Global()) + r.Handle(method, "/test", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + for _, want := range []int{http.StatusNoContent, http.StatusNoContent, http.StatusTooManyRequests} { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(method, "/test", nil)) + require.Equal(t, want, w.Code) + } + require.NotContains(t, allower.counts, "panel:heavy:admin-key:read-key") + }) + } +} + +func TestScopedAdminKeyPanelRateLimitPreservesUnrestrictedAdminWrites(t *testing.T) { + for _, exempt := range []string{"true", "false"} { + t.Run(exempt, func(t *testing.T) { + allower := &fakePanelAllower{} + p := &PanelRateLimiter{limiter: allower, settingService: newPanelRateLimitTestService(t, + `{"enabled":true,"user_rpm":10,"heavy_rpm":1,"exempt_admin":`+exempt+`}`)} + r := gin.New() + r.Use(func(c *gin.Context) { + // JWT 与旧全权密钥都只有管理员用户身份,没有受限 Principal。 + c.Set(string(ContextKeyUser), AuthSubject{UserID: 9}) + c.Set(string(ContextKeyUserRole), service.RoleAdmin) + c.Next() + }, p.Global()) + r.POST("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/test", nil)) + require.Equal(t, http.StatusNoContent, w.Code) + } + require.NotContains(t, allower.counts, "panel:heavy:user:9") + if exempt == "true" { + require.Empty(t, allower.counts) + } else { + require.Equal(t, int64(2), allower.counts["panel:global:user:9"]) + } + }) + } +} diff --git a/backend/internal/server/middleware/custom_admin_keys_test.go b/backend/internal/server/middleware/custom_admin_keys_test.go new file mode 100644 index 000000000000..23d40240c84b --- /dev/null +++ b/backend/internal/server/middleware/custom_admin_keys_test.go @@ -0,0 +1,117 @@ +//go:build unit + +package middleware + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type adminAuthKeyRepo struct { + service.ScopedAdminKeyRepository + item *service.ScopedAdminKey +} + +func (r *adminAuthKeyRepo) InsertScopedAdminKey(_ context.Context, k *service.ScopedAdminKey) error { + r.item = k + return nil +} +func (r *adminAuthKeyRepo) GetScopedAdminKey(context.Context, string) (*service.ScopedAdminKey, error) { + return r.item, nil +} +func (r *adminAuthKeyRepo) TouchScopedAdminKey(context.Context, string, string, time.Time, string, time.Time) error { + return nil +} +func TestScopedAdminKeyAuthDefaultDenyAndCreatorStatus(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &adminAuthKeyRepo{} + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(repo) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "只读", Enabled: true, Policy: adminaccess.Policy{GlobalPermissions: []string{"users.read"}}}, 9) + require.NoError(t, err) + active := true + users := service.NewUserService(&stubUserRepo{getByID: func(context.Context, int64) (*service.User, error) { + status := service.StatusActive + if !active { + status = "disabled" + } + return &service.User{ID: 9, Role: service.RoleAdmin, Status: status}, nil + }}, nil, nil, nil) + r := gin.New() + r.Use(gin.HandlerFunc(NewAdminAuthMiddleware(nil, users, settings, nil))) + for _, path := range []string{"/api/v1/admin/users", "/api/v1/admin/unknown", "/api/v1/admin/settings/admin-api-keys"} { + r.GET(path, func(c *gin.Context) { c.Status(204) }) + } + for _, tc := range []struct { + path string + status int + }{{"/api/v1/admin/users", 204}, {"/api/v1/admin/unknown", 403}, {"/api/v1/admin/settings/admin-api-keys", 403}} { + req := httptest.NewRequest("GET", tc.path, nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, tc.status, w.Code, tc.path) + } + active = false + req := httptest.NewRequest("GET", "/api/v1/admin/users", nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 401, w.Code) +} +func TestLegacyAdminKeyCannotManageKeys(t *testing.T) { + r := gin.New() + r.Use(gin.HandlerFunc(NewAdminAuthMiddleware(nil, nil, nil, nil))) + r.POST("/api/v1/admin/settings/admin-api-key/regenerate", func(c *gin.Context) { t.Fatal("不应执行 handler") }) + req := httptest.NewRequest("POST", "/api/v1/admin/settings/admin-api-key/regenerate", nil) + req.Header.Set("x-api-key", "admin-legacy") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 403, w.Code) +} + +func TestScopedAdminKeyAuditDenialOmitsSecrets(t *testing.T) { + repo := &auditCaptureRepository{} + audit := service.NewAuditLogService(repo, nil) + audit.Start() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/api/v1/admin/accounts/123/forbidden?token=private-query", nil) + c.Request.Header.Set("x-api-key", "adm_sc_private-secret") + c.Status(403) + c.Set("admin_key_denial_reason", "permission_denied") + recordAdminKeyRequest(c, audit, &service.ScopedAdminKey{ID: "key-id", Hash: "private-hash", CreatedBy: 9, ScopedAdminKeyInput: service.ScopedAdminKeyInput{Name: "审计身份"}}) + audit.Stop() + repo.mu.Lock() + defer repo.mu.Unlock() + require.Len(t, repo.logs, 1) + entry := repo.logs[0] + require.Equal(t, "/api/v1/admin/accounts/123/forbidden", entry.Path) + require.Equal(t, "permission_denied", entry.Extra["denial_reason"]) + require.Equal(t, 403, entry.StatusCode) + require.Equal(t, "key-id", entry.Extra["admin_key_id"]) + require.Equal(t, "审计身份", entry.Extra["admin_key_name"]) + data, err := json.Marshal(entry) + require.NoError(t, err) + require.NotContains(t, string(data), "private-secret") + require.NotContains(t, string(data), "private-hash") +} + +func TestScopedAdminKeyMissingDependenciesFailClosed(t *testing.T) { + r := gin.New() + r.Use(gin.HandlerFunc(NewAdminAuthMiddleware(nil, nil, nil, nil))) + r.GET("/api/v1/admin/users", func(c *gin.Context) { t.Fatal("不应执行") }) + req := httptest.NewRequest("GET", "/api/v1/admin/users", nil) + req.Header.Set("x-api-key", "adm_sc_invalid") + w := httptest.NewRecorder() + require.NotPanics(t, func() { r.ServeHTTP(w, req) }) + require.Equal(t, 401, w.Code) +} diff --git a/backend/internal/server/middleware/panel_rate_limit.go b/backend/internal/server/middleware/panel_rate_limit.go index 1d7846997a53..b9f2453d0f14 100644 --- a/backend/internal/server/middleware/panel_rate_limit.go +++ b/backend/internal/server/middleware/panel_rate_limit.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/middleware" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -18,6 +19,8 @@ import ( // panelRateLimitWindow 面板限流固定窗口时长(所有档位均按每分钟计数)。 const panelRateLimitWindow = time.Minute +const panelHeavyRateLimitChecked = "panel_heavy_rate_limit_checked" + // panelRateLimitAllower 抽象底层限流原语,便于单测注入。 type panelRateLimitAllower interface { Allow(ctx context.Context, key string, limit int, window time.Duration) (middleware.AllowResult, error) @@ -26,7 +29,7 @@ type panelRateLimitAllower interface { // PanelRateLimiter 面板(管理面 /api/v1)API 限流器。 // // 设计要点: -// - 认证接口按「用户 ID」维度计数:与客户端 IP 完全无关,反向代理/共享出口 +// - 认证接口按「用户 ID」维度计数,受限管理密钥按 Key ID 独立计数:与客户端 IP 完全无关,反向代理/共享出口 // (所有请求源 IP 坍缩为 127.0.0.1 等)不会互相误伤。 // - 公开接口按安全客户端 IP 计数:仅统计全局单播地址,回环/内网/链路本地 // 地址(反代内部转发地址)直接跳过,避免误拦整条反代链路的流量。 @@ -46,6 +49,7 @@ func NewPanelRateLimiter(redisClient *redis.Client, settingService *service.Sett } // Global 认证面板接口的全局按用户限流(宽松档,覆盖所有登录后端点)。 +// 受限管理密钥不享管理员豁免,变更类请求额外消耗严格档额度。 func (p *PanelRateLimiter) Global() gin.HandlerFunc { return p.userScoped("global", func(s service.PanelRateLimitSettings) int { return s.UserRPM }) } @@ -67,35 +71,52 @@ func (p *PanelRateLimiter) userScoped(scope string, limitOf func(service.PanelRa c.Next() return } - limit := limitOf(settings) - if limit <= 0 { - c.Next() - return - } subject, ok := GetAuthSubjectFromContext(c) if !ok || subject.UserID <= 0 { // 无认证主体(认证中间件缺位时的防御分支):放行,避免误伤 c.Next() return } - if settings.ExemptAdmin { + principal := adminaccess.FromContext(c.Request.Context()) + if settings.ExemptAdmin && principal == nil { if role, hasRole := GetUserRoleFromContext(c); hasRole && role == service.RoleAdmin { c.Next() return } } - key := "panel:" + scope + ":user:" + strconv.FormatInt(subject.UserID, 10) - result, err := p.limiter.Allow(c.Request.Context(), key, limit, panelRateLimitWindow) - if err != nil { - // fail-open:Redis 异常不阻断面板访问 - slog.Warn("panel rate limit check failed, allowing request", "scope", scope, "error", err) - c.Next() - return + identity := "user:" + strconv.FormatInt(subject.UserID, 10) + if principal != nil { + identity = "admin-key:" + principal.KeyID } - if !result.Allowed { - abortPanelRateLimited(c, result.RetryAfter) - return + type rateLimitCheck struct { + scope string + limit int + } + checks := []rateLimitCheck{{scope, limitOf(settings)}} + if scope == "global" && principal != nil && c.Request.Method != http.MethodGet && + c.Request.Method != http.MethodHead && c.Request.Method != http.MethodOptions { + checks = append(checks, rateLimitCheck{"heavy", settings.HeavyRPM}) + } + for _, check := range checks { + if check.limit <= 0 || (principal != nil && check.scope == "heavy" && c.GetBool(panelHeavyRateLimitChecked)) { + continue + } + if principal != nil && check.scope == "heavy" { + // Global 已为受限密钥写入计数,路由再挂 Heavy 时不能重复消耗额度。 + c.Set(panelHeavyRateLimitChecked, true) + } + key := "panel:" + check.scope + ":" + identity + result, err := p.limiter.Allow(c.Request.Context(), key, check.limit, panelRateLimitWindow) + if err != nil { + // fail-open:Redis 异常不阻断面板访问。 + slog.Warn("panel rate limit check failed, allowing request", "scope", check.scope, "error", err) + continue + } + if !result.Allowed { + abortPanelRateLimited(c, result.RetryAfter) + return + } } c.Next() } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 315fc20e4258..fbeac5fe11e7 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -30,6 +30,8 @@ func RegisterAdminRoutes( // 审计中间件挂在认证之后:所有管理面变更类操作 + 敏感读取入审计日志 admin.Use(gin.HandlerFunc(auditLog)) admin.Use(middleware.AdminComplianceGuard(settingService)) + // 受限密钥的账户操作走独立资源校验,普通管理员保持原处理器。 + admin.Use(h.Admin.Account.ScopedAdminKeyAccounts) { // 部署与运营合规确认 registerAdminComplianceRoutes(admin, h) @@ -74,7 +76,7 @@ func RegisterAdminRoutes( registerPromoCodeRoutes(admin, h) // 系统设置 - registerSettingsRoutes(admin, h) + registerSettingsRoutes(admin, h, stepUpAuth) // 数据管理 registerDataManagementRoutes(admin, h, stepUpAuth) @@ -326,6 +328,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { groups := admin.Group("/groups") + groups.Use(h.Admin.Group.ScopedAdminKeyGroupModels) { groups.GET("", h.Admin.Group.List) groups.GET("/all", h.Admin.Group.GetAll) @@ -564,8 +567,9 @@ func registerPromoCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { } } -func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { +func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAuth middleware.StepUpAuthMiddleware) { adminSettings := admin.Group("/settings") + registerScopedAdminKeyRoutes(adminSettings, h, stepUpAuth) { adminSettings.GET("", h.Admin.Setting.GetSettings) adminSettings.PUT("", h.Admin.Setting.UpdateSettings) @@ -578,8 +582,8 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { adminSettings.POST("/email-templates/:event/:locale/restore-official", h.Admin.Setting.RestoreOfficialEmailTemplate) // Admin API Key 管理 adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey) - adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey) - adminSettings.DELETE("/admin-api-key", h.Admin.Setting.DeleteAdminAPIKey) + adminSettings.POST("/admin-api-key/regenerate", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.RegenerateAdminAPIKey) + adminSettings.DELETE("/admin-api-key", gin.HandlerFunc(stepUpAuth), h.Admin.Setting.DeleteAdminAPIKey) // 529过载冷却配置 adminSettings.GET("/overload-cooldown", h.Admin.Setting.GetOverloadCooldownSettings) adminSettings.PUT("/overload-cooldown", h.Admin.Setting.UpdateOverloadCooldownSettings) diff --git a/backend/internal/server/routes/custom_admin_key_access_test.go b/backend/internal/server/routes/custom_admin_key_access_test.go new file mode 100644 index 000000000000..32794e1b8812 --- /dev/null +++ b/backend/internal/server/routes/custom_admin_key_access_test.go @@ -0,0 +1,420 @@ +//go:build unit + +package routes + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/handler" + adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type scopedRouteKeys struct { + service.ScopedAdminKeyRepository + item *service.ScopedAdminKey +} + +func (r *scopedRouteKeys) InsertScopedAdminKey(_ context.Context, item *service.ScopedAdminKey) error { + r.item = item + return nil +} +func (r *scopedRouteKeys) GetScopedAdminKey(context.Context, string) (*service.ScopedAdminKey, error) { + return r.item, nil +} +func (r *scopedRouteKeys) TouchScopedAdminKey(context.Context, string, string, time.Time, string, time.Time) error { + return nil +} + +type scopedRouteUsers struct{ service.UserRepository } + +type scopedRouteModelSettings struct{ service.SettingRepository } + +func (*scopedRouteModelSettings) Get(context.Context, string) (*service.Setting, error) { + return nil, service.ErrSettingNotFound +} + +func (*scopedRouteUsers) GetByID(context.Context, int64) (*service.User, error) { + return &service.User{ID: 9, Role: service.RoleAdmin, Status: service.StatusActive}, nil +} +func (*scopedRouteUsers) GetUserAvatar(context.Context, int64) (*service.UserAvatar, error) { + return nil, nil +} + +type scopedRouteAccounts struct { + service.AdminService + t *testing.T +} + +func (*scopedRouteAccounts) GetAccount(_ context.Context, id int64) (*service.Account, error) { + a := &service.Account{ID: id, Type: "oauth", Platform: "openai", GroupIDs: []int64{1, 2}, Credentials: map[string]any{"access_token": "绝不返回的凭证", "model_mapping": map[string]any{"custom-model": "upstream-model"}}, Extra: map[string]any{"secret": "绝不返回的扩展"}} + if id == 2 { + a.Type = "apikey" + } + if id == 3 { + a.GroupIDs = []int64{2} + } + if id == 4 { + a.Platform = "anthropic" + } + if id == 5 { + a.Credentials["auth_mode"] = service.OpenAIAuthModePersonalAccessToken + } + if id == 6 { + a.Credentials["auth_mode"] = service.OpenAIAuthModeAgentIdentity + a.Credentials["agent_private_key"] = "绝不返回的代理身份私钥" + } + return a, nil +} +func (s *scopedRouteAccounts) ScopedAdminList(context.Context, pagination.PaginationParams, string, string, string, string) ([]service.Account, int64, error) { + s.t.Fatal("本测试中列表必须在权限准入阶段被拒绝") + return nil, 0, nil +} +func (s *scopedRouteAccounts) ScopedAdminWrite(context.Context, int64, string, map[string]json.RawMessage, func(context.Context, *service.Account) (*service.Account, error)) (*service.Account, error) { + s.t.Fatal("未授予写权限,不应进入写服务") + return nil, nil +} + +func TestScopedAdminRegisteredRoutesEnforceDefaultDenyAndAccountScope(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, withRead := range []bool{false, true} { + t.Run(map[bool]string{false: "仅分组不赋权", true: "只读OAuth指定组"}[withRead], func(t *testing.T) { + rule := adminaccess.Rule{GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}} + if withRead { + rule.Permissions = []string{"accounts.read"} + } + keys := &scopedRouteKeys{} + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(keys) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "路由验收", Enabled: true, Policy: adminaccess.Policy{Rules: []adminaccess.Rule{rule}}}, 9) + require.NoError(t, err) + users := service.NewUserService(&scopedRouteUsers{}, nil, nil, nil) + auth := middleware.NewAdminAuthMiddleware(nil, users, settings, nil) + accountHandler := adminhandler.NewAccountHandler(&scopedRouteAccounts{t: t}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{Account: accountHandler, Setting: adminhandler.NewSettingHandler(settings, nil, nil, nil, nil, nil, nil)}}, auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + RegisterPaymentRoutes(r.Group("/api/v1"), &handler.PaymentHandler{}, &handler.PaymentWebhookHandler{}, &adminhandler.PaymentHandler{}, middleware.JWTAuthMiddleware(func(c *gin.Context) { c.Next() }), auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), nil, nil, nil) + for _, tc := range []struct{ method, path string }{ + {"GET", "/api/v1/admin/accounts/1"}, + {"GET", "/api/v1/admin/accounts/2"}, + {"GET", "/api/v1/admin/accounts/3"}, + {"GET", "/api/v1/admin/accounts/5"}, + {"GET", "/api/v1/admin/accounts/6"}, + {"DELETE", "/api/v1/admin/accounts/1"}, + {"GET", "/api/v1/admin/users"}, + {"PUT", "/api/v1/admin/settings"}, + {"GET", "/api/v1/admin/settings/admin-api-keys"}, + {"GET", "/api/v1/admin/groups"}, + {"GET", "/api/v1/admin/payment/orders"}, + {"POST", "/api/v1/admin/payment/orders/1/refund"}, + {"PUT", "/api/v1/admin/payment/config"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + want := 403 + if withRead && tc.method == "GET" { + switch tc.path { + case "/api/v1/admin/accounts/1", "/api/v1/admin/accounts/5", "/api/v1/admin/accounts/6": + want = 200 + case "/api/v1/admin/accounts/2", "/api/v1/admin/accounts/3": + want = 404 + } + } + require.Equal(t, want, w.Code, tc.method+" "+tc.path+": "+w.Body.String()) + require.NotContains(t, w.Body.String(), "绝不返回") + require.NotContains(t, w.Body.String(), "auth_mode") + require.NotContains(t, w.Body.String(), "agent_private_key") + if want == 200 { + require.Contains(t, w.Body.String(), `"group_ids":[1]`) + } + } + }) + } +} + +func TestScopedAdminRegisteredModelDefaultsRequireExplicitPermission(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, allowed := range []bool{false, true} { + policy := adminaccess.Policy{} + if allowed { + policy.GlobalPermissions = []string{"models.defaults.read"} + } + keys := &scopedRouteKeys{} + settings := service.NewSettingService(&scopedRouteModelSettings{}, nil) + settings.SetScopedAdminKeyRepository(keys) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "默认模型读取", Enabled: true, Policy: policy}, 9) + require.NoError(t, err) + users := service.NewUserService(&scopedRouteUsers{}, nil, nil, nil) + auth := middleware.NewAdminAuthMiddleware(nil, users, settings, nil) + accountHandler := adminhandler.NewAccountHandler(&scopedRouteAccounts{t: t}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{Account: accountHandler, Setting: adminhandler.NewSettingHandler(settings, nil, nil, nil, nil, nil, nil)}}, auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + for _, path := range []string{"/api/v1/admin/accounts/antigravity/default-model-mapping"} { + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + want := 403 + if allowed { + want = 200 + } + require.Equal(t, want, w.Code, w.Body.String()) + if allowed { + require.Contains(t, w.Body.String(), `"data":{`) + } + } + } +} + +// 使用真实注册路由,验证旧 Key 的模型读写入口也能进入受限处理器。 +func TestScopedAdminRegisteredLegacyModelInterfaces(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, permission := range []string{"accounts.read", "accounts.models.write", "models.defaults.read"} { + t.Run(permission, func(t *testing.T) { + policy := adminaccess.Policy{} + if permission == "models.defaults.read" { + policy.GlobalPermissions = []string{permission} + } else { + policy.Rules = []adminaccess.Rule{{Permissions: []string{permission}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}, Platforms: []string{"openai"}}} + } + keys := &scopedRouteKeys{} + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(keys) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "原模型接口", Enabled: true, Policy: policy}, 9) + require.NoError(t, err) + users := service.NewUserService(&scopedRouteUsers{}, nil, nil, nil) + auth := middleware.NewAdminAuthMiddleware(nil, users, settings, nil) + accounts := &scopedRouteModelAccounts{scopedRouteAccounts: scopedRouteAccounts{t: t}, t: t} + accountHandler := adminhandler.NewAccountHandler(accounts, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{Account: accountHandler}}, auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + for _, tc := range []struct { + method, path, body string + want int + }{ + {"GET", "/api/v1/admin/accounts/1/models", "", 200}, + {"GET", "/api/v1/admin/accounts/2/models", "", 404}, + {"GET", "/api/v1/admin/accounts/3/models", "", 404}, + {"GET", "/api/v1/admin/accounts/4/models", "", 404}, + {"GET", "/api/v1/admin/accounts/1", "", 200}, + {"POST", "/api/v1/admin/accounts/bulk-update", `{"account_ids":[1],"credentials":{"model_mapping":{"custom-model":"upstream-model"}}}`, 200}, + {"PUT", "/api/v1/admin/accounts/1", `{"credentials":{"model_mapping":{"custom-model":"upstream-model"}}}`, 200}, + } { + want := tc.want + if (tc.method == "GET" && permission != "accounts.read") || (tc.method != "GET" && permission != "accounts.models.write") { + want = 403 + } + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("x-api-key", key) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, want, w.Code, tc.method+" "+tc.path+": "+w.Body.String()) + require.NotContains(t, w.Body.String(), "绝不返回") + if tc.method == "GET" && want == 200 { + require.Contains(t, w.Body.String(), "custom-model") + } + } + if permission == "accounts.models.write" { + require.Equal(t, []int64{1, 1}, accounts.writes) + } else { + require.Empty(t, accounts.writes) + } + }) + } +} + +type scopedRouteModelAccounts struct { + scopedRouteAccounts + t *testing.T + writes []int64 +} + +func (s *scopedRouteModelAccounts) ScopedAdminWrite(_ context.Context, id int64, action string, fields map[string]json.RawMessage, run func(context.Context, *service.Account) (*service.Account, error)) (*service.Account, error) { + require.Empty(s.t, action) + require.Nil(s.t, run) + body, err := json.Marshal(fields) + require.NoError(s.t, err) + require.JSONEq(s.t, `{"credentials":{"model_mapping":{"custom-model":"upstream-model"}}}`, string(body)) + s.writes = append(s.writes, id) + return &service.Account{ID: id}, nil +} + +func TestScopedAdminCodexImportRouteReachesValidation(t *testing.T) { + gin.SetMode(gin.TestMode) + keys := &scopedRouteKeys{} + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(keys) + policy := adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.create", "accounts.credentials"}, GroupIDs: []int64{2}, AccountTypes: []string{"oauth"}}}} + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "导入回归", Enabled: true, Policy: policy}, 9) + require.NoError(t, err) + users := service.NewUserService(&scopedRouteUsers{}, nil, nil, nil) + auth := middleware.NewAdminAuthMiddleware(nil, users, settings, nil) + accountHandler := adminhandler.NewAccountHandler(&scopedRouteAccounts{t: t}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{Account: accountHandler}}, auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + req := httptest.NewRequest("POST", "/api/v1/admin/accounts/import/codex-session", strings.NewReader(`{"content":"","group_ids":[2]}`)) + req.Header.Set("x-api-key", key) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 400, w.Code, w.Body.String()) +} + +type scopedRouteCodexAccounts struct { + service.AdminService + created bool +} + +func (*scopedRouteCodexAccounts) ScopedAdminCodexCandidates(context.Context, service.ScopedCodexIdentity) ([]service.Account, error) { + return nil, nil +} +func (s *scopedRouteCodexAccounts) ScopedAdminCreateCodex(_ context.Context, input *service.CreateAccountInput) (*service.Account, error) { + s.created = true + return &service.Account{ID: 100, Credentials: input.Credentials}, nil +} +func (*scopedRouteCodexAccounts) ScopedAdminUpdateCodex(context.Context, int64, *service.UpdateAccountInput, func(*service.Account, *service.UpdateAccountInput) error) (*service.Account, error) { + panic("本用例不能更新已有账户") +} +func TestScopedAdminCodexImportAuthenticatedRoute(t *testing.T) { + for _, allowed := range []bool{false, true} { + t.Run(map[bool]string{false: "未勾选拒绝", true: "无读取权限可导入"}[allowed], func(t *testing.T) { + gin.SetMode(gin.TestMode) + rule := adminaccess.Rule{Permissions: []string{"accounts.credentials", "accounts.schedule"}, GroupIDs: []int64{2}, AccountTypes: []string{"oauth"}} + if allowed { + rule.Permissions = append(rule.Permissions, "accounts.create") + } + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(&scopedRouteKeys{}) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "导入鉴权", Enabled: true, Policy: adminaccess.Policy{Rules: []adminaccess.Rule{rule}}}, 9) + require.NoError(t, err) + users := service.NewUserService(&scopedRouteUsers{}, nil, nil, nil) + auth := middleware.NewAdminAuthMiddleware(nil, users, settings, nil) + api := &scopedRouteCodexAccounts{} + h := adminhandler.NewAccountHandler(api, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{Account: h}}, auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + req := httptest.NewRequest("POST", "/api/v1/admin/accounts/import/codex-session", strings.NewReader(`{"content":"{\"access_token\":\"test-private-access\",\"refresh_token\":\"test-private-refresh\"}","group_ids":[2]}`)) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if allowed { + require.Equal(t, 200, w.Code, w.Body.String()) + require.Contains(t, w.Body.String(), `"created":1`) + } else { + require.Equal(t, 403, w.Code, w.Body.String()) + } + require.Equal(t, allowed, api.created) + require.NotContains(t, w.Body.String(), "test-private") + }) + } +} + +type scopedRouteProxyOptions struct { + service.AdminService + page, size int +} + +func (s *scopedRouteProxyOptions) ListProxyOptions(_ context.Context, page, size int, _ string) ([]service.Proxy, int64, error) { + s.page = page + s.size = size + return []service.Proxy{{ID: 10, Name: "代理候选", Host: "192.0.2.1", Port: 8080, Username: "秘密用户", Password: "秘密密码", Status: service.StatusActive}}, 20001, nil +} +func (s *scopedRouteProxyOptions) SampleProxyOptions(_ context.Context, _ string) ([]service.Proxy, int64, error) { + s.page = 0 + s.size = 50 + return []service.Proxy{{ID: 12345, Name: "全池候选", Username: "秘密用户", Password: "秘密密码"}}, 20001, nil +} + +func TestScopedAdminProxyReadPermissionAndBoundedRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, allowed := range []bool{false, true} { + t.Run(map[bool]string{false: "网络权限不含列表", true: "独立代理读取"}[allowed], func(t *testing.T) { + policy := adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.network"}, AllGroups: true, AccountTypes: []string{"oauth"}}}} + if allowed { + policy.GlobalPermissions = []string{"proxies.read"} + } + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(&scopedRouteKeys{}) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "代理读取", Enabled: true, Policy: policy}, 9) + require.NoError(t, err) + users := service.NewUserService(&scopedRouteUsers{}, nil, nil, nil) + auth := middleware.NewAdminAuthMiddleware(nil, users, settings, nil) + svc := &scopedRouteProxyOptions{} + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{Proxy: adminhandler.NewProxyHandler(svc)}}, auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + for _, path := range []string{"/api/v1/admin/proxies", "/api/v1/admin/proxies/all"} { + req := httptest.NewRequest("GET", path+"?page=2&page_size=20000", nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if !allowed { + require.Equal(t, 403, w.Code, w.Body.String()) + continue + } + require.Equal(t, 200, w.Code, w.Body.String()) + require.Equal(t, 2, svc.page) + require.Equal(t, 100, svc.size) + require.NotContains(t, w.Body.String(), "username") + require.NotContains(t, w.Body.String(), "password") + require.NotContains(t, w.Body.String(), "秘密") + if strings.HasSuffix(path, "/all") { + require.Equal(t, "20001", w.Header().Get("X-Total-Count")) + require.Equal(t, "100", w.Header().Get("X-Page-Size")) + require.Contains(t, w.Body.String(), `"data":[`) + } else { + require.Contains(t, w.Body.String(), `"total":20001`) + require.Contains(t, w.Body.String(), `"page_size":100`) + } + } + req := httptest.NewRequest("GET", "/api/v1/admin/proxies/all", nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if allowed { + require.Equal(t, 200, w.Code, w.Body.String()) + require.Equal(t, 0, svc.page) + require.Equal(t, 50, svc.size) + require.Contains(t, w.Body.String(), `"id":12345`) + require.NotContains(t, w.Body.String(), "秘密") + } else { + require.Equal(t, 403, w.Code) + } + for _, path := range []string{"/api/v1/admin/proxies/data", "/api/v1/admin/proxies/10/accounts", "/api/v1/admin/proxies/10/stats"} { + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 403, w.Code) + } + }) + } +} + +func TestScopedAdminRegisteredRoutesExcludeForkOnlyEndpoints(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{}}, middleware.AdminAuthMiddleware(func(c *gin.Context) { c.Next() }), middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + paths := map[string]bool{} + for _, route := range r.Routes() { + paths[route.Method+" "+route.Path] = true + } + for _, path := range []string{"GET /api/v1/admin/model-catalog", "GET /api/v1/admin/proxies/options", "GET /api/v1/admin/groups/:id/models-list-candidates"} { + require.False(t, paths[path], path) + } + for _, path := range []string{"GET /api/v1/admin/proxies", "GET /api/v1/admin/proxies/all", "GET /api/v1/admin/groups/:id/model-allowlist-candidates", "POST /api/v1/admin/accounts/import/codex-session"} { + require.True(t, paths[path], path) + } +} diff --git a/backend/internal/server/routes/custom_admin_key_group_models_test.go b/backend/internal/server/routes/custom_admin_key_group_models_test.go new file mode 100644 index 000000000000..05657e6719c4 --- /dev/null +++ b/backend/internal/server/routes/custom_admin_key_group_models_test.go @@ -0,0 +1,203 @@ +//go:build unit + +package routes + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/handler" + adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type scopedRouteGroupModels struct { + service.AdminService + writes []int64 +} + +func (*scopedRouteGroupModels) GetGroup(_ context.Context, id int64) (*service.Group, error) { + if id == 99 { + return nil, service.ErrGroupNotFound + } + platform := "openai" + if id == 4 { + platform = "anthropic" + } + return &service.Group{ID: id, Name: "模型分组", Platform: platform, RateMultiplier: 9, Description: "不可泄露的分组备注", ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"gpt-test"}}}, nil +} + +func (*scopedRouteGroupModels) GetGroupModelsListCandidates(context.Context, int64, string) ([]string, error) { + return []string{"gpt-test", "gpt-default"}, nil +} + +func (s *scopedRouteGroupModels) ScopedAdminListGroupModels(ctx context.Context, _ pagination.PaginationParams, _ bool, _, _, _ string) ([]service.Group, int64, error) { + g, err := s.GetGroup(ctx, 2) + return []service.Group{*g}, 1, err +} + +func (s *scopedRouteGroupModels) ScopedAdminUpdateGroupModels(ctx context.Context, id int64, cfg service.GroupModelAllowlist) (*service.Group, error) { + s.writes = append(s.writes, id) + g, err := s.GetGroup(ctx, id) + if err == nil { + g.ModelAllowlist = cfg + } + return g, err +} + +func TestScopedAdminRegisteredGroupModelInterfaces(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, permission := range []string{"groups.models.read", "groups.models.write", "accounts.models.write", "models.defaults.read", ""} { + t.Run(permission, func(t *testing.T) { + keys := &scopedRouteKeys{} + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(keys) + policy := adminaccess.Policy{} + if permission != "" { + policy.Rules = []adminaccess.Rule{{Permissions: []string{permission}, GroupIDs: []int64{2}, AccountTypes: []string{"oauth"}, Platforms: []string{"openai"}}} + } + if permission == "models.defaults.read" { + policy = adminaccess.Policy{GlobalPermissions: []string{permission}} + } + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "分组模型接口", Enabled: true, Policy: policy}, 9) + require.NoError(t, err) + users := service.NewUserService(&scopedRouteUsers{}, nil, nil, nil) + auth := middleware.NewAdminAuthMiddleware(nil, users, settings, nil) + api := &scopedRouteGroupModels{} + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{Group: adminhandler.NewGroupHandler(api, nil, nil)}}, auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + for _, tc := range []struct { + method, path, body, allowed string + }{ + {"GET", "/api/v1/admin/groups", "", "groups.models.read"}, + {"GET", "/api/v1/admin/groups/all", "", "groups.models.read"}, + {"GET", "/api/v1/admin/groups/2", "", "groups.models.read"}, + {"GET", "/api/v1/admin/groups/2/model-allowlist-candidates", "", "groups.models.read"}, + {"GET", "/api/v1/admin/groups/0/model-allowlist-candidates?platform=openai", "", "models.defaults.read"}, + {"PUT", "/api/v1/admin/groups/2", `{"model_allowlist":{"enabled":true,"models":["gpt-test"]}}`, "groups.models.write"}, + } { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("x-api-key", key) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + want := 403 + if permission == tc.allowed { + want = 200 + } + if permission == "models.defaults.read" && strings.Contains(tc.path, "/groups/2/") { + want = 404 // 默认目录权限不允许读取现有分组中的账户模型。 + } + require.Equal(t, want, w.Code, tc.method+" "+tc.path+": "+w.Body.String()) + require.NotContains(t, w.Body.String(), "不可泄露") + require.NotContains(t, w.Body.String(), "models_list_config") + require.NotContains(t, w.Body.String(), "display_only") + if want == 200 { + require.Contains(t, w.Body.String(), "gpt-test") + } + } + }) + } +} + +func TestScopedAdminGroupModelsRejectUnauthorizedGroupsAndMixedFields(t *testing.T) { + gin.SetMode(gin.TestMode) + api := &scopedRouteGroupModels{} + h := adminhandler.NewGroupHandler(api, nil, nil) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(adminaccess.WithPrincipal(c.Request.Context(), &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"groups.models.read", "groups.models.write"}, GroupIDs: []int64{2, 4}, Platforms: []string{"openai"}}}}})) + }) + r.Use(h.ScopedAdminKeyGroupModels) + r.GET("/api/v1/admin/groups/:id", h.GetByID) + r.GET("/api/v1/admin/groups/:id/model-allowlist-candidates", h.GetGroupModelAllowlistCandidates) + r.PUT("/api/v1/admin/groups/:id", h.Update) + for _, tc := range []struct { + method, path, body string + want int + }{ + {"GET", "/api/v1/admin/groups/3", "", 404}, + {"GET", "/api/v1/admin/groups/4", "", 404}, + {"GET", "/api/v1/admin/groups/99", "", 404}, + {"GET", "/api/v1/admin/groups/3/model-allowlist-candidates", "", 404}, + {"GET", "/api/v1/admin/groups/0/model-allowlist-candidates", "", 403}, + {"GET", "/api/v1/admin/groups/2/model-allowlist-candidates?platform=anthropic", "", 400}, + {"PUT", "/api/v1/admin/groups/3", `{"model_allowlist":{"models":["x"]}}`, 404}, + {"PUT", "/api/v1/admin/groups/4", `{"model_allowlist":{"models":["x"]}}`, 404}, + {"PUT", "/api/v1/admin/groups/2", `{"rate_multiplier":1,"model_allowlist":{"models":["x"]}}`, 400}, + {"PUT", "/api/v1/admin/groups/2", `{"copy_accounts_from_group_ids":[3]}`, 400}, + {"PUT", "/api/v1/admin/groups/2", `{"status":"inactive"}`, 400}, + {"PUT", "/api/v1/admin/groups/2", `{"model_allowlist":{},"models_list_config":{}}`, 400}, + {"PUT", "/api/v1/admin/groups/2", `{"model_allowlist":{"foo":true}}`, 400}, + {"PUT", "/api/v1/admin/groups/2", `{"model_allowlist":null}`, 400}, + {"PUT", "/api/v1/admin/groups/2", `{"models_list_config":{"enabled":true,"models":["x"]}}`, 400}, + } { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, tc.want, w.Code, tc.method+" "+tc.path+": "+w.Body.String()) + require.NotContains(t, w.Body.String(), "不可泄露") + require.NotContains(t, w.Body.String(), "模型分组") + } + require.Empty(t, api.writes) +} + +type scopedRouteAllowlistRepository struct { + service.AdminGroupRepository + config service.GroupModelAllowlist + writes int +} + +func (r *scopedRouteAllowlistRepository) GetByID(_ context.Context, id int64) (*service.Group, error) { + return &service.Group{ID: id, Platform: service.PlatformOpenAI, ModelAllowlist: r.config}, nil +} +func (r *scopedRouteAllowlistRepository) ScopedAdminUpdateGroupModels(ctx context.Context, id int64, cfg service.GroupModelAllowlist) (*service.Group, error) { + r.config = cfg + r.writes++ + return r.GetByID(ctx, id) +} + +// 注册路由与真实管理服务共同验证白名单准入语义,仓储替身仅代替持久化。 +func TestScopedAdminRegisteredGroupModelsUseUpstreamAllowlistSemantics(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &scopedRouteAllowlistRepository{} + api := service.NewAdminService(nil, nil, repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(&scopedRouteKeys{}) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "白名单验证", Enabled: true, Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"groups.models.read", "groups.models.write"}, GroupIDs: []int64{2}, Platforms: []string{service.PlatformOpenAI}}}}}, 9) + require.NoError(t, err) + auth := middleware.NewAdminAuthMiddleware(nil, service.NewUserService(&scopedRouteUsers{}, nil, nil, nil), settings, nil) + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{Group: adminhandler.NewGroupHandler(api, nil, nil)}}, auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + call := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest("PUT", "/api/v1/admin/groups/2", strings.NewReader(body)) + req.Header.Set("x-api-key", key) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w + } + for _, body := range []string{`{"model_allowlist":{"enabled":true,"models":[]}}`, `{"models_list_config":{"enabled":true,"models":["gpt-allowed"]}}`} { + w := call(body) + require.Equal(t, 400, w.Code, w.Body.String()) + require.Zero(t, repo.writes) + } + w := call(`{"model_allowlist":{"enabled":true,"models":[" GPT-Allowed ","gpt-allowed","other-*"]}}`) + require.Equal(t, 200, w.Code, w.Body.String()) + require.Equal(t, 1, repo.writes) + require.Equal(t, []string{"GPT-Allowed", "other-*"}, repo.config.Models) + require.True(t, repo.config.Allows("gpt-allowed")) + require.True(t, repo.config.Allows("other-model")) + require.False(t, repo.config.Allows("blocked-model")) + require.Contains(t, w.Body.String(), `"model_allowlist"`) + require.NotContains(t, w.Body.String(), "models_list_config") + require.NotContains(t, w.Body.String(), "display_only") +} diff --git a/backend/internal/server/routes/custom_admin_key_payment_rate_limit_test.go b/backend/internal/server/routes/custom_admin_key_payment_rate_limit_test.go new file mode 100644 index 000000000000..7db8f6af69d3 --- /dev/null +++ b/backend/internal/server/routes/custom_admin_key_payment_rate_limit_test.go @@ -0,0 +1,54 @@ +//go:build unit + +package routes + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/handler" + adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/alicebob/miniredis/v2" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +type scopedPaymentRateSettings struct{ service.SettingRepository } + +func (*scopedPaymentRateSettings) GetValue(_ context.Context, key string) (string, error) { + if key == service.SettingKeyPanelRateLimitSettings { + return `{"enabled":true,"user_rpm":100,"heavy_rpm":1,"exempt_admin":true}`, nil + } + return "", service.ErrSettingNotFound +} + +func TestScopedAdminPaymentWritesUseHeavyRateLimitWithAdminExemption(t *testing.T) { + gin.SetMode(gin.TestMode) + redisServer := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + settings := service.NewSettingService(&scopedPaymentRateSettings{}, nil) + settings.SetScopedAdminKeyRepository(&scopedRouteKeys{}) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{Name: "支付取消", Enabled: true, Policy: adminaccess.Policy{GlobalPermissions: []string{"payment.cancel"}}}, 9) + require.NoError(t, err) + auth := middleware.NewAdminAuthMiddleware(nil, service.NewUserService(&scopedRouteUsers{}, nil, nil, nil), settings, nil) + r := gin.New() + RegisterPaymentRoutes(r.Group("/api/v1"), &handler.PaymentHandler{}, &handler.PaymentWebhookHandler{}, &adminhandler.PaymentHandler{}, middleware.JWTAuthMiddleware(func(c *gin.Context) { c.Next() }), auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), nil, middleware.NewPanelRateLimiter(client, settings), client) + // 无效订单 ID 在实际支付处理器返回 400,证明首个请求已经通过认证和授权。 + // 第二次应在解析订单之前触发严格档限流,不依赖真实支付数据库。 + for _, want := range []int{400, 429} { + req := httptest.NewRequest("POST", "/api/v1/admin/payment/orders/invalid/cancel", nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, want, w.Code, w.Body.String()) + if want == 429 { + require.NotEmpty(t, w.Header().Get("Retry-After")) + } + } +} diff --git a/backend/internal/server/routes/custom_admin_key_user_responses_test.go b/backend/internal/server/routes/custom_admin_key_user_responses_test.go new file mode 100644 index 000000000000..1260e7958402 --- /dev/null +++ b/backend/internal/server/routes/custom_admin_key_user_responses_test.go @@ -0,0 +1,134 @@ +//go:build unit + +package routes + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/handler" + adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type scopedUserResponses struct { + service.AdminService + filters service.UserListFilters +} + +func scopedUserFixture() *service.User { + return &service.User{ + ID: 7, Email: "user@example.com", Username: "测试用户", Role: "user", Status: "active", + Balance: 123, FrozenBalance: 12, TotalRecharged: 456, Notes: "内部敏感备注", + AllowedGroups: []int64{3}, RestrictPublicGroups: true, GroupRates: map[int64]float64{3: 0.8}, + APIKeys: []service.APIKey{{ID: 8, Key: "绝不返回的用户密钥"}}, + Subscriptions: []service.UserSubscription{{ID: 2, DailyUsageUSD: 11, WeeklyUsageUSD: 22, MonthlyUsageUSD: 33, + Group: &service.Group{ID: 3, Name: "内部定价分组", RateMultiplier: 2}}}, + } +} + +func (s *scopedUserResponses) ListUsers(_ context.Context, _, _ int, filters service.UserListFilters, _, _ string) ([]service.User, int64, error) { + s.filters = filters + // 故意保留关联数据,验证输出边界本身也能防止泄露。 + return []service.User{*scopedUserFixture()}, 1, nil +} +func (*scopedUserResponses) GetUser(context.Context, int64) (*service.User, error) { + return scopedUserFixture(), nil +} +func (*scopedUserResponses) GetUserIncludeDeleted(context.Context, int64) (*service.User, error) { + return scopedUserFixture(), nil +} +func (*scopedUserResponses) UpdateUserBalance(context.Context, int64, float64, string, string) (*service.User, error) { + return scopedUserFixture(), nil +} + +func TestScopedAdminUserResponsesRequireIndependentPermissions(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name string + permissions []string + }{ + {"仅用户资料", []string{"users.read"}}, + {"同时授权余额", []string{"users.read", "users.balance.read"}}, + {"同时授权分组", []string{"users.read", "groups.read"}}, + {"同时授权订阅", []string{"users.read", "subscriptions.read"}}, + {"仅余额写入", []string{"users.balance.write"}}, + } { + t.Run(tc.name, func(t *testing.T) { + keys := &scopedRouteKeys{} + settings := service.NewSettingService(nil, nil) + settings.SetScopedAdminKeyRepository(keys) + key, _, err := settings.CreateScopedAdminKey(context.Background(), service.ScopedAdminKeyInput{ + Name: "用户字段权限", Enabled: true, Policy: adminaccess.Policy{GlobalPermissions: tc.permissions}, + }, 9) + require.NoError(t, err) + auth := middleware.NewAdminAuthMiddleware(nil, service.NewUserService(&scopedRouteUsers{}, nil, nil, nil), settings, nil) + stub := &scopedUserResponses{} + r := gin.New() + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{ + Account: &adminhandler.AccountHandler{}, User: adminhandler.NewUserHandler(stub, nil, nil, nil, nil, nil, nil), + Setting: adminhandler.NewSettingHandler(settings, nil, nil, nil, nil, nil, nil), + }}, auth, middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() }), nil, nil) + paths := []string{"/users", "/users?include_subscriptions=true", "/users/7", "/users/7?include_deleted=true"} + balanceWrite := tc.name == "仅余额写入" + if balanceWrite { + paths = []string{"/users/7/balance"} + } + for _, path := range paths { + t.Run(path, func(t *testing.T) { + method, body := "GET", "" + if balanceWrite { + method, body = "POST", `{"balance":1,"operation":"add"}` + } + req := httptest.NewRequest(method, "/api/v1/admin"+path, strings.NewReader(body)) + req.Header.Set("x-api-key", key) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 200, w.Code, w.Body.String()) + var result struct { + Data map[string]json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &result)) + data := result.Data + if strings.HasPrefix(path, "/users?") || path == "/users" { + var items []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(data["items"], &items)) + require.Len(t, items, 1) + data = items[0] + require.NotNil(t, stub.filters.IncludeSubscriptions) + require.False(t, *stub.filters.IncludeSubscriptions) + } + for _, field := range []string{"subscriptions", "api_keys", "notes", "balance_notify_threshold", "balance_notify_extra_emails"} { + require.NotContains(t, data, field) + } + for _, field := range []string{"balance", "frozen_balance", "total_recharged"} { + require.Equal(t, tc.name == "同时授权余额", data[field] != nil, field) + } + for _, field := range []string{"allowed_groups", "restrict_public_groups", "group_rates"} { + require.Equal(t, tc.name == "同时授权分组", data[field] != nil, field) + } + require.Equal(t, !balanceWrite, data["email"] != nil) + require.NotContains(t, w.Body.String(), "daily_usage_usd") + require.NotContains(t, w.Body.String(), "内部定价分组") + }) + } + if tc.name == "仅用户资料" { + for _, path := range []string{"/users/7/subscriptions", "/users/7/api-keys"} { + req := httptest.NewRequest("GET", "/api/v1/admin"+path, nil) + req.Header.Set("x-api-key", key) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, 403, w.Code, path) + } + } + }) + } +} diff --git a/backend/internal/server/routes/custom_admin_keys.go b/backend/internal/server/routes/custom_admin_keys.go new file mode 100644 index 000000000000..00f09aa92903 --- /dev/null +++ b/backend/internal/server/routes/custom_admin_keys.go @@ -0,0 +1,18 @@ +package routes + +import ( + "github.com/Wei-Shaw/sub2api/internal/handler" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/gin-gonic/gin" +) + +// 管理密钥生命周期只接受管理员会话;写操作沿用二次验证。 +func registerScopedAdminKeyRoutes(settings *gin.RouterGroup, h *handler.Handlers, stepUp middleware.StepUpAuthMiddleware) { + keys := settings.Group("/admin-api-keys") + keys.GET("", h.Admin.Setting.ListScopedAdminKeys) + keys.POST("", gin.HandlerFunc(stepUp), h.Admin.Setting.CreateScopedAdminKey) + keys.PUT("/:id", gin.HandlerFunc(stepUp), h.Admin.Setting.UpdateScopedAdminKey) + keys.POST("/:id/rotate", gin.HandlerFunc(stepUp), h.Admin.Setting.RotateScopedAdminKey) + keys.DELETE("/:id", gin.HandlerFunc(stepUp), h.Admin.Setting.RevokeScopedAdminKey) + keys.DELETE("/:id/record", gin.HandlerFunc(stepUp), h.Admin.Setting.DeleteScopedAdminKeyRecord) +} diff --git a/backend/internal/server/routes/custom_admin_keys_test.go b/backend/internal/server/routes/custom_admin_keys_test.go new file mode 100644 index 000000000000..0eec76fff46e --- /dev/null +++ b/backend/internal/server/routes/custom_admin_keys_test.go @@ -0,0 +1,35 @@ +//go:build unit + +package routes + +import ( + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/handler" + adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminKeyManagementRoutesRequireStepUp(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + stepUp := middleware.StepUpAuthMiddleware(func(c *gin.Context) { c.AbortWithStatusJSON(403, gin.H{"reason": "STEP_UP_REQUIRED"}) }) + RegisterAdminRoutes(r.Group("/api/v1"), &handler.Handlers{Admin: &handler.AdminHandlers{Setting: adminhandler.NewSettingHandler(nil, nil, nil, nil, nil, nil, nil)}}, middleware.AdminAuthMiddleware(func(c *gin.Context) { c.Set("auth_method", "jwt"); c.Next() }), middleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() }), stepUp, nil, nil) + for _, test := range []struct{ method, path string }{ + {"POST", "/api/v1/admin/settings/admin-api-key/regenerate"}, + {"DELETE", "/api/v1/admin/settings/admin-api-key"}, + {"POST", "/api/v1/admin/settings/admin-api-keys"}, + {"PUT", "/api/v1/admin/settings/admin-api-keys/key-1"}, + {"POST", "/api/v1/admin/settings/admin-api-keys/key-1/rotate"}, + {"DELETE", "/api/v1/admin/settings/admin-api-keys/key-1"}, + {"DELETE", "/api/v1/admin/settings/admin-api-keys/key-1/record"}, + } { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(test.method, test.path, nil)) + require.Equal(t, 403, w.Code, test.method+" "+test.path) + require.Contains(t, w.Body.String(), "STEP_UP_REQUIRED") + } +} diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go index 26d0fcd66973..86536d67ac7a 100644 --- a/backend/internal/server/routes/payment.go +++ b/backend/internal/server/routes/payment.go @@ -91,6 +91,7 @@ func RegisterPaymentRoutes( // --- Admin payment endpoints (admin auth) --- adminGroup := v1.Group("/admin/payment") adminGroup.Use(gin.HandlerFunc(adminAuth)) + adminGroup.Use(panelRateLimiter.Global()) adminGroup.Use(gin.HandlerFunc(auditLog)) adminGroup.Use(middleware.AdminComplianceGuard(settingService)) { diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 256e488d0901..b5545ee18e15 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -546,30 +546,32 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou } } - // OAuth 账号:创建后异步设置隐私。 - // 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。 - if account.Type == AccountTypeOAuth { - switch account.Platform { - case PlatformOpenAI: - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r) - } + AfterAccountCommit(ctx, func(_ context.Context) { + // OAuth 账号:创建后异步设置隐私。 + // 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。 + if account.Type == AccountTypeOAuth { + switch account.Platform { + case PlatformOpenAI: + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r) + } + }() + s.EnsureOpenAIPrivacy(context.Background(), account) }() - s.EnsureOpenAIPrivacy(context.Background(), account) - }() - case PlatformAntigravity: - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r) - } + case PlatformAntigravity: + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r) + } + }() + s.EnsureAntigravityPrivacy(context.Background(), account) }() - s.EnsureAntigravityPrivacy(context.Background(), account) - }() + } } - } + }) return account, nil } @@ -1324,10 +1326,16 @@ func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Ac if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil { return nil, err } - if s.runtimeBlocker != nil { - s.runtimeBlocker.ClearAccountSchedulingBlock(id) + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err } - return s.accountRepo.GetByID(ctx, id) + AfterAccountCommit(ctx, func(context.Context) { + if s.runtimeBlocker != nil { + s.runtimeBlocker.ClearAccountSchedulingBlock(id) + } + }) + return account, nil } func (s *adminServiceImpl) SetAccountError(ctx context.Context, id int64, errorMsg string) error { diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index a5e0ad635c30..7b2d23164d33 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -396,6 +396,7 @@ type UpdateGroupInput struct { } type CreateAccountInput struct { + ProxyMode string // 受限写入在验权后选择代理。 Name string Notes *string Platform string @@ -428,6 +429,7 @@ type ShadowOptions struct { } type UpdateAccountInput struct { + ProxyMode string // 受限写入在验权后选择代理。 Name string Notes *string Type string // Account type: oauth, setup-token, apikey diff --git a/backend/internal/service/custom_admin_key_accounts.go b/backend/internal/service/custom_admin_key_accounts.go new file mode 100644 index 000000000000..91d5131f9c64 --- /dev/null +++ b/backend/internal/service/custom_admin_key_accounts.go @@ -0,0 +1,366 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + "unicode/utf8" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// ScopedAdminAccounts 只提供受限管理密钥需要的窄接口。 +type ScopedAdminAccounts interface { + ScopedAdminList(context.Context, pagination.PaginationParams, string, string, string, string) ([]Account, int64, error) + ScopedAdminWrite(context.Context, int64, string, map[string]json.RawMessage, func(context.Context, *Account) (*Account, error)) (*Account, error) +} + +func scopedAccountResource(a *Account) adminaccess.Account { + return adminaccess.Account{ID: a.ID, GroupIDs: a.GroupIDs, Type: a.Type, Platform: a.Platform} +} + +func scopedAccountFieldPermissions(fields map[string]json.RawMessage, create bool) ([]string, error) { + permissions := []string{} + for key, raw := range fields { + permission := "" + switch key { + case "name", "notes": + permission = "accounts.update" + case "status": + var status string + if json.Unmarshal(raw, &status) != nil || !slices.Contains([]string{StatusActive, "inactive", StatusError}, status) { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_STATUS", "账户状态无效") + } + permission = "accounts.status" + case "schedulable": + permission = "accounts.status" + case "concurrency", "priority", "load_factor", "expires_at", "auto_pause_on_expired": + permission = "accounts.schedule" + case "proxy_id", "proxy_mode", "random_proxy": + permission = "accounts.network" + case "group_ids": + permission = "accounts.groups" + case "platform", "type": + if !create { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_INPUT", "禁止更改类型或平台") + } + continue + case "credentials": + var credentials map[string]json.RawMessage + if json.Unmarshal(raw, &credentials) != nil || len(credentials) == 0 { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_CREDENTIALS", "凭证格式无效") + } + for name, value := range credentials { + // 白名单与映射共用 model_mapping,只授予模型配置写入权限。 + if name == "model_mapping" { + var mapping map[string]json.RawMessage + if len(value) > 65536 || json.Unmarshal(value, &mapping) != nil { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_MODELS", "模型映射格式无效") + } + for source, target := range mapping { + var model string + if strings.TrimSpace(source) == "" || json.Unmarshal(target, &model) != nil || strings.TrimSpace(model) == "" { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_MODELS", "模型 ID 格式无效") + } + } + permissions = append(permissions, "accounts.models.write") + continue + } + var text string + if json.Unmarshal(value, &text) != nil || len(text) > 65536 { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_CREDENTIALS", "凭证值格式无效") + } + switch name { + case "api_key", "access_token", "refresh_token", "id_token", "session_key": + permissions = append(permissions, "accounts.credentials") + case "base_url": + permissions = append(permissions, "accounts.network") + default: + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_FIELD", "请求包含不开放的凭证字段") + } + } + continue + default: + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_FIELD", "请求包含不开放的账户字段") + } + if !create || permission == "accounts.schedule" || permission == "accounts.network" { + permissions = append(permissions, permission) + } else if key == "status" || key == "schedulable" { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_INPUT", "创建不支持状态字段") + } + } + return permissions, nil +} + +func (s *adminServiceImpl) ScopedAdminList(ctx context.Context, p pagination.PaginationParams, platform, kind, status, search string) ([]Account, int64, error) { + repo, ok := s.accountRepo.(interface { + ScopedAdminList(context.Context, pagination.PaginationParams, string, string, string, string) ([]Account, int64, error) + }) + if !ok { + return nil, 0, fmt.Errorf("受限账户查询不可用") + } + return repo.ScopedAdminList(ctx, p, platform, kind, status, search) +} + +func (s *adminServiceImpl) ScopedAdminWrite(ctx context.Context, id int64, action string, fields map[string]json.RawMessage, run func(context.Context, *Account) (*Account, error)) (*Account, error) { + principal := adminaccess.FromContext(ctx) + if principal == nil { + return nil, infraerrors.Forbidden("SCOPED_ACCOUNT_FORBIDDEN", "缺少受限身份") + } + permissions, err := scopedAccountFieldPermissions(fields, id == 0) + if err != nil { + return nil, err + } + if id != 0 && action == "" && len(permissions) == 0 { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_INPUT", "缺少实际授权操作") + } + if id == 0 { + var input struct { + ProxyID *int64 `json:"proxy_id"` + ProxyMode string `json:"proxy_mode"` + RandomProxy bool `json:"random_proxy"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + Credentials map[string]any `json:"credentials"` + GroupIDs []int64 `json:"group_ids"` + Concurrency int `json:"concurrency"` + Priority int `json:"priority"` + LoadFactor *int `json:"load_factor"` + ExpiresAt *int64 `json:"expires_at"` + AutoPause *bool `json:"auto_pause_on_expired"` + } + body, _ := json.Marshal(fields) + if err = json.Unmarshal(body, &input); err != nil { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_INPUT", "请求格式无效") + } + if strings.TrimSpace(input.Name) == "" || utf8.RuneCountInString(input.Name) > maxAccountNameRunes || len(input.Credentials) == 0 || fields["group_ids"] == nil { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_INPUT", "创建须提供名称、凭证及明确分组") + } + if !slices.Contains([]string{"oauth", "setup-token", "apikey", "upstream", "bedrock", "service_account"}, input.Type) { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_INPUT", "账户类型无效") + } + if !slices.Contains(domain.ConcretePlatformIDs(), input.Platform) || input.Concurrency < 0 || input.Priority < 0 { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_INPUT", "创建参数无效") + } + resource := adminaccess.Account{Type: input.Type, Platform: input.Platform, GroupIDs: input.GroupIDs} + if input.Type == "" || input.Platform == "" || !principal.Policy.AllowsAccount("accounts.create", resource) { + return nil, infraerrors.Forbidden("SCOPED_ACCOUNT_FORBIDDEN", "创建范围未授权") + } + for _, g := range input.GroupIDs { + if !principal.Policy.AllowsGroup("accounts.create", resource, g) { + return nil, infraerrors.Forbidden("SCOPED_ACCOUNT_FORBIDDEN", "创建分组未授权") + } + } + // 创建本身允许初始化普通字段;凭证和网络须独立授权。 + for _, p := range permissions { + if !principal.Policy.AllowsAccount(p, resource) { + return nil, infraerrors.Forbidden("SCOPED_ACCOUNT_FORBIDDEN", "字段权限不足") + } + } + proxyMode, e := NormalizeAccountProxyMode(input.ProxyMode, input.ProxyID, input.RandomProxy) + if e != nil { + return nil, e + } + return s.scopedAdminCreateTransaction(ctx, &CreateAccountInput{ProxyID: input.ProxyID, ProxyMode: proxyMode, Name: input.Name, Notes: input.Notes, Platform: input.Platform, Type: input.Type, Credentials: input.Credentials, GroupIDs: input.GroupIDs, Concurrency: input.Concurrency, Priority: input.Priority, LoadFactor: input.LoadFactor, ExpiresAt: input.ExpiresAt, AutoPauseOnExpired: input.AutoPause, SkipDefaultGroupBind: true}) + } + result, err := s.scopedAdminTransaction(ctx, id, action == "accounts.delete", func(txctx context.Context) (*Account, error) { + a, e := s.accountRepo.GetByID(txctx, id) + if e != nil { + return nil, infraerrors.NotFound("SCOPED_ACCOUNT_NOT_FOUND", "账户不存在") + } + if a == nil { + return nil, infraerrors.NotFound("SCOPED_ACCOUNT_NOT_FOUND", "账户不存在") + } + resource := scopedAccountResource(a) + // 先隐藏授权范围外的账户,再区分范围内缺少具体字段权限的情况。 + inScope := false + for _, rule := range principal.Policy.Rules { + if slices.ContainsFunc(rule.Permissions, func(permission string) bool { return strings.HasPrefix(permission, "accounts.") }) && rule.Matches(resource) { + inScope = true + break + } + } + if !inScope { + return nil, infraerrors.NotFound("SCOPED_ACCOUNT_NOT_FOUND", "账户不存在") + } + if action != "" && !principal.Policy.AllowsAccount(action, resource) { + return nil, infraerrors.NotFound("SCOPED_ACCOUNT_NOT_FOUND", "账户不存在") + } + for _, p := range permissions { + if !principal.Policy.AllowsAccount(p, resource) { + return nil, infraerrors.Forbidden("SCOPED_ACCOUNT_FORBIDDEN", "账户字段未授权") + } + } + if a.IsShadow() { + return nil, infraerrors.Forbidden("SCOPED_ACCOUNT_FORBIDDEN", "不支持修改影子账户") + } + shadows, e := s.accountRepo.ListShadowsByParent(txctx, id) + if e != nil { + return nil, e + } + if len(shadows) > 0 { + return nil, infraerrors.Forbidden("SCOPED_ACCOUNT_FORBIDDEN", "请由完整管理员管理关联影子账户") + } + + if run != nil { + return run(txctx, a) + } + if action == "accounts.delete" { + return a, s.DeleteAccount(txctx, id) + } + if action == "accounts.recover" { + return s.ClearAccountError(txctx, id) + } + var input struct { + ProxyID *int64 `json:"proxy_id"` + ProxyMode string `json:"proxy_mode"` + RandomProxy bool `json:"random_proxy"` + Name string `json:"name"` + Notes *string `json:"notes"` + Credentials map[string]any `json:"credentials"` + Concurrency *int `json:"concurrency"` + Priority *int `json:"priority"` + LoadFactor *int `json:"load_factor"` + Status string `json:"status"` + GroupIDs *[]int64 `json:"group_ids"` + ExpiresAt *int64 `json:"expires_at"` + AutoPause *bool `json:"auto_pause_on_expired"` + Schedulable *bool `json:"schedulable"` + } + body, _ := json.Marshal(fields) + if e = json.Unmarshal(body, &input); e != nil { + return nil, infraerrors.BadRequest("SCOPED_ACCOUNT_INPUT", "请求格式无效") + } + if input.GroupIDs != nil { + groups, err := scopedAdminMergeGroups(principal.Policy, resource, a.GroupIDs, *input.GroupIDs) + if err != nil { + return nil, err + } + input.GroupIDs = &groups + } + proxyMode, e := NormalizeAccountProxyMode(input.ProxyMode, input.ProxyID, input.RandomProxy) + if e != nil { + return nil, e + } + input.ProxyID, e = s.scopedAccountProxySelection(txctx, proxyMode, input.ProxyID) + if e != nil { + return nil, e + } + // 受限更新按已授权字段补丁合并,避免上游完整编辑语义删除未授予的连接配置。 + if input.Credentials != nil { + merged := maps.Clone(a.Credentials) + if merged == nil { + merged = make(map[string]any) + } + maps.Copy(merged, input.Credentials) + input.Credentials = merged + } + updated, e := s.UpdateAccount(txctx, id, &UpdateAccountInput{ProxyID: input.ProxyID, Name: input.Name, Notes: input.Notes, Credentials: input.Credentials, Concurrency: input.Concurrency, Priority: input.Priority, LoadFactor: input.LoadFactor, Status: input.Status, GroupIDs: input.GroupIDs, ExpiresAt: input.ExpiresAt, AutoPauseOnExpired: input.AutoPause}) + if e != nil { + return nil, e + } + if input.Schedulable != nil { + return s.SetAccountSchedulable(txctx, id, *input.Schedulable) + } + return updated, nil + }) + return result, err +} + +// scopedAdminTransaction 锁住账户行,分组更改和外部操作均须在重新验权之后执行。 +func (s *adminServiceImpl) scopedAdminTransaction(ctx context.Context, id int64, deleted bool, run func(context.Context) (*Account, error)) (*Account, error) { + if s.entClient == nil { + return nil, fmt.Errorf("受限写入需要数据库事务") + } + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + txctx := dbent.NewTxContext(ctx, tx) + if err = lockScopedAdminAccount(txctx, tx, id); err != nil { + return nil, err + } + if _, err = tx.Client().ExecContext(txctx, "SELECT id FROM accounts WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", id); err != nil { + return nil, err + } + if _, err = tx.Client().ExecContext(txctx, "SELECT account_id FROM account_groups WHERE account_id = $1 FOR UPDATE", id); err != nil { + return nil, err + } + result, err := run(txctx) + if err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + if repo, ok := s.accountRepo.(interface { + ScopedAdminCommitted(context.Context, int64, bool) + }); ok { + repo.ScopedAdminCommitted(ctx, id, deleted) + } + return result, nil +} + +// scopedAdminMergeGroups 只替换授权关联,保留调用者不可操作的既有分组。 +func scopedAdminMergeGroups(policy adminaccess.Policy, resource adminaccess.Account, existing, requested []int64) ([]int64, error) { + groups := []int64{} + for _, id := range existing { + if !policy.AllowsGroup("accounts.groups", resource, id) { + groups = append(groups, id) + } + } + for _, id := range requested { + if !policy.AllowsGroup("accounts.groups", resource, id) { + return nil, infraerrors.Forbidden("SCOPED_ACCOUNT_GROUP", "目标分组未授权") + } + if !slices.Contains(groups, id) { + groups = append(groups, id) + } + } + return groups, nil +} + +// scopedAdminCreateTransaction 保证创建与绑定分组一同提交或回滚。 +func (s *adminServiceImpl) scopedAdminCreateTransaction(ctx context.Context, input *CreateAccountInput) (*Account, error) { + if s.entClient == nil { + return nil, fmt.Errorf("受限写入需要数据库事务") + } + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + txctx := dbent.NewTxContext(ctx, tx) + safe := *input + safe.ProxyID, err = s.scopedAccountProxySelection(txctx, safe.ProxyMode, safe.ProxyID) + if err != nil { + return nil, err + } + // 创建以 NULL 表示直连;更新接口的 0 仅是清除既有绑定的指令。 + if safe.ProxyID != nil && *safe.ProxyID == 0 { + safe.ProxyID = nil + } + account, err := s.CreateAccount(txctx, &safe) + if err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + if repo, ok := s.accountRepo.(interface { + ScopedAdminCommitted(context.Context, int64, bool) + }); ok { + repo.ScopedAdminCommitted(ctx, account.ID, false) + } + return account, nil +} diff --git a/backend/internal/service/custom_admin_key_accounts_test.go b/backend/internal/service/custom_admin_key_accounts_test.go new file mode 100644 index 000000000000..674ef3250396 --- /dev/null +++ b/backend/internal/service/custom_admin_key_accounts_test.go @@ -0,0 +1,325 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminAccountFieldsRejectUnknown(t *testing.T) { + for _, body := range []string{`{"credentials":{}}`, `{"extra":{}}`, `{"platform":"openai"}`} { + var fields map[string]json.RawMessage + _ = json.Unmarshal([]byte(body), &fields) + if _, err := scopedAccountFieldPermissions(fields, false); err == nil { + t.Fatalf("应拒绝字段: %s", body) + } + } +} + +func TestScopedAdminAccountModelMappingFieldPermissions(t *testing.T) { + for _, create := range []bool{false, true} { + for _, mapping := range []string{`{"gpt-6":"gpt-6","alias":"gpt-6"}`, `{}`, `null`} { + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(`{"credentials":{"model_mapping":`+mapping+`}}`), &fields)) + permissions, err := scopedAccountFieldPermissions(fields, create) + require.NoError(t, err) + require.Equal(t, []string{"accounts.models.write"}, permissions) + } + } + for _, mapping := range []string{`[]`, `"gpt-6"`, `{"gpt-6":42}`, `{"gpt-6":null}`, `{"gpt-6":{}}`, `{"":"gpt-6"}`, `{"gpt-6":" "}`, `{"gpt-6":"` + strings.Repeat("x", 65536) + `"}`} { + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(`{"credentials":{"model_mapping":`+mapping+`}}`), &fields)) + _, err := scopedAccountFieldPermissions(fields, false) + require.Equal(t, 400, infraerrors.Code(err), mapping) + } + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(`{"credentials":{"model_mapping":{},"access_token":"secret","base_url":"https://example.com"}}`), &fields)) + permissions, err := scopedAccountFieldPermissions(fields, false) + require.NoError(t, err) + require.ElementsMatch(t, []string{"accounts.models.write", "accounts.credentials", "accounts.network"}, permissions) +} + +func TestScopedAdminWriteModelMappingEnforcesScopeAndPreservesCredentials(t *testing.T) { + login, db := scopedAdminAccountTestDB(t) + _, err := db.Exec(`CREATE TABLE account_groups(account_id BIGINT,group_id BIGINT); + INSERT INTO accounts(id,name,platform,type,credentials) VALUES + (101,'共享','openai','oauth','{"access_token":"original-token","refresh_token":"original-refresh","base_url":"https://example.com","model_mapping":{"old":"old"}}'), + (102,'API','openai','apikey','{}'),(103,'其他组','openai','oauth','{}'),(104,'其他平台','anthropic','oauth','{}'); + INSERT INTO account_groups VALUES(101,1),(101,2),(102,1),(103,2),(104,1);`) + require.NoError(t, err) + repo := &scopedAdminWriteTestRepo{login: login} + admin := &adminServiceImpl{accountRepo: repo, entClient: dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))} + fields := func(body string) map[string]json.RawMessage { + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(body), &result)) + return result + } + ctxFor := func(permissions ...string) context.Context { + return adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: permissions, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}, Platforms: []string{"openai"}}}}}) + } + body := `{"credentials":{"model_mapping":{"gpt-6":"gpt-6","alias":"gpt-6"}}}` + _, err = admin.ScopedAdminWrite(ctxFor("accounts.credentials", "accounts.update"), 101, "", fields(body), nil) + require.Equal(t, 403, infraerrors.Code(err), "写凭证权限不能隐式修改模型") + ctx := ctxFor("accounts.models.write") + for _, id := range []int64{102, 103, 104, 999} { + _, err = admin.ScopedAdminWrite(ctx, id, "", fields(body), nil) + require.Equal(t, 404, infraerrors.Code(err)) + } + for _, forbidden := range []string{`{"credentials":{"model_mapping":{},"access_token":"changed"}}`, `{"credentials":{"model_mapping":{},"base_url":"https://evil.example"}}`} { + _, err = admin.ScopedAdminWrite(ctx, 101, "", fields(forbidden), nil) + require.Equal(t, 403, infraerrors.Code(err), "模型权限不能混写凭证或网络") + } + for _, mapping := range []string{`{"gpt-6":"gpt-6","alias":"gpt-6"}`, `{}`, `null`} { + _, err = admin.ScopedAdminWrite(ctx, 101, "", fields(`{"credentials":{"model_mapping":`+mapping+`}}`), nil) + require.NoError(t, err) + a, err := repo.GetByID(ctx, 101) + require.NoError(t, err) + require.Equal(t, "original-token", a.Credentials["access_token"]) + require.Equal(t, "original-refresh", a.Credentials["refresh_token"]) + require.Equal(t, "https://example.com", a.Credentials["base_url"]) + var want any + require.NoError(t, json.Unmarshal([]byte(mapping), &want)) + require.Equal(t, want, a.Credentials["model_mapping"]) + require.Equal(t, []int64{1, 2}, a.GroupIDs) + } +} + +// 用真实数据库事务承载最小仓储,直接验证授权服务到业务写入的完整调用链。 +type scopedAdminWriteTestRepo struct { + AccountRepository + login *scopedTestDB + failBind bool +} + +func (r *scopedAdminWriteTestRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + a := &Account{ID: id} + var credentials []byte + rows, err := r.login.sql(ctx).QueryContext(ctx, `SELECT name,platform,type,status,credentials FROM accounts WHERE id=$1`, id) + if err != nil { + return nil, err + } + if !rows.Next() { + _ = rows.Close() + return nil, ErrAccountNotFound + } + err = rows.Scan(&a.Name, &a.Platform, &a.Type, &a.Status, &credentials) + _ = rows.Close() + if err != nil { + return nil, err + } + if err = json.Unmarshal(credentials, &a.Credentials); err != nil { + return nil, err + } + rows, err = r.login.sql(ctx).QueryContext(ctx, `SELECT group_id FROM account_groups WHERE account_id=$1 ORDER BY group_id`, id) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var group int64 + if err = rows.Scan(&group); err != nil { + return nil, err + } + a.GroupIDs = append(a.GroupIDs, group) + } + return a, rows.Err() +} +func (r *scopedAdminWriteTestRepo) ListShadowsByParent(context.Context, int64) ([]*Account, error) { + return nil, nil +} +func (r *scopedAdminWriteTestRepo) Update(ctx context.Context, a *Account) error { + credentials, err := json.Marshal(a.Credentials) + if err != nil { + return err + } + _, err = r.login.sql(ctx).ExecContext(ctx, `UPDATE accounts SET name=$2,credentials=$3 WHERE id=$1`, a.ID, a.Name, string(credentials)) + return err +} +func (r *scopedAdminWriteTestRepo) Create(ctx context.Context, a *Account) error { + rows, err := r.login.sql(ctx).QueryContext(ctx, `INSERT INTO accounts(name,platform,type) VALUES($1,$2,$3) RETURNING id`, a.Name, a.Platform, a.Type) + if err != nil { + return err + } + defer rows.Close() + if !rows.Next() { + return errors.New("创建未返回 ID") + } + return rows.Scan(&a.ID) +} +func (r *scopedAdminWriteTestRepo) BindGroups(ctx context.Context, id int64, groups []int64) error { + if r.failBind { + return errors.New("模拟绑定失败") + } + if _, err := r.login.sql(ctx).ExecContext(ctx, `DELETE FROM account_groups WHERE account_id=$1`, id); err != nil { + return err + } + for _, group := range groups { + if _, err := r.login.sql(ctx).ExecContext(ctx, `INSERT INTO account_groups(account_id,group_id) VALUES($1,$2)`, id, group); err != nil { + return err + } + } + return nil +} + +type scopedAdminWriteTestGroups struct{ GroupRepository } + +func (*scopedAdminWriteTestGroups) GetByID(_ context.Context, id int64) (*Group, error) { + return &Group{ID: id}, nil +} + +func TestScopedAdminWriteEnforcesScopeAndAtomicCreate(t *testing.T) { + login, db := scopedAdminAccountTestDB(t) + _, err := db.Exec(`CREATE TABLE account_groups(account_id BIGINT,group_id BIGINT); + INSERT INTO accounts(id,name,type) VALUES(101,'共享','oauth'),(102,'API','apikey'),(103,'其他组','oauth'); + INSERT INTO account_groups VALUES(101,1),(101,2),(102,1),(103,2);`) + require.NoError(t, err) + repo := &scopedAdminWriteTestRepo{login: login} + admin := &adminServiceImpl{accountRepo: repo, groupRepo: &scopedAdminWriteTestGroups{}, entClient: dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))} + principal := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.groups", "accounts.update", "accounts.create", "accounts.credentials"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}}}} + ctx := adminaccess.WithPrincipal(context.Background(), principal) + fields := func(body string) map[string]json.RawMessage { + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(body), &result)) + return result + } + _, err = admin.ScopedAdminWrite(ctx, 101, "", fields(`{"name":"已修改","group_ids":[]}`), nil) + require.NoError(t, err) + a, err := repo.GetByID(ctx, 101) + require.NoError(t, err) + require.Equal(t, "已修改", a.Name) + require.Equal(t, []int64{2}, a.GroupIDs, "移除 A 时保留无权操作的 B") + for _, tc := range []struct { + id int64 + body string + }{ + {102, `{"name":"禁止"}`}, + {103, `{"group_ids":[1]}`}, + {103, `{"name":"禁止"}`}, + } { + _, err = admin.ScopedAdminWrite(ctx, tc.id, "", fields(tc.body), nil) + require.Error(t, err) + require.Equal(t, 404, infraerrors.Code(err)) + } + // 恢复 A 仅用于验证有范围但无字段权限的拒绝。 + _, err = db.Exec(`INSERT INTO account_groups VALUES(101,1)`) + require.NoError(t, err) + _, err = admin.ScopedAdminWrite(ctx, 101, "", fields(`{"status":"inactive"}`), nil) + require.Equal(t, 403, infraerrors.Code(err)) + _, err = admin.ScopedAdminWrite(ctx, 101, "", fields(`{"extra":{"secret":"禁止"}}`), nil) + require.Equal(t, 400, infraerrors.Code(err)) + repo.failBind = true + _, err = admin.ScopedAdminWrite(ctx, 0, "", fields(`{"name":"必须回滚","type":"oauth","platform":"openai","credentials":{"access_token":"测试"},"group_ids":[1]}`), nil) + require.ErrorContains(t, err, "模拟绑定失败") + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM accounts WHERE name='必须回滚'`).Scan(&count)) + require.Zero(t, count, "绑定失败不能留下未分组账户") +} + +func TestScopedAdminWriteHidesAccountsOutsideGrantedScope(t *testing.T) { + login, db := scopedAdminAccountTestDB(t) + _, err := db.Exec(`CREATE TABLE account_groups(account_id BIGINT,group_id BIGINT); + INSERT INTO accounts(id,name,platform,type) VALUES + (101,'范围内','openai','oauth'),(102,'其他组','openai','oauth'), + (103,'其他类型','openai','apikey'),(104,'其他平台','anthropic','oauth'), + (105,'未分组','openai','oauth'),(106,'仅分组模型权限','openai','oauth'); + INSERT INTO account_groups VALUES(101,1),(102,2),(103,1),(104,1),(106,3);`) + require.NoError(t, err) + repo := &scopedAdminWriteTestRepo{login: login} + admin := &adminServiceImpl{accountRepo: repo, entClient: dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))} + principal := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{ + {Permissions: []string{"accounts.update"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}, Platforms: []string{"openai"}}, + // 只有范围但未授予权限的规则不能暴露账户存在性。 + {GroupIDs: []int64{2}, IncludeUngrouped: true, AccountTypes: []string{"oauth"}}, + // 分组模型权限不能使账户进入可操作范围。 + {Permissions: []string{"groups.models.read"}, GroupIDs: []int64{3}, AccountTypes: []string{"oauth"}}, + }}} + ctx := adminaccess.WithPrincipal(context.Background(), principal) + for _, tc := range []struct { + name string + id int64 + body string + code int + }{ + {"范围内缺少字段权限", 101, `{"status":"inactive"}`, 403}, + {"其他组且仅匹配空权限规则", 102, `{"name":"禁止"}`, 404}, + {"其他账户类型", 103, `{"name":"禁止"}`, 404}, + {"其他平台", 104, `{"name":"禁止"}`, 404}, + {"未分组且仅匹配空权限规则", 105, `{"name":"禁止"}`, 404}, + {"仅匹配分组模型权限", 106, `{"name":"禁止"}`, 404}, + {"账户不存在", 999, `{"name":"禁止"}`, 404}, + } { + t.Run(tc.name, func(t *testing.T) { + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(tc.body), &fields)) + _, err := admin.ScopedAdminWrite(ctx, tc.id, "", fields, nil) + require.Error(t, err) + require.Equal(t, tc.code, infraerrors.Code(err)) + }) + } +} + +func TestScopedAdminGroupReplacementPreservesForeignBindings(t *testing.T) { + policy := adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.groups"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}}} + resource := adminaccess.Account{ID: 9, Type: "oauth", GroupIDs: []int64{1}} + for _, tc := range []struct { + request []int64 + want []int64 + denied bool + }{{[]int64{1}, []int64{2, 1}, false}, {[]int64{}, []int64{2}, false}, {[]int64{2}, nil, true}} { + got, err := scopedAdminMergeGroups(policy, resource, []int64{1, 2}, tc.request) + if (err != nil) != tc.denied || (!tc.denied && !reflect.DeepEqual(got, tc.want)) { + t.Fatalf("request=%v got=%v err=%v", tc.request, got, err) + } + } +} + +func TestScopedAdminAccountCredentialsNeedIndependentPermissions(t *testing.T) { + var fields map[string]json.RawMessage + _ = json.Unmarshal([]byte(`{"credentials":{"api_key":"secret","base_url":"https://example.com"},"status":"active","concurrency":2}`), &fields) + permissions, err := scopedAccountFieldPermissions(fields, false) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"accounts.credentials", "accounts.network", "accounts.status", "accounts.schedule"} { + found := false + for _, p := range permissions { + if p == want { + found = true + } + } + if !found { + t.Fatalf("缺少独立权限 %s", want) + } + } +} + +func TestScopedAdminCreateWithCredentialWriteDoesNotRequireRead(t *testing.T) { + login, db := scopedAdminAccountTestDB(t) + _, err := db.Exec(`CREATE TABLE account_groups(account_id BIGINT,group_id BIGINT)`) + require.NoError(t, err) + repo := &scopedAdminWriteTestRepo{login: login} + s := &adminServiceImpl{accountRepo: repo, groupRepo: &scopedAdminWriteTestGroups{}, entClient: dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))} + p := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.create", "accounts.credentials"}, GroupIDs: []int64{2}, AccountTypes: []string{"oauth"}}}}} + ctx := adminaccess.WithPrincipal(context.Background(), p) + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(`{"name":"仅写凭证也可创建","platform":"openai","type":"oauth","group_ids":[2],"credentials":{"access_token":"TEST_TOKEN"}}`), &fields)) + account, err := s.ScopedAdminWrite(ctx, 0, "", fields, nil) + require.NoError(t, err) + require.Positive(t, account.ID) + var count int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM accounts WHERE id=$1`, account.ID).Scan(&count)) + require.Equal(t, 1, count) + require.False(t, p.Policy.AllowsAccount("accounts.read", scopedAccountResource(account))) +} diff --git a/backend/internal/service/custom_admin_key_codex_import.go b/backend/internal/service/custom_admin_key_codex_import.go new file mode 100644 index 000000000000..8a09e7a90206 --- /dev/null +++ b/backend/internal/service/custom_admin_key_codex_import.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// ScopedCodexIdentity 描述单次导入的身份;不携带刷新令牌或其他待写入凭证。 +type ScopedCodexIdentity struct { + AccountID string + UserID string + Email string + AccessToken string + AccessTokenOnly bool + AgentIdentity bool +} + +// ScopedAdminCodexAccounts 仅供受限导入器内部使用,候选凭证不得写入 HTTP 响应。 +type ScopedAdminCodexAccounts interface { + ScopedAdminCodexCandidates(context.Context, ScopedCodexIdentity) ([]Account, error) + ScopedAdminCreateCodex(context.Context, *CreateAccountInput) (*Account, error) + ScopedAdminUpdateCodex(context.Context, int64, *UpdateAccountInput, func(*Account, *UpdateAccountInput) error) (*Account, error) +} + +func (s *adminServiceImpl) ScopedAdminCodexCandidates(ctx context.Context, identity ScopedCodexIdentity) ([]Account, error) { + repo, ok := s.accountRepo.(interface { + ScopedAdminCodexCandidates(context.Context, ScopedCodexIdentity) ([]Account, error) + }) + if !ok { + return nil, fmt.Errorf("受限导入查询不可用") + } + return repo.ScopedAdminCodexCandidates(ctx, identity) +} + +// 只允许解析器生成的认证元数据及独立授权的模型/连接配置,禁止任意 extra 穿透。 +func scopedCodexPermissions(credentials, extra map[string]any, proxy *int64) ([]string, error) { + permissions := []string{"accounts.credentials", "accounts.schedule"} + for key, value := range credentials { + switch key { + case "access_token", "refresh_token", "id_token", "client_id", "expires_at", "email", "chatgpt_account_id", "chatgpt_user_id", "organization_id", "plan_type", "auth_mode", "agent_runtime_id", "agent_private_key", "task_id": + if _, ok := value.(string); !ok { + return nil, infraerrors.BadRequest("SCOPED_CODEX_INPUT", "认证字段格式无效") + } + case "chatgpt_account_is_fedramp": + if _, ok := value.(bool); !ok { + return nil, infraerrors.BadRequest("SCOPED_CODEX_INPUT", "认证字段格式无效") + } + case "base_url", "model_mapping": + raw, err := json.Marshal(map[string]any{key: value}) + if err != nil { + return nil, err + } + required, err := scopedAccountFieldPermissions(map[string]json.RawMessage{"credentials": raw}, false) + if err != nil { + return nil, err + } + permissions = append(permissions, required...) + default: + return nil, infraerrors.BadRequest("SCOPED_CODEX_FIELD", "导入包含未开放的凭证字段") + } + } + for key := range extra { + switch key { + case "import_source", "imported_at", "auth_provider", "session_token_present", "session_expires_at", "user_image", "user_picture", "account_structure", "account_residency_region", "compute_residency", "access_token_sha256": + case "codex_fingerprint_mode", "openai_oauth_responses_websockets_v2_enabled", "openai_oauth_responses_websockets_v2_mode": + permissions = append(permissions, "accounts.network") + default: + return nil, infraerrors.BadRequest("SCOPED_CODEX_FIELD", "导入包含未开放的扩展字段") + } + } + if proxy != nil { + permissions = append(permissions, "accounts.network") + } + return permissions, nil +} + +func requireScopedCodexPermissions(ctx context.Context, resource adminaccess.Account, permissions ...string) error { + principal := adminaccess.FromContext(ctx) + for _, permission := range permissions { + if principal == nil || !principal.Policy.AllowsAccount(permission, resource) { + return infraerrors.Forbidden("SCOPED_CODEX_FORBIDDEN", "导入权限不足") + } + } + return nil +} + +func (s *adminServiceImpl) ScopedAdminCreateCodex(ctx context.Context, input *CreateAccountInput) (*Account, error) { + if input.Platform != PlatformOpenAI || input.Type != AccountTypeOAuth || input.GroupIDs == nil || len(input.Credentials) == 0 || input.SkipMixedChannelCheck { + return nil, infraerrors.BadRequest("SCOPED_CODEX_INPUT", "导入须明确 OAuth 类型和目标分组,且不能跳过分组风险检查") + } + permissions, err := scopedCodexPermissions(input.Credentials, input.Extra, input.ProxyID) + if input.ProxyMode != "" { + permissions = append(permissions, "accounts.network") + } + if err != nil { + return nil, err + } + resource := adminaccess.Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, GroupIDs: input.GroupIDs} + if err = requireScopedCodexPermissions(ctx, resource, append(permissions, "accounts.create")...); err != nil { + return nil, err + } + for _, group := range input.GroupIDs { + if !adminaccess.FromContext(ctx).Policy.AllowsGroup("accounts.create", resource, group) { + return nil, infraerrors.Forbidden("SCOPED_CODEX_GROUP", "目标分组未授权") + } + } + if input.RateMultiplier != nil && *input.RateMultiplier != 1 { + return nil, infraerrors.Forbidden("SCOPED_CODEX_RATE", "受限导入不能修改计费倍率") + } + safe := *input + safe.SkipDefaultGroupBind = true + return s.scopedAdminCreateTransaction(ctx, &safe) +} + +func (s *adminServiceImpl) ScopedAdminUpdateCodex(ctx context.Context, id int64, input *UpdateAccountInput, prepare func(*Account, *UpdateAccountInput) error) (*Account, error) { + permissions, err := scopedCodexPermissions(input.Credentials, input.Extra, input.ProxyID) + if input.ProxyMode != "" { + permissions = append(permissions, "accounts.network") + } + if err != nil { + return nil, err + } + result, err := s.ScopedAdminWrite(ctx, id, "accounts.credentials", nil, func(txctx context.Context, current *Account) (*Account, error) { + if !current.IsOpenAIOAuth() { + return nil, infraerrors.NotFound("SCOPED_ACCOUNT_NOT_FOUND", "账户不存在") + } + resource := scopedAccountResource(current) + if err := requireScopedCodexPermissions(txctx, resource, permissions...); err != nil { + return nil, err + } + safe := *input + if safe.SkipMixedChannelCheck { + return nil, infraerrors.Forbidden("SCOPED_CODEX_INPUT", "受限导入不能跳过分组风险检查") + } + if safe.GroupIDs != nil { + if err := requireScopedCodexPermissions(txctx, resource, "accounts.groups"); err != nil { + return nil, err + } + groups, err := scopedAdminMergeGroups(adminaccess.FromContext(txctx).Policy, resource, current.GroupIDs, *safe.GroupIDs) + if err != nil { + return nil, err + } + safe.GroupIDs = &groups + } + // 客户端常附带默认倍率;只接受与现值相同的值,绝不借导入改价。 + if safe.RateMultiplier != nil && *safe.RateMultiplier != current.BillingRateMultiplier() { + return nil, infraerrors.Forbidden("SCOPED_CODEX_RATE", "受限导入不能修改计费倍率") + } + safe.RateMultiplier = nil + if err := prepare(current, &safe); err != nil { + return nil, err + } + safe.ProxyID, err = s.scopedAccountProxySelection(txctx, safe.ProxyMode, safe.ProxyID) + if err != nil { + return nil, err + } + return s.UpdateAccount(txctx, id, &safe) + }) + return result, err +} diff --git a/backend/internal/service/custom_admin_key_codex_import_test.go b/backend/internal/service/custom_admin_key_codex_import_test.go new file mode 100644 index 000000000000..fd3e84aa9b15 --- /dev/null +++ b/backend/internal/service/custom_admin_key_codex_import_test.go @@ -0,0 +1,174 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +type scopedCodexTestRepo struct{ *scopedAdminWriteTestRepo } + +func (r *scopedCodexTestRepo) Create(ctx context.Context, a *Account) error { + if err := r.scopedAdminWriteTestRepo.Create(ctx, a); err != nil { + return err + } + return r.Update(ctx, a) +} +func (r *scopedCodexTestRepo) Update(ctx context.Context, a *Account) error { + if err := r.scopedAdminWriteTestRepo.Update(ctx, a); err != nil { + return err + } + extra, err := json.Marshal(a.Extra) + if err != nil { + return err + } + _, err = r.login.sql(ctx).ExecContext(ctx, `UPDATE accounts SET extra=$2,rate_multiplier=$3 WHERE id=$1`, a.ID, string(extra), a.BillingRateMultiplier()) + return err +} +func (r *scopedCodexTestRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + a, err := r.scopedAdminWriteTestRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + rows, err := r.login.sql(ctx).QueryContext(ctx, `SELECT extra,rate_multiplier FROM accounts WHERE id=$1`, id) + if err != nil { + return nil, err + } + defer rows.Close() + var extra []byte + var rate float64 + if rows.Next() { + if err = rows.Scan(&extra, &rate); err != nil { + return nil, err + } + } + if err = json.Unmarshal(extra, &a.Extra); err != nil { + return nil, err + } + a.RateMultiplier = &rate + return a, rows.Err() +} + +func TestScopedCodexWriteAuthorizationAndTransactions(t *testing.T) { + login, db := scopedAdminAccountTestDB(t) + _, err := db.Exec(`CREATE TABLE account_groups(account_id BIGINT,group_id BIGINT); + ALTER TABLE accounts ADD COLUMN extra JSONB DEFAULT '{}', ADD COLUMN rate_multiplier DOUBLE PRECISION DEFAULT 1; + INSERT INTO accounts(id,name,platform,type,credentials,extra) VALUES + (101,'共享','openai','oauth','{"access_token":"old","refresh_token":"keep"}','{"quota_used":7}'), + (102,'其他组','openai','oauth','{}','{}'),(103,'API','openai','apikey','{}','{}'),(104,'其他平台','anthropic','oauth','{}','{}'); + INSERT INTO account_groups VALUES(101,1),(101,2),(102,2),(103,1),(104,1);`) + require.NoError(t, err) + repo := &scopedCodexTestRepo{&scopedAdminWriteTestRepo{login: login}} + admin := &adminServiceImpl{accountRepo: repo, groupRepo: &scopedAdminWriteTestGroups{}, entClient: dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))} + ctxFor := func(permissions ...string) context.Context { + return adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: permissions, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}, Platforms: []string{"openai"}}}}}) + } + all := []string{"accounts.create", "accounts.credentials", "accounts.schedule", "accounts.network", "accounts.groups"} + input := func() *CreateAccountInput { + rate := 1.0 + return &CreateAccountInput{Name: "导入创建", Platform: PlatformOpenAI, Type: AccountTypeOAuth, GroupIDs: []int64{1}, Credentials: map[string]any{"access_token": "new", "refresh_token": "refresh", "email": "test@example.com", "chatgpt_account_id": "acct"}, Extra: map[string]any{"import_source": "codex_session", "codex_fingerprint_mode": "off"}, Concurrency: 40, RateMultiplier: &rate} + } + for _, missing := range []string{"accounts.create", "accounts.credentials", "accounts.schedule", "accounts.network"} { + t.Run("缺少"+missing, func(t *testing.T) { + permissions := []string{} + for _, p := range all { + if p != missing { + permissions = append(permissions, p) + } + } + _, err := admin.ScopedAdminCreateCodex(ctxFor(permissions...), input()) + require.Equal(t, 403, infraerrors.Code(err)) + }) + } + ctx := ctxFor(all...) + for _, groups := range [][]int64{{1, 2}, {2}, {}, {0}} { + v := input() + v.GroupIDs = groups + _, err = admin.ScopedAdminCreateCodex(ctx, v) + require.Equal(t, 403, infraerrors.Code(err)) + } + v := input() + v.Type = AccountTypeAPIKey + _, err = admin.ScopedAdminCreateCodex(ctx, v) + require.Equal(t, 400, infraerrors.Code(err)) + v = input() + rate := 0.0 + v.RateMultiplier = &rate + _, err = admin.ScopedAdminCreateCodex(ctx, v) + require.Equal(t, 403, infraerrors.Code(err)) + v = input() + v.Extra["quota_used"] = 0 + _, err = admin.ScopedAdminCreateCodex(ctx, v) + require.Equal(t, 400, infraerrors.Code(err)) + v = input() + v.Credentials["model_mapping"] = map[string]any{"a": "b"} + _, err = admin.ScopedAdminCreateCodex(ctx, v) + require.Equal(t, 403, infraerrors.Code(err)) + repo.failBind = true + _, err = admin.ScopedAdminCreateCodex(ctx, input()) + require.Error(t, err) + var count int + require.NoError(t, db.QueryRow(`SELECT count(*) FROM accounts WHERE name='导入创建'`).Scan(&count)) + require.Zero(t, count) + repo.failBind = false + created, err := admin.ScopedAdminCreateCodex(ctx, input()) + require.NoError(t, err) + saved, err := repo.GetByID(ctx, created.ID) + require.NoError(t, err) + require.Equal(t, "new", saved.Credentials["access_token"]) + require.Equal(t, "off", saved.Extra["codex_fingerprint_mode"]) + require.Equal(t, []int64{1}, saved.GroupIDs) + // 不需要账户读取权限;无分组授权显式控制,不自动绑定默认组。 + ungrouped := input() + ungrouped.GroupIDs = []int64{} + principal := *adminaccess.FromContext(ctx) + principal.Policy.Rules = append([]adminaccess.Rule(nil), principal.Policy.Rules...) + principal.Policy.Rules[0].IncludeUngrouped = true + created, err = admin.ScopedAdminCreateCodex(adminaccess.WithPrincipal(context.Background(), &principal), ungrouped) + require.NoError(t, err) + saved, err = repo.GetByID(ctx, created.ID) + require.NoError(t, err) + require.Empty(t, saved.GroupIDs) + groups := []int64{1} + update := &UpdateAccountInput{Credentials: map[string]any{"access_token": "changed"}, Extra: map[string]any{"import_source": "codex_session"}, GroupIDs: &groups} + called := 0 + prepare := func(current *Account, safe *UpdateAccountInput) error { + called++ + require.Equal(t, "old", current.Credentials["access_token"]) + safe.Credentials = map[string]any{"access_token": "changed", "refresh_token": current.Credentials["refresh_token"]} + safe.Extra = map[string]any{"import_source": "codex_session", "quota_used": current.Extra["quota_used"]} + return nil + } + for _, id := range []int64{102, 103, 104, 999} { + _, err = admin.ScopedAdminUpdateCodex(ctx, id, update, prepare) + require.Equal(t, 404, infraerrors.Code(err)) + } + require.Zero(t, called) + _, err = admin.ScopedAdminUpdateCodex(ctxFor("accounts.credentials", "accounts.schedule"), 101, update, prepare) + require.Equal(t, 403, infraerrors.Code(err)) + require.Zero(t, called) + _, err = admin.ScopedAdminUpdateCodex(ctx, 101, update, func(_ *Account, _ *UpdateAccountInput) error { return infraerrors.Conflict("CHANGED", "身份已变") }) + require.Equal(t, 409, infraerrors.Code(err)) + _, err = admin.ScopedAdminUpdateCodex(ctx, 101, update, prepare) + require.NoError(t, err) + require.Equal(t, 1, called) + saved, err = repo.GetByID(ctx, 101) + require.NoError(t, err) + require.Equal(t, "changed", saved.Credentials["access_token"]) + require.Equal(t, "keep", saved.Credentials["refresh_token"]) + require.Equal(t, float64(7), saved.Extra["quota_used"]) + require.Equal(t, []int64{1, 2}, saved.GroupIDs) + update.RateMultiplier = &rate + _, err = admin.ScopedAdminUpdateCodex(ctx, 101, update, prepare) + require.Equal(t, 403, infraerrors.Code(err)) + require.Equal(t, 1, called) +} diff --git a/backend/internal/service/custom_admin_key_group_models.go b/backend/internal/service/custom_admin_key_group_models.go new file mode 100644 index 000000000000..d02d8af4d561 --- /dev/null +++ b/backend/internal/service/custom_admin_key_group_models.go @@ -0,0 +1,56 @@ +package service + +import ( + "context" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// ScopedAdminUpdateGroupModels 仅更新模型白名单;仓储在锁内重新验证分组和平台。 +func (s *adminServiceImpl) ScopedAdminUpdateGroupModels(ctx context.Context, id int64, cfg GroupModelAllowlist) (*Group, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, err + } + if len(cfg.Models) > 1024 { + return nil, infraerrors.BadRequest("SCOPED_GROUP_MODELS_INVALID", "模型数量超过限制") + } + for _, model := range cfg.Models { + if strings.TrimSpace(model) == "" || len(model) > 512 { + return nil, infraerrors.BadRequest("SCOPED_GROUP_MODELS_INVALID", "模型 ID 格式无效") + } + } + repo, ok := s.groupRepo.(interface { + ScopedAdminUpdateGroupModels(context.Context, int64, GroupModelAllowlist) (*Group, error) + }) + if !ok { + return nil, infraerrors.ServiceUnavailable("SCOPED_GROUP_MODELS_UNAVAILABLE", "受限分组模型服务不可用") + } + normalized, err := normalizeGroupModelAllowlist(cfg) + if err != nil { + return nil, err + } + updated, err := repo.ScopedAdminUpdateGroupModels(ctx, id, normalized) + if err != nil { + return nil, err + } + // 仓储返回时事务已提交,避免回滚后缓存已提前失效。 + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id) + } + return updated, nil +} + +func (s *adminServiceImpl) ScopedAdminListGroupModels(ctx context.Context, params pagination.PaginationParams, all bool, platform, status, search string) ([]Group, int64, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, 0, err + } + repo, ok := s.groupRepo.(interface { + ScopedAdminListGroupModels(context.Context, pagination.PaginationParams, bool, string, string, string) ([]Group, int64, error) + }) + if !ok { + return nil, 0, infraerrors.ServiceUnavailable("SCOPED_GROUP_MODELS_UNAVAILABLE", "受限分组模型服务不可用") + } + return repo.ScopedAdminListGroupModels(ctx, params, all, platform, status, search) +} diff --git a/backend/internal/service/custom_admin_key_group_models_test.go b/backend/internal/service/custom_admin_key_group_models_test.go new file mode 100644 index 000000000000..d6d398c0bdec --- /dev/null +++ b/backend/internal/service/custom_admin_key_group_models_test.go @@ -0,0 +1,48 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type scopedGroupModelsRepo struct { + GroupRepository + config GroupModelAllowlist + writes int + err error +} + +func (r *scopedGroupModelsRepo) ScopedAdminUpdateGroupModels(_ context.Context, id int64, cfg GroupModelAllowlist) (*Group, error) { + r.writes++ + r.config = cfg + return &Group{ID: id, ModelAllowlist: cfg}, r.err +} + +func TestScopedAdminGroupModelsNormalizeAndInvalidateOnlyOnSuccess(t *testing.T) { + repo := &scopedGroupModelsRepo{} + cache := &authCacheInvalidatorStub{} + s := &adminServiceImpl{groupRepo: repo, authCacheInvalidator: cache} + cfg := GroupModelAllowlist{Enabled: true, Models: []string{" second ", "first", "second"}} + g, err := s.ScopedAdminUpdateGroupModels(context.Background(), 2, cfg) + require.NoError(t, err) + require.Equal(t, []string{"second", "first"}, g.ModelAllowlist.Models) + require.Equal(t, []int64{2}, cache.groupIDs) + repo.err = errors.New("提交失败") + _, err = s.ScopedAdminUpdateGroupModels(context.Background(), 3, cfg) + require.Error(t, err) + require.Equal(t, []int64{2}, cache.groupIDs, "仓储失败不得提前清理缓存") + writes := repo.writes + _, err = s.ScopedAdminUpdateGroupModels(context.Background(), 2, GroupModelAllowlist{Enabled: true}) + require.Error(t, err, "上游禁止启用空白名单,不能变成仅展示配置") + for _, models := range [][]string{{""}, {" "}, {strings.Repeat("x", 513)}, make([]string, 1025)} { + _, err = s.ScopedAdminUpdateGroupModels(context.Background(), 2, GroupModelAllowlist{Models: models}) + require.Error(t, err) + } + require.Equal(t, writes, repo.writes, "无效模型必须在写库前拒绝") +} diff --git a/backend/internal/service/custom_admin_key_proxy_selection.go b/backend/internal/service/custom_admin_key_proxy_selection.go new file mode 100644 index 000000000000..eb53926ca331 --- /dev/null +++ b/backend/internal/service/custom_admin_key_proxy_selection.go @@ -0,0 +1,72 @@ +package service + +import ( + "context" + "fmt" + + dbent "github.com/Wei-Shaw/sub2api/ent" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// NormalizeAccountProxyMode 显式模式与旧 proxy_id/random_proxy 互斥,省略时保留原绑定语义。 +func NormalizeAccountProxyMode(mode string, id *int64, random bool) (string, error) { + if id != nil && *id < 0 { + return "", infraerrors.BadRequest("PROXY_SELECTION_INVALID", "代理 ID 无效") + } + if random { + if mode != "" && mode != "random" { + return "", infraerrors.BadRequest("PROXY_SELECTION_CONFLICT", "代理选择参数冲突") + } + mode = "random" + } + switch mode { + case "": + return "", nil + case "specified": + if id == nil || *id <= 0 { + return "", infraerrors.BadRequest("PROXY_SELECTION_INVALID", "指定代理须提供正数 proxy_id") + } + case "direct": + if id != nil && *id != 0 { + return "", infraerrors.BadRequest("PROXY_SELECTION_CONFLICT", "直连模式不能同时指定代理") + } + case "random", "least_used": + if id != nil && *id != 0 { + return "", infraerrors.BadRequest("PROXY_SELECTION_CONFLICT", "自动选择不能同时指定代理") + } + default: + return "", infraerrors.BadRequest("PROXY_SELECTION_INVALID", "代理模式仅支持 direct、specified、random、least_used") + } + return mode, nil +} + +func (s *adminServiceImpl) scopedAccountProxySelection(ctx context.Context, mode string, id *int64) (*int64, error) { + normalized, err := NormalizeAccountProxyMode(mode, id, false) + if err != nil { + return nil, err + } + switch normalized { + case "", "specified": + return id, nil + case "direct": + zero := int64(0) + return &zero, nil + } + if dbent.TxFromContext(ctx) == nil { + return nil, fmt.Errorf("自动代理选择需要账户写事务") + } + repo, ok := s.proxyRepo.(interface { + SelectAccountProxy(context.Context, string) (int64, error) + }) + if !ok { + return nil, fmt.Errorf("自动代理选择服务不可用") + } + selected, err := repo.SelectAccountProxy(ctx, normalized) + if err != nil { + return nil, err + } + if selected <= 0 { + return nil, infraerrors.BadRequest("PROXY_POOL_EMPTY", "没有启用且未过期的代理") + } + return &selected, nil +} diff --git a/backend/internal/service/custom_admin_key_proxy_selection_test.go b/backend/internal/service/custom_admin_key_proxy_selection_test.go new file mode 100644 index 000000000000..1280afd2aac5 --- /dev/null +++ b/backend/internal/service/custom_admin_key_proxy_selection_test.go @@ -0,0 +1,174 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +type scopedSelectionProxyRepo struct { + ProxyRepository + calls int + mode string + id int64 +} + +func (r *scopedSelectionProxyRepo) SelectAccountProxy(ctx context.Context, mode string) (int64, error) { + if dbent.TxFromContext(ctx) == nil { + panic("选择必须位于账户事务中") + } + r.calls++ + r.mode = mode + return r.id, nil +} + +type scopedSelectionAccountRepo struct{ *scopedAdminWriteTestRepo } + +func (r *scopedSelectionAccountRepo) Create(ctx context.Context, a *Account) error { + if err := r.scopedAdminWriteTestRepo.Create(ctx, a); err != nil { + return err + } + _, err := r.login.sql(ctx).ExecContext(ctx, `UPDATE accounts SET proxy_id=$2 WHERE id=$1`, a.ID, a.ProxyID) + return err +} +func (r *scopedSelectionAccountRepo) Update(ctx context.Context, a *Account) error { + if err := r.scopedAdminWriteTestRepo.Update(ctx, a); err != nil { + return err + } + _, err := r.login.sql(ctx).ExecContext(ctx, `UPDATE accounts SET proxy_id=$2 WHERE id=$1`, a.ID, a.ProxyID) + return err +} +func (r *scopedSelectionAccountRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + a, err := r.scopedAdminWriteTestRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + rows, err := r.login.sql(ctx).QueryContext(ctx, `SELECT proxy_id FROM accounts WHERE id=$1`, id) + if err != nil { + return nil, err + } + defer rows.Close() + if rows.Next() { + err = rows.Scan(&a.ProxyID) + } + if err != nil { + return nil, err + } + return a, rows.Err() +} +func TestScopedAccountProxySelectionPermissionsAndRollback(t *testing.T) { + login, db := scopedAdminAccountTestDB(t) + _, err := db.Exec(`CREATE TABLE account_groups(account_id BIGINT,group_id BIGINT)`) + require.NoError(t, err) + repo := &scopedSelectionAccountRepo{&scopedAdminWriteTestRepo{login: login}} + proxy := &scopedSelectionProxyRepo{id: 7} + s := &adminServiceImpl{accountRepo: repo, proxyRepo: proxy, groupRepo: &scopedAdminWriteTestGroups{}, entClient: dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))} + principal := &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.create", "accounts.credentials"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}}}} + ctx := adminaccess.WithPrincipal(context.Background(), principal) + for _, field := range []string{`"proxy_id":7`, `"proxy_mode":"random"`, `"random_proxy":true`, `"proxy_mode":"least_used"`, `"proxy_mode":"direct"`} { + var body map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(`{"name":"代理选择","platform":"openai","type":"oauth","group_ids":[1],"credentials":{"access_token":"fixture"},`+field+`}`), &body)) + _, err = s.ScopedAdminWrite(ctx, 0, "", body, nil) + require.Equal(t, 403, infraerrors.Code(err)) + require.Zero(t, proxy.calls) + principal.Policy.Rules[0].Permissions = append(principal.Policy.Rules[0].Permissions, "accounts.network") + a, err := s.ScopedAdminWrite(ctx, 0, "", body, nil) + require.NoError(t, err) + var id *int64 + require.NoError(t, db.QueryRow(`SELECT proxy_id FROM accounts WHERE id=$1`, a.ID).Scan(&id)) + if field == `"proxy_mode":"direct"` { + require.Nil(t, id) + } else { + require.NotNil(t, id) + require.EqualValues(t, 7, *id) + } + principal.Policy.Rules[0].Permissions = principal.Policy.Rules[0].Permissions[:2] + proxy.calls = 0 + } + principal.Policy.Rules[0].Permissions = append(principal.Policy.Rules[0].Permissions, "accounts.network", "accounts.schedule", "accounts.groups") + for _, mode := range []string{"random", "least_used"} { + input := &CreateAccountInput{Name: "导入代理", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "fixture"}, GroupIDs: []int64{1}, ProxyMode: mode} + a, err := s.ScopedAdminCreateCodex(ctx, input) + require.NoError(t, err) + require.Equal(t, mode, proxy.mode) + require.EqualValues(t, 7, *a.ProxyID) + repo.failBind = true + _, err = s.ScopedAdminCreateCodex(ctx, input) + require.Error(t, err) + repo.failBind = false + } + _, err = db.Exec(`INSERT INTO accounts(id,name,platform,type,proxy_id) VALUES(1001,'可管理','openai','oauth',7),(1002,'组外','openai','oauth',7),(1003,'类型外','openai','apikey',7); INSERT INTO account_groups VALUES(1001,1),(1002,2),(1003,1);`) + require.NoError(t, err) + fields := map[string]json.RawMessage{"proxy_mode": json.RawMessage(`"least_used"`)} + calls := proxy.calls + for _, id := range []int64{1002, 1003} { + _, err = s.ScopedAdminWrite(ctx, id, "", fields, nil) + require.Equal(t, 404, infraerrors.Code(err)) + } + require.Equal(t, calls, proxy.calls, "越权时不能进入代理选择") + denied := adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.credentials", "accounts.schedule"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}}}}) + _, err = s.ScopedAdminWrite(denied, 1001, "", fields, nil) + require.Equal(t, 403, infraerrors.Code(err)) + _, err = s.ScopedAdminUpdateCodex(denied, 1001, &UpdateAccountInput{Credentials: map[string]any{"access_token": "fixture"}, ProxyMode: "random"}, func(_ *Account, _ *UpdateAccountInput) error { + t.Fatal("缺网络权限不能执行更新回调") + return nil + }) + require.Equal(t, 403, infraerrors.Code(err)) + require.Equal(t, calls, proxy.calls) + proxy.id = 8 + updated, err := s.ScopedAdminWrite(ctx, 1001, "", fields, nil) + require.NoError(t, err) + require.EqualValues(t, 8, *updated.ProxyID) + updated, err = s.ScopedAdminWrite(ctx, 1001, "", map[string]json.RawMessage{"proxy_mode": json.RawMessage(`"direct"`)}, nil) + require.NoError(t, err) + require.Nil(t, updated.ProxyID) + updated, err = s.ScopedAdminUpdateCodex(ctx, 1001, &UpdateAccountInput{Credentials: map[string]any{"access_token": "fixture"}, ProxyMode: "random"}, func(_ *Account, _ *UpdateAccountInput) error { return nil }) + require.NoError(t, err) + require.EqualValues(t, 8, *updated.ProxyID) + // 更新后的后续分组失败应回滚代理绑定。 + repo.failBind = true + _, err = s.ScopedAdminWrite(ctx, 1001, "", map[string]json.RawMessage{"proxy_mode": json.RawMessage(`"direct"`), "group_ids": json.RawMessage(`[1]`)}, nil) + require.Error(t, err) + repo.failBind = false + stored, err := repo.GetByID(ctx, 1001) + require.NoError(t, err) + require.EqualValues(t, 8, *stored.ProxyID) + proxy.id = 0 + _, err = s.ScopedAdminCreateCodex(ctx, &CreateAccountInput{Name: "空池禁止直连", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"access_token": "fixture"}, GroupIDs: []int64{1}, ProxyMode: "random"}) + require.Equal(t, 400, infraerrors.Code(err)) + var count int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM accounts WHERE name='空池禁止直连'`).Scan(&count)) + require.Zero(t, count) +} + +func TestNormalizeAccountProxySelectionConflicts(t *testing.T) { + id := int64(7) + zero := int64(0) + for _, tc := range []struct { + mode string + id *int64 + random bool + want string + invalid bool + }{ + {"", &id, false, "", false}, {"specified", &id, false, "specified", false}, {"direct", nil, false, "direct", false}, {"", nil, true, "random", false}, {"random", nil, false, "random", false}, {"least_used", nil, false, "least_used", false}, + {"random", &id, false, "", true}, {"least_used", &id, false, "", true}, {"direct", &id, false, "", true}, {"specified", &zero, false, "", true}, {"unknown", nil, false, "", true}, {"least_used", nil, true, "", true}, + } { + got, err := NormalizeAccountProxyMode(tc.mode, tc.id, tc.random) + if tc.invalid { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.want, got) + } + } +} diff --git a/backend/internal/service/custom_admin_key_recovery_review_test.go b/backend/internal/service/custom_admin_key_recovery_review_test.go new file mode 100644 index 000000000000..71820596ae52 --- /dev/null +++ b/backend/internal/service/custom_admin_key_recovery_review_test.go @@ -0,0 +1,77 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + "github.com/stretchr/testify/require" +) + +type scopedRecoveryReviewRepo struct { + accountRepoStubForClearAccountError + reads int + failRead bool +} + +func (r *scopedRecoveryReviewRepo) GetByID(ctx context.Context, id int64) (*Account, error) { + r.reads++ + if r.failRead && r.reads > 1 { + return nil, errors.New("模拟回读失败") + } + return r.accountRepoStubForClearAccountError.GetByID(ctx, id) +} +func (*scopedRecoveryReviewRepo) ListShadowsByParent(context.Context, int64) ([]*Account, error) { + return nil, nil +} + +type scopedRecoveryReviewBlocker struct { + runtimeBlockRecorder + resets int +} + +func (b *scopedRecoveryReviewBlocker) ResetOpenAI403Counter(context.Context, int64) { b.resets++ } + +func TestScopedAdminReviewRecoveryCommitBoundary(t *testing.T) { + for _, mode := range []string{"commit", "read_failure", "commit_failure"} { + t.Run(mode, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + repo := &scopedRecoveryReviewRepo{accountRepoStubForClearAccountError: accountRepoStubForClearAccountError{account: &Account{ID: 31, Platform: "openai", Type: "oauth", Status: StatusError, GroupIDs: []int64{1}}}, failRead: mode == "read_failure"} + blocker := &scopedRecoveryReviewBlocker{} + admin := &adminServiceImpl{accountRepo: repo, entClient: client, runtimeBlocker: blocker} + mock.ExpectBegin() + mock.ExpectExec(`SELECT pg_advisory_xact_lock`).WithArgs(int64(31)).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`SELECT id FROM accounts`).WithArgs(int64(31)).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`SELECT account_id FROM account_groups`).WithArgs(int64(31)).WillReturnResult(sqlmock.NewResult(0, 1)) + if mode == "read_failure" { + mock.ExpectRollback() + } else if mode == "commit_failure" { + mock.ExpectCommit().WillReturnError(errors.New("模拟提交失败")) + } else { + mock.ExpectCommit() + } + ctx := adminaccess.WithPrincipal(context.Background(), &adminaccess.Principal{Policy: adminaccess.Policy{Rules: []adminaccess.Rule{{Permissions: []string{"accounts.recover"}, GroupIDs: []int64{1}, AccountTypes: []string{"oauth"}}}}}) + _, err = admin.ScopedAdminWrite(ctx, 31, "accounts.recover", nil, nil) + if mode == "commit" { + require.NoError(t, err) + require.Equal(t, []int64{31}, blocker.clearedIDs) + require.Zero(t, blocker.resets) + } else { + require.Error(t, err) + require.Empty(t, blocker.clearedIDs, "失败事务不得清理运行态阻塞") + require.Zero(t, blocker.resets) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/backend/internal/service/custom_admin_key_testdb_test.go b/backend/internal/service/custom_admin_key_testdb_test.go new file mode 100644 index 000000000000..1855ae00a029 --- /dev/null +++ b/backend/internal/service/custom_admin_key_testdb_test.go @@ -0,0 +1,47 @@ +//go:build unit + +package service + +import ( + "context" + "database/sql" + "fmt" + dbent "github.com/Wei-Shaw/sub2api/ent" + _ "github.com/lib/pq" + "github.com/stretchr/testify/require" + "os" + "testing" + "time" +) + +type scopedTestSQL interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +} +type scopedTestDB struct{ db *sql.DB } + +func (s *scopedTestDB) sql(ctx context.Context) scopedTestSQL { + if tx := dbent.TxFromContext(ctx); tx != nil { + return tx.Client() + } + return s.db +} +func scopedAdminAccountTestDB(t *testing.T) (*scopedTestDB, *sql.DB) { + t.Helper() + dsn := os.Getenv("SCOPED_ADMIN_TEST_DSN") + if dsn == "" { + t.Skip("需要独立 SCOPED_ADMIN_TEST_DSN") + } + base, err := sql.Open("postgres", dsn) + require.NoError(t, err) + name := fmt.Sprintf("scoped_accounts_%d", time.Now().UnixNano()) + _, err = base.Exec(`CREATE SCHEMA ` + name) + require.NoError(t, err) + db, err := sql.Open("postgres", dsn+" search_path="+name) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close(); _, _ = base.Exec(`DROP SCHEMA ` + name + ` CASCADE`); _ = base.Close() }) + _, err = db.Exec(`CREATE TABLE accounts(id BIGSERIAL PRIMARY KEY,name TEXT DEFAULT '',platform TEXT DEFAULT 'openai',type TEXT DEFAULT 'oauth',credentials JSONB DEFAULT '{}',status TEXT DEFAULT 'active',schedulable BOOLEAN DEFAULT true,error_message TEXT,temp_unschedulable_until TIMESTAMPTZ,temp_unschedulable_reason TEXT,deleted_at TIMESTAMPTZ,parent_account_id BIGINT,proxy_id BIGINT,expires_at TIMESTAMPTZ,updated_at TIMESTAMPTZ DEFAULT NOW()); + CREATE TABLE proxies(id BIGSERIAL PRIMARY KEY,protocol TEXT,host TEXT,port INT,username TEXT,password TEXT,status TEXT DEFAULT 'active',expires_at TIMESTAMPTZ,deleted_at TIMESTAMPTZ);`) + require.NoError(t, err) + return &scopedTestDB{db: db}, db +} diff --git a/backend/internal/service/custom_admin_key_transaction.go b/backend/internal/service/custom_admin_key_transaction.go new file mode 100644 index 000000000000..ac46878a143f --- /dev/null +++ b/backend/internal/service/custom_admin_key_transaction.go @@ -0,0 +1,29 @@ +package service + +import ( + "context" + dbent "github.com/Wei-Shaw/sub2api/ent" +) + +func lockScopedAdminAccount(ctx context.Context, tx *dbent.Tx, id int64) error { + _, err := tx.Client().ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended('scoped_admin_account:' || $1::text, 0))`, id) + return err +} + +// AfterAccountCommit 将不参与回滚的缓存和上游副作用延迟到事务提交成功后。 +// 普通管理员没有外层事务时立即执行,保留原有行为。 +func AfterAccountCommit(ctx context.Context, run func(context.Context)) { + if tx := dbent.TxFromContext(ctx); tx != nil { + tx.OnCommit(func(next dbent.Committer) dbent.Committer { + return dbent.CommitFunc(func(commitCtx context.Context, tx *dbent.Tx) error { + if err := next.Commit(commitCtx, tx); err != nil { + return err + } + run(dbent.NewTxContext(ctx, nil)) + return nil + }) + }) + } else { + run(ctx) + } +} diff --git a/backend/internal/service/custom_admin_key_transaction_test.go b/backend/internal/service/custom_admin_key_transaction_test.go new file mode 100644 index 000000000000..93d6a65942c8 --- /dev/null +++ b/backend/internal/service/custom_admin_key_transaction_test.go @@ -0,0 +1,53 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/stretchr/testify/require" +) + +func TestScopedAdminAfterCommitEffects(t *testing.T) { + for _, mode := range []string{"commit", "rollback", "commit_failure"} { + t.Run(mode, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + mock.ExpectBegin() + tx, err := client.Tx(context.Background()) + require.NoError(t, err) + calls := 0 + AfterAccountCommit(dbent.NewTxContext(context.Background(), tx), func(ctx context.Context) { + require.Nil(t, dbent.TxFromContext(ctx), "提交后回调不能复用已关闭的事务") + calls++ + }) + require.Zero(t, calls) + switch mode { + case "commit": + mock.ExpectCommit() + require.NoError(t, tx.Commit()) + require.Equal(t, 1, calls) + case "rollback": + mock.ExpectRollback() + require.NoError(t, tx.Rollback()) + require.Zero(t, calls) + case "commit_failure": + mock.ExpectCommit().WillReturnError(errors.New("提交失败")) + require.Error(t, tx.Commit()) + require.Zero(t, calls) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } + calls := 0 + AfterAccountCommit(context.Background(), func(context.Context) { calls++ }) + require.Equal(t, 1, calls, "无事务时保留原管理员同步语义") +} diff --git a/backend/internal/service/custom_admin_keys.go b/backend/internal/service/custom_admin_keys.go new file mode 100644 index 000000000000..f11c31711be1 --- /dev/null +++ b/backend/internal/service/custom_admin_keys.go @@ -0,0 +1,253 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "net/netip" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +var ErrScopedAdminKeyInvalid = infraerrors.Unauthorized("INVALID_ADMIN_KEY", "Invalid admin API key") +var ErrScopedAdminKeyConflict = infraerrors.Conflict("ADMIN_KEY_CONFLICT", "密钥已变化,请刷新后重试") +var ErrScopedAdminKeyNotFound = infraerrors.NotFound("ADMIN_KEY_NOT_FOUND", "Admin key not found") + +type ScopedAdminKeyInput struct { + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + ExpiresAt *time.Time `json:"expires_at"` + AllowedIPs []string `json:"allowed_ips"` + Policy adminaccess.Policy `json:"policy"` +} +type ScopedAdminKey struct { + ScopedAdminKeyInput + ID string `json:"id"` + KeyPrefix string `json:"key_prefix"` + Hash string `json:"-"` + CreatedBy int64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastUsedAt *time.Time `json:"last_used_at"` + LastUsedIP string `json:"last_used_ip"` + RevokedAt *time.Time `json:"revoked_at"` +} +type ScopedAdminKeyRepository interface { + ListScopedAdminKeys(context.Context) ([]*ScopedAdminKey, error) + GetScopedAdminKey(context.Context, string) (*ScopedAdminKey, error) + InsertScopedAdminKey(context.Context, *ScopedAdminKey) error + UpdateScopedAdminKey(context.Context, *ScopedAdminKey) error + RotateScopedAdminKeyHash(context.Context, string, string, string, time.Time) error + TouchScopedAdminKey(context.Context, string, string, time.Time, string, time.Time) error + RevokeScopedAdminKey(context.Context, string, time.Time) error + DeleteScopedAdminKeyRecord(context.Context, string, time.Time) error +} + +func validateScopedAdminKeyInput(in *ScopedAdminKeyInput) error { + in.Name = strings.TrimSpace(in.Name) + if in.Name == "" || len(in.Name) > 100 || len(in.Description) > 1000 || len(in.AllowedIPs) > 100 { + return infraerrors.BadRequest("INVALID_ADMIN_KEY_INPUT", "名称、备注或 IP 列表无效") + } + if in.ExpiresAt != nil && !in.ExpiresAt.After(time.Now()) { + return infraerrors.BadRequest("INVALID_ADMIN_KEY_EXPIRY", "有效期必须在未来") + } + for _, ip := range in.AllowedIPs { + if _, e := netip.ParseAddr(ip); e != nil { + if _, e = netip.ParsePrefix(ip); e != nil { + return infraerrors.BadRequest("INVALID_ADMIN_KEY_IP", "IP 或 CIDR 无效") + } + } + } + if in.AllowedIPs == nil { + in.AllowedIPs = []string{} + } + if err := adminaccess.ValidatePolicy(in.Policy); err != nil { + return infraerrors.BadRequest("INVALID_ADMIN_KEY_POLICY", "权限策略无效") + } + return nil +} +func generateScopedAdminKey(id string) (string, string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", err + } + key := "adm_sc_" + id + "_" + hex.EncodeToString(b) + hash := sha256.Sum256([]byte(key)) + return key, hex.EncodeToString(hash[:]), nil +} +func verifyScopedAdminKey(key, hash string) bool { + h := sha256.Sum256([]byte(key)) + return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(h[:])), []byte(hash)) == 1 +} +func scopedAdminIPAllowed(allowed []string, ip string) bool { + if len(allowed) == 0 { + return true + } + addr, err := netip.ParseAddr(ip) + if err != nil { + return false + } + addr = addr.Unmap() + for _, s := range allowed { + if a, e := netip.ParseAddr(s); e == nil && a.Unmap() == addr { + return true + } + if p, e := netip.ParsePrefix(s); e == nil { + candidate := addr + if p.Addr().Is4In6() && addr.Is4() { + // 使用前缀的映射地址表示,保留原始位数和网段边界。 + candidate = netip.AddrFrom16(addr.As16()) + } + if p.Contains(candidate) { + return true + } + } + } + return false +} +func (s *SettingService) scopedAdminKeys() (ScopedAdminKeyRepository, error) { + if s.scopedAdminKeyRepo == nil { + return nil, errors.New("管理密钥存储未配置") + } + return s.scopedAdminKeyRepo, nil +} +func (s *SettingService) ListScopedAdminKeys(ctx context.Context) ([]*ScopedAdminKey, error) { + r, e := s.scopedAdminKeys() + if e != nil { + return nil, e + } + return r.ListScopedAdminKeys(ctx) +} +func (s *SettingService) CreateScopedAdminKey(ctx context.Context, in ScopedAdminKeyInput, creator int64) (string, *ScopedAdminKey, error) { + if e := validateScopedAdminKeyInput(&in); e != nil { + return "", nil, e + } + r, e := s.scopedAdminKeys() + if e != nil { + return "", nil, e + } + idBytes := make([]byte, 12) + if _, e = rand.Read(idBytes); e != nil { + return "", nil, e + } + id := hex.EncodeToString(idBytes) + key, hash, e := generateScopedAdminKey(id) + if e != nil { + return "", nil, e + } + now := time.Now().UTC() + item := &ScopedAdminKey{ScopedAdminKeyInput: in, ID: id, Hash: hash, KeyPrefix: "adm_sc_" + id, CreatedBy: creator, CreatedAt: now, UpdatedAt: now} + if e = r.InsertScopedAdminKey(ctx, item); e != nil { + return "", nil, e + } + return key, item, nil +} +func (s *SettingService) UpdateScopedAdminKey(ctx context.Context, id string, in ScopedAdminKeyInput) (*ScopedAdminKey, error) { + r, e := s.scopedAdminKeys() + if e != nil { + return nil, e + } + item, e := r.GetScopedAdminKey(ctx, id) + if e != nil { + return nil, e + } + if item.RevokedAt != nil { + return nil, ErrScopedAdminKeyNotFound + } + if e := validateScopedAdminKeyInputForUpdate(&in, item.ExpiresAt); e != nil { + return nil, e + } + item.ScopedAdminKeyInput = in + item.UpdatedAt = time.Now().UTC() + if e = r.UpdateScopedAdminKey(ctx, item); e != nil { + return nil, e + } + return item, nil +} + +func validateScopedAdminKeyInputForUpdate(in *ScopedAdminKeyInput, existingExpiry *time.Time) error { + if in.ExpiresAt != nil && !in.ExpiresAt.After(time.Now()) && (existingExpiry == nil || !in.ExpiresAt.Equal(*existingExpiry)) { + return infraerrors.BadRequest("INVALID_ADMIN_KEY_EXPIRY", "新的有效期必须在未来") + } + expiry := in.ExpiresAt + in.ExpiresAt = nil + err := validateScopedAdminKeyInput(in) + in.ExpiresAt = expiry + return err +} +func (s *SettingService) RotateScopedAdminKey(ctx context.Context, id string) (string, *ScopedAdminKey, error) { + r, e := s.scopedAdminKeys() + if e != nil { + return "", nil, e + } + item, e := r.GetScopedAdminKey(ctx, id) + if e != nil { + return "", nil, e + } + if item.RevokedAt != nil { + return "", nil, ErrScopedAdminKeyNotFound + } + key, hash, e := generateScopedAdminKey(id) + if e != nil { + return "", nil, e + } + previousHash := item.Hash + item.Hash = hash + item.UpdatedAt = time.Now().UTC() + if e = r.RotateScopedAdminKeyHash(ctx, item.ID, previousHash, hash, item.UpdatedAt); e != nil { + return "", nil, e + } + return key, item, nil +} +func (s *SettingService) RevokeScopedAdminKey(ctx context.Context, id string) error { + r, e := s.scopedAdminKeys() + if e != nil { + return e + } + return r.RevokeScopedAdminKey(ctx, id, time.Now().UTC()) +} + +func (s *SettingService) DeleteScopedAdminKeyRecord(ctx context.Context, id string) error { + r, e := s.scopedAdminKeys() + if e != nil { + return e + } + return r.DeleteScopedAdminKeyRecord(ctx, id, time.Now().UTC()) +} + +func (s *SettingService) AuthenticateScopedAdminKey(ctx context.Context, key, ip string) (*ScopedAdminKey, error) { + parts := strings.Split(key, "_") + if len(parts) != 4 || parts[0] != "adm" || parts[1] != "sc" || len(parts[2]) != 24 || len(parts[3]) != 64 { + return nil, ErrScopedAdminKeyInvalid + } + r, e := s.scopedAdminKeys() + if e != nil { + return nil, e + } + item, e := r.GetScopedAdminKey(ctx, parts[2]) + if e != nil { + return nil, ErrScopedAdminKeyInvalid + } + if !verifyScopedAdminKey(key, item.Hash) { + return nil, ErrScopedAdminKeyInvalid + } + if !item.Enabled || item.RevokedAt != nil || (item.ExpiresAt != nil && !item.ExpiresAt.After(time.Now())) || !scopedAdminIPAllowed(item.AllowedIPs, ip) { + return item, ErrScopedAdminKeyInvalid + } + if e = r.TouchScopedAdminKey(ctx, item.ID, item.Hash, item.UpdatedAt, ip, time.Now().UTC()); e != nil { + return nil, ErrScopedAdminKeyInvalid + } + return item, nil +} + +// SetScopedAdminKeyRepository 注入独立密钥存储。 +func (s *SettingService) SetScopedAdminKeyRepository(repo ScopedAdminKeyRepository) { + s.scopedAdminKeyRepo = repo +} diff --git a/backend/internal/service/custom_admin_keys_test.go b/backend/internal/service/custom_admin_keys_test.go new file mode 100644 index 000000000000..881ac853d010 --- /dev/null +++ b/backend/internal/service/custom_admin_keys_test.go @@ -0,0 +1,229 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/adminaccess" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +func TestScopedAdminKeyValidation(t *testing.T) { + input := ScopedAdminKeyInput{Name: "测试", Enabled: true, AllowedIPs: []string{"192.0.2.0/24"}, Policy: adminaccess.Policy{}} + if err := validateScopedAdminKeyInput(&input); err != nil { + t.Fatal(err) + } + input.AllowedIPs = []string{"invalid"} + if validateScopedAdminKeyInput(&input) == nil { + t.Fatal("应拒绝无效 IP") + } + input.AllowedIPs = nil + past := time.Now().Add(-time.Hour) + input.ExpiresAt = &past + if validateScopedAdminKeyInput(&input) == nil { + t.Fatal("应拒绝过去的有效期") + } +} +func TestScopedAdminKeySecret(t *testing.T) { + key, hash, err := generateScopedAdminKey("abc") + if err != nil { + t.Fatal(err) + } + if key == hash || !verifyScopedAdminKey(key, hash) || verifyScopedAdminKey(key+"x", hash) { + t.Fatal("密钥摘要校验错误") + } + if !scopedAdminIPAllowed([]string{"192.0.2.0/24"}, "192.0.2.5") || scopedAdminIPAllowed([]string{"192.0.2.0/24"}, "198.51.100.1") { + t.Fatal("IP 范围错误") + } +} + +type scopedKeyMemoryRepo struct { + item *ScopedAdminKey + deleted bool +} + +func (r *scopedKeyMemoryRepo) ListScopedAdminKeys(context.Context) ([]*ScopedAdminKey, error) { + if r.deleted { + return []*ScopedAdminKey{}, nil + } + return []*ScopedAdminKey{r.item}, nil +} +func (r *scopedKeyMemoryRepo) GetScopedAdminKey(_ context.Context, id string) (*ScopedAdminKey, error) { + if r.item == nil || r.item.ID != id { + return nil, ErrScopedAdminKeyNotFound + } + copy := *r.item + return ©, nil +} +func (r *scopedKeyMemoryRepo) InsertScopedAdminKey(_ context.Context, k *ScopedAdminKey) error { + copy := *k + r.item = © + return nil +} +func (r *scopedKeyMemoryRepo) UpdateScopedAdminKey(_ context.Context, k *ScopedAdminKey) error { + copy := *k + r.item = © + return nil +} +func (r *scopedKeyMemoryRepo) TouchScopedAdminKey(context.Context, string, string, time.Time, string, time.Time) error { + return nil +} +func (r *scopedKeyMemoryRepo) RevokeScopedAdminKey(_ context.Context, _ string, at time.Time) error { + r.item.RevokedAt = &at + return nil +} + +func (r *scopedKeyMemoryRepo) DeleteScopedAdminKeyRecord(_ context.Context, id string, _ time.Time) error { + if r.item == nil || r.item.ID != id || r.item.RevokedAt == nil || r.deleted { + return ErrScopedAdminKeyNotFound + } + r.deleted = true + return nil +} +func TestScopedAdminKeyLifecycle(t *testing.T) { + repo := &scopedKeyMemoryRepo{} + s := &SettingService{scopedAdminKeyRepo: repo} + ctx := context.Background() + key, item, err := s.CreateScopedAdminKey(ctx, ScopedAdminKeyInput{Name: "集成", Enabled: true, AllowedIPs: []string{"192.0.2.0/24"}}, 42) + if err != nil { + t.Fatal(err) + } + if item.CreatedBy != 42 || strings.Contains(item.Hash, key) { + t.Fatal("存储不应包含原始密钥") + } + if _, err = s.AuthenticateScopedAdminKey(ctx, key, "192.0.2.1"); err != nil { + t.Fatal(err) + } + if _, err = s.AuthenticateScopedAdminKey(ctx, key, "198.51.100.1"); err == nil { + t.Fatal("必须验证 IP") + } + next, _, err := s.RotateScopedAdminKey(ctx, item.ID) + if err != nil { + t.Fatal(err) + } + if _, err = s.AuthenticateScopedAdminKey(ctx, key, "192.0.2.1"); err == nil { + t.Fatal("轮换必须即时撤销旧密钥") + } + if _, err = s.AuthenticateScopedAdminKey(ctx, next, "192.0.2.1"); err != nil { + t.Fatal(err) + } + repo.item.Enabled = false + if _, err = s.AuthenticateScopedAdminKey(ctx, next, "192.0.2.1"); err == nil { + t.Fatal("停用必须生效") + } + repo.item.Enabled = true + if err = s.RevokeScopedAdminKey(ctx, item.ID); err != nil { + t.Fatal(err) + } + if _, err = s.AuthenticateScopedAdminKey(ctx, next, "192.0.2.1"); err == nil { + t.Fatal("撤销必须生效") + } + if _, _, err = s.RotateScopedAdminKey(ctx, item.ID); err == nil { + t.Fatal("撤销不可轮换恢复") + } +} + +func (r *scopedKeyMemoryRepo) RotateScopedAdminKeyHash(_ context.Context, _ string, expectedHash, hash string, at time.Time) error { + if r.item.Hash != expectedHash || r.item.RevokedAt != nil { + return ErrScopedAdminKeyConflict + } + r.item.Hash = hash + r.item.UpdatedAt = at + return nil +} + +func TestScopedAdminKeyPolicyValidationIsBadRequest(t *testing.T) { + in := ScopedAdminKeyInput{Name: "测试", Policy: adminaccess.Policy{GlobalPermissions: []string{"*"}}} + if err := validateScopedAdminKeyInput(&in); !infraerrors.IsBadRequest(err) { + t.Fatalf("策略错误应为400,实际 %v", err) + } +} +func TestScopedAdminKeyExpiredCanBeDisabledOrExtended(t *testing.T) { + expired := time.Now().Add(-time.Hour) + repo := &scopedKeyMemoryRepo{item: &ScopedAdminKey{ID: "expired", ScopedAdminKeyInput: ScopedAdminKeyInput{Name: "已过期", Enabled: true, ExpiresAt: &expired}}} + s := &SettingService{scopedAdminKeyRepo: repo} + in := repo.item.ScopedAdminKeyInput + in.Enabled = false + in.Description = "停用过期密钥" + if _, err := s.UpdateScopedAdminKey(context.Background(), "expired", in); err != nil { + t.Fatal(err) + } + in.ExpiresAt = ptrScopedTime(time.Now().Add(time.Hour)) + if _, err := s.UpdateScopedAdminKey(context.Background(), "expired", in); err != nil { + t.Fatal(err) + } + in.ExpiresAt = ptrScopedTime(time.Now().Add(-2 * time.Hour)) + if _, err := s.UpdateScopedAdminKey(context.Background(), "expired", in); err == nil { + t.Fatal("不允许改为另一过去时间") + } +} + +type scopedKeyCASRepo struct { + scopedKeyMemoryRepo + rotateOnTouch bool +} + +func (r *scopedKeyCASRepo) TouchScopedAdminKey(_ context.Context, id, hash string, updated time.Time, ip string, now time.Time) error { + if r.rotateOnTouch { + r.item.Hash = "new-hash" + r.item.UpdatedAt = now + return ErrScopedAdminKeyNotFound + } + if r.item.ID != id || r.item.Hash != hash || !r.item.UpdatedAt.Equal(updated) || !r.item.Enabled || r.item.RevokedAt != nil || (r.item.ExpiresAt != nil && !r.item.ExpiresAt.After(now)) { + return ErrScopedAdminKeyNotFound + } + return nil +} +func TestScopedAdminKeyConcurrentRotationRejectsStaleAuthentication(t *testing.T) { + repo := &scopedKeyCASRepo{rotateOnTouch: true} + s := &SettingService{scopedAdminKeyRepo: repo} + key, _, err := s.CreateScopedAdminKey(context.Background(), ScopedAdminKeyInput{Name: "CAS", Enabled: true}, 2) + if err != nil { + t.Fatal(err) + } + _, err = s.AuthenticateScopedAdminKey(context.Background(), key, "192.0.2.1") + if !errors.Is(err, ErrScopedAdminKeyInvalid) { + t.Fatalf("并发轮换后的旧读取必须拒绝,实际 %v", err) + } +} +func ptrScopedTime(t time.Time) *time.Time { return &t } + +func TestScopedAdminKeyMappedCIDRAuthentication(t *testing.T) { + for _, tc := range []struct { + prefix, ip string + allowed bool + }{ + {"::ffff:192.0.2.0/120", "192.0.2.9", true}, + {"::ffff:192.0.2.0/120", "::ffff:192.0.2.9", true}, + {"::ffff:192.0.2.0/120", "192.0.3.9", false}, + {"::ffff:192.0.2.0/120", "2001:db8::9", false}, + {"::ffff:192.0.2.9/128", "192.0.2.9", true}, + {"::ffff:192.0.2.9/128", "192.0.2.8", false}, + {"::ffff:0.0.0.0/96", "203.0.113.9", true}, + {"192.0.2.0/24", "::ffff:192.0.2.9", true}, + {"2001:db8::/32", "2001:db8::9", true}, + {"2001:db8::/32", "192.0.2.9", false}, + {"::ffff:192.0.2.9", "192.0.2.9", true}, + } { + t.Run(tc.prefix+"_"+tc.ip, func(t *testing.T) { + repo := &scopedKeyMemoryRepo{} + settings := &SettingService{scopedAdminKeyRepo: repo} + key, _, err := settings.CreateScopedAdminKey(context.Background(), ScopedAdminKeyInput{Name: "CIDR验收", Enabled: true, AllowedIPs: []string{tc.prefix}}, 9) + if err != nil { + t.Fatal(err) + } + _, err = settings.AuthenticateScopedAdminKey(context.Background(), key, tc.ip) + if tc.allowed && err != nil { + t.Fatalf("合法CIDR被接受但范围内地址认证失败: %v", err) + } + if !tc.allowed && !errors.Is(err, ErrScopedAdminKeyInvalid) { + t.Fatalf("范围外地址必须拒绝: %v", err) + } + }) + } +} diff --git a/backend/internal/service/custom_proxy_options.go b/backend/internal/service/custom_proxy_options.go new file mode 100644 index 000000000000..fd5839112b16 --- /dev/null +++ b/backend/internal/service/custom_proxy_options.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// ListProxyOptions 为受限管理密钥提供有界的代理候选。 +func (s *adminServiceImpl) ListProxyOptions(ctx context.Context, page, pageSize int, search string) ([]Proxy, int64, error) { + repo, ok := s.proxyRepo.(interface { + ListOptions(context.Context, pagination.PaginationParams, string) ([]Proxy, *pagination.PaginationResult, error) + }) + if !ok { + return nil, 0, fmt.Errorf("代理分页查询不可用") + } + proxies, result, err := repo.ListOptions(ctx, pagination.PaginationParams{Page: page, PageSize: pageSize}, strings.TrimSpace(search)) + if err != nil { + return nil, 0, err + } + return proxies, result.Total, nil +} + +// SampleProxyOptions 为不支持分页的受限客户端提供全池随机候选。 +func (s *adminServiceImpl) SampleProxyOptions(ctx context.Context, search string) ([]Proxy, int64, error) { + repo, ok := s.proxyRepo.(interface { + SampleOptions(context.Context, string) ([]Proxy, int64, error) + }) + if !ok { + return nil, 0, fmt.Errorf("随机代理候选查询不可用") + } + return repo.SampleOptions(ctx, strings.TrimSpace(search)) +} diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index f282761b58ad..6169cdd37982 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) // IsRegistrationEnabled 检查是否开放注册 @@ -566,7 +568,7 @@ func (s *SettingService) GetIdentityPatchPrompt(ctx context.Context) string { return value } -// GenerateAdminAPIKey 生成新的管理员 API Key +// GenerateAdminAPIKey 轮换已存在的旧版管理员 API Key,不允许新建。 func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) { // 生成 32 字节随机数 = 64 位十六进制字符 bytes := make([]byte, 32) @@ -576,10 +578,20 @@ func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error key := AdminAPIKeyPrefix + hex.EncodeToString(bytes) - // 存储到 settings 表 - if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil { + // 旧版密钥只能轮换已有记录。使用条件 UPDATE,避免删除与轮换并发时被 upsert 重新创建。 + repo, ok := s.settingRepo.(interface { + ReplaceExistingAdminAPIKey(context.Context, string) (bool, error) + }) + if !ok { + return "", infraerrors.InternalServer("ADMIN_API_KEY_REPOSITORY_UNSUPPORTED", "旧版管理员密钥存储不支持原子轮换") + } + exists, err := repo.ReplaceExistingAdminAPIKey(ctx, key) + if err != nil { return "", fmt.Errorf("save admin api key: %w", err) } + if !exists { + return "", infraerrors.Forbidden("ADMIN_API_KEY_NOT_FOUND", "旧版密钥已停止新建,请创建权限管理密钥") + } return key, nil } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 7aea7c8ae98d..c8c0ad1e9de9 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -117,6 +117,7 @@ type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[i // SettingService 系统设置服务 type SettingService struct { + scopedAdminKeyRepo ScopedAdminKeyRepository settingRepo SettingRepository defaultSubGroupReader DefaultSubscriptionGroupReader proxyRepo ProxyRepository // for resolving websearch provider proxy URLs diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 688d729b31e1..472046b04721 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -792,8 +792,9 @@ func ProvideOpsIngressRejectAggregator(opsRepo OpsRepository, opsService *OpsSer } // ProvideSettingService wires SettingService with group reader and proxy repo. -func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupRepository, proxyRepo ProxyRepository, cfg *config.Config) *SettingService { +func ProvideSettingService(scopedKeys ScopedAdminKeyRepository, settingRepo SettingRepository, groupRepo GroupRepository, proxyRepo ProxyRepository, cfg *config.Config) *SettingService { svc := NewSettingService(settingRepo, cfg) + svc.scopedAdminKeyRepo = scopedKeys svc.SetDefaultSubscriptionGroupReader(groupRepo) svc.SetProxyRepository(proxyRepo) if err := svc.LoadForwardedClientIPSettings(context.Background()); err != nil { diff --git a/backend/migrations/247_scoped_admin_api_keys.sql b/backend/migrations/247_scoped_admin_api_keys.sql new file mode 100644 index 000000000000..2a9e2dca835a --- /dev/null +++ b/backend/migrations/247_scoped_admin_api_keys.sql @@ -0,0 +1,22 @@ +-- 管理密钥独立存储,仅保存不可逆摘要,保留撤销记录用于审计。 +CREATE TABLE IF NOT EXISTS scoped_admin_api_keys ( + id VARCHAR(24) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + key_prefix VARCHAR(64) NOT NULL, + key_hash VARCHAR(64) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + expires_at TIMESTAMPTZ, + allowed_ips JSONB NOT NULL DEFAULT '[]', + policy JSONB NOT NULL DEFAULT '{}', + created_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + last_used_ip VARCHAR(64) NOT NULL DEFAULT '', + revoked_at TIMESTAMPTZ +); + +-- 仅将已撤销管理密钥从列表移除,保留原记录与历史审计。 +ALTER TABLE scoped_admin_api_keys + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; diff --git a/docs/admin-api-keys.md b/docs/admin-api-keys.md new file mode 100644 index 000000000000..c24627f9b681 --- /dev/null +++ b/docs/admin-api-keys.md @@ -0,0 +1,145 @@ +# 受限管理员 API 密钥 + +受限管理员密钥用于自动化管理指定资源。它与调用模型使用的用户 API Key 不同,也不等同于管理员登录会话。通过请求头 `x-api-key` 使用;只有权限目录明确登记的接口和操作才会开放,新增接口不会自动继承权限。 + +## 创建与配置 + +管理员登录后,在「系统设置 → 安全」的管理员访问密钥区域创建密钥。权限目录和分组加载成功后才能创建。 + +1. 填写名称、备注、到期时间及允许的 IP/CIDR。到期时间使用本地时间,留空表示永不过期;IP 列表留空表示不限制来源 IP。 +2. 添加资源规则,分别选择操作、分组、账户类型和平台。新建规则不勾选任何操作或账户类型,全站权限也全部关闭。 +3. 按需独立勾选全站权限,保存后立即复制完整密钥。完整密钥仅在创建或轮换的当前响应中出现,关闭展示后不能从列表重新读取。 + +创建、更新、停用/启用、轮换、撤销和删除记录均沿用系统二次验证流程;是否弹出验证由系统的 step-up 配置和当前授权状态决定。密钥生命周期接口只接受管理员 JWT 会话,受限密钥不能管理自身或创建其他管理员密钥。 + +已有旧版全权密钥时,页面显示兼容折叠区,可轮换或删除。页面不再提供新建旧版全权密钥的入口;应将自动化任务迁移到受限密钥。 + +## 账户规则如何匹配 + +对于同一项账户操作,一条规则内部必须同时满足以下条件(AND): + +- 该操作出现在 `permissions` 中。 +- 账户类型出现在 `account_types` 中。空列表表示没有账户操作权限,而非所有类型。 +- `platforms` 为空,或者账户平台命中其中一项。 +- 已分组账户至少命中一个所选分组,或规则启用了 `all_groups`;未分组账户必须显式启用 `include_ungrouped`。 + +多条规则之间是 OR:任一完整规则允许该项操作即可。不能将一条规则中的账户类型、另一条规则中的分组或平台拼起来授权。不同操作会分别检查其所需权限;写入凭证、修改网络、状态、调度、模型和分组关联不是「编辑名称与备注」的附带能力。 + +`all_groups` 包含未来新增的分组,但不自动包含未分组账户。新建账户的每个目标分组、以及调整关联时新增或移除的目标分组,还必须通过对应操作的分组检查。 + +**共享账户仍是一个账户资源。** 账户关联多个分组时,命中其中一个授权分组可能允许对该账户执行已授权操作。改凭证、改状态、刷新或删除会影响所有使用该账户的分组。界面会明确提示这一点;请按共享影响授予操作。受限账户响应只返回允许读取的分组关联,不代表该账户没有其他关联。 + +## 分组模型是实际准入限制 + +`groups.models.read` 和 `groups.models.write` 是资源规则中的独立权限。它们按同一规则的分组与平台检查,账户类型不限制整个分组的配置,也不要求同时授予账户操作或全站权限。 + +此处修改的是上游 `model_allowlist`,会影响整个分组可调用的模型,不能当作只改变前端展示的列表。写接口只允许单独提交 `model_allowlist`,不能借此修改费率等其他分组设置。只授予分组模型读取时,响应仅提供分组 ID、名称、平台和模型白名单;授予全站 `groups.read` 后才可读取完整分组配置。 + +读取指定分组的候选模型须有该分组的读取授权,或者全站 `groups.read`。读取默认模型候选(分组 ID 为 `0`)须独立授予 `models.defaults.read`。 + +## 全站权限和响应边界 + +`global_permissions` 与资源规则独立,不受所选分组或账户类型约束。例如 `users.read`、`dashboard.read`、`payment.read` 可读取全站相应数据。选择某个分组不会将这些权限限制为该分组;反过来,选择分组也不会隐式获得这些权限。 + +权限目录按接口与 HTTP 方法精确登记。当前不提供 `payment.mark_paid` 或退款预览权限;不要把退款、取消订单、重新履约等其他权限当作这些动作的替代。 + +受限响应按用途收窄: + +- 账户读取返回基本管理信息和模型映射,不返回 OAuth token、API 密钥、任意 `extra` 或完整凭证。`accounts.credentials` 是写入权限,不提供读取凭证的能力。 +- 代理候选包含 ID、名称、协议、主机、端口、状态与到期时间,不包含代理认证用户名或密码。 +- 用户资料采用字段白名单;联系方式需要 `users.read`,余额字段需要 `users.balance.read`,分组配置字段需要 `groups.read`。用户响应不嵌入订阅和用量关联数据,这些能力由各自接口及权限控制。 +- 订阅响应不嵌入用户、分配管理员或完整分组;仅有订阅或平台额度写权限时,成功响应只确认资源 ID,不附带独立读取权限的数据。 +- 用量记录仅返回请求自身的用量、计费快照及关联 ID,不嵌入用户 API Key、账户凭证或用户财务资料。优惠码使用记录及公告阅读记录也只返回记录字段与用户 ID。 +- 请求错误详情等敏感运维数据有单独权限。全站读取权限仍可能包含业务敏感数据,应只授予任务确实需要的项。 + +## 密钥存储、轮换与撤销 + +完整密钥由随机字节生成,数据库保存 SHA-256 摘要及用于展示和定位的前缀,不保存可供重显的完整密钥。客户端应把密钥放入密钥管理器或运行环境的秘密变量中。 + +停用可再次启用;到期或不符合允许 IP 的密钥不能认证。轮换立即使旧密钥失效并返回一次性新密钥。永久撤销不可恢复。只有已撤销密钥才可以删除列表记录;删除只移除管理列表展示,历史操作记录继续保留。 + +编辑时若没有修改到期时间,前端会保留服务端原始值,避免将秒数、微秒或时区信息截断。 + +## 代理查询与自动选择 + +`proxies.read` 是全站权限,与账户的 `accounts.network` 分开。前者用于获取安全候选信息,后者控制为授权账户设置接入地址、代理与连接选项。 + +- `GET /api/v1/admin/proxies` 使用 `page`、`page_size` 和 `search`,默认每页 50,最多 100。 +- `GET /api/v1/admin/proxies/all` 为兼容客户端返回数组;显式传入分页参数时按分页查询,分页信息在 `X-Total-Count`、`X-Page`、`X-Page-Size` 和 `X-Total-Pages` 响应头中。 +- `/proxies/all` 未传分页参数时,从全池重新随机抽样最多 50 个候选,不排除已被账户使用的代理;响应设置 `Cache-Control: no-store`。 +- `ids` 可回显已选代理,一次最多 100 个不同 ID,按请求顺序返回实际存在的记录。 + +受限账户写入与 Codex 导入支持 `proxy_mode`:`direct`(直连)、`specified`(正数 `proxy_id`)、`random`(随机)、`least_used`(最少使用)。自动选择由服务端在账户写事务内从启用且未过期的代理中完成,无可用代理则失败。自动模式不能同时指定正数 `proxy_id`;旧字段 `random_proxy: true` 兼容随机模式,不能与其他模式冲突。随机候选查询与写入时自动选择是两个独立行为。 + +## Codex 会话导入 + +受限密钥可使用 `POST /api/v1/admin/accounts/import/codex-session`,但不会回退到全权管理员导入流程。 + +- 必须明确提供 `group_ids`;新账户固定为 `openai` 平台、`oauth` 类型,目标分组逐一检查。 +- 新建需要 `accounts.create`、`accounts.credentials` 和 `accounts.schedule`。更新匹配账户会重新检查当前资源和对应写权限;调整关联还需要 `accounts.groups`。 +- `base_url`、代理、指纹或 WebSocket 连接配置需要 `accounts.network`;`model_mapping` 需要 `accounts.models.write`。 +- `credential_extras` 只接受独立授权的 `base_url`、`model_mapping`;`extra` 只开放已登记的 Codex 指纹及 WebSocket 选项,不支持任意字段透传或跳过混合渠道风险检查。 +- 导入只按当前条目的身份查询受限候选,响应不返回候选凭证。更新已有账户时仍会在写入阶段重验身份与权限。 + +## API 示例 + +以下域名、密钥、JWT、ID 和会话内容均为占位符。通用成功响应使用系统现有响应封装。 + +管理员会话创建受限密钥(如系统要求 step-up,应先完成当前二次验证流程): + +```bash +curl 'https://example.invalid/api/v1/admin/settings/admin-api-keys' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + --data '{ + "name": "只读 OAuth 工具", + "description": "读取指定分组的 OpenAI OAuth 账户", + "enabled": true, + "expires_at": null, + "allowed_ips": ["192.0.2.10/32"], + "policy": { + "rules": [{ + "permissions": ["accounts.read"], + "group_ids": [123], + "all_groups": false, + "include_ungrouped": false, + "account_types": ["oauth"], + "platforms": ["openai"] + }], + "global_permissions": [] + } + }' +``` + +使用受限密钥读取账户: + +```bash +curl 'https://example.invalid/api/v1/admin/accounts?page=1&page_size=20' \ + -H 'x-api-key: ' +``` + +已授予所选分组的 `groups.models.write` 后,单独更新其模型白名单: + +```bash +curl -X PUT 'https://example.invalid/api/v1/admin/groups/123' \ + -H 'x-api-key: ' \ + -H 'Content-Type: application/json' \ + --data '{"model_allowlist":{"enabled":true,"models":[""]}}' +``` + +具备上述导入所需权限后,新建 Codex OAuth 账户并由服务端随机选择代理: + +```bash +curl 'https://example.invalid/api/v1/admin/accounts/import/codex-session' \ + -H 'x-api-key: ' \ + -H 'Content-Type: application/json' \ + --data '{ + "content": "", + "name": "导入账户", + "group_ids": [123], + "update_existing": false, + "proxy_mode": "random" + }' +``` + +生命周期接口位于 `/api/v1/admin/settings/admin-api-keys`:`GET` 列表及目录、`POST` 创建、`PUT /` 更新、`POST //rotate` 轮换、`DELETE /` 永久撤销、`DELETE //record` 删除已撤销记录。更新请求包含完整配置与策略;不要将未提交字段当作保持原值的 PATCH 语义。 diff --git a/frontend/src/api/admin/adminAccessKeys.ts b/frontend/src/api/admin/adminAccessKeys.ts new file mode 100644 index 000000000000..3580a5f30fb7 --- /dev/null +++ b/frontend/src/api/admin/adminAccessKeys.ts @@ -0,0 +1,37 @@ +import { apiClient } from '../client' + +export interface AccessRule { + permissions: string[] + group_ids: number[] + all_groups: boolean + include_ungrouped: boolean + account_types: string[] + platforms: string[] +} +export interface AccessPolicy { rules: AccessRule[]; global_permissions: string[] } +export interface Permission { id: string; label: string; category: string; scope: 'account' | 'group' | 'global' } +export interface AdminKeyInput { + name: string + description: string + enabled: boolean + expires_at: string | null + allowed_ips: string[] + policy: AccessPolicy +} +export interface AdminKey extends AdminKeyInput { + id: string + key_prefix: string + created_by: number + created_at: string + updated_at: string + last_used_at: string | null + last_used_ip: string + revoked_at: string | null +} +const base = '/admin/settings/admin-api-keys' +export async function list() { return (await apiClient.get<{ items: AdminKey[]; permissions: Permission[] }>(base)).data } +export async function create(input: AdminKeyInput) { return (await apiClient.post<{ key: string; item: AdminKey }>(base, input)).data } +export async function update(id: string, input: AdminKeyInput) { return (await apiClient.put(`${base}/${encodeURIComponent(id)}`, input)).data } +export async function rotate(id: string) { return (await apiClient.post<{ key: string; item: AdminKey }>(`${base}/${encodeURIComponent(id)}/rotate`)).data } +export async function revoke(id: string) { return (await apiClient.delete<{ message: string }>(`${base}/${encodeURIComponent(id)}`)).data } +export async function deleteRecord(id: string) { return (await apiClient.delete<{ message: string }>(`${base}/${encodeURIComponent(id)}/record`)).data } diff --git a/frontend/src/components/admin/settings/AdminAccessKeysSettings.vue b/frontend/src/components/admin/settings/AdminAccessKeysSettings.vue new file mode 100644 index 000000000000..bc256fc47952 --- /dev/null +++ b/frontend/src/components/admin/settings/AdminAccessKeysSettings.vue @@ -0,0 +1,193 @@ +