Comment ça fonctionne
AuditChain fonctionne en se connectant aux événements du cycle de vie d'Eloquent, en construisant un payload structuré pour chaque modification et (en mode complet) en reliant les entrées entre elles avec des hash SHA-256. Cette page détaille les mécanismes internes.
Hooks sur les événements Eloquent
Lorsque vous ajoutez HasActivityLog ou HasAuditTrail à un model, le trait de base HasAuditableEvents enregistre des listeners sur les événements du model Eloquent lors du boot :
created— après l'insertion d'un nouvel enregistrementupdated— après la modification d'un enregistrement existantdeleted— après la suppression d'un enregistrement
Si le model utilise SoftDeletes, deux événements supplémentaires sont automatiquement enregistrés :
restored— après la restauration d'un enregistrement supprimé de manière logiqueforceDeleted— après la suppression définitive d'un enregistrement supprimé de manière logique
Si log_reads est activé dans la configuration, les événements retrieved sont également capturés (à utiliser avec précaution — c'est très verbeux).
Avant d'enregistrer tout événement, AuditChain vérifie AuditChainManager::isLoggingDisabled(). C'est ainsi que AuditChain::withoutAudit() supprime la journalisation pendant les seeds et les imports.
Construction du payload
Pour chaque événement, AuditChain construit un tableau de payload :
[
'auditable_type' => 'App\Models\Invoice', // morph class
'auditable_id' => '42', // always cast to string
'event' => 'updated',
'user_id' => 1, // Auth::id()
'ip_address' => '192.168.1.1', // null in console
'user_agent' => 'Mozilla/5.0...', // null in console
'old_values' => ['status' => 'draft'],
'new_values' => ['status' => 'published'],
'personal_data_accessed' => ['email'], // if applicable
'batch_uuid' => 'abc-123', // if inside batch()
'context' => ['source' => 'api'], // if set
'created_at' => '2026-02-20 14:30:00', // UTC
]
Les anciennes valeurs sont capturées à partir des attributs originaux bruts du model (via getRawOriginal()), filtrées pour n'inclure que les champs ayant réellement changé. Les nouvelles valeurs proviennent de getChanges() pour les mises à jour, ou de getAttributes() pour les créations.
Filtrage des champs
Trois niveaux de filtrage des champs sont appliqués aux anciennes/nouvelles valeurs :
$auditInclude— si défini, seuls ces champs sont enregistrés$auditExclude— ces champs sont toujours exclus- Exclusion automatique de
$hidden— les champs$hiddendu model (mots de passe, tokens API) sont automatiquement exclus
Dispatch : mode léger vs. complet
Après la construction du payload, la méthode dispatchAuditLog() détermine comment il est stocké. C'est la seule différence entre les deux modes :
HasActivityLogappelleDatabaseDriver::store()— définithashetprev_hashànull, puis insère.HasAuditTrailappelleDatabaseDriver::storeChained()— délègue àAuditChainService::record(), qui calcule la chaîne de hash au sein d'une transaction de base de données.
Lorsque la file d'attente est activée (par défaut), les deux modes dispatchent un job RecordAuditLog au lieu d'écrire de manière synchrone.
Calcul de la chaîne de hash
Cette section ne concerne que les models utilisant HasAuditTrail.
La formule de hash
Le hash de chaque log d'audit est un condensé SHA-256 d'un payload JSON déterministe :
hash = SHA-256(json_encode(sorted_data))
L'encodage JSON utilise des flags stricts : JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES.
Les données incluses dans le hash (triées alphabétiquement via recursiveKsort pour les tableaux imbriqués) :
| Champ | Source |
|---|---|
auditable_id |
Clé primaire du model (en tant que chaîne) |
auditable_type |
Classe morph du model |
event |
Nom de l'événement |
ip_address |
IP de la requête |
new_values |
Valeurs modifiées (triées récursivement via recursiveKsort) |
old_values |
Valeurs précédentes (triées récursivement via recursiveKsort) |
personal_data_accessed |
Champs personnels impliqués (triés) |
prev_hash |
Hash de l'entrée précédente dans la chaîne |
timestamp |
Chaîne datetime UTC |
user_agent |
User agent de la requête |
user_id |
ID de l'utilisateur authentifié |
Intentionnellement exclus du hash : batch_uuid et context. Ce sont des métadonnées opérationnelles — modifier la manière dont vous regroupez ou annotez les logs d'audit ne doit jamais invalider la chaîne.
Hash déterministe
Plusieurs étapes garantissent que les mêmes données produisent toujours le même hash :
- Tous les horodatages sont normalisés en UTC via
Carbon::parse($timestamp, 'UTC')->toDateTimeString()(fuseau horaire UTC explicite) old_valuesetnew_valuessont triés récursivement viarecursiveKsortpour gérer l'ordre incohérent des clés à tous les niveaux d'imbrication (les colonnes JSON MySQL peuvent réordonner les clés)personal_data_accessedest trié viasort- Le tableau de données de premier niveau est trié via
ksort auditable_idest toujours converti en chaîne
Hash de genèse
La toute première entrée de la chaîne n'a pas de prédécesseur. Son prev_hash est dérivé d'un seed configurable :
prev_hash = SHA-256(chain_seed)
Le seed est par défaut 'genesis' mais devrait être défini avec une valeur secrète en production via la variable d'environnement AUDIT_CHAIN_SEED. Un seed prévisible affaiblit la détection de falsification — si un attaquant connaît le seed, il peut reconstruire le hash de genèse et forger une chaîne entière à partir de zéro.
AUDIT_CHAIN_SEED=your-random-secret-value
Chaîne globale avec verrouillage
AuditChain maintient une chaîne globale unique à travers tous les models — et non une chaîne par model. Cela signifie qu'une mise à jour d'Invoice et une mise à jour d'User partagent la même chaîne, rendant plus difficile la falsification d'un sous-ensemble d'enregistrements sans casser l'intégralité de la chaîne.
Pour éviter les conditions de concurrence, record() utilise une table sentinelle (audit_chain_state) au lieu de parcourir la table audit_logs. Cette table à une seule ligne stocke le last_hash de la dernière entrée chaînée. La méthode record() s'exécute au sein d'une transaction de base de données et utilise SELECT last_hash FOR UPDATE sur la ligne sentinelle :
DB::transaction(function () use ($data) { // SELECT last_hash FROM audit_chain_state FOR UPDATE $prevHash = $this->getPreviousHash(); $data['prev_hash'] = $prevHash; $data['hash'] = $this->computeHash($data); AuditLog::create($data); // UPDATE audit_chain_state SET last_hash = $data['hash'] $this->updateLastHash($data['hash']); });
Le verrou FOR UPDATE sur la ligne sentinelle garantit que les requêtes concurrentes se sérialisent lors de l'ajout à la chaîne, empêchant deux entrées de revendiquer le même prédécesseur. Cette approche est en O(1) quel que soit le nombre de logs d'audit, contrairement au parcours de la table audit_logs qui se dégraderait à mesure que la table grandit.
Vérification
La vérification de la chaîne parcourt chaque log d'audit chaîné (où hash IS NOT NULL) par ordre chronologique et vérifie deux choses :
- Continuité de la chaîne — le
prev_hashde chaque entrée correspond auhashde l'entrée précédente - Intégrité du hash — le recalcul du hash à partir des données stockées correspond au
hashstocké
La vérification traite les logs par blocs de 1000 pour maintenir une utilisation constante de la mémoire. Consultez Vérification de la chaîne pour savoir comment l'exécuter et l'automatiser.
Immutabilité
Le model AuditLog impose l'immutabilité au niveau Eloquent — les événements updating et deleting lèvent une RuntimeException. Pour une véritable défense en profondeur, utilisez un utilisateur de base de données dédié avec des permissions INSERT et SELECT uniquement sur la table des logs d'audit. Consultez Sécurité pour plus de détails.