Files
ENI-projet-piscine/docs/architecture/20-backend.md
T
Johan LEROY 306c5a52e5 Merge remote-tracking branch 'origin/dev' into feat/dag-alertes
# Conflicts:
#	.env.example
#	Makefile
#	docs/README.md
2026-09-21 12:16:58 +02:00

28 KiB

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 et 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.

flowchart TB
  ep["endpoints<br/>health, auth, users, sites, alerts,<br/>recommendations, stats, readings, sensors, predictions"]
  sc["schemas<br/>Pydantic"]
  sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, ReadingService, SensorService, PredictionService"]
  rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading, prediction"]
  md["models<br/>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.

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/sites/{site_id}/current Dernière mesure d'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
POST /api/v1/recommendations/generate Applique le moteur de règles aux alertes, filtrable par site_id. admin 401, 403, 422, 500
GET /api/v1/stats/summary Résume la consommation instantanée du parc. lecteur 401, 403, 500
GET /api/v1/readings Historique des lectures, filtrable par site_id, fenêtre start/end (24h par défaut, 90 jours maximum) et paginé par limit/offset. lecteur 400, 401, 403, 422, 500
GET /api/v1/sensors/status État de santé des capteurs par site, dérivé de la dernière lecture. admin 401, 403, 500
GET /api/v1/predictions Dernière prévision de consommation par site, calculée hors ligne par le pipeline de scoring (ml/). 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.

Sept routes du contrat sont publiques : les deux sondes, /auth/login, /auth/logout, /auth/forgot-password et les deux routes de réinitialisation, qui portent leur autorisation dans le jeton à usage unique plutôt que dans un Principal. 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 ROUTES_PUBLIQUES dans tests/api/acces.py.

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 (dataset) : 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 et GET /sensors/status agrègent chacune deux repositories (SiteRepository, ReadingRepository) dans un service dédié plutôt que d'exposer une table : elles n'entrent donc pas dans ce gabarit route-par-table. GET /sites/{site_id}/current reste sur le gabarit sites, mais SiteService gagne la même seconde dépendance (ReadingRepository) pour restituer la dernière Reading du site : un site connu sans lecture rend 200 avec tous les champs de mesure à null et data_quality="critical", seul un site_id absent de la base rend 404. Le contrat détaillé pour le frontend est dans 31-contrat-authentification.md.

GET /predictions reprend ce même sous-gabarit « dernière valeur par site » (SiteRepository + PredictionRepository, un SitePredictionSummaryResponse par site plutôt qu'une table brute). Différence avec stats/sensors : prediction est une vraie table accumulée par un processus externe (enervision_ml.score, cf. ml/README.md), pas une valeur recalculée à la volée depuis reading à chaque appel. PredictionRepository.latest_by_site() isole donc un DISTINCT ON (site_id) ordonné par target_at DESC (couvert par l'index ix_prediction_site_target), le même mécanisme que ReadingRepository.latest_by_site(). Un site jamais scoré rend prediction: null plutôt qu'un statut inventé : le domaine available/insufficient_data/error de la contrainte ck_prediction_status n'a pas de valeur pour « pas encore de ligne ». L'API ne lance jamais LightGBM elle-même ; elle lit ce que le pipeline de scoring a déjà écrit, cf. ML-START.md section 3.

POST /recommendations/generate est la seule route d'écriture métier du contrat. Elle applique le moteur de règles d'app/services/recommendation_rules.py aux lignes d'alert, sans modèle ni feature ML : le catalogue REGLES associe à chaque type et à chaque gravité d'alerte une action et son explication, et une même alerte peut en déclencher plusieurs, comme le prévoit 40-data.md. L'idempotence est portée par la base, pas par le service : RecommendationRepository.create_missing() insère en ON CONFLICT DO NOTHING sur uq_recommendation_alert_rule, donc rejouer la génération sur les mêmes alertes ne crée rien et le rapport rendu distingue recommendations_created de already_present. Le même traitement est disponible hors HTTP par python -m app.cli generate-recommendations (cible make recommendations), sur le patron de make ml-score. Le choix de loger le moteur dans le backend plutôt que dans ml/ est justifié par l'ADR 0006. Les alertes traitées sont celles qu'écrit la détection interne (#104, section ci-dessous) : la génération ne rend donc de recommandations qu'une fois la détection passée. L'insertion est découpée en lots de TAILLE_DE_LOT lignes, asyncpg plafonnant une requête à 32 767 paramètres.

GET /readings reprend le même gabarit mais s'en écarte sur un point : reading est l'hypertable, donc la seule table métier pouvant porter des années d'historique, ce que docs/architecture/ owasp-traceabilite.md documentait comme un risque ouvert (API4, aucune pagination plafonnée ni fenêtre temporelle maximale). ReadingService porte donc une couche de validation absente des autres routes de lecture : start/end sont optionnels (24 dernières heures par défaut si les deux sont omis, l'un défaut par rapport à l'autre sinon), l'écart entre les deux est plafonné à 90 jours (FENETRE_MAXIMALE), et limit/offset (défaut 500, plafond 2000) empêchent qu'une fenêtre large mais peu dense reste malgré tout coûteuse. Un dépassement de plafond répond 400 (règle métier, portée par le service) plutôt que 422 (réservé à la validation structurelle de FastAPI, par exemple limit hors bornes). Un datetime sans fuseau dans start/end est traité comme de l'UTC plutôt que rejeté : le comparer tel quel à reading.timestamp (timestamptz) échouerait côté pilote, en 500 plutôt qu'un refus propre.

Détection d'alertes internes

AlertService n'est plus lecture seule : AlertService.detect() compare les reading (et, pour le type anomaly, les prediction) des dernières 48h (LOOKBACK) à cinq règles et enregistre une ligne alert par déclenchement, avec source="enervision". metric/value/threshold gardent leur sens dans chaque règle plutôt que d'être laissés à null par commodité :

type Règle value / threshold
threshold reading.consumption_kw dépasse site.capacity_kw (site sans capacité déclarée : ignoré) mesure / capacité du site
spike Variation relative ≥ 50% (SPIKE_RELATIVE_THRESHOLD) entre deux lectures consécutives du même site, ou redémarrage direct à une valeur positive depuis zéro (critical) mesure actuelle / mesure précédente
anomaly Écart relatif ≥ 30% (ANOMALY_RELATIVE_THRESHOLD) entre reading.consumption_kwh et la prediction du même site dont target_at == timestamp mesure réelle / valeur prédite
outage Aucune lecture depuis plus de 3h (OUTAGE_THRESHOLD, 3x la cadence horaire nominale), ou site jamais lu null / null
sensor reading.data_quality ∈ partial/degraded/critical null / null

La sévérité de chaque alerte (hors sensor, dérivée directement de data_quality) suit le même barème par ratio observé/seuil : low sous 1.2, medium sous 1.5, high sous 2.0, critical au-delà. AlertRepository.create_many() insère par lot avec ON CONFLICT DO NOTHING sur uq_alert_source_reference, et source_alert_id est construit de façon déterministe (règle + horodatage) : rejouer la détection sur une fenêtre déjà analysée ne duplique donc jamais une alerte.

Pièges de tri corrigés en revue : reading/prediction n'ont pas d'unicité sur leur couple métier (uq_reading_source autorise deux source différentes au même site_id+timestamp, prediction n'a aucune contrainte sur (site_id, target_at), chaque run de scoring gardant sa propre ligne). ReadingRepository.list_since()/PredictionRepository.list_since() départagent donc les égalités par reading_id/prediction_id croissant, comme le font déjà latest_by_site()/latest_for_site() sur les mêmes tables ; sans ce départage, l'ordre entre lignes à égalité n'est pas garanti d'un appel à l'autre, et _detect_spike/_detect_anomaly auraient pu comparer des lectures/choisir une prévision au hasard. _detect_spike ignore en plus explicitement les paires de lectures qui partagent le même horodatage (deux source pour un seul instant réel, pas une variation).

La détection s'exécute dans apps/backend, puisque les règles s'appuient sur les repositories ORM de l'API plutôt que sur une connexion SQL directe (contrairement à app/etl/historical_import.py) : uv run python -m app.detection.internal_alerts [--site-id ...] [--now ...], ou make detect-alerts. Cette issue (#104) débloquait #38 (moteur de règles pour recommandations), dont la FK alert_id NOT NULL n'avait jusqu'ici rien à référencer côté source="enervision".

Depuis l'issue #116, le lancement n'est plus manuel : le DAG Airflow alertes enchaîne cette détection et la génération des recommandations, toutes les heures à la quinzième minute. Airflow exécute le code du backend en sous-processus, dans son propre environnement, ce que décide l'ADR 0008 ; le détail de l'ordonnancement est dans 10-infra.md. La ligne de commande reste le moyen de rejouer une fenêtre passée, ce que --now permet et que le DAG ne fait pas.

/health/ready

Cette sonde porte une garde décrite dans l'ADR 0001 : 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.

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 :

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
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/ readings/sensors/predictions (dataset) :

  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. La classer dans tests/api/acces.py : ROLE_MINIMUM avec son rôle minimum si elle passe par require_role (LecteurDep/OperateurDep/AdminDep), ROUTES_SANS_ROLE si elle se contente de CurrentPrincipalDep, ROUTES_PUBLIQUES si elle est ouverte. L'oubli n'est plus silencieux : test_every_declared_route_is_classified échoue sur une route non classée comme sur une entrée qui ne correspond plus à aucune route. ROUTES_A_ROLE de test_openapi.py en est dérivée, et test_matrice_acces.py vérifie le niveau réellement monté.
  4. Si elle passe par require_trusted_origin, l'ajouter à ORIGINE_VERIFIEE dans tests/api/test_openapi.py. Cette liste-là reste maintenue à la main.
  5. make openapi, puis uv run pytest tests/api/test_openapi.py tests/api/test_route_protection.py tests/api/test_matrice_acces.py.

Sécurité

Voir la vue consolidée dans 00-vue-ensemble.md et les décisions dans les ADR 0002, 0003 et 0004. 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 reverse proxy les pose (ADR 0007).
  • Le conteneur tourne en utilisateur non-root, avec un HEALTHCHECK sur /api/v1/health/live.
  • TLS, limitation de débit au frontal et journal d'accès sont portés par le reverse proxy. APP_TRUST_PROXY_HEADERS doit alors valoir vrai, sinon le compteur par IP devient global.
  • Pas de 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.

Quatre fichiers méritent d'être connus avant de toucher à l'authentification :

  • tests/api/acces.py : la classification des routes, ROUTES_PUBLIQUES et ROLE_MINIMUM en tête. Ce n'est pas un test, c'est la référence que les deux suivants confrontent au comportement observé.
  • tests/api/test_route_protection.py : le garde-fou de l'autorisation, décrit plus haut.
  • tests/api/test_matrice_acces.py : chaque route gardée croisée avec chacun des trois rôles, dans les deux sens, puis rejouée sous integration avec de vrais jetons.
  • 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 : posés sur GET /readings (fenêtre plafonnée à 90 jours, limit/offset plafonné à 2000), mais toujours en limit/offset simple — pas de curseur ni de plan de secours si un offset élevé sur une fenêtre dense devient lent en pratique. statement_timeout reste absent au niveau de la connexion, donc rien n'empêche une requête individuelle de tourner longtemps si les plafonds au-dessus d'elle s'avéraient insuffisants.
  • Politique de versionnement de l'API au-delà du préfixe /api/v1.