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
@@ -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")