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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
* <p><b>Architecture Hybride:</b></p>
|
||||
* <ul>
|
||||
* <li><b>Cache en mémoire</b> - Pour les logs récents (performances)</li>
|
||||
* <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
|
||||
@Slf4j
|
||||
public class AuditServiceImpl implements AuditService {
|
||||
|
||||
// Stockage en mémoire (à remplacer par une DB en production)
|
||||
private final Map<String, AuditLogDTO> 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.
|
||||
* <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
|
||||
@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<AuditLogDTO> 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<AuditLogEntity> 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<AuditLogDTO> 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<TypeActionAudit, Long> 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<String, Long> result = new java.util.HashMap<>();
|
||||
result.put("success", successCount);
|
||||
result.put("failure", failureCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAuditStatistics(@NotBlank String realmName,
|
||||
LocalDateTime dateDebut,
|
||||
LocalDateTime dateFin) {
|
||||
Map<String, Object> 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<String, Object> getAuditStatistics(@NotBlank String realmName,
|
||||
LocalDateTime dateDebut,
|
||||
LocalDateTime dateFin) {
|
||||
Map<String, Object> 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<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);
|
||||
/**
|
||||
* 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 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<TypeActionAudit, Long> 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<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");
|
||||
|
||||
return auditLogs.values().stream()
|
||||
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)) {
|
||||
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<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;
|
||||
}
|
||||
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<String, Long> getUserActivityStatistics(LocalDateTime dateDebut, LocalDateTime dateFin) {
|
||||
log.debug("Calcul des statistiques d'activité utilisateurs entre {} et {}", dateDebut, dateFin);
|
||||
if (logToDatabase) {
|
||||
List<AuditLogEntity> 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<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");
|
||||
|
||||
// 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<AuditLogDTO> logs;
|
||||
if (logToDatabase) {
|
||||
List<AuditLogEntity> 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<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()
|
||||
.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) {
|
||||
|
||||
@@ -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
|
||||
-- =============================================================================
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user