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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user