# Backend API FastAPI, Python 3.14, SQLAlchemy asynchrone sur `asyncpg`. Source dans `apps/backend`. ## Couches La doctrine est posée dans [`apps/backend/README.md`](../../apps/backend/README.md) et [`TESTING.md`](../../apps/backend/TESTING.md) : `endpoints` appelle `services`, qui appelle `repositories`, qui seuls touchent les `models`. Le sens de dépendance ne s'inverse jamais. Les quatre couches existent désormais, portées par l'authentification. ```mermaid flowchart TB ep["endpoints
health, auth, users, sites,
recommendations, stats"] sc["schemas
Pydantic"] sv["services
AuthService, UserService,
SiteService, RecommendationService,
StatsService"] rp["repositories
user, refresh_token,
login_attempt, audit_log,
site, recommendation, reading"] md["models
10 tables"] db[("PostgreSQL")] ep --> sc ep --> sv sv --> rp rp --> md ep -->|"SQL brut, sonde seulement"| db rp --> db ``` Le trait plein de `endpoints` vers la base n'est pas une erreur de dessin : `/health/ready` exécute aujourd'hui son `SELECT` directement, sans repository. C'est acceptable pour une sonde d'infrastructure, qui vérifie la base elle-même et non une donnée métier. Ce raccourci ne doit pas servir de modèle au premier endpoint métier. `app/models/__init__.py` porte un avertissement qui reste valable à chaque nouveau modèle : tout modèle absent de ce module est invisible d'un `alembic revision --autogenerate`, qui produirait alors un `drop` de sa table. L'export va dans le même commit que le modèle. `AuthService` et `UserService` ne connaissent ni `AsyncSession` ni `Request` : ils reçoivent leurs dépôts et une `Transaction` réduite à `commit()`. C'est ce qui les rend testables sans base, avec des doubles écrits à la main. ## Démarrage Point d'entrée : **une factory**, `uvicorn app.main:create_app --factory`. Aucune configuration n'est lue à l'import du module, ce qui rend l'application testable et les migrations indépendantes de l'environnement d'exécution. ```mermaid sequenceDiagram participant U as uvicorn --factory participant F as create_app participant S as get_settings participant A as FastAPI U->>F: create_app() F->>S: Settings depuis .env et variables APP_* S-->>F: resolved F->>F: configure_logging(resolved) F->>A: FastAPI, docs fermés si prod F->>A: CORSMiddleware, seulement si allowed_origins F->>A: Instrumentator, expose /metrics F->>A: include_router, préfixe /api/v1 A-->>U: application ``` **Le `lifespan` n'ouvre aucune connexion.** Au démarrage il journalise le nom, la version et l'environnement ; à l'arrêt il libère l'engine. L'engine lui-même est construit paresseusement au premier appel de `get_engine()`, mis en cache par `lru_cache`. Conséquence directe : une API qui démarre ne prouve rien sur la base, la première connexion réelle a lieu au premier `GET /api/v1/health/ready`. C'est ce qui rend cette sonde indispensable. ## Configuration `Settings` est un `BaseSettings` Pydantic, lu depuis `.env` avec le préfixe `APP_`. | Variable | Défaut | Rôle | |---|---|---| | `APP_SECRET_KEY` | **aucun** | Secret applicatif, `SecretStr` | | `DATABASE_URL` | **aucun** | Chaîne de connexion, `postgresql+asyncpg://...` | | `APP_ENV` | `local` | `local`, `dev`, `staging` ou `prod` | | `APP_DEBUG` | `false` | Active aussi l'écho SQL de l'engine | | `APP_LOG_LEVEL` | `INFO` | | | `APP_CORS_ORIGINS` | `""` | Liste séparée par des virgules. Vide, aucun middleware CORS n'est posé | | `APP_API_PREFIX` | `/api/v1` | | | `APP_DATABASE_POOL_SIZE` | `5` | | | `APP_DATABASE_MAX_OVERFLOW` | `10` | | | `APP_JWT_ISSUER` | `enervision-api` | Claim `iss`, vérifié au décodage | | `APP_JWT_AUDIENCE` | `enervision-web` | Claim `aud`, vérifié au décodage | | `APP_ACCESS_TOKEN_TTL_SECONDS` | `900` | Durée du jeton d'accès | | `APP_REFRESH_TOKEN_TTL_SECONDS` | `604800` | Durée absolue d'une session, héritée à chaque rotation | | `APP_REFRESH_COOKIE_NAME` | `ev_refresh` | Préfixé `__Secure-` dès que le cookie est `Secure` | | `APP_COOKIE_PATH` | `/api/v1/auth` | Le cookie ne part que sur ces routes | | `APP_COOKIE_SAMESITE` | `strict` | | | `APP_COOKIE_SECURE` | déduit | Vrai hors `local` si non renseigné | | `APP_ARGON2_TIME_COST` | `2` | | | `APP_ARGON2_MEMORY_COST_KIB` | `19456` | Profil OWASP, environ 17 ms mesurés | | `APP_ARGON2_PARALLELISM` | `1` | | | `APP_ARGON2_MAX_CONCURRENCY` | `4` | Plafonne le pic mémoire du hachage | | `APP_LOGIN_WINDOW_SECONDS` | `900` | Fenêtre glissante de la limitation | | `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER_AND_IP` | `5` | Remplace le verrouillage de compte | | `APP_LOGIN_MAX_FAILURES_PER_IP` | `20` | Arrête le balayage | | `APP_LOGIN_MAX_FAILURES_PER_IDENTIFIER` | `50` | Signature d'une attaque distribuée | | `APP_TRUST_PROXY_HEADERS` | `false` | À vrai derrière un proxy, sinon le compteur par IP devient global | | `APP_EXPOSE_API_DOCS` | déduit | Faux en `staging` et `prod` si non renseigné | | `APP_METRICS_TOKEN` | absent | Si présent, `/metrics` exige `Authorization: Bearer` | Cinq gardes refusent de démarrer plutôt que de laisser passer une erreur silencieuse : secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou `prod`, joker dans `APP_CORS_ORIGINS`, liste d'origines vide hors `local`, et cookie `SameSite=None` sans `Secure`. Trois pièges : - **`DATABASE_URL` ne prend pas le préfixe `APP_`.** C'est le seul réglage dans ce cas, par `validation_alias`, pour rester compatible avec la convention d'Alembic et des hébergeurs. - **`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par défaut.** L'application refuse de démarrer si l'un manque. C'est délibéré : mieux vaut un échec au démarrage qu'un service qui tourne avec un secret de démonstration. - **Une `Settings` passée à `create_app()` pilote aussi les dépendances.** La factory installe une surcharge de `get_settings` ; sans elle, un test « en production » testerait la configuration du poste. Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docker-compose.yml`, `apps/backend/.env` alimente l'API lancée sur le poste. ## Routes exposées | Méthode | Chemin | Rôle | Erreurs déclarées | |---|---|---|---| | GET | `/api/v1/health/live` | Le processus répond. Ne touche pas la base | 500 | | GET | `/api/v1/health/ready` | La base répond **et** l'extension TimescaleDB est chargée | 503, 500 | | POST | `/api/v1/auth/login` | Ouvre une session. Publique | 401, 422, 429, 500 | | POST | `/api/v1/auth/refresh` | Fait tourner la session. Cookie seulement | 401, 403, 500 | | POST | `/api/v1/auth/logout` | Ferme la session courante. Idempotente | 403, 500 | | POST | `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | 401, 403, 500 | | POST | `/api/v1/auth/password` | Change son propre mot de passe | 401, 403, 422, 500 | | GET | `/api/v1/auth/me` | Décrit le compte connecté | 401, 500 | | GET | `/api/v1/users` | Liste les comptes. `admin` | 401, 403, 500 | | POST | `/api/v1/users` | Crée un compte, rend un mot de passe provisoire. `admin` | 401, 403, 409, 422, 500 | | PATCH | `/api/v1/users/{id}` | Change le rôle ou l'activation. `admin` | 400, 401, 403, 404, 409, 422, 500 | | POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 | | GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 | | GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/stats/summary` | Résume la consommation instantanée du parc. `lecteur` | 401, 403, 500 | | GET | `/metrics` | Format Prometheus, hors du schéma. Jeton requis si `APP_METRICS_TOKEN` est posé | | | GET | `/docs`, `/redoc`, `/openapi.json` | Hors du schéma. Fermés en `staging` et en `prod` | | Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier `openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent. **Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`. `tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose donc de modifier la liste dans ce fichier de test. `GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour `GET /alerts` puis pour les suivantes (`reading`, `dataset`, `prediction`, `recommendation`) : les quatre couches `endpoints → services → repositories → models` y sont toutes présentes, sur des tables déjà créées par la révision Alembic `e6d2026091501`. Elles n'exigent que le rôle `lecteur`, contrairement aux routes d'administration qui exigent `admin`. `SiteRepository` lit par `AsyncSession.scalar()` (une ligne) et `AsyncSession.scalars()` (plusieurs lignes) plutôt que par `execute()`, ce qui la rend testable par la fixture `fake_session` au niveau endpoint sans base réelle. `GET /recommendations` et `GET /recommendations/{recommendation_id}` reprennent le même gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. Une recommandation ne porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit route-par-table. Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` Cette sonde porte une garde décrite dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md) : un bootstrap de base sauté ne se voit pas au démarrage de l'API, elle le rend visible. Elle ne publie **pas** la version de l'extension, qui part dans le journal : une version exacte de composant servie sans authentification est de la reconnaissance gratuite pour qui cherche une CVE. ```mermaid sequenceDiagram participant C as Client participant R as readiness participant E as get_engine participant D as PostgreSQL C->>R: GET /api/v1/health/ready R->>E: session, engine créé au premier appel R->>D: SELECT extversion FROM pg_extension WHERE extname = 'timescaledb' alt base injoignable D--xR: SQLAlchemyError ou OSError R-->>C: 503 Base de données injoignable else extension absente D-->>R: NULL R-->>C: 503 Extension TimescaleDB absente else D-->>R: version de l'extension R-->>C: 200 timescaledb loaded end ``` ## Contrat OpenAPI Statut : `Fait`. Le schéma est servi sur `/openapi.json`, `/docs` et `/redoc`, fermés en `staging` et en `prod`. Il est aussi **versionné** dans [`apps/backend/openapi.json`](../../apps/backend/openapi.json) : ```bash make openapi ``` Pourquoi un fichier en plus de la route. Une route qui change son contrat public le montre alors dans la diff de la pull request, et le frontend dispose d'une référence lisible sans lancer l'API. `tests/api/test_openapi.py` compare le fichier au schéma généré et échoue si l'un bouge sans l'autre ; le fichier vivant sous `apps/backend/`, le filtre de chemins de `backend.yml` le couvre. **Le schéma exporté ne dépend pas du poste.** `settings_du_contrat()` pose le nom, la version et le préfixe, et coupe la lecture du `.env`. Sans cela, un `APP_API_PREFIX` local suffirait à faire diverger le fichier d'une machine à l'autre, et le test deviendrait un oracle de configuration plutôt qu'un garde-fou de contrat. Trois champs sont volontairement absents d'`info`, parce qu'ils poseraient une décision qui n'est pas prise : | Champ | Pourquoi | |---|---| | `servers` | L'URL publique dépend de l'ingress, question ouverte dans [10-infra.md](10-infra.md) | | `license_info` | Aucune licence n'est choisie | | `contact` | Aucun canal de support n'existe | Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et `Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le 401 sur lequel le frontend déclenche sa déconnexion. Les modèles de `app/schemas/errors.py` décrivent ce que les gestionnaires renvoient réellement. `ValidationErrorResponse` remplace le `HTTPValidationError` par défaut de FastAPI, dont la clé `loc` n'apparaît dans aucune réponse de cette API : `validation_error_handler()` rend `champ` et `type`. Renommer un champ là-bas sans le faire ici rend la documentation fausse en silence. ### Ajouter une route métier Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendations`/`stats` (`reading`, `dataset`, `prediction`) : 1. Composer ses `responses=` depuis `app/api/openapi.py` : `REPONSES_LECTEUR`/`REPONSES_ADMIN` au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux (404, 409, ...) directement sur l'endpoint qui les rend. 2. Décrire son tag dans `TAGS`. 3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à `ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`, l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** : une route oubliée n'y est pas détectée automatiquement. 4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`. ## Sécurité Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md) et les décisions dans les [ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md), [0003](../adr/0003-autorisation-rbac-a-trois-roles.md) et [0004](../adr/0004-journal-d-audit-en-ajout-seul.md). Côté backend, les ordres d'exécution qui portent la sécurité, et qu'un refactor casserait sans rien faire échouer de visible : 1. **Les compteurs de limitation sont lus avant le hachage Argon2.** Dans l'autre ordre, chaque requête rejetée coûterait quand même 17 ms de processeur et 19 Mio de mémoire, et la protection deviendrait l'amplificateur de déni de service qu'elle doit empêcher. 2. **Un haché leurre est vérifié quand l'adresse est inconnue.** Sans lui, l'écart entre 2 ms et 17 ms est un oracle d'existence de compte, mesurable à distance. 3. **La tentative échouée est validée en base avant que l'erreur ne soit levée.** `get_session()` ne valide pas de lui-même : la preuve disparaîtrait avec la transaction. 4. **Un jeton de rafraîchissement déjà tourné révoque toute sa famille ; un jeton expiré ne révoque rien.** La rotation ne protège de rien par elle-même, elle rend la réutilisation détectable. Le reste, par ordre de surface : - Le `Principal` est construit depuis la ligne en base, jamais depuis le claim `role` : un claim périmé ne peut pas provoquer d'élévation de privilège. - `credentials_changed_at` est comparé à la seconde entière, parce que `iat` est une date JWT et n'a pas de précision inférieure. - Le CORS liste ses origines, ses méthodes et ses en-têtes. Il n'est pas monté si la liste est vide, et la configuration refuse de démarrer dans ce cas hors `local`. - La 422 renvoie le champ fautif et le type d'erreur, **jamais la valeur rejetée** : la réponse par défaut de FastAPI contient `input`, donc le mot de passe sur `/auth/login`. - La 500 renvoie un identifiant de corrélation, la trace reste côté serveur. - Un filtre de caviardage expurge jetons, empreintes Argon2, mots de passe et cookies avant écriture des journaux. C'est la troisième ligne de défense : la première est de ne rien passer de secret au logger, la deuxième de ne jamais mettre un jeton dans une URL. - En-têtes posés par l'application : `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, plus `Cache-Control: no-store` sur `/auth/*`. HSTS et CSP appartiennent au terminateur TLS, que l'application ne connaît pas. - Le conteneur tourne en utilisateur non-root, avec un `HEALTHCHECK` sur `/api/v1/health/live`. - Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative. ## Observabilité - Journalisation par `dictConfig` : format console en développement, JSON dès `APP_ENV=prod`. `sqlalchemy.engine` est forcé à `WARNING` pour ne pas noyer les journaux. - `/metrics` au format Prometheus. **Aucun collecteur ne le lit** : `monitoring/` est vide. ## Tests Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md). Trois fichiers méritent d'être connus avant de toucher à l'authentification : - `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut. - `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro appel quand la limite est atteinte. - `tests/api/test_parcours_authentification.py` : six parcours contre la vraie base, sous le marqueur `integration`. C'est là que se démontrent l'atomicité de la rotation, la mort de la famille au rejeu et la révocation immédiate. ## Questions ouvertes - **Portée par site dans l'autorisation** : les rôles sont globaux, un opérateur du site A peut agir sur le site B. C'est la limite connue du modèle, et le risque BOLA du top 10 API. - **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage, plus le `REVOKE` sur `audit_log`. Dette assumée, décrite dans les ADR 0003 et 0004. - **Pagination et fenêtrage** des lectures de séries temporelles, qui conditionnent la forme des endpoints métier. Sans plafond dur, une requête sur dix ans d'historique suffit à faire tomber l'API. - **Politique de versionnement de l'API** au-delà du préfixe `/api/v1`.