# 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 : ```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.