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 enregistrement
  • updated — après la modification d'un enregistrement existant
  • deleted — 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 logique
  • forceDeleted — 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 :

  1. $auditInclude — si défini, seuls ces champs sont enregistrés
  2. $auditExclude — ces champs sont toujours exclus
  3. Exclusion automatique de $hidden — les champs $hidden du 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 :

  • HasActivityLog appelle DatabaseDriver::store() — définit hash et prev_hash à null, puis insère.
  • HasAuditTrail appelle DatabaseDriver::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_values et new_values sont triés récursivement via recursiveKsort pour 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_accessed est trié via sort
  • Le tableau de données de premier niveau est trié via ksort
  • auditable_id est 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 :

  1. Continuité de la chaîne — le prev_hash de chaque entrée correspond au hash de l'entrée précédente
  2. Intégrité du hash — le recalcul du hash à partir des données stockées correspond au hash stocké

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.