From 2d7775f9b3430dfa0284b421cfac3326827b0597 Mon Sep 17 00:00:00 2001 From: migatu Date: Mon, 17 Aug 2026 19:00:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(astroklient):=20pule=20plik=C3=B3w=20per?= =?UTF-8?q?=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 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 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, )