20 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, sensors"]
sc["schemas<br/>Pydantic"]
sv["services<br/>AuthService, UserService,<br/>SiteService, AlertService, RecommendationService,<br/>StatsService, SensorService"]
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, alert, recommendation, reading"]
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_URLne prend pas le préfixeAPP_. C'est le seul réglage dans ce cas, parvalidation_alias, pour rester compatible avec la convention d'Alembic et des hébergeurs.APP_SECRET_KEYetDATABASE_URLn'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
Settingspassée àcreate_app()pilote aussi les dépendances. La factory installe une surcharge deget_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 | /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 | /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 (dataset, prediction) : 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. Le contrat détaillé pour le frontend est dans
31-contrat-authentification.md.
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.
/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 (dataset, prediction) :
- Composer ses
responses=depuisapp/api/openapi.py:REPONSES_LECTEUR/REPONSES_ADMINau niveau de l'include_router()du routeur,REPONSE_VALIDATIONet les codes locaux (404, 409, ...) directement sur l'endpoint qui les rend. - Décrire son tag dans
TAGS. - Si elle passe par
require_role(LecteurDep/OperateurDep/AdminDep), l'ajouter àROUTES_A_ROLEdanstests/api/test_openapi.py. Si elle passe parrequire_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. make openapi, puisuv run pytest tests/api/test_openapi.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 :
- 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.
- 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.
- 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. - 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
Principalest construit depuis la ligne en base, jamais depuis le claimrole: un claim périmé ne peut pas provoquer d'élévation de privilège. credentials_changed_atest comparé à la seconde entière, parce queiatest 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, plusCache-Control: no-storesur/auth/*. HSTS et CSP appartiennent au terminateur TLS, que l'application ne connaît pas. - Le conteneur tourne en utilisateur non-root, avec un
HEALTHCHECKsur/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èsAPP_ENV=prod.sqlalchemy.engineest forcé àWARNINGpour ne pas noyer les journaux. /metricsau format Prometheus. Aucun collecteur ne le lit :monitoring/est vide.
Tests
Conventions, gabarits et arborescence : 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 marqueurintegration. 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
REVOKEsuraudit_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/offsetplafonné à 2000), mais toujours enlimit/offsetsimple — pas de curseur ni de plan de secours si unoffsetélevé sur une fenêtre dense devient lent en pratique.statement_timeoutreste 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.