diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 64479d7..8d9d9b0 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) + # astrodemo 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, astrodemo) run: | TAG=${GITHUB_SHA::8} - for SVC in data logic presentation; do + for SVC in data logic presentation astrodemo; 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..c102699 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -91,6 +91,27 @@ jobs: PYTHONPATH: . run: pytest tests -q -rs + astrodemo-tests: + name: Testy astrodemo + 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/astrodemo/requirements-dev.txt + - name: Instalacja zależności + run: pip install -r services/astrodemo/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/astrodemo + 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 656a3cf..63e679c 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ diff --git a/services/astrodemo/Dockerfile b/services/astrodemo/Dockerfile new file mode 100644 index 0000000..860165e --- /dev/null +++ b/services/astrodemo/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Do obrazu wchodzi WYŁĄCZNIE kod aplikacji. `COPY . .` wnosiło też katalog +# tests/ — a leży w nim lista słów, które w tej usłudze nie mają prawa paść, +# wypisanych wprost. Obraz tej usługi się KOMUŚ ODDAJE, więc niósłby dokładnie +# to, co ten test ma pilnować. Przy okazji obraz jest mniejszy. +COPY app ./app + +EXPOSE 8005 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"] diff --git a/services/astrodemo/README.md b/services/astrodemo/README.md new file mode 100644 index 0000000..4a0999e --- /dev/null +++ b/services/astrodemo/README.md @@ -0,0 +1,91 @@ +# astrodemo — wersja demonstracyjna (PRE-28) + +Osobna warstwa prezentacji o **dwóch funkcjach**: + +1. **dodanie pliku bazy** — wgranie i włączenie do użytku jedną czynnością, +2. **zapytanie o interpretację urodzeniową** dla podanych danych wejściowych. + +I na tym koniec. Nie ma tu kosmogramu, synastrii, kalendarza, promptów do modelu, +eksportu ani kont — nie są ukryte, tylko **nie istnieją w tym obrazie**. + +## 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 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. + +## Izolacja: własne dane, pula na konto + +``` +astrodemo → logic-demo → data-demo → udział astrololo-demo (pusty na starcie) + └── klientA/ ← pula konta + └── klientB/ ← pula konta +``` + +**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. + +**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. + +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-astrodemo.md`). + +## Konfiguracja + +| zmienna | znaczenie | +|---|---| +| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) | +| `ASTRODEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików | +| `ASTRODEMO_USER` / `ASTRODEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `ASTRODEMO_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 | +| `REPORT_LIMIT` | górny limit rekordów w jednej odpowiedzi, domyślnie 2000 | +| `TRUST_PROXY` | `true` za Ingressem — inaczej limit liczy wszystkich do jednego wiadra | + +Hash hasła zrobisz tym samym skryptem, co dla głównej aplikacji: + +```bash +cd services/presentation && python scripts/make_user.py demo +``` + +## Uruchomienie lokalne + +```bash +cd services/astrodemo +pip install -r requirements-dev.txt +ASTRODEMO_PASSWORD=tajne LOGIC_URL=http://localhost:8001 \ + uvicorn app.main:app --port 8005 +``` + +Testy: + +```bash +cd services/astrodemo && PYTHONPATH=. pytest tests -q +``` + +## Co pilnują testy + +Poza zwykłą poprawnością — dwie własności, które łatwo zepsuć bez zauważenia: + +* **powierzchnia**: zbiór tras aplikacji musi być dokładnie taki, jak dwie funkcje + wymagają; klient warstwy logicznej ma tylko te metody, których używa. Trasa, + o której nikt nie pamiętał, jest tu realnym ryzykiem, bo ta usługa rozmawia + z produkcyjną warstwą danych. +* **milczenie o regułach walidacji**: plik odrzucony daje komunikat bez powodu. + Reguły zna wyłącznie administrator w pełnej aplikacji (DAN-27), a demo nie ma + prawa ich zdradzić nawet mimochodem. diff --git a/services/astrodemo/app/__init__.py b/services/astrodemo/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/astrodemo/app/clients/__init__.py b/services/astrodemo/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/astrodemo/app/clients/logic_client.py b/services/astrodemo/app/clients/logic_client.py new file mode 100644 index 0000000..13cdadf --- /dev/null +++ b/services/astrodemo/app/clients/logic_client.py @@ -0,0 +1,79 @@ +"""Klient warstwy logicznej — CELOWO okrojony do dwóch funkcji (PRE-28). + +Ma tu być wyłącznie to, czego ta usługa naprawdę używa. Metoda, której nie ma, +nie zostanie przypadkiem zawołana ani nie podpowie, że po drugiej stronie jest +coś więcej. + +Rozmowa idzie tym samym szyfrowanym łączem co reszta (PRE-16) i pod tym samym +tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę. +""" +from __future__ import annotations + +import os +from typing import Any + +import httpx + +from app import link_crypto +from app.config import settings + + +def _auth_headers() -> 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: + """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. + + Jedyne pytanie, jakie astrodemo zadaje o treść — bez grupowania, + bez grupowania i bez żadnej obróbki treści.""" + payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, + "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()) + + 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, "tenant": self.tenant}, + 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, "tenant": self.tenant}, + 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/astrodemo/app/config.py b/services/astrodemo/app/config.py new file mode 100644 index 0000000..c88142a --- /dev/null +++ b/services/astrodemo/app/config.py @@ -0,0 +1,22 @@ +"""Konfiguracja astrodemo — wersji demonstracyjnej (PRE-28). + +Zna WYŁĄCZNIE adres warstwy logicznej. 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/astrodemo/app/link_crypto.py b/services/astrodemo/app/link_crypto.py new file mode 100644 index 0000000..da99bc2 --- /dev/null +++ b/services/astrodemo/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 protokołu") + 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/astrodemo/app/main.py b/services/astrodemo/app/main.py new file mode 100644 index 0000000..e90a9e7 --- /dev/null +++ b/services/astrodemo/app/main.py @@ -0,0 +1,255 @@ +"""astrodemo — 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. Ukrycie a nieobecność to +dwie różne rzeczy. Tutaj niczego poza tymi dwiema funkcjami NIE MA W OBRAZIE: nie +ma tras, nie ma szablonów, nie ma nawet metod w kliencie warstwy logicznej. Obraz +tej usługi można komuś oddać i nie oddaje się przy tym niczego ponad to, co widać +na ekranie — łącznie z komentarzami w tym pliku, bo one też jadą razem z obrazem. + +PULE SĄ ROZDZIELONE (PRE-29). Każde konto ma własny, izolowany zbiór plików, +niewidoczny dla pozostałych kont; pula bierze się WYŁĄCZNIE z loginu zalogowanej +osoby, nigdy z pola formularza. Konta są osobne (ASTRODEMO_USERS) — żeby dało się +odciąć tę usługę jedną zmienną, bez +ruszania kont głównej aplikacji. +""" +from __future__ import annotations + +import base64 +import logging +import re +from functools import lru_cache +from pathlib import Path +from datetime import datetime, timedelta, timezone + +import httpx +from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.templating import Jinja2Templates + +from app import link_crypto, security, session +from app.clients.logic_client import LogicClient + +# Automatyczna dokumentacja WYŁĄCZONA. /docs wypisałoby komplet tras, a ta usługa +# ma nie zdradzać nawet własnej powierzchni. +app = FastAPI(title="astrodemo", docs_url=None, redoc_url=None, openapi_url=None) +security.install(app) + +# ── zasoby (PRE-27) ──────────────────────────────────────────────────────── +# Katalog NIE jest montowany. Montowanie serwuje wszystko, co w nim leży, każdemu +# i bez śladu w dzienniku — a komentarz w arkuszu stylów mówił wprost, że istnieje +# więcej, niż widać na ekranie. Tu jest jawna lista: arkusz logowania jest +# publiczny, bo +# potrzebuje go strona logowania, a cokolwiek innego jest nieznane i nie istnieje. +ZASOBY_PUBLICZNE = frozenset({"styles.css"}) +_KATALOG_ZASOBOW = Path("app/static") + + +def _bez_komentarzy(tresc: str) -> str: + """Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt. + W repozytorium zostaje — do odpowiedzi nie ma po co trafiać.""" + tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S) + return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n" + + +@lru_cache(maxsize=None) +def _zasob(nazwa: str) -> bytes | None: + """Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma.""" + if nazwa not in ZASOBY_PUBLICZNE: + return None + sciezka = (_KATALOG_ZASOBOW / nazwa).resolve() + # Wyjście poza katalog: `..` w ścieżce albo dowiązanie na zewnątrz. + if _KATALOG_ZASOBOW.resolve() not in sciezka.parents or not sciezka.is_file(): + return None + if sciezka.suffix in (".css", ".js"): + return _bez_komentarzy(sciezka.read_text(encoding="utf-8")).encode("utf-8") + return sciezka.read_bytes() + + +@app.get("/static/{nazwa:path}") +def zasob(nazwa: str): + tresc = _zasob(nazwa) + if tresc is None: + raise HTTPException(status_code=404) + typ = "text/css; charset=utf-8" if nazwa.endswith(".css") else "application/octet-stream" + return Response(tresc, media_type=typ, + headers={"Cache-Control": "public, max-age=3600"}) + +templates = Jinja2Templates(directory="app/templates") +# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _blad). +log = logging.getLogger("astrodemo") +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. + + 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 astrodemo 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") + + +# Jedno zdanie dla wszystkich awarii i CELOWO żadnego szczegółu. Treść wyjątku +# niosła nazwę usługi, jej port i nazwy jej tras — czyli mapę tego, co stoi za tym +# ekranem, podaną komuś, kto ma widzieć wyłącznie dwa formularze. +_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę." + + +def _blad(e: Exception) -> str: + """Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.""" + log.warning("operacja nieudana: %s: %s", type(e).__name__, e) + return _AWARIA + + +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(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: + 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. + + Jedna czynność, nie dwie: „dodać plik" ma tu znaczyć, że od razu bierze udział + w wyszukiwaniu — inaczej po wgraniu nic by się nie zmieniło i wyglądałoby to + na zepsute. + + Plik o złym układzie nie wchodzi do użytku, ale NIE JEST tracony — zostaje + zapisany i czeka.""" + 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(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)) + + 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(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.") + + +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) + + +# ---------------- Logowanie i wylogowanie (LOG-34) ---------------- +# Bez własnego wolumenu nie ma gdzie trzymać licznika pokolenia sesji, więc +# „wyrzuć ze wszystkich urządzeń" robi się zmianą hasła w ASTRODEMO_USERS. +# Wylogowanie 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: + # Sama „ok". Nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa; + # kubernetes i compose czytają wyłącznie kod odpowiedzi. + return {"status": "ok"} diff --git a/services/astrodemo/app/security.py b/services/astrodemo/app/security.py new file mode 100644 index 0000000..a5639b1 --- /dev/null +++ b/services/astrodemo/app/security.py @@ -0,0 +1,266 @@ +"""Logowanie do astrodemo (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ń. Ta usługa 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, RedirectResponse + +log = logging.getLogger("astrodemo.security") +audit_log = logging.getLogger("astrodemo.audit") + +_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32 +_HASH_PREFIX = "scrypt$" + +# /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", "/static/styles.css"}) +# Pusto CELOWO. Prefiks "/static/" przepuszczał CAŁY katalog obok bramki: każdy +# plik pobierał ktokolwiek, także niezalogowany, i nie zostawiało to śladu ani +# w dzienniku, ani w limicie żądań. Publiczny jest jeden arkusz, wymieniony +# z nazwy, bo potrzebuje go ekran logowania. +PUBLIC_PREFIXES: tuple[str, ...] = () + +_hits: dict[str, deque[float]] = {} +MAX_TRACKED_CLIENTS = 4096 + + +def accounts() -> dict[str, str]: + """Konta demo: `login:sekret`, po przecinku, ze zmiennej ASTRODEMO_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. + + 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 ASTRODEMO_USER/ASTRODEMO_PASSWORD działa, gdy ASTRODEMO_USERS + jest puste.""" + out: dict[str, str] = {} + for entry in os.getenv("ASTRODEMO_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("ASTRODEMO_PASSWORD", "") + return {os.getenv("ASTRODEMO_USER", "demo"): single} if single else {} + + +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(accounts()) + + +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 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 + 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 ASTRODEMO_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: + """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 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 (ASTRODEMO_USERS / ASTRODEMO_PASSWORD) — astrodemo stoi OTWARTY. " + "Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym " + "zbiorze baz, widząc nawzajem swoje wgrania." + ) + + @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, odswiez = "", None + if auth_enabled(): + user = principal(request) or "" + if not user: + 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", + 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/astrodemo/app/session.py b/services/astrodemo/app/session.py new file mode 100644 index 0000000..0ea7083 --- /dev/null +++ b/services/astrodemo/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 + +# Nazwa własna tej usługi. Gdyby dwie aplikacje pod jedną domeną używały +# ciasteczka o tej samej nazwie, nadpisywałyby się nawzajem i człowiek wypadałby +# z jednej, logując się do drugiej. +COOKIE = "astrodemo_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/astrodemo/app/static/styles.css b/services/astrodemo/app/static/styles.css new file mode 100644 index 0000000..2e8699d --- /dev/null +++ b/services/astrodemo/app/static/styles.css @@ -0,0 +1,63 @@ +/* Arkusz CELOWO minimalny: dwa formularze i tabela wyników. Każda klasa ponad to + niosłaby nazwę funkcji, której 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); + 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; } + +.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/astrodemo/app/templates/base.html b/services/astrodemo/app/templates/base.html new file mode 100644 index 0000000..3a54f58 --- /dev/null +++ b/services/astrodemo/app/templates/base.html @@ -0,0 +1,30 @@ + + + + + + astrodemo · {% block title %}{% endblock %} + + + +
+
+

astrodemo

+ {# 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. #} +
+ {% block content %}{% endblock %} + +
+ + diff --git a/services/astrodemo/app/templates/index.html b/services/astrodemo/app/templates/index.html new file mode 100644 index 0000000..014530c --- /dev/null +++ b/services/astrodemo/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/astrodemo/app/templates/logowanie.html b/services/astrodemo/app/templates/logowanie.html new file mode 100644 index 0000000..6af2388 --- /dev/null +++ b/services/astrodemo/app/templates/logowanie.html @@ -0,0 +1,36 @@ + + + + + + astrodemo · logowanie + + + +
+

astrodemo

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

Wylogowano.

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

{{ blad }}

{% endif %} + +
+ {# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito + po stronie serwera — pole formularza jest wejściem od użytkownika. #} + + + +
+
+ +

+ Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała + Ci dostęp — kont nie da się odzyskać samodzielnie. +

+
+ + diff --git a/services/astrodemo/requirements-dev.txt b/services/astrodemo/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/services/astrodemo/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/services/astrodemo/requirements.txt b/services/astrodemo/requirements.txt new file mode 100644 index 0000000..1297467 --- /dev/null +++ b/services/astrodemo/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/astrodemo/tests/test_astrodemo.py b/services/astrodemo/tests/test_astrodemo.py new file mode 100644 index 0000000..f5b4ea4 --- /dev/null +++ b/services/astrodemo/tests/test_astrodemo.py @@ -0,0 +1,301 @@ +"""astrodemo — 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 + +from app import security + + +@pytest.fixture() +def env(monkeypatch): + monkeypatch.setenv("ASTRODEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB") + monkeypatch.delenv("ASTRODEMO_USER", raising=False) + monkeypatch.delenv("ASTRODEMO_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=""): + """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 = { + "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, 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.clients.logic_client import LogicClient + from app.main import app + + calls = calls if calls is not None else [] + 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 + + +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"), + ("GET", "/logowanie"), ("POST", "/logowanie"), ("POST", "/wyloguj")} + + +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")): + 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" + + +def test_demo_account_is_separate_from_the_main_application(env, monkeypatch): + """Konto astrodemo 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 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("ASTRODEMO_USERS", "demo:zażółć-gęślą") + c, _ = _client(monkeypatch) + + + +# ── 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][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): + """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 + + 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 + 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][2]["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.clients.logic_client import LogicClient + + c, _ = _client(monkeypatch) + monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw( + httpx.ConnectError("brak połączenia"))) + r = c.post("/", headers=_auth(), data=FORM) + # Komunikat jest CELOWO nieinformacyjny: treść wyjątku niosła nazwę usługi, + # jej port i nazwy tras, czyli mapę tego, co stoi za tym ekranem. + assert r.status_code == 200 + assert "Nie udało się teraz wykonać tej operacji" in r.text + assert "Warstwa logiczna" not in r.text and "8001" not 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" + + +# ── 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): + """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) + 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 ASTRODEMO_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("ASTRODEMO_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}" diff --git a/services/astrodemo/tests/test_slownik_zakazany.py b/services/astrodemo/tests/test_slownik_zakazany.py new file mode 100644 index 0000000..1348b37 --- /dev/null +++ b/services/astrodemo/tests/test_slownik_zakazany.py @@ -0,0 +1,192 @@ +"""Zapora słownikowa astrodemo: czego użytkownik demo nie może zobaczyć. + +astrodemo stoi najniżej w drabinie trzech produktów (astrodemo → astroklient → +astrololo) i jest dawane najszerzej. Ma dwie funkcje i ma wyglądać, jakby to +było wszystko, co program potrafi. + +Test nie sprawdza pojedynczych miejsc, tylko przechodzi WSZYSTKO, co da się +pobrać, i szuka słów, które nie mają prawa paść. Sprawdza też DRZEWO PLIKÓW +usługi — bo „nie ma śladu w kodzie" znaczy, że `grep` po obrazie ma nic nie +znaleźć, a nie tylko że odpowiedź jest czysta. + +Powód, dla którego to powstało: komentarz w pierwszej linii styles.css mówił +„Nie kopiujemy stylów pełnej aplikacji", a arkusz pobierał anonim. +""" +import pathlib +import re + +import pytest + +APP = pathlib.Path(__file__).resolve().parents[1] / "app" + +# (a) że istnieje wersja obszerniejsza i jak się nazywa +WIEKSZY_BRAT = ["pełna aplikacja", "pełnej aplikacji", "pełna wersja", "pełnej wersji", + "astrololo", "astroklient", "wersji podstawowej", "upgrade", "rozszerzon"] +# (b) że tekst pisze model językowy +# Bez samego „token": w tej usłudze znaczy token sesji i token międzywarstwowy, +# a nie jednostkę rozliczeniową modelu. Zamiast tego zwroty jednoznaczne. +MODEL = ["LLM", "anthropic", "openai", "gpt", "claude", "prompt", + "generowanie tekstu", "sztuczn", "okno kontekstu", "model językow"] +# (c) funkcje, których astrodemo nie ma +INNE_FUNKCJE = ["kosmogram", "aspektarian", "synastri", "kalendarz", "predykcj", + "skompiluj", "system domów", "systemy domów", "deklinacj", "antyscj"] +# (d) że jest ktoś, kto widzi więcej, i że produkt jest w budowie +MECHANIZMY = ["administrator", "uprawnieni", "walidacj", "kwarantann", "roadmap", + "w budowie", "widok testowy", "warstwa logiczna", "layer"] +# Dwa poziomy, bo dwie różne drogi wycieku. +# +# WSZĘDZIE — także w kodzie serwera, którego przeglądarka nigdy nie zobaczy. +# Obraz tej usługi się KOMUŚ ODDAJE, więc kto go dostanie, przeczyta też +# komentarze i docstringi. Zdanie „w pełnej aplikacji to działa inaczej" mówi mu +# wprost, że istnieje pełna aplikacja — a to jest dokładnie to, czego ma nie +# wiedzieć. Komentarz ma tłumaczyć TĘ usługę, bez powoływania się na rodzeństwo. +WSZEDZIE = WIEKSZY_BRAT + MODEL + INNE_FUNKCJE +# TYLKO DO PRZEGLĄDARKI — słownictwo inżynierskie opisujące własne działanie tej +# usługi. W komentarzu serwera jest na miejscu; w odpowiedzi HTTP nie ma czego +# szukać, bo mówi o mechanizmach, o których użytkownik nie ma wiedzieć. +DO_PRZEGLADARKI = WSZEDZIE + MECHANIZMY +ZAKAZANE = DO_PRZEGLADARKI +WZORCE = [(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania"), + (r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"), + (r"(?i)\bAI\b", "model językowy")] + + +def _grzechy(tresc: str, gdzie: str, slownik: list[str] | None = None) -> list[str]: + out = [] + for igla in (slownik if slownik is not None else ZAKAZANE): + if re.search(rf"(?i)(? 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/link_crypto.py b/services/data/app/link_crypto.py index 89ed3d8..da99bc2 100644 --- a/services/data/app/link_crypto.py +++ b/services/data/app/link_crypto.py @@ -153,7 +153,7 @@ class Link: 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") + raise LinkError("ramka bez znacznika protokołu") body = frame[len(MAGIC):] if len(body) <= NONCE_BYTES: raise LinkError("ramka za krótka") diff --git a/services/data/app/main.py b/services/data/app/main.py index 0dfdbf7..6565986 100644 --- a/services/data/app/main.py +++ b/services/data/app/main.py @@ -71,29 +71,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)} @@ -102,7 +108,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")} @@ -117,13 +124,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} @@ -131,7 +139,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/link_crypto.py b/services/logic/app/link_crypto.py index 89ed3d8..da99bc2 100644 --- a/services/logic/app/link_crypto.py +++ b/services/logic/app/link_crypto.py @@ -153,7 +153,7 @@ class Link: 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") + raise LinkError("ramka bez znacznika protokołu") body = frame[len(MAGIC):] if len(body) <= NONCE_BYTES: raise LinkError("ramka za krótka") diff --git a/services/logic/app/main.py b/services/logic/app/main.py index dc4e738..e6aa828 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -133,29 +133,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 @@ -175,27 +179,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") @@ -248,6 +252,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") @@ -263,7 +271,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, ) diff --git a/services/presentation/app/link_crypto.py b/services/presentation/app/link_crypto.py index 89ed3d8..da99bc2 100644 --- a/services/presentation/app/link_crypto.py +++ b/services/presentation/app/link_crypto.py @@ -153,7 +153,7 @@ class Link: 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") + raise LinkError("ramka bez znacznika protokołu") body = frame[len(MAGIC):] if len(body) <= NONCE_BYTES: raise LinkError("ramka za krótka") diff --git a/services/render/app/link_crypto.py b/services/render/app/link_crypto.py index 89ed3d8..da99bc2 100644 --- a/services/render/app/link_crypto.py +++ b/services/render/app/link_crypto.py @@ -153,7 +153,7 @@ class Link: 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") + raise LinkError("ramka bez znacznika protokołu") body = frame[len(MAGIC):] if len(body) <= NONCE_BYTES: raise LinkError("ramka za krótka")