feat(bezpieczeństwo): konta imienne i dziennik audytowy (PRE-17)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 11s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m27s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 11s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 7s

Jedno wspólne hasło nie mówiło, KTO sięgał do baz, a odebranie dostępu jednej
osobie wymagało zmiany hasła wszystkim. Przy bazach o realnej wartości handlowej
to za mało.

KONTA IMIENNE: `APP_USERS='alicja:scrypt$…,bartek:scrypt$…'`. Hash liczy scrypt ze
STDLIB — zero nowych zależności; hasła nie ma w konfiguracji jawnie. Zakładanie
konta: scripts/make_user.py (hasło interaktywnie, nie w historii powłoki).
Odebranie dostępu = usunięcie wpisu, reszta nie zmienia haseł.

Gdy APP_USERS jest ustawione, wspólne APP_PASSWORD PRZESTAJE działać (ostrzeżenie
przy starcie) — działające obok kont byłoby tylnym wejściem bez śladu w dzienniku.
Dopóki APP_USERS nie jest ustawione, stary tryb działa jak dotąd (zgodność wstecz).

DZIENNIK AUDYTOWY: każde żądanie zostawia wpis „kto, skąd, co, status, ILE
rekordów, ile ms". Liczba rekordów jest tu sednem — pojedyncze zapytanie wygląda
niewinnie, suma pokazuje powolne wypompowywanie bazy przez osobę uprawnioną.
Liczona dla wyszukiwarki sygnifikatorów, raportu i eksportu do Excela (ten wynosi
najwięcej naraz). W logach NIE MA treści — ani rekordów, ani promptów.

Dwa realne błędy złapane po drodze:
- `secrets.compare_digest` rzuca TypeError na znakach spoza ASCII, więc hasło z
  polskimi literami wywracało logowanie błędem 500 zamiast odmowy (błąd ZASTANY,
  sprzed tej zmiany) — porównujemy teraz bajty;
- dziennik był PUSTY na żywym serwerze: domyślna konfiguracja uvicorna nie
  obsługuje naszych loggerów. Niewidoczny dziennik jest gorszy niż jego brak,
  więc audyt dostał własny handler na stdout. Oba przypadki mają testy regresji.

Instrukcja wdrożeniowa: docs/konta-i-audyt.md. Testy: +15. Prezentacja 243.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 21:47:18 +02:00
parent 78af6d4755
commit c6b6f43cb9
5 changed files with 426 additions and 22 deletions
+18
View File
@@ -76,6 +76,18 @@ def _llm_catalog() -> dict:
return {"providers": {}, "defaults": {}}
def _report_records(report: dict | None) -> int:
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
Liczymy próbki we wszystkich fasetach: to one są treścią z baz. Sama liczba,
nigdy treść — dziennik ma pokazywać SKALĘ dostępu, nie powielać danych."""
total = 0
for o in (report or {}).get("objects") or []:
for f in o.get("facets") or []:
total += len(f.get("samples") or [])
return total
def _logic_error(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
return (
@@ -338,6 +350,8 @@ def significators_search(
ctx: dict = {"form": form, "result": None, "error": None}
try:
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "significators.html", ctx)
@@ -383,6 +397,9 @@ def interpret_run(
from app.report_export import report_to_xlsx
report = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
# Eksport wynosi NAJWIĘCEJ treści baz naraz — tym bardziej ma zostawiać
# ślad w dzienniku (PRE-17): sama liczba rekordów, nigdy treść.
security.audit_records(request, _report_records(report))
return Response(
content=report_to_xlsx(report),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -400,6 +417,7 @@ def interpret_run(
)
else:
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
security.audit_records(request, _report_records(ctx["result"]))
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e: