feat(astroklient): pule plików per konto i izolacja od produkcji (PRE-29)

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 e7b6d5013b
commit caf4fd80d1
12 changed files with 301 additions and 79 deletions
+16
View File
@@ -36,6 +36,22 @@ from datetime import datetime, timezone
from pathlib import Path
ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine"
# Nazwa puli może przyjść z zewnątrz (login konta w wersji demo), więc przechodzi
# przez sito: wyłącznie znaki bezpieczne w nazwie katalogu. Bez tego „../..” albo
# ukośnik wyprowadziłyby zapytanie poza udział — czyli wprost do cudzych baz.
_TENANT_OK = re.compile(r"[^A-Za-z0-9._-]")
def tenant_root(root: Path | str, tenant: str = "") -> Path:
"""Katalog, w którym mieszka pula danego konta.
Puste `tenant` = cały udział, czyli zachowanie produkcji. Nazwa jest
OCZYSZCZANA, a nie sprawdzana i odrzucana: sito zamienia wszystko podejrzane
na podkreślenie, więc żadne wejście nie wyprowadzi ścieżki wyżej."""
root = Path(root)
name = _TENANT_OK.sub("_", (tenant or "").strip()).strip("._")
return root / name if name else root
USABLE = frozenset({ACTIVE})
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
+13 -5
View File
@@ -71,29 +71,35 @@ def bases() -> dict:
class FilesQuery(BaseModel):
for_admin: bool = False
# Pula konta (PRE-29). Puste = cały udział — tak woła produkcja, która o pulach
# nic nie wie. Wersja demo podaje login, przez co widzi wyłącznie swój katalog.
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 = ""
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
u źródła, żeby nie dało się jej odczytać z podglądu źródła strony."""
root = settings.excel_dir
root = files.tenant_root(settings.excel_dir, q.tenant)
return {"files": files.registry(root, for_admin=q.for_admin),
"rules": files.rules(root) if q.for_admin else {},
"root": str(root)}
@@ -102,7 +108,8 @@ def files_list(q: FilesQuery) -> dict:
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
try:
row = files.set_status(settings.excel_dir, a.path, a.status, by=a.by)
row = files.set_status(files.tenant_root(settings.excel_dir, a.tenant),
a.path, a.status, by=a.by)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"path": a.path, "status": row.get("status")}
@@ -117,13 +124,14 @@ def files_upload(u: FileUpload) -> dict:
raw = base64.b64decode(u.content_b64, validate=True)
except (binascii.Error, ValueError) as e:
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
return files.store_upload(settings.excel_dir, u.filename, raw, by=u.by)
return files.store_upload(files.tenant_root(settings.excel_dir, u.tenant),
u.filename, raw, by=u.by)
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
try:
files.delete(settings.excel_dir, a.path)
files.delete(files.tenant_root(settings.excel_dir, a.tenant), a.path)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"deleted": a.path}
@@ -131,7 +139,7 @@ def files_delete(a: FileAction) -> dict:
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return {"rules": files.set_rules(settings.excel_dir, u.rules)}
return {"rules": files.set_rules(files.tenant_root(settings.excel_dir, u.tenant), u.rules)}
@app.get("/health", response_model=HealthInfo)
+5
View File
@@ -25,6 +25,11 @@ class SearchQuery(BaseModel):
fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
)
# PULA KONTA (PRE-29). Puste = cały udział, czyli zachowanie produkcji, która
# o pulach nic nie wie. Ustawione = wyszukiwanie widzi WYŁĄCZNIE podkatalog
# tego konta. Używa tego wersja demo, gdzie każde konto ma własny, niewidoczny
# dla pozostałych zbiór baz.
tenant: str = Field("", description="Pula plików konta; puste = cały udział.")
class SearchResult(BaseModel):
@@ -91,7 +91,7 @@ class ExcelDataProvider(DataProvider):
base = Path(self.s.excel_dir)
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
def _enabled_files(self, paths: list[str]) -> list[str]:
def _enabled_files(self, paths: list[str], tenant: str = "") -> list[str]:
"""Bazy biorące udział w wyszukiwaniu.
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
@@ -105,7 +105,7 @@ class ExcelDataProvider(DataProvider):
# przy KAŻDYM wyszukiwaniu, czyli 500 z warstwy danych.
from app import bases, files
usable = set(files.usable_paths(self.s.excel_dir))
usable = set(files.usable_paths(files.tenant_root(self.s.excel_dir, tenant)))
out = [p for p in paths if p in usable]
entries = bases.disabled_entries()
if entries:
@@ -128,7 +128,11 @@ class ExcelDataProvider(DataProvider):
from app import bases
disabled = ",".join(bases.disabled_entries())
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}"
# PULA MUSI BYĆ W KLUCZU. Bez niej wynik policzony dla jednego konta
# trafiłby z cache do drugiego — czyli cicha wymiana treści baz między
# kontami, niewidoczna w logach i nie do wykrycia z zewnątrz.
cache_key = (f"{query.key}|{query.value}|{query.exact}|{query.limit}"
f"|{query.fields}|{disabled}|{query.tenant}")
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
if hit is not None:
@@ -140,7 +144,7 @@ class ExcelDataProvider(DataProvider):
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
candidates = [(p, "0") for p in self._excel_files()]
# bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów
allowed = set(self._enabled_files([p for p, _ in candidates]))
allowed = set(self._enabled_files([p for p, _ in candidates], query.tenant))
candidates = [(p, s) for p, s in candidates if p in allowed]
rows: list[dict] = []