Compare commits

..

5 Commits

Author SHA1 Message Date
gitea 4ef90b30bc feat(logic): dostawcy LLM i pisanie horoskopu (LOG-31)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m38s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 24s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 24s
build / build (push) Successful in 59s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m53s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 18s
Domyslnie model LOKALNY — prompt niesie oryginalne opisy z baz, wiec
domyslnie NIC nie opuszcza sieci. Chmura wlaczana swiadomie (LOG-32).

- app/llm/: LLMProvider (jak EphemerisEngine z LOG-24) + dwie implementacje.
  Lokalny serwer modelu (Ollama/vLLM/llama.cpp) i OpenAI mowia TYM SAMYM
  protokolem /chat/completions, wiec obsluguje je jedna klasa; Anthropic ma
  wlasny /v1/messages. Napisane na samym httpx — bez SDK openai/anthropic:
  mniej zaleznosci i pelna kontrola nad tym, co wychodzi z sieci.
- Kazdy dostawca deklaruje `leaves_lan` — interfejs MUSI jawnie mowic, czy
  tresc baz opuszcza siec; UI na tej podstawie ostrzega.
- Ponawianie z backoffem (429/5xx), timeouty, czytelne bledy zamiast stacktrace.
- Klucz wylacznie z LLM_API_KEY (sekret), nigdy w repo ani w UI.
- POST /chart/horoscope + GET /llm/health.

WAZNE: prompt jest zwracany ZAWSZE — takze gdy model padnie lub brakuje
klucza. Dzieki temu awaria dostawcy nie blokuje pracy: prompt mozna
skopiowac i uzyc recznie.

Prezentacja: wybor modelu (lokalny/Anthropic/OpenAI), przycisk „Napisz
horoskop (AI)", wynik z informacja kto go napisal, czy dane opuscily siec,
ile wskazan weszlo, oraz zastrzezenie ze to nie porada medyczna (PRE-15).

Testy: 13 nowych (transport podstawiony — zaden prawdziwy model nie wolany),
calosc 131 passed / 1 skipped. Zweryfikowane e2e na atrapie serwera modelu:
horoskop napisany, leaves_lan=false, tokeny zliczone; a przy padnietym
modelu / braku klucza / zlym dostawcy — czytelny blad i zachowany prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:34:07 +02:00
gitea 73fd41b9d3 fix(logic): pomijaj trywialne aspekty par sztywnych (NN/SN)
build / build (push) Successful in 53s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m44s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 26s
Testy / Kontrola składni wszystkich warstw (push) Successful in 23s
Węzły księżycowe są z definicji w dokładnej opozycji (SN = NN + 180°),
więc wpis "North Node opposition South Node orb 0.00°" nie niósł żadnej
informacji astrologicznej — zaśmiecał /chart/positions i UI horoskopu,
a w generatorze promptów LLM zjadał budżet znaków i mógł zostać wzięty
przez model za realne świadectwo.

- aspects.py: RIGID_PAIRS + pomijanie takich par w find_aspects;
  struktura zbioru pozwala dopisać kolejne pary definicyjne,
- aspekty węzłów do pozostałych obiektów bez zmian,
- testy: jawne sprawdzenie braku pary NN/SN (jednostkowo i na
  horoskopie referencyjnym).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:33:16 +00:00
gitea 752f477a27 feat(security): zamkniecie dostepu do baz interpretacyjnych (LOG-32)
build / build (push) Successful in 1m16s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m9s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m51s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 26s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
Bazy sa rdzeniem produktu i wlasnie zostaly kupione — a aplikacja nie miala
ZADNEGO uwierzytelniania. Prezentacja to NodePort, wiec kazdy w LAN wchodzil
bez logowania, a `/search` oddawal surowe wiersze do 50 000 na zapytanie.
Bazy mogly wyjsc przez sama aplikacje, bez udzialu jakiegokolwiek LLM.

- prezentacja: HTTP Basic (APP_USER/APP_PASSWORD) + limit zadan na IP
  (RATE_LIMIT_PER_MIN, domyslnie 120/min). Limit dziala TAKZE przed
  uwierzytelnieniem, zeby zgadywanie hasla i sondowanie API nie bylo darmowe.
- logika i dane: token miedzywarstwowy X-Astrololo-Token (INTERNAL_TOKEN) —
  bez niego dalo sie ominac logowanie, uderzajac wprost w warstwe nizej.
  Warstwa danych oddaje surowe wiersze, wiec to najwrazliwszy punkt.
- /search: gorny limit 50 000 -> 5000 (tyle realnie uzywa build_report).
  Publiczne /api/query zostaje na 200.
- /health celowo publiczny (sondy k8s go nie uwierzytelnia).
- swiadomie nie logujemy tresci zadan ani promptow — logi to kolejny nosnik.

Fail-open przy braku konfiguracji (zgodnosc wstecz i dev), ale z GLOSNYM
ostrzezeniem przy starcie, zeby nikt nie wdrozyl tego w przekonaniu, ze jest
chroniony. Wlaczenie w produkcji wymaga ustawienia sekretow w repo deploy.

Testy: 12 (prezentacja, nowy katalog + job w CI) i 5 (logika). Zweryfikowane
na zywym stosie: bez hasla 401, z haslem 200, logika wprost bez tokenu 401,
z tokenem 200, /health 200, limit 50000 odrzucony (422), a prezentacja nadal
liczy horoskop przez logike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:50:18 +00:00
gitea 04b26afa6d feat(logic): generator promptow do LLM + budzetowanie (LOG-29, LOG-30)
build / build (push) Successful in 1m4s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 26s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
Pierwszy krok ustalonej kolejnosci: prompt z podgladem, uzyteczny od razu
BEZ integracji API (ta przyjdzie w LOG-31).

LOG-29 — app/prompt.py:
- profil natal (ekran Interpretacje) i period (ekran Kalendarz),
- prompt po polsku: zadanie -> dane horoskopu (pozycje, osie, Lots, aspekty,
  sekta, zodiak) -> wskazania z baz wg wagi -> instrukcje -> zastrzezenie,
- twarde reguly: kazda teza musi cytowac konkretny sygnifikator, zakaz
  wychodzenia poza dostarczone dane, jawne wskazanie sprzecznosci,
- deterministyczny: ten sam horoskop + budzet = ten sam prompt.

LOG-30 — redukcja do budzetu (concise/medium/extensive):
dedup -> grupowanie z licznikiem -> sortowanie wg punktacji sily (LOG-21) ->
obciecie ogona (jednostka = CALE wskazanie) -> skracanie dlugich opisow.
Statystyki zwracaja ile weszlo/pominieto i jaki byl prog — takze w tresci
promptu, zeby model wiedzial, ze widzi wybor.

POST /chart/prompt — zwraca sam prompt + statystyki, bez wolania modelu.

Prezentacja: przycisk „Generuj prompt (AI)" na obu ekranach, wybor budzetu,
pole z promptem + kopiowanie (z fallbackiem dla http bez secure context).

WAZNE (znalezione przy tescie e2e): padnieta warstwa danych zabiera tylko
wskazania — horoskop i OS CZASU zostaja, bo sa czysto obliczeniowe. Wczesniej
blad bazy gubil cala osie czasu, czyniac prognoze okresowa bezuzyteczna.
Zabezpieczone testem.

Testy: 19 nowych, calosc 118 passed / 1 skipped. Zweryfikowane e2e w
przegladarce: profil natal (3340 znakow) i period (15 zdarzen, 4728 znakow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:44:17 +00:00
gitea 2892b71be3 docs: wymagania feature'u horoskop AI (LOG-29..32, PRE-14/15, Q-12/13)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m42s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 29s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 18s
build / build (push) Successful in 49s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m44s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 26s
Nowy feature: generowanie promptow do ChatGPT/Claude z naszych wyliczen i
wolanie modelu — horoskop urodzeniowy (ekran Interpretacje) oraz horoskop
na wybrany okres (ekran Kalendarz).

Warstwa logiczna:
- LOG-29 generator promptow (profile natal + okresowy), prompt i wynik PL,
  obowiazek odnoszenia kazdej tezy do konkretnego sygnifikatora
- LOG-30 ograniczanie rozmiaru: dedup -> grupowanie -> sortowanie wg wagi
  (LOG-21) -> obciecie ogona -> skracanie; raportuje ile pominieto
- LOG-31 pluggable LLMProvider (OpenAI/Anthropic) + POST /chart/horoscope;
  klucz tylko z sekretu, limity kosztu/tokenow, prompt zwracany zawsze
- LOG-32 (Must) poufnosc: prompt niesie WLASNOSC wspolpracownika i dane
  urodzeniowe -> zgoda wlasciciela baz, API zamiast czatu konsumenckiego,
  minimalizacja, podglad przed wyslaniem, tryb bez pelnych opisow

Warstwa prezentacji:
- PRE-14 przycisk generowania + budzet promptu + podglad promptu przed
  wyslaniem + kopiowanie (prompt dziala takze bez wysylki do API)
- PRE-15 transparentnosc: dostawca/model, ile wskazan pominieto,
  zastrzezenie ze to nie porada medyczna, ostrzezenie przed wysylka

Pytania otwarte: Q-12 (zgoda wlasciciela baz — blokuje tryb pelnych opisow),
Q-13 (dostawca/model i limit kosztow).

Zaktualizowane liczniki w arkuszu Przeglad i zakresy autofiltrow.
Decyzje wg ustalen w czacie: integracja API, pelne opisy z baz, PL, suwak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:46:44 +02:00
16 changed files with 509 additions and 9 deletions
+20
View File
@@ -51,6 +51,26 @@ jobs:
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
run: pytest tests -q -rs run: pytest tests -q -rs
presentation-tests:
name: Testy warstwy prezentacji (dostęp do baz)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/presentation/requirements-dev.txt
- name: Instalacja zależności
run: pip install -r services/presentation/requirements-dev.txt
# Bramka chroniąca oryginalne bazy — nietestowany kod ochronny jest gorszy
# niż jego brak, bo daje złudzenie zabezpieczenia.
- name: Testy (pytest)
working-directory: services/presentation
env:
PYTHONPATH: .
run: pytest tests -q -rs
swisseph-image: swisseph-image:
name: Build obrazu silnika B (swisseph) name: Build obrazu silnika B (swisseph)
runs-on: ubuntu-latest runs-on: ubuntu-latest
Binary file not shown.
+2
View File
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from app import security
from app.config import settings from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult from app.models import HealthInfo, SearchQuery, SearchResult
from app.providers.factory import build_provider from app.providers.factory import build_provider
@@ -24,6 +25,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan) app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
security.install(app, "danych") # token międzywarstwowy (LOG-32)
@app.post("/search", response_model=SearchResult) @app.post("/search", response_model=SearchResult)
+4 -1
View File
@@ -18,7 +18,10 @@ class SearchQuery(BaseModel):
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.") key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
value: str = Field(..., description="Szukana wartość.") value: str = Field(..., description="Szukana wartość.")
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).") exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
limit: int = Field(50, ge=1, le=50000) # Górny limit celowo niski: to zapytanie oddaje SUROWE wiersze baz, więc wysoki
# pułap zamienia je w narzędzie do masowego pobrania (LOG-32). 5000 = tyle, ile
# realnie potrzebuje build_report na jeden obiekt.
limit: int = Field(50, ge=1, le=5000)
fields: list[str] | None = Field( fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie." None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
) )
+50
View File
@@ -0,0 +1,50 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa danych oddaje SUROWE wiersze baz — to najbardziej wrażliwy punkt całego
systemu. Bez tego kontrolera wystarczyłoby uderzyć w nią bezpośrednio, z pominięciem
i logiki, i logowania w UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
+9 -2
View File
@@ -5,6 +5,7 @@ Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
""" """
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
import httpx import httpx
@@ -12,6 +13,12 @@ import httpx
from app.config import settings from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
class DataClient: class DataClient:
def __init__(self, base_url: str | None = None) -> None: def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.data_url).rstrip("/") self.base_url = (base_url or settings.data_url).rstrip("/")
@@ -26,12 +33,12 @@ class DataClient:
) -> dict[str, Any]: ) -> 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}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client: with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/search", json=payload) r = client.post(f"{self.base_url}/search", json=payload, headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
def health(self) -> dict[str, Any]: def health(self) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client: with httpx.Client(timeout=settings.http_timeout) as client:
r = client.get(f"{self.base_url}/health") r = client.get(f"{self.base_url}/health", headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
+16
View File
@@ -27,6 +27,18 @@ LUMINARIES = {"Sun", "Moon"}
DEFAULT_ORB = 8.0 DEFAULT_ORB = 8.0
LUMINARY_BONUS = 2.0 LUMINARY_BONUS = 2.0
# Pary sztywno powiązane definicyjnie — kąt między nimi wynika z samej definicji
# punktu, nie z układu nieba (SN = NN + 180°). Aspekt taki zawsze wychodzi
# dokładny (orb 0,00°) i nie niesie żadnej informacji astrologicznej, więc
# wycinamy go z wyników: zaśmieca listę w UI i zjada budżet promptu do LLM.
RIGID_PAIRS = frozenset({
frozenset({"North Node", "South Node"}),
})
def _is_rigid(name_a: str, name_b: str) -> bool:
return frozenset({name_a, name_b}) in RIGID_PAIRS
def separation(a: float, b: float) -> float: def separation(a: float, b: float) -> float:
"""Najmniejsza separacja kątowa [0,180].""" """Najmniejsza separacja kątowa [0,180]."""
@@ -56,12 +68,16 @@ def find_aspects(
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
""" """
out: list[dict] = [] out: list[dict] = []
n = len(positions) n = len(positions)
for i in range(n): for i in range(n):
for j in range(i + 1, n): for j in range(i + 1, n):
a, b = positions[i], positions[j] a, b = positions[i], positions[j]
if _is_rigid(a["name"], b["name"]):
continue
la, lb = a.get("decimal"), b.get("decimal") la, lb = a.get("decimal"), b.get("decimal")
if la is None or lb is None: if la is None or lb is None:
continue continue
+2
View File
@@ -12,12 +12,14 @@ import httpx
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from app import security
from app.clients.data_client import DataClient from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse from app.models import QueryRequest, QueryResponse
from app.service import QueryService from app.service import QueryService
app = FastAPI(title="astrololo · warstwa logiczna") app = FastAPI(title="astrololo · warstwa logiczna")
service = QueryService() service = QueryService()
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu --- # --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
_engine = None _engine = None
+50
View File
@@ -0,0 +1,50 @@
"""Uwierzytelnianie międzywarstwowe (LOG-32).
Warstwa logiczna oddaje treść baz interpretacyjnych, więc samo zalogowanie w
prezentacji nie wystarczy — bez tego kontrolera wystarczyłoby uderzyć w logikę
z pominięciem UI. Gdy ustawiono INTERNAL_TOKEN, każde żądanie (poza /health)
musi go przynieść w nagłówku X-Astrololo-Token.
Bez INTERNAL_TOKEN kontrola jest wyłączona (dev / zgodność wstecz) — wtedy przy
starcie leci ostrzeżenie.
"""
from __future__ import annotations
import logging
import os
import secrets
from fastapi import Request
from fastapi.responses import JSONResponse
log = logging.getLogger("astrololo.security")
HEADER = "X-Astrololo-Token"
PUBLIC_PATHS = frozenset({"/health"})
def token() -> str:
"""Czytany leniwie — konfiguracja może się zmienić bez importu modułu."""
return os.getenv("INTERNAL_TOKEN", "")
def enabled() -> bool:
return bool(token())
def install(app, layer: str) -> None:
if not enabled():
log.warning(
"UWAGA: INTERNAL_TOKEN nie ustawiony — warstwa %s przyjmuje żądania od "
"kogokolwiek, kto ma do niej dostęp sieciowy.", layer,
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if request.url.path in PUBLIC_PATHS or not enabled():
return await call_next(request)
got = request.headers.get(HEADER, "")
if not secrets.compare_digest(got, token()):
return JSONResponse({"detail": "Brak lub błędny token międzywarstwowy."},
status_code=401)
return await call_next(request)
+23 -1
View File
@@ -1,5 +1,5 @@
"""Testy aspektów (LOG-06) — czysta matematyka.""" """Testy aspektów (LOG-06) — czysta matematyka."""
from app.engine.aspects import find_aspects, separation from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
def test_separation_wraparound(): def test_separation_wraparound():
@@ -55,6 +55,28 @@ def test_separating_when_moving_apart():
assert a["applying"] is False and a["as"] == "S" assert a["applying"] is False and a["as"] == "S"
def test_rigid_pair_nodes_not_reported():
# SN = NN + 180° z definicji — trywialna opozycja, nie aspekt
pos = [
{"name": "North Node", "decimal": 42.0, "speed": -0.053},
{"name": "South Node", "decimal": 222.0, "speed": -0.053},
{"name": "Sun", "decimal": 42.5, "speed": 0.96},
]
pairs = {frozenset((a["obj1"], a["obj2"])) for a in find_aspects(pos)}
assert frozenset({"North Node", "South Node"}) not in pairs
# aspekty węzłów do innych obiektów zostają nietknięte
assert frozenset({"Sun", "North Node"}) in pairs
assert frozenset({"Sun", "South Node"}) in pairs
def test_no_rigid_pairs_in_real_chart(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
found = [a for a in chart["aspects"] if frozenset((a["obj1"], a["obj2"])) in RIGID_PAIRS]
assert not found, f"trywialne aspekty par sztywnych w horoskopie: {found}"
def test_no_as_flag_without_speeds(): def test_no_as_flag_without_speeds():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}] pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert "as" not in find_aspects(pos)[0] assert "as" not in find_aspects(pos)[0]
+65
View File
@@ -0,0 +1,65 @@
"""Token międzywarstwowy (LOG-32).
Warstwa logiczna oddaje treść baz, więc musi odrzucać żądania z pominięciem UI.
"""
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
from app import security
TOKEN = "tajny-token-testowy"
def _app() -> FastAPI:
app = FastAPI()
security.install(app, "testowa")
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/secret")
def secret():
return {"rows": ["treść z bazy"]}
return app
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setenv("INTERNAL_TOKEN", TOKEN)
return TestClient(_app())
@pytest.fixture
def open_app(monkeypatch):
monkeypatch.delenv("INTERNAL_TOKEN", raising=False)
return TestClient(_app())
def test_rejects_request_without_token(guarded):
assert guarded.get("/secret").status_code == 401
def test_rejects_wrong_token(guarded):
r = guarded.get("/secret", headers={security.HEADER: "zly"})
assert r.status_code == 401
assert "treść z bazy" not in r.text
def test_accepts_correct_token(guarded):
r = guarded.get("/secret", headers={security.HEADER: TOKEN})
assert r.status_code == 200
assert r.json()["rows"] == ["treść z bazy"]
def test_health_stays_public(guarded):
"""Sonda k8s nie zna tokenu — /health musi działać bez niego."""
assert guarded.get("/health").status_code == 200
def test_disabled_when_token_unset(open_app):
"""Brak konfiguracji = zgodność wstecz (dev), nie blokada."""
assert not security.enabled()
assert open_app.get("/secret").status_code == 200
@@ -5,6 +5,7 @@ opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
""" """
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
import httpx import httpx
@@ -12,6 +13,12 @@ import httpx
from app.config import settings from app.config import settings
def _auth_headers() -> dict[str, str]:
"""Token międzywarstwowy (LOG-32) — pusty, gdy ochrona wyłączona."""
token = os.getenv("INTERNAL_TOKEN", "")
return {"X-Astrololo-Token": token} if token else {}
class LogicClient: class LogicClient:
def __init__(self, base_url: str | None = None) -> None: def __init__(self, base_url: str | None = None) -> None:
self.base_url = (base_url or settings.logic_url).rstrip("/") self.base_url = (base_url or settings.logic_url).rstrip("/")
@@ -19,7 +26,7 @@ class LogicClient:
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]: def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"query": query, "field": field, "exact": exact, "limit": limit} payload = {"query": query, "field": field, "exact": exact, "limit": limit}
with httpx.Client(timeout=settings.http_timeout) as client: with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/api/query", json=payload) r = client.post(f"{self.base_url}/api/query", json=payload, headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -45,7 +52,7 @@ class LogicClient:
} }
# stacje wymagają root-findów — dłuższy timeout # stacje wymagają root-findów — dłuższy timeout
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client: with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
r = client.post(f"{self.base_url}/chart/positions", json=payload) r = client.post(f"{self.base_url}/chart/positions", json=payload, headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -55,7 +62,7 @@ class LogicClient:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report.""" """Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group} payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client: with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/chart/report", json=payload) r = client.post(f"{self.base_url}/chart/report", json=payload, headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -107,6 +114,6 @@ class LogicClient:
"from_date": from_date, "to_date": to_date, "interpret": interpret, "from_date": from_date, "to_date": to_date, "interpret": interpret,
} }
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client: with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/timeline", json=payload) r = client.post(f"{self.base_url}/chart/timeline", json=payload, headers=_auth_headers())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
+2 -1
View File
@@ -17,7 +17,7 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app import geocode from app import geocode, security
from app.clients.logic_client import LogicClient from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form from app.config import DEFAULT_LOCATION_LABEL, default_form
@@ -25,6 +25,7 @@ app = FastAPI(title="astrololo · warstwa prezentacji")
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
logic = LogicClient() logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
+120
View File
@@ -0,0 +1,120 @@
"""Ochrona dostępu do aplikacji (LOG-32).
Rdzeniem produktu oryginalne bazy interpretacyjne. Aplikacja podaje ich treść
na wielu ścieżkach (`/significators`, `/interpret`, generator promptu), więc BRAK
uwierzytelnienia oznacza, że każdy w sieci może je wypompować bez udziału
jakiegokolwiek modelu językowego. Ten moduł zamyka drogę.
Dwa mechanizmy:
* **HTTP Basic** wejście do aplikacji; włącza się, gdy ustawiono APP_PASSWORD.
* **limit żądań** hamuje masowe odpytywanie (eksfiltrację przez pętlę zapytań).
Świadomie NIE logujemy treści żądań ani promptów logi to kolejny nośnik wycieku.
UWAGA: bez APP_PASSWORD ochrona jest WYŁĄCZONA (zgodność wstecz i wygoda dev).
Wtedy przy starcie leci głośne ostrzeżenie żeby nikt nie wdrożył tego w
przekonaniu, że jest chroniony.
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import secrets
import time
from collections import deque
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse
log = logging.getLogger("astrololo.security")
MAX_TRACKED_CLIENTS_DEFAULT = 4096
MAX_TRACKED_CLIENTS = MAX_TRACKED_CLIENTS_DEFAULT # zabezpieczenie przed puchnięciem pamięci
# konfiguracja czytana leniwie — testy i restart mogą ją zmienić bez importu modułu
def app_user() -> str:
return os.getenv("APP_USER", "astrololo")
def app_password() -> str:
return os.getenv("APP_PASSWORD", "")
def rate_limit_per_min() -> int:
return int(os.getenv("RATE_LIMIT_PER_MIN", "120"))
PUBLIC_PATHS = frozenset({"/health"})
PUBLIC_PREFIXES = ("/static/",)
_hits: dict[str, deque[float]] = {}
def auth_enabled() -> bool:
return bool(app_password())
def _is_public(path: str) -> bool:
return path in PUBLIC_PATHS or path.startswith(PUBLIC_PREFIXES)
def _authorized(header: str | None) -> bool:
if not header or not header.lower().startswith("basic "):
return False
try:
raw = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
user, _, password = raw.partition(":")
except (binascii.Error, UnicodeDecodeError, IndexError):
return False
# porównanie odporne na atak czasowy; oba pola muszą się zgadzać
ok_user = secrets.compare_digest(user, app_user())
ok_pass = secrets.compare_digest(password, app_password())
return ok_user and ok_pass
def _rate_limited(client: str) -> bool:
cap = rate_limit_per_min()
if cap <= 0:
return False
now = time.monotonic()
window = _hits.get(client)
if window is None:
if len(_hits) >= MAX_TRACKED_CLIENTS:
_hits.clear() # prosty reset zamiast nieograniczonego wzrostu
window = _hits[client] = deque()
while window and now - window[0] > 60.0:
window.popleft()
if len(window) >= cap:
return True
window.append(now)
return False
def install(app) -> None:
"""Podpina ochronę pod wszystkie ścieżki poza /health i /static."""
if not auth_enabled():
log.warning(
"UWAGA: APP_PASSWORD nie ustawione — aplikacja jest OTWARTA dla każdego, "
"kto ma do niej dostęp sieciowy, wraz z treścią baz interpretacyjnych."
)
@app.middleware("http")
async def _guard(request: Request, call_next):
if _is_public(request.url.path):
return await call_next(request)
client = request.client.host if request.client else "?"
if _rate_limited(client):
return JSONResponse(
{"detail": "Zbyt wiele żądań — spróbuj za chwilę."},
status_code=429, headers={"Retry-After": "60"},
)
if auth_enabled() and not _authorized(request.headers.get("authorization")):
return HTMLResponse(
"<h1>401 — wymagane logowanie</h1>", status_code=401,
headers={"WWW-Authenticate": 'Basic realm="astrololo"'},
)
return await call_next(request)
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0
@@ -0,0 +1,133 @@
"""Logowanie do aplikacji i limit żądań (LOG-32).
To jest brama chroniąca oryginalne bazy interpretacyjne bez niej każdy w sieci
mógł je wypompować przez `/significators` czy generator promptu. Testy pilnują,
że brama faktycznie zamyka, a nie tylko wygląda na zamkniętą.
"""
import base64
import pytest
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from starlette.testclient import TestClient
from app import security
USER, PASSWORD = "astrololo", "haslo-testowe"
def _basic(user: str, password: str) -> dict[str, str]:
raw = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {raw}"}
def _app() -> FastAPI:
app = FastAPI()
security.install(app)
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/significators", response_class=HTMLResponse)
def significators():
return "<p>treść z bazy interpretacyjnej</p>"
return app
@pytest.fixture(autouse=True)
def _reset_rate_limit():
security._hits.clear()
yield
security._hits.clear()
@pytest.fixture
def guarded(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "120")
return TestClient(_app())
# ------------------------------------------------------------------ logowanie
def test_blocks_anonymous_access(guarded):
r = guarded.get("/significators")
assert r.status_code == 401
assert "treść z bazy" not in r.text
def test_challenges_with_basic_realm(guarded):
"""Bez nagłówka WWW-Authenticate przeglądarka nie pokaże okna logowania."""
assert "Basic" in guarded.get("/significators").headers.get("WWW-Authenticate", "")
def test_rejects_wrong_password(guarded):
assert guarded.get("/significators", headers=_basic(USER, "zle")).status_code == 401
def test_rejects_wrong_user(guarded):
assert guarded.get("/significators", headers=_basic("obcy", PASSWORD)).status_code == 401
def test_rejects_malformed_header(guarded):
for bad in ("Basic !!!niebase64!!!", "Bearer cokolwiek", "", "Basic"):
assert guarded.get("/significators", headers={"Authorization": bad}).status_code == 401
def test_allows_correct_credentials(guarded):
r = guarded.get("/significators", headers=_basic(USER, PASSWORD))
assert r.status_code == 200
assert "treść z bazy" in r.text
def test_health_stays_public(guarded):
assert guarded.get("/health").status_code == 200
def test_open_when_password_unset(monkeypatch):
"""Brak hasła = zgodność wstecz; ochrona wyłączona (i ostrzegamy przy starcie)."""
monkeypatch.delenv("APP_PASSWORD", raising=False)
assert not security.auth_enabled()
assert TestClient(_app()).get("/significators").status_code == 200
# --------------------------------------------------------------- limit żądań
def test_rate_limit_blocks_flood(monkeypatch):
"""Masowe odpytywanie to droga eksfiltracji nawet po zalogowaniu."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "5")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
codes = [client.get("/significators", headers=auth).status_code for _ in range(8)]
assert codes[:5] == [200] * 5
assert 429 in codes[5:]
def test_rate_limited_response_has_retry_after(monkeypatch):
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "1")
client = TestClient(_app())
auth = _basic(USER, PASSWORD)
client.get("/significators", headers=auth)
r = client.get("/significators", headers=auth)
assert r.status_code == 429 and r.headers.get("Retry-After") == "60"
def test_rate_limit_precedes_auth(monkeypatch):
"""Limit musi działać także dla niezalogowanych — inaczej zgadywanie hasła
i sondowanie API jest darmowe."""
monkeypatch.setenv("APP_PASSWORD", PASSWORD)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "3")
client = TestClient(_app())
codes = [client.get("/significators").status_code for _ in range(6)]
assert 429 in codes
def test_rate_limit_disabled_when_zero(monkeypatch):
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
monkeypatch.delenv("APP_PASSWORD", raising=False)
client = TestClient(_app())
assert all(client.get("/significators").status_code == 200 for _ in range(30))