diff --git a/.env.example b/.env.example index c204860..defa395 100644 --- a/.env.example +++ b/.env.example @@ -588,13 +588,6 @@ KG__TOP_K=4 # codice e' su main. Vedi knowledge-graph#7 (blocco) e #6 (provider openai). #API_KG_BASE_URL=http://host.docker.internal:8000 -# ============================================================================= -# Clerk auth — DEPRECATO / RIMOSSO (non ammesso per la PA). -# ============================================================================= -# Sostituito dall'auth su database (blocco AUTH__ più sopra + src/limen/auth/, -# issue #49). Il codice di validazione JWT Clerk resta finché non si conclude -# la migrazione, ma è disattivato di default e non usato dal frontend. -CLERK__ENABLED=false # ============================================================================= # limen-ops MCP server (agent tools over Limen itself) diff --git a/CLAUDE.md b/CLAUDE.md index 5523287..251f4e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -216,7 +216,7 @@ src/limen/ │ ├── main.py # FastAPI app factory + lifespan │ ├── dependencies.py # AppDependencies + typed Depends() providers │ ├── schemas.py # Pydantic request/response DTOs -│ ├── endpoints/ # health/ready, aoi, monitor, risk, alerts, tiles +│ ├── endpoints/ # health/ready, aoi, risk, alerts, tiles │ └── jobs/ # APScheduler: hourly_monitoring, weekly_idrogeo_sync, │ # cache_cleanup, registration ├── notifications/ # NotificationChannel Protocol + AlertPayload + Telegram/MQTT/Email + dispatcher diff --git a/README.md b/README.md index a9c4fea..75f5742 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ persistenza. | **Backtest** | replay di una finestra storica con pioggia antecedente **CERRA** (5.5 km) + truth set e-ITALICA → hit rate / FAR / lead time vs target §2.5 | one-shot | `limen backtest` + `reports/backtest_*.md` | | **Workflow MAF (V1)** | AreaResolver → StaticFactors → MeteoFetch → SeismicCheck → FireCheck → \[SensorFetch?\] → RiskScoring → EscalationGate → RiskAnalyst → Briefing → PersistResult → AlertDispatch | one-shot CLI | `agents/` + `limen monitor-once` | | **Provider LLM** | precedenza `LLM__PROVIDER` > Anthropic > OpenAI > Foundry > **llama.cpp**; il resolver salta i provider cloud senza SDK e cade su llama.cpp (solo httpx). Ollama resta selezionabile con `LLM__PROVIDER=ollama`. Briefing in italiano; RiskAnalyst restituisce JSON tipizzato. | risolto all'avvio | `agents/llm_factory/resolve_llm_factory` | -| **API HTTP** | `/health` + `/ready`, `POST /api/monitor/{aoi}`, `GET /api/aoi/{id}/risk/latest`, `GET /api/cell/{id}/breakdown`, `GET /api/aoi`, `GET /api/alerts`, `/api/tiles/...`, OpenAPI su `/docs` e `/redoc` | FastAPI / uvicorn | `api/` + `limen serve` | +| **API HTTP** | `/health` + `/ready`, `GET /api/aoi/{id}/risk/latest`, `GET /api/cell/{id}/breakdown`, `GET /api/aoi`, `GET /api/alerts`, `/api/tiles/...`, OpenAPI su `/docs` e `/redoc` | FastAPI / uvicorn | `api/` + `limen serve` | | **Job periodici** | workflow MAF orario (con shadow ML), **sweep previsionale** ogni 6 h, **nowcast radar DPC** ogni 15 min, **hotspot NASA FIRMS** ogni 45 min, report nazionale giornaliero, sync ISPRA settimanale, **drift monitor ML** (PSI/KS training-vs-live sulle feature canoniche che lo shadow persiste), cache cleanup + retention di `model_runs` (default 30 gg) | APScheduler in-process | `api/jobs/` | | **Radar DPC (nowcast)** | SRI nazionale 1 km / 5 min (piattaforma radar DPC, CC-BY-SA): pioggia ≥ `NOWCAST__MIN_INTENSITY_MMH` su una regione ⇒ il workflow di quella AOI parte subito invece di aspettare il tick orario (cooldown 45 min; alert dal percorso operativo normale) | poll ogni `NOWCAST__INTERVAL_MINUTES` | `integrations/dpc/` + `api/jobs/nowcast_monitoring.py` | | **Hotspot NASA FIRMS** | detection attive VIIRS 375 m (S-NPP / NOAA-20 / NOAA-21) e MODIS 1 km con latenza NRT ~3 h: `FIRMS__MIN_HOTSPOTS` hotspot dentro una AOI ⇒ workflow immediato (cooldown `FIRMS__COOLDOWN_MINUTES`), e `months_since_fire` — quindi il fattore F post-incendio — si attiva in ore invece di attendere il perimetro EFFIS. Fail-closed: senza `FIRMS__MAP_KEY` il feed è inerte e il job non viene registrato | poll ogni `FIRMS__INTERVAL_MINUTES` (1 transazione per sorgente sul bbox nazionale) | `integrations/firms/` + `api/jobs/firms_monitoring.py` + `limen firms-sync` | diff --git a/docs/api.md b/docs/api.md index e7806f1..eb16409 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,8 +23,7 @@ precedenza. > temporale, da affiancare — mai sostituire — alle fonti e alle procedure > ufficiali. Vedi anche [`docs/warning-logic.md`](./warning-logic.md). -Gli esempi seguenti usano solo endpoint **read-only** (l'unico che scrive è -`POST /api/monitor/{aoi}`, mostrato a parte). Ogni esempio riassume la **forma +Gli esempi seguenti usano solo endpoint **read-only**.Ogni esempio riassume la **forma attesa della risposta**. ## Salute e prontezza @@ -61,51 +60,6 @@ Restituisce ogni riga della tabella `aoi`: ## Esegui un ciclo di monitoraggio -``` -curl -s -X POST http://localhost:8080/api/monitor/it-puglia \ - -H 'content-type: application/json' \ - -d '{"cell_limit": 25}' | jq -``` - -Corpo (opzionale): - -```json -{ - "cell_limit": 25, - "valuation_time": "2026-06-01T12:00:00+00:00" -} -``` - -Risposta: - -```json -{ - "aoi_id": "it-puglia", - "assessment_id": 4567, - "assessment": { - "aoi_id": "it-puglia", - "model_version": "limen-deterministic-v1", - "valuation_time": "2026-06-01T12:00:03.412678+00:00", - "n_cells": 25, - "cells_high_or_above": 2, - "cells_by_level": {"None": 21, "Low": 2, "High": 1, "VeryHigh": 1}, - "top_cells": [ - {"cell_id": "it-puglia|12|7", "score": 0.81, "level": "VeryHigh", ...} - ], - "analysis": {"driver": "meteo_trigger", "anomalies": [...], ...}, - "briefing_it": "Le condizioni osservate ..." - }, - "cells_scored": 25, - "high_or_above": 2, - "dispatched_alerts": [...] -} -``` - -Un AOI mancante restituisce `404`. Il workflow stesso non solleva mai -eccezioni sui fallimenti delle sorgenti esterne — degrada. - -## Ultima valutazione per-AOI - ``` curl -s http://localhost:8080/api/aoi/it-puglia/risk/latest | jq ``` @@ -208,7 +162,6 @@ I codici rilevanti: | Codice | Quando | |---|---| -| `404` | `POST /api/monitor/{aoi_id}` con un AOI sconosciuto | | `404` | `GET /api/aoi/{id}/risk/latest` senza alcuna valutazione persistita | | `503` | `/ready` mentre il lifespan è in fase di bootstrap | -| `503` | `/api/tiles/...` quando `API__PG_TILESERV_URL` non è configurato | +| `503` | `/api/tiles/...` quando `API__PG_TILESERV_URL` non è configurato | \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index bc0b90d..16a957f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,6 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-http>=1.27", "aiomqtt>=2.3", "aiosmtplib>=3.0", - "pyjwt[crypto]>=2.9", ] [project.scripts] diff --git a/src/limen/api/auth.py b/src/limen/api/auth.py deleted file mode 100644 index 1452886..0000000 --- a/src/limen/api/auth.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Clerk JWT validation for protected FastAPI endpoints. - -Off unless ``CLERK__ENABLED`` — the public, read-only map endpoints stay open -and dev/test need no Clerk config. When enabled, protected endpoints require a -Bearer Clerk **session JWT**, verified against the instance JWKS (public RSA -keys). The Clerk secret key is never used here. -""" - -from __future__ import annotations - -from functools import lru_cache -from typing import Annotated, Any - -import jwt -from fastapi import Depends, HTTPException, Request, status -from jwt import PyJWKClient - -from limen.api.dependencies import SettingsDep -from limen.config.settings import Settings -from limen.core.logging import get_logger - -log = get_logger(__name__) - - -@lru_cache(maxsize=8) -def _jwks_client(jwks_url: str) -> PyJWKClient: - # Cached per URL — PyJWKClient keeps its own signing-key cache across calls. - return PyJWKClient(jwks_url) - - -def _verify(token: str, settings: Settings) -> dict[str, Any]: - clerk = settings.clerk - if not clerk.jwks_url: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Clerk auth enabled but CLERK__JWKS_URL is unset", - ) - try: - signing_key = _jwks_client(clerk.jwks_url).get_signing_key_from_jwt(token) - claims: dict[str, Any] = jwt.decode( - token, - signing_key.key, - algorithms=["RS256"], - issuer=clerk.issuer, - options={"verify_aud": False, "require": ["exp", "iat"]}, - ) - except (jwt.InvalidTokenError, jwt.PyJWKClientError) as exc: - log.warning("clerk.jwt.invalid", error=str(exc)) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="invalid Clerk token", - headers={"WWW-Authenticate": "Bearer"}, - ) from exc - if clerk.authorized_parties and claims.get("azp") not in clerk.authorized_parties: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized party") - return claims - - -async def require_user(request: Request, settings: SettingsDep) -> dict[str, Any]: - """Require a valid Clerk JWT on protected endpoints. - - No-op (returns ``{}``) when Clerk auth is disabled, so the public map and - dev/test keep working without any Clerk configuration. - """ - if not settings.clerk.enabled: - return {} - header = request.headers.get("Authorization", "") - if not header.startswith("Bearer "): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="missing bearer token", - headers={"WWW-Authenticate": "Bearer"}, - ) - return _verify(header.removeprefix("Bearer "), settings) - - -RequireUser = Annotated[dict[str, Any], Depends(require_user)] diff --git a/src/limen/api/endpoints/__init__.py b/src/limen/api/endpoints/__init__.py index bc82dc1..9d4f82c 100644 --- a/src/limen/api/endpoints/__init__.py +++ b/src/limen/api/endpoints/__init__.py @@ -16,7 +16,6 @@ auth, comuni, health, - monitor, risk, tiles, ) @@ -26,7 +25,6 @@ def all_routers() -> Iterable[APIRouter]: return ( health.router, aoi.router, - monitor.router, risk.router, alerts.router, tiles.router, diff --git a/src/limen/api/endpoints/monitor.py b/src/limen/api/endpoints/monitor.py deleted file mode 100644 index 4c0586a..0000000 --- a/src/limen/api/endpoints/monitor.py +++ /dev/null @@ -1,67 +0,0 @@ -"""POST /api/monitor/{aoi_id} — run the MAF workflow once for an AOI.""" - -from __future__ import annotations - -from datetime import UTC, datetime - -from fastapi import APIRouter, HTTPException, status - -from limen.api.auth import RequireUser -from limen.api.dependencies import DepsDep -from limen.api.schemas import MonitorRequest, MonitorResponse -from limen.core.logging import get_logger -from limen.core.models.context import MonitoringContext - -log = get_logger(__name__) - -router = APIRouter(prefix="/api/monitor", tags=["monitor"]) - - -@router.post("/{aoi_id}", response_model=MonitorResponse) -async def run_monitor( - aoi_id: str, - deps: DepsDep, - _user: RequireUser, - body: MonitorRequest | None = None, -) -> MonitorResponse: - """Execute the Phase-4 workflow for ``aoi_id`` and persist the result. - - Protected: requires a valid Clerk JWT when ``CLERK__ENABLED`` (open - otherwise). The public read-only map endpoints stay unauthenticated. - """ - body = body or MonitorRequest() - workflow = deps.build_workflow(cell_limit=body.cell_limit) - ctx = MonitoringContext( - aoi_id=aoi_id, - valuation_time=body.valuation_time or datetime.now(UTC), - enable_insitu=deps.settings.enable_insitu, - ) - try: - result = await workflow.run(ctx) - except RuntimeError as exc: - # `AreaResolverExecutor` raises this when the AOI is unknown. - if "AOI" in str(exc) and "not found" in str(exc): - log.warning("monitor.aoi_missing", aoi_id=aoi_id) - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"AOI {aoi_id!r} not found", - ) from exc - raise - - out = result.context - high_or_above = out.assessment.cells_high_or_above if out.assessment is not None else 0 - log.info( - "monitor.done", - aoi_id=aoi_id, - cells=len(out.cell_results), - assessment_id=out.assessment_id, - high_or_above=high_or_above, - ) - return MonitorResponse( - aoi_id=aoi_id, - assessment_id=out.assessment_id, - assessment=out.assessment, - cells_scored=len(out.cell_results), - high_or_above=high_or_above, - dispatched_alerts=list(out.dispatched_alerts), - ) diff --git a/src/limen/api/schemas.py b/src/limen/api/schemas.py index ffabd44..668cc7c 100644 --- a/src/limen/api/schemas.py +++ b/src/limen/api/schemas.py @@ -51,30 +51,6 @@ class AoiListResponse(BaseModel): items: list[AoiSummary] -class MonitorRequest(BaseModel): - model_config = ConfigDict(extra="forbid") - - cell_limit: int | None = Field( - default=None, - ge=1, - description="Optional cap on the number of cells scored (smoke runs).", - ) - valuation_time: datetime | None = None - - -class MonitorResponse(BaseModel): - """Wrap-up of one workflow run.""" - - model_config = ConfigDict(extra="forbid") - - aoi_id: str - assessment_id: int | None = None - assessment: AggregateAssessment | None = None - cells_scored: int = 0 - high_or_above: int = 0 - dispatched_alerts: list[str] = Field(default_factory=list) - - class LatestAssessmentResponse(BaseModel): """Latest persisted assessment summary for an AOI.""" diff --git a/src/limen/config/settings.py b/src/limen/config/settings.py index 08f612a..726aac9 100644 --- a/src/limen/config/settings.py +++ b/src/limen/config/settings.py @@ -594,27 +594,6 @@ class KgSettings(BaseSettings): top_k: int = Field(default=4, ge=1, le=20) -class ClerkSettings(BaseSettings): - """Clerk JWT validation for protected FastAPI endpoints. - - Off by default so the public, read-only map endpoints stay open and dev - /test need no Clerk config. When ``enabled``, operator endpoints (e.g. - ``POST /api/monitor``) require a valid Clerk session JWT verified against - the instance JWKS (public RSA keys) — the secret key is never needed. - """ - - model_config = SettingsConfigDict(extra="ignore") - - enabled: bool = False - # Clerk instance JWKS endpoint (public keys): - # https:///.well-known/jwks.json - jwks_url: str | None = None - # Expected `iss` claim (the Clerk Frontend API origin). None ⇒ unchecked. - issuer: str | None = None - # Accepted `azp` (authorized party) origins; empty ⇒ unchecked. - authorized_parties: list[str] = Field(default_factory=list) - - class AuthSettings(BaseSettings): """Database-backed auth (replaces Clerk — PA-compliant, self-hosted). @@ -855,7 +834,6 @@ class Settings(BaseSettings): kg: KgSettings = Field(default_factory=KgSettings) geodata: GeodataSettings = Field(default_factory=GeodataSettings) geoserver_source: GeoServerSourceSettings = Field(default_factory=GeoServerSourceSettings) - clerk: ClerkSettings = Field(default_factory=ClerkSettings) auth: AuthSettings = Field(default_factory=AuthSettings) spid: SpidSettings = Field(default_factory=SpidSettings) diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index ba46402..53bc600 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -143,75 +143,6 @@ async def test_list_aoi(app_client: httpx.AsyncClient) -> None: assert _AOI_ID in ids -async def test_monitor_endpoint_returns_full_assessment(app_client: httpx.AsyncClient) -> None: - await _seed(_AOI_ID) - with respx.mock(assert_all_called=False) as mock: - mock.get(FORECAST_URL).mock(return_value=httpx.Response(200, json=_hourly_payload())) - mock.get(ARCHIVE_URL).mock(return_value=httpx.Response(200, json=_archive_payload())) - register_flood_mocks(mock) - - r = await app_client.post(f"/api/monitor/{_AOI_ID}", json={"cell_limit": 25}) - assert r.status_code == 200, r.text - body = r.json() - assert body["aoi_id"] == _AOI_ID - assert body["cells_scored"] >= 1 - assert body["assessment"] is not None - assert body["assessment"]["briefing_it"] is not None - assert body["assessment"]["analysis"] is not None - - -async def test_monitor_unknown_aoi_returns_404(app_client: httpx.AsyncClient) -> None: - r = await app_client.post("/api/monitor/does-not-exist", json={}) - assert r.status_code == 404 - - -async def test_latest_assessment_after_monitor(app_client: httpx.AsyncClient) -> None: - await _seed(_AOI_ID) - with respx.mock(assert_all_called=False) as mock: - mock.get(FORECAST_URL).mock(return_value=httpx.Response(200, json=_hourly_payload())) - mock.get(ARCHIVE_URL).mock(return_value=httpx.Response(200, json=_archive_payload())) - register_flood_mocks(mock) - run = await app_client.post(f"/api/monitor/{_AOI_ID}", json={"cell_limit": 25}) - assert run.status_code == 200 - - r = await app_client.get(f"/api/aoi/{_AOI_ID}/risk/latest") - assert r.status_code == 200, r.text - body = r.json() - assert body["aoi_id"] == _AOI_ID - assert body["cells"] - assert body["briefing_it"] is not None - - -async def test_cell_breakdown_returns_jsonb(app_client: httpx.AsyncClient) -> None: - await _seed(_AOI_ID) - with respx.mock(assert_all_called=False) as mock: - mock.get(FORECAST_URL).mock(return_value=httpx.Response(200, json=_hourly_payload())) - mock.get(ARCHIVE_URL).mock(return_value=httpx.Response(200, json=_archive_payload())) - register_flood_mocks(mock) - run = await app_client.post(f"/api/monitor/{_AOI_ID}", json={"cell_limit": 25}) - assert run.status_code == 200 - cell_id = run.json()["assessment"]["top_cells"][0]["cell_id"] - - r = await app_client.get(f"/api/cell/{cell_id}/breakdown") - assert r.status_code == 200, r.text - body = r.json() - assert body["cell_id"] == cell_id - assert "s" in body["factors"] - assert "model_version" in body["explanation"] - - -async def test_alerts_endpoint(app_client: httpx.AsyncClient) -> None: - await _seed(_AOI_ID) - with respx.mock(assert_all_called=False) as mock: - mock.get(FORECAST_URL).mock(return_value=httpx.Response(200, json=_hourly_payload())) - mock.get(ARCHIVE_URL).mock(return_value=httpx.Response(200, json=_archive_payload())) - register_flood_mocks(mock) - await app_client.post(f"/api/monitor/{_AOI_ID}", json={"cell_limit": 25}) - - r = await app_client.get("/api/alerts?threshold=None&since_hours=24") - assert r.status_code == 200 - body = r.json() - assert isinstance(body["items"], list) async def test_tiles_returns_503_when_unconfigured(app_client: httpx.AsyncClient) -> None: diff --git a/tests/unit/test_clerk_auth.py b/tests/unit/test_clerk_auth.py deleted file mode 100644 index 1330768..0000000 --- a/tests/unit/test_clerk_auth.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Clerk JWT dependency: disabled → open; enabled → requires a bearer token.""" - -from __future__ import annotations - -import asyncio -from typing import Any, cast - -import pytest -from fastapi import HTTPException, Request - -from limen.api.auth import require_user -from limen.config.settings import Settings - - -def _settings(**overrides: object) -> Settings: - return cast(Settings, cast(Any, Settings)(_env_file=None, **overrides)) - - -def _request(headers: dict[str, str]) -> Request: - # require_user only touches request.headers.get(...) — a dict suffices. - return cast(Request, cast(Any, type("Req", (), {"headers": headers})())) - - -def test_auth_disabled_is_open() -> None: - out = asyncio.run(require_user(_request({}), _settings())) - assert out == {} - - -def test_auth_enabled_missing_token_401() -> None: - with pytest.raises(HTTPException) as exc: - asyncio.run(require_user(_request({}), _settings(clerk={"enabled": True}))) - assert exc.value.status_code == 401 - - -def test_auth_enabled_bad_token_401() -> None: - with pytest.raises(HTTPException) as exc: - asyncio.run( - require_user( - _request({"Authorization": "Bearer not.a.jwt"}), - _settings( - clerk={ - "enabled": True, - "jwks_url": "https://example.test/.well-known/jwks.json", - } - ), - ) - ) - assert exc.value.status_code == 401