Aller au contenu

Histoire CP-004 : Service dorsal de piste d'audit

Module : plan de contrôle Slice : Tranche 4 de l'architecture Contexte de la marque [LES DEUX] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priorité : 4 Dépend de : CP-001 Complexité estimée M


Objectif

Construire le service backend d'audit partagé et immuable utilisé par TOUS les modules de la plateforme. Chaque opération de création, de mise à jour ou de suppression doit produire une entrée d'audit avec des instantanés avant/après. Cela répond à l'art. 24 de l'AUDCIF (conservation pendant 10 ans). 24 de l'AUDCIF (conservation pendant 10 ans). Le service d'audit est un composant d'infrastructure partagé - il est consommé par toutes les histoires qui suivent. Le visualisateur de l'interface utilisateur d'audit se trouve dans CP-016.


Portée du backend

Entités et enregistrements

Entité AuditEntry (base de données du locataire, partitionnée dans le temps):

Field Type Constraints
id UUID v7 PK, server-generated
tenant_id UUID NOT NULL
user_id UUID NOT NULL (FK to User — who performed the action)
timestamp Instant NOT NULL, UTC
entity_type String NOT NULL (e.g., "Employee", "LeaveRequest", "Tenant")
entity_id UUID NOT NULL
module_code String NOT NULL (e.g., "CP", "ABSMGT", "PAYROL")
action AuditAction enum NOT NULL
before_value JSONB Nullable (null for CREATE)
after_value JSONB Nullable (null for DELETE)
metadata JSONB Nullable (extra context, e.g., "reason for suspension")

AuditAction enum:

public enum AuditAction { CREATE, UPDATE, DELETE }

DTOs:

public record AuditEntryResponse(
    UUID id, UUID userId, String userFullName, Instant timestamp,
    String entityType, UUID entityId, String moduleCode,
    AuditAction action, JsonNode beforeValue, JsonNode afterValue,
    JsonNode metadata
) {}

public record AuditPageResponse(
    List<AuditEntryResponse> entries, int totalCount, int page, int pageSize
) {}

public record AuditQueryParams(
    @Nullable String entityType,
    @Nullable UUID entityId,
    @Nullable UUID userId,
    @Nullable AuditAction action,
    @Nullable String moduleCode,
    @Nullable Instant dateFrom,
    @Nullable Instant dateTo,
    int page,    // default 0
    int pageSize // default 50
) {}

Couche de repository

  • AuditRepository.java - Tenant-aware :
  • save(AuditEntry) → AuditEntry - INSERT uniquement (jamais de mise à jour)
  • findByFilters(UUID tenantId, AuditQueryParams) → Page<AuditEntry> - Requête paginée avec filtres optionnels sur entity_type, entity_id, user_id, action, module_code, date range
  • Pas de méthode de suppression (immuable)
  • Pas de méthode de mise à jour (immuable)

Couche de service

AuditEventListener.java (partagé - piloté par les événements selon ADR-21): - Pas de méthode directe .log() exposée à d'autres modules. Au lieu de cela, les modules publient des événements de domaine (par exemple EmployeeCreatedEvent, TenantStatusChangedEvent), et le module Audit écoute. - @EventListener sur tous les événements du domaine → convertit chaque événement en une AuditEntry : - Injecte automatiquement le tenant_id du TenantContext et le user_id du SecurityContext - Mappage du nom de la classe de l'événement → entity_type, des champs de l'événement → JSONB after_value - Génère un UUID v7 pour l'entrée - Sauvegarde de manière asynchrone (non bloquante - ne ralentit pas l'opération principale) - Selon ADR-21 : "Zéro couplage - les modules publient des événements, le module d'audit écoute. Livraison garantie via la table EVENT_PUBLICATION. Pas d'appel explicite au service d'audit"

AuditQueryService.java: - query(AuditQueryParams params) → AuditPageResponse - Requête à l'échelle du locataire pour l'interface utilisateur (consommée par CP-016)

Notes de mise en œuvre: - Les écritures d'audit sont de type "fire-and-forget" (asynchrone). Si l'écriture d'audit échoue, elle ne doit PAS faire échouer la transaction principale. Enregistrer l'échec dans les journaux d'application pour la surveillance des opérations. - Couverture = couverture des événements : chaque mutation de données DOIT publier un événement de domaine, que l'auditeur capture automatiquement.

Points d'extrémité de l'API

Pas d'endpoint REST dans cette histoire. L'endpoint de requête (GET /api/v1/audit) est dans CP-016.

Règles de validation

  • Les entrées d'audit sont immuables : aucun endpoint PUT, PATCH ou DELETE n'existe
  • entity_type max 100 chars
  • module_code max 20 chars
  • Période d'interrogation : date_from doit être antérieure à date_to
  • Taille de la page : 1-100 (par défaut 50)

Considérations multi-tenant

  • Les entrées d'audit se trouvent dans la base de données du locataire (étendue au locataire)
  • L'API de requête s'étend automatiquement par TenantContext.current().tenantId()
  • L'administrateur de la plateforme (CP-012) peut effectuer des requêtes sur l'ensemble des locataires - cet endpoint se trouve dans l'histoire de la console d'administration
  • Partitionnement temporel : partitions mensuelles sur la colonne timestamp pour la performance à l'échelle

Migrations par la voie des airs

  • V002__create_audit_entry_partitioned.sql - Base de données des locataires : table audit_entries avec partitionnement temporel (mensuel). Créer des partitions initiales pour le mois en cours et les 3 prochains mois. Inclure une tâche planifiée ou un déclencheur pour créer les partitions futures.

Champ d'application frontal

Aucune - cette histoire ne concerne que le backend. Toute l'interface d'audit (AuditTrailPage, AuditEntryCard, AuditDetailSheet, AuditFilterBar) est dans CP-016.


Critères d'acceptation

AC-026: Audit entry created on data mutation
Given I create an employee via POST /api/v1/employees
When the employee is persisted
Then an audit entry is created with action=CREATE, entityType="Employee", afterValue=employee JSON, beforeValue=null

AC-027: Audit entry captures before/after on update
Given employee "Moussa Koné" exists with department="Production"
When I update the department to "Commercial"
Then an audit entry is created with action=UPDATE, beforeValue containing department="Production", afterValue containing department="Commercial"

AC-028: Audit entries are immutable
Given an audit entry exists
When any attempt is made to update or delete it (via API or direct DB)
Then the operation is rejected (no DELETE or UPDATE API exists; DB constraint prevents DELETE)

AC-030: Audit is tenant-scoped
Given tenant A has 20 audit entries and tenant B has 10
When tenant A queries GET /api/v1/audit
Then only tenant A's 20 entries are returned (zero from tenant B)

Moving ACs : AC-029, AC-031, AC-032, AC-033 → CP-016.


OHADA et règles réglementaires

  • AUDCIF Art. 24 : Tous les documents financiers et comptables doivent être conservés pendant au moins 10 ans. Les écritures d'audit répondent à cette exigence de traçabilité de TOUTES les mutations de données.
  • AUDCIF Art. 22 : Les écritures d'audit doivent être immuables - aucune modification, aucune suppression. Le schéma de la base de données applique cette règle (pas de déclencheur DELETE, pas de permission UPDATE sur la table). Remarque : la conformité totale à l'art. 22 de l'AUDCIF (chaînes de hachage, verrouillage périodique) est pour le module de comptabilité, pas pour la piste d'audit générale.
  • Loi 2013-450 : La piste d'audit fournit l'imputabilité requise pour le traitement des données personnelles. Chaque accès et modification de données personnelles (dossiers d'employés) est tracé.

Normes et conventions

  • docs/standards/java-spring-guidelines.md - §Entités, §Traitement asynchrone, §Événements d'application
  • docs/standards/database-guidelines.md - §JSONB columns, §Time-partitioned tables, §Immutable tables
  • docs/standards/api-guidelines.md - §Pagination, §Conventions des paramètres de requête
  • docs/standards/react-typescript-guidelines.md - §Composants de liste/table, §Modèle de filtre, §Détail de la feuille inférieure

Exigences de test

Tests unitaires

  • AuditService.log() : création d'entrée correcte avec sérialisation avant/après
  • AuditService.log() : gère null avant (CREATE) et null après (DELETE)
  • AuditService.log() : injecte le tenant_id et le user_id du contexte

Tests d'intégration

  • Flux complet : création d'une entité → l'entrée d'audit existe dans la base de données avec les données correctes
  • Flux complet : mise à jour d'une entité → l'entrée d'audit est correcte avant/après
  • Isolation du locataire : une requête du locataire B renvoie zéro entrée du locataire A
  • Immutabilité : tentative de DELETE sur la table audit_entries → échec
  • Asynchronisme : l'échec de l'écriture d'un audit n'entraîne pas l'échec de la transaction principale

Ce que l'assurance qualité validera

  • Des entrées d'audit sont créées pour chaque mutation de données (vérification via la base de données ou l'API)
  • Isolation des locataires : pas de fuites d'audit entre les locataires

Hors du champ d'application

  • Instructions d'audit (AuditTrailPage, filtres, vue détaillée, contrôle d'accès) - CP-016
  • Endpoint de requête d'audit avec filtres (GET /api/v1/audit avec paramètres de filtre) - CP-016
  • Exportation CSV de la piste d'audit - V2
  • Requête d'audit inter-locataires (pour l'administrateur de la plateforme) - CP-012
  • Chaînes de hachageAUDCIF Art. 22 hash chains - Module de comptabilité
  • Automatisation de la gestion des partitions (création de nouvelles partitions mensuelles) - DevOps/cron, pas de code d'application dans cette histoire. Créer des partitions pour les 6 mois à venir dans la migration.
  • La gestion des partitions (création de nouvelles partitions mensuelles) - DevOps/cron, pas le code de l'application

Définition de Fait

  • [Le service d'audit est injectable et produit des entrées correctes pour CREATE, UPDATE, DELETE
  • [L'entité AuditEntry est persistée dans la base de données du locataire avec une isolation correcte du locataire
  • [La migration Flyway crée une table partitionnée
  • [Tous les critères d'acceptation sont passés manuellement
  • [Tests unitaires écrits et réussis
  • [Tests d'intégration rédigés et réussis
  • [Isolation multi-tenant vérifiée (pas de fuites de données entre locataires)
  • [Pas de types bruts Java - tous les types génériques sont explicites
  • [L'échec de l'écriture d'un audit asynchrone n'entraîne pas l'échec de la transaction principale