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.
105 lines
5.3 KiB
Markdown
105 lines
5.3 KiB
Markdown
# 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.
|