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
@@ -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] = []