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

166 lines
9.1 KiB
Markdown

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