Skip to content

Journal d'audit

Trace qui a fait quoi, quand, sur quelle ressource — pour tout type d'utilisateur (invité, voyageur, membre d'organisation, super-admin). Chaque endpoint qui modifie la base écrit au moins une entrée. Motivations d'origine : émission / activation des cartes-cadeaux et litiges B2B.

Le journal se consulte via GET /api/audit-log, réservé aux super-admins (voir Consultation ci-dessous).

Paquet & adaptation UUID

spatie/laravel-activitylog v5. Conformément à l'invariant UUIDv7 du projet, la migration publiée est éditée (uuid en clé primaire, nullableUuidMorphs pour subject et causer) et le modèle est sous-classé — App\Models\AuditLog (nommé ainsi et non Activity, réservé à l'entité métier « activité »), enregistré via config('activitylog.activity_model') — au même titre que Role et PersonalAccessToken (voir Base de données).

Colonnes utiles : log_name (domaine), event (verbe), description, subject (morph — la ressource touchée), causer (morph — l'utilisateur authentifié, résolu automatiquement via Sanctum ; null pour un invité), attribute_changes (diff attributes / old), properties (contexte libre).

Deux mécanismes de capture

1. Automatique — concern Auditable sur les modèles du domaine

App\Models\Concerns\Auditable enveloppe le trait LogsActivity avec les défauts du projet : log_name = nom de table, événements created/updated/deleted, attributs modifiés uniquement (logOnlyDirty), aucun log de diff vide, et exclusions — horodatages, attributs cachés (les identifiants de connexion, aussi exclus globalement via config('activitylog.default_except_attributes')), plus les extras par modèle déclarés en surchargeant auditExcept() (ex. le token d'Invitation, même haché).

Modèles couverts : User, Organization, Invitation, ActivationCode, Role, Membership, Provider, Activity, Tag, ActivityTag. Exclusions délibérées : PersonalAccessToken (le va- et-vient des tokens est du bruit — les flux d'auth ont leurs entrées explicites), Permission (données de seed) et StepRouting (toutes ses écritures de production sont des upserts ensemblistes qui contournent les événements Eloquent — révélation et routage écrivent leurs entrées explicites, ci-dessous).

Cas particulier du pivot Membership (clé composite, getKey() nul) : son hook beforeActivityLogged() (invoqué par LogActivityAction avant chaque sauvegarde) re-pointe le sujet sur le User et range organization_id dans les properties. Les écritures de pivot via attach() / syncWithoutDetaching() / detach() sur les relations ->using(Membership::class) déclenchent bien les événements du modèle pivot — comportement épinglé par MembershipAuditTest.

2. Explicite — activity() là où aucun événement Eloquent ne part

Conventions : log_name = domaine (auth, invitations, activation_codes), event = verbe anglais en snake_case, description courte en anglais. L'appel vit dans la transaction propriétaire de l'écriture, pour qu'un rollback emporte aussi l'entrée d'audit.

Écriture sans événementEntrée explicite
POST /auth/login (création de token)auth / login (+ device_name ; causer explicite : pas encore authentifié)
POST /auth/logout (suppression de token)auth / logout (+ token_id)
POST /auth/forgot-password (password_reset_tokens)auth / password_reset_requested (+ email ; causer nul — la réponse HTTP reste anti-énumération, le journal peut tracer la tentative)
POST /auth/reset-password (mot de passe + révocation des tokens)auth / password_reset (le diff du modèle est vide par construction : identifiants exclus)
POST /invitations (pivots de rôle via assignRole)invitations / sent (+ email, assignments, is_super_admin)
POST /invitations/{token}/accept (bascule en lot des memberships)invitations / accepted (+ activated_organization_ids)
POST /activation-codes (insertion en lot)activation_codes / generated (une entrée par lot : prefix, count, amount, currency, duration ; sujet = organisation émettrice)
PUT /activation-codes/status (UPDATE en lot)activation_codes / status_updated (+ status, new_expiration, updated_ids, skipped)
POST /activation-codes/{id}/steps/{stepId}/reveal (upsert brut)step_routings / revealed au premier affichage (+ activity_step_id, previous_activity_step_id si un pré-routage retiré a été re-résolu) ; re_routed quand un rappel re-résout un plan retiré depuis ; le rappel idempotent n'écrit rien
POST /activation-codes/{id}/steps/{stepId}/next-plan (UPDATE brut)step_routings / plan_requested — le voyageur s'est déplacé lui-même vers l'alternative suivante (+ activation_code_id, step_id, activity_step_id, previous_activity_step_id, toujours présent : l'appel avance ou échoue)
PUT /tour/step/{stepId}/routings (upsert en lot)step_routings / routedune entrée par voyageur routé (sujet = la ligne de routage ; + activity_step_id, revealed, previous_activity_step_id le cas échéant)

Enrichissement par requête

App\Models\AuditLog porte un hook creating qui enrichit chaque entrée — issue du trait comme d'un appel manuel (tout converge vers $activity->save()) — avec le contexte HTTP dans properties.request : ip (fiable derrière le proxy, trustProxies configuré), method, path, et quand la route porte le middleware resolve-viewing-context : viewing_as (traveler | super-admin | organization) et viewing_organization_id. Les écritures console / queue (aucune route liée) sont stockées sans ce bloc.

Octane & seeding

  • Les liaisons du paquet (CauserResolver, ActivityLogStatus, ActivityBuffer) sont scoped() — remises à zéro entre requêtes Octane, rien à ajouter dans config/octane.php. Le hook d'enrichissement résout la requête à chaque appel (jamais de mémoïsation).
  • Le DatabaseSeeder désactive le journal (activity()->disableLogging(), réactivé en finally) : WithoutModelEvents ne mute que le logging par trait, pas les appels activity() explicites des actions réutilisées par les seeders. Garde-fou : DatabaseSeederTest::test_seeding_writes_no_activity. La migration de données seed_default_organization écrit via DB::table() (aucun événement) — hors journal par construction.

Consultation — GET /api/audit-log

ListAuditLogController (invokable, sous auth:sanctum + resolve-viewing-context), réservé au contexte super-admin (ListAuditLogRequest::authorize()). Renvoie les entrées de la plus récente à la plus ancienne, paginées selon la mécanique commune (per_page, plafonné par config('pagination.max_page_size') ; réponse data + meta).

Filtres optionnels sur l'horodatage : from / until (dates, bornes incluses ; until doit suivre from quand les deux sont fournis, sinon 422). La colonne activity_log.created_at est indexée pour que le tri et les bornes temporelles tiennent sur la durée.

Chaque ligne (AuditLogResource) est plate : timestamp (= created_at), id, puis l'identité du causer inlinée — email, first_name, last_name (via eager loading du morph, null pour les actions d'invité) — suivie des champs bruts : log_name, event, description, subject_type, subject_id, causer_id, attribute_changes, properties. Aucune rédaction supplémentaire n'est nécessaire : les secrets sont exclus à l'écriture.

Configuration & rétention

config/activitylog.php : modèle App\Models\AuditLog, écritures synchrones (buffer désactivé — volume faible, atomicité avec la transaction), coupe-circuit ACTIVITYLOG_ENABLED (.env), exclusion globale des identifiants de connexion.

clean_after_days reste à 365 mais la commande activitylog:clean n'est pas planifiée : la durée de rétention (litiges B2B) est une décision produit encore ouverte. Aucun scheduler n'existe à ce jour dans routes/console.php.

Auditer un nouveau modèle — checklist

  1. use Auditable; sur le modèle (ordre des traits sans importance).
  2. Surcharger auditExcept() si certains attributs doivent rester hors journal (secrets, blobs).
  3. Toute écriture hors Eloquent (insert/update en lot, DB::table(), pivots spatie) : ajouter un appel activity() explicite dans la transaction, avec log_name du domaine et un event en snake_case.
  4. Ajouter l'assertion d'audit au test de fonctionnalité du flux concerné.

Contributors

No contributors

Changelog

No recent changes