update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
182
app/Core/Journal.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user