diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 3cfaf92..e6f860b 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ diff --git a/services/astroklient/README.md b/services/astroklient/README.md index 2d31742..d8445af 100644 --- a/services/astroklient/README.md +++ b/services/astroklient/README.md @@ -15,29 +15,42 @@ a nieobecność to dwie różne rzeczy**: tutaj nie ma tras, nie ma szablonów, nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy okazji kodu reszty programu. -## ⚠️ Demo pracuje na produkcyjnej warstwie danych +## Izolacja: własne dane, pula na konto -Świadoma decyzja właściciela. Konsekwencje, żeby nie było niespodzianek: +``` +astroklient → logic-demo → data-demo → udział astrololo-demo (pusty na starcie) + └── klientA/ ← pula konta + └── klientB/ ← pula konta +``` -* **kto ma dostęp do demo, czyta oryginalne bazy interpretacyjne** — czyli rdzeń - produktu, którego pilnują LOG-32, DAN-25 i PRE-27, -* **pliki wgrane przez demo trafiają do produkcyjnego zbioru baz** i od razu biorą - udział w wyszukiwaniu, także w pełnej aplikacji. +**Oryginalne bazy interpretacyjne są dla demo nieosiągalne.** Nie chodzi +o uprawnienia: demo ma własną warstwę danych, pracującą na osobnym udziale. +Osobna jest też warstwa logiczna, bo zna ona JEDEN adres warstwy danych — demo +korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy. -Dlatego konto astroklienta jest **osobne** (`DEMO_USER` / `DEMO_PASSWORD`): demo -odcina się jedną zmienną środowiskową, bez ruszania kont głównej aplikacji i bez -zmiany hasła komukolwiek. +**Każde konto ma własną pulę** plików: swój podkatalog na tym udziale. Konta nie +widzą swoich baz nawzajem ani w liście, ani w wynikach wyszukiwania. Dlatego demo +można rozdawać szeroko. -Jeśli demo ma trafić do kogoś spoza kręgu zaufania, właściwą odpowiedzią jest -osobna warstwa danych z pustym udziałem — nie jest to dziś zrobione. +Pula bierze się **z loginu zalogowanej osoby** i z niczego innego. Gdyby +przychodziła z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze +bazy — osobny test wysyła `tenant`, `user` i `login` w formularzu i sprawdza, że +nie mają na nią wpływu. + +Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne +w nazwie katalogu: `../..` czy ukośnik wyprowadziłyby zapytanie poza udział. + +**Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza +utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`, +`astrololo/README-astroklient.md`). ## Konfiguracja | zmienna | znaczenie | |---|---| | `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) | -| `DEMO_USER` | login (domyślnie `demo`) | -| `DEMO_PASSWORD` | hasło — jawne albo hash `scrypt$sól$hash`. **Puste = aplikacja stoi otwarta** i mówi o tym głośno przy starcie | +| `DEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików | +| `DEMO_USER` / `DEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `DEMO_USERS` jest puste. **Brak obu = aplikacja stoi otwarta** i mówi o tym głośno przy starcie | | `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług | | `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja | | `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza | diff --git a/services/astroklient/app/clients/logic_client.py b/services/astroklient/app/clients/logic_client.py index c09da2f..bc64945 100644 --- a/services/astroklient/app/clients/logic_client.py +++ b/services/astroklient/app/clients/logic_client.py @@ -32,8 +32,15 @@ def _link() -> 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, )