This commit is contained in:
PTA
2026-08-20 17:00:06 +02:00
parent 5d8c0993a6
commit 94e7f09eb4
58 changed files with 5605 additions and 615 deletions

View File

@@ -0,0 +1,275 @@
-- ============================================================================
-- PTA V7.11 — Accès par jeton, comptes gérés par la DSI et journal d'activité
-- ============================================================================
-- Trois apports, indissociables :
-- 1. un compte d'accès par utilisateur (compte_acces), identifié par un JETON
-- unique dont la base ne conserve qu'une empreinte SHA-256 ;
-- 2. un quatrième rôle DSI, seul habilité à créer, régénérer et désactiver
-- ces comptes, et à lire le journal ;
-- 3. un journal d'activité (journal_activite) alimenté par le code PHP.
--
-- POURQUOI « v7_9e » ALORS QUE C'EST LA V7.11 : les migrations sont jouées dans
-- l'ORDRE ALPHABÉTIQUE (docker/db/apply-migrations.sh). Un fichier nommé
-- « migration_v7_11_… » passerait AVANT « migration_v7_8_… », donc avant la
-- création des tables de droits qu'il doit étendre. Le préfixe suit donc la
-- dernière migration en date (v7_9d) plutôt que le numéro de version
-- fonctionnelle. Voir CLAUDE.md, section « Commandes ».
--
-- Prérequis : migration_v7_8_securite_acces.sql (tables acces_role_*).
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 1. LE RÔLE DSI ENTRE DANS LA MATRICE DES DROITS
-- ----------------------------------------------------------------------------
-- Un MODIFY d'ENUM est idempotent : rejouer la migration ne change rien.
ALTER TABLE acces_role_page
MODIFY COLUMN role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL;
ALTER TABLE acces_role_permission
MODIFY COLUMN role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL;
-- Les vues sont recréées après l'ALTER : leurs métadonnées de colonnes sont
-- figées à la définition, un ENUM élargi ne s'y propage pas tout seul.
CREATE OR REPLACE VIEW v_acces_role_page AS
SELECT role, page, ordre_affichage
FROM acces_role_page;
CREATE OR REPLACE VIEW v_acces_role_permission AS
SELECT role, permission, autorise
FROM acces_role_permission;
-- La DSI n'a qu'une page : la sienne. Elle n'accède NI à la saisie, NI aux
-- référentiels métier — c'est une administration technique, pas fonctionnelle.
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
('DSI', 'dsi', 10)
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
-- Deux permissions nouvelles, refusées explicitement aux trois rôles métier :
-- une ligne à FALSE documente le refus, là où une ligne absente le laisse
-- deviner (une permission inconnue vaut de toute façon « refusé »).
INSERT INTO acces_role_permission (role, permission, autorise) VALUES
('DSI', 'can_manage_access', TRUE),
('DSI', 'can_read_journal', TRUE),
('DSI', 'read_only', FALSE),
('DSI', 'can_edit_draft', FALSE),
('DSI', 'can_edit_cross_structure', FALSE),
('DSI', 'can_validate', FALSE),
('DSI', 'can_reopen', FALSE),
('DSI', 'can_use_templates', FALSE),
('DSI', 'can_copy_full_week', FALSE),
('DSI', 'can_view_pending', FALSE),
('DSI', 'can_manage_coverage', FALSE),
('DSI', 'can_manage_agents', FALSE),
('DSI', 'can_manage_structures', FALSE),
('DSI', 'can_manage_vacations', FALSE),
('DSI', 'can_manage_teams', FALSE),
('DSI', 'can_manage_pta', FALSE),
('DSI', 'can_forecast_next_year', FALSE),
('AGENT', 'can_manage_access', FALSE),
('AGENT', 'can_read_journal', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_access', FALSE),
('RESPONSABLE_STRUCTURE', 'can_read_journal', FALSE),
('SERVICE_ENFANCE', 'can_manage_access', FALSE),
('SERVICE_ENFANCE', 'can_read_journal', FALSE)
ON DUPLICATE KEY UPDATE autorise = VALUES(autorise);
-- ----------------------------------------------------------------------------
-- 2. COMPTES D'ACCÈS — un jeton par utilisateur, jamais de mot de passe
-- ----------------------------------------------------------------------------
-- Le jeton complet n'est JAMAIS stocké : la base ne garde qu'une empreinte
-- SHA-256 et les huit premiers caractères, qui servent uniquement à reconnaître
-- une ligne dans la liste. Un jeton perdu ne se retrouve pas, il se régénère.
-- Une fuite du dump SQL ne donne donc aucun accès.
--
-- L'empreinte n'est pas salée, et c'est délibéré : le jeton est tiré au sort sur
-- 120 bits par le serveur, il n'est pas devinable par force brute comme le
-- serait un mot de passe choisi par un humain. L'absence de sel est ce qui
-- permet de retrouver le compte en une lecture d'index à la connexion.
--
-- id_agent / id_structure sont UNIQUE tout en étant NULLables : MariaDB tolère
-- autant de NULL que voulu dans un index unique. Un agent ne peut donc avoir
-- qu'un seul compte, un lieu qu'un seul compte responsable, tandis que les
-- comptes SERVICE_ENFANCE et DSI (les deux colonnes à NULL) sont libres en
-- nombre.
CREATE TABLE IF NOT EXISTS compte_acces (
id_compte INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE', 'DSI') NOT NULL,
libelle VARCHAR(180) NOT NULL COMMENT 'Titulaire du jeton, tel qu''affiché dans la liste',
id_agent INT UNSIGNED NULL COMMENT 'Obligatoire pour un compte AGENT',
id_structure INT UNSIGNED NULL COMMENT 'Obligatoire pour un compte RESPONSABLE_STRUCTURE',
jeton_empreinte CHAR(64) NOT NULL COMMENT 'SHA-256 hexadécimal du jeton',
jeton_prefixe VARCHAR(12) NOT NULL COMMENT 'Début du jeton, pour le reconnaître sans le révéler',
actif BOOLEAN NOT NULL DEFAULT TRUE,
commentaire VARCHAR(255) NULL,
date_creation DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
date_rotation DATETIME NULL COMMENT 'Dernière régénération du jeton',
date_derniere_connexion DATETIME NULL,
nombre_connexions INT UNSIGNED NOT NULL DEFAULT 0,
CONSTRAINT uk_compte_empreinte UNIQUE (jeton_empreinte),
CONSTRAINT uk_compte_agent UNIQUE (id_agent),
CONSTRAINT uk_compte_structure UNIQUE (id_structure),
INDEX idx_compte_role_actif (role, actif),
CONSTRAINT fk_compte_agent FOREIGN KEY (id_agent)
REFERENCES agent (id_agent) ON DELETE CASCADE,
CONSTRAINT fk_compte_structure FOREIGN KEY (id_structure)
REFERENCES structure (id_structure) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ----------------------------------------------------------------------------
-- 3. COMPTE DSI D'INSTALLATION
-- ----------------------------------------------------------------------------
-- Problème de l'œuf et de la poule : les jetons ne sont distribuables que par la
-- DSI, qui a elle-même besoin d'un jeton pour entrer. Cette ligne est la porte
-- d'entrée initiale.
--
-- JETON D'INSTALLATION : PTA-INSTALLATION-DSI
--
-- Il est public — il est écrit ici, dans un fichier versionné. La page DSI
-- affiche un avertissement rouge tant que ce compte porte encore cette
-- empreinte : LE RÉGÉNÉRER EST LA PREMIÈRE ACTION À FAIRE APRÈS L'INSTALLATION.
--
-- L'identifiant 1 est forcé pour que le rejeu de la migration ne recrée pas un
-- second compte d'installation après régénération du jeton (l'empreinte, elle,
-- aura changé). Supprimer la ligne 1 puis rejouer la migration est d'ailleurs la
-- procédure de secours documentée en cas de perte du jeton DSI.
INSERT INTO compte_acces
(id_compte, role, libelle, jeton_empreinte, jeton_prefixe, commentaire)
VALUES
(1, 'DSI', 'Compte DSI d''installation',
'7f61ffc8efed4ed46d581a98286115ad03bb0122ffc70db3398584348aca431d',
'PTA-INSTALL',
'Jeton d''installation public : à régénérer immédiatement.')
ON DUPLICATE KEY UPDATE id_compte = id_compte;
-- ----------------------------------------------------------------------------
-- 4. JOURNAL D'ACTIVITÉ
-- ----------------------------------------------------------------------------
-- Alimenté exclusivement par App\Core\Journal, jamais par des déclencheurs SQL :
-- le journal doit nommer l'UTILISATEUR à l'origine de l'action, information que
-- la base n'a pas (la connexion MariaDB est la même pour tout le monde).
--
-- Sont tracés : les connexions et déconnexions, toutes les écritures en base et
-- tous les refus d'accès. Les lectures ne le sont pas — c'est un choix de
-- volume : elles représenteraient l'essentiel des lignes pour une valeur
-- d'audit très faible.
--
-- L'acteur est FIGÉ au moment de l'action (rôle, libellé) : renommer un agent ou
-- supprimer son compte ne doit pas réécrire l'histoire. C'est aussi pourquoi la
-- clé étrangère est en ON DELETE SET NULL et non en CASCADE.
CREATE TABLE IF NOT EXISTS journal_activite (
id_journal BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
date_evenement DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
categorie ENUM('CONNEXION', 'ECRITURE', 'ACCES_REFUSE', 'ADMINISTRATION', 'SYSTEME') NOT NULL,
action VARCHAR(60) NOT NULL COMMENT 'Code stable de l''action, ex. PLANNING_VALIDE',
resultat ENUM('SUCCES', 'REFUS', 'ERREUR') NOT NULL DEFAULT 'SUCCES',
id_compte INT UNSIGNED NULL,
role VARCHAR(30) NULL COMMENT 'Rôle au moment de l''action, figé',
acteur VARCHAR(180) NULL COMMENT 'Libellé de l''utilisateur au moment de l''action, figé',
cible_table VARCHAR(64) NULL COMMENT 'Table principale touchée par l''écriture',
cible_id INT UNSIGNED NULL,
description VARCHAR(500) NULL,
contexte JSON NULL COMMENT 'Détail structuré, ex. semaine et lieu concernés',
adresse_ip VARCHAR(45) NULL,
agent_utilisateur VARCHAR(255) NULL,
INDEX idx_journal_date (date_evenement),
INDEX idx_journal_categorie (categorie, date_evenement),
INDEX idx_journal_compte (id_compte, date_evenement),
INDEX idx_journal_cible (cible_table, cible_id),
INDEX idx_journal_ip (adresse_ip, date_evenement),
CONSTRAINT fk_journal_compte FOREIGN KEY (id_compte)
REFERENCES compte_acces (id_compte) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ----------------------------------------------------------------------------
-- 5. VUES
-- ----------------------------------------------------------------------------
-- Comme pour la V7.8, le PHP interroge des vues et non les tables : l'identité
-- réelle derrière un compte (nom de l'agent, nom du lieu) et son activité sont
-- résolues en SQL, une seule fois, au même endroit.
CREATE OR REPLACE VIEW v_acces_compte AS
SELECT
c.id_compte,
c.role,
c.libelle,
c.id_agent,
c.id_structure,
c.jeton_empreinte,
c.jeton_prefixe,
c.actif,
c.commentaire,
c.date_creation,
c.date_rotation,
c.date_derniere_connexion,
c.nombre_connexions,
a.matricule,
a.nom AS agent_nom,
a.prenom AS agent_prenom,
s.code AS structure_code,
s.nom AS structure_nom,
-- Libellé affiché : l'identité VIVE pour un agent ou un lieu (un agent
-- renommé doit apparaître sous son nouveau nom), le libellé saisi pour les
-- comptes Service Enfance et DSI, qui ne pointent vers rien.
CASE c.role
WHEN 'AGENT' THEN CONCAT(a.prenom, ' ', a.nom)
WHEN 'RESPONSABLE_STRUCTURE' THEN s.nom
ELSE c.libelle
END AS identite_libelle,
-- Un compte reste utilisable tant que l'identité qu'il porte est active :
-- désactiver un agent coupe son accès sans avoir à toucher à son compte.
CASE c.role
WHEN 'AGENT' THEN COALESCE(a.actif, 0)
WHEN 'RESPONSABLE_STRUCTURE' THEN COALESCE(s.actif, 0)
ELSE 1
END AS identite_active
FROM compte_acces c
LEFT JOIN agent a ON a.id_agent = c.id_agent
LEFT JOIN structure s ON s.id_structure = c.id_structure;
-- Identités actives encore dépourvues de compte : alimente le bouton
-- « générer les jetons manquants » de la page DSI, qui permet d'équiper d'un
-- coup tous les agents et tous les lieux lors de la mise en service.
CREATE OR REPLACE VIEW v_acces_compte_manquant AS
SELECT
'AGENT' AS role,
a.id_agent AS id_agent,
CAST(NULL AS UNSIGNED) AS id_structure,
CONCAT(a.prenom, ' ', a.nom) AS libelle
FROM agent a
LEFT JOIN compte_acces c ON c.id_agent = a.id_agent
WHERE a.actif = 1 AND c.id_compte IS NULL
UNION ALL
SELECT
'RESPONSABLE_STRUCTURE',
CAST(NULL AS UNSIGNED),
s.id_structure,
s.nom
FROM structure s
LEFT JOIN compte_acces c ON c.id_structure = s.id_structure
WHERE s.actif = 1 AND c.id_compte IS NULL;
-- Journal enrichi du compte encore existant, quand il existe. Le libellé figé
-- (`acteur`) reste la source d'affichage : la jointure ne sert qu'à retrouver le
-- préfixe du jeton et à filtrer par compte.
CREATE OR REPLACE VIEW v_journal_activite AS
SELECT
j.id_journal,
j.date_evenement,
j.categorie,
j.action,
j.resultat,
j.id_compte,
j.role,
j.acteur,
j.cible_table,
j.cible_id,
j.description,
j.contexte,
j.adresse_ip,
j.agent_utilisateur,
c.jeton_prefixe,
c.actif AS compte_actif
FROM journal_activite j
LEFT JOIN compte_acces c ON c.id_compte = j.id_compte;

View File

@@ -0,0 +1,91 @@
-- ============================================================================
-- PTA V7.12 — Mot de passe des comptes privilégiés (Service Enfance et DSI)
-- ============================================================================
-- Le jeton reste le seul facteur pour les agents et les responsables de lieu.
-- Les comptes qui VALIDENT (Service Enfance) et ceux qui DISTRIBUENT LES ACCÈS
-- (DSI) en réclament un second : un mot de passe, connu du seul titulaire.
--
-- Deux facteurs de nature différente : le jeton est remis par la DSI et peut
-- circuler par courriel ; le mot de passe est choisi par le titulaire à sa
-- première connexion et n'est jamais affiché nulle part. Un jeton intercepté ne
-- suffit donc plus à valider un planning ni à créer un compte.
--
-- CONTRAIREMENT AU JETON, le mot de passe est haché avec password_hash()
-- (bcrypt) et non SHA-256 : il est choisi par un humain, donc attaquable par
-- dictionnaire, et c'est précisément ce que le hachage lent rend coûteux. La
-- vérification se fait sur un compte déjà identifié par son jeton, l'absence
-- d'index sur le mot de passe n'a donc aucune importance.
--
-- POURQUOI « v7_9f » : les migrations sont jouées dans l'ORDRE ALPHABÉTIQUE
-- (docker/db/apply-migrations.sh). Le préfixe suit la dernière migration en date
-- (v7_9e), pas le numéro de version fonctionnelle. Voir CLAUDE.md.
--
-- Prérequis : migration_v7_9e_dsi_comptes_journal.sql.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 1. DEUX COLONNES SUR LE COMPTE
-- ----------------------------------------------------------------------------
-- `IF NOT EXISTS` est une extension MariaDB : elle rend l'ALTER idempotent, ce
-- qu'exige le rejeu des migrations.
--
-- Le hachage est NULLABLE, et cet état porte du sens : « mot de passe pas encore
-- défini ». C'est l'état d'un compte fraîchement créé, et c'est aussi ce que
-- produit une réinitialisation par la DSI. Un compte privilégié dans cet état ne
-- peut pas ouvrir de session : la connexion impose d'abord d'en choisir un.
ALTER TABLE compte_acces
ADD COLUMN IF NOT EXISTS mot_de_passe_hash VARCHAR(255) NULL
COMMENT 'password_hash() du mot de passe ; NULL = à définir à la prochaine connexion',
ADD COLUMN IF NOT EXISTS date_mot_de_passe DATETIME NULL
COMMENT 'Dernière définition ou modification du mot de passe par le titulaire';
-- ----------------------------------------------------------------------------
-- 2. LA VUE PORTE LA RÈGLE « QUI DOIT AVOIR UN MOT DE PASSE »
-- ----------------------------------------------------------------------------
-- Comme pour les droits depuis la V7.8, la règle descend en base : le PHP
-- demande à la vue si ce compte réclame un second facteur, il ne rejoue pas la
-- liste des rôles concernés de son côté.
CREATE OR REPLACE VIEW v_acces_compte AS
SELECT
c.id_compte,
c.role,
c.libelle,
c.id_agent,
c.id_structure,
c.jeton_empreinte,
c.jeton_prefixe,
c.actif,
c.commentaire,
c.date_creation,
c.date_rotation,
c.date_derniere_connexion,
c.nombre_connexions,
c.mot_de_passe_hash,
c.date_mot_de_passe,
-- Les deux rôles qui engagent l'application : celui qui valide les plannings
-- de toute la collectivité, et celui qui distribue les accès.
CASE WHEN c.role IN ('SERVICE_ENFANCE', 'DSI') THEN 1 ELSE 0 END AS mot_de_passe_requis,
CASE WHEN c.mot_de_passe_hash IS NULL THEN 0 ELSE 1 END AS mot_de_passe_defini,
a.matricule,
a.nom AS agent_nom,
a.prenom AS agent_prenom,
s.code AS structure_code,
s.nom AS structure_nom,
-- Libellé affiché : l'identité VIVE pour un agent ou un lieu (un agent
-- renommé doit apparaître sous son nouveau nom), le libellé saisi pour les
-- comptes Service Enfance et DSI, qui ne pointent vers rien.
CASE c.role
WHEN 'AGENT' THEN CONCAT(a.prenom, ' ', a.nom)
WHEN 'RESPONSABLE_STRUCTURE' THEN s.nom
ELSE c.libelle
END AS identite_libelle,
-- Un compte reste utilisable tant que l'identité qu'il porte est active :
-- désactiver un agent coupe son accès sans avoir à toucher à son compte.
CASE c.role
WHEN 'AGENT' THEN COALESCE(a.actif, 0)
WHEN 'RESPONSABLE_STRUCTURE' THEN COALESCE(s.actif, 0)
ELSE 1
END AS identite_active
FROM compte_acces c
LEFT JOIN agent a ON a.id_agent = c.id_agent
LEFT JOIN structure s ON s.id_structure = c.id_structure;

View File

@@ -0,0 +1,38 @@
-- ============================================================================
-- PTA V7.14 — Groupe « Management » : onglets Agents et Lieux d'affectation
-- ============================================================================
-- L'écran « Administration » réunissait trois objets sans rapport : le
-- référentiel des agents, celui des lieux, et les horaires de couverture
-- attendus. Les deux premiers deviennent deux ONGLETS distincts, réunis dans un
-- groupe « Management » de la barre de navigation, au même titre que
-- « Piloter » ou « Planifier ». « Administration » ne conserve que les horaires
-- de couverture.
--
-- Le GROUPE lui-même est un simple regroupement d'affichage
-- (app/Views/layouts/header.php) : la base ne connaît que des pages. Ce sont
-- donc deux lignes qu'il faut ici, une par onglet.
--
-- Comme toute page de PTA, celles-ci n'existent que si le droit est en base :
-- sans ces lignes, les pages renvoient vers l'accueil du rôle et les onglets
-- n'apparaissent pas (voir CLAUDE.md, « Rôles et périmètres »).
--
-- La ligne « management » d'une version intermédiaire de cette migration est
-- supprimée : cette page unique n'existe plus, et une page inconnue laissée dans
-- acces_role_page finirait dans le groupe de repêchage « Autres » de la barre de
-- navigation.
--
-- POURQUOI « v7_9g » : les migrations sont jouées dans l'ORDRE ALPHABÉTIQUE
-- (docker/db/apply-migrations.sh). Le préfixe suit la dernière migration en date
-- (v7_9f), pas le numéro de version fonctionnelle.
--
-- Prérequis : migration_v7_8_securite_acces.sql.
-- ============================================================================
DELETE FROM acces_role_page WHERE page = 'management';
-- 95 et 96 : les deux onglets se rangent juste avant « Administration » (100),
-- dont ils reprennent l'essentiel du contenu.
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
('SERVICE_ENFANCE', 'manage_agents', 95),
('SERVICE_ENFANCE', 'manage_structures', 96)
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);