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:
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_typemax 100 charsmodule_codemax 20 chars- Période d'interrogation :
date_fromdoit ê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
timestamppour la performance à l'échelle
Migrations par la voie des airs¶
V002__create_audit_entry_partitioned.sql- Base de données des locataires : tableaudit_entriesavec 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'applicationdocs/standards/database-guidelines.md- §JSONB columns, §Time-partitioned tables, §Immutable tablesdocs/standards/api-guidelines.md- §Pagination, §Conventions des paramètres de requêtedocs/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