Files
ENI-projet-piscine/docs/adr/0004-journal-d-audit-en-ajout-seul.md
T
Johan LEROY 3b7383697e 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.
2026-09-15 15:05:28 +02:00

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.