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:
@@ -0,0 +1,311 @@
|
||||
"""Kontrola dostępu: konta, uprawnienia i niewidzialność funkcji (PRE-27).
|
||||
|
||||
DWIE WŁASNOŚCI, KTÓRYCH PILNUJE TEN PLIK
|
||||
|
||||
1. Konto widzi dokładnie to, co mu przyznano — ani mniej, ani więcej. Sprawdzane
|
||||
przez PRAWDZIWE żądania, nie przez czytanie szablonów: ukrycie pola w formularzu
|
||||
nie chroni przed kimś, kto zna nazwy pól.
|
||||
|
||||
2. Konto ograniczone nie ma SKĄD wiedzieć, że program umie więcej. To własność
|
||||
negatywna — łatwo ją zepsuć przez dobre intencje („dodajmy czytelny komunikat
|
||||
o braku uprawnień"), więc testy są tu wprost o tym: 404 zamiast 403, brak
|
||||
pozycji w menu, brak rysunków w źródle strony.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from app import accounts as store
|
||||
from app import features, security
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path, monkeypatch):
|
||||
"""Świeży plik kont + konto administracyjne ze środowiska."""
|
||||
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||
monkeypatch.setenv("APP_USER", "szef")
|
||||
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||
monkeypatch.delenv("APP_USERS", raising=False)
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0") # limit nie jest tu przedmiotem badania
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _client(monkeypatch, chart=None):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app, logic
|
||||
|
||||
monkeypatch.setattr(logic, "positions", lambda **kw: chart or _chart())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _auth(user, password):
|
||||
import base64
|
||||
|
||||
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {raw}"}
|
||||
|
||||
|
||||
def _chart():
|
||||
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
|
||||
"sign_glyph": "♈"} for i in range(12)]
|
||||
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0,
|
||||
"sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")}
|
||||
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
|
||||
"sign_glyphs": [{"sign": "Aries", "glyph": "♈"}], "house_system": "whole_sign",
|
||||
"aspects": [], "house_systems": []}
|
||||
|
||||
|
||||
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||
"lat": "50.06", "lon": "19.94"}
|
||||
|
||||
|
||||
# ── katalog i mapa tras ──────────────────────────────────────────────────
|
||||
|
||||
def test_every_route_declares_the_permission_it_needs():
|
||||
"""Trasa bez wpisu w ROUTES to dziura, której nie widać.
|
||||
|
||||
Idziemy po TRASACH APLIKACJI, nie po wpisach mapy — inaczej test
|
||||
potwierdzałby tylko sam siebie i nie zauważyłby nowej, nieopisanej trasy."""
|
||||
from app.main import app
|
||||
|
||||
missing = []
|
||||
for route in app.routes:
|
||||
path = getattr(route, "path", None)
|
||||
methods = getattr(route, "methods", None) or set()
|
||||
if not path or path.startswith("/static"):
|
||||
continue
|
||||
for method in methods & {"GET", "POST"}:
|
||||
if (method, path) not in features.ROUTES:
|
||||
missing.append(f"{method} {path}")
|
||||
assert not missing, f"trasy bez zadeklarowanego uprawnienia: {missing}"
|
||||
|
||||
|
||||
def test_unknown_route_defaults_to_the_narrowest_permission():
|
||||
"""Przeoczenie ma ZAMYKAĆ, nie otwierać."""
|
||||
assert features.required("GET", "/cokolwiek-nowego") == features.ADMIN
|
||||
|
||||
|
||||
def test_admin_permission_cannot_be_granted_from_a_form():
|
||||
"""Nawet spreparowane żądanie nie nada uprawnień administracyjnych —
|
||||
konto administracyjne pochodzi wyłącznie ze środowiska."""
|
||||
assert features.ADMIN not in features.normalise([features.ADMIN, "chart"])
|
||||
assert features.normalise(["chart", "wymyślone"]) == frozenset({"chart"})
|
||||
|
||||
|
||||
# ── magazyn kont ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_password_is_stored_only_as_a_hash(env):
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
raw = pathlib.Path(store.store_path()).read_text(encoding="utf-8")
|
||||
assert "hasło-ali" not in raw
|
||||
assert json.loads(raw)["users"]["ala"]["secret"].startswith("scrypt$")
|
||||
|
||||
|
||||
def test_listing_accounts_never_exposes_secrets(env):
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
assert "secret" not in store.all_users()["ala"]
|
||||
|
||||
|
||||
def test_create_update_delete(env):
|
||||
store.create("ala", "x", ["chart"], note="do testów")
|
||||
assert store.permissions_of("ala") == frozenset({"chart"})
|
||||
store.update("ala", granted=["chart", "ai"])
|
||||
assert store.permissions_of("ala") == frozenset({"chart", "ai"})
|
||||
with pytest.raises(ValueError):
|
||||
store.create("ala", "y", []) # login zajęty
|
||||
store.delete("ala")
|
||||
assert not store.exists("ala")
|
||||
|
||||
|
||||
def test_empty_password_on_update_keeps_the_old_one(env):
|
||||
store.create("ala", "stare", ["chart"])
|
||||
before = store.secret_of("ala")
|
||||
store.update("ala", granted=["chart"], password="")
|
||||
assert store.secret_of("ala") == before
|
||||
|
||||
|
||||
# ── logowanie i rozpoznanie konta ────────────────────────────────────────
|
||||
|
||||
def test_environment_account_is_the_administrator(env):
|
||||
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
|
||||
assert who and who.is_admin
|
||||
assert features.ADMIN in who.permissions
|
||||
assert features.GRANTABLE <= who.permissions, "administrator ma widzieć wszystko"
|
||||
|
||||
|
||||
def test_managed_account_gets_exactly_its_own_permissions(env):
|
||||
store.create("ala", "hasło-ali", ["chart", "ai"])
|
||||
who = security.principal(_auth("ala", "hasło-ali")["Authorization"])
|
||||
assert who and not who.is_admin
|
||||
assert who.permissions == frozenset({"chart", "ai"})
|
||||
|
||||
|
||||
def test_managed_account_cannot_shadow_the_administrator(env):
|
||||
"""Konto założone w pliku o loginie administratora NIE MOŻE go przesłonić —
|
||||
inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać."""
|
||||
store.create("szef", "podszywam-się", [])
|
||||
who = security.principal(_auth("szef", "tajne-szefa")["Authorization"])
|
||||
assert who and who.is_admin and features.ADMIN in who.permissions
|
||||
|
||||
|
||||
def test_wrong_password_is_refused_the_same_way_as_unknown_login(env):
|
||||
store.create("ala", "hasło-ali", ["chart"])
|
||||
assert security.principal(_auth("ala", "złe")["Authorization"]) is None
|
||||
assert security.principal(_auth("nie-ma-takiego", "cokolwiek")["Authorization"]) is None
|
||||
|
||||
|
||||
# ── niewidzialność funkcji ───────────────────────────────────────────────
|
||||
|
||||
def test_forbidden_screen_answers_404_not_403(env, monkeypatch):
|
||||
"""403 samo w sobie mówi „tu coś jest". Ma być nie do odróżnienia od
|
||||
adresu, którego nie ma."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
for path in ("/significators", "/interpret", "/timeline", "/synastry",
|
||||
"/compile", "/settings", "/accounts"):
|
||||
r = c.get(path, headers=_auth("ala", "x"))
|
||||
assert r.status_code == 404, f"{path} → {r.status_code}"
|
||||
assert c.get("/nie-ma-takiego-adresu", headers=_auth("ala", "x")).status_code == 404
|
||||
|
||||
|
||||
def test_menu_shows_only_granted_screens(env, monkeypatch):
|
||||
store.create("ala", "x", ["chart", "significators"])
|
||||
c = _client(monkeypatch)
|
||||
html = c.get("/", headers=_auth("ala", "x")).text
|
||||
assert 'href="/significators"' in html
|
||||
for absent in ('href="/interpret"', 'href="/timeline"', 'href="/synastry"',
|
||||
'href="/compile"', 'href="/settings"', 'href="/accounts"'):
|
||||
assert absent not in html, f"menu zdradza {absent}"
|
||||
for word in ("Interpretacje", "Kalendarz", "Synastria", "Skompiluj", "Konta"):
|
||||
assert word not in html, f"nazwa „{word}” nie powinna paść"
|
||||
|
||||
|
||||
def test_administrator_sees_everything_including_the_accounts_tab(env, monkeypatch):
|
||||
c = _client(monkeypatch)
|
||||
html = c.get("/", headers=_auth("szef", "tajne-szefa")).text
|
||||
for tab in features.SCREENS:
|
||||
assert f'href="{tab.href}"' in html, tab.key
|
||||
assert 'href="/accounts"' in html
|
||||
|
||||
|
||||
def test_root_lands_on_the_first_granted_screen(env, monkeypatch):
|
||||
"""Konto bez „Horoskopu" nie może zobaczyć 404 pod adresem głównym —
|
||||
wyglądałoby to na zepsuty program, a nie na węższy zestaw funkcji."""
|
||||
store.create("ala", "x", ["significators"])
|
||||
c = _client(monkeypatch)
|
||||
r = c.get("/", headers=_auth("ala", "x"), follow_redirects=False)
|
||||
assert r.status_code == 303 and r.headers["location"] == "/significators"
|
||||
|
||||
|
||||
# ── ograniczenia opcji egzekwowane NA SERWERZE ──────────────────────────
|
||||
|
||||
def test_crafted_request_cannot_buy_options_the_account_lacks(env, monkeypatch):
|
||||
"""Ukrycie pola w formularzu chroni przed przypadkiem, nie przed kimś,
|
||||
kto zna nazwy pól. Granicą jest handler."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
seen = {}
|
||||
from app.main import app, logic
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
monkeypatch.setattr(logic, "positions", lambda **kw: (seen.update(kw), _chart())[1])
|
||||
r = TestClient(app).post("/", headers=_auth("ala", "x"), data={
|
||||
**FORM, "stations": "true", "tables": "true", "aspect_minor": "true",
|
||||
"zodiac": "sidereal_lahiri", "house_system": "koch",
|
||||
"house_systems": ["koch", "campanus"]})
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
assert seen["stations"] is False and seen["tables"] is False
|
||||
assert seen["aspect_minor"] is False and seen["zodiac"] == "tropical"
|
||||
assert seen["house_system"] == "whole_sign" and seen["house_systems"] == []
|
||||
|
||||
|
||||
def test_extra_charts_are_not_even_in_the_page_source(env, monkeypatch):
|
||||
"""Nie chodzi o ukrycie rysunków stylem — nie mają w ogóle powstać."""
|
||||
store.create("ala", "x", ["chart"])
|
||||
c = _client(monkeypatch)
|
||||
html = c.post("/", headers=_auth("ala", "x"), data=FORM).text
|
||||
for word in ("Aspektarian", "deklinacj", "ntyscj"):
|
||||
assert word not in html, f"źródło strony zdradza „{word}”"
|
||||
|
||||
|
||||
def test_the_same_account_with_the_extra_gets_them(env, monkeypatch):
|
||||
"""Kontrola pozytywna: bez niej powyższy test przechodziłby też wtedy,
|
||||
gdyby rysunki były zepsute dla wszystkich."""
|
||||
store.create("ola", "x", ["chart", "extra_charts"])
|
||||
c = _client(monkeypatch, chart=_chart_with_objects())
|
||||
html = c.post("/", headers=_auth("ola", "x"), data=FORM).text
|
||||
assert "Aspektarian" in html
|
||||
|
||||
|
||||
def _chart_with_objects():
|
||||
"""Horoskop na tyle bogaty, żeby rysunki dodatkowe w ogóle powstały."""
|
||||
base = _chart()
|
||||
base["positions"] = [
|
||||
{"name": n, "glyph": g, "sign": "Aries", "sign_glyph": "♈", "in_sign": "0",
|
||||
"decimal": float(i * 30), "direction": "D", "speed": 1.0,
|
||||
"declination": 10.0, "house": i + 1}
|
||||
for i, (n, g) in enumerate((("Sun", "☉"), ("Moon", "☽"), ("Mars", "♂")))]
|
||||
base["aspects"] = [{"obj1": "Sun", "obj2": "Moon", "aspect": "trine",
|
||||
"orb": 1.0, "allowed": 8.0}]
|
||||
return base
|
||||
|
||||
|
||||
def test_only_the_administrator_can_manage_accounts(env, monkeypatch):
|
||||
store.create("ala", "x", ["chart", "ai", "export", "extra_charts"])
|
||||
c = _client(monkeypatch)
|
||||
for method, path in (("get", "/accounts"), ("post", "/accounts/create"),
|
||||
("post", "/accounts/update"), ("post", "/accounts/delete")):
|
||||
kwargs = {"headers": _auth("ala", "x")}
|
||||
if method == "post":
|
||||
kwargs["data"] = {"login": "ktoś"}
|
||||
r = getattr(c, method)(path, **kwargs)
|
||||
assert r.status_code == 404, f"{path} → {r.status_code}"
|
||||
assert c.get("/accounts", headers=_auth("szef", "tajne-szefa")).status_code == 200
|
||||
|
||||
|
||||
# ── pełny obieg: założenie konta z ekranu i zalogowanie się na nie ───────
|
||||
|
||||
def test_administrator_creates_an_account_and_it_works_immediately(env, monkeypatch):
|
||||
"""Nagłówek całej funkcji: administrator zakłada konto z ekranu, a osoba na
|
||||
tym koncie loguje się i dostaje DOKŁADNIE przyznany zestaw — bez restartu
|
||||
aplikacji i bez dotykania konfiguracji środowiska."""
|
||||
c = _client(monkeypatch)
|
||||
admin = _auth("szef", "tajne-szefa")
|
||||
|
||||
r = c.post("/accounts/create", headers=admin, follow_redirects=False, data={
|
||||
"login": "nowa", "password": "jej-hasło", "note": "praktykantka",
|
||||
"granted": ["chart", "significators", "extra_charts"]})
|
||||
assert r.status_code == 303
|
||||
|
||||
listing = c.get("/accounts", headers=admin).text
|
||||
assert "nowa" in listing and "praktykantka" in listing
|
||||
assert "jej-hasło" not in listing, "hasło nie ma prawa trafić na ekran"
|
||||
|
||||
her = _auth("nowa", "jej-hasło")
|
||||
html = c.get("/", headers=her).text
|
||||
assert 'href="/significators"' in html
|
||||
assert 'href="/accounts"' not in html and 'href="/compile"' not in html
|
||||
assert c.get("/compile", headers=her).status_code == 404
|
||||
|
||||
# odebranie uprawnienia działa od razu
|
||||
c.post("/accounts/update", headers=admin, follow_redirects=False,
|
||||
data={"login": "nowa", "granted": ["chart"]})
|
||||
assert c.get("/significators", headers=her).status_code == 404
|
||||
|
||||
# skasowanie konta odcina logowanie
|
||||
c.post("/accounts/delete", headers=admin, follow_redirects=False, data={"login": "nowa"})
|
||||
assert c.get("/", headers=her).status_code == 401
|
||||
|
||||
|
||||
def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
|
||||
"""Konto administracyjne nie leży w pliku, więc nie ma czego skasować —
|
||||
ale próba nie może też wywalić aplikacji ani skasować czegoś innego."""
|
||||
c = _client(monkeypatch)
|
||||
admin = _auth("szef", "tajne-szefa")
|
||||
store.create("ala", "x", ["chart"])
|
||||
r = c.post("/accounts/delete", headers=admin, follow_redirects=False,
|
||||
data={"login": "szef"})
|
||||
assert r.status_code == 303
|
||||
assert store.exists("ala"), "kasowanie nieistniejącego konta ruszyło inne"
|
||||
assert c.get("/", headers=admin).status_code == 200
|
||||
Reference in New Issue
Block a user