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

@@ -22,9 +22,17 @@ final class Access
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
/** Périmètre complet ; seul rôle habilité à valider un planning. */
public const ROLE_SERVICE = 'SERVICE_ENFANCE';
/**
* Administration technique (V7.11) : comptes d'accès, jetons et journal.
* Aucun droit métier — la DSI ne voit ni les plannings, ni les agents.
*/
public const ROLE_DSI = 'DSI';
private const SESSION_KEY = 'pta_access';
/** @var array<int, string> Les quatre rôles connus de l'application. */
private const ROLES = [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE, self::ROLE_DSI];
/**
* Caches par requête : les vues d'accès sont interrogées une seule fois par
* rôle et par requête HTTP. Rien n'est mis en cache entre deux requêtes, pour
@@ -37,6 +45,19 @@ final class Access
/** @var array<string, array<int, string>> */
private array $pageCache = [];
/**
* Compte d'accès de la session, relu UNE FOIS par requête.
*
* Il est relu à chaque requête et non conservé en session : c'est ce qui
* fait qu'un jeton désactivé par la DSI coupe l'accès à la requête suivante,
* sans attendre l'expiration de la session.
*
* @var array<string, mixed>|null
*/
private ?array $accountCache = null;
private bool $accountLoaded = false;
public function __construct(private PDO $pdo)
{
Session::start();
@@ -50,22 +71,32 @@ final class Access
public function isConfigured(): bool
{
$role = $this->role();
if ($role === null) {
return false;
}
// Depuis la V7.11, toute session est adossée à un compte d'accès actif :
// pas de compte vivant, pas de session. Un jeton révoqué, un agent
// désactivé ou un lieu fermé referment donc l'accès immédiatement.
$compte = $this->accountRecord();
if ($compte === null) {
return false;
}
if ($role === self::ROLE_AGENT) {
return $this->agentId() !== null && $this->agentRecord() !== null;
}
if ($role === self::ROLE_RESPONSABLE) {
return $this->structureId() !== null && $this->structureRecord() !== null;
}
return $role === self::ROLE_SERVICE;
return true;
}
/** Rôle en session, ou null si aucun rôle valide n'est sélectionné. */
public function role(): ?string
{
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)
? $role
: null;
return in_array($role, self::ROLES, true) ? $role : null;
}
/** Agent sélectionné (rôle AGENT uniquement), ou null. */
@@ -100,50 +131,87 @@ final class Access
}
/**
* Enregistre la sélection de rôle en session.
* Ouvre la session de travail à partir d'un compte d'accès authentifié.
*
* ATTENTION : ceci est un sélecteur, PAS une authentification — l'utilisateur
* choisit librement son identité. C'est la faille ouverte la plus importante
* du projet ; un passage en production doit remplacer cet appel par une
* authentification réelle, sans toucher au reste de la mécanique de droits.
* DEPUIS LA V7.11, C'EST LE SEUL CHEMIN D'ENTRÉE : l'ancien sélecteur libre
* (select()) a disparu, une session ne peut plus naître que de la
* présentation d'un jeton valide. Le rôle, l'agent et le lieu ne sont plus
* choisis par l'utilisateur mais LUS SUR LE COMPTE — c'est ce qui rend la
* mécanique de droits opposable.
*
* @throws \DomainException Si le rôle est inconnu ou l'identité inactive.
* La vérification du jeton elle-même appartient à CompteModel : cette
* méthode reçoit un compte déjà authentifié.
*
* @param array<string, mixed> $compte Ligne de v_acces_compte.
*/
public function select(string $role, ?int $agentId = null, ?int $structureId = null): void
public function openSession(array $compte): void
{
$role = strtoupper(trim($role));
if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) {
throw new \DomainException('Rôle invalide.');
}
$selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null];
if ($role === self::ROLE_AGENT) {
if (!$agentId || !$this->activeAgentExists($agentId)) {
throw new \DomainException('Sélectionnez un agent actif.');
}
$selection['agent_id'] = $agentId;
} elseif ($role === self::ROLE_RESPONSABLE) {
if (!$structureId || !$this->activeStructureExists($structureId)) {
throw new \DomainException('Sélectionnez un lieu d’affectation actif.');
}
$selection['structure_id'] = $structureId;
}
// Regénération de l'identifiant de session à chaque changement de rôle :
// parade classique à la fixation de session.
// Regénération de l'identifiant de session à chaque ouverture : parade
// classique à la fixation de session.
session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = $selection;
// Le jeton CSRF suit le cycle de vie de la session : un changement de
// rôle invalide le jeton précédent.
$_SESSION[self::SESSION_KEY] = [
'role' => (string) $compte['role'],
'agent_id' => isset($compte['id_agent']) && $compte['id_agent'] !== null
? (int) $compte['id_agent']
: null,
'structure_id' => isset($compte['id_structure']) && $compte['id_structure'] !== null
? (int) $compte['id_structure']
: null,
'compte_id' => (int) $compte['id_compte'],
// Libellé figé pour le journal : il évite une jointure à chaque trace.
'acteur' => (string) ($compte['identite_libelle'] ?? $compte['libelle'] ?? ''),
];
// Le jeton CSRF suit le cycle de vie de la session : une nouvelle
// connexion invalide le jeton précédent.
unset($_SESSION['csrf_token']);
$this->accountCache = null;
$this->accountLoaded = false;
}
/** Abandonne la sélection courante (« changer de rôle »). */
/** Ferme la session de travail (déconnexion). */
public function clear(): void
{
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
session_regenerate_id(true);
$this->accountCache = null;
$this->accountLoaded = false;
}
/** Identifiant du compte d'accès de la session, ou null. */
public function accountId(): ?int
{
$value = filter_var($_SESSION[self::SESSION_KEY]['compte_id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
return $value === false ? null : (int) $value;
}
/**
* Instantané de session destiné au JOURNAL, lisible sans instancier Access
* ni ouvrir de connexion.
*
* Il expose la clé de session privée sous une forme stable : Journal (et lui
* seul) a besoin de connaître l'acteur courant depuis un contexte statique,
* sans pouvoir dépendre d'une instance.
*
* @return array{id_compte: int|null, role: string|null, acteur: string|null}
*/
public static function sessionSnapshot(): array
{
$selection = $_SESSION[self::SESSION_KEY] ?? [];
$compteId = filter_var($selection['compte_id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
$role = strtoupper(trim((string) ($selection['role'] ?? '')));
$acteur = trim((string) ($selection['acteur'] ?? ''));
return [
'id_compte' => $compteId === false ? null : (int) $compteId,
'role' => in_array($role, self::ROLES, true) ? $role : null,
'acteur' => $acteur === '' ? null : $acteur,
];
}
/**
@@ -164,6 +232,7 @@ final class Access
'label' => $this->roleLabel(),
'agent_id' => $this->agentId(),
'structure_id' => $this->structureId(),
'account_id' => $this->accountId(),
'permissions' => $permissions,
];
@@ -182,6 +251,14 @@ final class Access
$profile['structure_name'] = (string) $structure['nom'];
$profile['structure_code'] = (string) $structure['code'];
}
} else {
// Service Enfance et DSI ne portent aucune identité métier : le
// libellé affiché est celui du compte (« Direction des systèmes
// d'information », « Service Enfance — Camille »…).
$compte = $this->accountRecord();
if ($compte !== null) {
$profile['label'] = (string) $compte['identite_libelle'];
}
}
return $profile;
@@ -269,6 +346,9 @@ final class Access
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
// La DSI n'a qu'une page ; le repli sur role.php évite la boucle de
// redirection si la migration V7.11 n'est pas jouée.
self::ROLE_DSI => $this->canAccessPage('dsi') ? 'dsi.php' : 'role.php',
default => 'role.php',
};
}
@@ -286,6 +366,11 @@ final class Access
exit;
}
if (!$this->canAccessPage($page)) {
Journal::refus(
'PAGE_REFUSEE',
sprintf('Accès refusé à la page « %s ».', $page),
['contexte' => ['page' => $page]]
);
header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302);
exit;
}
@@ -300,19 +385,58 @@ final class Access
public function requireRoles(array $roles): void
{
if (!$this->isConfigured()) {
JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']);
$this->refuser(401, 'SESSION_ABSENTE', 'Appel d’API sans session valide.', [
'endpoint' => $this->endpointCourant(),
]);
JsonResponse::send(401, ['error' => 'Connectez-vous avec votre jeton d’accès avant de continuer.', 'code' => 'ROLE_REQUIRED']);
}
if (!in_array($this->role(), $roles, true)) {
$this->refuser(403, 'ROLE_INSUFFISANT', 'Rôle insuffisant pour cette action.', [
'endpoint' => $this->endpointCourant(),
'roles_attendus' => array_values($roles),
]);
JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']);
}
}
/**
* Journalise un refus d'API. Méthode privée volontairement minuscule : elle
* n'existe que pour que les gardes ci-dessous tiennent en deux lignes.
*
* @param array<string, mixed> $contexte
*/
private function refuser(int $status, string $action, string $description, array $contexte = []): void
{
Journal::refus($action, $description, [
'contexte' => array_merge(['statut_http' => $status], $contexte),
]);
}
/** Chemin de l'endpoint appelé, pour situer un refus dans le journal. */
private function endpointCourant(): string
{
$chemin = (string) ($_SERVER['SCRIPT_NAME'] ?? '');
return $chemin !== '' ? basename($chemin) : 'inconnu';
}
/** Réservé au Service Enfance : validation, référentiels, administration. */
public function requireService(): void
{
$this->requireRoles([self::ROLE_SERVICE]);
}
/**
* Réservé à la DSI : gestion des comptes d'accès et lecture du journal.
*
* Cette garde double le contrôle de page : la page « dsi » n'est autorisée
* qu'au rôle DSI dans acces_role_page, mais un endpoint doit rester sûr même
* si quelqu'un ouvrait ce droit à un autre rôle par une requête SQL.
*/
public function requireDsi(): void
{
$this->requireRoles([self::ROLE_DSI]);
}
/** Rôles autorisés à modifier un brouillon (l'agent est en lecture seule). */
public function requireDraftEditor(): void
{
@@ -324,6 +448,7 @@ final class Access
{
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) {
$this->refuser(403, 'PERIMETRE_LIEU', 'Consultation d’un lieu hors périmètre.', ['id_structure' => $structureId]);
JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']);
}
}
@@ -333,6 +458,7 @@ final class Access
{
$this->requireDraftEditor();
if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) {
$this->refuser(403, 'PERIMETRE_LIEU', 'Modification d’un lieu hors périmètre.', ['id_structure' => $structureId]);
JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']);
}
}
@@ -354,6 +480,7 @@ final class Access
if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) {
return;
}
$this->refuser(403, 'PERIMETRE_AGENT', 'Consultation d’un agent hors périmètre.', ['id_agent' => $agentId]);
JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']);
}
@@ -412,23 +539,51 @@ final class Access
self::ROLE_AGENT => 'Agent',
self::ROLE_RESPONSABLE => 'Responsable de structure',
self::ROLE_SERVICE => 'Service Enfance',
self::ROLE_DSI => 'DSI',
default => 'Rôle non sélectionné',
};
}
/** Les vues v_acces_identite_* ne listent que les identités ACTIVES. */
private function activeAgentExists(int $agentId): bool
/**
* Compte d'accès de la session, relu une fois par requête.
*
* La vue ne renvoie le compte que s'il est actif ET que l'identité qu'il
* porte l'est aussi (`identite_active`) : désactiver un agent suffit donc à
* lui couper l'accès, sans avoir à toucher à son jeton.
*
* @return array<string, mixed>|null
*/
private function accountRecord(): ?array
{
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
$stmt->execute(['id' => $agentId]);
return $stmt->fetchColumn() !== false;
}
if ($this->accountLoaded) {
return $this->accountCache;
}
private function activeStructureExists(int $structureId): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
$stmt->execute(['id' => $structureId]);
return $stmt->fetchColumn() !== false;
$this->accountLoaded = true;
$this->accountCache = null;
$compteId = $this->accountId();
if ($compteId === null) {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id_compte, role, libelle, id_agent, id_structure, identite_libelle
FROM v_acces_compte
WHERE id_compte = :id AND actif = 1 AND identite_active = 1
LIMIT 1'
);
$stmt->execute(['id' => $compteId]);
$compte = $stmt->fetch();
// Le rôle de la session doit toujours correspondre à celui du compte :
// une session forgée ou survivant à un changement de rôle du compte est
// refusée plutôt que réparée.
if ($compte !== false && (string) $compte['role'] === $this->role()) {
$this->accountCache = $compte;
}
return $this->accountCache;
}
/**

View File

@@ -44,6 +44,12 @@ final class Csrf
$provided = (string) ($payload['csrf_token'] ?? '');
if ($provided === '' || !hash_equals($expected, $provided)) {
// Un jeton CSRF absent ou faux est le plus souvent une session
// expirée, parfois une tentative réelle : dans les deux cas la trace
// a sa place dans le journal (V7.11).
Journal::refus('CSRF_INVALIDE', 'Jeton de sécurité absent ou invalide.', [
'contexte' => ['endpoint' => basename((string) ($_SERVER['SCRIPT_NAME'] ?? 'inconnu'))],
]);
// send() termine le script : rien ne s'exécute après ce point.
JsonResponse::send(403, ['error' => 'Jeton de sécurité invalide. Rechargez la page.']);
}

182
app/Core/Journal.php Normal file
View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
use Throwable;
/**
* Journal d'activité — écriture des traces dans journal_activite (V7.11).
*
* Trois familles d'événements sont tracées, et seulement celles-là :
* - CONNEXION : entrée par jeton, échec de jeton, déconnexion ;
* - ECRITURE : toute modification en base, avec la table et l'identifiant
* visés ;
* - ACCES_REFUSE : page interdite, rôle insuffisant, jeton CSRF invalide.
* S'y ajoutent ADMINISTRATION (actions de la DSI sur les comptes et le journal)
* et SYSTEME, réservé aux incidents techniques.
* Les LECTURES ne sont pas journalisées : elles représenteraient l'essentiel du
* volume pour une valeur d'audit très faible.
*
* DEUX RÈGLES ABSOLUES :
* 1. écrire dans le journal ne doit JAMAIS faire échouer l'action journalisée.
* Toute erreur d'insertion est avalée et renvoyée dans le journal serveur
* (error_log). Un journal indisponible dégrade l'audit, il ne bloque pas la
* planification des agents.
* 2. la trace est FIGÉE : rôle et libellé de l'acteur sont copiés au moment de
* l'action. Renommer un agent ou supprimer son compte ne réécrit pas
* l'histoire (d'où le ON DELETE SET NULL de la clé étrangère).
*
* La connexion PDO est posée une fois par app/bootstrap.php
* (Journal::useConnection()) : les appelants — y compris les gardes statiques
* comme Csrf — n'ont ainsi rien à transporter.
*/
final class Journal
{
public const CONNEXION = 'CONNEXION';
public const ECRITURE = 'ECRITURE';
public const ACCES_REFUSE = 'ACCES_REFUSE';
public const ADMINISTRATION = 'ADMINISTRATION';
public const SYSTEME = 'SYSTEME';
private static ?PDO $pdo = null;
/**
* Enregistre la connexion utilisée pour écrire les traces.
* Appelé par app/bootstrap.php, donc valable pour toute la requête.
*/
public static function useConnection(PDO $pdo): void
{
self::$pdo = $pdo;
}
/**
* Trace une entrée, une sortie ou un échec d'authentification.
*
* @param array<string, mixed> $options Voir enregistrer().
*/
public static function connexion(string $action, array $options = []): void
{
self::enregistrer(self::CONNEXION, $action, $options);
}
/**
* Trace une modification en base.
*
* @param string $action Code stable, ex. « PLANNING_VALIDE ».
* @param string $cibleTable Table principale touchée.
* @param int|null $cibleId Identifiant de la ligne visée.
* @param string $description Phrase lisible dans la page DSI.
* @param array<string, mixed> $contexte Détail structuré (semaine, lieu…).
*/
public static function ecriture(
string $action,
string $cibleTable,
?int $cibleId,
string $description,
array $contexte = []
): void {
self::enregistrer(self::ECRITURE, $action, [
'cible_table' => $cibleTable,
'cible_id' => $cibleId,
'description' => $description,
'contexte' => $contexte,
]);
}
/**
* Trace un refus d'accès. Le résultat vaut REFUS, jamais SUCCES.
*
* @param array<string, mixed> $options Voir enregistrer().
*/
public static function refus(string $action, string $description, array $options = []): void
{
self::enregistrer(self::ACCES_REFUSE, $action, array_merge($options, [
'description' => $description,
'resultat' => 'REFUS',
]));
}
/**
* Trace une action d'administration technique (comptes, jetons, journal).
*
* @param array<string, mixed> $options Voir enregistrer().
*/
public static function administration(string $action, array $options = []): void
{
self::enregistrer(self::ADMINISTRATION, $action, $options);
}
/**
* Écrit une ligne de journal. Ne lève jamais d'exception.
*
* L'acteur est déduit de la session, sauf si l'appelant le fournit : c'est
* indispensable à la connexion, où la trace est écrite AVANT que la session
* porte le compte (ou pour un jeton refusé, qui n'ouvre aucune session).
*
* @param string $categorie Une des constantes de classe.
* @param string $action Code stable de l'action.
* @param array<string, mixed> $options resultat, cible_table, cible_id,
* description, contexte, id_compte,
* role, acteur.
*/
public static function enregistrer(string $categorie, string $action, array $options = []): void
{
if (self::$pdo === null) {
return;
}
try {
$session = Access::sessionSnapshot();
$contexte = $options['contexte'] ?? [];
$stmt = self::$pdo->prepare(
'INSERT INTO journal_activite
(categorie, action, resultat, id_compte, role, acteur,
cible_table, cible_id, description, contexte,
adresse_ip, agent_utilisateur)
VALUES
(:categorie, :action, :resultat, :id_compte, :role, :acteur,
:cible_table, :cible_id, :description, :contexte,
:adresse_ip, :agent_utilisateur)'
);
$stmt->execute([
'categorie' => $categorie,
'action' => mb_substr($action, 0, 60),
'resultat' => $options['resultat'] ?? 'SUCCES',
'id_compte' => $options['id_compte'] ?? $session['id_compte'],
'role' => $options['role'] ?? $session['role'],
'acteur' => self::tronquer($options['acteur'] ?? $session['acteur'], 180),
'cible_table' => self::tronquer($options['cible_table'] ?? null, 64),
'cible_id' => $options['cible_id'] ?? null,
'description' => self::tronquer($options['description'] ?? null, 500),
// JSON_INVALID_UTF8_SUBSTITUTE : une donnée mal encodée ne doit
// pas faire échouer l'encodage, donc la trace tout entière.
'contexte' => $contexte === []
? null
: json_encode($contexte, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE),
// REMOTE_ADDR et non X-Forwarded-For : cet en-tête est fourni par
// le client et falsifiable. Derrière un reverse proxy, c'est au
// proxy de réécrire REMOTE_ADDR.
'adresse_ip' => self::tronquer($_SERVER['REMOTE_ADDR'] ?? null, 45),
'agent_utilisateur' => self::tronquer($_SERVER['HTTP_USER_AGENT'] ?? null, 255),
]);
} catch (Throwable $e) {
// Règle 1 : le journal ne casse jamais l'action journalisée.
error_log(sprintf('[PTA][journal] écriture impossible (%s) : %s', $action, $e->getMessage()));
}
}
/** Tronque à la longueur de la colonne, en préservant les caractères accentués. */
private static function tronquer(?string $valeur, int $longueur): ?string
{
if ($valeur === null) {
return null;
}
$valeur = trim($valeur);
return $valeur === '' ? null : mb_substr($valeur, 0, $longueur);
}
}