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

5.3 KiB

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.