Files
pta/docs/README_V7_11.md
2026-08-20 17:00:06 +02:00

9.1 KiB

PTA V7.11 — Connexion par jeton, espace DSI et journal d'activité

Le problème traité

Depuis l'origine, role.php était un sélecteur, pas une authentification : n'importe qui ouvrant l'application choisissait librement son rôle et son identité, y compris « Service Enfance », qui valide les plannings de toute la collectivité. Les READMEs successifs et CLAUDE.md la désignaient comme la faille ouverte la plus importante du projet.

Deux conséquences, au-delà du contrôle d'accès lui-même :

  • aucune action n'était imputable — un planning validé, un agent modifié, un lieu créé ne portaient le nom de personne ;
  • rien n'était traçable : pas de journal, donc aucune façon de répondre à « qui a rouvert cette semaine ? » ou « depuis quand cet accès est-il utilisé ? ».

Ce qui change

1. On entre avec un jeton, plus avec un choix

Chaque utilisateur — agent, responsable de lieu, administrateur du Service Enfance, DSI — reçoit un jeton personnel de la forme PTA-XXXXXX-XXXXXX-XXXXXX-XXXXXX, tiré au sort par le serveur sur 120 bits d'entropie, dans un alphabet sans caractères ambigus (ni I, ni O, ni 0, ni 1).

Il n'y a aucun mot de passe dans PTA : rien à choisir, rien à renouveler périodiquement, aucune procédure de réinitialisation à écrire. Un jeton perdu ne se retrouve pas, il se régénère, ce qui révoque l'ancien à la seconde.

Deux façons de le présenter, l'une et l'autre acceptées :

  • le champ de saisie de role.php (POST) — le jeton ne transite alors ni par l'URL, ni par l'historique du navigateur, ni par les journaux de nginx ;
  • le lien direct role.php?token=… — plus commode à envoyer par courriel, mais le jeton s'inscrit dans l'historique et les journaux du serveur web, et un lien transféré donne l'accès. La session ouverte, une redirection immédiate retire le jeton de la barre d'adresse.

Le rôle, l'agent et le lieu ne sont plus déclarés par l'utilisateur : ils sont lus sur son compte. Le reste de la mécanique de droits n'a pas bougé d'une ligne — Access interroge les mêmes vues SQL qu'en V7.8.

2. La base ne conserve jamais le jeton

compte_acces ne stocke qu'une empreinte SHA-256 du jeton et ses onze premiers caractères, qui servent uniquement à reconnaître une ligne dans la liste. Un vol du dump SQL ou un accès à phpMyAdmin ne donne donc aucun accès applicatif.

Conséquence assumée, visible partout dans l'interface : un jeton n'est affiché qu'une seule fois, dans le panneau « Jetons à transmettre » qui suit sa création. Il faut le copier à ce moment-là.

L'empreinte n'est volontairement pas salée, contrairement à ce qu'on ferait d'un mot de passe : un jeton est tiré par le serveur, pas devinable par dictionnaire, et l'absence de sel est ce qui permet de retrouver le compte en une lecture d'index à la connexion plutôt qu'en comparant à chaque compte.

3. Un quatrième rôle : DSI

DSI rejoint AGENT, RESPONSABLE_STRUCTURE et SERVICE_ENFANCE dans acces_role_page et acces_role_permission. C'est une administration technique, pas fonctionnelle : la DSI ne voit ni planning, ni quota, ni agent — une seule page lui est ouverte, dsi.php, qui contient :

  • les comptes d'accès : création, régénération de jeton, désactivation, suppression, filtres par type, par état et par nom ;
  • « Générer les jetons manquants » : équipe d'un coup tous les agents et tous les lieux actifs qui n'ont pas encore de compte. C'est l'outil de mise en service — sans lui, personne ne pourrait entrer le jour du déploiement ;
  • le journal d'activité : recherche filtrée et paginée, quatre chiffres de tête, purge des entrées anciennes.

Deux permissions nouvelles, can_manage_access et can_read_journal, sont accordées à la seule DSI et refusées explicitement aux trois rôles métier.

4. Ce qui est journalisé — et ce qui ne l'est pas

journal_activite est alimenté par App\Core\Journal, jamais par des déclencheurs SQL : la base ne sait pas qui agit, la connexion MariaDB étant la même pour tout le monde.

Catégorie Contenu
CONNEXION Connexion réussie, jeton refusé, blocage anti-force-brute, déconnexion
ECRITURE Toute modification en base, avec la table et l'identifiant visés
ACCES_REFUSE Page interdite, rôle insuffisant, périmètre dépassé, jeton CSRF invalide
ADMINISTRATION Actions de la DSI sur les comptes et le journal, purge comprise
SYSTEME Réservé aux incidents techniques

Les lectures ne sont pas tracées : elles représenteraient l'essentiel du volume pour une valeur d'audit très faible.

Deux garanties tenues par le code :

  • écrire dans le journal ne peut jamais faire échouer l'action journalisée — toute erreur d'insertion est avalée et renvoyée dans error_log ;
  • la trace est figée : rôle et libellé de l'acteur sont copiés au moment de l'action, et la clé étrangère vers compte_acces est en ON DELETE SET NULL. Supprimer un compte n'efface pas son histoire, et renommer un agent ne la réécrit pas.

5. Freins et garde-fous

  • Anti-force-brute : au-delà de 10 jetons refusés en 15 minutes depuis une même adresse IP, plus aucun jeton n'est même comparé, et le blocage est journalisé. Le compteur, c'est le journal lui-même — aucune table supplémentaire.
  • Révocation immédiate : Access relit le compte à chaque requête. Désactiver un compte, désactiver l'agent qui le porte ou fermer son lieu referme la session en cours à la requête suivante, sans attendre son expiration.
  • On ne se coupe pas la branche : la DSI ne peut ni désactiver ni supprimer le compte avec lequel elle est connectée, et le modèle refuse de retirer le dernier accès DSI actif.
  • Purge plancher à 30 jours : une purge plus courte reviendrait à effacer les traces d'un incident avant même qu'il soit constaté.

Mise en service

  1. Jouer les migrations (docker compose up -d --build s'en charge) ; migration_v7_9e_… crée les tables, les vues, le rôle DSI et un compte DSI d'installation.

  2. Se connecter avec le jeton d'installation, écrit en clair dans la migration :

    PTA-INSTALLATION-DSI
    

    Il est public — il est versionné dans le dépôt. La page DSI affiche un bandeau rouge tant qu'il n'a pas été remplacé.

  3. Régénérer immédiatement ce jeton (bouton « Régénérer » sur la ligne du compte DSI), le copier et le conserver. Créer au passage un second compte DSI si plusieurs personnes doivent administrer.

  4. Cliquer sur « Générer les jetons manquants » : un jeton par agent et par lieu actif. Le panneau les affiche tous, une seule fois — les copier avant de le refermer, puis les distribuer.

  5. Créer les comptes Service Enfance un par un (ils ne correspondent à aucune identité en base, leur libellé est saisi à la main).

Perte du jeton DSI

C'est le seul cas qui ne se résout pas dans l'interface. Procédure de secours, en SQL :

DELETE FROM compte_acces WHERE id_compte = 1;

puis rejouer la migration (docker compose run --rm db-migrate après avoir touché le fichier, ou directement l'INSERT de la section 3 de la migration) : le compte d'installation est recréé avec son jeton public, à régénérer aussitôt.

Fichiers

Fichier Rôle
database/migration_v7_9e_dsi_comptes_journal.sql Rôle DSI, compte_acces, journal_activite, trois vues, compte d'installation
app/Core/Journal.php Écriture des traces ; connexion posée par app/bootstrap.php
app/Core/Access.php ROLE_DSI, openSession() (remplace select()), relecture du compte, journalisation des refus
app/Models/CompteModel.php Jetons : génération, empreinte, authentification, cycle de vie des comptes
app/Models/JournalModel.php Lecture paginée, statistiques, compteur d'échecs, purge
app/Controllers/RoleController.php Connexion par jeton (champ et lien), déconnexion, frein anti-force-brute
app/Controllers/DsiController.php Les sept endpoints de l'espace DSI
dsi.php, api/dsi_*.php Points d'entrée minces
app/Views/pages/role.php Écran de connexion (le sélecteur de rôle a disparu)
app/Views/partials/dsi.php, assets/js/dsi.js Interface de l'espace DSI

Ce que la V7.11 ne fait pas

  • Pas d'annuaire ni de SSO : le jeton reste un secret partagé hors de l'application (courriel, papier). Un raccordement LDAP ou OpenID Connect resterait à faire, et ne toucherait que RoleController et CompteModel.
  • Pas d'expiration automatique des jetons : un jeton vit tant que la DSI ne le régénère pas. Une date de péremption serait une colonne de plus sur compte_acces et une condition de plus dans v_acces_compte.
  • Pas d'export du journal : il se consulte à l'écran, filtre par filtre. Un export CSV demanderait un endpoint supplémentaire.
  • Pas de purge automatique : la purge est déclenchée à la main par la DSI, et journalisée.