feat(astroklient): pule plików per konto i izolacja od produkcji (PRE-29)

Demo ma być rozdawane szeroko i różnym osobom, więc pierwsza wersja — jedno konto
na produkcyjnej warstwie danych — nie nadawała się do użycia: każdy dostawałby
dostęp do oryginalnych baz, a wgrania jednego klienta widzieliby wszyscy.

IZOLACJA OD PRODUKCJI. Warstwa danych i logiczna demo są osobne (manifesty w repo
deploy). Osobna musi być TEŻ LOGICZNA, bo zna ona jeden adres warstwy danych —
demo korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.

PULE PER KONTO w warstwie danych. Zapytanie i lista plików niosą nazwę puli;
puste = cały udział, czyli produkcja działa dokładnie jak dotąd i o pulach nic
nie wie. Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
w nazwie katalogu — „../..” albo ukośnik wyprowadziłyby zapytanie wprost do cudzych
baz, więc sito ZAMIENIA podejrzane znaki zamiast ufać, że nikt ich nie poda.

PULA MUSI BYĆ W KLUCZU CACHE ZAPYTAŃ. Bez tego wynik policzony dla jednego konta
trafiłby z cache do drugiego — cicha wymiana treści baz między klientami,
niewidoczna w logach i nie do wykrycia z zewnątrz. Osobny test tego pilnuje.

PULA WYNIKA Z LOGINU, nigdy z żądania. Klient warstwy logicznej jest budowany
per żądanie i związany z pulą zalogowanej osoby; gdyby nazwa przychodziła
z formularza, wystarczyłoby podstawić cudzy login. Test wysyła `tenant`, `user`
i `login` w polach formularza i sprawdza, że nie mają na nią wpływu.

Pulę wstrzykujemy w INSTANCJĘ klienta, nie w sygnatury metod. Argumentem trzeba
by ją przeprowadzić przez protokół DataSource i build_report — kod, który o kontach
nie ma prawa nic wiedzieć — a każde nowe wywołanie byłoby okazją, żeby o nią
zapomnieć i sięgnąć nie tam.

Konta demo to lista `login:sekret` (DEMO_USERS), bo jedno wspólne konto oznaczałoby
wspólną pulę. Format i skrypt haseł te same, co w głównej aplikacji.

Pula klienta to JEDEN KATALOG, więc przejście na pełną wersję nie oznacza utraty
wgrań — procedurę importu opisuje runbook w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:00:39 +02:00
parent e7b6d5013b
commit caf4fd80d1
12 changed files with 301 additions and 79 deletions
+26 -13
View File
@@ -15,29 +15,42 @@ a nieobecność to dwie różne rzeczy**: tutaj nie ma tras, nie ma szablonów,
nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając przy
okazji kodu reszty programu.
## ⚠️ Demo pracuje na produkcyjnej warstwie danych
## Izolacja: własne dane, pula na konto
Świadoma decyzja właściciela. Konsekwencje, żeby nie było niespodzianek:
```
astroklient → logic-demo → data-demo → udział astrololo-demo (pusty na starcie)
└── klientA/ ← pula konta
└── klientB/ ← pula konta
```
* **kto ma dostęp do demo, czyta oryginalne bazy interpretacyjne** — czyli rdzeń
produktu, którego pilnują LOG-32, DAN-25 i PRE-27,
* **pliki wgrane przez demo trafiają do produkcyjnego zbioru baz** i od razu biorą
udział w wyszukiwaniu, także w pełnej aplikacji.
**Oryginalne bazy interpretacyjne są dla demo nieosiągalne.** Nie chodzi
o uprawnienia: demo ma własną warstwę danych, pracującą na osobnym udziale.
Osobna jest też warstwa logiczna, bo zna ona JEDEN adres warstwy danych — demo
korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.
Dlatego konto astroklienta jest **osobne** (`DEMO_USER` / `DEMO_PASSWORD`): demo
odcina się jedną zmienną środowiskową, bez ruszania kont głównej aplikacji i bez
zmiany hasła komukolwiek.
**Każde konto ma własną pulę** plików: swój podkatalog na tym udziale. Konta nie
widzą swoich baz nawzajem ani w liście, ani w wynikach wyszukiwania. Dlatego demo
można rozdawać szeroko.
Jeśli demo ma trafić do kogoś spoza kręgu zaufania, właściwą odpowiedzią jest
osobna warstwa danych z pustym udziałem — nie jest to dziś zrobione.
Pula bierze się **z loginu zalogowanej osoby** i z niczego innego. Gdyby
przychodziła z żądania, wystarczyłoby podstawić cudzy login, żeby czytać cudze
bazy — osobny test wysyła `tenant`, `user` i `login` w formularzu i sprawdza, że
nie mają na nią wpływu.
Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
w nazwie katalogu: `../..` czy ukośnik wyprowadziłyby zapytanie poza udział.
**Pula klienta to jeden katalog**, więc przejście na pełną wersję nie oznacza
utraty wgrań — procedura importu jest w runbooku wdrożenia (repo `deploy`,
`astrololo/README-astroklient.md`).
## Konfiguracja
| zmienna | znaczenie |
|---|---|
| `LOGIC_URL` | adres warstwy logicznej (domyślnie `http://localhost:8001`) |
| `DEMO_USER` | login (domyślnie `demo`) |
| `DEMO_PASSWORD` | hasło — jawne albo hash `scrypt$sól$hash`. **Puste = aplikacja stoi otwarta** i mówi o tym głośno przy starcie |
| `DEMO_USERS` | konta jako `login:sekret`, po przecinku. Sekret jawny albo hash `scrypt$sól$hash`. Każdy login = osobna pula plików |
| `DEMO_USER` / `DEMO_PASSWORD` | pojedyncze konto — zgodność wstecz, działa gdy `DEMO_USERS` jest puste. **Brak obu = aplikacja stoi otwarta** i mówi o tym głośno przy starcie |
| `INTERNAL_TOKEN` | token międzywarstwowy (LOG-32) — ten sam co reszta usług |
| `LINK_KEY_PRESENTATION_LOGIC` | klucz szyfrowanego łącza (PRE-16) — ten sam co prezentacja |
| `RATE_LIMIT_PER_MIN` | limit żądań na adres, domyślnie 60; `0` wyłącza |
@@ -32,8 +32,15 @@ def _link() -> link_crypto.Link | None:
class LogicClient:
def __init__(self, base_url: str | None = None) -> None:
"""Rozmówca warstwy logicznej ZWIĄZANY Z PULĄ KONTA (PRE-29).
Pulę podaje się przy tworzeniu klienta, a bierze się ją WYŁĄCZNIE z loginu
zalogowanej osoby — nigdy z pola formularza. Gdyby przychodziła z żądania,
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
def __init__(self, tenant: str = "", base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/")
self.tenant = tenant or ""
def report(self, when_utc_iso: str, lat: float, lon: float) -> dict[str, Any]:
"""Interpretacja urodzeniowa: sygnifikatory z obliczeń szukane w bazach.
@@ -41,7 +48,8 @@ class LogicClient:
Jedyne pytanie, jakie astroklient zadaje o treść — bez grupowania,
bez promptów, bez modelu językowego."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon,
"limit": settings.report_limit, "group": False}
"limit": settings.report_limit, "group": False,
"tenant": self.tenant}
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}/chart/report",
payload=payload, headers=_auth_headers(), link=_link())
@@ -50,7 +58,8 @@ class LogicClient:
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
return link_crypto.call_json(
client, "POST", f"{self.base_url}/files/upload",
payload={"filename": filename, "content_b64": content_b64, "by": by},
payload={"filename": filename, "content_b64": content_b64,
"by": by, "tenant": self.tenant},
headers=_auth_headers(), link=_link())
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
@@ -60,7 +69,7 @@ class LogicClient:
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(
client, "POST", f"{self.base_url}/files/status",
payload={"path": path, "status": status, "by": by},
payload={"path": path, "status": status, "by": by, "tenant": self.tenant},
headers=_auth_headers(), link=_link())
def health(self) -> dict[str, Any]:
+11 -4
View File
@@ -38,7 +38,14 @@ security.install(app)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
def _logic(request: Request) -> LogicClient:
"""Klient związany z pulą ZALOGOWANEGO konta.
Budowany PER ŻĄDANIE, nie raz na moduł: pula musi wynikać z tego, kto się
zalogował, a nie z globalnego stanu procesu. Login bierzemy z request.state,
które ustawia warstwa logowania — nigdy z pola formularza, bo wtedy
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
return LogicClient(tenant=getattr(request.state, "user", "") or "")
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
# tutaj wprost, bo astroklient nie importuje tamtej warstwy; literówka objawiłaby
@@ -90,7 +97,7 @@ def interpretacja(request: Request, date: str = Form(...), time: str = Form(...)
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon)
ctx["result"] = _logic(request).report(when_utc_iso=iso_utc, lat=lat, lon=lon)
# Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17).
security.audit_records(request, _ile_rekordow(ctx["result"]))
except ValueError as e:
@@ -117,7 +124,7 @@ async def dodaj_plik(request: Request, upload: UploadFile = File(...)):
who = getattr(request.state, "user", "") or "demo"
try:
out = logic.files_upload(upload.filename or "baza.xlsx",
out = _logic(request).files_upload(upload.filename or "baza.xlsx",
base64.b64encode(raw).decode("ascii"), by=who)
except (httpx.HTTPError, link_crypto.LinkError) as e:
return _wroc(error=_blad(e))
@@ -128,7 +135,7 @@ async def dodaj_plik(request: Request, upload: UploadFile = File(...)):
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
f"do użycia w tej postaci.")
try:
logic.files_status(out["path"], ACTIVE, by=who)
_logic(request).files_status(out["path"], ACTIVE, by=who)
except (httpx.HTTPError, link_crypto.LinkError) as e:
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}")
return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.")
+31 -10
View File
@@ -39,12 +39,30 @@ _hits: dict[str, deque[float]] = {}
MAX_TRACKED_CLIENTS = 4096
def demo_user() -> str:
return os.getenv("DEMO_USER", "demo")
def accounts() -> dict[str, str]:
"""Konta demo: `login:sekret`, po przecinku, ze zmiennej DEMO_USERS.
WIELE KONT, bo każde ma WŁASNĄ, niewidoczną dla pozostałych pulę baz (PRE-29).
Wspólne konto oznaczałoby wspólną pulę, czyli klientów oglądających nawzajem
swoje wgrania.
def demo_password() -> str:
return os.getenv("DEMO_PASSWORD", "")
Sekret najlepiej jako hash scrypt (`scrypt$sól$hash`) — ten sam format i ten
sam skrypt make_user.py, co w głównej aplikacji.
Zgodność wstecz: pojedyncze DEMO_USER/DEMO_PASSWORD działa, gdy DEMO_USERS
jest puste."""
out: dict[str, str] = {}
for entry in os.getenv("DEMO_USERS", "").split(","):
entry = entry.strip()
if not entry:
continue
user, sep, secret = entry.partition(":")
if sep and user.strip() and secret.strip():
out[user.strip()] = secret.strip()
if out:
return out
single = os.getenv("DEMO_PASSWORD", "")
return {os.getenv("DEMO_USER", "demo"): single} if single else {}
def rate_limit_per_min() -> int:
@@ -56,7 +74,7 @@ def trust_proxy() -> bool:
def auth_enabled() -> bool:
return bool(demo_password())
return bool(accounts())
def _secret_matches(spec: str, password: str) -> bool:
@@ -82,9 +100,12 @@ def authenticate(header: str | None) -> str | None:
user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError):
return None
if user != demo_user():
spec = accounts().get(user)
if spec is None:
return None
return user if _secret_matches(demo_password(), password) else None
# Nieznany login i złe hasło traktujemy tak samo — odmowa nie mówi, które
# konto istnieje, a przy demo rozdawanym szeroko to realna informacja.
return user if _secret_matches(spec, password) else None
def client_ip(request: Request) -> str:
@@ -142,9 +163,9 @@ def install(app) -> None:
_setup_audit_logging()
if not auth_enabled():
log.warning(
"UWAGA: DEMO_PASSWORD nie ustawione — astroklient stoi OTWARTY, a pracuje "
"na tej samej warstwie danych co produkcja, więc wystawia treść "
"oryginalnych baz interpretacyjnych każdemu, kto zna adres."
"UWAGA: brak kont (DEMO_USERS / DEMO_PASSWORD) — astroklient stoi OTWARTY. "
"Bez logowania nie ma też PULI: wszyscy pracowaliby na jednym, wspólnym "
"zbiorze baz, widząc nawzajem swoje wgrania."
)
@app.middleware("http")
+67 -24
View File
@@ -17,8 +17,9 @@ import pytest
@pytest.fixture()
def env(monkeypatch):
monkeypatch.setenv("DEMO_USER", "demo")
monkeypatch.setenv("DEMO_PASSWORD", "tajne-demo")
monkeypatch.setenv("DEMO_USERS", "demo:tajne-demo,klientA:hasloA,klientB:hasloB")
monkeypatch.delenv("DEMO_USER", raising=False)
monkeypatch.delenv("DEMO_PASSWORD", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
@@ -39,20 +40,25 @@ REPORT = {
}
def _client(monkeypatch, calls=None):
def _client(monkeypatch, calls=None, upload=None):
"""Podmieniamy METODY KLASY, nie obiekt modułowy: klient jest budowany per
żądanie, żeby pula wynikała z zalogowanego konta. Dzięki temu podglądamy
też `self.tenant`, czyli to, do której puli faktycznie poszło zapytanie."""
from starlette.testclient import TestClient
from app.main import app, logic
from app.clients.logic_client import LogicClient
from app.main import app
calls = calls if calls is not None else []
monkeypatch.setattr(logic, "report",
lambda **kw: (calls.append(("report", kw)), REPORT)[1])
monkeypatch.setattr(logic, "files_upload",
lambda *a, **kw: (calls.append(("upload", a, kw)),
{"path": "nowa.xlsx", "name": "nowa.xlsx",
"accepted": True})[1])
monkeypatch.setattr(logic, "files_status",
lambda *a, **kw: (calls.append(("status", a, kw)), {})[1])
domyslny = {"path": "nowa.xlsx", "name": "nowa.xlsx", "accepted": True}
monkeypatch.setattr(LogicClient, "report",
lambda self, **kw: (calls.append(("report", self.tenant, kw)), REPORT)[1])
monkeypatch.setattr(LogicClient, "files_upload",
lambda self, *a, **kw: (calls.append(("upload", self.tenant, a)),
upload or domyslny)[1])
monkeypatch.setattr(LogicClient, "files_status",
lambda self, *a, **kw: (calls.append(("status", self.tenant, a)), {})[1])
return TestClient(app), calls
@@ -109,7 +115,7 @@ def test_demo_account_is_separate_from_the_main_application(env, monkeypatch):
def test_a_password_with_polish_letters_is_refused_not_crashed(env, monkeypatch):
monkeypatch.setenv("DEMO_PASSWORD", "zażółć-gęślą")
monkeypatch.setenv("DEMO_USERS", "demo:zażółć-gęślą")
c, _ = _client(monkeypatch)
assert c.get("/", headers=_auth("demo", "złe-hasło")).status_code == 401
assert c.get("/", headers=_auth("demo", "zażółć-gęślą")).status_code == 200
@@ -125,7 +131,7 @@ def test_adding_a_file_uploads_AND_switches_it_on(env, monkeypatch):
assert r.status_code == 303
rodzaje = [k[0] for k in calls]
assert rodzaje == ["upload", "status"], f"oczekiwano wgrania i włączenia, było: {rodzaje}"
assert calls[1][1][1] == "active", "plik nie został włączony do użytku"
assert calls[1][2][1] == "active", "plik nie został włączony do użytku"
def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, monkeypatch):
@@ -133,13 +139,8 @@ def test_a_rejected_file_is_not_switched_on_and_the_reason_stays_hidden(env, mon
demo nie ma prawa ich zdradzić, nawet mimochodem."""
from urllib.parse import unquote_plus
from app.main import logic
c, calls = _client(monkeypatch)
monkeypatch.setattr(logic, "files_upload", lambda *a, **kw: (
calls.append(("upload", a, kw)),
{"path": "zla.xlsx", "name": "zla.xlsx", "accepted": False})[1])
c, calls = _client(monkeypatch, upload={"path": "zla.xlsx", "name": "zla.xlsx",
"accepted": False})
r = c.post("/plik", headers=_auth(), follow_redirects=False,
files={"upload": ("zla.xlsx", b"x", "text/plain")})
assert r.status_code == 303
@@ -171,7 +172,7 @@ def test_local_time_is_converted_to_utc_before_asking(env, monkeypatch):
a wynik nadal wygląda wiarygodnie — dlatego sprawdzamy wprost."""
c, calls = _client(monkeypatch)
c.post("/", headers=_auth(), data=FORM)
assert calls[0][1]["when_utc_iso"].startswith("1984-04-30T09:20")
assert calls[0][2]["when_utc_iso"].startswith("1984-04-30T09:20")
def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch):
@@ -184,10 +185,10 @@ def test_a_malformed_date_is_explained_not_thrown(env, monkeypatch):
def test_a_broken_logic_layer_is_reported_not_crashed(env, monkeypatch):
import httpx
from app.main import logic
from app.clients.logic_client import LogicClient
c, _ = _client(monkeypatch)
monkeypatch.setattr(logic, "report", lambda **kw: (_ for _ in ()).throw(
monkeypatch.setattr(LogicClient, "report", lambda self, **kw: (_ for _ in ()).throw(
httpx.ConnectError("brak połączenia")))
r = c.post("/", headers=_auth(), data=FORM)
assert r.status_code == 200 and "Warstwa logiczna niedostępna" in r.text
@@ -201,3 +202,45 @@ def test_the_page_never_offers_anything_it_cannot_do(env, monkeypatch):
for slowo in ("Kosmogram", "Synastria", "Kalendarz", "Skompiluj", "Ustawienia",
"Konta", "PDF", "Excel", "prompt", "model"):
assert slowo not in html, f"strona wspomina o „{slowo}”, czego demo nie umie"
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
# Demo jest udostępniane szeroko i różnym osobom, więc pula musi wynikać
# z tego, KTO SIĘ ZALOGOWAŁ — i z niczego innego. Wyciek byłby cichy: klient
# zobaczyłby cudze bazy jako własne.
def test_the_pool_is_taken_from_the_login(env, monkeypatch):
c, calls = _client(monkeypatch)
c.post("/", headers=_auth("klientA", "hasloA"), data=FORM)
c.post("/", headers=_auth("klientB", "hasloB"), data=FORM)
assert [k[1] for k in calls] == ["klientA", "klientB"]
def test_uploads_go_to_the_pool_of_the_account_that_sent_them(env, monkeypatch):
c, calls = _client(monkeypatch)
c.post("/plik", headers=_auth("klientA", "hasloA"), follow_redirects=False,
files={"upload": ("baza.xlsx", b"x", "application/vnd.ms-excel")})
assert {k[1] for k in calls} == {"klientA"}, "wgranie trafiło do cudzej puli"
def test_the_pool_cannot_be_chosen_from_the_form(env, monkeypatch):
"""Gdyby pula przychodziła z żądania, wystarczyłoby podstawić cudzy login."""
c, calls = _client(monkeypatch)
c.post("/", headers=_auth("klientA", "hasloA"),
data={**FORM, "tenant": "klientB", "user": "klientB", "login": "klientB"})
assert [k[1] for k in calls] == ["klientA"], "pula dała się podmienić z formularza"
def test_every_account_has_its_own_pool_by_construction(env, monkeypatch):
"""Każdy login = inna pula. Dwa konta nie mogą dzielić zbioru baz."""
c, calls = _client(monkeypatch)
for login, haslo in (("demo", "tajne-demo"), ("klientA", "hasloA"), ("klientB", "hasloB")):
c.post("/", headers=_auth(login, haslo), data=FORM)
pule = [k[1] for k in calls]
assert len(set(pule)) == len(pule) == 3
def test_an_unknown_account_gets_nothing(env, monkeypatch):
c, calls = _client(monkeypatch)
assert c.post("/", headers=_auth("obcy", "cokolwiek"), data=FORM).status_code == 401
assert calls == [], "poszło zapytanie mimo odmowy logowania"