183 lines
7.4 KiB
PHP
183 lines
7.4 KiB
PHP
<?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);
|
|
}
|
|
}
|