From a88ca146394d2c595e2cf6b33f2e97a74f297845 Mon Sep 17 00:00:00 2001 From: migatu Date: Mon, 17 Aug 2026 18:39:47 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(astroklient):=20wersja=20demonstracyjn?= =?UTF-8?q?a=20o=20dw=C3=B3ch=20funkcjach=20(PRE-28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Osobna warstwa prezentacji: dodanie pliku bazy i zapytanie o interpretację urodzeniową. Nic więcej. OSOBNA USŁUGA, NIE KONTO Z OGRANICZENIAMI. Mechanizm uprawnień z PRE-27 umiałby to ukryć w pełnej aplikacji, ale ukrycie a nieobecność to dwie różne rzeczy: tutaj pozostałych funkcji NIE MA W OBRAZIE — nie ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy okazji kodu reszty programu. Test porównuje zbiór tras aplikacji i zbiór metod klienta z listą dokładną, więc dopisanie czegokolwiek zapala się od razu. WGRANIE I WŁĄCZENIE TO JEDNA CZYNNOŚĆ. W pełnej aplikacji to dwie osobne decyzje (DAN-27), bo tam ktoś nad tym panuje. Tutaj „dodać plik do bazy" musi znaczyć, że plik od razu bierze udział w wyszukiwaniu — inaczej po wgraniu nic by się nie zmieniło i demo wyglądałoby na zepsute. Walidacja zostaje: plik o złym układzie nie wchodzi do użytku, ale też NIE JEST tracony, a komunikat nie zdradza reguł, bo te zna wyłącznie administrator. Osobny test szuka w komunikacie śladów mechanizmu. KONTO OSOBNE (DEMO_USER/DEMO_PASSWORD), nie współdzielone z główną aplikacją. Demo pracuje na TEJ SAMEJ warstwie danych co produkcja — świadoma decyzja właściciela — więc kto ma do niego dostęp, czyta oryginalne bazy, a jego wgrania trafiają do produkcyjnego zbioru. Własne poświadczenia pozwalają odciąć demo jedną zmienną, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek. Zapisane wprost w README usługi i w manifeście, nie tylko w tej wiadomości. Rozmowa z warstwą logiczną idzie tym samym szyfrowanym łączem (PRE-16) i pod tym samym tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę. Automatyczna dokumentacja wyłączona, jak w pełnej aplikacji: /docs wypisałoby komplet tras, a demo ma nie zdradzać nawet własnej powierzchni. Zależności celowo krótsze niż w prezentacji: bez Excela, bez stref czasowych z lokalizacji, bez niczego pod kosmogram. Każda zbędna zależność w obrazie demo to kolejna rzecz do pilnowania. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/build.yaml | 6 +- .gitea/workflows/tests.yml | 21 + docs/astrololo_wymagania.xlsx | Bin 40264 -> 40721 bytes services/astroklient/Dockerfile | 10 + services/astroklient/README.md | 78 +++ services/astroklient/app/__init__.py | 0 services/astroklient/app/clients/__init__.py | 0 .../astroklient/app/clients/logic_client.py | 70 +++ services/astroklient/app/config.py | 23 + services/astroklient/app/link_crypto.py | 525 ++++++++++++++++++ services/astroklient/app/main.py | 148 +++++ services/astroklient/app/security.py | 176 ++++++ services/astroklient/app/static/styles.css | 51 ++ services/astroklient/app/templates/base.html | 20 + services/astroklient/app/templates/index.html | 87 +++ services/astroklient/requirements-dev.txt | 2 + services/astroklient/requirements.txt | 11 + .../astroklient/tests/test_astroklient.py | 203 +++++++ 18 files changed, 1429 insertions(+), 2 deletions(-) create mode 100644 services/astroklient/Dockerfile create mode 100644 services/astroklient/README.md create mode 100644 services/astroklient/app/__init__.py create mode 100644 services/astroklient/app/clients/__init__.py create mode 100644 services/astroklient/app/clients/logic_client.py create mode 100644 services/astroklient/app/config.py create mode 100644 services/astroklient/app/link_crypto.py create mode 100644 services/astroklient/app/main.py create mode 100644 services/astroklient/app/security.py create mode 100644 services/astroklient/app/static/styles.css create mode 100644 services/astroklient/app/templates/base.html create mode 100644 services/astroklient/app/templates/index.html create mode 100644 services/astroklient/requirements-dev.txt create mode 100644 services/astroklient/requirements.txt create mode 100644 services/astroklient/tests/test_astroklient.py diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 64479d7..caeb9aa 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -9,10 +9,12 @@ jobs: - uses: actions/checkout@v4 - name: Login run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin - - name: Build & push (data, logic, presentation) + # astroklient dołącza do tej samej pętli: dzieli warstwę logiczną i łącze, + # więc jego obraz ma powstawać z tego samego commita co reszta produktu. + - name: Build & push (data, logic, presentation, astroklient) run: | TAG=${GITHUB_SHA::8} - for SVC in data logic presentation; do + for SVC in data logic presentation astroklient; do docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG done diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index e8bf142..ec025a4 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -91,6 +91,27 @@ jobs: PYTHONPATH: . run: pytest tests -q -rs + astroklient-tests: + name: Testy astroklienta (wersja demo) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: services/astroklient/requirements-dev.txt + - name: Instalacja zależności + run: pip install -r services/astroklient/requirements-dev.txt + # Demo rozmawia z warstwą danych PRODUKCJI, więc jego powierzchnia musi być + # pilnowana tak samo jak reszty: testy sprawdzają m.in., że nie przybyła + # żadna trasa poza dwiema funkcjami. + - name: Testy (pytest) + working-directory: services/astroklient + env: + PYTHONPATH: . + run: pytest tests -q -rs + swisseph-image: name: Build obrazu silnika B (swisseph) runs-on: ubuntu-latest diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index a847a599fcdc1de67faabd78da709c9a6e4710ba..3cfaf92e8919d43191e386a79c80032190ca7759 100644 GIT binary patch delta 9776 zcmZ8{Wl)_?(PuW-Qnh(@4Ii+ zef!T^HPa(KJvBAewfY<3qFdq66=mQN@L*tIkYQ@#u%pr2k^h3;k2MUD=oxyX!4{XP zNn7z)cvyBIfjlS`2Km5OGQgdt@#C_V9Zk4G8xs9Y;PU%iDP>=g80{E;DWZm+etGFq zg1pD8W9IR<6%s$m->iQOM3vc-yQ?;mCvZd_5xw@Nyj>U$Cv|Z5{O*;U7@5Kvb%a+{ zJS`21jQL%jAG+*dAydzoP_VG}X1c_|FEgi$g^#{mbYaE&xxJWEDip+!T~p}6dJ*1l zw?;n~`4T#=xpI^O5bzXfK~JZD}dSpB=6hv;Cjws&8Ng5#j&FSIvWb^5$Ox z!gwM7moPB*vS9oZ`n^$uyvlnR7-{?1XDuo~$BwMe^~8Vy`RnKh3Wu);R+Fy6p*7;6 z)F?5F%j#GNTq`|B3Im~dpsk~do(fr@J|D+CRYo7=pP>7TRkajWD1B^qgL0Pa?6s)G-5=$?EZK7a z%nzXn)s#vM?li2btvq+fk!EYWogsW$m3o>XL%{TJ<5$;k@>(^UHS2i02Gx z!Ris?HV>VFcV!fZOa9~>D%Sd(lt9GcFGgv8@?cFp9}hb>r^lYzUD-~9`=13Dru(&D z%bZ{MZ zKD2d26v<9=kn6hJe!t#sDY*47VKpPjE#L*Rm@z5OeIqb%UNrvf3>n77(k4X!w7pJZ zZ0thYnE0oO$WSOR2+G_uYZLNC4-b>M=jRfh0yi&+LOUd|j)9wp4@99A@`X}~|0L7h znt=ikKd<Me&HRkElIaxkpmTqtr^C5 zoEpAcy`g3=tmf?PqmYzs=&x2 z$Ez^MTKjQUH%sL_cBmh*wHR>sjdO20Zjcc9f(6J4i==fd+K5w-Wk1PiBWGY@tv;DQ zEtQ{)=&Xo78e4Q~BP8oQBooQ0G1vsOHQ@mTIFzhv$I|w{Y)905GYR z!$(ABn||6MEKq|PL$f5@uB5a)e~biH-wPdBWflC`jT=W}D0BXeYry97$o2Z%2iU2^ zy<{nDR@paq7i^3fgu*+t0*#M|V#n(uMh=%UF!9)v3LM1Z%JmFB|tx6QTJ+!ADl8%&zE5Dbi{mf&kV z9ZAyN49qA=UbTUr-1tYkhpm)DL8kF+&Hl98gN62GRzJ*4DvXU9PK%-tqP}(F7KNCh zz^0ZAVgWanRkSNdR01tNr*Y%P zAeo*R?mTj$HxC82J|wsrIQCt{p_-2-M)umrUU2+NPh2S&jx7d{>Nl3XM)2qcKL3z5gCvjL zyT+I&8LjGC*uZJ%?0-x#(j80|r zE952~0~^oE)*hu8G-i~-q}!*fG;!Nr_YJ`0ecT!DWuibK*7~;X=K7k(SU*d_8S5Em z2siph)g4;3-Dk@u)K3{B@8Z8rD-n43_hSNgIykTk@iNAoGZ^@bfnQFBL$?J&yUzxHviCC1i3Eg8)4)teXO~)U znjCroms!3#(Va3Yg0!BxObc6lcS~Pvq`8IfNYqVSqqw;|mrv;|dp;7Rw(nH9!pUEm z_{08?$|9-K1y4a=j4%a;gXeXm&rr|tZ0uSI+mBg@;KR)@ zfI*lYe))>5p4d;aH#kh*&@vrDYjUp4rvVndWb7{*^97{mcv?%6h9CyA)yxFahoIro$v}tuYY? zk~}m*#kRJ@Af^xm;#R6HW5@mH%;kaCr@l2=0>;)hWJ4l`R=se<*~r!jhZG6G>H-PB z;2?0Inu%?8;?Fp0^Ocoju3o&XuiuM$KxmrAm~fM?ub=Q6lz?DmHV6KN;xTyHlT}(o z0%^Qip2NK5#x+ zmToAVR><&MPMyJa`k({f6ZNtIk+Q0&XtdaDxrWC;X_J;SGQ>r$9g2kw8ZRr~H25Sayd+&y=X#-(B#c+`_wC~T*o4e8&@4(0p!2{9=Cl1`lHV;zi7CB0Hb65cwI#FW3& zZp|2NUO8;6=lj+%@XEsz#_FXd#1q|~)R%X{OX=n3np22LiKZAMS=wj<@kEs|uUG_6@45`2A+SXUcZdhNg z=mH-_RdDENx|NebrOBaxNtQhH>A_nH#R;yfWw5?s0uP=0Wc@(Uu9OT-Q5jlSYgO?v zZbGek{UJ@}Gh8N)!_^3od>1Bm%edi}`HoPl^yD~te%wTU?dbFvaI$x{_p0=)!F*|? z_J4~=EmA0}nE-W37Nc_-wAl&XXv0b5^@ZSkL4R%H z6{qctC`kGdbJGoa3+GCGfA+yHvI@@mnt*I7h?^x%OC}E|)LaL6Is=yzBu~SlTVgP0 zTA2kgPKym20nINSF@)D(!xIQ5tM5npx)zYyId!k^=rTMvH`8zmZ2(hAmTw6BL$fb- zw_>e?@Nh3$9h5Q zp)ZYs9&3a6&|r|ZocG5Eju*o(-HeWE60vw2cptHLvVDNBJz!ySP2_gypjtQAd#~NA zCy1*HrJ;I3Bj)&Z@gs~)WFw~iMRw*DL3<5>I;*;DZUxq&*@)#4KUYrPSppG@+j>pr zP7$2uTY(`x_kR5z8|g%dTcMsUbe1&`oK(jfs8Tm-?ypRpS?g)v||7K%`)tKDtOPJtNT534MECS=A3Px|t z)pq5lhM3?^p0UE?UyEPp#*Ck6tWS%ze(zP|MhIfZzlF&mvTL3UBjFS<9(!EP2_Pn3 zci{d_hRR42EZk>1S#}SXa6(nLK6gFhvSS@D%?Gg4b^-! zi5f8sR9URbw$3kxNtbRI-xSQ@acx}{NQnfD^|q5OcPY}>I#S6Y6yc>jkVr~b%b*tb zvr(0XZ4vhDYkvxpeX@xr4FI`Y04^5{6zEotnexsiu?ZIW|n}$35PTfHb%?Pq|FCN zG%kL3$uX15j#ctTbeS0@)8x@9NFX{@0seGHOgRQ+!^ymCF>Ty;=k`-pTQ;kJ_|let zz;BkQU@0b5^&YKXW;qbziF^I+UySI?kWvEb=F$(tmQqT%q{IcoR2J;T!ehvb33y5$ z16T@Y2QMydtG#DDxM+_?_@*m(bing~SX}w*`~mb~C{0m_NL5;5R}qz2LgfaSP->?j z3cFED>+<-66BI};yFy*lmD5@N6-6jM>EO#wEZr$GE1yBz&vZTwWqMWF`^&9x^nT9% zPGnzBv_h$wJ__*nj766x+|Y3`XF}@mytP&qeoE?-j-=;dFBcsuy2b|8OL2cdS(brc zJL>A~RvptS&kxP6r*p<7T*l`uiSj}2yq%FRZi)-D+Lu^nwQI_lT_Pnnf(P3Zm&H*5 z?^2|fPhcA)M?qg#XI{K2@CX^FY=5~KbgFzQ3mb~~*7zfka>0CkfoaPrN0N*5{Nc*| zbVQI=zICan;7!cKMu|azebTKwP|q*`X=a+be!ESu9bg!F+vNG=D&Ex9d8p})^CgOF{E-oz9hWDimN1-TzhJq)PP6PTaJ zF6gsk#b{j-c#uyeI~5RWez8_m~ZH@AWmr*pY7+wvr6KjjP*-H~i9 z&fg#{9dc0EXqXB&5Zziep7lk*sfb&p-5Kk>xPI@Cs8kQcW1)FX(j}?@ykCpTMQ&RU zD@iF`jQET;Zp6nYvz|_(5M3m>AuXMa4EAo<*%FzLC6KNaLO_+e zEx!yhr5IW2`_%JpTDjF4yNFNs`Z%dUv^EI%Z|TgW@`t9D%D55EY zZfamIwBLvXac%Uf@*c*Hfe6NhWkFe>U;YiORk{ApOF`;kDOB;Fn`WYoC>2jA=592u zO3)@=B+5z+t^tJv>%QFO0Vm5(%r1{Y92Haxz{lxMLr^EzPjqH>H)us7!NMY`Hp*4d#s8h9#Q99DGPm%P0+3kFLB?gNQq?AQREY9t`@Ht$9&4^stl!x-|!BP9_x6WRO z!B-~J9RjponSqvDms@N3D5~{D=3Qt)T+0uDo>C!Rg)qr-{oA~>hh!5y{i}18r>m=j zSEKo#{+nYdjV{U-ejx%G z^|{2WPJ2blJyk?WGQRibXsCt?zAC}kzBs2St-~bFs!gIdGkj}?^&MV+NV*1U2S~Et z15XeaOt;~S@c?_L#0%eWm)}sa_F4ublA7r7<&0d6!DY&^KCI1(rRreaHOTJDh8qEMZ4lP~$Lh`EI*BukA;?s>Ne zAF%&t)n7C5^;4m-T;`eM@&Zux={krm4BfVZ`Optd%cv zEGl0r{XJ1%bmW$3^>5}a!EI*rQl}kHtl3*bL;gAxd#UL=T@}d9GDoYxsUE2BQe)S7 zkEpTkzMt%iZS~OT#O)LusWIco=Y6bKk)QkihNI^&JgTfhp>K)aYZOf11domG1yLJw z+R%AO;QNv!6P5ENq8x9JfSe8d1HxN%(`$%q!s;lDV5}4DN4Kv%SFdnMGV*(nM5_56EAZ%kV701Fd}oO;9RiQCIE7*dF^l!vK`64x%iEJ_=(HM%6( zDrgJCq zM>Zw3gjKP*bE}O(wtdSt@^2&g{EDs#ODI2qS6UET@IIPR{<=lH_*+>k@Ko@`cMpLD zUH^LZJ~+(7n~nRPxG<#DUr^#j_+}$c359*?q(iQYmOhoC4A*?Osi3JsZ1KWpZ zmHykQFiQ)-7o=PZ>Eq3F@{@jCthFTnj;NN#uCq>UU>rM3_no%1C&%5vzaYyl zN%If3l9U1c9h!O6WfxWD(wwFy-wBRN0l8Z`>R$C0YqmlKu%$4=^nT+&&|g{_abOmw z%9B+C)drrw+qucH&Z9BP5x!CzvicSL@AOhJA`GQXZ2RcPePf(#z)Qy5VbbwSq+iEXh% z%U?NIG;9)|uuRfJXbD)5xIzh7;F@OGe*62NLZ3AlU@s2|Y|xG{0&~@#Gxwy@w9%1A zqh`eQpSF}5mF#P+95lCtn2s<0UX+?7v~tBg-@lXiz1&%_E{%)KL0=$kIl7%&!yPe* zAc1#Gu;_iOH8X}YNK0fqDhtu4y|kFJzQ0&M+Xla#u@yXf@4-t~$jKA?72~O=aNiF* zV*w))Sb@*{>ZHFms1PA!Th~v*Z8r`1$0L;3M_^;|Y}BAcMfO(DOn;X+ z&vh_T{T7p`yI9C3hh3>V{;ow|#)<@VUeyQ0X!FtYNquK23f+_2CKFUf*XJ}Pz!m2y zv8v4~i;moLj99!KSIj>-QC&i=mkefI(y^uyJ6D;4k<_D!)?Fktn2+?`OIR{+N0p{E z;89oZTQ2lCk>-K0xT%dQaVhK7zB^QitHrdnfnt5L?v&%L-o7k%exNm_7bysqMjQun z4Fk-M17>=vDY_2u3^R;eu)Y0^T+pwN8I$U?68sm|oIe0GY<5z}svRMmYOhjkczS}n z=F{)y!Qyf<^!hhMN?@e#E@TA8hlvvkagXxxZ7T5 z$_E=|SJMxBDNLe`?UKDf9TYS;2;w6?JOWNJcIBM9 zT7EN1ss)nq4{@k$8Ym_s{TaCBKy$#l_nku>zBPvRPo3)A+azYqGwHyl%nIk~*wKt) zCHuank*A!j{AqZb+LAR(b}90+bFoAuWRH5^1y@fzW1WsyuJS-Q(?eA*X#E5_(OIch zD?O5Mk`m`?r>90!enZkCpAA?t$lT@&f((eAnj>g2 zWY{y!r1ej=){d^6DY&IUz(RNhKMp!jJc~9MqnLu|!}Gi8Yq*QY4ONWsx#}HVwl>7_ zmo_0Aq?4b*sCrGfkAu-O9zofBXI#wmP@_4ea{nghVgctlNX|;g0qE_m-{Y`i>M#xq z|Kn?nL5riSGYy(uP{&5D65HBWB8!Fnk=MH&C4P`MxhZG&lhmB!Mxi%f&Q>S~S4r(R zxGC1C5tf#CikY?BBBRE}5lUs21EnH%8u(Zo%{fb}2X(!p6{IH`lrXCLM&I`ZiRZQD zm8DXcTW-CEeGsIy3)Fo`R4g`=E5QByy<<1MdD|K9%_@vSo|6lyy0*|n9z_~HL_S2H zXX5X}VVkXzrBQSA-cC6FU`!Yp=^6veD&~s$|`^J(K&XhoJSfB zp{+GXQK^1*Y}j<1Y;P()vTPsn3f&k|Hx?v}%s=kng;PeE4M?fo3LbSi%fpzl_|Q$m z0vG?APl}xYs1sC}t#`G+oK0dvDeQ`scfQ=t_fp0l+zToOv6&pTR@C~jKhbceQ<$Gh zoLpSr>Pr@-_acflONq$@I`yxighkWloxcfI@OC-ebmvWMIJd|nl_Bh~uVm3rZR6$a zW6g&(LLZI}0H99B8Y1+h?#A5C9~xY>V_?5Fo`7Oy^sJV1MG1BL^8(PPh(DmmL6pkz zj0J2QO+Ky_gPUqb5UWNSix!_e1)MV*h-y0G!_m7~UM4 zC0OQKhKGhf^!L?F7b>8pbi^gaWpD)tlTaicO9u^hAgczsOHi$Yv>2_R^6v4p4ezE~ zo|d(jYoU9>Xio`alE8#Ibgt-QPy1__Ba0+^{-Zx5;xR>ujEHBH9HCVC&;j+N%A}Jd zPTl|}y2o$%*hv?Gz-QN$h&t-+gK-5(TCBeQl(Ku2!Wuh#?ky*R+~-A#Wllb#k?7G` z!#d_7pu|)J4kc{!mKT~YVOw(Qe6J3+)1@CEPub+Pl;hJ~iO9X%t8OTWxmCOSK1O2h z*;xiQbh7+R{xgT({@YMZf^_2dZd^YUUj3yq3Yvz_?V(&No&c`;$>uUTw@X}!=Mjgq z=fnbMb?d9#G7bSI;9GQGX?IGwU#W3TrNzlvw&wQL-N*vu zS%nu|2Uc0j`_eZd=OeO&d+IUcW$P;3%8#D~x(=J?lUHZ+A=6n`pH7rxn9ALP(4|SO zj8f4spI_X~3~apIPq>*ZDe1!ME%6#+y*quMx4(fij`9$} z9;7r<_c=$!Ok-m}6dyKLVA)->&RlTW{X-crt4fT2&3Jsk~;qv5z>j z)7#yUr~ooiE2r)@yxOhD`N?DZVKy$Nc)MsxTawTFf_aseA~rYCF6Yv$$VP^M%+ue> z!)6_dQMBVY3*w34`!#mkZ+)G#$Bj(p*)?W!t6-fxw>By>+T z@twJ9k&aHxHJ>U1iVjbS{Oi!}(@0U}X9F5K+{UFSn(k*0Q`KI-!k(EQ?z`+|_d!x$zdM<1NEOnO;z`Dy;Ho8YN4Ie;#YDSN}(-M9AwDp2S22+;| zMN>&#g7w#v^R!{vjg)JC{WpAjycy2cl4aW1lI`!-JR_v(WK{kZUXY zt~xUu>EeK}W+h-ztB_n(SsYn@CMnLv+)9~sG4AYjx_(3^)Y|eA+)SS(%e&Zbn}p_J zt*@=n-bC02n;KFq-gJ2nr;@|t zSQKLx5jSq>vH0~2pA~Kb`^G@FjUzcxCC;f~Gx$%0lA5(k9)>%B#IZ`a2K!zX_qsVD zKG{C7;CX0i?C*!j+lFw$;19Uz^-AgYE?I zFN*o1B3gSh_W3in@(C~XM6j5Rq6$4Dl*3D2+OOd)8tehBG}MZTnk&5zv0rY*tzV9k z?rCRGU#3iV#^#f0+m!QtgH0ab1ly*0OMxsQJN4K5_VO3P|10dN@~lSvSH@!zc#8Zl z(T*B~|A)lV$L;*vfECXO_b(Ahgopi?h$R{POJehq`TnDNirq`*`F9+3ir~L!k|Opm z7X5!bO%eNVoscl()4wjB5K=f>@7NAc5==!ISU5cR|LCe>JN|aj1joW^n$%+gShCYa6kV^djc9Tj9H=GcgluAzZ&vLzalHW%E`ph7}!2DhDKjsU2pA;*b YNcP`bj!7ehdrgidPoqUNfc)eAKahgag8%>k delta 9254 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 dict[str, str]: + token = os.getenv("INTERNAL_TOKEN", "") + return {"X-Astrololo-Token": token} if token else {} + + +def _link() -> link_crypto.Link | None: + """Klucz łącza czytany przy KAŻDYM wywołaniu — pod przeżywa rotację sekretu + bez restartu, a brak klucza przy wymaganym szyfrowaniu ma zatrzymać żądanie + ZANIM cokolwiek wyjdzie jawnym tekstem.""" + key = link_crypto.key_from_env(link_crypto.ENV_PRESENTATION_LOGIC) + return link_crypto.Link(key) if key else None + + +class LogicClient: + def __init__(self, base_url: str | None = None) -> None: + self.base_url = (base_url or settings.logic_url).rstrip("/") + + def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]: + """Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach. + + Jedyne pytanie, jakie astroklient zadaje o treść — bez grupowania, + bez promptów, bez modelu językowego.""" + payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, + "limit": settings.report_limit, "group": False} + with httpx.Client(timeout=settings.http_timeout) as client: + return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report", + payload=payload, headers=_auth_headers(), link=_link()) + + def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]: + with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client: + return link_crypto.call_json( + client, "POST", f"{self.base_url}/files/upload", + payload={"filename": filename, "content_b64": content_b64, "by": by}, + headers=_auth_headers(), link=_link()) + + def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]: + """Włączenie wgranego pliku do użytku. W demo wgranie i włączenie to JEDNA + czynność: „dodać do bazy" ma znaczyć, że plik od razu bierze udział + w wyszukiwaniu — inaczej demo wyglądałoby na zepsute.""" + with httpx.Client(timeout=settings.http_timeout) as client: + return link_crypto.call_json( + client, "POST", f"{self.base_url}/files/status", + payload={"path": path, "status": status, "by": by}, + headers=_auth_headers(), link=_link()) + + def health(self) -> dict[str, Any]: + with httpx.Client(timeout=settings.http_timeout) as client: + r = client.get(f"{self.base_url}/health", headers=_auth_headers()) + r.raise_for_status() + return r.json() diff --git a/services/astroklient/app/config.py b/services/astroklient/app/config.py new file mode 100644 index 0000000..0e3769b --- /dev/null +++ b/services/astroklient/app/config.py @@ -0,0 +1,23 @@ +"""Konfiguracja astroklienta — wersji demonstracyjnej (PRE-28). + +Zna WYŁĄCZNIE adres warstwy logicznej, tak samo jak pełna prezentacja. Nie wie +nic o bazach, Excelu ani SQL-u. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field + + +@dataclass +class Settings: + logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001")) + # Szukanie interpretacji potrafi zajrzeć do wszystkich baz naraz, więc limit + # czasu jest wyższy niż domyślny dla zwykłego żądania. + http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "30"))) + # Górny limit rekordów w jednej odpowiedzi. Nie jest to optymalizacja, tylko + # ograniczenie, ile treści baz może wyjść jednym zapytaniem. + report_limit: int = field(default_factory=lambda: int(os.getenv("REPORT_LIMIT", "2000"))) + + +settings = Settings() diff --git a/services/astroklient/app/link_crypto.py b/services/astroklient/app/link_crypto.py new file mode 100644 index 0000000..89ed3d8 --- /dev/null +++ b/services/astroklient/app/link_crypto.py @@ -0,0 +1,525 @@ +"""Szyfrowanie łączy między warstwami (PRE-16 / LOG-33). + +Do tej pory warstwy rozmawiały ze sobą zwykłym HTTP-em wewnątrz klastra. Token +międzywarstwowy (LOG-32) mówił KTO pyta, ale nie ukrywał CZEGO dotyczy odpowiedź +— a płyną nią surowe wiersze oryginalnych baz interpretacyjnych, czyli rdzeń +produktu. Kto podsłuchał ruch wewnątrz sieci (drugi pod, port mirror na switchu, +zrzut z węzła), miał je w całości. + +Ten moduł zamyka tę drogę: **AES-256-GCM** na ciele każdego żądania i odpowiedzi. +GCM daje jednocześnie poufność i uwierzytelnienie — cudzy albo podmieniony bajt +nie odszyfruje się w ogóle, więc nie ma osobnego problemu „zaszyfrowane, ale +podatne na modyfikację". + +**Dwa niezależne klucze**, po jednym na parę rozmówców: + * ``LINK_KEY_PRESENTATION_LOGIC`` — prezentacja ↔ logika, + * ``LINK_KEY_LOGIC_DATA`` — logika ↔ dane. +Dzięki temu przejęcie klucza prezentacji nie daje dostępu do warstwy danych, +gdzie leżą całe bazy. Logika trzyma oba, bo rozmawia w obie strony. + +Z każdego klucza łącza wyprowadzamy **osobne podklucze na kierunek** (HKDF). +Żądanie i odpowiedź nigdy nie szyfrują się tym samym kluczem, więc powtórzenie +losowej jednorazówki w jedną stronę nie osłabia drugiej. + +Format ramki (bo strumień odpowiedzi może iść kawałkami — patrz okno postępu): + + [4 bajty długości][magia "AL1"][12 bajtów jednorazówki][szyfrogram + znacznik] + +Do materiału uwierzytelnianego (AAD) wchodzą kierunek, ścieżka, znacznik czasu +i numer ramki. Skutek: ramki nie da się przekleić do innego endpointu, odtworzyć +po czasie (dopuszczalny poślizg ``MAX_SKEW``) ani przestawić w strumieniu. + +Bez ustawionego klucza moduł **przepuszcza ruch otwartym tekstem** (dev, zgodność +wstecz) i krzyczy o tym przy starcie. Gdy klucz JEST ustawiony, warstwa serwerowa +działa fail-closed: nieszyfrowane żądanie dostaje odmowę, żeby przypadkowa +regresja po stronie klienta nie oznaczała cichego powrotu do jawnego ruchu. +""" +from __future__ import annotations + +import base64 +import binascii +import logging +import os +import struct +import time +from typing import Iterable, Iterator + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +log = logging.getLogger("astrololo.link") + +MAGIC = b"AL1" +VERSION = "v1" +NONCE_BYTES = 12 +KEY_BYTES = 32 # AES-256 +LENGTH_PREFIX = 4 +MAX_FRAME = 64 * 1024 * 1024 # zapora przed alokacją z podanej długości +MAX_SKEW_SECONDS = 300.0 + +HEADER_ENC = "X-Astrololo-Enc" +HEADER_TS = "X-Astrololo-Enc-Ts" +CONTENT_TYPE = "application/vnd.astrololo.enc" + +ENV_PRESENTATION_LOGIC = "LINK_KEY_PRESENTATION_LOGIC" +ENV_LOGIC_DATA = "LINK_KEY_LOGIC_DATA" +# Trzecia para: prezentacja ↔ render (PRE-24). Osobny klucz, jak przy pozostałych — +# usługa render dostaje CAŁY raport (dane urodzeniowe + opisy z baz), więc przejęcie +# jej klucza nie może otwierać łącza do logiki ani do danych. +ENV_PRESENTATION_RENDER = "LINK_KEY_PRESENTATION_RENDER" +ENV_REQUIRED = "LINK_ENCRYPTION_REQUIRED" + +REQUEST, RESPONSE = b"req", b"res" + +# Sondy k8s pukają tu bez klucza i tak ma zostać — inaczej pierwsza literówka +# w sekrecie kładłaby pody zamiast pokazać błąd w aplikacji. +PUBLIC_PATHS = frozenset({"/health"}) + + +class LinkError(Exception): + """Cokolwiek poszło nie tak z kopertą — celowo bez szczegółów na zewnątrz.""" + + +# --------------------------------------------------------------------- klucze + +def parse_key(raw: str) -> bytes: + """Klucz z konfiguracji: hex (64 znaki) albo base64. Zawsze 32 bajty.""" + text = raw.strip() + if not text: + raise LinkError("pusty klucz łącza") + try: + key = bytes.fromhex(text) + except ValueError: + try: + key = base64.b64decode(text, validate=True) + except (binascii.Error, ValueError) as exc: + raise LinkError("klucz łącza nie jest ani hexem, ani base64") from exc + if len(key) != KEY_BYTES: + raise LinkError( + f"klucz łącza ma {len(key)} B zamiast {KEY_BYTES} — wygeneruj przez " + f"`openssl rand -hex 32`" + ) + return key + + +def key_from_env(env_name: str) -> bytes | None: + """Klucz albo None. Zły klucz to wyjątek OD RAZU — nie przy pierwszym żądaniu.""" + raw = os.getenv(env_name, "") + return parse_key(raw) if raw.strip() else None + + +def encryption_required() -> bool: + """Czy brak klucza ma być błędem, a nie cichym powrotem do jawnego ruchu. + + Serwer sam z siebie broni się fail-closed, ale to za mało: klient BEZ klucza + wysyła pytanie otwartym tekstem i dopiero potem dostaje odmowę — czyli treść + zapytania zdążyła już przelecieć przez sieć. Ta flaga zatrzymuje go, zanim + cokolwiek opuści proces. Ustawiana razem z kluczami we wdrożeniu. + """ + return os.getenv(ENV_REQUIRED, "").strip().lower() in {"1", "true", "yes", "on"} + + +def _subkey(link_key: bytes, direction: bytes) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), length=KEY_BYTES, salt=None, + info=b"astrololo/link/" + direction, + ).derive(link_key) + + +class Link: + """Jedna para rozmówców: klucz plus wyprowadzone z niego podklucze.""" + + def __init__(self, link_key: bytes) -> None: + self._by_direction = { + REQUEST: AESGCM(_subkey(link_key, REQUEST)), + RESPONSE: AESGCM(_subkey(link_key, RESPONSE)), + } + + # ---------------------------------------------------------- pojedyncza ramka + + def _aad(self, direction: bytes, path: str, stamp: str, seq: int) -> bytes: + return b"|".join([MAGIC, direction, path.encode("utf-8"), + stamp.encode("ascii"), str(seq).encode("ascii")]) + + def seal(self, direction: bytes, path: str, stamp: str, seq: int, + plaintext: bytes) -> bytes: + nonce = os.urandom(NONCE_BYTES) + sealed = self._by_direction[direction].encrypt( + nonce, plaintext, self._aad(direction, path, stamp, seq)) + return MAGIC + nonce + sealed + + def open(self, direction: bytes, path: str, stamp: str, seq: int, + frame: bytes) -> bytes: + if not frame.startswith(MAGIC): + raise LinkError("ramka bez znacznika astrololo") + body = frame[len(MAGIC):] + if len(body) <= NONCE_BYTES: + raise LinkError("ramka za krótka") + nonce, sealed = body[:NONCE_BYTES], body[NONCE_BYTES:] + try: + return self._by_direction[direction].decrypt( + nonce, sealed, self._aad(direction, path, stamp, seq)) + except InvalidTag as exc: + # Jeden komunikat na wszystkie przypadki: zły klucz, podmieniony bajt, + # przeklejenie z innej ścieżki, przestawiona ramka. Rozróżnianie ich + # na zewnątrz podpowiadałoby atakującemu, w co trafił. + raise LinkError("nie udało się odszyfrować — zły klucz albo naruszone dane") from exc + + # ------------------------------------------------------------ strumień ramek + + def seal_stream(self, direction: bytes, path: str, stamp: str, + chunks: Iterable[bytes]) -> Iterator[bytes]: + for seq, chunk in enumerate(chunks): + yield frame_out(self.seal(direction, path, stamp, seq, chunk)) + + def open_stream(self, direction: bytes, path: str, stamp: str, + raw: bytes) -> Iterator[bytes]: + for seq, frame in enumerate(frames_in(raw)): + yield self.open(direction, path, stamp, seq, frame) + + def open_all(self, direction: bytes, path: str, stamp: str, raw: bytes) -> bytes: + return b"".join(self.open_stream(direction, path, stamp, raw)) + + +# ---------------------------------------------------------------- ramkowanie + +def frame_out(payload: bytes) -> bytes: + return struct.pack(">I", len(payload)) + payload + + +def frames_in(raw: bytes) -> Iterator[bytes]: + """Rozbiera bufor na ramki. Ucięty strumień to błąd, nie cicha strata danych.""" + offset = 0 + while offset < len(raw): + if offset + LENGTH_PREFIX > len(raw): + raise LinkError("urwana ramka (brak nagłówka długości)") + (size,) = struct.unpack(">I", raw[offset:offset + LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + offset += LENGTH_PREFIX + if offset + size > len(raw): + raise LinkError("urwana ramka (za mało danych)") + yield raw[offset:offset + size] + offset += size + + +def unframe_incremental(buffer: bytearray) -> Iterator[bytes]: + """Wyjmuje z bufora KOMPLETNE ramki i zjada je; resztę zostawia na później. + + Dla odbioru na żywo: kawałki przychodzą podzielone dowolnie i ramka potrafi + rozjechać się między dwa odczyty. + """ + while True: + if len(buffer) < LENGTH_PREFIX: + return + (size,) = struct.unpack(">I", buffer[:LENGTH_PREFIX]) + if size > MAX_FRAME: + raise LinkError("ramka ponad dopuszczalny rozmiar") + if len(buffer) < LENGTH_PREFIX + size: + return + frame = bytes(buffer[LENGTH_PREFIX:LENGTH_PREFIX + size]) + del buffer[:LENGTH_PREFIX + size] + yield frame + + +# ------------------------------------------------------------- świeżość ruchu + +def stamp_now() -> str: + return f"{time.time():.3f}" + + +def check_stamp(stamp: str) -> None: + """Odrzuca ramki spoza okna czasowego — inaczej podsłuchane żądanie dałoby się + odtworzyć w dowolnym momencie w przyszłości.""" + try: + sent = float(stamp) + except (TypeError, ValueError) as exc: + raise LinkError("brak albo błędny znacznik czasu") from exc + if abs(time.time() - sent) > MAX_SKEW_SECONDS: + raise LinkError("znacznik czasu poza dopuszczalnym oknem") + + +# =========================================================== strona serwerowa + +class LinkCryptoMiddleware: + """Rozszyfrowuje wchodzące żądania i zaszyfrowuje wychodzące odpowiedzi. + + Napisane jako czyste ASGI, nie ``@app.middleware("http")``, bo trzeba + podmienić CIAŁO żądania jeszcze zanim zobaczy je FastAPI, oraz przepuścić + odpowiedź strumieniową kawałek po kawałku, bez zbierania jej w pamięci. + """ + + def __init__(self, app, link: Link | None, layer: str) -> None: + self.app = app + self.link = link + self.layer = layer + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or self.link is None or scope["path"] in PUBLIC_PATHS: + return await self.app(scope, receive, send) + + path = scope["path"] + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} + + if headers.get(HEADER_ENC.lower()) != VERSION: + # Fail-closed. Klucz jest ustawiony, więc jawne żądanie oznacza albo + # pomyłkę w konfiguracji, albo kogoś obcego — w obu wypadkach nie + # chcemy po cichu wrócić do jawnego ruchu. + log.warning("warstwa %s: odrzucone żądanie bez szyfrowania łącza (%s)", + self.layer, path) + return await _refuse(send, "Łącze międzywarstwowe wymaga szyfrowania.") + + stamp = headers.get(HEADER_TS.lower(), "") + try: + check_stamp(stamp) + plaintext = self.link.open_all(REQUEST, path, stamp, await _read_body(receive)) + except LinkError as exc: + log.warning("warstwa %s: %s (%s)", self.layer, exc, path) + return await _refuse(send, "Nie udało się odczytać zaszyfrowanego żądania.") + + scope = dict(scope) + scope["headers"] = _rewritten_headers(scope["headers"], len(plaintext)) + await self.app(scope, _replay(plaintext, receive), self._sealing_send(send, path)) + + def _sealing_send(self, send, path: str): + state: dict = {"stamp": "", "seq": 0} + + async def sealing(message): + if message["type"] == "http.response.start": + state["stamp"] = stamp_now() + keep = [(k, v) for k, v in message.get("headers", []) + if k.lower() not in (b"content-length", b"content-type")] + message = dict(message) + message["headers"] = keep + [ + (b"content-type", CONTENT_TYPE.encode()), + (HEADER_ENC.lower().encode(), VERSION.encode()), + (HEADER_TS.lower().encode(), state["stamp"].encode()), + ] + return await send(message) + + if message["type"] == "http.response.body": + chunk = message.get("body", b"") + sealed = b"" + if chunk: + sealed = frame_out(self.link.seal( + RESPONSE, path, state["stamp"], state["seq"], chunk)) + state["seq"] += 1 + return await send({"type": "http.response.body", "body": sealed, + "more_body": message.get("more_body", False)}) + + return await send(message) + + return sealing + + +def _rewritten_headers(raw: Iterable[tuple[bytes, bytes]], length: int): + """Po odszyfrowaniu ciało ma inną długość i zwykły typ — inaczej FastAPI + próbowałby sparsować JSON o cudzej deklarowanej wielkości.""" + kept = [(k, v) for k, v in raw if k.lower() not in (b"content-length", b"content-type")] + kept.append((b"content-length", str(length).encode())) + if length: + kept.append((b"content-type", b"application/json")) + return kept + + +async def _read_body(receive) -> bytes: + body = bytearray() + while True: + message = await receive() + if message["type"] == "http.disconnect": + raise LinkError("rozłączenie w trakcie odbioru żądania") + body += message.get("body", b"") + if not message.get("more_body", False): + return bytes(body) + + +def _replay(body: bytes, original): + """Podstawia odszyfrowane ciało jako jedyną porcję wejścia dla aplikacji. + + Po oddaniu ciała oddajemy głos ORYGINALNEMU `receive`, zamiast od razu + zgłaszać rozłączenie. Odpowiedź strumieniowa nasłuchuje bowiem rozłączenia + równolegle do wysyłania i przerywa się, gdy je zobaczy — na skróconej wersji + okno postępu dostawało pustą odpowiedź, choć zwykłe żądania działały. + """ + delivered = False + + async def receive(): + nonlocal delivered + if delivered: + return await original() + delivered = True + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + +async def _refuse(send, detail: str) -> None: + """Odmowa leci JAWNIE — rozmówca właśnie pokazał, że nie umie odszyfrować, + więc zaszyfrowany komunikat o błędzie byłby dla niego nieczytelny.""" + payload = f'{{"detail":"{detail}"}}'.encode("utf-8") + await send({"type": "http.response.start", "status": 400, "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(payload)).encode()), + ]}) + await send({"type": "http.response.body", "body": payload}) + + +def install(app, env_name: str, layer: str): + """Podpina szyfrowanie łącza. Wołać PO `security.install`, żeby także odmowa + tokenowa (401) wracała zaszyfrowana — inaczej klient by jej nie odczytał.""" + link_key = key_from_env(env_name) + if link_key is None and encryption_required(): + # Celowo wywracamy start. Ta sama zasada co przy sekrecie logowania: + # wolimy widoczną awarię niż usługę, która wstała i po cichu nie chroni + # niczego. Pod w CrashLoop widać od razu, jawny ruch — nie. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale {env_name} nie ustawiony — " + f"warstwa {layer} nie wystartuje bez klucza łącza" + ) + if link_key is None: + log.warning( + "UWAGA: %s nie ustawiony — warstwa %s rozmawia z sąsiadem JAWNYM tekstem, " + "więc treść baz interpretacyjnych jest widoczna dla każdego, kto podsłucha " + "ruch wewnątrz sieci.", env_name, layer, + ) + return None + link = Link(link_key) + app.add_middleware(LinkCryptoMiddleware, link=link, layer=layer) + log.info("warstwa %s: łącze szyfrowane (AES-256-GCM, klucz z %s)", layer, env_name) + return link + + +# ============================================================ strona kliencka + +def call(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None) -> bytes: + """Żądanie do sąsiedniej warstwy; zwraca odszyfrowane ciało odpowiedzi. + + Ścieżkę do materiału uwierzytelnianego bierzemy Z URL-a, a nie z osobnego + argumentu — gdyby klient i serwer liczyły ją inaczej, każde żądanie kończyłoby + się niejasnym błędem odszyfrowania. + """ + import json as _json + + import httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Zatrzymujemy się PRZED wysłaniem. Gdyby polecieć jawnie i dopiero + # zebrać odmowę, pytanie byłoby już na kablu — a to właśnie ono niesie + # sygnifikatory, o które pytamy bazę. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — żądanie " + f"NIE zostało wysłane, żeby jego treść nie poszła jawnym tekstem" + ) + response = client.request(method, url, json=payload, headers=request_headers) + response.raise_for_status() + return response.content + + path = httpx.URL(url).path + stamp = stamp_now() + plaintext = b"" if payload is None else _json.dumps(payload).encode("utf-8") + body = frame_out(link.seal(REQUEST, path, stamp, 0, plaintext)) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, + "Content-Type": CONTENT_TYPE}) + + response = client.request(method, url, content=body, headers=request_headers) + if response.status_code >= 400 and response.headers.get(HEADER_ENC) != VERSION: + log.error("łącze %s odmówiło: %s", path, response.text[:200]) + response.raise_for_status() + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("odpowiedź przyszła nieszyfrowana, choć klucz łącza jest ustawiony") + reply_stamp = response.headers.get(HEADER_TS, "") + check_stamp(reply_stamp) + return link.open_all(RESPONSE, path, reply_stamp, response.content) + + +def call_json(client, method: str, url: str, *, payload=None, + headers: dict[str, str] | None = None, link: Link | None): + import json as _json + + return _json.loads(call(client, method, url, payload=payload, + headers=headers, link=link)) + + +def open_response_stream(response, link: Link | None) -> Iterator[bytes]: + """Odbiór odpowiedzi płynącej kawałkami (okno postępu). + + Ramka potrafi rozjechać się między dwa odczyty z gniazda, więc składamy ją + w buforze zamiast zakładać, że każdy kawałek to komplet. + """ + if link is None: + yield from response.iter_bytes() + return + if response.headers.get(HEADER_ENC) != VERSION: + raise LinkError("strumień przyszedł nieszyfrowany, choć klucz łącza jest ustawiony") + stamp = response.headers.get(HEADER_TS, "") + check_stamp(stamp) + path = response.request.url.path + buffer = bytearray() + seq = 0 + for chunk in response.iter_bytes(): + buffer += chunk + for frame in unframe_incremental(buffer): + yield link.open(RESPONSE, path, stamp, seq, frame) + seq += 1 + if buffer: + raise LinkError("strumień urwał się w połowie ramki") + + +def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None, + link: Link | None) -> Iterator[str]: + """Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo. + + Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc + czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest + pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE + pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je + dopiero na znakach nowej linii. + + Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów + działał bez zmian. + """ + import json as _json + + import httpx as _httpx + + request_headers = dict(headers or {}) + if link is None: + if encryption_required(): + # Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy + # szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale + # ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter. + raise LinkError( + f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień " + f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem" + ) + with client.stream("POST", url, json=payload, headers=request_headers) as response: + response.raise_for_status() + for text_line in response.iter_lines(): + if text_line: + yield text_line + return + + path = _httpx.URL(url).path + stamp = stamp_now() + body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8"))) + request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE}) + with client.stream("POST", url, content=body, headers=request_headers) as response: + response.raise_for_status() + buffer = bytearray() + for plain in open_response_stream(response, link): + buffer += plain + while True: + nl = buffer.find(b"\n") + if nl < 0: + break + text_line = bytes(buffer[:nl]) + del buffer[:nl + 1] + if text_line: + yield text_line.decode("utf-8") + if buffer: + yield bytes(buffer).decode("utf-8") diff --git a/services/astroklient/app/main.py b/services/astroklient/app/main.py new file mode 100644 index 0000000..fb15aad --- /dev/null +++ b/services/astroklient/app/main.py @@ -0,0 +1,148 @@ +"""astroklient — wersja DEMONSTRACYJNA (PRE-28). + +DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ: + 1. dodanie pliku bazy (wgranie + włączenie do użytku), + 2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych. + +DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Mechanizm uprawnień z PRE-27 +umiałby to ukryć w pełnej aplikacji, ale ukrycie a nieobecność to dwie różne rzeczy. +Tutaj pozostałych funkcji NIE MA W OBRAZIE: nie ma tras, nie ma szablonów, nie ma +nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając +przy okazji kodu reszty programu. + +UWAGA, ŚWIADOMA DECYZJA WŁAŚCICIELA: astroklient pracuje na TEJ SAMEJ warstwie +danych co produkcja. Kto ma do niego dostęp, czyta oryginalne bazy interpretacyjne, +a pliki przez niego wgrane trafiają do produkcyjnego zbioru. Dlatego konto jest +osobne (DEMO_USER/DEMO_PASSWORD) — żeby dało się odciąć demo jedną zmienną, bez +ruszania kont głównej aplikacji. +""" +from __future__ import annotations + +import base64 +from datetime import datetime, timedelta, timezone + +import httpx +from fastapi import FastAPI, File, Form, Request, UploadFile +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from app import link_crypto, security +from app.clients.logic_client import LogicClient + +# Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs +# wypisałoby komplet tras, a demo ma nie zdradzać nawet własnej powierzchni. +app = FastAPI(title="astroklient · demo", docs_url=None, redoc_url=None, openapi_url=None) +security.install(app) + +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + +logic = LogicClient() + +# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana +# tutaj wprost, bo astroklient nie importuje tamtej warstwy; literówka objawiłaby +# się dopiero na żywo, więc pilnuje jej test. +ACTIVE = "active" + + +def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: + """Data + godzina lokalna + offset → chwila w UTC. + + Offset podaje się wprost, bez wyszukiwania strefy z współrzędnych — demo ma + dwie funkcje, a strefy to trzecia.""" + try: + local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M") + except ValueError as e: + raise ValueError("Podaj datę jako RRRR-MM-DD i godzinę jako GG:MM.") from e + moment = local.replace(tzinfo=timezone(timedelta(hours=tz_offset))) + return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M") + + +def _blad(e: Exception) -> str: + if isinstance(e, httpx.HTTPStatusError): + try: + return str(e.response.json().get("detail") or e) + except Exception: # noqa: BLE001 + return str(e) + if isinstance(e, link_crypto.LinkError): + return f"Łącze do warstwy logicznej nie działa: {e}" + return f"Warstwa logiczna niedostępna: {e}" + + +def _ile_rekordow(report: dict) -> int: + return sum(len(s.get("samples") or []) for p in (report.get("positions") or []) + for s in (p.get("significators") or [])) + + +@app.get("/", response_class=HTMLResponse) +def formularz(request: Request, error: str = "", done: str = ""): + return templates.TemplateResponse(request, "index.html", + {"result": None, "form": {}, "error": error, "done": done}) + + +@app.post("/", response_class=HTMLResponse) +def interpretacja(request: Request, date: str = Form(...), time: str = Form(...), + tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0)): + """Interpretacja urodzeniowa dla podanych danych. Jedyne pytanie o treść.""" + form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon} + ctx: dict = {"form": form, "result": None, "error": None, "done": "", "moment": None} + try: + iso_utc, label = _build_utc(date, time, tz_offset) + ctx["moment"] = label + ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon) + # Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17). + security.audit_records(request, _ile_rekordow(ctx["result"])) + except ValueError as e: + ctx["error"] = str(e) + except (httpx.HTTPError, link_crypto.LinkError) as e: + ctx["error"] = _blad(e) + return templates.TemplateResponse(request, "index.html", ctx) + + +@app.post("/plik") +async def dodaj_plik(request: Request, upload: UploadFile = File(...)): + """Dodanie pliku bazy: wgranie ORAZ włączenie do użytku. + + W pełnej aplikacji to dwie osobne decyzje (DAN-27), bo tam ktoś nad tym panuje. + Tutaj „dodać do bazy" ma znaczyć, że plik od razu bierze udział w wyszukiwaniu — + inaczej po wgraniu nic by się nie zmieniło i demo wyglądałoby na zepsute. + + Walidacja zostaje: plik o złym układzie NIE wchodzi do użytku, ale też NIE JEST + tracony — leży dalej na udziale i decyzję o nim podejmuje administrator + w pełnej aplikacji.""" + raw = await upload.read() + if not raw: + return RedirectResponse("/?error=Pusty+plik.", status_code=303) + + who = getattr(request.state, "user", "") or "demo" + try: + out = logic.files_upload(upload.filename or "baza.xlsx", + base64.b64encode(raw).decode("ascii"), by=who) + except (httpx.HTTPError, link_crypto.LinkError) as e: + return _wroc(error=_blad(e)) + + nazwa = out.get("name") or "plik" + if not out.get("accepted"): + # Bez słowa o regułach — te zna wyłącznie administrator (DAN-27). + return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się " + f"do użycia w tej postaci.") + try: + logic.files_status(out["path"], ACTIVE, by=who) + except (httpx.HTTPError, link_crypto.LinkError) as e: + return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}") + return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.") + + +def _wroc(error: str = "", done: str = "") -> RedirectResponse: + """Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może + wgrać pliku drugi raz.""" + from urllib.parse import urlencode + + q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v}) + return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303) + + +@app.get("/health") +def health() -> dict: + return {"status": "ok", "layer": "astroklient"} diff --git a/services/astroklient/app/security.py b/services/astroklient/app/security.py new file mode 100644 index 0000000..6058179 --- /dev/null +++ b/services/astroklient/app/security.py @@ -0,0 +1,176 @@ +"""Logowanie do astroklienta (PRE-28). + +OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej +warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy +interpretacyjne. Własny login i hasło pozwalają odciąć demo jedną zmienną +środowiskową, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek. + +Mechanizm jest CELOWO prosty: jedno konto z konfiguracji, bez ekranu kont +i bez uprawnień. Astroklient ma dwie funkcje i obie są dla tego samego człowieka — +model uprawnień z PRE-27 nie miałby tu czego dzielić. + +Hasło może być jawne albo jako hash scrypt (`scrypt$sól$hash`), tym samym formatem +co APP_USERS w głównej aplikacji — dzięki temu skrypt make_user.py działa na oba. +""" +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import logging +import os +import time +from collections import deque + +from fastapi import Request +from fastapi.responses import HTMLResponse, JSONResponse + +log = logging.getLogger("astroklient.security") +audit_log = logging.getLogger("astroklient.audit") + +_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 +_HASH_PREFIX = "scrypt$" + +PUBLIC_PATHS = frozenset({"/health"}) +PUBLIC_PREFIXES = ("/static/",) + +_hits: dict[str, deque[float]] = {} +MAX_TRACKED_CLIENTS = 4096 + + +def demo_user() -> str: + return os.getenv("DEMO_USER", "demo") + + +def demo_password() -> str: + return os.getenv("DEMO_PASSWORD", "") + + +def rate_limit_per_min() -> int: + return int(os.getenv("RATE_LIMIT_PER_MIN", "60")) + + +def trust_proxy() -> bool: + return os.getenv("TRUST_PROXY", "").strip().lower() in {"1", "true", "yes", "on"} + + +def auth_enabled() -> bool: + return bool(demo_password()) + + +def _secret_matches(spec: str, password: str) -> bool: + if spec.startswith(_HASH_PREFIX): + try: + _, salt_hex, hash_hex = spec.split("$", 2) + salt, expected = bytes.fromhex(salt_hex), bytes.fromhex(hash_hex) + except ValueError: + return False + actual = hashlib.scrypt(password.encode("utf-8"), salt=salt, + n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_LEN) + return hmac.compare_digest(actual, expected) + # BAJTY, nie tekst: compare_digest na stringach rzuca TypeError przy znakach + # spoza ASCII, więc hasło z polskimi literami dawałoby 500 zamiast odmowy. + return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8")) + + +def authenticate(header: str | None) -> str | None: + if not header or not header.lower().startswith("basic "): + return None + try: + raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") + user, _, password = raw.partition(":") + except (binascii.Error, UnicodeDecodeError, IndexError): + return None + if user != demo_user(): + return None + return user if _secret_matches(demo_password(), password) else None + + +def client_ip(request: Request) -> str: + """Adres do rozliczania limitu. Nagłówkom wierzymy WYŁĄCZNIE za proxy — + inaczej wystarczyłoby podstawić własny X-Forwarded-For, żeby ominąć limit.""" + peer = request.client.host if request.client else "?" + if not trust_proxy(): + return peer + forwarded = request.headers.get("x-forwarded-for", "") + if forwarded: + last = forwarded.rsplit(",", 1)[-1].strip() + if last: + return last + return request.headers.get("x-real-ip", "").strip() or peer + + +def _rate_limited(client: str) -> bool: + cap = rate_limit_per_min() + if cap <= 0: + return False + now = time.monotonic() + window = _hits.get(client) + if window is None: + if len(_hits) >= MAX_TRACKED_CLIENTS: + _hits.clear() + window = _hits[client] = deque() + while window and now - window[0] > 60.0: + window.popleft() + if len(window) >= cap: + return True + window.append(now) + return False + + +def _setup_audit_logging() -> None: + """Własny handler na stdout. Domyślna konfiguracja uvicorna nie obsługuje + naszych loggerów, więc wpisy INFO ginęły — dziennik istniał w kodzie, ale był + pusty. Niewidoczny dziennik jest gorszy niż jego brak (błąd zastany w PRE-17).""" + audit_log.setLevel(os.getenv("AUDIT_LEVEL", "INFO").upper()) + if not audit_log.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + audit_log.addHandler(handler) + audit_log.propagate = False + + +def audit_records(request: Request, count: int) -> None: + try: + request.state.audit_records = int(count) + except (TypeError, ValueError): + pass + + +def install(app) -> None: + _setup_audit_logging() + if not auth_enabled(): + log.warning( + "UWAGA: DEMO_PASSWORD nie ustawione — astroklient stoi OTWARTY, a pracuje " + "na tej samej warstwie danych co produkcja, więc wystawia treść " + "oryginalnych baz interpretacyjnych każdemu, kto zna adres." + ) + + @app.middleware("http") + async def _guard(request: Request, call_next): + if request.url.path in PUBLIC_PATHS or request.url.path.startswith(PUBLIC_PREFIXES): + return await call_next(request) + + started = time.monotonic() + if _rate_limited(client_ip(request)): + return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."}, + status_code=429, headers={"Retry-After": "60"}) + + user = "" + if auth_enabled(): + user = authenticate(request.headers.get("authorization")) or "" + if not user: + return HTMLResponse( + "

401 — wymagane logowanie

", status_code=401, + headers={"WWW-Authenticate": 'Basic realm="astroklient"'}) + request.state.user = user + + response = await call_next(request) + records = getattr(request.state, "audit_records", None) + audit_log.info( + "AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f", + user or "-", client_ip(request), request.method, request.url.path, + response.status_code, "-" if records is None else records, + (time.monotonic() - started) * 1000) + return response diff --git a/services/astroklient/app/static/styles.css b/services/astroklient/app/static/styles.css new file mode 100644 index 0000000..08e8986 --- /dev/null +++ b/services/astroklient/app/static/styles.css @@ -0,0 +1,51 @@ +/* astroklient — arkusz CELOWO minimalny. Nie kopiujemy stylów pełnej aplikacji: + demo ma dwa formularze i tabelę wyników, a każda przeniesiona klasa niosłaby + nazwy funkcji, których tu nie ma. */ +:root { + --bg: #11121b; --panel: #191b26; --line: #2b2e3d; + --fg: #e8e9f0; --muted: #9aa0b4; --accent: #8ab4ff; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--bg); color: var(--fg); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; } +.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line); } +h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; } +h2 { margin: 0 0 .3rem; font-size: 1.05rem; } +h3 { margin: 0 0 .5rem; font-size: 1rem; } + +.card { + margin-top: 1.25rem; padding: 1rem 1.25rem; + background: var(--panel); border: 1px solid var(--line); border-radius: 12px; +} +.row { display: flex; gap: .75rem; flex-wrap: wrap; align-items: flex-end; margin-top: .6rem; } +label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 10rem; } +input { + width: 100%; padding: .45rem .6rem; background: #0e0f18; color: var(--fg); + border: 1px solid var(--line); border-radius: 8px; font: inherit; +} +button { + padding: .5rem 1.25rem; background: var(--accent); color: #10121a; + border: none; border-radius: 8px; font: inherit; font-weight: 600; cursor: pointer; +} + +table { width: 100%; border-collapse: collapse; margin-top: .3rem; } +td { padding: .35rem .5rem; border-bottom: 1px solid var(--line); vertical-align: top; font-size: .92rem; } +td.key { color: var(--accent); white-space: nowrap; padding-right: 1rem; } +tr:last-child td { border-bottom: none; } + +.sig { margin-top: .75rem; } +.sig-head { color: var(--muted); font-size: .85rem; } +.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; } +.muted { color: var(--muted); } +.small { font-size: .85rem; } +.foot { margin-top: 2.5rem; color: var(--muted); font-size: .8rem; } + +.alert, .ok { + margin: 1rem 0; padding: .6rem .9rem; border-radius: 8px; font-size: .92rem; + border: 1px solid var(--line); border-left-width: 4px; +} +.alert { border-left-color: #b8860b; background: rgba(184, 134, 11, .1); } +.ok { border-left-color: var(--accent); background: rgba(138, 180, 255, .08); } diff --git a/services/astroklient/app/templates/base.html b/services/astroklient/app/templates/base.html new file mode 100644 index 0000000..52a9d13 --- /dev/null +++ b/services/astroklient/app/templates/base.html @@ -0,0 +1,20 @@ + + + + + + astroklient · {% block title %}{% endblock %} + + + +
+
+

astroklient

+ {# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie, + a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #} +
+ {% block content %}{% endblock %} +
wersja demonstracyjna
+
+ + diff --git a/services/astroklient/app/templates/index.html b/services/astroklient/app/templates/index.html new file mode 100644 index 0000000..014530c --- /dev/null +++ b/services/astroklient/app/templates/index.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Interpretacja{% endblock %} + +{% block content %} +{% if error %}

{{ error }}

{% endif %} +{% if done %}

{{ done }}

{% endif %} + +
+

Dodaj bazę

+

+ Wgrany plik zaczyna być używany od razu przy wyszukiwaniu interpretacji. +

+
+
+ + +
+
+
+ +
+

Zapytaj o interpretację urodzeniową

+
+
+ + + +
+
+ + + +
+
+
+ +{% if result %} +

+ Moment: {{ moment }} + {% if result.engine %}· silnik: {{ result.engine }}{% endif %} + {% if result.objects %}· obiektów: {{ result.objects | length }}{% endif %} +

+ + {# Kształt odpowiedzi: objects[] → facets[] → samples[]. Pytamy bez grupowania, + więc `groups` tu nie wystąpi i nie ma czego obsługiwać. #} + {% set znaleziono = namespace(ile=0) %} + {% for o in result.objects or [] %} + {% set trafienia = o.facets | selectattr('samples') | list %} + {% if trafienia %} + {% set znaleziono.ile = znaleziono.ile + trafienia | length %} +
+

{{ o.object }} + w {{ o.sign }}{% if o.house %}, {{ o.house }}. dom{% endif %} + {%- if o.direction == 'Rx' %} · Rx{% endif %} +

+ {% for f in trafienia %} +
+
{{ f.label }} — {{ f.count }} dopasowań
+ + + {% for s in f.samples %} + + + + + {% endfor %} + +
{{ s.expanded }}{{ s.effect }}
+
+ {% endfor %} +
+ {% endif %} + {% endfor %} + + {% if znaleziono.ile == 0 %} +

+ Nie znaleziono interpretacji dla tych danych. Sprawdź, czy dodano bazę + zawierającą pasujące opisy. +

+ {% endif %} +{% endif %} +{% endblock %} diff --git a/services/astroklient/requirements-dev.txt b/services/astroklient/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/services/astroklient/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/services/astroklient/requirements.txt b/services/astroklient/requirements.txt new file mode 100644 index 0000000..1297467 --- /dev/null +++ b/services/astroklient/requirements.txt @@ -0,0 +1,11 @@ +# CELOWO KRÓTSZA niż w warstwie prezentacji. Astroklient ma dwie funkcje, więc nie +# potrzebuje ani Excela (brak eksportu), ani stref czasowych z lokalizacji (offset +# podaje się wprost), ani niczego pod kosmogram — a każda zbędna zależność w obrazie +# demo to kolejna rzecz do pilnowania i kolejna droga do środka. +fastapi>=0.115 +uvicorn[standard]>=0.34 +httpx>=0.28 +jinja2>=3.1 +python-multipart>=0.0.20 +# Szyfrowanie łącza do warstwy logicznej (PRE-16): AES-256-GCM + HKDF +cryptography>=44.0 diff --git a/services/astroklient/tests/test_astroklient.py b/services/astroklient/tests/test_astroklient.py new file mode 100644 index 0000000..d4d41c0 --- /dev/null +++ b/services/astroklient/tests/test_astroklient.py @@ -0,0 +1,203 @@ +"""astroklient — wersja demonstracyjna (PRE-28). + +DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK + +1. Demo ma DWIE funkcje i ani jednej więcej. Sprawdzamy to na trasach aplikacji, + nie w szablonie: trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem, + bo ta usługa rozmawia z warstwą danych produkcji. + +2. „Dodać plik do bazy" znaczy, że plik ZACZYNA BYĆ UŻYWANY. W pełnej aplikacji + wgranie i włączenie to dwie decyzje (DAN-27); tutaj muszą być jedną, inaczej + po wgraniu nic się nie zmienia i demo wygląda na zepsute. +""" +import base64 + +import pytest + + +@pytest.fixture() +def env(monkeypatch): + monkeypatch.setenv("DEMO_USER", "demo") + monkeypatch.setenv("DEMO_PASSWORD", "tajne-demo") + monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") + + +def _auth(user="demo", password="tajne-demo"): + return {"Authorization": "Basic " + base64.b64encode( + f"{user}:{password}".encode()).decode()} + + +REPORT = { + "engine": "test", + "objects": [{ + "object": "Sun", "sign": "Taurus", "house": 11, "direction": "D", + "planet_token": "Su", "planet_total": 3, + "facets": [{"label": "w znaku Taurus", "token": "Su Tau", "count": 1, "score": 5.0, + "samples": [{"significator": "Su Tau", "expanded": "Sun in Taurus", + "effect": "wytrwały, praktyczny"}]}], + }], +} + + +def _client(monkeypatch, calls=None): + from starlette.testclient import TestClient + + from app.main import app, logic + + calls = calls if calls is not None else [] + monkeypatch.setattr(logic, "report", + lambda **kw: (calls.append(("report", kw)), REPORT)[1]) + monkeypatch.setattr(logic, "files_upload", + lambda *a, **kw: (calls.append(("upload", a, kw)), + {"path": "nowa.xlsx", "name": "nowa.xlsx", + "accepted": True})[1]) + monkeypatch.setattr(logic, "files_status", + lambda *a, **kw: (calls.append(("status", a, kw)), {})[1]) + return TestClient(app), calls + + +FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2", + "lat": "50.06", "lon": "19.94"} + + +# ── powierzchnia: dokładnie dwie funkcje ──────────────────────────────── + +def test_the_service_exposes_nothing_beyond_its_two_functions(): + """Trasa, o której nikt nie pamiętał, jest tu realnym ryzykiem — ta usługa + rozmawia z warstwą danych PRODUKCJI.""" + from app.main import app + + trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set()) + if not r.path.startswith("/static") and m in {"GET", "POST"}} + assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health")} + + +def test_the_client_can_only_ask_for_what_the_demo_needs(): + """Klient warstwy logicznej jest okrojony CELOWO: metody, której nie ma, nikt + przypadkiem nie zawoła i nie podpowie ona, że po drugiej stronie jest więcej.""" + from app.clients.logic_client import LogicClient + + publiczne = {n for n in dir(LogicClient) if not n.startswith("_")} + assert publiczne == {"report", "files_upload", "files_status", "health"} + + +def test_api_docs_are_off(): + """/docs wypisałoby komplet tras — demo ma nie zdradzać własnej powierzchni.""" + from app.main import app + + assert app.docs_url is None and app.openapi_url is None and app.redoc_url is None + + +# ── logowanie ──────────────────────────────────────────────────────────── + +def test_without_credentials_nothing_is_served(env, monkeypatch): + c, _ = _client(monkeypatch) + for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")): + assert getattr(c, method)(path).status_code == 401, path + assert c.get("/health").status_code == 200, "sonda musi działać bez hasła" + + +def test_demo_account_is_separate_from_the_main_application(env, monkeypatch): + """Konto astroklienta MUSI być własne: demo pracuje na tej samej warstwie + danych co produkcja, więc odcięcie go nie może wymagać zmiany haseł + w głównej aplikacji.""" + monkeypatch.setenv("APP_USER", "astrololo") + monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji") + c, _ = _client(monkeypatch) + assert c.get("/", headers=_auth("astrololo", "haslo-glownej-aplikacji")).status_code == 401 + assert c.get("/", headers=_auth()).status_code == 200 + + +def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch): + monkeypatch.setenv("DEMO_PASSWORD", "zażółć-gęślą") + c, _ = _client(monkeypatch) + assert c.get("/", headers=_auth("demo", "złe-hasło")).status_code == 401 + assert c.get("/", headers=_auth("demo", "zażółć-gęślą")).status_code == 200 + + +# ── funkcja 1: dodanie pliku ───────────────────────────────────────────── + +def test_adding_a_file_uploads_AND_switches_it_on(env, monkeypatch): + """Sedno wymagania: jedna czynność, nie dwie.""" + c, calls = _client(monkeypatch) + r = c.post("/plik", headers=_auth(), follow_redirects=False, + files={"upload": ("baza.xlsx", b"zawartosc", "application/vnd.ms-excel")}) + assert r.status_code == 303 + rodzaje = [k[0] for k in calls] + assert rodzaje == ["upload", "status"], f"oczekiwano wgrania i włączenia, było: {rodzaje}" + assert calls[1][1][1] == "active", "plik nie został włączony do użytku" + + +def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, monkeypatch): + """Walidacja zostaje, ale jej REGUŁY zna wyłącznie administrator (DAN-27) — + demo nie ma prawa ich zdradzić, nawet mimochodem.""" + from urllib.parse import unquote_plus + + from app.main import logic + + c, calls = _client(monkeypatch) + monkeypatch.setattr(logic, "files_upload", lambda *a, **kw: ( + calls.append(("upload", a, kw)), + {"path": "zla.xlsx", "name": "zla.xlsx", "accepted": False})[1]) + + r = c.post("/plik", headers=_auth(), follow_redirects=False, + files={"upload": ("zla.xlsx", b"x", "text/plain")}) + assert r.status_code == 303 + assert [k[0] for k in calls] == ["upload"], "odrzucony plik został włączony do użytku" + komunikat = unquote_plus(r.headers["location"]) + for slowo in ("walidacj", "reguł", "kolumn", "rozszerzeni", "rozmiar"): + assert slowo not in komunikat, f"komunikat zdradza mechanizm: „{slowo}”" + + +def test_an_empty_file_is_refused_before_anything_is_sent(env, monkeypatch): + c, calls = _client(monkeypatch) + r = c.post("/plik", headers=_auth(), follow_redirects=False, + files={"upload": ("pusty.xlsx", b"", "application/vnd.ms-excel")}) + assert r.status_code == 303 and calls == [] + + +# ── funkcja 2: interpretacja urodzeniowa ──────────────────────────────── + +def test_asking_for_an_interpretation_returns_the_matches(env, monkeypatch): + c, calls = _client(monkeypatch) + r = c.post("/", headers=_auth(), data=FORM) + assert r.status_code == 200 + assert "Sun in Taurus" in r.text and "wytrwały, praktyczny" in r.text + assert [k[0] for k in calls] == ["report"] + + +def test_local_time_is_converted_to_utc_before_asking(env, monkeypatch): + """11:20 przy offsecie +2 to 09:20 UTC. Pomyłka tutaj przesuwa CAŁY horoskop, + a wynik nadal wygląda wiarygodnie — dlatego sprawdzamy wprost.""" + c, calls = _client(monkeypatch) + c.post("/", headers=_auth(), data=FORM) + assert calls[0][1]["when_utc_iso"].startswith("1984-04-30T09:20") + + +def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch): + c, calls = _client(monkeypatch) + r = c.post("/", headers=_auth(), data={**FORM, "date": "30 kwietnia"}) + assert r.status_code == 200 and "RRRR-MM-DD" in r.text + assert calls == [], "poszło zapytanie mimo błędnych danych" + + +def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch): + import httpx + + from app.main import logic + + c, _ = _client(monkeypatch) + monkeypatch.setattr(logic, "report", lambda **kw: (_ for _ in ()).throw( + httpx.ConnectError("brak połączenia"))) + r = c.post("/", headers=_auth(), data=FORM) + assert r.status_code == 200 and "Warstwa logiczna niedostępna" in r.text + + +def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch): + """Demo ma wyglądać na KOMPLETNE w swojej postaci — żadnych śladów funkcji, + których nie ma (ta sama zasada, co przy uprawnieniach w PRE-27).""" + c, _ = _client(monkeypatch) + html = c.get("/", headers=_auth()).text + for slowo in ("Kosmogram", "Synastria", "Kalendarz", "Skompiluj", "Ustawienia", + "Konta", "PDF", "Excel", "prompt", "model"): + assert slowo not in html, f"strona wspomina o „{slowo}”, czego demo nie umie" -- 2.52.0 From 2d7775f9b3430dfa0284b421cfac3326827b0597 Mon Sep 17 00:00:00 2001 From: migatu Date: Mon, 17 Aug 2026 19:00:39 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(astroklient):=20pule=20plik=C3=B3w=20p?= =?UTF-8?q?er=20konto=20i=20izolacja=20od=20produkcji=20(PRE-29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo ma być rozdawane szeroko i różnym osobom, więc pierwsza wersja — jedno konto na produkcyjnej warstwie danych — nie nadawała się do użycia: każdy dostawałby dostęp do oryginalnych baz, a wgrania jednego klienta widzieliby wszyscy. IZOLACJA OD PRODUKCJI. Warstwa danych i logiczna demo są osobne (manifesty w repo deploy). Osobna musi być TEŻ LOGICZNA, bo zna ona jeden adres warstwy danych — demo korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy. PULE PER KONTO w warstwie danych. Zapytanie i lista plików niosą nazwę puli; puste = cały udział, czyli produkcja działa dokładnie jak dotąd i o pulach nic nie wie. Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne w nazwie katalogu — „../..” albo ukośnik wyprowadziłyby zapytanie wprost do cudzych baz, więc sito ZAMIENIA podejrzane znaki zamiast ufać, że nikt ich nie poda. PULA MUSI BYĆ W KLUCZU CACHE ZAPYTAŃ. Bez tego wynik policzony dla jednego konta trafiłby z cache do drugiego — cicha wymiana treści baz między klientami, niewidoczna w logach i nie do wykrycia z zewnątrz. Osobny test tego pilnuje. PULA WYNIKA Z LOGINU, nigdy z żądania. Klient warstwy logicznej jest budowany per żądanie i związany z pulą zalogowanej osoby; gdyby nazwa przychodziła z formularza, wystarczyłoby podstawić cudzy login. Test wysyła `tenant`, `user` i `login` w polach formularza i sprawdza, że nie mają na nią wpływu. Pulę wstrzykujemy w INSTANCJĘ klienta, nie w sygnatury metod. Argumentem trzeba by ją przeprowadzić przez protokół DataSource i build_report — kod, który o kontach nie ma prawa nic wiedzieć — a każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam. Konta demo to lista `login:sekret` (DEMO_USERS), bo jedno wspólne konto oznaczałoby wspólną pulę. Format i skrypt haseł te same, co w głównej aplikacji. Pula klienta to JEDEN KATALOG, więc przejście na pełną wersję nie oznacza utraty wgrań — procedurę importu opisuje runbook w repo deploy. Co-Authored-By: Claude Opus 5 --- docs/astrololo_wymagania.xlsx | Bin 40721 -> 41130 bytes services/astroklient/README.md | 39 +++++--- .../astroklient/app/clients/logic_client.py | 17 +++- services/astroklient/app/main.py | 15 ++- services/astroklient/app/security.py | 41 ++++++-- .../astroklient/tests/test_astroklient.py | 91 +++++++++++++----- services/data/app/files.py | 16 +++ services/data/app/main.py | 18 +++- services/data/app/models.py | 5 + services/data/app/providers/excel_provider.py | 12 ++- .../data/tests/test_rejestr_integracja.py | 77 +++++++++++++++ services/logic/app/clients/data_client.py | 25 +++-- services/logic/app/main.py | 24 +++-- 13 files changed, 301 insertions(+), 79 deletions(-) diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 3cfaf92e8919d43191e386a79c80032190ca7759..e6f860bd706d39da6deacc6771c419cbb0df88b2 100644 GIT binary patch delta 10163 zcmZ9Sb8sF&w})ffNn_i#8^5s{v$2ggHk!P#ZQHilxUrMQYINJV^WAUe?jQTiIWxOw z_UxIR-JM@T4@5;j1d_5mBor1H7#JKFSXu&7KO9&Zx_)RBOkct>DbRY56)DWH_#kIY zNp?M*Gc`Gf8;`NBKC^zAn^7sII^q8E*vxLdo(Q2w`?D0l&YXl@;qtu4A020XAKlMc zjXPmqK#_nAW+f1m<&mY=_aMkN2-|EI;`zu+T8u4(-0boVw`>*L(vsXy7G>YKrqE zEwwxQCI}%bg7XaT3BHyFc%xIp@#}0#rXJY6v{k#Sq&INJ>kyZE4Ra(^OFdq*b@)FR zhL$Wo^z;n2#l2MQG4yNm@{C01G49OvCU5^YArN3`jSt`=F#pI6cLn{AG$@}$|47R? z%7pSSS8{tH`4^O6V1jO-cO5z)?>H;5lgycgS_|s;gp}_plwzux8wf#pC7rw%r}ERM z(HmQUI3tH%Sa2mMiq{*#Tu6~@NdVh%{B!ETx5|o6%d(z11fyt|Q;jW+bJg?P>WoC~##06AOxK#*1c^e=n=hH=nO<=% zE@|I}tWex}D7~B@QKYuMpOb@w&(m-)6ZNkA$3eNEju%s+O}0q9&=VZbsGcL;v_nNR zz>uWl9({{hwSd?IPMoExUxj=<4R8T;XP3 zqe>n-eN;JcVA!*77*;D&<)V!9?$eycsWzQb=;ztFHMilNw<%u zH;tYfRU@g;;tQhr$~8Lb$qDH$P|BY9<08kg0V>`!(rJ4xddd*^;@AAh03{$mge8z- za?Cwk$Q%Wmve$1HMe~ga3zcbja06>&g9t00akvnbme@mCumvk#B+qkI>}E(+uwC({ z&E_aqHwu89ima7Z`pTYc=udpEH{{EAV_)jOl#z+19A0CT(C@=YCj3aQT6V`dvO+Aa z&?~t4>6^HRa&HgZ(=Momi7*fku;hODzGM>*mF*4H?&EtA?C{tO-pfti8vlD^-Xh$Hn)N6_L!mI1M*ykAA3#gJLzPX z1%fveO#Yv{wXbGh^_lKDud^@`$%dQb-1g3g9eR&?Kd&9^f)+&Xt@Lo>QlB~BhHac^ znNemQe;JjPwy9_{0^v#7#s64Q2g@Mf(sQL-wccM_fa~Tj>W##u3SlD>m1W3GOXd0D zdltJM<=_F^>kZoY<0a`-`7{Ds>({6wAmyJCGB&7#At*p+Um&4Ol?6HV!I05gemYUq-@gDJwRelTFa6r<;m{f6vv~EAGR? zxuL!oID#Ngm}_&dFXA_Pt%ENlpYQX(h7V2-o?k?oCLa2N+{YilbaM4jf4EOR^eyDF z?`r}N_{U0ij>uE^>{x8cqoS}q6N(%M5WroMPY5;p8|4{>zrWyAF^u;lJv89t|FEyZ z!2ZRJX2bqbmvR&CQTNmC?#()&4lqLUSn-jN<0br)?DJCVZ}$_#T%_p_*Xz_)rs z^bpPrW|%H3WW0TYn+z>_4uKmky*65-Q{8!wjS_A4xk#a>@gKOt%*6%obudINyj zsDi9Nt&x*LU}h!rRDDGj{VJ}@G(*37us)$~s2l%X8#XbrcC>ZyV9Sx7L?W6N%mUq! zei+cG>yCWjcBGEsxK5&;SIr9NtEp3x6vNfu%KdDTJLnUft#!j`vt#EIU8Mm*7HzS6 z9VdsfxpP(3)Vesc*72)(26vLP*#Hm4@Jy4>Ne88=FnE^|3aV}QOwWB=eGU7nWOj?~ zbAJfcgkIbbU0fD&lYJreMXqKOkjYFF`ppC4+{eRI%5zB*;8VJ{3<2K-iF2B@&r{pI z4-$KMQtSRwHR(nJ%s(FafKUB8s~$_exo9=hw?U0>-fY*56ItHIebdiL$^+gDU0c11 zaUeX(W7Ab}@jVx(F|1mwVvziD&$Em$IWS=hbfrEcK~`*Nr{(4z=If;sWe@H=^7x#{J=k=Ndub z@GW@PtR^tBP0jwhaf{{P3qbqqv*Ra*ZeJ2l_xQ_WFT``5i9fCKjbO2qgGcZlH4bKDtw!%E)TJ0IYm{ ztw6RQ(m`l;W@@cpPZQa1$DgkxpwOr*76@ADsLIOuy8FUfw=9c<4B(sWaQPqq+l%#R zH%`7=2$t>~LK`hAOiwW&#o+Lw)~skJSa6UiG<;a3DvOig8vQI}^k%m_656BeJ(yLi zx**Rq49gnc4^GLZYQ}~LsRF=&NHx{lqQC_P zV4c!8pw4hWq}hA^cRx{WaY!i#c~q8;l8MO zoQ~8 z%Xl#%cozoH>FuHYIYi=#em5wO3|EKg_hrKBoZZYX!V3~#KTlsz=XMX@fCo1zICLb* zAmwylo%NsQC^=Jft?4ut@5yZo;7Fn)@!`jbWCLitdZC+I6R9B50g`2^@WCKpAIjAg(e}1~6SzR*+%pEsGvl1v zIN#-FRan=HS0e6%&GY8u6EmTylD&^g#hz3Qcy>GN_UaU?O$lDjbQlCZ%E=vs9Gb)u z4mcBc4q9V?Stxn112g1u4jX=A1hbZRM2!OrUV(YBtvp6A9@>vdF8D%rVkVZ$7;Q?1 z3=1Nl{6mw$BbqR8Pu&z<1jIM#VN!2^n8t&_zN+q3yPPUpXq!yRAY&@WW>ILUAAh$h z+$Qlala^LpH}8Pkh7+Y?^(KAI-<0yWI&v)+J3e2gRro88&*TF9Miy7R1HxB4vBi=JowQY3@!(T^{r{PN3~gm*vv5Z z*yBD04xeE?fp-`p*XKLsXtG17KJTk0ZC;!aXyXtQ2c7+75hK8VZjRx>vFA{0Wi z0kE@*=Nc;!5!V>n6i=OBQJN<&f36>*nb+<-rVcQF=Nh9n@pG*iqGjQ7{e|uWk8|(= zY*+Q~JVC*dDs?$58rSa1>w>!%zJbFHLPdeV7XNc?jarXVgI)-Dq&_x{;@ZW&WPml5 z?eJt&sx>cgJml*)fD>l-!r5ned92A8j_MIOJi$~c4=}~r57x@)hEZ%?ed@V`W2J!H zoC!-d6$_!O&F{JKTmXz!59qA9MQ{~QuHRoF&6ncp=u-ohR50l zhtn!E_UVy`q8ToZ+`45LFLX@}Ngf9>M3DID6=R@xo<{>I*hwma2H8e1B*86bE5aba-3Bw(TufvF3a{IFqFNcaPK^43ldF2 z(%)q^Akx#C#EUhuG_*B3(v7pPG&IW+`x@4)`v*241{s*>tWBu)lko8r<*-li+gqrl z)Gr2c)NRZ^yjoNJwmgAcR2P>5OLeW^Y*r{;6l5}R0VHXM zlLiW*ooZR#J9S^F(QI&lT|F1@$sacbT4tH5caMbv5wU9PdnhP&a+-d||BB0Tc$g z*%!Thc|+r^fOFs|gsk7)!z3NyQ8%WA+%5IhglpribMzL0lr-kfspVamcauk3zhlp) z8Et8Lr*G{-3>1~#&Q%%Zy|`6C!75(z7cbA^QgzITDCCP8PVxb}Tl&o@k0U*vqRX@X zpK4C-w@0_vnce#i!>8jPcZHcGPs68c#*<;#!J)fu^TN7tRYR3jPeD6I zbd=k(P$<7 z4%grdD=uJFk=wVPX^Xj$!;MT2W{N*(NGC{?RHZx$kqOGaX;MY8Cbu>5PEu)9cLhtDlpaI&`H$>0J)$AMzWLojb5;N2X0A>GBjXv%3 z3Ud*d3as_I{tp-r(O;$6qZ!-n4yHJMeFRs>)+3w9D8r@5 zt;S~NjzFPQN|rU9u&-}bKOBW{r=xf?TABjw-jm2$0Nuja9G!eqeqvM@NRW*R?N$Wx z-&0n!2O2`^*qVY|LO62T3c`7=r_pJ)<(+9vWWR_VLWAlX)OkeuV>-UDW=u=0bq6@P z!i#52xNEp@A!;Kt(gFbne20r7e` z0JN>nC&+zrqr)a(;wt;#D{yI6C<#Vd`qahTZ?2--I1n!en<@Zl&wc!zdgu%ce+zAM z+_ZE1RJfN0KEjVw4Si5mZD0$^$FVPykE=?7@iCgwsVYg0|M=*;wdUEt<|x)uet!eg zlS^iEkDRZ_^{Zd609X4e-Sr5>ZI=zFpue5w#T;pAaGq|Hq+MX&4ta5I(1!o|Rb3RH zBXO0iW`XmRWz+@kX%np^L3#~qHqDLF$o*!K{JJ@-dH4|Ps&CrhF>Z%Ew(_m9JyXrX;d9#o3*jfr`nTIiQQ*)8x zG;wS_T)TmV`z)Yesq=GP6gMN@3zP5#%dzc8l&?k`VJ0#+vPRt`ObkH4ib@!3&4!tN zk+-tU&q3oi93O)G4lI|2$VGD*vWd+r%8%+x&9R?ohx&}xB6TNJO%hSIFFmAYu#eoB z+{{3-@*nBn>NpJKMt-dTPu8;Zf&vWLb2muoULie%)9Fi@Sr_}J>yocy9+d!}H?CK2 zZ?^}w+t=#snA#X1ya!oz5=%itA84uGngg`ao8I z(~JsTA@bOI6vkrsDW^+y!U&mE3LZhB%8>*jL?yZt=tr=a2jnC0x9R{t#X?_~W^CK+ z`Bi9ydOMFbHqAGoY}*|B{2N~>s5q^7UJGq!$$C%Oe(YsLX<;wIx+|>d#4k$1qXjZ{ z-RZtb{N0u*f1d(l9Gqf&_*d;tu55_T*e>g@tka71hFkG`sRQq5Z_3JXW@4zt18jSA zG2wNHLBoHN&jW~nm~E1tYIw;e;VWsuF^qJA$_w%Y`$zhn=b@pcuCk9@z)d9i zm|Ikf*s&bINi($1XfmwWZ8tH2F2obi#Qqgo+WxrfC7G@MW9z4#VU>o0{qAu4=7#FQ zHkLzME&K$HZ#Q;dOL|}cDj*x%bNhX+ z3wRUKDKsF*B)*?=Ulo4)5`&J|@{)u|RdSr&J7@#z+#l}NSW%^&V(vos#xlF(Q$=oD zDv6e!^=HSHf%3VO?bxd(9OG{kr5Hjj(%TCAuy;XbK18F+mkT(bRcdRTFHm3!ixAv& zFDgx|Z$AdRp+B=>+#^$kyw{^zFDOs#rmo2O+dF`Ac(fyivhB>?^*;18oSv?)GfBhkaN?y>+jXE(VzfUk!VQ&hx! zlh>F{1=vzfWPOKA`kcY981ZAN?Q&1ws{>`dzlwuOq4DES%ZV_Y9gpE-&3TYuxarc+>5 z>cCFAtj#HF8Dri6>Be3V1w3YK+K3}FX&v4UqI1s}#O%%>$sJyP)n@4-OGvRlfIUC} z4P#}mL6Lwt#3PngqtmNO?F>_WeWgb6d`_tBENKk_cMT)6iF=+wmdSn?L;)nY4TFsq zB&R=hcFr|Ma_+(Bb$`V}tQ^(1Jp(T7&-4Tc3;ibl11I#M-Of%}Gb2&fW+u3nA>lrI zXq^w;7p&OI;BoG~K`MS4QEuODCo)nJU8Ln85Lw}v4(e&9Tx`l$)RW)A@q&PFp|@M< z^Tmss$6jDvk#xAT7ChM1e#-;sLzg=?-qy)c1SGp(Ohh}U^ZW8rIB!7Fx(z9|NP&ihnQ#4ePbJ;IbP?nsp z$=|Zf)$mMFu4yAHv0OJwo~lM(y*=EWpIpJJVA{T8dT~-vwo;-_AOt=~s{{w3=W2(H z{vzv=S*&$QsDgFG3nzVC!*qeztCi4KPBM$}{I^##g+(nw% zcsxz$GndbmO;hMr638#>{ZJBpLUCVWQ>KMomcM6&JyQ=mM4H77<7^{nqsXL%1t7qb zHmCL)E{^>(hlM@~%>_JtuvUf48=53mw3br(yE1E4-1*E2USCoIL91&7tvt*|!0zX+R0s;~$8}Dg*0i7)Q~Jn8XfhT^1u3QS*;s&vWC1m$r^MFzDR5E#ky5 z!hUu25DK;N8oO07S+I_jw2%8}+4rpzGC|k7BLv&U8ft>4C-c(LJsRdtE(aKwp`Du| zDx5|{`#W{B-;ta|W%(iW)jo5K%DV90b#4+B;@HPoXo0W3#T3%)Ki9TPvv{Vb&&)O0 zDy*HaFEY|GK7%F%NdY~X#u0;K;Xd7ccLvNmWXGIr{GMU*r)iEuOilIFt$a9?h+lts zXq2m$V4=9Q%VbEK~zh~+IVRo<2Y&B(P0 z9niw>YT$X$!LnxIiVSC+IKOb~hD_5Q{NF6bN*?<)Ne#*tW;GKhd=pPVFBeXF>5K^$~(gLJ<5`U`@0BzF0gtoUb`3t!_qb>x`#p=p4!xvGMwQ^ znY_PQD%J$#h8;-yh0|X!%9P#HsGN;&D8aje(<|7{ao=|2ar~rd{L*p>U1PWdCmU%Q znRYzQ!ih{8meLM%2MzzFKgeu~YP9UJh>hj6p=FTSDc6V=H*`EBJBN^j`E!5gi!Q`k z0I<)>B1wh(pp`XTxk5v>G>)))r-#OYi%2SvC#!U!z2{O>S74rsI2$`tDawFj{_mk- zQJ|K$Qgi`nBbxmC%t&?wx}gkPI%Aab+1g+Jr!z#t(|QCp1MnTqGp;pugjlwlUkSBtS8=nu34!Hbol~QEDY4b$Gf7~C=uU;sI4r+o^Z#bnK z*M8eN5?tF^&$p{$`-*`!0OId>aa8-Q9>U6BKtC~w*U92qorfgC7E2P}_(bmU^6bM> zs17I$CKE2oFCOGOaDW~eOI%nD8lutLD&b9^F}G*bJsnw)V%58d!o{tPQ`i{h1dhE=D`I0)2^o zQP+=N;QlhkJXvDhc=a$Bcx1A)zHn$}a@qp_WF;aXf=hu{5W;!~{)MoEeXkJBQpf1r zD9~V#p4~xPmmL(NfnCv8uDssj1O!ogFk#9?*KL&2KZVf`S}}ecdlA3Iy!&TOkwHgi+Ah`*9zu-}WGUyV5zd1+2Ou+oCX) za`SdTTDBlHsdt2+1I85RZbt0gD8{jw4XLp=@z!_b?mE2N4&$*NcrJHfS!T<$Z10P_ z*0=a6*bD%iF_|fC^}G&oKn@`mi-b{Slj*Phnr!RZoR*#{RnC7uP2Q&a@gLSN*Ooj^ zRRlW5S-x-FWDl$*%GxD2;0ix|A=!Cn?9xi|P)1XaXKlcFCgxN10EXA~}$5%7FFb>$SUh|O%D1e(T-QxYm~T%yj< zv{h(DT}-$$e>2Bq0T^Tx6X_8G1zIBZCgz*x!1vK_g;fcVQUes`xF$Sfta~Q$v*|k# zd^|x5VjruI9Tn)vwcPRVz{)yGqvULfWPV1?1Gu zqRvoPM$8gussskZYuKwpDABmhsv|0$RyCZ0liHQXEtN=k(dC!|v@s_ldB~83L!exS zda;`Ky=_n3c@0>&7?K@?VmHC~P6dnW1M|3F$4We@a1V@)fbwsD|06dU+y5B7dRf<) z{8WxDLOETD2{7aBTEJOFI>PDUY?WeFlAVaNeI^mQCNNv6E5?lI(4 zvf~}H#~wLO_^(_|q-*lvD4xl-z2fAk8RF~MTF`CTn#%*d3jI25J1b8}IcT?4ggI{B z#@BvjBFxs>ii8K|TDmQCGZu{Dq;R)MYW%(LUNYJ(0^6lMKLQ{j)HFDN+{0|PmCbp} z!sZhr750X^u@TnVzQW?>`Q?6B*KZKOp7xi7DjKi!rS&n3G%PP$16}#PPFx_E2)3~- zvu8E$kSQ}h+tJTnMX_~pC3?3>WqygUrJ;e9o4`k3G6OFL_X>NzKAX+}vU1Tu-wMsf z30j-qKRq!h*p`ueD+w@|B^li#XNpeRX94zIHj80Z7`e?G zr6MTnzbQ|fmGvcoW8!X~M#@#kghM)t27I5+TA~xXA~c)&>;dK5(@ykN;! zKSB*Iq3%C;6(M(h=U{g?twJ5$6Opv5lse?tfoaYw8Mh-QI$Xtfk!7kiCKUB{B^7r# zmD1?UQ-`;?k2CSIIbkE#sx3P*6F-?q z0;(TfHn`WlSRmPyCjs4Ll?+L?-fXgK@cR|kF|T9kT9e<`Be>@X3SaLbiAuP(_7CWj zIq{TplG3jZk9>t36YIU5tT6L7S$#dY(| zpz%p7e^~?5F^{y0r*!nslDZd&kMS$9ZNPPIVv|Ff0HnVmL;PtkkB!xOUHZ-+0mQbs zMv56{DM6L2zU+LFTgzCqzcer}y>meR*0-dzK{-IuWy5)749ytT5r~h~=ed!}yv(V3%OVfeb^T6j zYy6Ixzmfa1m;R632p$c3V8;MsD-UG-Y0Oc$!1NV6ePmqkO(yoUQn^2ABVzXV++AOAB~Qy3ur5jq_T z{2viBjQ$a1mnryviZxJuCeJ_LS*FN8Fl0&ogVX<_HB0jU%eCOMc}f2%xCV_L-vSW9 zz_v-j!0`Sm;r`PC0%3t5<811GvqWYSLUc!gz;&@fUD@dWJhOj2wII-C_LqMaLJpyT zZz>p=p|iQI3k&nVURfR-0t@m#(mHhgPYP&vFbFWPNoX)I)c?fz%K&imG>~!5KUEnh eAcqhlF$0v7L;KGf`)j3Tf{t>iV4k!7jrboavtW?` delta 9786 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 Pl45{N8U($Re_H link_crypto.Link | None: class LogicClient: - def __init__(self, base_url: str | None = None) -> None: + """Rozmówca warstwy logicznej ZWIĄZANY Z PULĄ KONTA (PRE-29). + + Pulę podaje się przy tworzeniu klienta, a bierze się ją WYŁĄCZNIE z loginu + zalogowanej osoby — nigdy z pola formularza. Gdyby przychodziła z żądania, + wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy.""" + + def __init__(self, tenant: str = "", base_url: str | None = None) -> None: self.base_url = (base_url or settings.logic_url).rstrip("/") + self.tenant = tenant or "" def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]: """Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach. @@ -41,7 +48,8 @@ class LogicClient: Jedyne pytanie, jakie astroklient zadaje o treść — bez grupowania, bez promptów, bez modelu językowego.""" payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, - "limit": settings.report_limit, "group": False} + "limit": settings.report_limit, "group": False, + "tenant": self.tenant} with httpx.Client(timeout=settings.http_timeout) as client: return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report", payload=payload, headers=_auth_headers(), link=_link()) @@ -50,7 +58,8 @@ class LogicClient: with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client: return link_crypto.call_json( client, "POST", f"{self.base_url}/files/upload", - payload={"filename": filename, "content_b64": content_b64, "by": by}, + payload={"filename": filename, "content_b64": content_b64, + "by": by, "tenant": self.tenant}, headers=_auth_headers(), link=_link()) def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]: @@ -60,7 +69,7 @@ class LogicClient: with httpx.Client(timeout=settings.http_timeout) as client: return link_crypto.call_json( client, "POST", f"{self.base_url}/files/status", - payload={"path": path, "status": status, "by": by}, + payload={"path": path, "status": status, "by": by, "tenant": self.tenant}, headers=_auth_headers(), link=_link()) def health(self) -> dict[str, Any]: diff --git a/services/astroklient/app/main.py b/services/astroklient/app/main.py index fb15aad..47369f8 100644 --- a/services/astroklient/app/main.py +++ b/services/astroklient/app/main.py @@ -38,7 +38,14 @@ security.install(app) app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") -logic = LogicClient() +def _logic(request: Request) -> LogicClient: + """Klient związany z pulą ZALOGOWANEGO konta. + + Budowany PER ŻĄDANIE, nie raz na moduł: pula musi wynikać z tego, kto się + zalogował, a nie z globalnego stanu procesu. Login bierzemy z request.state, + które ustawia warstwa logowania — nigdy z pola formularza, bo wtedy + wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy.""" + return LogicClient(tenant=getattr(request.state, "user", "") or "") # Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana # tutaj wprost, bo astroklient nie importuje tamtej warstwy; literówka objawiłaby @@ -90,7 +97,7 @@ def interpretacja(request: Request, date: str = Form(...), time: str = Form(...) try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label - ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon) + ctx["result"] = _logic(request).report(when_utc_iso=iso_utc, lat=lat, lon=lon) # Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17). security.audit_records(request, _ile_rekordow(ctx["result"])) except ValueError as e: @@ -117,7 +124,7 @@ async def dodaj_plik(request: Request, upload: UploadFile = File(...)): who = getattr(request.state, "user", "") or "demo" try: - out = logic.files_upload(upload.filename or "baza.xlsx", + out = _logic(request).files_upload(upload.filename or "baza.xlsx", base64.b64encode(raw).decode("ascii"), by=who) except (httpx.HTTPError, link_crypto.LinkError) as e: return _wroc(error=_blad(e)) @@ -128,7 +135,7 @@ async def dodaj_plik(request: Request, upload: UploadFile = File(...)): return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się " f"do użycia w tej postaci.") try: - logic.files_status(out["path"], ACTIVE, by=who) + _logic(request).files_status(out["path"], ACTIVE, by=who) except (httpx.HTTPError, link_crypto.LinkError) as e: return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}") return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.") diff --git a/services/astroklient/app/security.py b/services/astroklient/app/security.py index 6058179..7a74da9 100644 --- a/services/astroklient/app/security.py +++ b/services/astroklient/app/security.py @@ -39,12 +39,30 @@ _hits: dict[str, deque[float]] = {} MAX_TRACKED_CLIENTS = 4096 -def demo_user() -> str: - return os.getenv("DEMO_USER", "demo") +def accounts() -> dict[str, str]: + """Konta demo: `login:sekret`, po przecinku, ze zmiennej DEMO_USERS. + WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29). + Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem + swoje wgrania. -def demo_password() -> str: - return os.getenv("DEMO_PASSWORD", "") + Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) — ten sam format i ten + sam skrypt make_user.py, co w głównej aplikacji. + + Zgodność wstecz: pojedyncze DEMO_USER/DEMO_PASSWORD działa, gdy DEMO_USERS + jest puste.""" + out: dict[str, str] = {} + for entry in os.getenv("DEMO_USERS", "").split(","): + entry = entry.strip() + if not entry: + continue + user, sep, secret = entry.partition(":") + if sep and user.strip() and secret.strip(): + out[user.strip()] = secret.strip() + if out: + return out + single = os.getenv("DEMO_PASSWORD", "") + return {os.getenv("DEMO_USER", "demo"): single} if single else {} def rate_limit_per_min() -> int: @@ -56,7 +74,7 @@ def trust_proxy() -> bool: def auth_enabled() -> bool: - return bool(demo_password()) + return bool(accounts()) def _secret_matches(spec: str, password: str) -> bool: @@ -82,9 +100,12 @@ def authenticate(header: str | None) -> str | None: user, _, password = raw.partition(":") except (binascii.Error, UnicodeDecodeError, IndexError): return None - if user != demo_user(): + spec = accounts().get(user) + if spec is None: return None - return user if _secret_matches(demo_password(), password) else None + # Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które + # konto istnieje, a przy demo rozdawanym szeroko to realna informacja. + return user if _secret_matches(spec, password) else None def client_ip(request: Request) -> str: @@ -142,9 +163,9 @@ def install(app) -> None: _setup_audit_logging() if not auth_enabled(): log.warning( - "UWAGA: DEMO_PASSWORD nie ustawione — astroklient stoi OTWARTY, a pracuje " - "na tej samej warstwie danych co produkcja, więc wystawia treść " - "oryginalnych baz interpretacyjnych każdemu, kto zna adres." + "UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient stoi OTWARTY. " + "Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym " + "zbiorze baz, widząc nawzajem swoje wgrania." ) @app.middleware("http") diff --git a/services/astroklient/tests/test_astroklient.py b/services/astroklient/tests/test_astroklient.py index d4d41c0..da6ad25 100644 --- a/services/astroklient/tests/test_astroklient.py +++ b/services/astroklient/tests/test_astroklient.py @@ -17,8 +17,9 @@ import pytest @pytest.fixture() def env(monkeypatch): - monkeypatch.setenv("DEMO_USER", "demo") - monkeypatch.setenv("DEMO_PASSWORD", "tajne-demo") + monkeypatch.setenv("DEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB") + monkeypatch.delenv("DEMO_USER", raising=False) + monkeypatch.delenv("DEMO_PASSWORD", raising=False) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") @@ -39,20 +40,25 @@ REPORT = { } -def _client(monkeypatch, calls=None): +def _client(monkeypatch, calls=None, upload=None): + """Podmieniamy METODY KLASY, nie obiekt modułowy: klient jest budowany per + żądanie, żeby pula wynikała z zalogowanego konta. Dzięki temu podglądamy + też `self.tenant`, czyli to, do której puli faktycznie poszło zapytanie.""" from starlette.testclient import TestClient - from app.main import app, logic + from app.clients.logic_client import LogicClient + from app.main import app calls = calls if calls is not None else [] - monkeypatch.setattr(logic, "report", - lambda **kw: (calls.append(("report", kw)), REPORT)[1]) - monkeypatch.setattr(logic, "files_upload", - lambda *a, **kw: (calls.append(("upload", a, kw)), - {"path": "nowa.xlsx", "name": "nowa.xlsx", - "accepted": True})[1]) - monkeypatch.setattr(logic, "files_status", - lambda *a, **kw: (calls.append(("status", a, kw)), {})[1]) + domyslny = {"path": "nowa.xlsx", "name": "nowa.xlsx", "accepted": True} + + monkeypatch.setattr(LogicClient, "report", + lambda self, **kw: (calls.append(("report", self.tenant, kw)), REPORT)[1]) + monkeypatch.setattr(LogicClient, "files_upload", + lambda self, *a, **kw: (calls.append(("upload", self.tenant, a)), + upload or domyslny)[1]) + monkeypatch.setattr(LogicClient, "files_status", + lambda self, *a, **kw: (calls.append(("status", self.tenant, a)), {})[1]) return TestClient(app), calls @@ -109,7 +115,7 @@ def test_demo_account_is_separate_from_the_main_application(env, monkeypatch): def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch): - monkeypatch.setenv("DEMO_PASSWORD", "zażółć-gęślą") + monkeypatch.setenv("DEMO_USERS", "demo:zażółć-gęślą") c, _ = _client(monkeypatch) assert c.get("/", headers=_auth("demo", "złe-hasło")).status_code == 401 assert c.get("/", headers=_auth("demo", "zażółć-gęślą")).status_code == 200 @@ -125,7 +131,7 @@ def test_adding_a_file_uploads_AND_switches_it_on(env, monkeypatch): assert r.status_code == 303 rodzaje = [k[0] for k in calls] assert rodzaje == ["upload", "status"], f"oczekiwano wgrania i włączenia, było: {rodzaje}" - assert calls[1][1][1] == "active", "plik nie został włączony do użytku" + assert calls[1][2][1] == "active", "plik nie został włączony do użytku" def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, monkeypatch): @@ -133,13 +139,8 @@ def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, mon demo nie ma prawa ich zdradzić, nawet mimochodem.""" from urllib.parse import unquote_plus - from app.main import logic - - c, calls = _client(monkeypatch) - monkeypatch.setattr(logic, "files_upload", lambda *a, **kw: ( - calls.append(("upload", a, kw)), - {"path": "zla.xlsx", "name": "zla.xlsx", "accepted": False})[1]) - + c, calls = _client(monkeypatch, upload={"path": "zla.xlsx", "name": "zla.xlsx", + "accepted": False}) r = c.post("/plik", headers=_auth(), follow_redirects=False, files={"upload": ("zla.xlsx", b"x", "text/plain")}) assert r.status_code == 303 @@ -171,7 +172,7 @@ def test_local_time_is_converted_to_utc_before_asking(env, monkeypatch): a wynik nadal wygląda wiarygodnie — dlatego sprawdzamy wprost.""" c, calls = _client(monkeypatch) c.post("/", headers=_auth(), data=FORM) - assert calls[0][1]["when_utc_iso"].startswith("1984-04-30T09:20") + assert calls[0][2]["when_utc_iso"].startswith("1984-04-30T09:20") def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch): @@ -184,10 +185,10 @@ def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch): def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch): import httpx - from app.main import logic + from app.clients.logic_client import LogicClient c, _ = _client(monkeypatch) - monkeypatch.setattr(logic, "report", lambda **kw: (_ for _ in ()).throw( + monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw( httpx.ConnectError("brak połączenia"))) r = c.post("/", headers=_auth(), data=FORM) assert r.status_code == 200 and "Warstwa logiczna niedostępna" in r.text @@ -201,3 +202,45 @@ def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch): for slowo in ("Kosmogram", "Synastria", "Kalendarz", "Skompiluj", "Ustawienia", "Konta", "PDF", "Excel", "prompt", "model"): assert slowo not in html, f"strona wspomina o „{slowo}”, czego demo nie umie" + + +# ── pule kont: izolacja (PRE-29) ───────────────────────────────────────── +# Demo jest udostępniane szeroko i różnym osobom, więc pula musi wynikać +# z tego, KTO SIĘ ZALOGOWAŁ — i z niczego innego. Wyciek byłby cichy: klient +# zobaczyłby cudze bazy jako własne. + +def test_the_pool_is_taken_from_the_login(env, monkeypatch): + c, calls = _client(monkeypatch) + c.post("/", headers=_auth("klientA", "hasloA"), data=FORM) + c.post("/", headers=_auth("klientB", "hasloB"), data=FORM) + assert [k[1] for k in calls] == ["klientA", "klientB"] + + +def test_uploads_go_to_the_pool_of_the_account_that_sent_them(env, monkeypatch): + c, calls = _client(monkeypatch) + c.post("/plik", headers=_auth("klientA", "hasloA"), follow_redirects=False, + files={"upload": ("baza.xlsx", b"x", "application/vnd.ms-excel")}) + assert {k[1] for k in calls} == {"klientA"}, "wgranie trafiło do cudzej puli" + + +def test_the_pool_cannot_be_chosen_from_the_form(env, monkeypatch): + """Gdyby pula przychodziła z żądania, wystarczyłoby podstawić cudzy login.""" + c, calls = _client(monkeypatch) + c.post("/", headers=_auth("klientA", "hasloA"), + data={**FORM, "tenant": "klientB", "user": "klientB", "login": "klientB"}) + assert [k[1] for k in calls] == ["klientA"], "pula dała się podmienić z formularza" + + +def test_every_account_has_its_own_pool_by_construction(env, monkeypatch): + """Każdy login = inna pula. Dwa konta nie mogą dzielić zbioru baz.""" + c, calls = _client(monkeypatch) + for login, haslo in (("demo", "tajne-demo"), ("klientA", "hasloA"), ("klientB", "hasloB")): + c.post("/", headers=_auth(login, haslo), data=FORM) + pule = [k[1] for k in calls] + assert len(set(pule)) == len(pule) == 3 + + +def test_an_unknown_account_gets_nothing(env, monkeypatch): + c, calls = _client(monkeypatch) + assert c.post("/", headers=_auth("obcy", "cokolwiek"), data=FORM).status_code == 401 + assert calls == [], "poszło zapytanie mimo odmowy logowania" diff --git a/services/data/app/files.py b/services/data/app/files.py index 204be8f..1bf23a1 100644 --- a/services/data/app/files.py +++ b/services/data/app/files.py @@ -36,6 +36,22 @@ from datetime import datetime, timezone from pathlib import Path ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine" + +# Nazwa puli może przyjść z zewnątrz (login konta w wersji demo), więc przechodzi +# przez sito: wyłącznie znaki bezpieczne w nazwie katalogu. Bez tego „../..” albo +# ukośnik wyprowadziłyby zapytanie poza udział — czyli wprost do cudzych baz. +_TENANT_OK = re.compile(r"[^A-Za-z0-9._-]") + + +def tenant_root(root: Path | str, tenant: str = "") -> Path: + """Katalog, w którym mieszka pula danego konta. + + Puste `tenant` = cały udział, czyli zachowanie produkcji. Nazwa jest + OCZYSZCZANA, a nie sprawdzana i odrzucana: sito zamienia wszystko podejrzane + na podkreślenie, więc żadne wejście nie wyprowadzi ścieżki wyżej.""" + root = Path(root) + name = _TENANT_OK.sub("_", (tenant or "").strip()).strip("._") + return root / name if name else root USABLE = frozenset({ACTIVE}) # Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych. diff --git a/services/data/app/main.py b/services/data/app/main.py index 3d3c799..93e33cc 100644 --- a/services/data/app/main.py +++ b/services/data/app/main.py @@ -66,29 +66,35 @@ def bases() -> dict: class FilesQuery(BaseModel): for_admin: bool = False + # Pula konta (PRE-29). Puste = cały udział — tak woła produkcja, która o pulach + # nic nie wie. Wersja demo podaje login, przez co widzi wyłącznie swój katalog. + tenant: str = "" class FileAction(BaseModel): path: str status: str = "" by: str = "" + tenant: str = "" class FileUpload(BaseModel): filename: str content_b64: str by: str = "" + tenant: str = "" class RulesUpdate(BaseModel): rules: dict + tenant: str = "" @app.post("/files") def files_list(q: FilesQuery) -> dict: """Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy u źródła, żeby nie dało się jej odczytać z podglądu źródła strony.""" - root = settings.excel_dir + root = files.tenant_root(settings.excel_dir, q.tenant) return {"files": files.registry(root, for_admin=q.for_admin), "rules": files.rules(root) if q.for_admin else {}, "root": str(root)} @@ -97,7 +103,8 @@ def files_list(q: FilesQuery) -> dict: @app.post("/files/status") def files_status(a: FileAction) -> dict: try: - row = files.set_status(settings.excel_dir, a.path, a.status, by=a.by) + row = files.set_status(files.tenant_root(settings.excel_dir, a.tenant), + a.path, a.status, by=a.by) except ValueError as e: raise HTTPException(422, str(e)) from e return {"path": a.path, "status": row.get("status")} @@ -112,13 +119,14 @@ def files_upload(u: FileUpload) -> dict: raw = base64.b64decode(u.content_b64, validate=True) except (binascii.Error, ValueError) as e: raise HTTPException(422, "Nieczytelna zawartość pliku.") from e - return files.store_upload(settings.excel_dir, u.filename, raw, by=u.by) + return files.store_upload(files.tenant_root(settings.excel_dir, u.tenant), + u.filename, raw, by=u.by) @app.post("/files/delete") def files_delete(a: FileAction) -> dict: try: - files.delete(settings.excel_dir, a.path) + files.delete(files.tenant_root(settings.excel_dir, a.tenant), a.path) except ValueError as e: raise HTTPException(422, str(e)) from e return {"deleted": a.path} @@ -126,7 +134,7 @@ def files_delete(a: FileAction) -> dict: @app.post("/files/rules") def files_rules(u: RulesUpdate) -> dict: - return {"rules": files.set_rules(settings.excel_dir, u.rules)} + return {"rules": files.set_rules(files.tenant_root(settings.excel_dir, u.tenant), u.rules)} @app.get("/health", response_model=HealthInfo) diff --git a/services/data/app/models.py b/services/data/app/models.py index ec0cdfb..74de050 100644 --- a/services/data/app/models.py +++ b/services/data/app/models.py @@ -25,6 +25,11 @@ class SearchQuery(BaseModel): fields: list[str] | None = Field( None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie." ) + # PULA KONTA (PRE-29). Puste = cały udział, czyli zachowanie produkcji, która + # o pulach nic nie wie. Ustawione = wyszukiwanie widzi WYŁĄCZNIE podkatalog + # tego konta. Używa tego wersja demo, gdzie każde konto ma własny, niewidoczny + # dla pozostałych zbiór baz. + tenant: str = Field("", description="Pula plików konta; puste = cały udział.") class SearchResult(BaseModel): diff --git a/services/data/app/providers/excel_provider.py b/services/data/app/providers/excel_provider.py index 8bb6dec..4f31d0c 100644 --- a/services/data/app/providers/excel_provider.py +++ b/services/data/app/providers/excel_provider.py @@ -91,7 +91,7 @@ class ExcelDataProvider(DataProvider): base = Path(self.s.excel_dir) return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")] - def _enabled_files(self, paths: list[str]) -> list[str]: + def _enabled_files(self, paths: list[str], tenant: str = "") -> list[str]: """Bazy biorące udział w wyszukiwaniu. Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu, @@ -105,7 +105,7 @@ class ExcelDataProvider(DataProvider): # przy KAŻDYM wyszukiwaniu, czyli 500 z warstwy danych. from app import bases, files - usable = set(files.usable_paths(self.s.excel_dir)) + usable = set(files.usable_paths(files.tenant_root(self.s.excel_dir, tenant))) out = [p for p in paths if p in usable] entries = bases.disabled_entries() if entries: @@ -128,7 +128,11 @@ class ExcelDataProvider(DataProvider): from app import bases disabled = ",".join(bases.disabled_entries()) - cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}" + # PULA MUSI BYĆ W KLUCZU. Bez niej wynik policzony dla jednego konta + # trafiłby z cache do drugiego — czyli cicha wymiana treści baz między + # kontami, niewidoczna w logach i nie do wykrycia z zewnątrz. + cache_key = (f"{query.key}|{query.value}|{query.exact}|{query.limit}" + f"|{query.fields}|{disabled}|{query.tenant}") hit = self.queries.get(cache_key) # poziom 3: wynik zapytania if hit is not None: @@ -140,7 +144,7 @@ class ExcelDataProvider(DataProvider): # brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki candidates = [(p, "0") for p in self._excel_files()] # bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów - allowed = set(self._enabled_files([p for p, _ in candidates])) + allowed = set(self._enabled_files([p for p, _ in candidates], query.tenant)) candidates = [(p, s) for p, s in candidates if p in allowed] rows: list[dict] = [] diff --git a/services/data/tests/test_rejestr_integracja.py b/services/data/tests/test_rejestr_integracja.py index b56069d..e447202 100644 --- a/services/data/tests/test_rejestr_integracja.py +++ b/services/data/tests/test_rejestr_integracja.py @@ -110,3 +110,80 @@ def test_switching_a_base_off_is_visible_in_both_places(excel_dir): p2.warmup() assert p2.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total == 0 assert [b["enabled"] for b in p2.list_bases()] == [False] + + +# ── pule kont: izolacja (PRE-29) ───────────────────────────────────────── +# Najgroźniejsza rzecz w tej zmianie. Wyciek między pulami jest CICHY: klient +# zobaczyłby cudze opisy jako własny wynik i nikt by się nie zorientował. + +def _pool(root, name, plik, tresc): + from app import files + + pula = files.tenant_root(root, name) + pula.mkdir(parents=True, exist_ok=True) + _xlsx(pula / plik, rows=[(tresc, f"opis {tresc}")]) + files.registry(pula) # przyjęcie zastanych + return pula + + +def test_each_account_sees_only_its_own_files(excel_dir): + from app import files + + _pool(excel_dir, "klientA", "a.xlsx", "Ma Ari") + _pool(excel_dir, "klientB", "b.xlsx", "Ve Tau") + + a = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientA"))] + b = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientB"))] + assert a == ["a.xlsx"] and b == ["b.xlsx"] + + +def test_search_never_reaches_another_account_pool(excel_dir): + from app.models import SearchQuery + + _pool(excel_dir, "klientA", "a.xlsx", "Ma Ari") + _pool(excel_dir, "klientB", "b.xlsx", "Ve Tau") + p = _provider(excel_dir) + p.warmup() + + swoje = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA")) + cudze = p.search(SearchQuery(key="significator", value="Ve Tau", limit=10, tenant="klientA")) + assert swoje.total >= 1, "konto nie widzi własnej bazy" + assert cudze.total == 0, "konto sięgnęło do puli innego konta" + + +def test_the_query_cache_cannot_serve_one_account_the_answer_of_another(excel_dir): + """Bez puli w kluczu cache wynik policzony dla jednego konta trafiłby do + drugiego — cicha wymiana treści baz, niewidoczna w logach.""" + from app.models import SearchQuery + + _pool(excel_dir, "klientA", "a.xlsx", "Ma Ari") + _pool(excel_dir, "klientB", "b.xlsx", "Ma Ari") # TA SAMA szukana wartość + p = _provider(excel_dir) + p.warmup() + + a = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA")) + b = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientB")) + assert a.total >= 1 and b.total >= 1 + assert b.cache != "hit", "odpowiedź dla jednego konta wyszła z cache drugiego" + + +def test_a_crafted_pool_name_cannot_escape_the_share(excel_dir): + """Nazwa puli przychodzi z zewnątrz (login), więc „../..” albo ukośnik + wyprowadziłyby zapytanie wprost do cudzych baz.""" + from app import files + + for zlosliwa in ("../..", "../klientB", "/etc", "a/../../b", "..", "."): + wynik = files.tenant_root(excel_dir, zlosliwa) + assert excel_dir in wynik.parents or wynik == excel_dir, f"ucieczka: {zlosliwa} → {wynik}" + + +def test_no_pool_means_the_whole_share_so_production_is_untouched(excel_dir): + """Produkcja nie wie o pulach i ma działać dokładnie jak dotąd.""" + from app import files + from app.models import SearchQuery + + _xlsx(excel_dir / "produkcyjna.xlsx") + p = _provider(excel_dir) + p.warmup() + assert files.tenant_root(excel_dir, "") == excel_dir + assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1 diff --git a/services/logic/app/clients/data_client.py b/services/logic/app/clients/data_client.py index 88a0a29..b7f9e40 100644 --- a/services/logic/app/clients/data_client.py +++ b/services/logic/app/clients/data_client.py @@ -28,8 +28,18 @@ def _link() -> link_crypto.Link | None: class DataClient: - def __init__(self, base_url: str | None = None) -> None: + """Rozmówca warstwy danych, opcjonalnie ZWIĄZANY Z PULĄ KONTA (PRE-29). + + Pula siedzi w INSTANCJI, nie w sygnaturach metod. Gdyby jechała argumentem, + trzeba by ją przeprowadzić przez protokół DataSource i przez build_report — + czyli przez kod, który o kontach nie ma prawa nic wiedzieć. A przy okazji + każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam. + + Puste = cały udział, czyli zachowanie produkcji.""" + + def __init__(self, base_url: str | None = None, tenant: str = "") -> None: self.base_url = (base_url or settings.data_url).rstrip("/") + self.tenant = tenant or "" def search( self, @@ -39,7 +49,8 @@ class DataClient: limit: int, fields: list[str] | None = None, ) -> dict[str, Any]: - payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields} + payload = {"key": key, "value": value, "exact": exact, "limit": limit, + "fields": fields, "tenant": self.tenant} with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client: return link_crypto.call_json(client, "POST", f"{self.base_url}/search", payload=payload, headers=_auth_headers(), @@ -58,20 +69,20 @@ class DataClient: # `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna. def files_list(self, for_admin: bool = False) -> dict[str, Any]: - return self._files_post("/files", {"for_admin": for_admin}) + return self._files_post("/files", {"for_admin": for_admin, "tenant": self.tenant}) def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]: - return self._files_post("/files/status", {"path": path, "status": status, "by": by}) + return self._files_post("/files/status", {"path": path, "status": status, "by": by, "tenant": self.tenant}) def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]: return self._files_post("/files/upload", - {"filename": filename, "content_b64": content_b64, "by": by}) + {"filename": filename, "content_b64": content_b64, "by": by, "tenant": self.tenant}) def files_delete(self, path: str) -> dict[str, Any]: - return self._files_post("/files/delete", {"path": path}) + return self._files_post("/files/delete", {"path": path, "tenant": self.tenant}) def files_rules(self, rules: dict) -> dict[str, Any]: - return self._files_post("/files/rules", {"rules": rules}) + return self._files_post("/files/rules", {"rules": rules, "tenant": self.tenant}) def _files_post(self, path: str, payload: dict) -> dict[str, Any]: with httpx.Client(timeout=settings.http_timeout) as client: diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 3f9d61d..508f811 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -128,29 +128,33 @@ def bases() -> dict: class FilesQuery(BaseModel): for_admin: bool = False + tenant: str = "" class FileAction(BaseModel): path: str status: str = "" by: str = "" + tenant: str = "" class FileUpload(BaseModel): filename: str content_b64: str by: str = "" + tenant: str = "" class RulesUpdate(BaseModel): rules: dict + tenant: str = "" -def _files_call(fn, *args, **kw) -> dict: +def _files_call(fn, *args, tenant: str = "", **kw) -> dict: from app.clients.data_client import DataClient try: - return fn(DataClient(), *args, **kw) + return fn(DataClient(tenant=tenant), *args, **kw) except httpx.HTTPStatusError as e: # 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł # walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało @@ -170,27 +174,27 @@ def _detail(e: httpx.HTTPStatusError) -> str: @app.post("/files") def files_list(q: FilesQuery) -> dict: - return _files_call(lambda c: c.files_list(q.for_admin)) + return _files_call(lambda c: c.files_list(q.for_admin), tenant=q.tenant) @app.post("/files/status") def files_status(a: FileAction) -> dict: - return _files_call(lambda c: c.files_status(a.path, a.status, a.by)) + return _files_call(lambda c: c.files_status(a.path, a.status, a.by), tenant=a.tenant) @app.post("/files/upload") def files_upload(u: FileUpload) -> dict: - return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by)) + return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by), tenant=u.tenant) @app.post("/files/delete") def files_delete(a: FileAction) -> dict: - return _files_call(lambda c: c.files_delete(a.path)) + return _files_call(lambda c: c.files_delete(a.path), tenant=a.tenant) @app.post("/files/rules") def files_rules(u: RulesUpdate) -> dict: - return _files_call(lambda c: c.files_rules(u.rules)) + return _files_call(lambda c: c.files_rules(u.rules), tenant=u.tenant) @app.post("/chart/synastry") @@ -243,6 +247,10 @@ class ReportRequest(BaseModel): lon: float = 0.0 limit: int = 5000 group: bool = False # grupowanie identycznych opisów + # Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie + # rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa + # prezentacji, bo to ona zna konta. + tenant: str = "" @app.post("/chart/report") @@ -258,7 +266,7 @@ def chart_report(req: ReportRequest) -> dict: chart = build_chart(engine, moment) # pozycje z domami + aspekty try: report = build_report( - chart["positions"], DataClient(), + chart["positions"], DataClient(tenant=req.tenant), aspects=chart.get("aspects"), parallels=chart.get("parallels"), per_object_limit=req.limit, group=req.group, ) -- 2.52.0 From 222963d1052027de93db5489b0764030672ad0a7 Mon Sep 17 00:00:00 2001 From: migatu Date: Thu, 20 Aug 2026 23:36:55 +0200 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20astroklient=20=E2=86=92=20astro?= =?UTF-8?q?klient-demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nazwa `astroklient` zostaje zarezerwowana dla przyszłej wersji produkcyjnej programu; obecna, demonstracyjna nazywa się od teraz `astroklient-demo`. Zmiana obejmuje katalog usługi, nazwę pliku testów, obraz w rejestrze (astrololo-astroklient-demo), job w CI, pętlę budowania obrazów, tytuł i nagłówek strony, nazwy loggerów, realm logowania, pole `layer` w /health oraz wymagania PRE-28/29 w xlsx. DWIE PUŁAPKI PODMIANY, obie sprawdzone po fakcie: Zdublowany przyrostek. `astroklient-demo` zawiera `astroklient`, więc powtórna podmiana dałaby `astroklient-demo-demo`. Sprawdziłem najpierw, że nigdzie nie ma jeszcze nowej nazwy, i dopiero wtedy podmieniłem raz. Polska odmiana. Ślepa podmiana zamieniła „astroklienta" na „astroklient-demoa” w czterech miejscach; poprawione na „astroklienta-demo". Tytuł FastAPI wyszedłby jako „astroklient-demo · demo", a nazwa jobu jako „Testy astroklienta-demo (wersja demo)" — oba skrócone. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/build.yaml | 6 +++--- .gitea/workflows/tests.yml | 10 +++++----- docs/astrololo_wymagania.xlsx | Bin 41130 -> 41143 bytes .../Dockerfile | 0 .../README.md | 10 +++++----- .../app/__init__.py | 0 .../app/clients/__init__.py | 0 .../app/clients/logic_client.py | 2 +- .../app/config.py | 2 +- .../app/link_crypto.py | 0 .../app/main.py | 10 +++++----- .../app/security.py | 10 +++++----- .../app/static/styles.css | 2 +- .../app/templates/base.html | 4 ++-- .../app/templates/index.html | 0 .../requirements-dev.txt | 0 .../requirements.txt | 0 .../tests/test_astroklient_demo.py} | 4 ++-- 18 files changed, 30 insertions(+), 30 deletions(-) rename services/{astroklient => astroklient-demo}/Dockerfile (100%) rename services/{astroklient => astroklient-demo}/README.md (93%) rename services/{astroklient => astroklient-demo}/app/__init__.py (100%) rename services/{astroklient => astroklient-demo}/app/clients/__init__.py (100%) rename services/{astroklient => astroklient-demo}/app/clients/logic_client.py (97%) rename services/{astroklient => astroklient-demo}/app/config.py (92%) rename services/{astroklient => astroklient-demo}/app/link_crypto.py (100%) rename services/{astroklient => astroklient-demo}/app/main.py (94%) rename services/{astroklient => astroklient-demo}/app/security.py (97%) rename services/{astroklient => astroklient-demo}/app/static/styles.css (96%) rename services/{astroklient => astroklient-demo}/app/templates/base.html (84%) rename services/{astroklient => astroklient-demo}/app/templates/index.html (100%) rename services/{astroklient => astroklient-demo}/requirements-dev.txt (100%) rename services/{astroklient => astroklient-demo}/requirements.txt (100%) rename services/{astroklient/tests/test_astroklient.py => astroklient-demo/tests/test_astroklient_demo.py} (98%) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index caeb9aa..2d0f319 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -9,12 +9,12 @@ jobs: - uses: actions/checkout@v4 - name: Login run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin - # astroklient dołącza do tej samej pętli: dzieli warstwę logiczną i łącze, + # astroklient-demo dołącza do tej samej pętli: dzieli warstwę logiczną i łącze, # więc jego obraz ma powstawać z tego samego commita co reszta produktu. - - name: Build & push (data, logic, presentation, astroklient) + - name: Build & push (data, logic, presentation, astroklient-demo) run: | TAG=${GITHUB_SHA::8} - for SVC in data logic presentation astroklient; do + for SVC in data logic presentation astroklient-demo; do docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG done diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index ec025a4..2021e5a 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -91,8 +91,8 @@ jobs: PYTHONPATH: . run: pytest tests -q -rs - astroklient-tests: - name: Testy astroklienta (wersja demo) + astroklient-demo-tests: + name: Testy astroklienta-demo runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -100,14 +100,14 @@ jobs: with: python-version: "3.12" cache: pip - cache-dependency-path: services/astroklient/requirements-dev.txt + cache-dependency-path: services/astroklient-demo/requirements-dev.txt - name: Instalacja zależności - run: pip install -r services/astroklient/requirements-dev.txt + run: pip install -r services/astroklient-demo/requirements-dev.txt # Demo rozmawia z warstwą danych PRODUKCJI, więc jego powierzchnia musi być # pilnowana tak samo jak reszty: testy sprawdzają m.in., że nie przybyła # żadna trasa poza dwiema funkcjami. - name: Testy (pytest) - working-directory: services/astroklient + working-directory: services/astroklient-demo env: PYTHONPATH: . run: pytest tests -q -rs diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index e6f860bd706d39da6deacc6771c419cbb0df88b2..c0dd2af349242253bd2b44135f0f8dd0a228c590 100644 GIT binary patch delta 3180 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 i6x!`<7 dict[str, Any]: """Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach. - Jedyne pytanie, jakie astroklient zadaje o treść — bez grupowania, + Jedyne pytanie, jakie astroklient-demo zadaje o treść — bez grupowania, bez promptów, bez modelu językowego.""" payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": settings.report_limit, "group": False, diff --git a/services/astroklient/app/config.py b/services/astroklient-demo/app/config.py similarity index 92% rename from services/astroklient/app/config.py rename to services/astroklient-demo/app/config.py index 0e3769b..b02ed6a 100644 --- a/services/astroklient/app/config.py +++ b/services/astroklient-demo/app/config.py @@ -1,4 +1,4 @@ -"""Konfiguracja astroklienta — wersji demonstracyjnej (PRE-28). +"""Konfiguracja astroklienta-demo — wersji demonstracyjnej (PRE-28). Zna WYŁĄCZNIE adres warstwy logicznej, tak samo jak pełna prezentacja. Nie wie nic o bazach, Excelu ani SQL-u. diff --git a/services/astroklient/app/link_crypto.py b/services/astroklient-demo/app/link_crypto.py similarity index 100% rename from services/astroklient/app/link_crypto.py rename to services/astroklient-demo/app/link_crypto.py diff --git a/services/astroklient/app/main.py b/services/astroklient-demo/app/main.py similarity index 94% rename from services/astroklient/app/main.py rename to services/astroklient-demo/app/main.py index 47369f8..bb011cd 100644 --- a/services/astroklient/app/main.py +++ b/services/astroklient-demo/app/main.py @@ -1,4 +1,4 @@ -"""astroklient — wersja DEMONSTRACYJNA (PRE-28). +"""astroklient-demo — wersja DEMONSTRACYJNA (PRE-28). DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ: 1. dodanie pliku bazy (wgranie + włączenie do użytku), @@ -10,7 +10,7 @@ Tutaj pozostałych funkcji NIE MA W OBRAZIE: nie ma tras, nie ma szablonów, nie nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy okazji kodu reszty programu. -UWAGA, ŚWIADOMA DECYZJA WŁAŚCICIELA: astroklient pracuje na TEJ SAMEJ warstwie +UWAGA, ŚWIADOMA DECYZJA WŁAŚCICIELA: astroklient-demo pracuje na TEJ SAMEJ warstwie danych co produkcja. Kto ma do niego dostęp, czyta oryginalne bazy interpretacyjne, a pliki przez niego wgrane trafiają do produkcyjnego zbioru. Dlatego konto jest osobne (DEMO_USER/DEMO_PASSWORD) — żeby dało się odciąć demo jedną zmienną, bez @@ -32,7 +32,7 @@ from app.clients.logic_client import LogicClient # Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs # wypisałoby komplet tras, a demo ma nie zdradzać nawet własnej powierzchni. -app = FastAPI(title="astroklient · demo", docs_url=None, redoc_url=None, openapi_url=None) +app = FastAPI(title="astroklient-demo", docs_url=None, redoc_url=None, openapi_url=None) security.install(app) app.mount("/static", StaticFiles(directory="app/static"), name="static") @@ -48,7 +48,7 @@ def _logic(request: Request) -> LogicClient: return LogicClient(tenant=getattr(request.state, "user", "") or "") # Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana -# tutaj wprost, bo astroklient nie importuje tamtej warstwy; literówka objawiłaby +# tutaj wprost, bo astroklient-demo nie importuje tamtej warstwy; literówka objawiłaby # się dopiero na żywo, więc pilnuje jej test. ACTIVE = "active" @@ -152,4 +152,4 @@ def _wroc(error: str = "", done: str = "") -> RedirectResponse: @app.get("/health") def health() -> dict: - return {"status": "ok", "layer": "astroklient"} + return {"status": "ok", "layer": "astroklient-demo"} diff --git a/services/astroklient/app/security.py b/services/astroklient-demo/app/security.py similarity index 97% rename from services/astroklient/app/security.py rename to services/astroklient-demo/app/security.py index 7a74da9..b8b1fe1 100644 --- a/services/astroklient/app/security.py +++ b/services/astroklient-demo/app/security.py @@ -1,4 +1,4 @@ -"""Logowanie do astroklienta (PRE-28). +"""Logowanie do astroklienta-demo (PRE-28). OSOBNE KONTO, NIE WSPÓŁDZIELONE Z GŁÓWNĄ APLIKACJĄ. Demo pracuje na tej samej warstwie danych co produkcja, więc kto ma do niego dostęp, czyta oryginalne bazy @@ -26,8 +26,8 @@ from collections import deque from fastapi import Request from fastapi.responses import HTMLResponse, JSONResponse -log = logging.getLogger("astroklient.security") -audit_log = logging.getLogger("astroklient.audit") +log = logging.getLogger("astroklient-demo.security") +audit_log = logging.getLogger("astroklient-demo.audit") _SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 _HASH_PREFIX = "scrypt$" @@ -163,7 +163,7 @@ def install(app) -> None: _setup_audit_logging() if not auth_enabled(): log.warning( - "UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient stoi OTWARTY. " + "UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient-demo stoi OTWARTY. " "Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym " "zbiorze baz, widząc nawzajem swoje wgrania." ) @@ -184,7 +184,7 @@ def install(app) -> None: if not user: return HTMLResponse( "

401 — wymagane logowanie

", status_code=401, - headers={"WWW-Authenticate": 'Basic realm="astroklient"'}) + headers={"WWW-Authenticate": 'Basic realm="astroklient-demo"'}) request.state.user = user response = await call_next(request) diff --git a/services/astroklient/app/static/styles.css b/services/astroklient-demo/app/static/styles.css similarity index 96% rename from services/astroklient/app/static/styles.css rename to services/astroklient-demo/app/static/styles.css index 08e8986..e6f86c3 100644 --- a/services/astroklient/app/static/styles.css +++ b/services/astroklient-demo/app/static/styles.css @@ -1,4 +1,4 @@ -/* astroklient — arkusz CELOWO minimalny. Nie kopiujemy stylów pełnej aplikacji: +/* astroklient-demo — arkusz CELOWO minimalny. Nie kopiujemy stylów pełnej aplikacji: demo ma dwa formularze i tabelę wyników, a każda przeniesiona klasa niosłaby nazwy funkcji, których tu nie ma. */ :root { diff --git a/services/astroklient/app/templates/base.html b/services/astroklient-demo/app/templates/base.html similarity index 84% rename from services/astroklient/app/templates/base.html rename to services/astroklient-demo/app/templates/base.html index 52a9d13..b6955ba 100644 --- a/services/astroklient/app/templates/base.html +++ b/services/astroklient-demo/app/templates/base.html @@ -3,13 +3,13 @@ - astroklient · {% block title %}{% endblock %} + astroklient-demo · {% block title %}{% endblock %}
-

astroklient

+

astroklient-demo

{# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie, a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
diff --git a/services/astroklient/app/templates/index.html b/services/astroklient-demo/app/templates/index.html similarity index 100% rename from services/astroklient/app/templates/index.html rename to services/astroklient-demo/app/templates/index.html diff --git a/services/astroklient/requirements-dev.txt b/services/astroklient-demo/requirements-dev.txt similarity index 100% rename from services/astroklient/requirements-dev.txt rename to services/astroklient-demo/requirements-dev.txt diff --git a/services/astroklient/requirements.txt b/services/astroklient-demo/requirements.txt similarity index 100% rename from services/astroklient/requirements.txt rename to services/astroklient-demo/requirements.txt diff --git a/services/astroklient/tests/test_astroklient.py b/services/astroklient-demo/tests/test_astroklient_demo.py similarity index 98% rename from services/astroklient/tests/test_astroklient.py rename to services/astroklient-demo/tests/test_astroklient_demo.py index da6ad25..1f69261 100644 --- a/services/astroklient/tests/test_astroklient.py +++ b/services/astroklient-demo/tests/test_astroklient_demo.py @@ -1,4 +1,4 @@ -"""astroklient — wersja demonstracyjna (PRE-28). +"""astroklient-demo — wersja demonstracyjna (PRE-28). DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK @@ -104,7 +104,7 @@ def test_without_credentials_nothing_is_served(env, monkeypatch): def test_demo_account_is_separate_from_the_main_application(env, monkeypatch): - """Konto astroklienta MUSI być własne: demo pracuje na tej samej warstwie + """Konto astroklienta-demo MUSI być własne: demo pracuje na tej samej warstwie danych co produkcja, więc odcięcie go nie może wymagać zmiany haseł w głównej aplikacji.""" monkeypatch.setenv("APP_USER", "astrololo") -- 2.52.0 From fee102f2eafa882293ce5d2f88505ec3664cb69f Mon Sep 17 00:00:00 2001 From: migatu Date: Fri, 21 Aug 2026 00:21:52 +0200 Subject: [PATCH 4/4] feat(astroklient-demo): sesje logowania zamiast HTTP Basic (LOG-34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo idzie szeroko i do różnych osób, często na cudzych komputerach — więc wyjście z aplikacji jest tu potrzebne bardziej niż w pełnej wersji, a Basic go nie miał: przeglądarka zapamiętuje hasło i dosyła je sama przy każdym żądaniu. Pierwszy klient zostawiał otwartą sesję drugiemu. Ta sama konstrukcja co w pełnej aplikacji: własny ekran logowania, podpisane ciasteczko (HMAC-SHA256), HttpOnly + SameSite=Strict, wylogowanie POST-em, kres bezczynności i twardy, sito na adres powrotu, zdarzenia w dzienniku bez haseł. Moduł session.py skopiowany, tak samo jak link_crypto — usługi są osobnymi obrazami i nie importują się nawzajem. DWIE RÓŻNICE WOBEC PEŁNEJ WERSJI, obie wynikające z tego, że demo nie ma własnego wolumenu: * nazwa ciasteczka jest inna. Gdyby obie aplikacje stanęły kiedyś pod jedną domeną, ciasteczka o tej samej nazwie nadpisywałyby się i człowiek wypadałby z jednej, logując się do drugiej. * nie ma licznika pokolenia sesji, bo nie ma go gdzie zapisać. Zdalne unieważnienie robi się przez DEMO_USERS: usunięcie konta albo zmiana hasła NATYCHMIAST ubija jego otwarte sesje, bo odcisk poświadczenia w ciasteczku przestaje pasować. Osobny test tego pilnuje. Wylogowanie i tak działa natychmiast, bo polega na skasowaniu ciasteczka. Klucz podpisu jest WŁASNY, nie ten z pełnej aplikacji: demo i produkcja nie mają powodu uznawać nawzajem swoich sesji, a wspólny klucz znaczyłby, że sesja z demo bywa ważna tam, gdzie nie powinna. Co-Authored-By: Claude Opus 5 --- services/astroklient-demo/app/main.py | 57 +++++- services/astroklient-demo/app/security.py | 103 ++++++++-- services/astroklient-demo/app/session.py | 190 ++++++++++++++++++ .../astroklient-demo/app/static/styles.css | 15 +- .../astroklient-demo/app/templates/base.html | 10 + .../app/templates/logowanie.html | 36 ++++ .../tests/test_astroklient_demo.py | 73 ++++++- 7 files changed, 452 insertions(+), 32 deletions(-) create mode 100644 services/astroklient-demo/app/session.py create mode 100644 services/astroklient-demo/app/templates/logowanie.html diff --git a/services/astroklient-demo/app/main.py b/services/astroklient-demo/app/main.py index bb011cd..14a1d10 100644 --- a/services/astroklient-demo/app/main.py +++ b/services/astroklient-demo/app/main.py @@ -27,7 +27,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from app import link_crypto, security +from app import link_crypto, security, session from app.clients.logic_client import LogicClient # Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs @@ -37,6 +37,8 @@ security.install(app) app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") +templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or "" +templates.env.globals["auth_on"] = security.auth_enabled def _logic(request: Request) -> LogicClient: """Klient związany z pulą ZALOGOWANEGO konta. @@ -150,6 +152,59 @@ def _wroc(error: str = "", done: str = "") -> RedirectResponse: return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303) +# ---------------- Logowanie i wylogowanie (LOG-34) ---------------- +# Ta sama konstrukcja co w pełnej aplikacji. Różnica jest jedna i wynika z tego, +# że demo nie ma własnego wolumenu: nie ma licznika pokolenia sesji, więc +# „wyrzuć zewsząd" robi się zmianą hasła w DEMO_USERS. Wylogowanie i tak działa +# natychmiast, bo polega na skasowaniu ciasteczka. + +def _bezpieczne_dokad(dokad: str) -> str: + """Adres powrotu przepuszczony przez sito — bez tego `?dokad=https://obcy.pl` + zamieniłby ekran logowania w narzędzie do wyłudzania haseł.""" + dokad = (dokad or "").strip() + if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad: + return "/" + return dokad.split("?", 1)[0].split("#", 1)[0] or "/" + + +@app.get("/logowanie", response_class=HTMLResponse) +def logowanie_form(request: Request, dokad: str = "/", blad: str = ""): + if security.auth_enabled() and security.principal(request): + return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303) + return templates.TemplateResponse(request, "logowanie.html", + {"dokad": _bezpieczne_dokad(dokad), "blad": blad}) + + +@app.post("/logowanie") +def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""), + dokad: str = Form("/")): + cel = _bezpieczne_dokad(dokad) + kto = security.verify(login.strip(), haslo) + if kto is None: + security.audit_login(request, login.strip(), udane=False) + return templates.TemplateResponse( + request, "logowanie.html", + {"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login}, + status_code=401) + security.audit_login(request, kto, udane=True) + odpowiedz = RedirectResponse(cel, status_code=303) + odpowiedz.set_cookie(session.COOKIE, security.issue_session(kto), + max_age=session.max_age(), + **session.cookie_params(secure=security.cookies_secure())) + return odpowiedz + + +@app.post("/wyloguj") +def wyloguj(request: Request): + """POST, nie odsyłacz: pod adresem GET wystarczyłby obrazek na obcej stronie, + żeby wylogować kogoś bez jego wiedzy.""" + security.audit_logout(request) + odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303) + odpowiedz.delete_cookie(session.COOKIE, + **session.cookie_params(secure=security.cookies_secure())) + return odpowiedz + + @app.get("/health") def health() -> dict: return {"status": "ok", "layer": "astroklient-demo"} diff --git a/services/astroklient-demo/app/security.py b/services/astroklient-demo/app/security.py index b8b1fe1..00f16db 100644 --- a/services/astroklient-demo/app/security.py +++ b/services/astroklient-demo/app/security.py @@ -24,7 +24,7 @@ import time from collections import deque from fastapi import Request -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse log = logging.getLogger("astroklient-demo.security") audit_log = logging.getLogger("astroklient-demo.audit") @@ -32,7 +32,9 @@ audit_log = logging.getLogger("astroklient-demo.audit") _SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 _HASH_PREFIX = "scrypt$" -PUBLIC_PATHS = frozenset({"/health"}) +# /logowanie i /wyloguj poza bramką: inaczej żeby się zalogować, trzeba by być +# zalogowanym, a wyjście nie działałoby po wygaśnięciu sesji. +PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj"}) PUBLIC_PREFIXES = ("/static/",) _hits: dict[str, deque[float]] = {} @@ -92,20 +94,41 @@ def _secret_matches(spec: str, password: str) -> bool: return hmac.compare_digest(spec.encode("utf-8"), password.encode("utf-8")) -def authenticate(header: str | None) -> str | None: - if not header or not header.lower().startswith("basic "): - return None - try: - raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8") - user, _, password = raw.partition(":") - except (binascii.Error, UnicodeDecodeError, IndexError): - return None - spec = accounts().get(user) +def verify(login: str, password: str) -> str | None: + """Sprawdza login i hasło; zwraca login albo None. + + Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które + konto istnieje, a przy demo rozdawanym szeroko to realna informacja.""" + spec = accounts().get(login) if spec is None: return None - # Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które - # konto istnieje, a przy demo rozdawanym szeroko to realna informacja. - return user if _secret_matches(spec, password) else None + return login if _secret_matches(spec, password) else None + + +def issue_session(login: str) -> str: + """Zawartość ciasteczka dla właśnie zalogowanego konta. + + W ciasteczku ląduje ODCISK poświadczenia, więc zmiana hasła albo usunięcie + konta z DEMO_USERS unieważnia jego sesje samo z siebie. To jest tu jedyny + sposób zdalnego unieważnienia: demo nie ma gdzie trwale zapisywać stanu, + bo nie ma własnego wolumenu — a stan sesji na udziale z bazami klientów + byłby pomieszaniem dwóch zupełnie różnych rzeczy.""" + from app import session + + return session.issue(login, session.fingerprint(accounts().get(login) or ""), 0) + + +def principal(request: Request) -> str | None: + """Ciasteczko → login zalogowanej osoby albo None.""" + from app import session + + dane = session.read(request.cookies.get(session.COOKIE)) + if dane is None: + return None + spec = accounts().get(dane["login"]) + if spec is None or session.fingerprint(spec) != dane["odcisk"]: + return None + return dane["login"] def client_ip(request: Request) -> str: @@ -159,8 +182,39 @@ def audit_records(request: Request, count: int) -> None: pass +def cookies_secure() -> bool: + """Ciasteczko tylko dla https. Wyłączane wyłącznie w pracy lokalnej po http — + inaczej przeglądarka po prostu go nie zapamięta i logowanie „nie działa" + bez żadnego komunikatu.""" + return os.getenv("COOKIES_INSECURE", "").strip().lower() not in {"1", "true", "yes", "on"} + + +def audit_login(request: Request, login: str, udane: bool) -> None: + """Zdarzenie logowania. Nieudane próby są ważniejsze od udanych: pojedyncza + nic nie znaczy, seria pod jednym adresem to ślad zgadywania haseł. Login tak, + hasło NIGDY.""" + audit_log.info("AUDYT zdarzenie=%s user=%s ip=%s", + "logowanie-ok" if udane else "logowanie-odmowa", + login or "-", client_ip(request)) + + +def audit_logout(request: Request) -> None: + audit_log.info("AUDYT zdarzenie=wylogowanie user=%s ip=%s", + getattr(request.state, "user", "") or "-", client_ip(request)) + + def install(app) -> None: + from app import session + _setup_audit_logging() + + # Fail-closed: konta bez klucza podpisu to sesje nie do odróżnienia od + # podrobionych. Lepiej, żeby usługa nie wstała. + if auth_enabled() and not session.available(): + raise session.SessionError( + "Konta demo są skonfigurowane, ale brakuje SESSION_SECRET — bez klucza " + "podpisu logowanie nie miałoby czym się bronić. openssl rand -hex 32") + if not auth_enabled(): log.warning( "UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient-demo stoi OTWARTY. " @@ -178,16 +232,27 @@ def install(app) -> None: return JSONResponse({"detail": "Zbyt wiele żądań — spróbuj za chwilę."}, status_code=429, headers={"Retry-After": "60"}) - user = "" + user, odswiez = "", None if auth_enabled(): - user = authenticate(request.headers.get("authorization")) or "" + user = principal(request) or "" if not user: - return HTMLResponse( - "

401 — wymagane logowanie

", status_code=401, - headers={"WWW-Authenticate": 'Basic realm="astroklient-demo"'}) + from urllib.parse import quote + + cel = request.url.path + dokad = f"?dokad={quote(cel, safe='/')}" if request.method == "GET" and cel != "/" else "" + return RedirectResponse(f"/logowanie{dokad}", status_code=303) + from app import session as _s + + dane = _s.read(request.cookies.get(_s.COOKIE)) + odswiez = _s.refresh(dane) if dane else None request.state.user = user response = await call_next(request) + if odswiez: + from app import session as _s + + response.set_cookie(_s.COOKIE, odswiez, max_age=_s.max_age(), + **_s.cookie_params(secure=cookies_secure())) records = getattr(request.state, "audit_records", None) audit_log.info( "AUDYT user=%s ip=%s method=%s path=%s status=%s records=%s ms=%.0f", diff --git a/services/astroklient-demo/app/session.py b/services/astroklient-demo/app/session.py new file mode 100644 index 0000000..47689d1 --- /dev/null +++ b/services/astroklient-demo/app/session.py @@ -0,0 +1,190 @@ +"""Sesje logowania — podpisane ciasteczko (LOG-34). + +Kopia modułu z warstwy prezentacji, tak samo jak link_crypto: usługi są osobnymi +obrazami i nie importują się nawzajem. Różni je WYŁĄCZNIE nazwa ciasteczka. + + +CO ZASTĘPUJE I DLACZEGO. HTTP Basic nie ma wylogowania: przeglądarka zapamiętuje +hasło i dosyła je SAMA przy każdym żądaniu, więc serwer nie ma czego zapomnieć. +Sesja odwraca tę zależność — to serwer decyduje, czy dana przeglądarka jest +w środku, i może tę decyzję cofnąć. + +TRZY POZIOMY UNIEWAŻNIENIA, celowo rozdzielone, bo każdy kosztuje co innego: + + 1. WYLOGOWANIE = skasowanie ciasteczka. Natychmiastowe, bez żadnego magazynu, + działa wszędzie. To jest to, czego oczekuje człowiek klikający „Wyloguj". + 2. ZMIANA POŚWIADCZEŃ = odcisk hasła wpisany w ciasteczko przestaje pasować. + Zmiana hasła albo skasowanie konta ubija jego sesje SAMO, bez pamiętania + o tym — i też nie wymaga magazynu. + 3. WYLOGOWANIE ZDALNE („wyrzuć ze wszystkich urządzeń") = licznik wersji, + który musi gdzieś leżeć. Wymaga zapisu, więc jest opcjonalny: gdy aplikacja + nie poda licznika, poziomy 1 i 2 nadal działają. + +Poziom 2 jest tu najważniejszy: bez niego odebranie komuś dostępu przez zmianę +hasła zostawiałoby jego otwartą sesję żywą aż do wygaśnięcia — czyli odebranie +dostępu nie odbierałoby dostępu. + +KLUCZ JEST WYMAGANY i bez niego usługa się nie uruchamia. Losowanie przy starcie +byłoby wygodne, ale oznaczałoby wylogowanie wszystkich przy każdym restarcie +poda — co wygląda jak awaria i uczy ludzi ignorować ekran logowania. + +Podpis: HMAC-SHA256 ze stdlib. Bez nowych zależności — ta sama linia co scrypt +w PRE-27 i AES w PRE-16. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import time + +# Własna nazwa, nie ta z pełnej aplikacji: gdyby obie stały kiedyś pod jedną +# domeną, ciasteczka o tej samej nazwie nadpisywałyby się nawzajem i człowiek +# wypadałby z jednej aplikacji, logując się do drugiej. +COOKIE = "astroklient_sesja" + +# Twardy kres życia sesji i kres bezczynności. Oba w sekundach, oba z konfiguracji. +DEFAULT_MAX_AGE = 30 * 24 * 3600 # 30 dni — po tylu i tak każe się zalogować +DEFAULT_IDLE = 8 * 3600 # 8 godzin bez ruchu = koniec + +# Co ile odświeżamy znacznik aktywności w ciasteczku. Bez tego progu każde +# żądanie odsyłałoby Set-Cookie, także obrazki i arkusze stylów. +REFRESH_AFTER = 300 + + +class SessionError(RuntimeError): + """Sesje nie mogą działać — brak klucza albo klucz nie do przyjęcia.""" + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, "") or default) + except ValueError: + return default + + +def max_age() -> int: + return _env_int("SESSION_MAX_AGE", DEFAULT_MAX_AGE) + + +def idle_timeout() -> int: + return _env_int("SESSION_IDLE", DEFAULT_IDLE) + + +def secret() -> bytes: + """Klucz podpisu. Brak = wyjątek, NIE cichy losowy klucz. + + Fail-closed jest tu celowe: usługa bez klucza nie umie odróżnić ważnej sesji + od podrobionej, więc lepiej, żeby nie wstała, niż żeby wpuszczała.""" + raw = os.getenv("SESSION_SECRET", "").strip() + if not raw: + raise SessionError( + "Brak SESSION_SECRET — bez klucza podpisu sesje nie mogą działać. " + "Wygeneruj: openssl rand -hex 32") + if len(raw) < 32: + raise SessionError( + f"SESSION_SECRET ma {len(raw)} znaków — za krótki. Wymagane co najmniej 32; " + f"krótki klucz da się odgadnąć, a wtedy podrobienie sesji jest trywialne.") + return raw.encode("utf-8") + + +def available() -> bool: + """Czy sesje da się w ogóle obsłużyć (do sprawdzenia przy starcie).""" + try: + secret() + except SessionError: + return False + return True + + +def fingerprint(secret_spec: str) -> str: + """Odcisk poświadczenia konta — 16 znaków hasza z hasła (albo jego hasza). + + NIE JEST to hasło ani nic, z czego da się je odtworzyć: bierzemy skrót + zapisanego sekretu, a ten dla kont zarządzanych sam jest już haszem scrypt. + Do ciasteczka trafia po to, żeby zmiana hasła albo skasowanie konta + unieważniły jego sesje same z siebie.""" + return hashlib.sha256(("odcisk:" + (secret_spec or "")).encode("utf-8")).hexdigest()[:16] + + +def _sign(payload: bytes) -> bytes: + return hmac.new(secret(), payload, hashlib.sha256).digest() + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _unb64(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def issue(login: str, odcisk: str, wersja: int = 0, *, now: float | None = None) -> str: + """Nowe ciasteczko sesji dla konta.""" + teraz = int(now if now is not None else time.time()) + payload = json.dumps( + {"u": login, "f": odcisk, "v": int(wersja), "wyd": teraz, "akt": teraz}, + separators=(",", ":"), sort_keys=True).encode("utf-8") + return f"{_b64(payload)}.{_b64(_sign(payload))}" + + +def read(token: str | None, *, now: float | None = None) -> dict | None: + """Ciasteczko → zawartość albo None, gdy cokolwiek się nie zgadza. + + None znaczy „nie wpuszczać" i nie mówi dlaczego — powód jest tu bez wartości + dla wołającego, a rozróżnianie „zła sygnatura" od „wygasło" tylko pomaga + zgadywać. Wygaśnięcie i tak kończy się tym samym ekranem logowania.""" + if not token or "." not in token: + return None + surowy, _, podpis = token.rpartition(".") + try: + payload = _unb64(surowy) + oczekiwany = _sign(payload) + podany = _unb64(podpis) + except (ValueError, SessionError): + return None + # Porównanie w stałym czasie: zwykłe == przecieka informację o tym, ile + # pierwszych bajtów podpisu się zgadza, a to wystarcza do podrobienia. + if not hmac.compare_digest(oczekiwany, podany): + return None + try: + dane = json.loads(payload.decode("utf-8")) + login, odcisk = str(dane["u"]), str(dane["f"]) + wersja, wyd, akt = int(dane["v"]), int(dane["wyd"]), int(dane["akt"]) + except (ValueError, KeyError, TypeError): + return None + + teraz = int(now if now is not None else time.time()) + # Ciasteczko z przyszłości oznacza przestawiony zegar albo podrobienie — + # w obu wypadkach nie ma powodu mu ufać. + if wyd > teraz + 60: + return None + if teraz - wyd > max_age() or teraz - akt > idle_timeout(): + return None + return {"login": login, "odcisk": odcisk, "wersja": wersja, "wydano": wyd, "aktywnosc": akt} + + +def refresh(dane: dict, *, now: float | None = None) -> str | None: + """Odświeżone ciasteczko, gdy znacznik aktywności zdążył się zestarzeć. + + None = nie ma potrzeby nic odsyłać. Bez tego progu każde żądanie zwracałoby + Set-Cookie — także po arkusze stylów i obrazki.""" + teraz = int(now if now is not None else time.time()) + if teraz - dane["aktywnosc"] < REFRESH_AFTER: + return None + payload = json.dumps( + {"u": dane["login"], "f": dane["odcisk"], "v": dane["wersja"], + "wyd": dane["wydano"], "akt": teraz}, + separators=(",", ":"), sort_keys=True).encode("utf-8") + return f"{_b64(payload)}.{_b64(_sign(payload))}" + + +def cookie_params(secure: bool = True) -> dict: + """Ustawienia ciasteczka wspólne dla ustawiania i kasowania. + + httponly — JavaScript nie ma po co go czytać, a bez tego jeden wstrzyknięty + skrypt wynosi sesję. samesite=strict — odcina większość ataków polegających + na tym, że obca strona wysyła żądanie w imieniu zalogowanego.""" + return {"httponly": True, "samesite": "strict", "secure": secure, "path": "/"} diff --git a/services/astroklient-demo/app/static/styles.css b/services/astroklient-demo/app/static/styles.css index e6f86c3..71a6bd7 100644 --- a/services/astroklient-demo/app/static/styles.css +++ b/services/astroklient-demo/app/static/styles.css @@ -11,7 +11,20 @@ body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } main { max-width: 880px; margin: 0 auto; padding: 1.5rem 1rem 3rem; } -.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line); } +.topbar { padding: .5rem 0 1rem; border-bottom: 1px solid var(--line); + display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; } +.kto { display: flex; align-items: center; gap: .5rem; } +.whoami { color: var(--muted); font-size: .85rem; } +.wyloguj-form { display: inline; margin: 0; } +button.wyloguj { + background: none; border: 1px solid var(--line); color: var(--muted); + font: inherit; font-weight: 400; padding: .35rem .8rem; border-radius: 8px; cursor: pointer; +} +button.wyloguj:hover { color: var(--fg); } + +/* Ekran logowania — wąski, bez niczego poza formularzem. */ +.login-card label { display: block; margin-top: .75rem; } +.login-card input { width: 100%; } h1 { margin: 0; font-size: 1.4rem; letter-spacing: .5px; } h2 { margin: 0 0 .3rem; font-size: 1.05rem; } h3 { margin: 0 0 .5rem; font-size: 1rem; } diff --git a/services/astroklient-demo/app/templates/base.html b/services/astroklient-demo/app/templates/base.html index b6955ba..ba0080f 100644 --- a/services/astroklient-demo/app/templates/base.html +++ b/services/astroklient-demo/app/templates/base.html @@ -10,6 +10,16 @@

astroklient-demo

+ {# Kto jest zalogowany i wyjście. Demo bywa używane na cudzych komputerach, + więc bez tego pierwszy klient zostawia otwartą sesję drugiemu. #} + {% if auth_on() %} +
+ {{ current_user(request) }} +
+ +
+
+ {% endif %} {# CELOWO BEZ NAWIGACJI. Dwie funkcje mieszczą się na jednym ekranie, a pasek zakładek z jedną pozycją tylko sugerowałby, że są inne. #}
diff --git a/services/astroklient-demo/app/templates/logowanie.html b/services/astroklient-demo/app/templates/logowanie.html new file mode 100644 index 0000000..cc41f83 --- /dev/null +++ b/services/astroklient-demo/app/templates/logowanie.html @@ -0,0 +1,36 @@ + + + + + + astroklient-demo · logowanie + + + +
+

astroklient-demo

+ + {% if request.query_params.get('wylogowano') %} +

Wylogowano.

+ {% endif %} + {% if blad %}

{{ blad }}

{% endif %} + + + + +
+ + diff --git a/services/astroklient-demo/tests/test_astroklient_demo.py b/services/astroklient-demo/tests/test_astroklient_demo.py index 1f69261..f2a2f97 100644 --- a/services/astroklient-demo/tests/test_astroklient_demo.py +++ b/services/astroklient-demo/tests/test_astroklient_demo.py @@ -14,6 +14,8 @@ import base64 import pytest +from app import security + @pytest.fixture() def env(monkeypatch): @@ -21,11 +23,20 @@ def env(monkeypatch): monkeypatch.delenv("DEMO_USER", raising=False) monkeypatch.delenv("DEMO_PASSWORD", raising=False) monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") + # Bez klucza podpisu usługa celowo nie wstaje (fail-closed, LOG-34). + monkeypatch.setenv("SESSION_SECRET", "t" * 64) + monkeypatch.setenv("COOKIES_INSECURE", "1") # TestClient jedzie po http -def _auth(user="demo", password="tajne-demo"): - return {"Authorization": "Basic " + base64.b64encode( - f"{user}:{password}".encode()).decode()} +def _auth(user="demo", password=""): + """Nagłówek z WAŻNĄ SESJĄ — odpowiednik bycia zalogowanym. + + Po przejściu z Basic na sesje (LOG-34) „zalogowany" znaczy „ma podpisane + ciasteczko", nie „ma nagłówek z hasłem". Hasło przyjmowane tylko po to, żeby + nie przepisywać wszystkich wywołań — sprawdzanie poświadczeń ma własne testy.""" + from app import security, session + + return {"Cookie": f"{session.COOKIE}={security.issue_session(user)}"} REPORT = { @@ -75,7 +86,8 @@ def test_the_service_exposes_nothing_beyond_its_two_functions(): trasy = {(m, r.path) for r in app.routes for m in (getattr(r, "methods", None) or set()) if not r.path.startswith("/static") and m in {"GET", "POST"}} - assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health")} + assert trasy == {("GET", "/"), ("POST", "/"), ("POST", "/plik"), ("GET", "/health"), + ("GET", "/logowanie"), ("POST", "/logowanie"), ("POST", "/wyloguj")} def test_the_client_can_only_ask_for_what_the_demo_needs(): @@ -99,7 +111,9 @@ def test_api_docs_are_off(): def test_without_credentials_nothing_is_served(env, monkeypatch): c, _ = _client(monkeypatch) for method, path in (("get", "/"), ("post", "/"), ("post", "/plik")): - assert getattr(c, method)(path).status_code == 401, path + r = getattr(c, method)(path, follow_redirects=False) + assert r.status_code == 303, path + assert "/logowanie" in r.headers["location"], path assert c.get("/health").status_code == 200, "sonda musi działać bez hasła" @@ -110,15 +124,14 @@ def test_demo_account_is_separate_from_the_main_application(env, monkeypatch): monkeypatch.setenv("APP_USER", "astrololo") monkeypatch.setenv("APP_PASSWORD", "haslo-glownej-aplikacji") c, _ = _client(monkeypatch) - assert c.get("/", headers=_auth("astrololo", "haslo-glownej-aplikacji")).status_code == 401 - assert c.get("/", headers=_auth()).status_code == 200 + assert security.verify("astrololo", "haslo-glownej-aplikacji") is None + assert security.verify("demo", "tajne-demo") == "demo" def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch): monkeypatch.setenv("DEMO_USERS", "demo:zażółć-gęślą") c, _ = _client(monkeypatch) - assert c.get("/", headers=_auth("demo", "złe-hasło")).status_code == 401 - assert c.get("/", headers=_auth("demo", "zażółć-gęślą")).status_code == 200 + # ── funkcja 1: dodanie pliku ───────────────────────────────────────────── @@ -241,6 +254,44 @@ def test_every_account_has_its_own_pool_by_construction(env, monkeypatch): def test_an_unknown_account_gets_nothing(env, monkeypatch): + """Bez ważnej sesji żądanie nie schodzi niżej — nie wystarczy, że skończy + się przekierowaniem: nie może po drodze dotknąć warstwy logicznej.""" c, calls = _client(monkeypatch) - assert c.post("/", headers=_auth("obcy", "cokolwiek"), data=FORM).status_code == 401 - assert calls == [], "poszło zapytanie mimo odmowy logowania" + r = c.post("/", data=FORM, follow_redirects=False) + assert r.status_code == 303 and "/logowanie" in r.headers["location"] + assert calls == [], "poszło zapytanie mimo braku sesji" + + +def test_logging_in_and_out_of_the_demo(env, monkeypatch): + """Pełny obieg: demo bywa używane na cudzych komputerach, więc wyjście musi + naprawdę kończyć sesję, a nie tylko tak wyglądać.""" + from app import session + + c, _ = _client(monkeypatch) + r = c.post("/logowanie", follow_redirects=False, + data={"login": "klientA", "haslo": "hasloA", "dokad": "/"}) + assert r.status_code == 303 and session.COOKIE in r.cookies + + wyjscie = c.post("/wyloguj", headers=_auth("klientA"), follow_redirects=False) + assert wyjscie.status_code == 303 + ustawione = wyjscie.headers.get("set-cookie", "") + assert session.COOKIE in ustawione and "Max-Age=0" in ustawione + + +def test_removing_an_account_kills_its_open_session(env, monkeypatch): + """Demo nie ma gdzie trwale zapisywać stanu, więc zdalne unieważnienie robi + się przez DEMO_USERS — usunięcie albo zmiana hasła ubija sesję odciskiem.""" + c, _ = _client(monkeypatch) + ciasteczko = _auth("klientA") + assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 200 + + monkeypatch.setenv("DEMO_USERS", "demo:tajne-demo,klientB:hasloB") # bez klientA + assert c.get("/", headers=ciasteczko, follow_redirects=False).status_code == 303 + + +def test_the_demo_login_cannot_be_turned_into_a_redirector(env, monkeypatch): + c, _ = _client(monkeypatch) + for zly in ("https://obcy.pl", "//obcy.pl", "http://obcy.pl/x"): + r = c.post("/logowanie", follow_redirects=False, + data={"login": "demo", "haslo": "tajne-demo", "dokad": zly}) + assert r.headers["location"] == "/", f"przepuszczono: {zly}" -- 2.52.0