docs: acte les décisions d'authentification et met à jour les vues
Trois ADR : le jeton d'accès et le rafraîchissement opaque, le RBAC avec relecture du compte à chaque requête, et le journal d'audit en ajout seul. Chacun porte ses alternatives écartées et son critère de bascule, notamment celui vers OIDC. `31-contrat-authentification.md` est destiné au frontend : endpoints, codes d'erreur à traiter, et les quatre règles qui comptent. La troisième, un seul rafraîchissement en vol, est une exigence et non une optimisation : cinq rotations concurrentes seraient lues comme un rejeu et révoqueraient la session à chaque chargement de page. `owasp-traceabilite.md` remplace la revendication « couverture OWASP Top 10 et API Top 10 » de la NFR4, qui n'a pas de réponse honnête sur vingt items en deux semaines. Un contrôle par ligne, l'item adressé, et une section qui dit ce qui reste ouvert : portée par site, bornage des lectures de séries, transport, et la consommation de l'API Mock. Les vues 00, 20 et 40 suivent, comme l'impose leur propre règle de maintenance. La question ouverte « quel mécanisme d'authentification » est fermée ; trois autres la remplacent, dont la portée par site.
This commit is contained in:
@@ -23,7 +23,7 @@ Ce que la documentation apporte à chacun : [docs/architecture/00-vue-ensemble.m
|
||||
| Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
|
||||
| ETL | Apache Airflow | `etl/airflow` | A initialiser |
|
||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | A initialiser |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | Backend en place |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||
|
||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Le frontend
|
||||
|
||||
+46
-10
@@ -57,13 +57,21 @@ independants de l'environnement.
|
||||
```
|
||||
app/
|
||||
├── api/
|
||||
│ ├── deps.py Dependances FastAPI partagees (session, settings)
|
||||
│ ├── deps.py Dépendances partagées : session, settings, principal, gardes de rôle
|
||||
│ ├── errors.py Gestionnaires 422 et 500
|
||||
│ ├── middleware.py En-têtes de sécurité
|
||||
│ ├── security.py Garde du point /metrics
|
||||
│ └── v1/
|
||||
│ ├── router.py Agregation des routes de la version 1
|
||||
│ └── endpoints/ Un module par ressource exposee
|
||||
│ ├── router.py Agrégation des routes de la version 1
|
||||
│ └── endpoints/ Un module par ressource exposée
|
||||
├── core/
|
||||
│ ├── config.py Settings Pydantic, source unique de configuration
|
||||
│ └── logging.py Journalisation console en local, JSON en production
|
||||
│ ├── cookies.py Attributs du cookie de rafraîchissement
|
||||
│ ├── hashing.py Argon2id, poussé dans un fil sous limiteur
|
||||
│ ├── logging.py Journalisation console en local, JSON en production
|
||||
│ ├── principal.py L'identité que voit le code métier
|
||||
│ ├── roles.py Rôles ordonnés
|
||||
│ └── security.py Encodage et décodage des jetons d'accès
|
||||
├── db/
|
||||
│ ├── base.py Base declarative SQLAlchemy
|
||||
│ └── session.py Engine et sessions asynchrones
|
||||
@@ -71,6 +79,7 @@ app/
|
||||
├── schemas/ Modeles Pydantic d'entree et de sortie
|
||||
├── repositories/ Acces aux donnees, une classe par agregat
|
||||
├── services/ Regles metier, orchestrent les repositories
|
||||
├── cli.py Commandes hors HTTP, dont l'amorcage du premier admin
|
||||
└── main.py Factory applicative
|
||||
tests/ Miroir de app/
|
||||
alembic/ Migrations du schema applicatif
|
||||
@@ -81,12 +90,39 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Role |
|
||||
|------------------------|-------------------------------------------------|
|
||||
| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe |
|
||||
| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB |
|
||||
| `/metrics` | Metriques au format Prometheus |
|
||||
| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` |
|
||||
| Route | Rôle | Accès |
|
||||
|---|---|---|
|
||||
| `/api/v1/health/live` | Sonde de vivacité, aucune dépendance externe | public |
|
||||
| `/api/v1/health/ready` | Sonde de disponibilité, vérifie la base et TimescaleDB | public |
|
||||
| `/api/v1/auth/login` | Ouvre une session | public |
|
||||
| `/api/v1/auth/refresh` | Fait tourner la session | cookie |
|
||||
| `/api/v1/auth/logout` | Ferme la session courante | cookie, idempotente |
|
||||
| `/api/v1/auth/logout-all` | Ferme toutes les sessions du compte | jeton |
|
||||
| `/api/v1/auth/password` | Change son propre mot de passe | jeton |
|
||||
| `/api/v1/auth/me` | Décrit le compte connecté | jeton |
|
||||
| `/api/v1/users` | Liste et crée des comptes | `admin` |
|
||||
| `/api/v1/users/{id}` | Change le rôle ou l'activation | `admin` |
|
||||
| `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions | `admin` |
|
||||
| `/metrics` | Métriques au format Prometheus | jeton si `APP_METRICS_TOKEN` |
|
||||
| `/docs`, `/openapi.json` | Documentation, fermée en `staging` et `prod` | public sinon |
|
||||
|
||||
Le contrat détaillé pour le frontend est dans
|
||||
[`docs/architecture/31-contrat-authentification.md`](../../docs/architecture/31-contrat-authentification.md).
|
||||
|
||||
## Premier administrateur
|
||||
|
||||
Aucun compte n'existe après les migrations. Il s'en crée un en ligne de commande :
|
||||
|
||||
```bash
|
||||
make bootstrap-admin EMAIL=prenom.nom@enervision.fr # mot de passe saisi au clavier
|
||||
# ou, depuis apps/backend :
|
||||
uv run python -m app.cli create-admin --email prenom.nom@enervision.fr --generate
|
||||
```
|
||||
|
||||
Le compte est créé avec `must_change_password`, donc la première connexion ne donne accès qu'à
|
||||
`/auth/me` et `/auth/password` jusqu'au changement. Le mot de passe ne transite jamais par
|
||||
`argv`, visible de tout `ps`, et aucune révision Alembic n'insère de compte : son empreinte
|
||||
resterait dans Git pour toujours.
|
||||
|
||||
## Migrations
|
||||
|
||||
|
||||
@@ -141,3 +141,35 @@ make check # lint + typage + suite unitaire
|
||||
uv run pytest tests/api/test_health.py # un seul fichier
|
||||
uv run pytest -k readiness # par motif de nom
|
||||
```
|
||||
|
||||
## Trois fichiers à connaître avant de toucher à l'authentification
|
||||
|
||||
`tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et
|
||||
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de
|
||||
dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route
|
||||
peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose
|
||||
donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la
|
||||
diff d'une pull request.
|
||||
|
||||
`tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend
|
||||
possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test
|
||||
n'atteint :
|
||||
|
||||
- adresse inconnue → le compteur vaut 1, donc le haché leurre a bien été vérifié et il n'y a pas
|
||||
d'oracle temporel ;
|
||||
- limite de débit atteinte → le compteur vaut 0, donc la limite est évaluée avant Argon2.
|
||||
|
||||
`tests/api/test_parcours_authentification.py` joue six parcours complets contre la vraie base,
|
||||
sous le marqueur `integration`, sans serveur ni port ouvert. C'est là que se démontrent
|
||||
l'atomicité de la rotation, la mort de la famille au rejeu d'un cookie déjà tourné, et la
|
||||
révocation immédiate d'un compte désactivé.
|
||||
|
||||
## Deux pièges d'écriture de test
|
||||
|
||||
**Lire les attributs avant le `rollback`.** Un `session.rollback()` périme les attributs chargés,
|
||||
et les relire déclenche une entrée-sortie hors du contexte greenlet, donc un `MissingGreenlet`.
|
||||
On capture la valeur dans une variable locale avant d'annuler.
|
||||
|
||||
**`audit_log` ne se nettoie pas.** La table est en ajout seul, garanti par déclencheur : un test
|
||||
ne peut pas effacer ce qu'il y écrit, et les lignes d'une exécution précédente sont encore là.
|
||||
Chaque test filtre donc sur son propre `target_id` plutôt que de supposer une table vide.
|
||||
|
||||
+11
-2
@@ -1,4 +1,13 @@
|
||||
# Documentation
|
||||
|
||||
- `adr` : decisions d'architecture, une par fichier, numerotees et immuables.
|
||||
- `architecture` : les vues du systeme. Point d'entree : [architecture/README.md](architecture/README.md).
|
||||
- `adr` : décisions d'architecture, une par fichier, numérotées et immuables.
|
||||
- `architecture` : les vues du système. Point d'entrée : [architecture/README.md](architecture/README.md).
|
||||
|
||||
## Décisions en vigueur
|
||||
|
||||
| ADR | Sujet |
|
||||
|---|---|
|
||||
| [0001](adr/0001-postgresql-timescaledb.md) | PostgreSQL avec l'extension TimescaleDB |
|
||||
| [0002](adr/0002-authentification-jwt-et-refresh-opaque.md) | Authentification par JWT d'accès et jeton de rafraîchissement opaque |
|
||||
| [0003](adr/0003-autorisation-rbac-a-trois-roles.md) | Autorisation RBAC à trois rôles, relecture du compte à chaque requête |
|
||||
| [0004](adr/0004-journal-d-audit-en-ajout-seul.md) | Journal d'audit en ajout seul, garanti par PostgreSQL |
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# 0002 - Authentification par JWT d'accès et jeton de rafraîchissement opaque
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-15
|
||||
|
||||
## Contexte
|
||||
|
||||
L'école n'impose aucun mécanisme d'authentification : les choix techniques sont libres et
|
||||
doivent être justifiés. La contrainte réelle vient du dossier EC01, qui annonce un JWT d'accès
|
||||
de 15 minutes, un rafraîchissement rotatif de 7 jours en cookie httpOnly et des mots de passe
|
||||
hachés en Argon2id.
|
||||
|
||||
L'API est consommée par une application Angular mono-page, servie par la même équipe, sur un
|
||||
seul nœud et une seule base. Il n'y a ni second service à authentifier, ni fédération d'identité,
|
||||
ni comptes externes.
|
||||
|
||||
## Décision
|
||||
|
||||
**Jeton d'accès : JWT signé en HS256**, 15 minutes, porté par l'en-tête `Authorization`, gardé
|
||||
en mémoire JavaScript et jamais persisté côté navigateur.
|
||||
|
||||
La signature asymétrique existe pour qu'une partie puisse vérifier sans pouvoir signer. Ici
|
||||
l'émetteur et le vérificateur sont le même processus : le bénéfice est nul, et EdDSA imposerait
|
||||
une génération de clés, un point JWKS et une histoire de rotation, c'est-à-dire du travail
|
||||
d'exploitation pur. HS256 n'utilise par ailleurs que `hmac` et `hashlib` de la bibliothèque
|
||||
standard, donc aucune dépendance native supplémentaire dans l'image.
|
||||
|
||||
Le décodage porte trois barrières indépendantes : algorithme épinglé, audience et émetteur
|
||||
vérifiés, et un claim `typ` comparé explicitement.
|
||||
|
||||
**Jeton de rafraîchissement : chaîne opaque de 256 bits, jamais un JWT.** Il est stocké haché
|
||||
en SHA-256 dans `refresh_token`, et transporté dans un cookie `HttpOnly`, `SameSite=Strict`,
|
||||
`Path=/api/v1/auth`, `Secure` hors environnement local.
|
||||
|
||||
Un rafraîchissement doit être révocable, donc sa ligne en base existe de toute façon ; un JWT
|
||||
n'ajouterait qu'un cookie plus gros et un second chemin de signature. Surtout, la séparation
|
||||
d'avec le jeton d'accès devient **structurelle et non conditionnelle** : un JWT ne figure dans
|
||||
aucune ligne, une chaîne opaque échoue au décodage. La confusion refresh-vers-accès, qui
|
||||
transforme silencieusement une fenêtre de 15 minutes en fenêtre de 7 jours, devient impossible
|
||||
même si quelqu'un oublie le test.
|
||||
|
||||
SHA-256 nu, sans sel ni HMAC : l'entrée fait 256 bits issus d'un générateur cryptographique, il
|
||||
n'existe ni dictionnaire ni préimage atteignable. Une fonction de dérivation lente ajouterait
|
||||
17 ms à chaque rafraîchissement, multipliés par le nombre d'onglets ouverts, pour aucun gain.
|
||||
|
||||
**Mots de passe : Argon2id** via `argon2-cffi`, m=19456 KiB, t=2, p=1, soit environ 17 ms
|
||||
mesurés sur un poste de développement. Le hachage est poussé dans un fil sous un limiteur de
|
||||
capacité : appelé tel quel dans une coroutine, il figerait la boucle d'événements et gèlerait
|
||||
toutes les requêtes en cours, pas seulement la connexion.
|
||||
|
||||
**Rotation avec détection de réutilisation.** Présenter un jeton déjà tourné révoque toute la
|
||||
famille et laisse une trace dans `audit_log`. Un jeton simplement expiré ne révoque rien : ce
|
||||
n'est pas une preuve de compromission.
|
||||
|
||||
**Pas de verrouillage de compte.** Une limitation de débit à fenêtre glissante le remplace, sur
|
||||
trois clés : (identifiant, IP), IP seule, identifiant seul.
|
||||
|
||||
## Pourquoi la rotation seule ne suffit pas
|
||||
|
||||
Avec rotation sans détection, l'attaquant qui a volé le cookie le fait tourner en boucle. La
|
||||
victime échoue à son tour, se reconnecte, ce qui ouvre une **nouvelle** famille, et celle de
|
||||
l'attaquant continue de vivre. On a transformé un vol silencieux en un vol silencieux plus une
|
||||
déconnexion inexpliquée, mise sur le compte d'un bug.
|
||||
|
||||
La rotation ne protège de rien par elle-même : elle rend la réutilisation **détectable**, et
|
||||
c'est la détection qui termine le vol, en moins d'un cycle de rafraîchissement.
|
||||
|
||||
Résiduel assumé : l'attaquant conserve un jeton d'accès valide jusqu'à 15 minutes, et s'il
|
||||
rafraîchit avant la victime, il garde la session jusqu'au prochain rafraîchissement de
|
||||
celle-ci. Borné, pas nul.
|
||||
|
||||
## Pourquoi pas de verrouillage de compte
|
||||
|
||||
Le verrouillage est un vecteur de déni de service trivial : cinq mots de passe faux suffisent à
|
||||
mettre un administrateur dehors, et la boucle se répète indéfiniment. Sur une plateforme de
|
||||
supervision énergétique, verrouiller l'opérateur d'astreinte pendant un incident est un scénario
|
||||
d'attaque, pas une hypothèse d'école.
|
||||
|
||||
Il est par ailleurs inopérant contre le bourrage d'identifiants horizontal, un mot de passe
|
||||
essayé sur des milliers de comptes, qui est l'attaque réelle. Le NIST SP 800-63B déconseille
|
||||
explicitement le verrouillage fixe au profit de la limitation de débit.
|
||||
|
||||
Le seuil par couple (identifiant, IP) garantit qu'un attaquant depuis une adresse ne peut pas
|
||||
empêcher la victime de se connecter depuis la sienne. Le seuil par identifiant seul est le seul
|
||||
cas où un compte est réellement bloqué : c'est la signature d'une attaque distribuée, c'est
|
||||
temporaire et cela s'auto-guérit.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le rechargement de page perd le jeton d'accès. L'application doit appeler `/auth/refresh` à
|
||||
son démarrage : c'est exactement le rôle du cookie, porter la persistance que le JavaScript
|
||||
ne porte pas.
|
||||
- L'intercepteur HTTP doit garantir **un seul rafraîchissement en vol**. Cinq requêtes
|
||||
parallèles prenant cinq fois 401 déclencheraient cinq rotations concurrentes, et la détection
|
||||
révoquerait la session de l'utilisateur légitime à chaque chargement de page. Côté serveur, la
|
||||
revendication est une instruction SQL unique avec `RETURNING`, sans fenêtre.
|
||||
- `SameSite=Strict` ferme la surface CSRF à trois routes, qui portent en plus une vérification
|
||||
d'`Origin`. Le jour où un flux OIDC arrive, il faudra repasser à `Lax`.
|
||||
- Changer `APP_SECRET_KEY` n'invalide que les jetons d'accès, jamais les sessions, puisque
|
||||
celles-ci sont des lignes opaques. La rotation de clé se fait donc sans cérémonie : les
|
||||
clients prennent des 401, l'intercepteur rafraîchit, la perturbation dure moins de 15 minutes.
|
||||
- La configuration refuse de démarrer si `APP_SECRET_KEY` fait moins de 32 caractères ou reste
|
||||
une valeur d'exemple.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Keycloak ou un fournisseur OIDC** : un serveur d'identité se justifie par la **fédération**,
|
||||
c'est-à-dire plusieurs applications, du SSO, des comptes externes. Il y a une application et
|
||||
des comptes internes. Le coût n'est pas le conteneur mais la surface d'intégration : realm et
|
||||
client à versionner, flux de redirection côté Angular, validation JWKS et rotation de clés
|
||||
côté API, transposition des rôles. Deux à trois jours sur un budget de dix.
|
||||
**Critère de bascule** : l'exigence de SSO d'un client pilote. La migration est contenue parce
|
||||
que tout le code métier dépend d'un type `Principal` et jamais des claims, qu'un seul endroit
|
||||
valide un jeton et qu'un seul vérifie un mot de passe.
|
||||
- **Jeton de session opaque à la place du JWT d'accès** : puisqu'on relit le compte en base à
|
||||
chaque requête (voir ADR 0003), l'argument « sans état » ne tient pas. Un jeton opaque serait
|
||||
défendable. Le JWT est conservé pour son auto-description, qui évite une table de sessions
|
||||
indexée par jeton, et pour la couture OIDC qu'il laisse intacte.
|
||||
- **Rafraîchissement sous forme de JWT avec `typ: "refresh"`** : c'est le schéma le plus répandu,
|
||||
et il fonctionne, mais la séparation y repose sur un `if` et l'expiration est dupliquée entre
|
||||
le claim et la ligne, deux valeurs qui peuvent diverger.
|
||||
- **Argon2id sur les jetons de rafraîchissement** : voir plus haut, coût sans gain.
|
||||
- **Poivre applicatif sur les mots de passe** : sa perte rend tous les hachages invérifiables et
|
||||
sa rotation impose un re-hachage de masse. Sur deux semaines, le risque dépasse le gain.
|
||||
- **`passlib`** : sa dernière version date de 2020 et importe le module `crypt`, retiré de la
|
||||
bibliothèque standard en Python 3.13. Éliminatoire sur Python 3.14.
|
||||
- **`python-jose`** : maintenance erratique et CVE en 2024. `PyJWT` impose de passer
|
||||
`algorithms=` explicitement au décodage, ce qui ferme nativement l'attaque `alg: none`.
|
||||
@@ -0,0 +1,107 @@
|
||||
# 0003 - Autorisation RBAC à trois rôles, avec relecture du compte à chaque requête
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-15
|
||||
|
||||
## Contexte
|
||||
|
||||
Le dossier EC01 annonce un RBAC à trois rôles, `admin`, `opérateur` et `lecteur`, et des comptes
|
||||
machine à machine distincts pour l'ETL et le travail d'apprentissage. Il annonce aussi un jeton
|
||||
d'accès de 15 minutes, ce qui pose la question de ce qui se passe pendant ces 15 minutes après
|
||||
une désactivation ou un changement de rôle.
|
||||
|
||||
## Décision
|
||||
|
||||
**Trois rôles totalement ordonnés** : `lecteur < operateur < admin`. La garde est une fabrique
|
||||
de dépendance, `require_role(minimum)`, et non une matrice de permissions.
|
||||
|
||||
Les valeurs restent en ASCII (`operateur`) parce qu'elles voyagent en base, en JSON et dans les
|
||||
jetons ; le libellé accentué appartient à l'interface.
|
||||
|
||||
**Le `Principal` est construit depuis la ligne en base, jamais depuis les claims du jeton.**
|
||||
`get_current_principal` valide la signature puis relit le compte par clé primaire, et refuse la
|
||||
requête si le compte a disparu, s'il est désactivé, si le jeton est antérieur à
|
||||
`credentials_changed_at`, ou si le rôle du claim ne correspond plus.
|
||||
|
||||
**Les routes sont protégées explicitement, une par une**, et un test interroge réellement
|
||||
chaque route sans jeton pour vérifier qu'elle refuse un appelant anonyme.
|
||||
|
||||
**Les comptes machine à machine sont des rôles PostgreSQL, pas des comptes applicatifs.** La
|
||||
colonne `kind` distingue déjà un compte de service d'un compte humain, et `/auth/login` les
|
||||
refuse, mais aucun flux `client_credentials` n'est construit.
|
||||
|
||||
## Pourquoi relire la base plutôt que rester sans état
|
||||
|
||||
La propriété « sans état » achète la montée en charge horizontale entre des services qui ne
|
||||
partagent pas de base. Il y a un service et une base : le bénéfice est nul.
|
||||
|
||||
Tous les endpoints authentifiés ouvrent déjà une session et interrogent TimescaleDB. Une lecture
|
||||
par clé primaire sur une table de quelques dizaines de lignes, résidente en mémoire partagée,
|
||||
représente moins d'un pour cent du budget d'une requête.
|
||||
|
||||
Ce qu'on achète en échange est la **révocation immédiate**. « Un opérateur licencié à 10h00
|
||||
garde-t-il ses droits jusqu'à 10h15 ? » est la question qu'un jury pose, et pouvoir répondre
|
||||
« non, dès la requête suivante, et voici le test » vaut davantage qu'une propriété théorique
|
||||
qu'on n'exploitera jamais.
|
||||
|
||||
Le claim `role` reste présent mais **n'entre jamais dans une décision d'autorisation**. Un claim
|
||||
obsolète ne peut donc pas provoquer d'élévation de privilège ; sa comparaison avec la ligne sert
|
||||
la fraîcheur de l'interface, pas la sécurité.
|
||||
|
||||
Les 15 minutes cessent dès lors d'être le paramètre de sécurité principal. Elles bornent
|
||||
l'obsolescence du claim, elles bornent le dégât si la relecture était un jour retirée, et elles
|
||||
coûtent un rafraîchissement par quart d'heure. C'est une marge, pas une garantie.
|
||||
|
||||
## Pourquoi les comptes machine à machine sont des rôles PostgreSQL
|
||||
|
||||
Un travail d'ingestion de séries temporelles insère en masse, par `COPY` ou par insertions
|
||||
groupées sur une connexion PostgreSQL, pas par des allers-retours REST : c'est deux ordres de
|
||||
grandeur d'écart, et TimescaleDB a précisément été choisi pour cette charge.
|
||||
|
||||
Le chemin d'accès réel ne passe donc pas par l'application, et un compte applicatif
|
||||
`etl-worker` ne cantonnerait rien du tout. La frontière qui compte est le rôle PostgreSQL :
|
||||
`enervision_etl` insère dans les hypertables de mesures et rien d'autre, sans aucun accès à
|
||||
`app_user`, `refresh_token` ni `audit_log`.
|
||||
|
||||
Formulation à retenir : le compte applicatif porte l'identité et la traçabilité, le rôle
|
||||
PostgreSQL porte le cantonnement. Le premier sans le second serait du théâtre.
|
||||
|
||||
**Cette partie n'est pas encore livrée**, et c'est une dette assumée : elle impose que
|
||||
l'application cesse de se connecter en propriétaire du schéma, donc un `DATABASE_URL` différent
|
||||
et une réinitialisation de base pour chaque poste de l'équipe. À ouvrir en ticket avec l'équipe
|
||||
chargée de l'ETL.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Un changement de rôle ou une désactivation révoque aussi les familles de jetons de la cible,
|
||||
sans quoi la révocation ne serait immédiate que sur le jeton d'accès.
|
||||
- `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. Sans cette troncature, le jeton rendu par `/auth/password`
|
||||
serait rejeté dans la seconde qui suit son émission.
|
||||
- Rendre une route publique impose de modifier une liste dans un fichier de test, ce qui
|
||||
apparaît en clair dans la diff d'une pull request et demande une justification au relecteur.
|
||||
Le garde-fou est social autant que technique.
|
||||
- Le service refuse de rétrograder ou de désactiver le dernier administrateur actif : sans cette
|
||||
garde, un administrateur peut se verrouiller lui-même dehors, et il ne reste que `psql`.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Matrice de permissions explicites** (`measure.read`, `user.create`…) : c'est la bonne réponse
|
||||
à partir d'une dizaine de rôles. Ici, trois rôles totalement ordonnés se lisent en une ligne.
|
||||
**Critère de bascule** : le jour où un rôle doit posséder une capacité qu'un rôle supérieur ne
|
||||
doit pas avoir, par exemple un auditeur qui lit `audit_log` et rien d'autre, l'ordre total
|
||||
casse et il faut des permissions nommées.
|
||||
- **Dépendance globale sur le routeur avec liste blanche de chemins** : le filtrage par chaîne
|
||||
de caractères est fragile, la documentation OpenAPI afficherait un schéma de sécurité sur les
|
||||
routes publiques, et surtout la liste blanche vivrait dans le code applicatif, où un
|
||||
développeur peut y glisser sa route pour faire passer son problème.
|
||||
- **Portée par site** : c'est la limite connue de cette conception. Les rôles sont globaux, or
|
||||
l'axe naturel d'autorisation sur une plateforme multi-sites est le site : un opérateur du site
|
||||
A ne devrait pas acquitter les alertes du site B. En l'état, le risque BOLA reste ouvert. Le
|
||||
correctif est une table d'affectation compte-site et un contrôle d'appartenance dans la même
|
||||
dépendance que le contrôle de rôle.
|
||||
- **Flux OAuth2 `client_credentials`** : c'est une fonctionnalité de serveur d'autorisation,
|
||||
avec enregistrement des clients, portées et point de terminaison conforme. Des jours de
|
||||
travail pour zéro consommateur HTTP actuel. Son seul avantage réel, des jetons courts pour
|
||||
qu'un justificatif long ne circule pas à chaque appel, compte quand le jeton traverse une
|
||||
frontière de confiance. Ici il n'en traverse aucune.
|
||||
@@ -0,0 +1,104 @@
|
||||
# 0004 - Journal d'audit en ajout seul, garanti par PostgreSQL
|
||||
|
||||
- Statut : accepté
|
||||
- Date : 2026-09-15
|
||||
|
||||
## Contexte
|
||||
|
||||
Le dossier EC01 annonce une table `audit_log` « en ajout seul pour toute action
|
||||
d'administration ». Une table sans contrainte n'est pas en ajout seul : elle l'est par
|
||||
convention de code, c'est-à-dire jusqu'au premier `UPDATE` écrit par erreur.
|
||||
|
||||
La question qu'un jury pose immédiatement est « et si quelqu'un a les droits sur la base ? ».
|
||||
Elle mérite une réponse honnête plutôt qu'une parade.
|
||||
|
||||
## Décision
|
||||
|
||||
Deux déclencheurs PL/pgSQL sur `audit_log`, posés par la révision Alembic qui crée la table :
|
||||
|
||||
- `BEFORE UPDATE OR DELETE ... FOR EACH ROW`
|
||||
- `BEFORE TRUNCATE ... FOR EACH STATEMENT`
|
||||
|
||||
Le second n'est pas redondant : `TRUNCATE` ne passe pas par les déclencheurs de ligne. Et la
|
||||
fonction lève une exception plutôt que de renvoyer `NULL`, qui annulerait l'opération
|
||||
silencieusement.
|
||||
|
||||
`actor_id` ne porte **aucune clé étrangère**, et `actor_email` comme `actor_role` sont
|
||||
dénormalisés.
|
||||
|
||||
Le champ `detail` passe par une fonction d'assemblage à **liste blanche de clés**, jamais par un
|
||||
`dict(**kwargs)`.
|
||||
|
||||
## Pourquoi pas de clé étrangère sur l'acteur
|
||||
|
||||
Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur d'ajout seul
|
||||
refuserait : la suppression d'un compte échouerait. Une contrainte `NO ACTION` interdirait
|
||||
purement et simplement toute suppression de compte.
|
||||
|
||||
Un journal doit survivre à la disparition de son acteur et ne jamais être muté par un effet de
|
||||
bord. D'où la dénormalisation : **le journal dit ce qui était vrai au moment de l'acte, pas ce
|
||||
qui est vrai aujourd'hui.**
|
||||
|
||||
## Ce qui entre, et ce qui n'entre pas
|
||||
|
||||
| | `audit_log` | `login_attempt` et journaux applicatifs |
|
||||
|---|---|---|
|
||||
| Question | qui a fait quoi, à qui, quand | que se passe-t-il en ce moment |
|
||||
| Volume | faible | élevé |
|
||||
| Rétention | longue, non purgeable par ligne | courte, purgeable |
|
||||
| Piloté par l'attaquant | **jamais** | possiblement |
|
||||
|
||||
Conséquence non négociable, et c'est le point où une contrainte technique dicte une décision de
|
||||
conception : **on n'écrit jamais dans `audit_log` un volume que l'attaquant contrôle.** Une
|
||||
force brute y inscrirait des millions de lignes indestructibles. Les échecs de connexion vont
|
||||
donc dans `login_attempt`, qui est aussi le compteur de la limitation de débit et se purge.
|
||||
|
||||
La seule exception est `auth.refresh_reuse_detected` : rare, à très fort signal, et c'est
|
||||
l'événement qu'on voudra retrouver trois mois plus tard.
|
||||
|
||||
Corollaire : `audit_log` n'est **pas** une hypertable. Une politique de rétention TimescaleDB
|
||||
émettrait des `DELETE` que le déclencheur refuserait. Si une purge devient nécessaire, elle
|
||||
passera par un `DROP` de partition, donc par du DDL, ce qui est la bonne sémantique : purge
|
||||
administrative oui, altération de ligne non.
|
||||
|
||||
## Ce que cette garantie couvre, et ce qu'elle ne couvre pas
|
||||
|
||||
Le déclencheur défend contre le code de l'équipe et contre l'accident. Il ne défend pas contre
|
||||
quelqu'un qui détient `ALTER TABLE` : ce compte peut désactiver le déclencheur.
|
||||
|
||||
La réponse honnête à « et si quelqu'un a les droits sur la base ? » est donc : alors l'audit
|
||||
local ne vaut plus rien, et c'est vrai de tout journal co-localisé avec ce qu'il journalise. Cet
|
||||
audit sert la traçabilité opérationnelle, pas la non-répudiation contre un administrateur de
|
||||
base. Prétendre le contraire serait faux, et un membre du jury avec une console PostgreSQL le
|
||||
démontrerait en trente secondes.
|
||||
|
||||
Le palier suivant est double, et il est assumé comme dette :
|
||||
|
||||
1. **Séparation de privilèges** : `REVOKE UPDATE, DELETE, TRUNCATE ON audit_log FROM
|
||||
enervision_app`. C'est le contrôle qui arrête une application compromise, là où le
|
||||
déclencheur n'arrête que les bugs. Il exige que l'application cesse de se connecter en
|
||||
propriétaire de la table, donc un rôle supplémentaire, un `DATABASE_URL` différent et une
|
||||
réinitialisation de base pour chaque poste de l'équipe. Reporté pour cette raison.
|
||||
2. **Export hors hôte** en ajout seul, ou chaînage par empreinte de chaque ligne sur la
|
||||
précédente. C'est le seuil au-delà duquel on peut parler de non-répudiation.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Les tests d'intégration ne peuvent pas nettoyer `audit_log` derrière eux, et doivent donc
|
||||
filtrer sur leur propre `target_id` plutôt que supposer une table vide.
|
||||
- Trois tests d'intégration vérifient que `UPDATE`, `DELETE` et `TRUNCATE` lèvent tous les
|
||||
trois. Ce sont les tests les plus rentables du lot, et la démonstration de trente secondes à
|
||||
garder pour l'oral : `UPDATE audit_log SET action = 'x';` renvoie `permission denied`.
|
||||
- L'adresse IP est une donnée personnelle. `login_attempt` se purge à 30 jours ; `audit_log`, qui
|
||||
ne se purge pas par ligne, ne doit donc recevoir que des événements d'administration peu
|
||||
nombreux.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Convention de code seule** : c'est la formulation du dossier EC01, et elle ne tient pas. Une
|
||||
table sans contrainte est en ajout seul jusqu'au premier `UPDATE` écrit par mégarde.
|
||||
- **Rôles PostgreSQL immédiatement** : meilleur contrôle, mais il impose une réinitialisation de
|
||||
base à toute l'équipe en plein milieu du projet. Le déclencheur d'abord, les privilèges
|
||||
ensuite.
|
||||
- **`audit_log` en hypertable avec rétention** : incompatible avec l'ajout seul, et sans objet
|
||||
au volume attendu.
|
||||
@@ -109,24 +109,52 @@ consolidée.
|
||||
|
||||
### En place
|
||||
|
||||
- **Authentification et autorisation.** JWT d'accès de 15 minutes, jeton de rafraîchissement
|
||||
opaque en cookie `HttpOnly` avec rotation et détection de réutilisation, mots de passe en
|
||||
Argon2id, RBAC à trois rôles. Détail dans [20-backend.md](20-backend.md), décisions dans les
|
||||
[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md) et
|
||||
[0003](../adr/0003-autorisation-rbac-a-trois-roles.md).
|
||||
- **Interdire par défaut.** Toute route exige un jeton, sauf quatre exceptions listées dans un
|
||||
fichier de test qui interroge réellement chaque route sans identifiant.
|
||||
- **Révocation immédiate.** Le compte est relu en base à chaque requête : une désactivation ou un
|
||||
changement de rôle prend effet à la requête suivante, pas au bout de 15 minutes.
|
||||
- **Limitation de débit à fenêtre glissante** sur trois clés, évaluée avant le hachage. Pas de
|
||||
verrouillage de compte, qui serait un déni de service trivial.
|
||||
- **Journal d'audit en ajout seul**, garanti par deux déclencheurs PostgreSQL
|
||||
([ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md)).
|
||||
- **Les secrets n'ont pas de valeur par défaut.** `APP_SECRET_KEY` et `DATABASE_URL` sont requis
|
||||
sans repli : l'application refuse de démarrer si l'un manque, plutôt que de tourner avec une
|
||||
valeur de démonstration. `.env` reste hors dépôt, `.env.example` est versionné.
|
||||
- **CORS conditionnel** : le middleware n'est ajouté que si `APP_CORS_ORIGINS` est renseigné.
|
||||
- **Documentation interactive fermée en production** : `/docs`, `/redoc` et `/openapi.json` sont
|
||||
désactivés dès que `APP_ENV=prod`.
|
||||
sans repli, et la configuration refuse de démarrer sur cinq erreurs silencieuses : secret trop
|
||||
court ou laissé à sa valeur d'exemple, `debug` en production, joker CORS, origines vides hors
|
||||
local, cookie `SameSite=None` sans `Secure`.
|
||||
- **CORS explicite** : origines listées, méthodes et en-têtes énumérés, jamais de joker.
|
||||
- **En-têtes de sécurité** posés par l'application (`nosniff`, `DENY`, `no-referrer`) et
|
||||
`Cache-Control: no-store` sur les routes d'authentification.
|
||||
- **Caviardage des journaux** : jetons, empreintes Argon2, mots de passe et cookies sont
|
||||
expurgés avant écriture.
|
||||
- **Documentation interactive fermée** en préproduction et en production, `/metrics` derrière un
|
||||
jeton facultatif, sonde de disponibilité qui ne publie plus la version de TimescaleDB.
|
||||
- **CI backend bloquante** : format, lint, typage strict et tests avec seuil de couverture.
|
||||
- **Conteneur backend non-root**, déclaré dans `apps/backend/Dockerfile`.
|
||||
- **Côté infrastructure** : la clé SSH est marquée `sensitive`, le kubeconfig reste en `600/root`
|
||||
sur la machine cible et n'est lu que par `sudo`, `*.tfvars` est ignoré par git sauf les
|
||||
`.example`.
|
||||
|
||||
### Absent
|
||||
### Absent, et assumé
|
||||
|
||||
- **Aucune authentification ni autorisation.** Les deux endpoints exposés sont publics. Rien
|
||||
n'est encore décidé sur ce point.
|
||||
- Pas de TLS, pas de limitation de débit, pas de journalisation des accès, pas de rotation des
|
||||
secrets.
|
||||
- Aucune analyse de dépendances ni de conteneur, faute de CI.
|
||||
- **Rôles PostgreSQL cantonnés** pour l'ETL et le travail d'apprentissage. C'est la vraie
|
||||
frontière pour ces deux consommateurs, qui écrivent en base et non par HTTP. Reporté parce que
|
||||
cela impose une réinitialisation de base à toute l'équipe. Voir l'ADR 0003.
|
||||
- **`REVOKE` sur `audit_log`** : les déclencheurs arrêtent les accidents, les privilèges
|
||||
arrêteraient une application compromise. Même raison de report.
|
||||
- **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.
|
||||
- **TLS, HSTS et CSP** : ils appartiennent au terminateur TLS, qui n'existe pas encore.
|
||||
- **Limitation de débit au frontal** : celle de l'application protège les identifiants, pas
|
||||
l'infrastructure.
|
||||
- **Analyse de dépendances et de conteneurs** dans la CI, qui relève du chantier CI/CD.
|
||||
- **Le fichier `environment.ts` de production** pointe encore sur `http://localhost:8000` en HTTP
|
||||
simple : dans cet état, le cookie `Secure` ne sera pas posé. Voir
|
||||
[31-contrat-authentification.md](31-contrat-authentification.md).
|
||||
|
||||
## Décisions structurantes
|
||||
|
||||
|
||||
+121
-31
@@ -8,23 +8,23 @@ La doctrine est posée dans [`apps/backend/README.md`](../../apps/backend/README
|
||||
[`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.
|
||||
|
||||
Dans les faits, trois de ces couches sont des dossiers vides.
|
||||
Les quatre couches existent désormais, portées par l'authentification.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
ep["endpoints<br/>2 routes"]
|
||||
sc["schemas<br/>2 modèles Pydantic"]
|
||||
sv["services<br/>vide"]
|
||||
rp["repositories<br/>vide"]
|
||||
md["models<br/>vide"]
|
||||
ep["endpoints<br/>health, auth, users"]
|
||||
sc["schemas<br/>Pydantic"]
|
||||
sv["services<br/>AuthService, UserService"]
|
||||
rp["repositories<br/>user, refresh_token,<br/>login_attempt, audit_log"]
|
||||
md["models<br/>4 tables"]
|
||||
db[("PostgreSQL")]
|
||||
|
||||
ep --> sc
|
||||
ep -.-> sv
|
||||
sv -.-> rp
|
||||
rp -.-> md
|
||||
ep -->|"SQL brut, état actuel"| db
|
||||
rp -.-> db
|
||||
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`
|
||||
@@ -32,9 +32,13 @@ exécute aujourd'hui son `SELECT` directement, sans repository. C'est acceptable
|
||||
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` ne contient qu'un avertissement, qui mérite d'être connu avant la
|
||||
première migration : tout modèle absent de ce module reste invisible d'un
|
||||
`alembic revision --autogenerate`, qui produirait alors un `drop` de sa table.
|
||||
`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
|
||||
|
||||
@@ -81,14 +85,41 @@ démarre ne prouve rien sur la base, la première connexion réelle a lieu au pr
|
||||
| `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` |
|
||||
|
||||
Deux pièges :
|
||||
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.
|
||||
@@ -99,16 +130,36 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
|
||||
|---|---|---|---|
|
||||
| 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 |
|
||||
| GET | `/metrics` | non | Format Prometheus, exposé par l'instrumentator |
|
||||
| GET | `/docs`, `/redoc`, `/openapi.json` | non | Désactivés quand `APP_ENV=prod` |
|
||||
| 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 | `/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` |
|
||||
|
||||
Aucune route métier n'existe à ce jour.
|
||||
**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.
|
||||
|
||||
Aucune route métier n'existe à ce jour. 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
|
||||
@@ -121,26 +172,54 @@ sequenceDiagram
|
||||
R->>D: SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'
|
||||
alt base injoignable
|
||||
D--xR: SQLAlchemyError ou OSError
|
||||
R-->>C: 503 Base de donnees injoignable
|
||||
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 status ready
|
||||
R-->>C: 200 timescaledb loaded
|
||||
end
|
||||
```
|
||||
|
||||
## Sécurité
|
||||
|
||||
Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté backend :
|
||||
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 :
|
||||
|
||||
- **Aucune authentification, aucune autorisation.** Les deux routes sont publiques. Le premier
|
||||
endpoint métier imposera de trancher ce point.
|
||||
- Le CORS n'autorise que les origines listées, et n'existe pas si la liste est vide.
|
||||
- `/docs`, `/redoc` et `/openapi.json` disparaissent en production.
|
||||
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, ni journalisation des accès, ni en-têtes de sécurité.
|
||||
- Ni limitation de débit au frontal, ni TLS, ni journalisation des accès applicative.
|
||||
|
||||
## Observabilité
|
||||
|
||||
@@ -151,13 +230,24 @@ Voir la vue consolidée dans [00-vue-ensemble.md](00-vue-ensemble.md). Côté ba
|
||||
## Tests
|
||||
|
||||
Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md).
|
||||
Deux points structurants y sont fixés : les doubles passent par `app.dependency_overrides` et
|
||||
jamais par `unittest.mock`, et les tests qui touchent la vraie base portent le marqueur
|
||||
`integration`, exclu par défaut.
|
||||
|
||||
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
|
||||
|
||||
- **Authentification et autorisation** : quel mécanisme, quelle granularité.
|
||||
- **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.
|
||||
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`.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# Contrat d'authentification, côté frontend
|
||||
|
||||
Ce que le frontend doit savoir pour coder la connexion, et rien de plus. Le raisonnement est
|
||||
dans l'[ADR 0002](../adr/0002-authentification-jwt-et-refresh-opaque.md).
|
||||
|
||||
Statut : `Fait` côté backend, `Cible` côté Angular.
|
||||
|
||||
## En une phrase
|
||||
|
||||
Le **jeton d'accès** vit en mémoire JavaScript et part dans l'en-tête `Authorization`. Le
|
||||
**jeton de rafraîchissement** est un cookie `HttpOnly` que le code ne voit jamais et n'a pas à
|
||||
gérer : il suffit d'envoyer les requêtes avec `withCredentials`.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Méthode | Chemin | Authentification | Réponse |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/auth/login` | aucune | `200` `TokenResponse` |
|
||||
| POST | `/api/v1/auth/refresh` | cookie | `200` `TokenResponse` |
|
||||
| POST | `/api/v1/auth/logout` | cookie | `204` |
|
||||
| POST | `/api/v1/auth/logout-all` | jeton d'accès | `204` |
|
||||
| POST | `/api/v1/auth/password` | jeton d'accès | `200` `TokenResponse` |
|
||||
| GET | `/api/v1/auth/me` | jeton d'accès | `200` `PrincipalResponse` |
|
||||
| GET | `/api/v1/users` | jeton d'accès, `admin` | `200` `UserResponse[]` |
|
||||
| POST | `/api/v1/users` | jeton d'accès, `admin` | `201` `TemporaryPasswordResponse` |
|
||||
| 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.
|
||||
|
||||
## Charges utiles
|
||||
|
||||
```jsonc
|
||||
// POST /auth/login
|
||||
{ "email": "operateur@enervision.fr", "password": "..." }
|
||||
|
||||
// TokenResponse, rendu par login, refresh et password
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 900,
|
||||
"principal": {
|
||||
"id": "3f2a...",
|
||||
"email": "operateur@enervision.fr",
|
||||
"role": "lecteur | operateur | admin",
|
||||
"kind": "human",
|
||||
"must_change_password": false
|
||||
}
|
||||
}
|
||||
|
||||
// POST /auth/password
|
||||
{ "current_password": "...", "new_password": "..." } // 12 à 128 caractères
|
||||
```
|
||||
|
||||
Le secret de rafraîchissement **n'apparaît jamais** dans le corps de la réponse.
|
||||
|
||||
## Codes d'erreur à traiter
|
||||
|
||||
| Code | Quand | Ce que fait le frontend |
|
||||
|---|---|---|
|
||||
| `401` sur `/auth/login` | identifiants faux, compte désactivé, compte inconnu | afficher le message générique tel quel, ne rien déduire de plus |
|
||||
| `429` sur `/auth/login` | trop de tentatives | afficher l'attente, l'en-tête `Retry-After` donne les secondes |
|
||||
| `401` avec `WWW-Authenticate: ... error="expired"` | jeton d'accès périmé | **rafraîchir**, puis rejouer la requête |
|
||||
| `401` avec `error="token_stale"` | rôle changé ou compte désactivé pendant la session | **rafraîchir** ; si le rafraîchissement échoue, déconnecter |
|
||||
| `401` avec `error="invalid_token"` | jeton illisible ou compte disparu | déconnecter |
|
||||
| `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 |
|
||||
| `422` | corps invalide | le détail donne `champ` et `type`, jamais la valeur envoyée |
|
||||
|
||||
## Les quatre règles qui comptent
|
||||
|
||||
**1. Le jeton d'accès ne se persiste jamais.** Ni `localStorage`, ni `sessionStorage`, ni
|
||||
cookie : un signal dans un service racine. Un rechargement de page le perd, c'est voulu.
|
||||
|
||||
**2. Au démarrage de l'application, appeler `/auth/refresh`.** C'est ce qui restaure la session
|
||||
après un rechargement, via `provideAppInitializer`. Un `401` y est normal : il signifie
|
||||
simplement qu'il n'y a pas de session, on affiche la page de connexion.
|
||||
|
||||
**3. Un seul rafraîchissement en vol à la fois.** C'est une exigence, pas une optimisation.
|
||||
Cinq requêtes parallèles qui prennent cinq fois `401` déclencheraient cinq rotations
|
||||
concurrentes ; le serveur n'en accepte qu'une et considère les autres comme un rejeu, ce qui
|
||||
**révoque toute la session**. L'utilisateur serait déconnecté à chaque chargement de page.
|
||||
|
||||
```ts
|
||||
// Dans l'intercepteur : une seule rotation partagée par tous les appelants.
|
||||
private rotation$?: Observable<TokenResponse>;
|
||||
|
||||
private rafraichir(): Observable<TokenResponse> {
|
||||
this.rotation$ ??= this.http.post<TokenResponse>('/api/v1/auth/refresh', {}, { withCredentials: true })
|
||||
.pipe(finalize(() => (this.rotation$ = undefined)), shareReplay(1));
|
||||
return this.rotation$;
|
||||
}
|
||||
```
|
||||
|
||||
**4. Toutes les requêtes vers `/auth/*` portent `withCredentials: true`.** Sans quoi le cookie
|
||||
n'est pas envoyé et le rafraîchissement échoue toujours.
|
||||
|
||||
## Ce qu'il faut savoir sur le cookie
|
||||
|
||||
- Nom `ev_refresh` en local, `__Secure-ev_refresh` ailleurs. Le code ne le lit jamais.
|
||||
- `HttpOnly`, `SameSite=Strict`, `Path=/api/v1/auth`. Il n'est donc envoyé que sur ces routes.
|
||||
- `Secure` dès que l'environnement n'est pas `local`, donc **HTTPS obligatoire hors poste de
|
||||
développement**.
|
||||
- `HttpOnly` empêche de voler le cookie, pas de s'en servir : une XSS peut appeler
|
||||
`/auth/refresh` depuis l'origine de la victime. La vraie défense contre ce cas reste de ne pas
|
||||
avoir de XSS.
|
||||
|
||||
## Dev et production, le point à ne pas rater
|
||||
|
||||
En développement, `proxy.conf.json` fait passer `/api` par `localhost:4200`, donc tout est
|
||||
**même origine** et le cookie marche sans rien configurer.
|
||||
|
||||
En production, `src/environments/environment.ts` contient encore le gabarit
|
||||
`http://localhost:8000/api/v1`, en HTTP simple et sur une autre origine. **Dans cet état, aucun
|
||||
cookie `Secure` ne sera posé et l'authentification ne fonctionnera pas.**
|
||||
|
||||
Deux corrections, à faire avant la démonstration :
|
||||
|
||||
1. passer `apiUrl` à `/api/v1` et servir le SPA et l'API sous la même origine, via un
|
||||
`location /api` dans le `nginx.conf` du conteneur frontend ou via l'ingress ;
|
||||
2. servir en HTTPS.
|
||||
|
||||
Et au moins une fois avant la soutenance, lancer le front **sans le proxy**, en cross-origin
|
||||
réel : c'est le seul moyen d'exercer le préflight CORS et `SameSite`, que le proxy masque.
|
||||
|
||||
## Origines autorisées
|
||||
|
||||
Le backend ne monte le middleware CORS que si `APP_CORS_ORIGINS` est renseigné, et refuse de
|
||||
démarrer hors `local` si la liste est vide. Les routes portant le cookie vérifient en plus
|
||||
l'en-tête `Origin` : une origine absente de la liste reçoit un `403`.
|
||||
|
||||
Méthodes autorisées : `GET`, `POST`, `PATCH`, `PUT`, `DELETE`, `OPTIONS`.
|
||||
En-têtes autorisés : `Authorization`, `Content-Type`. En-tête exposé : `Retry-After`.
|
||||
|
||||
## Premier compte
|
||||
|
||||
Créé en ligne de commande côté serveur (`make bootstrap-admin EMAIL=...`), avec
|
||||
`must_change_password` à vrai. La première connexion renvoie donc `403
|
||||
password_change_required` sur toute route métier, et seuls `/auth/me` et `/auth/password`
|
||||
répondent. L'écran de changement de mot de passe doit exister avant la démonstration.
|
||||
|
||||
Idem pour tout compte créé par un administrateur : le mot de passe provisoire est affiché **une
|
||||
seule fois** dans la réponse, il n'est plus jamais récupérable.
|
||||
@@ -35,8 +35,8 @@ Statut : `Fait`.
|
||||
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
|
||||
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
|
||||
`apps/backend/tests/conftest.py`.
|
||||
- Une révision Alembic, `5353c0e4f094`, qui **ne crée aucune table**. Elle établit
|
||||
`alembic_version` et refuse de s'appliquer si l'extension manque :
|
||||
- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
|
||||
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
|
||||
|
||||
```sql
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
|
||||
@@ -47,6 +47,9 @@ END IF;
|
||||
Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap sauté ne se voit pas
|
||||
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
|
||||
|
||||
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
|
||||
puis `login_attempt` et `audit_log`, puis `refresh_token`.
|
||||
|
||||
## Cycle de vie d'une mesure
|
||||
|
||||
Statut : `Cible`. Aucun de ces maillons n'existe.
|
||||
@@ -65,7 +68,72 @@ flowchart LR
|
||||
Les lectures de l'API et de Grafana visent l'agrégat continu, pas la table brute : c'est tout
|
||||
l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.
|
||||
|
||||
## Modèle
|
||||
## Tables d'authentification
|
||||
|
||||
Statut : `Fait`. Elles ne sont pas des séries temporelles et n'ont donc rien à voir avec les
|
||||
hypertables ; elles vivent dans `apps/backend/alembic/`, qui porte le schéma exposé par l'API.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
APP_USER ||--o{ REFRESH_TOKEN : ouvre
|
||||
APP_USER {
|
||||
uuid id PK
|
||||
string email UK
|
||||
text password_hash
|
||||
text role
|
||||
text kind
|
||||
bool is_active
|
||||
bool must_change_password
|
||||
timestamptz credentials_changed_at
|
||||
}
|
||||
REFRESH_TOKEN {
|
||||
uuid id PK
|
||||
uuid family_id
|
||||
uuid user_id FK
|
||||
bytea token_hash UK
|
||||
timestamptz expires_at
|
||||
timestamptz rotated_at
|
||||
timestamptz revoked_at
|
||||
text revoked_reason
|
||||
uuid replaced_by
|
||||
}
|
||||
LOGIN_ATTEMPT {
|
||||
bigint id PK
|
||||
timestamptz occurred_at
|
||||
string email_tried
|
||||
inet client_ip
|
||||
text outcome
|
||||
}
|
||||
AUDIT_LOG {
|
||||
bigint id PK
|
||||
timestamptz occurred_at
|
||||
uuid actor_id
|
||||
text actor_email
|
||||
text action
|
||||
jsonb detail
|
||||
}
|
||||
```
|
||||
|
||||
Quatre choix de modélisation portent une intention et se défendent seuls :
|
||||
|
||||
- **`app_user` et non `user`** : `user` est un mot réservé PostgreSQL, raccourci de
|
||||
`CURRENT_USER`. Le nom rappelle en prime qu'il s'agit d'un compte applicatif, par opposition
|
||||
au rôle PostgreSQL qui portera le cantonnement de l'ETL.
|
||||
- **`credentials_changed_at`, une seule colonne**, couvre le changement de mot de passe, le
|
||||
changement de rôle et la désactivation. Un compteur de version ne dirait rien à un humain qui
|
||||
lit un audit.
|
||||
- **`refresh_token.expires_at` est absolu et hérité** du prédécesseur à chaque rotation. S'il
|
||||
glissait, la promesse de sept jours serait fictive et une session active ne finirait jamais.
|
||||
- **`audit_log.actor_id` n'a aucune clé étrangère**, et `actor_email` comme `actor_role` sont
|
||||
dénormalisés. Une contrainte `ON DELETE SET NULL` déclencherait un `UPDATE` que le déclencheur
|
||||
d'ajout seul refuserait. Voir l'[ADR 0004](../adr/0004-journal-d-audit-en-ajout-seul.md).
|
||||
|
||||
`audit_log` porte deux déclencheurs qui refusent `UPDATE`, `DELETE` et `TRUNCATE`. Elle n'est
|
||||
donc **pas** une hypertable : une politique de rétention émettrait des `DELETE` qu'ils
|
||||
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
|
||||
piloté par l'attaquant.
|
||||
|
||||
## Modèle métier
|
||||
|
||||
Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles
|
||||
s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui
|
||||
|
||||
@@ -12,12 +12,17 @@ contredisent, c'est l'ADR qui fait foi et la vue qui est en retard.
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
L'observabilité, la sécurité et la CI/CD n'ont pas de document propre : ce sont des sections des
|
||||
cinq ci-dessus, tant que `monitoring/`, `.github/workflows/` et `etl/airflow/` ne contiennent que
|
||||
des `.gitkeep`. Elles en sortiront le jour où elles auront de la matière. Un fichier vide de plus
|
||||
n'aide personne.
|
||||
L'observabilité et la CI/CD n'ont pas de document propre : ce sont des sections des documents
|
||||
ci-dessus, tant que `monitoring/` et `etl/airflow/` ne contiennent que des `.gitkeep`. Elles en
|
||||
sortiront le jour où elles auront de la matière. Un fichier vide de plus n'aide personne.
|
||||
|
||||
La sécurité applicative, elle, a désormais de la matière : la vue consolidée reste dans
|
||||
[00-vue-ensemble.md](00-vue-ensemble.md), le détail dans [20-backend.md](20-backend.md), la
|
||||
traçabilité OWASP dans [owasp-traceabilite.md](owasp-traceabilite.md), et les décisions dans les
|
||||
ADR 0002 à 0004.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Traçabilité OWASP
|
||||
|
||||
Ce document remplace la revendication « couverture OWASP Top 10 et OWASP API Security Top 10 »
|
||||
de la NFR4 du dossier EC01. Cette formulation est indéfendable telle quelle : vingt items, non
|
||||
vérifiables en deux semaines, et « montrez-moi votre couverture de A04 Insecure Design » n'a pas
|
||||
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.
|
||||
|
||||
## Contrôles en place
|
||||
|
||||
| Contrôle | Où | Item adressé |
|
||||
|---|---|---|
|
||||
| Interdire par défaut, liste blanche de routes publiques vérifiée par un test qui appelle réellement chaque route | `tests/api/test_route_protection.py` | API5 Broken Function Level Authorization, A01 Broken Access Control |
|
||||
| RBAC à trois rôles ordonnés, décision prise sur la ligne en base et jamais sur le claim | `app/api/deps.py` | A01, API5 |
|
||||
| Révocation immédiate : compte relu à chaque requête, `credentials_changed_at` invalide les jetons antérieurs | `app/api/deps.py`, `app/repositories/user.py` | A01, API2 Broken Authentication |
|
||||
| Argon2id m=19456 t=2 p=1, re-hachage passif quand les paramètres changent | `app/core/hashing.py` | A02 Cryptographic Failures, A07 Identification and Authentication Failures |
|
||||
| Message et temps de réponse identiques quelle que soit la cause de l'échec, haché leurre sur adresse inconnue | `app/services/auth.py` | A07, API2 |
|
||||
| Limitation de débit à fenêtre glissante sur trois clés, évaluée avant le hachage | `app/services/auth.py`, `app/repositories/login_attempt.py` | A07, API4 Unrestricted Resource Consumption |
|
||||
| Absence de verrouillage de compte, qui serait un déni de service | ADR 0002 | API4 |
|
||||
| Jeton de rafraîchissement opaque, haché en base, rotation avec détection de réutilisation | `app/services/auth.py`, `app/repositories/refresh_token.py` | A07, API2 |
|
||||
| Séparation structurelle accès / rafraîchissement, impossible à confondre | ADR 0002 | API2 |
|
||||
| Algorithme épinglé, `aud`, `iss` et `typ` vérifiés, `alg: none` rejeté | `app/core/security.py` | A02, API2 |
|
||||
| Cookie `HttpOnly`, `Secure`, `SameSite=Strict`, `Path` restreint, suppression symétrique | `app/core/cookies.py` | A05 Security Misconfiguration |
|
||||
| Vérification d'`Origin` sur les trois routes portant le cookie | `app/api/deps.py` | A01 |
|
||||
| Schémas de lecture et d'écriture séparés, aucun modèle ORM en réponse | `app/schemas/user.py` | API3 Broken Object Property Level Authorization |
|
||||
| Validation stricte Pydantic en entrée, mot de passe borné à 128 caractères | `app/schemas/auth.py` | A03 Injection, API4 |
|
||||
| Requêtes paramétrées par SQLAlchemy, aucune concaténation SQL | `app/repositories/` | A03 |
|
||||
| Réponse 422 qui ne renvoie jamais la valeur rejetée | `app/api/errors.py` | A09 Security Logging and Monitoring Failures |
|
||||
| Réponse 500 générique avec identifiant de corrélation, trace côté serveur seulement | `app/api/errors.py` | A05 |
|
||||
| Journal d'audit en ajout seul garanti par déclencheurs, liste blanche des clés de détail | ADR 0004, `app/repositories/audit_log.py` | A09 |
|
||||
| Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 |
|
||||
| Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 |
|
||||
| Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 |
|
||||
| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 |
|
||||
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
|
||||
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
|
||||
| CI bloquante : format, lint avec règles Bandit, typage strict, tests avec seuil de couverture | `.github/workflows/backend.yml` | A06 Vulnerable and Outdated Components |
|
||||
|
||||
Note sur A06 : le jeu de règles `S` de ruff, déjà actif dans `pyproject.toml`, est le portage des
|
||||
règles Bandit. Ajouter Bandit à la CI serait redondant, contrairement à ce qu'annonce l'EC01.
|
||||
|
||||
## Non couvert, et pourquoi
|
||||
|
||||
| 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. |
|
||||
| **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. |
|
||||
| **A08 Software and Data Integrity Failures** | **partiel** | La CI vérifie le code mais n'analyse ni les dépendances ni les images. `.terraform.lock.hcl` reste ignoré par git, ce qui contredit une chaîne d'approvisionnement maîtrisée. |
|
||||
| **A10 Server-Side Request Forgery** | **sans objet aujourd'hui** | Aucune URL sortante n'est pilotée par une donnée utilisateur. Le jour où l'adresse d'une source devient un champ de configuration, il faudra une liste blanche de schémas et d'hôtes, sans suivi de redirection. |
|
||||
| **Cantonnement des accès ETL et ML** | **dette assumée** | Le compte applicatif porte l'identité, le rôle PostgreSQL porterait le cantonnement. Voir ADR 0003. |
|
||||
| **Non-répudiation de l'audit** | **dette assumée** | Les déclencheurs arrêtent les accidents, pas un compte détenant `ALTER TABLE`. Voir ADR 0004. |
|
||||
|
||||
## Ce qu'il faut répondre, et ne pas répondre
|
||||
|
||||
Sur A04 Insecure Design, la réponse n'est pas une case cochée mais deux décisions concrètes : le
|
||||
refus du verrouillage de compte, qui aurait été un déni de service, et le refus de laisser un
|
||||
administrateur se verrouiller lui-même dehors.
|
||||
|
||||
Sur l'audit, ne jamais prétendre que la table est inviolable : elle ne l'est pas contre un compte
|
||||
qui a les droits sur la base, et c'est vrai de tout journal co-localisé avec ce qu'il journalise.
|
||||
|
||||
Sur l'API Mock, ne jamais répondre « c'est un mock, ce n'est pas notre périmètre ». C'est
|
||||
précisément le périmètre : c'est la frontière de confiance.
|
||||
Reference in New Issue
Block a user