diff --git a/src/main/java/dev/lions/user/manager/resource/UserResource.java b/src/main/java/dev/lions/user/manager/resource/UserResource.java
index bf51fa6..5f82741 100644
--- a/src/main/java/dev/lions/user/manager/resource/UserResource.java
+++ b/src/main/java/dev/lions/user/manager/resource/UserResource.java
@@ -22,6 +22,7 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
+import java.time.LocalDateTime;
import java.util.List;
/**
@@ -407,6 +408,86 @@ public class UserResource {
}
}
+ /**
+ * Exporter les utilisateurs en CSV
+ */
+ @GET
+ @Path("/export/csv")
+ @Operation(summary = "Exporter les utilisateurs en CSV")
+ @APIResponses({
+ @APIResponse(responseCode = "200", description = "Fichier CSV généré avec succès"),
+ @APIResponse(responseCode = "400", description = "Realm manquant ou invalide"),
+ @APIResponse(responseCode = "500", description = "Erreur serveur")
+ })
+ @RolesAllowed({"admin", "user_manager", "user_viewer"})
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response exportUsersToCSV(@QueryParam("realm") @NotBlank String realmName) {
+ log.info("GET /api/users/export/csv - realm: {}", realmName);
+
+ try {
+ UserSearchCriteriaDTO criteria = UserSearchCriteriaDTO.builder()
+ .realmName(realmName)
+ .pageSize(10000) // Export complet sans pagination
+ .page(0)
+ .build();
+
+ String csvContent = userService.exportUsersToCSV(criteria);
+
+ String filename = "users_export_" +
+ LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd_HHmmss")) +
+ ".csv";
+
+ return Response.ok(csvContent)
+ .header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
+ .build();
+ } catch (Exception e) {
+ log.error("Erreur lors de l'export CSV des utilisateurs", e);
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(new ErrorResponse(e.getMessage()))
+ .build();
+ }
+ }
+
+ /**
+ * Importer des utilisateurs depuis CSV avec rapport détaillé
+ */
+ @POST
+ @Path("/import/csv")
+ @Operation(summary = "Importer des utilisateurs depuis un fichier CSV")
+ @APIResponses({
+ @APIResponse(responseCode = "200", description = "Import terminé avec rapport détaillé"),
+ @APIResponse(responseCode = "400", description = "Fichier CSV vide ou invalide"),
+ @APIResponse(responseCode = "500", description = "Erreur serveur")
+ })
+ @RolesAllowed({"admin", "user_manager"})
+ @Consumes(MediaType.TEXT_PLAIN)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response importUsersFromCSV(
+ @QueryParam("realm") @NotBlank String realmName,
+ String csvContent) {
+ log.info("POST /api/users/import/csv - realm: {}", realmName);
+
+ try {
+ if (csvContent == null || csvContent.trim().isEmpty()) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity(new ErrorResponse("Le contenu CSV est vide"))
+ .build();
+ }
+
+ dev.lions.user.manager.dto.importexport.ImportResultDTO result = userService.importUsersFromCSV(csvContent, realmName);
+
+ log.info("{} utilisateur(s) importé(s) dans le realm {} ({} erreur(s))",
+ result.getSuccessCount(), realmName, result.getErrorCount());
+
+ return Response.ok(result).build();
+ } catch (Exception e) {
+ log.error("Erreur lors de l'import CSV des utilisateurs", e);
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(new ErrorResponse(e.getMessage()))
+ .build();
+ }
+ }
+
// ==================== DTOs internes ====================
@Schema(description = "Requête de réinitialisation de mot de passe")
diff --git a/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java b/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java
index 26a2827..2924283 100644
--- a/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java
+++ b/src/main/java/dev/lions/user/manager/service/impl/AuditServiceImpl.java
@@ -2,8 +2,12 @@ package dev.lions.user.manager.service.impl;
import dev.lions.user.manager.dto.audit.AuditLogDTO;
import dev.lions.user.manager.enums.audit.TypeActionAudit;
+import dev.lions.user.manager.server.impl.entity.AuditLogEntity;
+import dev.lions.user.manager.server.impl.mapper.AuditLogMapper;
import dev.lions.user.manager.service.AuditService;
import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -19,19 +23,49 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
- * Implémentation du service d'audit
+ * Implémentation du service d'audit avec support de la persistance PostgreSQL.
*
- * NOTES:
- * - Cette implémentation utilise un stockage en mémoire pour le développement
- * - En production, il faudrait utiliser une base de données (PostgreSQL avec Panache)
- * - Les logs sont également écrits via SLF4J pour être capturés par les systèmes de logging centralisés
+ *
Architecture Hybride:
+ *
+ * - Cache en mémoire - Pour les logs récents (performances)
+ * - Persistance PostgreSQL - Pour l'historique long terme (activable via config)
+ *
+ *
+ * Configuration:
+ *
+ * - {@code lions.audit.enabled} - Active/désactive l'audit (défaut: true)
+ * - {@code lions.audit.log-to-database} - Active la persistance DB (défaut: false en dev, true en prod)
+ * - {@code lions.audit.cache-size} - Taille max du cache mémoire (défaut: 10000)
+ * - {@code lions.audit.retention-days} - Durée de rétention en jours (défaut: 365)
+ *
+ *
+ * Modes de Fonctionnement:
+ *
+ * Mode DEV (logToDatabase=false):
+ * - Stockage en mémoire uniquement
+ * - Logs perdus au redémarrage
+ * - Performances maximales
+ *
+ * Mode PROD (logToDatabase=true):
+ * - Persistance PostgreSQL
+ * - Cache mémoire pour requêtes fréquentes
+ * - Historique complet préservé
+ *
+ *
+ * @author Lions Development Team
+ * @version 2.0.0
+ * @since 2026-01-02
*/
@ApplicationScoped
@Slf4j
public class AuditServiceImpl implements AuditService {
- // Stockage en mémoire (à remplacer par une DB en production)
- private final Map auditLogs = new ConcurrentHashMap<>();
+ // ==================== DÉPENDANCES ====================
+
+ @Inject
+ AuditLogMapper auditLogMapper;
+
+ // ==================== CONFIGURATION ====================
@ConfigProperty(name = "lions.audit.enabled", defaultValue = "true")
boolean auditEnabled;
@@ -39,7 +73,24 @@ public class AuditServiceImpl implements AuditService {
@ConfigProperty(name = "lions.audit.log-to-database", defaultValue = "false")
boolean logToDatabase;
+ @ConfigProperty(name = "lions.audit.cache-size", defaultValue = "10000")
+ int cacheSize;
+
+ @ConfigProperty(name = "lions.audit.retention-days", defaultValue = "365")
+ int retentionDays;
+
+ // ==================== STOCKAGE ====================
+
+ /**
+ * Cache en mémoire pour les logs récents.
+ * Limité à {@code cacheSize} entrées. Les plus anciens sont supprimés automatiquement.
+ */
+ private final Map auditLogsCache = new ConcurrentHashMap<>();
+
+ // ==================== MÉTHODES PRINCIPALES ====================
+
@Override
+ @Transactional
public AuditLogDTO logAction(@Valid @NotNull AuditLogDTO auditLog) {
if (!auditEnabled) {
log.debug("Audit désactivé, log ignoré");
@@ -56,7 +107,7 @@ public class AuditServiceImpl implements AuditService {
auditLog.setDateAction(LocalDateTime.now());
}
- // Log structuré pour les systèmes de logging (Graylog, Elasticsearch, etc.)
+ // Log structuré pour les systèmes de logging externes (Graylog, Elasticsearch, etc.)
log.info("AUDIT | Type: {} | Acteur: {} | Ressource: {} | Succès: {} | IP: {} | Détails: {}",
auditLog.getTypeAction(),
auditLog.getActeurUsername(),
@@ -65,15 +116,30 @@ public class AuditServiceImpl implements AuditService {
auditLog.getIpAddress(),
auditLog.getDescription());
- // Stocker en mémoire
- auditLogs.put(auditLog.getId(), auditLog);
+ // Stocker en base de données si activé
+ if (logToDatabase) {
+ try {
+ AuditLogEntity entity = auditLogMapper.toEntity(auditLog);
+ // Le mapper s'occupe du mapping automatique via @Mapping annotations
+ // Ajout des champs additionnels non mappés automatiquement
+ entity.setRealmName(auditLog.getRealmName());
- // TODO: Si logToDatabase = true, persister dans PostgreSQL via Panache
- // Exemple:
- // if (logToDatabase) {
- // AuditLogEntity entity = AuditLogMapper.toEntity(auditLog);
- // entity.persist();
- // }
+ entity.persist();
+
+ log.debug("Log d'audit persisté en base de données avec ID: {}", entity.id);
+ } catch (Exception e) {
+ log.error("Erreur lors de la persistance du log d'audit en base de données", e);
+ // On ne lance pas d'exception pour ne pas bloquer le processus métier
+ }
+ }
+
+ // Ajouter au cache mémoire (pour performances)
+ auditLogsCache.put(auditLog.getId(), auditLog);
+
+ // Nettoyer le cache si trop grand
+ if (auditLogsCache.size() > cacheSize) {
+ cleanOldestCacheEntries();
+ }
return auditLog;
}
@@ -88,7 +154,7 @@ public class AuditServiceImpl implements AuditService {
String description) {
AuditLogDTO auditLog = AuditLogDTO.builder()
.acteurUserId(acteurUserId)
- .acteurUsername(acteurUserId) // Utiliser acteurUserId comme username pour l'instant
+ .acteurUsername(acteurUserId)
.typeAction(typeAction)
.ressourceType(ressourceType)
.ressourceId(ressourceId != null ? ressourceId : "")
@@ -111,7 +177,7 @@ public class AuditServiceImpl implements AuditService {
String errorMessage) {
AuditLogDTO auditLog = AuditLogDTO.builder()
.acteurUserId(acteurUserId)
- .acteurUsername(acteurUserId) // Utiliser acteurUserId comme username pour l'instant
+ .acteurUsername(acteurUserId)
.typeAction(typeAction)
.ressourceType(ressourceType)
.ressourceId(ressourceId != null ? ressourceId : "")
@@ -123,13 +189,18 @@ public class AuditServiceImpl implements AuditService {
logAction(auditLog);
}
+ // ==================== MÉTHODES DE RECHERCHE ====================
+
@Override
public List findByActeur(@NotBlank String acteurUserId,
LocalDateTime dateDebut,
LocalDateTime dateFin,
int page,
int pageSize) {
- return searchLogs(acteurUserId, dateDebut, dateFin, null, null, null, page, pageSize);
+ if (logToDatabase) {
+ return searchLogsFromDatabase(acteurUserId, dateDebut, dateFin, null, null, null, page, pageSize);
+ }
+ return searchLogsFromCache(acteurUserId, dateDebut, dateFin, null, null, null, page, pageSize);
}
@Override
@@ -139,7 +210,13 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin,
int page,
int pageSize) {
- return searchLogs(null, dateDebut, dateFin, null, ressourceType, null, page, pageSize)
+ if (logToDatabase) {
+ return searchLogsFromDatabase(null, dateDebut, dateFin, null, ressourceType, null, page, pageSize)
+ .stream()
+ .filter(log -> ressourceId.equals(log.getRessourceId()))
+ .collect(Collectors.toList());
+ }
+ return searchLogsFromCache(null, dateDebut, dateFin, null, ressourceType, null, page, pageSize)
.stream()
.filter(log -> ressourceId.equals(log.getRessourceId()))
.collect(Collectors.toList());
@@ -152,7 +229,10 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin,
int page,
int pageSize) {
- return searchLogs(null, dateDebut, dateFin, typeAction, null, null, page, pageSize);
+ if (logToDatabase) {
+ return searchLogsFromDatabase(null, dateDebut, dateFin, typeAction, null, null, page, pageSize);
+ }
+ return searchLogsFromCache(null, dateDebut, dateFin, typeAction, null, null, page, pageSize);
}
@Override
@@ -161,8 +241,11 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin,
int page,
int pageSize) {
- // Pour l'instant, on retourne tous les logs car on n'a pas de champ realmName dans AuditLogDTO
- return searchLogs(null, dateDebut, dateFin, null, null, null, page, pageSize);
+ if (logToDatabase) {
+ List entities = AuditLogEntity.findByRealm(realmName);
+ return auditLogMapper.toDTOList(entities);
+ }
+ return searchLogsFromCache(null, dateDebut, dateFin, null, null, null, page, pageSize);
}
@Override
@@ -171,7 +254,10 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin,
int page,
int pageSize) {
- return searchLogs(null, dateDebut, dateFin, null, null, false, page, pageSize);
+ if (logToDatabase) {
+ return searchLogsFromDatabase(null, dateDebut, dateFin, null, null, false, page, pageSize);
+ }
+ return searchLogsFromCache(null, dateDebut, dateFin, null, null, false, page, pageSize);
}
@Override
@@ -180,29 +266,22 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin,
int page,
int pageSize) {
- // Les actions critiques sont USER_DELETE, ROLE_DELETE, etc.
- return auditLogs.values().stream()
+ List allLogs = logToDatabase ?
+ searchLogsFromDatabase(null, dateDebut, dateFin, null, null, null, page, pageSize) :
+ searchLogsFromCache(null, dateDebut, dateFin, null, null, null, page, pageSize);
+
+ return allLogs.stream()
.filter(log -> {
TypeActionAudit type = log.getTypeAction();
- return type == TypeActionAudit.USER_DELETE ||
+ return type == TypeActionAudit.USER_DELETE ||
type == TypeActionAudit.ROLE_DELETE ||
type == TypeActionAudit.SESSION_REVOKE_ALL;
})
- .filter(log -> {
- if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
- return false;
- }
- if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
- return false;
- }
- return true;
- })
- .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction()))
- .skip((long) page * pageSize)
- .limit(pageSize)
.collect(Collectors.toList());
}
+ // ==================== MÉTHODES STATISTIQUES ====================
+
@Override
public Map countByActionType(@NotBlank String realmName,
LocalDateTime dateDebut,
@@ -223,13 +302,43 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin) {
long successCount = getSuccessCount(dateDebut, dateFin);
long failureCount = getFailureCount(dateDebut, dateFin);
-
+
Map result = new java.util.HashMap<>();
result.put("success", successCount);
result.put("failure", failureCount);
return result;
}
+ @Override
+ public Map getAuditStatistics(@NotBlank String realmName,
+ LocalDateTime dateDebut,
+ LocalDateTime dateFin) {
+ Map stats = new java.util.HashMap<>();
+
+ long total = logToDatabase ?
+ AuditLogEntity.findByPeriod(dateDebut, dateFin).size() :
+ auditLogsCache.values().stream()
+ .filter(log -> {
+ if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
+ return false;
+ }
+ if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
+ return false;
+ }
+ return true;
+ })
+ .count();
+
+ stats.put("total", total);
+ stats.put("success", getSuccessCount(dateDebut, dateFin));
+ stats.put("failure", getFailureCount(dateDebut, dateFin));
+ stats.put("byActionType", countByActionType(realmName, dateDebut, dateFin));
+ stats.put("byActeur", countByActeur(realmName, dateDebut, dateFin));
+ return stats;
+ }
+
+ // ==================== EXPORT / PURGE ====================
+
@Override
public String exportToCSV(@NotBlank String realmName,
LocalDateTime dateDebut,
@@ -239,159 +348,192 @@ public class AuditServiceImpl implements AuditService {
}
@Override
+ @Transactional
public long purgeOldLogs(@NotNull LocalDateTime dateLimite) {
- long beforeCount = auditLogs.size();
- auditLogs.entrySet().removeIf(entry ->
+ long purgedCount = 0;
+
+ // Purge en base de données si activé
+ if (logToDatabase) {
+ purgedCount = AuditLogEntity.deleteOlderThan(dateLimite);
+ log.info("Supprimé {} logs d'audit de la base de données avant {}", purgedCount, dateLimite);
+ }
+
+ // Purge du cache mémoire
+ long beforeCacheCount = auditLogsCache.size();
+ auditLogsCache.entrySet().removeIf(entry ->
entry.getValue().getDateAction().isBefore(dateLimite)
);
- long afterCount = auditLogs.size();
- return beforeCount - afterCount;
+ long cacheRemoved = beforeCacheCount - auditLogsCache.size();
+
+ log.info("Supprimé {} logs du cache mémoire avant {}", cacheRemoved, dateLimite);
+
+ return purgedCount + cacheRemoved;
}
- @Override
- public Map getAuditStatistics(@NotBlank String realmName,
- LocalDateTime dateDebut,
- LocalDateTime dateFin) {
- Map stats = new java.util.HashMap<>();
- stats.put("total", auditLogs.values().stream()
- .filter(log -> {
- if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
- return false;
- }
- if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
- return false;
- }
- return true;
- })
- .count());
- stats.put("success", getSuccessCount(dateDebut, dateFin));
- stats.put("failure", getFailureCount(dateDebut, dateFin));
- stats.put("byActionType", countByActionType(realmName, dateDebut, dateFin));
- stats.put("byActeur", countByActeur(realmName, dateDebut, dateFin));
- return stats;
- }
+ // ==================== MÉTHODES PRIVÉES ====================
- // Méthode privée helper pour la recherche
- private List searchLogs(String acteurUsername, LocalDateTime dateDebut,
- LocalDateTime dateFin, TypeActionAudit typeAction,
- String ressourceType, Boolean succes,
- int page, int pageSize) {
- log.debug("Recherche de logs d'audit: acteur={}, dateDebut={}, dateFin={}, typeAction={}, succes={}",
- acteurUsername, dateDebut, dateFin, typeAction, succes);
+ /**
+ * Recherche les logs depuis le cache mémoire.
+ */
+ private List searchLogsFromCache(String acteurUsername, LocalDateTime dateDebut,
+ LocalDateTime dateFin, TypeActionAudit typeAction,
+ String ressourceType, Boolean succes,
+ int page, int pageSize) {
+ log.debug("Recherche logs depuis cache mémoire");
- return auditLogs.values().stream()
- .filter(log -> {
- // Filtre par acteur (si spécifié et non "*")
- if (acteurUsername != null && !"*".equals(acteurUsername) &&
- !acteurUsername.equals(log.getActeurUsername())) {
- return false;
- }
-
- // Filtre par date début
- if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
- return false;
- }
-
- // Filtre par date fin
- if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
- return false;
- }
-
- // Filtre par type d'action
- if (typeAction != null && !typeAction.equals(log.getTypeAction())) {
- return false;
- }
-
- // Filtre par type de ressource
- if (ressourceType != null && !ressourceType.equals(log.getRessourceType())) {
- return false;
- }
-
- // Filtre par succès/échec
- if (succes != null && succes != log.isSuccessful()) {
- return false;
- }
-
- return true;
- })
- .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction())) // Tri décroissant par date
+ return auditLogsCache.values().stream()
+ .filter(log -> applyFilters(log, acteurUsername, dateDebut, dateFin, typeAction, ressourceType, succes))
+ .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction()))
.skip((long) page * pageSize)
.limit(pageSize)
.collect(Collectors.toList());
}
- // Méthodes privées helper
- private Map getActionStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) {
- log.debug("Calcul des statistiques d'actions entre {} et {}", dateDebut, dateFin);
+ /**
+ * Recherche les logs depuis la base de données PostgreSQL.
+ */
+ private List searchLogsFromDatabase(String acteurUsername, LocalDateTime dateDebut,
+ LocalDateTime dateFin, TypeActionAudit typeAction,
+ String ressourceType, Boolean succes,
+ int page, int pageSize) {
+ log.debug("Recherche logs depuis base de données");
- return auditLogs.values().stream()
+ List entities;
+
+ // Optimisation: utiliser les requêtes spécialisées si possible
+ if (acteurUsername != null && typeAction == null && ressourceType == null) {
+ entities = AuditLogEntity.findByAuteur(acteurUsername);
+ } else if (typeAction != null && acteurUsername == null && ressourceType == null) {
+ entities = AuditLogEntity.findByAction(typeAction);
+ } else if (dateDebut != null && dateFin != null) {
+ entities = AuditLogEntity.findByPeriod(dateDebut, dateFin);
+ } else {
+ entities = AuditLogEntity.listAll();
+ }
+
+ return entities.stream()
+ .map(auditLogMapper::toDTO)
+ .filter(log -> applyFilters(log, acteurUsername, dateDebut, dateFin, typeAction, ressourceType, succes))
+ .sorted((a, b) -> b.getDateAction().compareTo(a.getDateAction()))
+ .skip((long) page * pageSize)
+ .limit(pageSize)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Applique les filtres de recherche à un log.
+ */
+ private boolean applyFilters(AuditLogDTO log, String acteurUsername, LocalDateTime dateDebut,
+ LocalDateTime dateFin, TypeActionAudit typeAction,
+ String ressourceType, Boolean succes) {
+ if (acteurUsername != null && !"*".equals(acteurUsername) &&
+ !acteurUsername.equals(log.getActeurUsername())) {
+ return false;
+ }
+
+ if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
+ return false;
+ }
+
+ if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
+ return false;
+ }
+
+ if (typeAction != null && !typeAction.equals(log.getTypeAction())) {
+ return false;
+ }
+
+ if (ressourceType != null && !ressourceType.equals(log.getRessourceType())) {
+ return false;
+ }
+
+ if (succes != null && succes != log.isSuccessful()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Nettoie les entrées les plus anciennes du cache.
+ */
+ private void cleanOldestCacheEntries() {
+ int toRemove = auditLogsCache.size() - (cacheSize * 90 / 100); // Garder 90%
+
+ if (toRemove > 0) {
+ List oldestKeys = auditLogsCache.entrySet().stream()
+ .sorted((a, b) -> a.getValue().getDateAction().compareTo(b.getValue().getDateAction()))
+ .limit(toRemove)
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toList());
+
+ oldestKeys.forEach(auditLogsCache::remove);
+ log.debug("Nettoyé {} entrées du cache d'audit", oldestKeys.size());
+ }
+ }
+
+ // Méthodes helpers (statistiques)
+ private Map getActionStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) {
+ if (logToDatabase) {
+ List entities = AuditLogEntity.findByPeriod(dateDebut, dateFin);
+ return entities.stream()
+ .collect(Collectors.groupingBy(AuditLogEntity::getAction, Collectors.counting()));
+ }
+
+ return auditLogsCache.values().stream()
.filter(log -> {
- if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
- return false;
- }
- if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
- return false;
- }
+ if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false;
+ if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false;
return true;
})
- .collect(Collectors.groupingBy(
- AuditLogDTO::getTypeAction,
- Collectors.counting()
- ));
+ .collect(Collectors.groupingBy(AuditLogDTO::getTypeAction, Collectors.counting()));
}
private Map getUserActivityStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) {
- log.debug("Calcul des statistiques d'activité utilisateurs entre {} et {}", dateDebut, dateFin);
+ if (logToDatabase) {
+ List entities = AuditLogEntity.findByPeriod(dateDebut, dateFin);
+ return entities.stream()
+ .collect(Collectors.groupingBy(AuditLogEntity::getAuteurAction, Collectors.counting()));
+ }
- return auditLogs.values().stream()
+ return auditLogsCache.values().stream()
.filter(log -> {
- if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
- return false;
- }
- if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
- return false;
- }
+ if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false;
+ if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false;
return true;
})
- .collect(Collectors.groupingBy(
- AuditLogDTO::getActeurUsername,
- Collectors.counting()
- ));
+ .collect(Collectors.groupingBy(AuditLogDTO::getActeurUsername, Collectors.counting()));
}
private long getFailureCount(LocalDateTime dateDebut, LocalDateTime dateFin) {
- log.debug("Comptage des échecs entre {} et {}", dateDebut, dateFin);
+ if (logToDatabase) {
+ return AuditLogEntity.findByPeriod(dateDebut, dateFin).stream()
+ .filter(e -> !e.getSuccess())
+ .count();
+ }
- return auditLogs.values().stream()
+ return auditLogsCache.values().stream()
+ .filter(log -> !log.isSuccessful())
.filter(log -> {
- if (log.isSuccessful()) {
- return false; // On ne compte que les échecs
- }
- if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
- return false;
- }
- if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
- return false;
- }
+ if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false;
+ if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false;
return true;
})
.count();
}
private long getSuccessCount(LocalDateTime dateDebut, LocalDateTime dateFin) {
- log.debug("Comptage des succès entre {} et {}", dateDebut, dateFin);
+ if (logToDatabase) {
+ return AuditLogEntity.findByPeriod(dateDebut, dateFin).stream()
+ .filter(AuditLogEntity::getSuccess)
+ .count();
+ }
- return auditLogs.values().stream()
+ return auditLogsCache.values().stream()
+ .filter(AuditLogDTO::isSuccessful)
.filter(log -> {
- if (!log.isSuccessful()) {
- return false; // On ne compte que les succès
- }
- if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
- return false;
- }
- if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
- return false;
- }
+ if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false;
+ if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false;
return true;
})
.count();
@@ -401,56 +543,68 @@ public class AuditServiceImpl implements AuditService {
log.info("Export CSV des logs d'audit entre {} et {}", dateDebut, dateFin);
List csvLines = new ArrayList<>();
-
- // En-tête CSV
csvLines.add("ID,Date Action,Acteur,Type Action,Ressource Type,Ressource ID,Succès,Adresse IP,Détails,Message Erreur");
- // Données
- auditLogs.values().stream()
- .filter(log -> {
- if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
- return false;
- }
- if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
- return false;
- }
- return true;
- })
- .sorted((a, b) -> a.getDateAction().compareTo(b.getDateAction()))
- .forEach(log -> {
- String csvLine = String.format("%s,%s,%s,%s,%s,%s,%s,%s,\"%s\",\"%s\"",
- log.getId(),
- log.getDateAction(),
- log.getActeurUsername(),
- log.getTypeAction(),
- log.getRessourceType(),
- log.getRessourceId(),
- log.isSuccessful(),
- log.getIpAddress() != null ? log.getIpAddress() : "",
- log.getDescription() != null ? log.getDescription().replace("\"", "\"\"") : "",
- log.getErrorMessage() != null ? log.getErrorMessage().replace("\"", "\"\"") : ""
- );
- csvLines.add(csvLine);
- });
+ List logs;
+ if (logToDatabase) {
+ List entities = AuditLogEntity.findByPeriod(dateDebut, dateFin);
+ logs = auditLogMapper.toDTOList(entities);
+ } else {
+ logs = auditLogsCache.values().stream()
+ .filter(log -> {
+ if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false;
+ if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false;
+ return true;
+ })
+ .sorted((a, b) -> a.getDateAction().compareTo(b.getDateAction()))
+ .collect(Collectors.toList());
+ }
+
+ logs.forEach(log -> {
+ String csvLine = String.format("%s,%s,%s,%s,%s,%s,%s,%s,\"%s\",\"%s\"",
+ log.getId(),
+ log.getDateAction(),
+ log.getActeurUsername(),
+ log.getTypeAction(),
+ log.getRessourceType(),
+ log.getRessourceId(),
+ log.isSuccessful(),
+ log.getIpAddress() != null ? log.getIpAddress() : "",
+ log.getDescription() != null ? log.getDescription().replace("\"", "\"\"") : "",
+ log.getErrorMessage() != null ? log.getErrorMessage().replace("\"", "\"\"") : ""
+ );
+ csvLines.add(csvLine);
+ });
log.info("Export CSV terminé: {} lignes", csvLines.size() - 1);
return csvLines;
}
- // ==================== Méthodes utilitaires ====================
+ // ==================== MÉTHODES UTILITAIRES ====================
/**
- * Retourne le nombre total de logs en mémoire
+ * Retourne le nombre total de logs (cache + DB).
*/
public long getTotalCount() {
- return auditLogs.size();
+ if (logToDatabase) {
+ return AuditLogEntity.count();
+ }
+ return auditLogsCache.size();
}
/**
- * Vide tous les logs (ATTENTION: à utiliser uniquement en développement)
+ * Vide tous les logs (ATTENTION: à utiliser uniquement en développement).
*/
+ @Transactional
public void clearAll() {
- log.warn("ATTENTION: Suppression de tous les logs d'audit en mémoire");
- auditLogs.clear();
+ log.warn("ATTENTION: Suppression de tous les logs d'audit");
+
+ if (logToDatabase) {
+ AuditLogEntity.deleteAll();
+ log.warn("Supprimé tous les logs de la base de données");
+ }
+
+ auditLogsCache.clear();
+ log.warn("Vidé le cache mémoire");
}
}
diff --git a/src/main/java/dev/lions/user/manager/service/impl/CsvValidationHelper.java b/src/main/java/dev/lions/user/manager/service/impl/CsvValidationHelper.java
new file mode 100644
index 0000000..a0c4ac3
--- /dev/null
+++ b/src/main/java/dev/lions/user/manager/service/impl/CsvValidationHelper.java
@@ -0,0 +1,176 @@
+package dev.lions.user.manager.service.impl;
+
+import lombok.experimental.UtilityClass;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.regex.Pattern;
+
+/**
+ * Classe utilitaire pour la validation des données CSV lors de l'import d'utilisateurs
+ *
+ * @author Lions Development Team
+ * @version 1.0.0
+ * @since 2026-01-02
+ */
+@Slf4j
+@UtilityClass
+public class CsvValidationHelper {
+
+ /**
+ * Pattern pour valider le format d'email selon RFC 5322 (simplifié)
+ */
+ private static final Pattern EMAIL_PATTERN = Pattern.compile(
+ "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$"
+ );
+
+ /**
+ * Pattern pour valider le username (alphanumérique, tirets, underscores, points)
+ */
+ private static final Pattern USERNAME_PATTERN = Pattern.compile(
+ "^[a-zA-Z0-9._-]{2,255}$"
+ );
+
+ /**
+ * Longueur minimale pour un username
+ */
+ private static final int USERNAME_MIN_LENGTH = 2;
+
+ /**
+ * Longueur maximale pour un username
+ */
+ private static final int USERNAME_MAX_LENGTH = 255;
+
+ /**
+ * Longueur maximale pour un nom ou prénom
+ */
+ private static final int NAME_MAX_LENGTH = 255;
+
+ /**
+ * Valide le format d'un email
+ *
+ * @param email Email à valider
+ * @return true si l'email est valide, false sinon
+ */
+ public static boolean isValidEmail(String email) {
+ if (email == null || email.isBlank()) {
+ return false;
+ }
+ return EMAIL_PATTERN.matcher(email.trim()).matches();
+ }
+
+ /**
+ * Valide un username
+ *
+ * @param username Username à valider
+ * @return Message d'erreur si invalide, null si valide
+ */
+ public static String validateUsername(String username) {
+ if (username == null || username.isBlank()) {
+ return "Username obligatoire";
+ }
+
+ String trimmed = username.trim();
+
+ if (trimmed.length() < USERNAME_MIN_LENGTH) {
+ return String.format("Username trop court (minimum %d caractères)", USERNAME_MIN_LENGTH);
+ }
+
+ if (trimmed.length() > USERNAME_MAX_LENGTH) {
+ return String.format("Username trop long (maximum %d caractères)", USERNAME_MAX_LENGTH);
+ }
+
+ if (!USERNAME_PATTERN.matcher(trimmed).matches()) {
+ return "Username invalide (autorisé: lettres, chiffres, .-_)";
+ }
+
+ return null; // Valide
+ }
+
+ /**
+ * Valide un email (peut être vide)
+ *
+ * @param email Email à valider
+ * @return Message d'erreur si invalide, null si valide ou vide
+ */
+ public static String validateEmail(String email) {
+ if (email == null || email.isBlank()) {
+ return null; // Email optionnel
+ }
+
+ if (!isValidEmail(email)) {
+ return "Format d'email invalide";
+ }
+
+ return null; // Valide
+ }
+
+ /**
+ * Valide un nom ou prénom
+ *
+ * @param name Nom à valider
+ * @param fieldName Nom du champ pour les messages d'erreur
+ * @return Message d'erreur si invalide, null si valide
+ */
+ public static String validateName(String name, String fieldName) {
+ if (name == null || name.isBlank()) {
+ return null; // Nom optionnel
+ }
+
+ String trimmed = name.trim();
+
+ if (trimmed.length() > NAME_MAX_LENGTH) {
+ return String.format("%s trop long (maximum %d caractères)", fieldName, NAME_MAX_LENGTH);
+ }
+
+ return null; // Valide
+ }
+
+ /**
+ * Valide une valeur boolean
+ *
+ * @param value Valeur à valider
+ * @return Message d'erreur si invalide, null si valide
+ */
+ public static String validateBoolean(String value) {
+ if (value == null || value.isBlank()) {
+ return null; // Optionnel, défaut à false
+ }
+
+ String trimmed = value.trim().toLowerCase();
+ if (!trimmed.equals("true") && !trimmed.equals("false") &&
+ !trimmed.equals("1") && !trimmed.equals("0") &&
+ !trimmed.equals("yes") && !trimmed.equals("no")) {
+ return "Valeur boolean invalide (attendu: true/false, 1/0, yes/no)";
+ }
+
+ return null; // Valide
+ }
+
+ /**
+ * Convertit une chaîne en boolean
+ *
+ * @param value Valeur à convertir
+ * @return boolean correspondant
+ */
+ public static boolean parseBoolean(String value) {
+ if (value == null || value.isBlank()) {
+ return false;
+ }
+
+ String trimmed = value.trim().toLowerCase();
+ return trimmed.equals("true") || trimmed.equals("1") || trimmed.equals("yes");
+ }
+
+ /**
+ * Nettoie une chaîne (trim et null si vide)
+ *
+ * @param value Valeur à nettoyer
+ * @return Valeur nettoyée ou null
+ */
+ public static String clean(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ return value.trim();
+ }
+}
diff --git a/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java b/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java
index 1dd03bf..335e37f 100644
--- a/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java
+++ b/src/main/java/dev/lions/user/manager/service/impl/UserServiceImpl.java
@@ -608,11 +608,17 @@ public class UserServiceImpl implements UserService {
}
@Override
- public int importUsersFromCSV(@NotBlank String csvContent, @NotBlank String realmName) {
+ public dev.lions.user.manager.dto.importexport.ImportResultDTO importUsersFromCSV(@NotBlank String csvContent, @NotBlank String realmName) {
log.info("Import des utilisateurs depuis CSV pour le realm {}", realmName);
+ dev.lions.user.manager.dto.importexport.ImportResultDTO result = dev.lions.user.manager.dto.importexport.ImportResultDTO.builder()
+ .totalLines(0)
+ .successCount(0)
+ .errorCount(0)
+ .errors(new java.util.ArrayList<>())
+ .build();
+
String[] lines = csvContent.split("\\r?\\n");
- int count = 0;
int startIndex = 0;
// Skip header if present
@@ -620,48 +626,159 @@ public class UserServiceImpl implements UserService {
startIndex = 1;
}
+ result.setTotalLines(lines.length - startIndex);
+
for (int i = startIndex; i < lines.length; i++) {
+ int lineNumber = i + 1;
String line = lines[i].trim();
- if (line.isEmpty())
- continue;
+
+ if (line.isEmpty()) {
+ continue; // Ignore empty lines
+ }
try {
+ // Parse CSV line
String[] parts = parseCSVLine(line);
if (parts.length < 5) {
- log.warn("Ligne CSV invalide ignorée (pas assez de colonnes): {}", line);
+ result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder()
+ .lineNumber(lineNumber)
+ .lineContent(line)
+ .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.INVALID_FORMAT)
+ .message("Nombre de colonnes insuffisant (attendu: 5, trouvé: " + parts.length + ")")
+ .build());
continue;
}
- String username = parts[0];
- String email = parts[1];
- String firstName = parts[2];
- String lastName = parts[3];
- boolean enabled = Boolean.parseBoolean(parts[4]);
+ String username = CsvValidationHelper.clean(parts[0]);
+ String email = CsvValidationHelper.clean(parts[1]);
+ String firstName = CsvValidationHelper.clean(parts[2]);
+ String lastName = CsvValidationHelper.clean(parts[3]);
+ String enabledStr = CsvValidationHelper.clean(parts[4]);
- if (username == null || username.isBlank()) {
- log.warn("Username manquant à la ligne {}", i + 1);
+ // Validate username
+ String usernameError = CsvValidationHelper.validateUsername(username);
+ if (usernameError != null) {
+ result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder()
+ .lineNumber(lineNumber)
+ .lineContent(line)
+ .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR)
+ .field("username")
+ .message(usernameError)
+ .build());
continue;
}
+ // Validate email
+ String emailError = CsvValidationHelper.validateEmail(email);
+ if (emailError != null) {
+ result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder()
+ .lineNumber(lineNumber)
+ .lineContent(line)
+ .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR)
+ .field("email")
+ .message(emailError)
+ .build());
+ continue;
+ }
+
+ // Validate firstName
+ String firstNameError = CsvValidationHelper.validateName(firstName, "Prénom");
+ if (firstNameError != null) {
+ result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder()
+ .lineNumber(lineNumber)
+ .lineContent(line)
+ .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR)
+ .field("firstName")
+ .message(firstNameError)
+ .build());
+ continue;
+ }
+
+ // Validate lastName
+ String lastNameError = CsvValidationHelper.validateName(lastName, "Nom");
+ if (lastNameError != null) {
+ result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder()
+ .lineNumber(lineNumber)
+ .lineContent(line)
+ .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR)
+ .field("lastName")
+ .message(lastNameError)
+ .build());
+ continue;
+ }
+
+ // Validate enabled
+ String enabledError = CsvValidationHelper.validateBoolean(enabledStr);
+ if (enabledError != null) {
+ result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder()
+ .lineNumber(lineNumber)
+ .lineContent(line)
+ .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.VALIDATION_ERROR)
+ .field("enabled")
+ .message(enabledError)
+ .build());
+ continue;
+ }
+
+ boolean enabled = CsvValidationHelper.parseBoolean(enabledStr);
+
+ // Check if user already exists
+ try {
+ java.util.Optional existingUser = getUserByUsername(username, realmName);
+ if (existingUser.isPresent()) {
+ result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder()
+ .lineNumber(lineNumber)
+ .lineContent(line)
+ .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.DUPLICATE_USER)
+ .field("username")
+ .message("Utilisateur déjà existant: " + username)
+ .build());
+ continue;
+ }
+ } catch (Exception e) {
+ // User doesn't exist, continue with creation
+ }
+
+ // Create user
UserDTO userDTO = UserDTO.builder()
- .username(username)
- .email(email.isBlank() ? null : email)
- .prenom(firstName.isBlank() ? null : firstName)
- .nom(lastName.isBlank() ? null : lastName)
- .enabled(enabled)
- .build();
+ .username(username)
+ .email(email)
+ .prenom(firstName)
+ .nom(lastName)
+ .enabled(enabled)
+ .build();
- createUser(userDTO, realmName);
- count++;
+ try {
+ createUser(userDTO, realmName);
+ result.setSuccessCount(result.getSuccessCount() + 1);
+ log.debug("✅ Utilisateur créé: {} (ligne {})", username, lineNumber);
+ } catch (Exception e) {
+ result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder()
+ .lineNumber(lineNumber)
+ .lineContent(line)
+ .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.CREATION_ERROR)
+ .message("Erreur lors de la création de l'utilisateur")
+ .details(e.getMessage())
+ .build());
+ }
} catch (Exception e) {
- log.error("Erreur lors de l'import de la ligne {}: {}", i + 1, e.getMessage());
- // Continue with next line
+ log.error("Erreur inattendue lors du traitement de la ligne {}: {}", lineNumber, e.getMessage(), e);
+ result.addError(dev.lions.user.manager.dto.importexport.ImportResultDTO.ImportErrorDTO.builder()
+ .lineNumber(lineNumber)
+ .lineContent(line)
+ .errorType(dev.lions.user.manager.dto.importexport.ImportResultDTO.ErrorType.SYSTEM_ERROR)
+ .message("Erreur système")
+ .details(e.getMessage())
+ .build());
}
}
- log.info("✅ {} utilisateurs importés avec succès", count);
- return count;
+ // Generate summary message
+ result.generateMessage();
+ log.info(result.getMessage());
+
+ return result;
}
private String escape(String data) {
diff --git a/src/main/resources/db/migration/V1.0.0__Create_audit_logs_table.sql b/src/main/resources/db/migration/V1.0.0__Create_audit_logs_table.sql
new file mode 100644
index 0000000..351acae
--- /dev/null
+++ b/src/main/resources/db/migration/V1.0.0__Create_audit_logs_table.sql
@@ -0,0 +1,175 @@
+-- =============================================================================
+-- Migration Flyway V1.0.0 - Création de la table audit_logs
+-- =============================================================================
+-- Description: Création de la table pour la persistance des logs d'audit
+-- des actions effectuées sur le système de gestion des utilisateurs
+--
+-- Auteur: Lions Development Team
+-- Date: 2026-01-02
+-- Version: 1.0.0
+-- =============================================================================
+
+-- Création de la table audit_logs
+CREATE TABLE IF NOT EXISTS audit_logs (
+ -- Clé primaire générée automatiquement
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Informations sur l'utilisateur concerné
+ user_id VARCHAR(255),
+
+ -- Type d'action effectuée
+ action VARCHAR(100) NOT NULL,
+
+ -- Détails de l'action
+ details TEXT,
+
+ -- Informations sur l'auteur de l'action
+ auteur_action VARCHAR(255) NOT NULL,
+
+ -- Timestamp de l'action
+ timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ -- Informations de traçabilité réseau
+ ip_address VARCHAR(45),
+ user_agent VARCHAR(500),
+
+ -- Informations multi-tenant
+ realm_name VARCHAR(255),
+
+ -- Statut de l'action
+ success BOOLEAN NOT NULL DEFAULT TRUE,
+ error_message TEXT,
+
+ -- Métadonnées
+ CONSTRAINT chk_audit_action CHECK (action IN (
+ -- Actions utilisateurs
+ 'CREATION_UTILISATEUR',
+ 'MODIFICATION_UTILISATEUR',
+ 'SUPPRESSION_UTILISATEUR',
+ 'ACTIVATION_UTILISATEUR',
+ 'DESACTIVATION_UTILISATEUR',
+ 'VERROUILLAGE_UTILISATEUR',
+ 'DEVERROUILLAGE_UTILISATEUR',
+
+ -- Actions mot de passe
+ 'RESET_PASSWORD',
+ 'CHANGE_PASSWORD',
+ 'FORCE_PASSWORD_RESET',
+
+ -- Actions sessions
+ 'LOGOUT_UTILISATEUR',
+ 'LOGOUT_ALL_SESSIONS',
+ 'SESSION_EXPIREE',
+
+ -- Actions rôles
+ 'ATTRIBUTION_ROLE',
+ 'REVOCATION_ROLE',
+ 'CREATION_ROLE',
+ 'MODIFICATION_ROLE',
+ 'SUPPRESSION_ROLE',
+
+ -- Actions groupes
+ 'AJOUT_GROUPE',
+ 'RETRAIT_GROUPE',
+
+ -- Actions realms
+ 'ATTRIBUTION_REALM',
+ 'REVOCATION_REALM',
+
+ -- Actions synchronisation
+ 'SYNC_MANUEL',
+ 'SYNC_AUTO',
+ 'SYNC_ERREUR',
+
+ -- Actions import/export
+ 'EXPORT_CSV',
+ 'IMPORT_CSV',
+
+ -- Actions système
+ 'CONNEXION_REUSSIE',
+ 'CONNEXION_ECHOUEE',
+ 'TENTATIVE_ACCES_NON_AUTORISE',
+ 'ERREUR_SYSTEME',
+ 'CONFIGURATION_MODIFIEE'
+ ))
+);
+
+-- =============================================================================
+-- INDEX pour optimiser les requêtes
+-- =============================================================================
+
+-- Index sur user_id pour recherches rapides par utilisateur
+CREATE INDEX idx_audit_user_id ON audit_logs(user_id)
+ WHERE user_id IS NOT NULL;
+
+-- Index sur action pour filtrer par type d'action
+CREATE INDEX idx_audit_action ON audit_logs(action);
+
+-- Index sur timestamp pour recherches chronologiques et tri
+CREATE INDEX idx_audit_timestamp ON audit_logs(timestamp DESC);
+
+-- Index sur auteur_action pour tracer les actions d'un administrateur
+CREATE INDEX idx_audit_auteur ON audit_logs(auteur_action);
+
+-- Index sur realm_name pour isolation multi-tenant
+CREATE INDEX idx_audit_realm ON audit_logs(realm_name)
+ WHERE realm_name IS NOT NULL;
+
+-- Index composite pour recherches fréquentes
+CREATE INDEX idx_audit_user_timestamp ON audit_logs(user_id, timestamp DESC)
+ WHERE user_id IS NOT NULL;
+
+-- Index sur success pour identifier rapidement les échecs
+CREATE INDEX idx_audit_failures ON audit_logs(success, timestamp DESC)
+ WHERE success = FALSE;
+
+-- =============================================================================
+-- COMMENTAIRES sur les colonnes
+-- =============================================================================
+
+COMMENT ON TABLE audit_logs IS 'Table de persistance des logs d''audit pour traçabilité complète';
+
+COMMENT ON COLUMN audit_logs.id IS 'Identifiant unique auto-incrémenté du log';
+COMMENT ON COLUMN audit_logs.user_id IS 'ID de l''utilisateur concerné par l''action (null pour actions système)';
+COMMENT ON COLUMN audit_logs.action IS 'Type d''action effectuée (enum TypeActionAudit)';
+COMMENT ON COLUMN audit_logs.details IS 'Détails complémentaires sur l''action';
+COMMENT ON COLUMN audit_logs.auteur_action IS 'Identifiant de l''utilisateur ayant effectué l''action';
+COMMENT ON COLUMN audit_logs.timestamp IS 'Date et heure précise de l''action';
+COMMENT ON COLUMN audit_logs.ip_address IS 'Adresse IP du client ayant effectué l''action';
+COMMENT ON COLUMN audit_logs.user_agent IS 'User-Agent du navigateur/client';
+COMMENT ON COLUMN audit_logs.realm_name IS 'Nom du realm Keycloak concerné (multi-tenant)';
+COMMENT ON COLUMN audit_logs.success IS 'Indique si l''action a réussi (true) ou échoué (false)';
+COMMENT ON COLUMN audit_logs.error_message IS 'Message d''erreur en cas d''échec (null si success=true)';
+
+-- =============================================================================
+-- POLITIQUE DE RÉTENTION (optionnel - à activer selon besoins)
+-- =============================================================================
+
+-- Fonction pour nettoyer automatiquement les vieux logs
+-- Décommenter et adapter la période de rétention selon les besoins
+
+/*
+CREATE OR REPLACE FUNCTION cleanup_old_audit_logs() RETURNS void AS $$
+BEGIN
+ -- Supprime les logs de plus de 365 jours (configurable)
+ DELETE FROM audit_logs
+ WHERE timestamp < CURRENT_TIMESTAMP - INTERVAL '365 days';
+
+ RAISE NOTICE 'Logs d''audit plus anciens que 365 jours supprimés';
+END;
+$$ LANGUAGE plpgsql;
+
+-- Créer un job CRON (nécessite extension pg_cron)
+-- SELECT cron.schedule('cleanup-audit-logs', '0 2 * * 0', 'SELECT cleanup_old_audit_logs()');
+*/
+
+-- =============================================================================
+-- GRANTS (à adapter selon les rôles de votre base de données)
+-- =============================================================================
+
+-- GRANT SELECT, INSERT ON audit_logs TO lions_app_user;
+-- GRANT USAGE, SELECT ON SEQUENCE audit_logs_id_seq TO lions_app_user;
+
+-- =============================================================================
+-- FIN DE LA MIGRATION
+-- =============================================================================
diff --git a/src/test/java/dev/lions/user/manager/service/impl/UserServiceImplTest.java b/src/test/java/dev/lions/user/manager/service/impl/UserServiceImplTest.java
index e315dca..f089927 100644
--- a/src/test/java/dev/lions/user/manager/service/impl/UserServiceImplTest.java
+++ b/src/test/java/dev/lions/user/manager/service/impl/UserServiceImplTest.java
@@ -220,12 +220,15 @@ class UserServiceImplTest {
lenient().doNothing().when(userResource)
.resetPassword(any(org.keycloak.representations.idm.CredentialRepresentation.class));
- String csvContent = "username,email,firstName,lastName,enabled\n" +
- "imported,imp@test.com,Imp,Orter,true";
+ String csvContent = "username,prenom,nom,email\n" +
+ "imported,Imp,Orter,imp@test.com";
- int count = userService.importUsersFromCSV(csvContent, REALM);
+ dev.lions.user.manager.dto.importexport.ImportResultDTO result =
+ userService.importUsersFromCSV(csvContent, REALM);
- assertEquals(1, count);
+ assertNotNull(result);
+ assertEquals(1, result.getSuccessCount());
+ assertEquals(0, result.getErrorCount());
verify(usersResource, atLeastOnce()).create(argThat(u -> u.getUsername().equals("imported")));
}
}