Files
astrololo/services/data/app/main.py
T
gitea caf4fd80d1 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>
2026-08-26 11:57:05 +02:00

148 lines
5.4 KiB
Python

"""Warstwa BAZODANOWA — usługa HTTP.
Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszukać
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
ani prezentacji.
"""
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
import base64
import binascii
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from app import canary, files, link_crypto, security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from pydantic import BaseModel
from app.providers.factory import build_provider
provider = build_provider(settings)
@asynccontextmanager
async def lifespan(app: FastAPI):
provider.warmup() # zbuduj/odśwież indeks i cache przy starcie
yield
# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan,
docs_url=None, redoc_url=None, openapi_url=None)
security.install(app, "danych") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
link_crypto.install(app, link_crypto.ENV_LOGIC_DATA, "danych")
@app.post("/search", response_model=SearchResult)
def search(query: SearchQuery) -> SearchResult:
result = provider.search(query)
# Rekordy-pułapki (DAN-26) odsiewamy TU, na wyjściu z warstwy danych — dzięki
# temu nie dotrą ani wyżej, ani do promptu LLM (LOG-30), niezależnie od dostawcy.
visible, report = canary.screen(result.rows, query.value)
if report["active"]:
result.rows = visible
result.total = len(visible)
return result
@app.get("/bases")
def bases() -> dict:
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
Same METADANE — nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
listy nie jest kolejną drogą do ich wyniesienia."""
items = provider.list_bases()
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
# ── zarządzanie plikami baz (DAN-27) ─────────────────────────────────────
# Warstwa danych jest właścicielem plików, więc to ona nimi zarządza. Uprawnienia
# rozstrzyga PREZENTACJA (PRE-27) i przekazuje tu wynik jako `for_admin` / `by` —
# ta warstwa nie zna kont i nie ma jak ich znać. Nie jest to dziura: warstwa
# danych stoi za tokenem międzywarstwowym i szyfrowanym łączem, więc rozmawia
# z nią wyłącznie warstwa logiczna.
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 = 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)}
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
try:
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")}
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
"""Plik wędruje w base64 wewnątrz zaszyfrowanego łącza — tym samym kanałem,
co reszta ruchu międzywarstwowego. Osobny, nieszyfrowany kanał na pliki
byłby obejściem PRE-16."""
try:
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(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(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}
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return {"rules": files.set_rules(files.tenant_root(settings.excel_dir, u.tenant), u.rules)}
@app.get("/health", response_model=HealthInfo)
def health() -> HealthInfo:
return provider.health()