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
@@ -0,0 +1,148 @@
"""Konta imienne i dziennik audytowy (PRE-17).
Kod ochronny — testujemy zachowanie, nie kształt źródła. Dwie rzeczy są tu
krytyczne i CICHE, gdy się zepsują: (1) odebranie dostępu jednej osobie musi
faktycznie działać, (2) dziennik musi mówić KTO i ILE, ale NIGDY nie powielać
treści baz (byłby wtedy kolejnym nośnikiem wycieku).
"""
import base64
import logging
import pytest
from app import security
def _basic(user: str, password: str) -> dict:
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for var in ("APP_USERS", "APP_PASSWORD", "APP_USER"):
monkeypatch.delenv(var, raising=False)
security._hits.clear() # limit żądań nie może przeciekać między testami
# ── hasła: hash zamiast jawnego tekstu ──
def test_hash_roundtrip_and_salt_is_random():
h = security.hash_password("tajne")
assert h.startswith("scrypt$")
assert security._secret_matches(h, "tajne")
assert not security._secret_matches(h, "inne")
assert security.hash_password("tajne") != h # inna sól za każdym razem
def test_broken_hash_denies_instead_of_crashing():
assert security._secret_matches("scrypt$niepoprawny", "cokolwiek") is False
def test_non_ascii_password_is_handled_not_crashing():
"""REGRESJA (błąd zastany): `secrets.compare_digest` rzuca TypeError na znakach
spoza ASCII, więc hasło z polskimi literami wywracało logowanie błędem 500
zamiast zwykłej odmowy. Porównujemy bajty."""
assert security._secret_matches("zażółć", "zażółć") is True
assert security._secret_matches("zażółć", "inne") is False
assert security._secret_matches(security.hash_password("gęślą"), "gęślą") is True
# ── konta imienne ──
def test_named_accounts_are_parsed(monkeypatch):
monkeypatch.setenv("APP_USERS", "alicja:tajne, bartek:inne")
assert set(security.accounts()) == {"alicja", "bartek"}
def test_login_with_hashed_password(monkeypatch):
monkeypatch.setenv("APP_USERS", f"alicja:{security.hash_password('tajne')}")
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
assert security.authenticate(_basic("alicja", "złe")["Authorization"]) is None
def test_unknown_user_is_denied(monkeypatch):
monkeypatch.setenv("APP_USERS", "alicja:tajne")
assert security.authenticate(_basic("obcy", "tajne")["Authorization"]) is None
def test_revoking_one_account_leaves_others_untouched(monkeypatch):
"""Sedno PRE-17: odcinamy jedną osobę BEZ zmiany hasła pozostałym."""
monkeypatch.setenv("APP_USERS", "alicja:tajne,bartek:inne")
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) == "bartek"
monkeypatch.setenv("APP_USERS", "alicja:tajne") # usunięto wpis Bartka
assert security.authenticate(_basic("bartek", "inne")["Authorization"]) is None
assert security.authenticate(_basic("alicja", "tajne")["Authorization"]) == "alicja"
def test_shared_password_still_works_alone(monkeypatch):
"""Zgodność wstecz — dotychczasowe wdrożenie nie może paść po aktualizacji."""
monkeypatch.setenv("APP_USER", "astrololo")
monkeypatch.setenv("APP_PASSWORD", "wspolne")
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) == "astrololo"
def test_named_accounts_disable_the_shared_password(monkeypatch):
"""Gdy są konta imienne, wspólne hasło MUSI przestać działać — inaczej
zostawiałoby tylne wejście bez śladu w dzienniku."""
monkeypatch.setenv("APP_USERS", "alicja:tajne")
monkeypatch.setenv("APP_USER", "astrololo")
monkeypatch.setenv("APP_PASSWORD", "wspolne")
assert security.authenticate(_basic("astrololo", "wspolne")["Authorization"]) is None
def test_no_accounts_means_auth_disabled(monkeypatch):
assert security.auth_enabled() is False
# ── dziennik audytowy ──
def _client():
from starlette.testclient import TestClient
from app.main import app
return TestClient(app)
def test_audit_logs_who_and_how_many(monkeypatch, caplog):
monkeypatch.setenv("APP_USERS", "alicja:tajne")
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
_client().get("/synastry", headers=_basic("alicja", "tajne"))
entry = "\n".join(r.getMessage() for r in caplog.records)
assert "user=alicja" in entry and "path=/synastry" in entry and "status=200" in entry
def test_audit_marks_failed_login_without_naming_the_account(monkeypatch, caplog):
monkeypatch.setenv("APP_USERS", "alicja:tajne")
with caplog.at_level(logging.INFO, logger="astrololo.audit"):
_client().get("/synastry", headers=_basic("alicja", "złe"))
entry = "\n".join(r.getMessage() for r in caplog.records)
assert "status=401" in entry and "user=-" in entry
def test_audit_logger_actually_emits(monkeypatch):
"""REGRESJA: wpisy szły na INFO, ale domyślna konfiguracja uvicorna nie
obsługuje naszych loggerów — dziennik istniał w kodzie i był PUSTY na żywym
serwerze. Niewidoczny dziennik jest gorszy niż jego brak (złudzenie kontroli),
więc pilnujemy, że ma własny handler i poziom pozwalający na INFO."""
security._setup_audit_logging()
assert security.audit_log.handlers, "dziennik audytowy nie ma własnego handlera"
assert security.audit_log.isEnabledFor(logging.INFO)
def test_audit_never_carries_database_content():
"""Wpis niesie WYŁĄCZNIE metadane i liczby — logi nie mogą być nośnikiem wycieku."""
src = (__import__("pathlib").Path(security.__file__)).read_text(encoding="utf-8")
fmt = src[src.index('"AUDYT'):src.index("ms=%.0f") + 10]
for forbidden in ("rows", "samples", "effect", "significator", "text", "body"):
assert forbidden not in fmt, f"format wpisu audytowego dotyka treści: {forbidden}"
def test_handlers_report_record_counts():
"""Bez liczby rekordów dziennik pokazywałby „ktoś wchodził", a nie „ktoś
wyniósł 5000 rekordów" — czyli nie wykryłby powolnego wypompowywania bazy."""
import pathlib
main = (pathlib.Path(security.__file__).parent / "main.py").read_text(encoding="utf-8")
assert main.count("security.audit_records(") >= 3 # sygnifikatory, raport, eksport