10970c579f
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m19s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m26s
Testy / Testy astrodemo (pull_request) Failing after 0s
Testy / Testy astroklient (pull_request) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 7s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 5s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m26s
Testy / Testy astrodemo (push) Failing after 0s
Testy / Testy astroklient (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Trzeci produkt drabiny: astrodemo (dwie funkcje) → astroklient → astrololo. Po trzech poprzednich krokach jest cienki, bo jest ZŁOŻENIEM, a nie kopią: własne main.py z ośmioma importami, a rdzeń — ekrany, szablony, zasoby — bierze z warstwy prezentacji przy budowaniu obrazu. Jedno źródło, dwa produkty; inaczej te same 2500 linii szablonów żyłyby w dwóch egzemplarzach i rozjechały się w ciągu tygodni, po cichu. CO MA: Horoskop, Interpretacje, Kalendarz, Synastria, Sygnifikatory, wgrywanie plików. Wyszukiwarka miejsca i strefa czasowa zgodnie z ustaleniem. CZEGO NIE MA I DLACZEGO NIE DA SIĘ WŁĄCZYĆ: plików usuniętych wg usun.txt nie ma w obrazie. Nie istnieje uprawnienie, którym dałoby się je odsłonić, bo katalog funkcji składa się ze ZGŁOSZEŃ ekranów obecnych w obrazie. To dlatego „każde konto dostaje wszystko, co ta usługa umie" jest tu bezpieczne i nie wymaga wypisywania listy: zbiór liczy się z katalogu, więc opisuje ten produkt. KONTA jak w astrodemo: z konfiguracji środowiska (ASTROKLIENT_USERS), jeden poziom dostępu, bez pliku kont i bez ekranu ich zakładania. Konta rozdziela się po to, żeby każde miało własną pulę plików. PULE PER KONTO — tu była realna dziura. Warstwa logiczna przenosiła pulę tylko przy raporcie i operacjach na plikach, więc Kalendarz i Sygnifikatory czytałyby CAŁY udział: jedno konto widziałoby pliki drugiego, mimo obietnicy izolacji. Domknięte: TimelineRequest i QueryRequest niosą teraz pulę, a QueryService buduje klienta danych na żądanie. Pula jedzie w każdym żądaniu w dół i bierze się z kontekstu ustawianego przy wejściu, nigdy z formularza. Test podstawia `tenant=ktos-inny` w POST i sprawdza, że w dół poszedł login zalogowanego. WARSTWA WSPÓLNA ROZDZIELONA OD POJĘCIA ADMINISTRATORA. base.html miał wpisany na sztywno warunek `can(request, 'admin')` i odsyłacz do ekranu kont — czyli w produkcie bez tego ekranu zostawał martwy link i nazwa czegoś, czego nie ma. Rejestr niesie teraz wymagane uprawnienie, a szablon dostaje gotową listę. Podstawa przestała też importować moduły służące jednemu ekranowi (konta, stany plików), bo produkt bez tego ekranu wlókł ich zależności. ZAPORA SŁOWNIKOWA NAD REALNYM DRZEWEM. Test buduje złożenie tak samo jak Dockerfile i szuka słów o funkcjach, których nie ma — w odpowiedziach ORAZ w plikach. Pierwsza wersja znalazła dziesięć trafień, w tym trzy moje własne docstringi WYLICZAJĄCE nieobecne funkcje: zdanie „nie ma tu generowania tekstu" mówi wprost, że coś takiego istnieje, więc jest takim samym śladem jak przycisk. Po poprawkach: zero. Test ma kontrolę negatywną — podrzucony plik ma go wywrócić. usun.txt jest DANYMI, nie tekstem w Dockerfile: czyta go też test pilnujący, żeby zgadzał się ze złożeniem w main.py. Rozjazd znaczyłby albo martwy kod w obrazie, albo błąd dopiero przy uruchomieniu. CI: astroklient buduje się z KORZENIA repozytorium (jego Dockerfile sięga po rdzeń), a zmiana w warstwie prezentacji też go przebudowuje — bez tego jego obraz zostawałby ze starymi ekranami, a różnicy nie byłoby widać do zgłoszenia użytkownika. Testy: astroklient 12, presentation 368, astrodemo 28, logic 342, data 42, render 41. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
7.1 KiB
Python
158 lines
7.1 KiB
Python
"""Katalog funkcji i uprawnień (PRE-27).
|
|
|
|
DWIE ZASADY, KTÓRE RZĄDZĄ TYM MODUŁEM
|
|
|
|
1. Konto ograniczone ma widzieć program KOMPLETNY — tylko mniejszy. Nic nie może
|
|
sugerować, że istnieje coś więcej: żadnych wyszarzonych pozycji, żadnego
|
|
„brak uprawnień", żadnego 403. Ścieżka bez uprawnienia zwraca **404**, bo 403
|
|
jest samo w sobie informacją, że coś tam jest.
|
|
|
|
2. Mapa trasa → uprawnienie jest JEDNA i leży tutaj. 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. Test przechodzi po
|
|
wszystkich trasach aplikacji i wymaga, żeby każda była tu wymieniona.
|
|
|
|
PODZIAŁ NA GRUPY. Ekrany odpowiadają zakładkom — to naturalna jednostka, bo
|
|
zakładka jest tym, co widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI
|
|
wewnątrz ekranów: ktoś może liczyć horoskopy, nie mając dostępu do porównywania
|
|
systemów domów ani do generowania tekstu przez model (co kosztuje pieniądze).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from app import rozszerzenia
|
|
|
|
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Feature:
|
|
key: str
|
|
label: str
|
|
hint: str
|
|
group: str
|
|
href: str = "" # tylko ekrany; rozszerzenia nie mają własnej zakładki
|
|
kolejnosc: int = 0 # pozycja w nawigacji; nie zależy od kolejności importów
|
|
|
|
|
|
# ── rejestr: katalog składa się z tego, co zgłoszą ekrany ────────────────
|
|
# Dawniej wszystkie funkcje były wypisane TUTAJ. To znaczyło, że obraz produktu,
|
|
# który części z nich nie ma, i tak niósł ich nazwy — czyli spis funkcji, których
|
|
# nie ma jak włączyć. Teraz ekran zgłasza siebie sam, przy imporcie swojego
|
|
# modułu, więc katalog opisuje dokładnie ten produkt, który się zbudowało.
|
|
#
|
|
# `kolejnosc` jest jawna, bo o zawartości decyduje lista importów w `main.py`,
|
|
# a kolejność importów nie może rządzić kolejnością zakładek w nawigacji.
|
|
SCREENS: list[Feature] = []
|
|
# Pozycje nawigacji dla uprawnień NIENADAWALNYCH — tych, których nie ma w
|
|
# GRANTABLE, więc nie da się ich przyznać z żadnego ekranu. Osobno od SCREENS
|
|
# i wraz z wymaganym uprawnieniem, żeby szablon nie musiał go wypowiadać: wpisane
|
|
# w szablonie na sztywno zostawiało nazwę ekranu i martwy odsyłacz w produkcie,
|
|
# który tego ekranu nie ma.
|
|
NAWIGACJA_SPECJALNA: list[tuple[str, str, str, str]] = [] # (klucz, adres, etykieta, uprawnienie)
|
|
EXTRAS: list[Feature] = []
|
|
ALL: tuple[Feature, ...] = ()
|
|
BY_KEY: dict[str, Feature] = {}
|
|
GRANTABLE: frozenset[str] = frozenset()
|
|
|
|
ROUTES: dict[tuple[str, str], str | None] = {}
|
|
STATIC: dict[str, frozenset[str] | str | None] = {}
|
|
|
|
|
|
def _przelicz() -> None:
|
|
global ALL, BY_KEY, GRANTABLE
|
|
SCREENS.sort(key=lambda f: f.kolejnosc)
|
|
EXTRAS.sort(key=lambda f: f.kolejnosc)
|
|
ALL = tuple(SCREENS) + tuple(EXTRAS)
|
|
BY_KEY = {f.key: f for f in ALL}
|
|
GRANTABLE = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
|
|
|
|
|
|
def zarejestruj(*, ekran: Feature | None = None, rozszerzenie: Feature | None = None,
|
|
trasy: dict[tuple[str, str], str | None] | None = None,
|
|
zasoby: dict | None = None,
|
|
nawigacja: tuple[str, str, str, str] | None = None) -> None:
|
|
"""Zgłoszenie funkcji, jej tras i jej zasobów.
|
|
|
|
Idempotentne po kluczu: `export` zgłaszają dwa ekrany (raport do PDF-a
|
|
i wyniki do arkusza), a wpis ma powstać raz."""
|
|
for pozycja, zbior in ((ekran, SCREENS), (rozszerzenie, EXTRAS)):
|
|
if pozycja and pozycja.key not in {f.key for f in zbior}:
|
|
zbior.append(pozycja)
|
|
if nawigacja and nawigacja[0] not in {p[0] for p in NAWIGACJA_SPECJALNA}:
|
|
NAWIGACJA_SPECJALNA.append(nawigacja)
|
|
ROUTES.update(trasy or {})
|
|
STATIC.update(zasoby or {})
|
|
_przelicz()
|
|
|
|
|
|
# Funkcje wnoszone przez moduły opcjonalne — zgłaszane tak samo jak ekrany,
|
|
# tylko przez neutralny most, żeby katalog nie musiał ich wymieniać z nazwy.
|
|
for _krotka in rozszerzenia.funkcje():
|
|
zarejestruj(rozszerzenie=Feature(*_krotka))
|
|
zarejestruj(trasy=rozszerzenia.trasy(), zasoby=rozszerzenia.zasoby())
|
|
|
|
|
|
# ── mapa trasa → wymagane uprawnienie ────────────────────────────────────
|
|
# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa
|
|
# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu.
|
|
|
|
|
|
# ── zasoby statyczne ─────────────────────────────────────────────────────
|
|
# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
|
|
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `compile.js`
|
|
# zdradza składanie raportu, a `wheelzoom.js` — istnienie kosmogramu. Dlatego każdy
|
|
# zasób przechodzi przez tę samą bramkę co ekrany.
|
|
#
|
|
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
|
|
# siedzi na trzech ekranach i konto z którymkolwiek z nich musi ją dostać.
|
|
# `None` = każdy zalogowany. Zasób spoza mapy wymaga admina — jak trasa.
|
|
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
|
|
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
|
|
_KOLO = frozenset({"chart", "compile"})
|
|
|
|
|
|
|
|
def static_required(name: str) -> frozenset[str] | str | None:
|
|
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
|
|
return STATIC.get(name, ADMIN)
|
|
|
|
|
|
def required(method: str, path: str) -> frozenset[str] | str | None:
|
|
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
|
|
|
|
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
|
|
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
|
|
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
|
|
if path.startswith("/static/"):
|
|
return static_required(path[len("/static/"):])
|
|
return ROUTES.get((method.upper(), path), ADMIN)
|
|
|
|
|
|
def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
|
|
"""Czy te uprawnienia otwierają tę funkcję.
|
|
|
|
Zbiór znaczy „wystarczy którekolwiek": jeden zasób bywa wspólny dla kilku
|
|
ekranów i nie ma powodu wymagać ich wszystkich naraz."""
|
|
if feature is None:
|
|
return True
|
|
if isinstance(feature, str):
|
|
return feature in perms
|
|
return bool(perms & feature)
|
|
|
|
|
|
def screens_for(perms: frozenset[str]) -> list[Feature]:
|
|
"""Zakładki do pokazania w nawigacji — w kolejności zadeklarowanej, nie w tej,
|
|
w której akurat wykonały się importy."""
|
|
return [f for f in SCREENS if f.key in perms]
|
|
|
|
|
|
def normalise(keys) -> frozenset[str]:
|
|
"""Zbiór uprawnień z wejścia formularza: tylko znane klucze, bez ADMIN-a.
|
|
|
|
ADMIN odsiewamy TUTAJ, a nie w handlerze: konto administracyjne pochodzi
|
|
wyłącznie z konfiguracji środowiska, więc żadne wysłanie formularza — nawet
|
|
spreparowane — nie może go nadać."""
|
|
return frozenset(k for k in (keys or ()) if k in GRANTABLE)
|