feat: Optimisations UX/UI et amélioration import/export CSV

Optimisations majeures de l'interface utilisateur et amélioration du système d'import/export CSV avec rapport d'erreurs détaillé.

## Optimisations UX/UI
- Suppression des blocs Actions Rapides redondants dans les pages list/view
- Consolidation des actions dans les en-têtes de page
- Conversion des filtres en panneau collapsible avec badge Filtres actifs
- Suppression du sous-menu Attribution Rôles (redondant avec /users/edit)
- Amélioration de la navigation et de l'ergonomie générale
- Correction des attributs iconLeft non supportés par fr:fieldInput

## Import/Export CSV
- Ajout de ImportResultDTO avec rapport détaillé des erreurs
- Création de CsvValidationHelper pour validation robuste des données
- Amélioration des messages d'erreur avec numéros de ligne
- Support de colonnes flexibles (username,prenom,nom,email)
- Validation stricte des formats email

## Corrections techniques
- Fix DashboardBeanTest: getRecentActions() → getActionsLast24h()
- Fix UserServiceImplTest: retour ImportResultDTO au lieu de int
- Amélioration de la gestion d'erreurs dans AuditServiceImpl
- Migration Flyway V1.0.0 pour la table audit_logs

## Infrastructure
- Mise à jour .gitignore professionnel (exclusion docs de session)
- Configuration production sécurisée (variables d'environnement)
- Pas de secrets hardcodés dans les fichiers de configuration

Testé et validé en environnement de développement.
This commit is contained in:
lionsdev
2026-01-03 13:53:35 +00:00
parent 2bc1b0f6a5
commit 3773fac0b0
6 changed files with 926 additions and 220 deletions

View File

@@ -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.responses.APIResponses;
import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import java.time.LocalDateTime;
import java.util.List; 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 ==================== // ==================== DTOs internes ====================
@Schema(description = "Requête de réinitialisation de mot de passe") @Schema(description = "Requête de réinitialisation de mot de passe")

View File

@@ -2,8 +2,12 @@ package dev.lions.user.manager.service.impl;
import dev.lions.user.manager.dto.audit.AuditLogDTO; import dev.lions.user.manager.dto.audit.AuditLogDTO;
import dev.lions.user.manager.enums.audit.TypeActionAudit; 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 dev.lions.user.manager.service.AuditService;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@@ -19,19 +23,49 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* Implémentation du service d'audit * Implémentation du service d'audit avec support de la persistance PostgreSQL.
* *
* NOTES: * <p><b>Architecture Hybride:</b></p>
* - Cette implémentation utilise un stockage en mémoire pour le développement * <ul>
* - En production, il faudrait utiliser une base de données (PostgreSQL avec Panache) * <li><b>Cache en mémoire</b> - Pour les logs récents (performances)</li>
* - Les logs sont également écrits via SLF4J pour être capturés par les systèmes de logging centralisés * <li><b>Persistance PostgreSQL</b> - Pour l'historique long terme (activable via config)</li>
* </ul>
*
* <p><b>Configuration:</b></p>
* <ul>
* <li>{@code lions.audit.enabled} - Active/désactive l'audit (défaut: true)</li>
* <li>{@code lions.audit.log-to-database} - Active la persistance DB (défaut: false en dev, true en prod)</li>
* <li>{@code lions.audit.cache-size} - Taille max du cache mémoire (défaut: 10000)</li>
* <li>{@code lions.audit.retention-days} - Durée de rétention en jours (défaut: 365)</li>
* </ul>
*
* <p><b>Modes de Fonctionnement:</b></p>
* <pre>
* 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é
* </pre>
*
* @author Lions Development Team
* @version 2.0.0
* @since 2026-01-02
*/ */
@ApplicationScoped @ApplicationScoped
@Slf4j @Slf4j
public class AuditServiceImpl implements AuditService { public class AuditServiceImpl implements AuditService {
// Stockage en mémoire (à remplacer par une DB en production) // ==================== DÉPENDANCES ====================
private final Map<String, AuditLogDTO> auditLogs = new ConcurrentHashMap<>();
@Inject
AuditLogMapper auditLogMapper;
// ==================== CONFIGURATION ====================
@ConfigProperty(name = "lions.audit.enabled", defaultValue = "true") @ConfigProperty(name = "lions.audit.enabled", defaultValue = "true")
boolean auditEnabled; boolean auditEnabled;
@@ -39,7 +73,24 @@ public class AuditServiceImpl implements AuditService {
@ConfigProperty(name = "lions.audit.log-to-database", defaultValue = "false") @ConfigProperty(name = "lions.audit.log-to-database", defaultValue = "false")
boolean logToDatabase; 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.
* <p>Limité à {@code cacheSize} entrées. Les plus anciens sont supprimés automatiquement.</p>
*/
private final Map<String, AuditLogDTO> auditLogsCache = new ConcurrentHashMap<>();
// ==================== MÉTHODES PRINCIPALES ====================
@Override @Override
@Transactional
public AuditLogDTO logAction(@Valid @NotNull AuditLogDTO auditLog) { public AuditLogDTO logAction(@Valid @NotNull AuditLogDTO auditLog) {
if (!auditEnabled) { if (!auditEnabled) {
log.debug("Audit désactivé, log ignoré"); log.debug("Audit désactivé, log ignoré");
@@ -56,7 +107,7 @@ public class AuditServiceImpl implements AuditService {
auditLog.setDateAction(LocalDateTime.now()); 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: {}", log.info("AUDIT | Type: {} | Acteur: {} | Ressource: {} | Succès: {} | IP: {} | Détails: {}",
auditLog.getTypeAction(), auditLog.getTypeAction(),
auditLog.getActeurUsername(), auditLog.getActeurUsername(),
@@ -65,15 +116,30 @@ public class AuditServiceImpl implements AuditService {
auditLog.getIpAddress(), auditLog.getIpAddress(),
auditLog.getDescription()); auditLog.getDescription());
// Stocker en mémoire // Stocker en base de données si activé
auditLogs.put(auditLog.getId(), auditLog); 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 entity.persist();
// Exemple:
// if (logToDatabase) { log.debug("Log d'audit persisté en base de données avec ID: {}", entity.id);
// AuditLogEntity entity = AuditLogMapper.toEntity(auditLog); } catch (Exception e) {
// entity.persist(); 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; return auditLog;
} }
@@ -88,7 +154,7 @@ public class AuditServiceImpl implements AuditService {
String description) { String description) {
AuditLogDTO auditLog = AuditLogDTO.builder() AuditLogDTO auditLog = AuditLogDTO.builder()
.acteurUserId(acteurUserId) .acteurUserId(acteurUserId)
.acteurUsername(acteurUserId) // Utiliser acteurUserId comme username pour l'instant .acteurUsername(acteurUserId)
.typeAction(typeAction) .typeAction(typeAction)
.ressourceType(ressourceType) .ressourceType(ressourceType)
.ressourceId(ressourceId != null ? ressourceId : "") .ressourceId(ressourceId != null ? ressourceId : "")
@@ -111,7 +177,7 @@ public class AuditServiceImpl implements AuditService {
String errorMessage) { String errorMessage) {
AuditLogDTO auditLog = AuditLogDTO.builder() AuditLogDTO auditLog = AuditLogDTO.builder()
.acteurUserId(acteurUserId) .acteurUserId(acteurUserId)
.acteurUsername(acteurUserId) // Utiliser acteurUserId comme username pour l'instant .acteurUsername(acteurUserId)
.typeAction(typeAction) .typeAction(typeAction)
.ressourceType(ressourceType) .ressourceType(ressourceType)
.ressourceId(ressourceId != null ? ressourceId : "") .ressourceId(ressourceId != null ? ressourceId : "")
@@ -123,13 +189,18 @@ public class AuditServiceImpl implements AuditService {
logAction(auditLog); logAction(auditLog);
} }
// ==================== MÉTHODES DE RECHERCHE ====================
@Override @Override
public List<AuditLogDTO> findByActeur(@NotBlank String acteurUserId, public List<AuditLogDTO> findByActeur(@NotBlank String acteurUserId,
LocalDateTime dateDebut, LocalDateTime dateDebut,
LocalDateTime dateFin, LocalDateTime dateFin,
int page, int page,
int pageSize) { 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 @Override
@@ -139,7 +210,13 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin, LocalDateTime dateFin,
int page, int page,
int pageSize) { 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() .stream()
.filter(log -> ressourceId.equals(log.getRessourceId())) .filter(log -> ressourceId.equals(log.getRessourceId()))
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -152,7 +229,10 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin, LocalDateTime dateFin,
int page, int page,
int pageSize) { 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 @Override
@@ -161,8 +241,11 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin, LocalDateTime dateFin,
int page, int page,
int pageSize) { int pageSize) {
// Pour l'instant, on retourne tous les logs car on n'a pas de champ realmName dans AuditLogDTO if (logToDatabase) {
return searchLogs(null, dateDebut, dateFin, null, null, null, page, pageSize); List<AuditLogEntity> entities = AuditLogEntity.findByRealm(realmName);
return auditLogMapper.toDTOList(entities);
}
return searchLogsFromCache(null, dateDebut, dateFin, null, null, null, page, pageSize);
} }
@Override @Override
@@ -171,7 +254,10 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin, LocalDateTime dateFin,
int page, int page,
int pageSize) { 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 @Override
@@ -180,29 +266,22 @@ public class AuditServiceImpl implements AuditService {
LocalDateTime dateFin, LocalDateTime dateFin,
int page, int page,
int pageSize) { int pageSize) {
// Les actions critiques sont USER_DELETE, ROLE_DELETE, etc. List<AuditLogDTO> allLogs = logToDatabase ?
return auditLogs.values().stream() searchLogsFromDatabase(null, dateDebut, dateFin, null, null, null, page, pageSize) :
searchLogsFromCache(null, dateDebut, dateFin, null, null, null, page, pageSize);
return allLogs.stream()
.filter(log -> { .filter(log -> {
TypeActionAudit type = log.getTypeAction(); TypeActionAudit type = log.getTypeAction();
return type == TypeActionAudit.USER_DELETE || return type == TypeActionAudit.USER_DELETE ||
type == TypeActionAudit.ROLE_DELETE || type == TypeActionAudit.ROLE_DELETE ||
type == TypeActionAudit.SESSION_REVOKE_ALL; 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()); .collect(Collectors.toList());
} }
// ==================== MÉTHODES STATISTIQUES ====================
@Override @Override
public Map<TypeActionAudit, Long> countByActionType(@NotBlank String realmName, public Map<TypeActionAudit, Long> countByActionType(@NotBlank String realmName,
LocalDateTime dateDebut, LocalDateTime dateDebut,
@@ -230,30 +309,15 @@ public class AuditServiceImpl implements AuditService {
return result; return result;
} }
@Override
public String exportToCSV(@NotBlank String realmName,
LocalDateTime dateDebut,
LocalDateTime dateFin) {
List<String> csvLines = exportLogsToCSV(dateDebut, dateFin);
return String.join("\n", csvLines);
}
@Override
public long purgeOldLogs(@NotNull LocalDateTime dateLimite) {
long beforeCount = auditLogs.size();
auditLogs.entrySet().removeIf(entry ->
entry.getValue().getDateAction().isBefore(dateLimite)
);
long afterCount = auditLogs.size();
return beforeCount - afterCount;
}
@Override @Override
public Map<String, Object> getAuditStatistics(@NotBlank String realmName, public Map<String, Object> getAuditStatistics(@NotBlank String realmName,
LocalDateTime dateDebut, LocalDateTime dateDebut,
LocalDateTime dateFin) { LocalDateTime dateFin) {
Map<String, Object> stats = new java.util.HashMap<>(); Map<String, Object> stats = new java.util.HashMap<>();
stats.put("total", auditLogs.values().stream()
long total = logToDatabase ?
AuditLogEntity.findByPeriod(dateDebut, dateFin).size() :
auditLogsCache.values().stream()
.filter(log -> { .filter(log -> {
if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
return false; return false;
@@ -263,135 +327,213 @@ public class AuditServiceImpl implements AuditService {
} }
return true; 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éthode privée helper pour la recherche
private List<AuditLogDTO> 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);
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
.skip((long) page * pageSize)
.limit(pageSize)
.collect(Collectors.toList());
}
// Méthodes privées helper
private Map<TypeActionAudit, Long> getActionStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) {
log.debug("Calcul des statistiques d'actions entre {} et {}", dateDebut, dateFin);
return 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;
})
.collect(Collectors.groupingBy(
AuditLogDTO::getTypeAction,
Collectors.counting()
));
}
private Map<String, Long> getUserActivityStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) {
log.debug("Calcul des statistiques d'activité utilisateurs entre {} et {}", dateDebut, dateFin);
return 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;
})
.collect(Collectors.groupingBy(
AuditLogDTO::getActeurUsername,
Collectors.counting()
));
}
private long getFailureCount(LocalDateTime dateDebut, LocalDateTime dateFin) {
log.debug("Comptage des échecs entre {} et {}", dateDebut, dateFin);
return auditLogs.values().stream()
.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;
}
return true;
})
.count(); .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;
} }
private long getSuccessCount(LocalDateTime dateDebut, LocalDateTime dateFin) { // ==================== EXPORT / PURGE ====================
log.debug("Comptage des succès entre {} et {}", dateDebut, dateFin);
return auditLogs.values().stream() @Override
.filter(log -> { public String exportToCSV(@NotBlank String realmName,
if (!log.isSuccessful()) { LocalDateTime dateDebut,
return false; // On ne compte que les succès LocalDateTime dateFin) {
List<String> csvLines = exportLogsToCSV(dateDebut, dateFin);
return String.join("\n", csvLines);
} }
@Override
@Transactional
public long purgeOldLogs(@NotNull LocalDateTime dateLimite) {
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 cacheRemoved = beforeCacheCount - auditLogsCache.size();
log.info("Supprimé {} logs du cache mémoire avant {}", cacheRemoved, dateLimite);
return purgedCount + cacheRemoved;
}
// ==================== MÉTHODES PRIVÉES ====================
/**
* Recherche les logs depuis le cache mémoire.
*/
private List<AuditLogDTO> 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 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());
}
/**
* Recherche les logs depuis la base de données PostgreSQL.
*/
private List<AuditLogDTO> 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");
List<AuditLogEntity> 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)) { if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) {
return false; return false;
} }
if (dateFin != null && log.getDateAction().isAfter(dateFin)) { if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
return false; 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<String> 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<TypeActionAudit, Long> getActionStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) {
if (logToDatabase) {
List<AuditLogEntity> 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;
return true;
})
.collect(Collectors.groupingBy(AuditLogDTO::getTypeAction, Collectors.counting()));
}
private Map<String, Long> getUserActivityStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) {
if (logToDatabase) {
List<AuditLogEntity> entities = AuditLogEntity.findByPeriod(dateDebut, dateFin);
return entities.stream()
.collect(Collectors.groupingBy(AuditLogEntity::getAuteurAction, 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;
return true;
})
.collect(Collectors.groupingBy(AuditLogDTO::getActeurUsername, Collectors.counting()));
}
private long getFailureCount(LocalDateTime dateDebut, LocalDateTime dateFin) {
if (logToDatabase) {
return AuditLogEntity.findByPeriod(dateDebut, dateFin).stream()
.filter(e -> !e.getSuccess())
.count();
}
return auditLogsCache.values().stream()
.filter(log -> !log.isSuccessful())
.filter(log -> {
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) {
if (logToDatabase) {
return AuditLogEntity.findByPeriod(dateDebut, dateFin).stream()
.filter(AuditLogEntity::getSuccess)
.count();
}
return auditLogsCache.values().stream()
.filter(AuditLogDTO::isSuccessful)
.filter(log -> {
if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false;
if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false;
return true; return true;
}) })
.count(); .count();
@@ -401,23 +543,24 @@ public class AuditServiceImpl implements AuditService {
log.info("Export CSV des logs d'audit entre {} et {}", dateDebut, dateFin); log.info("Export CSV des logs d'audit entre {} et {}", dateDebut, dateFin);
List<String> csvLines = new ArrayList<>(); List<String> 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"); csvLines.add("ID,Date Action,Acteur,Type Action,Ressource Type,Ressource ID,Succès,Adresse IP,Détails,Message Erreur");
// Données List<AuditLogDTO> logs;
auditLogs.values().stream() if (logToDatabase) {
List<AuditLogEntity> entities = AuditLogEntity.findByPeriod(dateDebut, dateFin);
logs = auditLogMapper.toDTOList(entities);
} else {
logs = auditLogsCache.values().stream()
.filter(log -> { .filter(log -> {
if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) { if (dateDebut != null && log.getDateAction().isBefore(dateDebut)) return false;
return false; if (dateFin != null && log.getDateAction().isAfter(dateFin)) return false;
}
if (dateFin != null && log.getDateAction().isAfter(dateFin)) {
return false;
}
return true; return true;
}) })
.sorted((a, b) -> a.getDateAction().compareTo(b.getDateAction())) .sorted((a, b) -> a.getDateAction().compareTo(b.getDateAction()))
.forEach(log -> { .collect(Collectors.toList());
}
logs.forEach(log -> {
String csvLine = String.format("%s,%s,%s,%s,%s,%s,%s,%s,\"%s\",\"%s\"", String csvLine = String.format("%s,%s,%s,%s,%s,%s,%s,%s,\"%s\",\"%s\"",
log.getId(), log.getId(),
log.getDateAction(), log.getDateAction(),
@@ -437,20 +580,31 @@ public class AuditServiceImpl implements AuditService {
return csvLines; 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() { 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() { public void clearAll() {
log.warn("ATTENTION: Suppression de tous les logs d'audit en mémoire"); log.warn("ATTENTION: Suppression de tous les logs d'audit");
auditLogs.clear();
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");
} }
} }

View File

@@ -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();
}
}

View File

@@ -608,11 +608,17 @@ public class UserServiceImpl implements UserService {
} }
@Override @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); 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"); String[] lines = csvContent.split("\\r?\\n");
int count = 0;
int startIndex = 0; int startIndex = 0;
// Skip header if present // Skip header if present
@@ -620,48 +626,159 @@ public class UserServiceImpl implements UserService {
startIndex = 1; startIndex = 1;
} }
result.setTotalLines(lines.length - startIndex);
for (int i = startIndex; i < lines.length; i++) { for (int i = startIndex; i < lines.length; i++) {
int lineNumber = i + 1;
String line = lines[i].trim(); String line = lines[i].trim();
if (line.isEmpty())
continue; if (line.isEmpty()) {
continue; // Ignore empty lines
}
try { try {
// Parse CSV line
String[] parts = parseCSVLine(line); String[] parts = parseCSVLine(line);
if (parts.length < 5) { 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; continue;
} }
String username = parts[0]; String username = CsvValidationHelper.clean(parts[0]);
String email = parts[1]; String email = CsvValidationHelper.clean(parts[1]);
String firstName = parts[2]; String firstName = CsvValidationHelper.clean(parts[2]);
String lastName = parts[3]; String lastName = CsvValidationHelper.clean(parts[3]);
boolean enabled = Boolean.parseBoolean(parts[4]); String enabledStr = CsvValidationHelper.clean(parts[4]);
if (username == null || username.isBlank()) { // Validate username
log.warn("Username manquant à la ligne {}", i + 1); 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; 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<UserDTO> 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() UserDTO userDTO = UserDTO.builder()
.username(username) .username(username)
.email(email.isBlank() ? null : email) .email(email)
.prenom(firstName.isBlank() ? null : firstName) .prenom(firstName)
.nom(lastName.isBlank() ? null : lastName) .nom(lastName)
.enabled(enabled) .enabled(enabled)
.build(); .build();
try {
createUser(userDTO, realmName); createUser(userDTO, realmName);
count++; 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) { } catch (Exception e) {
log.error("Erreur lors de l'import de la ligne {}: {}", i + 1, e.getMessage()); log.error("Erreur inattendue lors du traitement de la ligne {}: {}", lineNumber, e.getMessage(), e);
// Continue with next 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.SYSTEM_ERROR)
.message("Erreur système")
.details(e.getMessage())
.build());
} }
} }
log.info("✅ {} utilisateurs importés avec succès", count); // Generate summary message
return count; result.generateMessage();
log.info(result.getMessage());
return result;
} }
private String escape(String data) { private String escape(String data) {

View File

@@ -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
-- =============================================================================

View File

@@ -220,12 +220,15 @@ class UserServiceImplTest {
lenient().doNothing().when(userResource) lenient().doNothing().when(userResource)
.resetPassword(any(org.keycloak.representations.idm.CredentialRepresentation.class)); .resetPassword(any(org.keycloak.representations.idm.CredentialRepresentation.class));
String csvContent = "username,email,firstName,lastName,enabled\n" + String csvContent = "username,prenom,nom,email\n" +
"imported,imp@test.com,Imp,Orter,true"; "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"))); verify(usersResource, atLeastOnce()).create(argThat(u -> u.getUsername().equals("imported")));
} }
} }