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:
@@ -18,25 +18,32 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app import accounts as accounts_store
|
||||
from app import chartwheel as chartwheel_mod
|
||||
from app import features
|
||||
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
||||
|
||||
from app import geocode, security
|
||||
from app.clients.logic_client import LogicClient
|
||||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||
# Automatyczna dokumentacja WYŁĄCZONA. /docs, /redoc i /openapi.json wypisują
|
||||
# KOMPLET tras aplikacji — czyli spis wszystkich funkcji, łącznie z tymi, o których
|
||||
# konto ograniczone nie ma prawa wiedzieć (PRE-27). Ochrona zakładek nic by nie dała,
|
||||
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
|
||||
app = FastAPI(title="astrololo · warstwa prezentacji",
|
||||
docs_url=None, redoc_url=None, openapi_url=None)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
logic = LogicClient()
|
||||
security.install(app) # logowanie + limit żądań (LOG-32)
|
||||
|
||||
|
||||
# ── cache-busting statyki (PRE-26) ─────────────────────────────────────────
|
||||
# ── cache-busting statyki (PRE-27) ─────────────────────────────────────────
|
||||
# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache).
|
||||
# Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL →
|
||||
# przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
|
||||
@@ -64,6 +71,45 @@ templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
||||
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
||||
|
||||
|
||||
def _perms(request: Request) -> frozenset[str]:
|
||||
"""Uprawnienia zalogowanego konta — pusty zbiór, gdy nie wiadomo kto to."""
|
||||
who = getattr(request.state, "principal", None)
|
||||
return who.permissions if who is not None else frozenset()
|
||||
|
||||
|
||||
# Szablony pytają o uprawnienia PRZEZ TE DWIE FUNKCJE, nigdy o konto wprost.
|
||||
# Dzięki temu nie da się w szablonie napisać warunku na login — a to jest
|
||||
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
|
||||
# nikt już nie pamięta.
|
||||
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
|
||||
templates.env.globals["can"] = lambda request, key: key in _perms(request)
|
||||
|
||||
|
||||
def _limit_options(request: Request, opts: dict) -> dict:
|
||||
"""Ścina opcje, których konto nie ma prawa użyć — PO STRONIE SERWERA.
|
||||
|
||||
Ukrycie pola w formularzu chroni tylko przed przypadkiem. Kto zna nazwy pól,
|
||||
wyśle je wprost — a wtedy policzyłby funkcję, której mu nie przyznano, i to
|
||||
jeszcze zobaczyłby ją w wyniku. Granicą jest ta funkcja, nie szablon.
|
||||
|
||||
Wartości zastępcze są DOMYŚLNE, nie puste: konto ograniczone ma dostać
|
||||
działający horoskop w podstawowej postaci, a nie komunikat o braku czegoś.
|
||||
"""
|
||||
perms = _perms(request)
|
||||
if "advanced_calc" not in perms:
|
||||
opts.update(stations=False, tables=False, aspect_minor=False, zodiac="tropical")
|
||||
if "houses_compare" not in perms:
|
||||
opts.update(house_system="whole_sign", house_systems=[],
|
||||
wheel_orientation=chartwheel_mod.ASC_LEFT)
|
||||
return opts
|
||||
|
||||
|
||||
def _landing(request: Request) -> str | None:
|
||||
"""Pierwszy ekran dostępny dla konta — dokąd odesłać z korzenia."""
|
||||
allowed = features.screens_for(_perms(request))
|
||||
return allowed[0].href if allowed else None
|
||||
|
||||
|
||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
|
||||
|
||||
@@ -108,6 +154,16 @@ def _logic_error(e: Exception) -> str:
|
||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def chart_form(request: Request):
|
||||
# Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie.
|
||||
# Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem
|
||||
# głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw.
|
||||
if "chart" not in _perms(request):
|
||||
target = _landing(request)
|
||||
if target:
|
||||
return RedirectResponse(target, status_code=303)
|
||||
return HTMLResponse("<h1>Brak przypisanych funkcji</h1>"
|
||||
"<p>Skontaktuj się z osobą, która zakładała konto.</p>",
|
||||
status_code=200)
|
||||
return templates.TemplateResponse(
|
||||
request, "chart.html",
|
||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||||
@@ -140,22 +196,27 @@ def chart_compute(
|
||||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||
"wheel_orientation": wheel_orientation}
|
||||
form = _limit_options(request, form)
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=house_system, house_systems=house_systems,
|
||||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||
aspect_minor=aspect_minor,
|
||||
stations=stations, zodiac=zodiac, tables=tables,
|
||||
aspect_minor=form["aspect_minor"],
|
||||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||||
)
|
||||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
|
||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||
orientation=form["wheel_orientation"])
|
||||
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
|
||||
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
|
||||
if "extra_charts" in _perms(request):
|
||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||
except (httpx.HTTPError,) as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
except ValueError as e:
|
||||
@@ -206,22 +267,25 @@ def compile_build(
|
||||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||
"wheel_orientation": wheel_orientation}
|
||||
form = _limit_options(request, form)
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=house_system, house_systems=house_systems,
|
||||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||||
aspect_minor=aspect_minor,
|
||||
stations=stations, zodiac=zodiac, tables=tables,
|
||||
aspect_minor=form["aspect_minor"],
|
||||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||||
)
|
||||
from app import chartwheel
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
|
||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||||
orientation=form["wheel_orientation"])
|
||||
if "extra_charts" in _perms(request):
|
||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||||
except (httpx.HTTPError,) as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
except ValueError as e:
|
||||
@@ -608,6 +672,67 @@ def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00")
|
||||
return res
|
||||
|
||||
|
||||
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
||||
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
|
||||
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
|
||||
|
||||
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
|
||||
return {
|
||||
"users": accounts_store.all_users(),
|
||||
"catalog": features.ALL,
|
||||
"screens": features.SCREENS,
|
||||
"extras": features.EXTRAS,
|
||||
"admin_login": security.app_user(),
|
||||
"store_path": accounts_store.store_path(),
|
||||
"error": error, "done": done,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/accounts", response_class=HTMLResponse)
|
||||
def accounts_view(request: Request, error: str = "", done: str = ""):
|
||||
return templates.TemplateResponse(request, "accounts.html",
|
||||
_accounts_context(request, error, done))
|
||||
|
||||
|
||||
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||||
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
||||
powtórzyć zakładania ani kasowania konta."""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/accounts/create")
|
||||
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
|
||||
note: str = Form(""), granted: list[str] = Form([])):
|
||||
try:
|
||||
accounts_store.create(login, password, granted, note)
|
||||
except ValueError as e:
|
||||
return _accounts_redirect(error=str(e))
|
||||
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
|
||||
|
||||
|
||||
@app.post("/accounts/update")
|
||||
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
|
||||
note: str = Form(""), granted: list[str] = Form([])):
|
||||
try:
|
||||
accounts_store.update(login, granted=granted, password=password, note=note)
|
||||
except ValueError as e:
|
||||
return _accounts_redirect(error=str(e))
|
||||
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
|
||||
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
|
||||
|
||||
|
||||
@app.post("/accounts/delete")
|
||||
def accounts_delete(request: Request, login: str = Form(...)):
|
||||
try:
|
||||
accounts_store.delete(login)
|
||||
except ValueError as e:
|
||||
return _accounts_redirect(error=str(e))
|
||||
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "layer": "presentation"}
|
||||
|
||||
Reference in New Issue
Block a user