Fusionne dev dans feat/stats-summary
Resout les conflits de deps.py, router.py, repositories/site.py et test_site.py entre l'ajout de stats et le merge de sites/openapi-contrat sur dev. Generalise ROUTES_A_ROLE dans test_openapi.py et documente la checklist d'ajout d'une route metier, absentes de dev au moment du fork.
This commit is contained in:
@@ -74,9 +74,9 @@ collecteur ne vient le lire.
|
||||
|
||||
| Domaine | Technologie | Emplacement | Statut | Ce qui existe réellement |
|
||||
|---|---|---|---|---|
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`. Aucune couche métier |
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | `En cours` | Factory, configuration, journalisation, 2 sondes de santé, `/metrics`, `GET /sites` et `GET /sites/{site_id}` (première couche métier, endpoints → services → repositories → models) |
|
||||
| Frontend | Angular 22, Node 24 | `apps/frontend` | `En cours` | Tableau de bord sur route `/dashboard`, deux services HTTP, graphiques Chart.js, données servies par des fixtures |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Aucune table applicative |
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | `Fait` | Bootstrap de l'extension, base de test, chaîne Alembic. Schéma applicatif créé (`site`, `dataset`, `reading` en hypertable, `prediction`, `alert`, `recommendation`) |
|
||||
| Infra | Terraform, k3s single-node | `infra/terraform` | `En cours` | Module d'installation du cluster. Jamais appliqué, aucune ressource Kubernetes déclarée |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | `Cible` | Rien, hors le `/metrics` exposé par l'API |
|
||||
| ETL | Apache Airflow | `etl/airflow` | `Cible` | Rien |
|
||||
|
||||
@@ -35,9 +35,10 @@ flowchart TB
|
||||
| `backend` | Construite depuis `apps/backend` | `depends_on: db, condition: service_healthy`. **N'embarque pas le source** : toute modification impose `docker compose up -d --build backend` |
|
||||
|
||||
**La boucle de développement n'utilise pas le service `backend`.** `make db-up` puis `make dev` :
|
||||
seule la base tourne en conteneur, l'API tourne sur le poste avec le rechargement à chaud. Le
|
||||
service `backend` sert la stack complète et la recette. Les deux occupent le port 8000, ils ne se
|
||||
lancent donc pas ensemble.
|
||||
seule la base tourne en conteneur, l'API et `ng serve` tournent sur le poste avec le rechargement
|
||||
à chaud, lancés ensemble par `make dev` (`make dev-backend`/`make dev-frontend` pour lancer l'un
|
||||
des deux seul). Le service `backend` sert la stack complète et la recette. Les deux occupent le
|
||||
port 8000, ils ne se lancent donc pas ensemble.
|
||||
|
||||
Deux pièges sont documentés en tête du `docker-compose.yml`, ils ne se devinent pas :
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ Les quatre couches existent désormais, portées par l'authentification.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
ep["endpoints<br/>health, auth, users, stats"]
|
||||
ep["endpoints<br/>health, auth, users, sites, stats"]
|
||||
sc["schemas<br/>Pydantic"]
|
||||
sv["services<br/>AuthService, UserService, StatsService"]
|
||||
sv["services<br/>AuthService, UserService,<br/>SiteService, StatsService"]
|
||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log,<br/>site, reading"]
|
||||
md["models<br/>6 tables"]
|
||||
md["models<br/>10 tables"]
|
||||
db[("PostgreSQL")]
|
||||
|
||||
ep --> sc
|
||||
@@ -126,30 +126,44 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
|
||||
|
||||
## Routes exposées
|
||||
|
||||
| Méthode | Chemin | Dans l'OpenAPI | Rôle |
|
||||
| Méthode | Chemin | Rôle | Erreurs déclarées |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/health/live` | oui | Le processus répond. Ne touche pas la base |
|
||||
| GET | `/api/v1/health/ready` | oui | La base répond **et** l'extension TimescaleDB est chargée |
|
||||
| POST | `/api/v1/auth/login` | oui | Ouvre une session. Publique |
|
||||
| POST | `/api/v1/auth/refresh` | oui | Fait tourner la session. Cookie seulement |
|
||||
| POST | `/api/v1/auth/logout` | oui | Ferme la session courante. Idempotente |
|
||||
| POST | `/api/v1/auth/logout-all` | oui | Ferme toutes les sessions du compte |
|
||||
| POST | `/api/v1/auth/password` | oui | Change son propre mot de passe |
|
||||
| GET | `/api/v1/auth/me` | oui | Décrit le compte connecté |
|
||||
| GET | `/api/v1/users` | oui | Liste les comptes. `admin` |
|
||||
| POST | `/api/v1/users` | oui | Crée un compte, rend un mot de passe provisoire. `admin` |
|
||||
| PATCH | `/api/v1/users/{id}` | oui | Change le rôle ou l'activation. `admin` |
|
||||
| POST | `/api/v1/users/{id}/password-reset` | oui | Réinitialise et ferme les sessions. `admin` |
|
||||
| GET | `/api/v1/stats/summary` | oui | Résume la consommation instantanée du parc. `lecteur` |
|
||||
| GET | `/metrics` | non | Format Prometheus. Jeton requis si `APP_METRICS_TOKEN` est posé |
|
||||
| GET | `/docs`, `/redoc`, `/openapi.json` | non | Fermés en `staging` et en `prod` |
|
||||
| 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/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.
|
||||
|
||||
Le contrat détaillé pour le frontend est dans
|
||||
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit à réutiliser
|
||||
pour les suivantes (`reading`, `dataset`, `prediction`, `alert`, `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 /stats/summary` agrège ces 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`
|
||||
@@ -183,6 +197,62 @@ sequenceDiagram
|
||||
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`/`stats` (`reading`, `dataset`,
|
||||
`prediction`, `alert`, `recommendation`) :
|
||||
|
||||
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
|
||||
|
||||
@@ -108,8 +108,9 @@ déploiement, en même temps que sera tranchée la question de l'ingress dans
|
||||
le message d'erreur arrive avant toute compilation. Un poste en 22.21 ou en 24.12 ne peut donc ni
|
||||
tester ni construire le frontend.
|
||||
|
||||
Le frontend **n'a pas de cible dans le `Makefile` racine** et **aucun service dans
|
||||
`docker-compose.yml`** : il se pilote uniquement par `npm`, depuis `apps/frontend`. Le port 4200
|
||||
Le frontend a ses cibles dans le `Makefile` racine (`install-frontend`, `dev-frontend`,
|
||||
englobées par `install` et `dev`), mais **aucun service dans `docker-compose.yml`** : en
|
||||
développement il tourne toujours directement via `npm`, depuis `apps/frontend`. Le port 4200
|
||||
n'apparaît dans le compose que comme valeur par défaut d'`APP_CORS_ORIGINS`, côté backend.
|
||||
|
||||
Un `Dockerfile` frontend existe sur la branche `feat/pipeline-cd`, mais il est mono-étage et sans
|
||||
|
||||
@@ -26,7 +26,9 @@ gérer : il suffit d'envoyer les requêtes avec `withCredentials`.
|
||||
| PATCH | `/api/v1/users/{id}` | jeton d'accès, `admin` | `200` `UserResponse` |
|
||||
| POST | `/api/v1/users/{id}/password-reset` | jeton d'accès, `admin` | `200` `TemporaryPasswordResponse` |
|
||||
|
||||
Le schéma exact est dans `/docs` (Swagger), servi en local et en développement.
|
||||
Le schéma exact est dans [`apps/backend/openapi.json`](../../apps/backend/openapi.json),
|
||||
lisible sans lancer l'API, et servi par `/docs` en local et en développement. La table des
|
||||
codes d'erreur ci-dessous reste la référence de comportement, le schéma celle de forme.
|
||||
|
||||
## Charges utiles
|
||||
|
||||
@@ -66,6 +68,7 @@ Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la répon
|
||||
| `401` sur `/auth/refresh` | session révoquée, expirée ou rejouée | **déconnecter** et renvoyer vers la page de connexion |
|
||||
| `403` avec `detail: "password_change_required"` | mot de passe provisoire | rediriger vers l'écran de changement de mot de passe |
|
||||
| `403` avec `detail: "Droits insuffisants"` | rôle trop bas | masquer ou griser l'action, ne pas déconnecter |
|
||||
| `403` sur `/auth/refresh`, `/logout`, `/logout-all`, `/password` | origine hors liste autorisée (voir « Origines autorisées ») | erreur de configuration réseau, pas un cas à gérer par l'utilisateur |
|
||||
| `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée |
|
||||
|
||||
## Les quatre règles qui comptent
|
||||
|
||||
@@ -10,7 +10,7 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
|
||||
|---|---|
|
||||
| [00-vue-ensemble.md](00-vue-ensemble.md) | Jalons du projet, contexte, conteneurs, sécurité, flux bout en bout |
|
||||
| [10-infra.md](10-infra.md) | Poste de développement, cible k3s, décisions figées, ports et noms |
|
||||
| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration |
|
||||
| [20-backend.md](20-backend.md) | Couches FastAPI, séquence de démarrage, routes, configuration, contrat OpenAPI |
|
||||
| [30-frontend.md](30-frontend.md) | Angular, arborescence cible, flux HTTP |
|
||||
| [31-contrat-authentification.md](31-contrat-authentification.md) | Ce que le frontend doit savoir pour coder la connexion |
|
||||
| [40-data.md](40-data.md) | Frontières `db/` et `alembic/`, cycle de vie d'une mesure, modèle |
|
||||
|
||||
@@ -8,8 +8,9 @@ de réponse honnête.
|
||||
Ce qui est défendable, c'est une ligne par contrôle réellement implémenté, l'item qu'il adresse,
|
||||
et une section qui dit ce qui n'est pas couvert et pourquoi.
|
||||
|
||||
Statut : `Fait` pour le périmètre authentification et autorisation. Les endpoints métier
|
||||
n'existent pas encore, donc plusieurs lignes resteront à compléter.
|
||||
Statut : `Fait` pour le périmètre authentification et autorisation. `GET /sites` et
|
||||
`GET /sites/{site_id}` sont les premiers endpoints métier, en lecture seule ; plusieurs lignes
|
||||
resteront à compléter une fois les endpoints d'écriture posés.
|
||||
|
||||
## Contrôles en place
|
||||
|
||||
@@ -48,7 +49,7 @@ règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu
|
||||
|
||||
| Item | État | Raison |
|
||||
|---|---|---|
|
||||
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site. Un opérateur du site A pourra agir sur le site B dès que les endpoints métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
||||
| **API1 Broken Object Level Authorization** | **ouvert** | Les rôles sont globaux, il n'y a pas de portée par site : `GET /sites/{site_id}` répond à tout compte `lecteur` pour n'importe quel site, sans vérifier une affectation compte-site qui n'existe pas encore. Un opérateur du site A pourra agir sur le site B dès que les endpoints d'écriture métier existeront. Correctif prévu : table d'affectation compte-site, contrôle d'appartenance dans la même dépendance que le contrôle de rôle. |
|
||||
| **API4, lectures de séries temporelles** | **ouvert** | Pas encore d'endpoint métier, donc ni pagination plafonnée, ni fenêtre temporelle maximale, ni `statement_timeout`. C'est la façon la plus probable dont la démonstration tombera : une requête sur dix ans d'historique suffit. |
|
||||
| **API8 Security Misconfiguration, transport** | **ouvert** | Pas de TLS, donc ni HSTS, ni cookie `Secure` réellement posé en production. Ils appartiennent au terminateur TLS, qui n'existe pas. |
|
||||
| **API10 Unsafe Consumption of APIs** | **ouvert, et spécifique à ce projet** | L'API Mock de l'école n'a aucune authentification, tourne en HTTP clair sur le réseau de l'école, et expose un endpoint mutatif à quiconque. Sa réponse doit être traitée comme une entrée hostile : bornes physiques, taille de tableau plafonnée, timeout, et frontière d'anti-corruption. La conséquence la plus sérieuse n'est pas la fausse alerte, c'est l'empoisonnement du jeu d'entraînement du modèle de prédiction. |
|
||||
|
||||
Reference in New Issue
Block a user