feat(astroklient): pule plików per konto i izolacja od produkcji (PRE-29)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m17s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astroklienta (wersja demo) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astroklienta (wersja demo) (pull_request) Successful in 9m26s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s

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 a88ca14639
commit 2d7775f9b3
13 changed files with 301 additions and 79 deletions
+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")