374 lines
22 KiB
Markdown
374 lines
22 KiB
Markdown
# 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<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.
|
|
|
|
```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 | `/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.
|
|
|
|
**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`) : 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](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](../../ML-START.md) section 3.
|
|
|
|
`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.
|
|
|
|
`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](../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`/
|
|
`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. 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** : 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`.
|