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
@@ -110,3 +110,80 @@ def test_switching_a_base_off_is_visible_in_both_places(excel_dir):
p2.warmup()
assert p2.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total == 0
assert [b["enabled"] for b in p2.list_bases()] == [False]
# ── pule kont: izolacja (PRE-29) ─────────────────────────────────────────
# Najgroźniejsza rzecz w tej zmianie. Wyciek między pulami jest CICHY: klient
# zobaczyłby cudze opisy jako własny wynik i nikt by się nie zorientował.
def _pool(root, name, plik, tresc):
from app import files
pula = files.tenant_root(root, name)
pula.mkdir(parents=True, exist_ok=True)
_xlsx(pula / plik, rows=[(tresc, f"opis {tresc}")])
files.registry(pula) # przyjęcie zastanych
return pula
def test_each_account_sees_only_its_own_files(excel_dir):
from app import files
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
_pool(excel_dir, "klientB", "b.xlsx", "Ve Tau")
a = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientA"))]
b = [e["name"] for e in files.registry(files.tenant_root(excel_dir, "klientB"))]
assert a == ["a.xlsx"] and b == ["b.xlsx"]
def test_search_never_reaches_another_account_pool(excel_dir):
from app.models import SearchQuery
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
_pool(excel_dir, "klientB", "b.xlsx", "Ve Tau")
p = _provider(excel_dir)
p.warmup()
swoje = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA"))
cudze = p.search(SearchQuery(key="significator", value="Ve Tau", limit=10, tenant="klientA"))
assert swoje.total >= 1, "konto nie widzi własnej bazy"
assert cudze.total == 0, "konto sięgnęło do puli innego konta"
def test_the_query_cache_cannot_serve_one_account_the_answer_of_another(excel_dir):
"""Bez puli w kluczu cache wynik policzony dla jednego konta trafiłby do
drugiego — cicha wymiana treści baz, niewidoczna w logach."""
from app.models import SearchQuery
_pool(excel_dir, "klientA", "a.xlsx", "Ma Ari")
_pool(excel_dir, "klientB", "b.xlsx", "Ma Ari") # TA SAMA szukana wartość
p = _provider(excel_dir)
p.warmup()
a = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientA"))
b = p.search(SearchQuery(key="significator", value="Ma Ari", limit=10, tenant="klientB"))
assert a.total >= 1 and b.total >= 1
assert b.cache != "hit", "odpowiedź dla jednego konta wyszła z cache drugiego"
def test_a_crafted_pool_name_cannot_escape_the_share(excel_dir):
"""Nazwa puli przychodzi z zewnątrz (login), więc „../..” albo ukośnik
wyprowadziłyby zapytanie wprost do cudzych baz."""
from app import files
for zlosliwa in ("../..", "../klientB", "/etc", "a/../../b", "..", "."):
wynik = files.tenant_root(excel_dir, zlosliwa)
assert excel_dir in wynik.parents or wynik == excel_dir, f"ucieczka: {zlosliwa}{wynik}"
def test_no_pool_means_the_whole_share_so_production_is_untouched(excel_dir):
"""Produkcja nie wie o pulach i ma działać dokładnie jak dotąd."""
from app import files
from app.models import SearchQuery
_xlsx(excel_dir / "produkcyjna.xlsx")
p = _provider(excel_dir)
p.warmup()
assert files.tenant_root(excel_dir, "") == excel_dir
assert p.search(SearchQuery(key="significator", value="Ma Ari", limit=10)).total >= 1