feat(bezpieczeństwo): konta z uprawnieniami do zakładek i funkcji (PRE-27)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m58s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 3m37s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
build / build (push) Successful in 18s

Ekran „Konta" dla administratora: zakładanie, kasowanie i nadawanie uprawnień.
Zestaw funkcji zależy od konta, a konto ograniczone widzi program KOMPLETNY —
tylko mniejszy.

PODZIAŁ NA GRUPY. Ekrany to zakładki (7), bo zakładka jest naturalną jednostką —
to ją widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI wewnątrz ekranów:
porównanie systemów domów, wykresy dodatkowe, obliczenia zaawansowane, generowanie
tekstu przez model (kosztuje pieniądze) i eksport plików. Konto bez porównania
domów dostaje horoskop w Whole Sign i nie wie, że systemów jest trzynaście.

NIC NIE ZDRADZA, ŻE JEST WIĘCEJ:
- brak pozycji w menu zamiast pozycji wyszarzonej,
- 404 zamiast 403 — odmowa z powodem sama mówi, że coś tam jest,
- rysunki bez uprawnienia w OGÓLE NIE POWSTAJĄ, więc nie ma ich nawet w źródle,
- automatyczna dokumentacja API wyłączona. /docs, /redoc i /openapi.json wypisują
  komplet tras, czyli spis wszystkich funkcji programu — ochrona zakładek nic by
  nie dała, gdyby obok leżał ich katalog. Znalezione TESTEM przechodzącym po
  trasach aplikacji, nie przeglądem kodu.

KONTO ADMINISTRACYJNE zostaje w APP_USER/APP_PASSWORD, jak było. Nie leży w pliku
kont, więc nie da się go skasować ani ograniczyć z ekranu. Konto założone w pliku
o tym samym loginie NIE przesłoni administracyjnego — kolejność sprawdzania jest
odwrotna, inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać.
Uprawnienia administracyjnego nie da się też nadać z formularza: odsiewamy je
w normalise(), a nie w handlerze, więc żadne spreparowane żądanie tam nie sięgnie.

GRANICA JEST W HANDLERZE, NIE W SZABLONIE. Ukrycie pola chroni przed przypadkiem,
nie przed kimś, kto zna nazwy pól — _limit_options() ścina opcje po stronie
serwera i test wysyła spreparowane żądanie, żeby to potwierdzić.

MAPA TRASA→UPRAWNIENIE JEST JEDNA (features.ROUTES). Rozproszenie jej po
dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura
jest niewidoczna, dopóki ktoś jej nie znajdzie. Trasa bez wpisu wymaga
administratora: przeoczenie ma ZAMYKAĆ, nie otwierać. Test idzie po trasach
APLIKACJI, nie po wpisach mapy — inaczej potwierdzałby tylko sam siebie.

Konta w pliku JSON na własnym podkatalogu NFS (nie tam, gdzie bazy — zamontowanie
całego udziału obeszłoby bokiem DAN-25). Hasła wyłącznie jako hash scrypt, tym
samym mechanizmem co APP_USERS. Zapis atomowy, bo przerwanie zapisu na NFS
obcięłoby plik, czyli skasowało wszystkie konta naraz.

Przy okazji przepisane trzy testy, które greppowały nawigację i main.py: menu
powstaje teraz z katalogu funkcji, więc szukanie sztywnych linków w base.html
niczego już nie sprawdzało.

Wymaga wolumenu na konta — osobny PR w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #68.
This commit is contained in:
2026-08-06 21:45:23 +02:00
committed by gitea
parent baf4e0e38a
commit a833965909
22 changed files with 1086 additions and 56 deletions
+67 -10
View File
@@ -42,6 +42,7 @@ import os
import secrets
import time
from collections import deque
from dataclasses import dataclass
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse
@@ -131,12 +132,36 @@ def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
def authenticate(header: str | None) -> str | None:
"""Nagłówek Basic → LOGIN zalogowanej osoby albo None.
@dataclass(frozen=True)
class Principal:
"""Kto się zalogował i co wolno mu zobaczyć."""
Zwracamy login (nie bool), bo dziennik audytowy musi wiedzieć KTO — to sedno
PRE-17. Nieznany login i złe hasło traktujemy tak samo (bez podpowiadania,
które konto istnieje)."""
login: str
permissions: frozenset[str]
is_admin: bool
def _admin_permissions() -> frozenset[str]:
"""Administrator widzi wszystko — z definicji, bez wpisywania listy ręcznie.
Liczone z katalogu, więc dopisanie nowej funkcji NIE wymaga pamiętania
o koncie administracyjnym. Przeciwnie byłoby cichą pułapką: nowa funkcja
byłaby niewidoczna dla jedynej osoby, która ma ją komu nadać."""
from app import features
return frozenset(features.GRANTABLE) | {features.ADMIN}
def principal(header: str | None) -> Principal | None:
"""Nagłówek Basic → kto to jest i co mu wolno; None, gdy dane nie pasują.
Kolejność: najpierw konta z KONFIGURACJI ŚRODOWISKA (administracyjne, pełne
uprawnienia), potem konta zarządzane z ekranu. Nie odwrotnie — inaczej
założenie w pliku konta o loginie administratora przesłoniłoby konto
administracyjne i odebrało mu uprawnienia.
Nieznany login i złe hasło traktujemy tak samo: odmowa nie mówi, które konto
istnieje."""
if not header or not header.lower().startswith("basic "):
return None
try:
@@ -144,10 +169,26 @@ def authenticate(header: str | None) -> str | None:
user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError):
return None
spec = accounts().get(user)
if spec is None:
if spec is not None:
return (Principal(user, _admin_permissions(), True)
if _secret_matches(spec, password) else None)
from app import accounts as store
managed = store.secret_of(user)
if managed is None:
return None
return user if _secret_matches(spec, password) else None
if not _secret_matches(managed, password):
return None
return Principal(user, store.permissions_of(user), False)
def authenticate(header: str | None) -> str | None:
"""Sam LOGIN — do dziennika audytowego (PRE-17) i zgodności wstecz."""
who = principal(header)
return who.login if who else None
def client_ip(request: Request) -> str:
@@ -264,16 +305,32 @@ def install(app) -> None:
status_code=429, headers={"Retry-After": "60"},
)
user = ""
from app import features
user, who = "", None
if auth_enabled():
user = authenticate(request.headers.get("authorization")) or ""
if not user:
who = principal(request.headers.get("authorization"))
if who is None:
_audit(request, "-", 401, (time.monotonic() - started) * 1000)
return HTMLResponse(
"<h1>401 — wymagane logowanie</h1>", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
)
user = who.login
else:
# Bez kont ochrona jest wyłączona (dev), więc widać wszystko — inaczej
# deweloper zobaczyłby pustą aplikację i szukałby błędu tam, gdzie go nie ma.
who = Principal("", _admin_permissions(), True)
request.state.user = user
request.state.principal = who
# 404, NIE 403. Odmowa z powodem jest informacją, że coś tam jest —
# a konto ograniczone ma widzieć program kompletny, tylko mniejszy.
need = features.required(request.method, request.url.path)
if not features.can(who.permissions, need):
_audit(request, user, 404, (time.monotonic() - started) * 1000)
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
response = await call_next(request)
_audit(request, user, response.status_code, (time.monotonic() - started) * 1000)
return response