feat(astroklient): wersja demonstracyjna o dwóch funkcjach (PRE-28)

Osobna warstwa prezentacji: dodanie pliku bazy i zapytanie o interpretację
urodzeniową. Nic więcej.

OSOBNA USŁUGA, 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
pozostałych funkcji NIE MA W OBRAZIE — 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. Test porównuje zbiór tras aplikacji i zbiór metod
klienta z listą dokładną, więc dopisanie czegokolwiek zapala się od razu.

WGRANIE I WŁĄCZENIE TO JEDNA CZYNNOŚĆ. W pełnej aplikacji to dwie osobne decyzje
(DAN-27), bo tam ktoś nad tym panuje. Tutaj „dodać plik do bazy" musi znaczyć, że
plik od razu bierze udział w wyszukiwaniu — inaczej po wgraniu nic by się nie
zmieniło i demo wyglądałoby na zepsute. Walidacja zostaje: plik o złym układzie nie
wchodzi do użytku, ale też NIE JEST tracony, a komunikat nie zdradza reguł, bo te
zna wyłącznie administrator. Osobny test szuka w komunikacie śladów mechanizmu.

KONTO OSOBNE (DEMO_USER/DEMO_PASSWORD), nie współdzielone z główną aplikacją.
Demo pracuje na TEJ SAMEJ warstwie danych co produkcja — świadoma decyzja
właściciela — więc kto ma do niego dostęp, czyta oryginalne bazy, a jego wgrania
trafiają do produkcyjnego zbioru. Własne poświadczenia pozwalają odciąć demo jedną
zmienną, bez ruszania kont głównej aplikacji i bez zmiany hasła komukolwiek.
Zapisane wprost w README usługi i w manifeście, nie tylko w tej wiadomości.

Rozmowa z warstwą logiczną idzie tym samym szyfrowanym łączem (PRE-16) i pod tym
samym tokenem międzywarstwowym (LOG-32) — demo nie jest furtką omijającą ochronę.
Automatyczna dokumentacja wyłączona, jak w pełnej aplikacji: /docs wypisałoby
komplet tras, a demo ma nie zdradzać nawet własnej powierzchni.

Zależności celowo krótsze niż w prezentacji: bez Excela, bez stref czasowych
z lokalizacji, bez niczego pod kosmogram. Każda zbędna zależność w obrazie demo to
kolejna rzecz do pilnowania.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:39:47 +02:00
parent 320a0ab24e
commit e7b6d5013b
18 changed files with 1429 additions and 2 deletions
+176
View File
@@ -0,0 +1,176 @@
"""Logowanie do astroklienta (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ń. Astroklient 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
log = logging.getLogger("astroklient.security")
audit_log = logging.getLogger("astroklient.audit")
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _SCRYPT_LEN = 2**14, 8, 1, 32
_HASH_PREFIX = "scrypt$"
PUBLIC_PATHS = frozenset({"/health"})
PUBLIC_PREFIXES = ("/static/",)
_hits: dict[str, deque[float]] = {}
MAX_TRACKED_CLIENTS = 4096
def demo_user() -> str:
return os.getenv("DEMO_USER", "demo")
def demo_password() -> str:
return os.getenv("DEMO_PASSWORD", "")
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(demo_password())
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 authenticate(header: str | None) -> str | None:
if not header or not header.lower().startswith("basic "):
return None
try:
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError):
return None
if user != demo_user():
return None
return user if _secret_matches(demo_password(), password) else None
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 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."
)
@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 = ""
if auth_enabled():
user = authenticate(request.headers.get("authorization")) or ""
if not user:
return HTMLResponse(
"<h1>401 — wymagane logowanie</h1>", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="astroklient"'})
request.state.user = user
response = await call_next(request)
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