feat(astroklient): pule plików per konto i izolacja od produkcji (PRE-29)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m17s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Testy astroklienta (wersja demo) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Successful in 4s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m21s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m25s
Testy / Testy astroklienta (wersja demo) (pull_request) Successful in 9m26s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 5s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 4s

Demo ma być rozdawane szeroko i różnym osobom, więc pierwsza wersja — jedno konto
na produkcyjnej warstwie danych — nie nadawała się do użycia: każdy dostawałby
dostęp do oryginalnych baz, a wgrania jednego klienta widzieliby wszyscy.

IZOLACJA OD PRODUKCJI. Warstwa danych i logiczna demo są osobne (manifesty w repo
deploy). Osobna musi być TEŻ LOGICZNA, bo zna ona jeden adres warstwy danych —
demo korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy.

PULE PER KONTO w warstwie danych. Zapytanie i lista plików niosą nazwę puli;
puste = cały udział, czyli produkcja działa dokładnie jak dotąd i o pulach nic
nie wie. Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne
w nazwie katalogu — „../..” albo ukośnik wyprowadziłyby zapytanie wprost do cudzych
baz, więc sito ZAMIENIA podejrzane znaki zamiast ufać, że nikt ich nie poda.

PULA MUSI BYĆ W KLUCZU CACHE ZAPYTAŃ. Bez tego wynik policzony dla jednego konta
trafiłby z cache do drugiego — cicha wymiana treści baz między klientami,
niewidoczna w logach i nie do wykrycia z zewnątrz. Osobny test tego pilnuje.

PULA WYNIKA Z LOGINU, nigdy z żądania. Klient warstwy logicznej jest budowany
per żądanie i związany z pulą zalogowanej osoby; gdyby nazwa przychodziła
z formularza, wystarczyłoby podstawić cudzy login. Test wysyła `tenant`, `user`
i `login` w polach formularza i sprawdza, że nie mają na nią wpływu.

Pulę wstrzykujemy w INSTANCJĘ klienta, nie w sygnatury metod. Argumentem trzeba
by ją przeprowadzić przez protokół DataSource i build_report — kod, który o kontach
nie ma prawa nic wiedzieć — a każde nowe wywołanie byłoby okazją, żeby o nią
zapomnieć i sięgnąć nie tam.

Konta demo to lista `login:sekret` (DEMO_USERS), bo jedno wspólne konto oznaczałoby
wspólną pulę. Format i skrypt haseł te same, co w głównej aplikacji.

Pula klienta to JEDEN KATALOG, więc przejście na pełną wersję nie oznacza utraty
wgrań — procedurę importu opisuje runbook w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:00:39 +02:00
parent a88ca14639
commit 2d7775f9b3
13 changed files with 301 additions and 79 deletions
+18 -7
View File
@@ -28,8 +28,18 @@ def _link() -> link_crypto.Link | None:
class DataClient:
def __init__(self, base_url: str | None = None) -> None:
"""Rozmówca warstwy danych, opcjonalnie ZWIĄZANY Z PULĄ KONTA (PRE-29).
Pula siedzi w INSTANCJI, nie w sygnaturach metod. Gdyby jechała argumentem,
trzeba by ją przeprowadzić przez protokół DataSource i przez build_report —
czyli przez kod, który o kontach nie ma prawa nic wiedzieć. A przy okazji
każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam.
Puste = cały udział, czyli zachowanie produkcji."""
def __init__(self, base_url: str | None = None, tenant: str = "") -> None:
self.base_url = (base_url or settings.data_url).rstrip("/")
self.tenant = tenant or ""
def search(
self,
@@ -39,7 +49,8 @@ class DataClient:
limit: int,
fields: list[str] | None = None,
) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
payload = {"key": key, "value": value, "exact": exact, "limit": limit,
"fields": fields, "tenant": self.tenant}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}/search",
payload=payload, headers=_auth_headers(),
@@ -58,20 +69,20 @@ class DataClient:
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
return self._files_post("/files", {"for_admin": for_admin})
return self._files_post("/files", {"for_admin": for_admin, "tenant": self.tenant})
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
return self._files_post("/files/status", {"path": path, "status": status, "by": by, "tenant": self.tenant})
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/upload",
{"filename": filename, "content_b64": content_b64, "by": by})
{"filename": filename, "content_b64": content_b64, "by": by, "tenant": self.tenant})
def files_delete(self, path: str) -> dict[str, Any]:
return self._files_post("/files/delete", {"path": path})
return self._files_post("/files/delete", {"path": path, "tenant": self.tenant})
def files_rules(self, rules: dict) -> dict[str, Any]:
return self._files_post("/files/rules", {"rules": rules})
return self._files_post("/files/rules", {"rules": rules, "tenant": self.tenant})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
+16 -8
View File
@@ -128,29 +128,33 @@ def bases() -> dict:
class FilesQuery(BaseModel):
for_admin: bool = False
tenant: str = ""
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
tenant: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
tenant: str = ""
class RulesUpdate(BaseModel):
rules: dict
tenant: str = ""
def _files_call(fn, *args, **kw) -> dict:
def _files_call(fn, *args, tenant: str = "", **kw) -> dict:
from app.clients.data_client import DataClient
try:
return fn(DataClient(), *args, **kw)
return fn(DataClient(tenant=tenant), *args, **kw)
except httpx.HTTPStatusError as e:
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
@@ -170,27 +174,27 @@ def _detail(e: httpx.HTTPStatusError) -> str:
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
return _files_call(lambda c: c.files_list(q.for_admin))
return _files_call(lambda c: c.files_list(q.for_admin), tenant=q.tenant)
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
return _files_call(lambda c: c.files_status(a.path, a.status, a.by))
return _files_call(lambda c: c.files_status(a.path, a.status, a.by), tenant=a.tenant)
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by))
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by), tenant=u.tenant)
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
return _files_call(lambda c: c.files_delete(a.path))
return _files_call(lambda c: c.files_delete(a.path), tenant=a.tenant)
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return _files_call(lambda c: c.files_rules(u.rules))
return _files_call(lambda c: c.files_rules(u.rules), tenant=u.tenant)
@app.post("/chart/synastry")
@@ -243,6 +247,10 @@ class ReportRequest(BaseModel):
lon: float = 0.0
limit: int = 5000
group: bool = False # grupowanie identycznych opisów
# Pula konta (PRE-29) — puste = cały udział. Warstwa logiczna niczego tu nie
# rozstrzyga, tylko przenosi: kto do jakiej puli ma prawo, wie warstwa
# prezentacji, bo to ona zna konta.
tenant: str = ""
@app.post("/chart/report")
@@ -258,7 +266,7 @@ def chart_report(req: ReportRequest) -> dict:
chart = build_chart(engine, moment) # pozycje z domami + aspekty
try:
report = build_report(
chart["positions"], DataClient(),
chart["positions"], DataClient(tenant=req.tenant),
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
per_object_limit=req.limit, group=req.group,
)