fix(security): audit RBAC complet v3.0 — rôles normalisés, lifecycle, changement mdp mobile
RBAC:
- HealthResource: @PermitAll
- RoleResource: @RolesAllowed ADMIN/SUPER_ADMIN/ADMIN_ORGANISATION class-level
- PropositionAideResource: @RolesAllowed MEMBRE/USER class-level
- AuthCallbackResource: @PermitAll
- EvenementResource: @PermitAll /publics et /test, count restreint
- BackupResource/LogsMonitoringResource/SystemResource: MODERATOR → MODERATEUR
- AnalyticsResource: MANAGER/MEMBER → ADMIN_ORGANISATION/MEMBRE
- RoleConstant.java: constantes de rôles centralisées
Cycle de vie membres:
- MemberLifecycleService: ajouterMembre()/retirerMembre() sur activation/radiation/archivage
- MembreResource: endpoint GET /numero/{numeroMembre}
- MembreService: méthode trouverParNumeroMembre()
Changement mot de passe:
- CompteAdherentResource: endpoint POST /auth/change-password (mobile)
- MembreKeycloakSyncService: changerMotDePasseDirectKeycloak() via API Admin Keycloak directe
- Fallback automatique si lions-user-manager indisponible
Workflow:
- Flyway V17-V23: rôles, types org, formules Option C, lifecycle columns, bareme cotisation
- Nouvelles classes: MemberLifecycleService, OrganisationModuleService, scheduler
- Security: OrganisationContextFilter, OrganisationContextHolder, ModuleAccessFilter
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
|
||||
import dev.lions.unionflow.server.api.enums.membre.StatutMembre;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
/**
|
||||
* Service de cycle de vie des membres au sein d'une organisation.
|
||||
*
|
||||
* <p>Gère les transitions de statut automatiques et manuelles :
|
||||
* <ul>
|
||||
* <li>Invitation → EN_ATTENTE_VALIDATION (après acceptation)</li>
|
||||
* <li>EN_ATTENTE_VALIDATION → ACTIF (après validation admin)</li>
|
||||
* <li>ACTIF → SUSPENDU (suspension manuelle ou automatique)</li>
|
||||
* <li>INVITE → expiré (si la date d'expiration est dépassée)</li>
|
||||
* <li>ACTIF → ARCHIVE (archivage)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Les tâches automatiques sont déclenchées par {@link MemberLifecycleScheduler}.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class MemberLifecycleService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(MemberLifecycleService.class);
|
||||
|
||||
/** Durée de validité par défaut d'une invitation (7 jours). */
|
||||
public static final int INVITATION_EXPIRY_DAYS = 7;
|
||||
|
||||
/** Délai de rappel avant expiration (24h avant). */
|
||||
public static final int INVITATION_REMINDER_HOURS = 24;
|
||||
|
||||
@Inject
|
||||
MembreOrganisationRepository membreOrgRepository;
|
||||
|
||||
@Inject
|
||||
NotificationService notificationService;
|
||||
|
||||
// =========================================================================
|
||||
// Transitions manuelles
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Active un membre dans une organisation (transition → ACTIF).
|
||||
*
|
||||
* @param membreOrgId UUID du lien membre-organisation
|
||||
* @param adminId UUID de l'administrateur qui valide
|
||||
* @param motif Motif optionnel de l'activation
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation activerMembre(UUID membreOrgId, UUID adminId, String motif) {
|
||||
MembreOrganisation lien = charger(membreOrgId);
|
||||
verifierTransitionAutorisee(lien.getStatutMembre(),
|
||||
StatutMembre.EN_ATTENTE_VALIDATION, StatutMembre.INVITE, StatutMembre.SUSPENDU);
|
||||
|
||||
lien.setStatutMembre(StatutMembre.ACTIF);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifStatut(motif != null ? motif : "Validation par l'administrateur");
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
// Mettre à jour le compteur de membres de l'organisation
|
||||
lien.getOrganisation().ajouterMembre();
|
||||
|
||||
LOG.infof("Membre %s activé dans organisation %s par admin %s",
|
||||
lien.getMembre().getId(), lien.getOrganisation().getId(), adminId);
|
||||
|
||||
// Notifier le membre
|
||||
envoyerNotification(lien.getMembre(), "Adhésion activée",
|
||||
"Votre adhésion à " + lien.getOrganisation().getNom() + " est maintenant active.");
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspend un membre dans une organisation (ACTIF → SUSPENDU).
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation suspendreMembre(UUID membreOrgId, UUID adminId, String motif) {
|
||||
MembreOrganisation lien = charger(membreOrgId);
|
||||
verifierTransitionAutorisee(lien.getStatutMembre(), StatutMembre.ACTIF);
|
||||
|
||||
lien.setStatutMembre(StatutMembre.SUSPENDU);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifStatut(motif != null ? motif : "Suspension par l'administrateur");
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
LOG.infof("Membre %s suspendu dans organisation %s", lien.getMembre().getId(),
|
||||
lien.getOrganisation().getId());
|
||||
|
||||
envoyerNotification(lien.getMembre(), "Adhésion suspendue",
|
||||
"Votre adhésion à " + lien.getOrganisation().getNom() + " a été suspendue. Motif : " + motif);
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Radie un membre d'une organisation (→ RADIE).
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation radierMembre(UUID membreOrgId, UUID adminId, String motif) {
|
||||
MembreOrganisation lien = charger(membreOrgId);
|
||||
|
||||
lien.setStatutMembre(StatutMembre.RADIE);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifStatut(motif != null ? motif : "Radiation par l'administrateur");
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
// Mettre à jour le compteur de membres de l'organisation
|
||||
lien.getOrganisation().retirerMembre();
|
||||
|
||||
LOG.infof("Membre %s radié de l'organisation %s. Motif: %s",
|
||||
lien.getMembre().getId(), lien.getOrganisation().getId(), motif);
|
||||
|
||||
envoyerNotification(lien.getMembre(), "Adhésion radiée",
|
||||
"Votre adhésion à " + lien.getOrganisation().getNom() + " a été radiée.");
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive un membre (→ ARCHIVE) sans supprimer l'historique.
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation archiverMembre(UUID membreOrgId, String motif) {
|
||||
MembreOrganisation lien = charger(membreOrgId);
|
||||
|
||||
lien.setStatutMembre(StatutMembre.ARCHIVE);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifArchivage(motif);
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
// Mettre à jour le compteur de membres de l'organisation
|
||||
lien.getOrganisation().retirerMembre();
|
||||
|
||||
LOG.infof("Membre %s archivé dans l'organisation %s", lien.getMembre().getId(),
|
||||
lien.getOrganisation().getId());
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une invitation (statut INVITE) pour un nouveau membre.
|
||||
*
|
||||
* @param membre Le membre à inviter (doit exister en base)
|
||||
* @param organisation L'organisation cible
|
||||
* @param adminId L'administrateur qui invite
|
||||
* @param roleOrg Rôle proposé dans l'organisation (optionnel)
|
||||
* @return le lien MembreOrganisation créé avec le token d'invitation
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation inviterMembre(
|
||||
dev.lions.unionflow.server.entity.Membre membre,
|
||||
dev.lions.unionflow.server.entity.Organisation organisation,
|
||||
UUID adminId,
|
||||
String roleOrg) {
|
||||
|
||||
// Vérifier s'il n'existe pas déjà un lien actif
|
||||
membreOrgRepository.findByMembreIdAndOrganisationId(membre.getId(), organisation.getId())
|
||||
.ifPresent(existing -> {
|
||||
throw new IllegalStateException("Le membre est déjà lié à cette organisation.");
|
||||
});
|
||||
|
||||
String token = UUID.randomUUID().toString().replace("-", "");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
MembreOrganisation lien = MembreOrganisation.builder()
|
||||
.membre(membre)
|
||||
.organisation(organisation)
|
||||
.statutMembre(StatutMembre.INVITE)
|
||||
.dateInvitation(now)
|
||||
.dateExpirationInvitation(now.plusDays(INVITATION_EXPIRY_DAYS))
|
||||
.tokenInvitation(token)
|
||||
.invitePar(adminId)
|
||||
.roleOrg(roleOrg)
|
||||
.build();
|
||||
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
LOG.infof("Invitation créée pour membre %s dans organisation %s (expire: %s)",
|
||||
membre.getId(), organisation.getId(), lien.getDateExpirationInvitation());
|
||||
|
||||
// Notifier le membre
|
||||
envoyerNotification(membre, "Invitation à rejoindre " + organisation.getNom(),
|
||||
"Vous avez été invité à rejoindre " + organisation.getNom() +
|
||||
". Votre invitation expire dans " + INVITATION_EXPIRY_DAYS + " jours.");
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepte une invitation via son token (INVITE → EN_ATTENTE_VALIDATION).
|
||||
*/
|
||||
@Transactional
|
||||
public MembreOrganisation accepterInvitation(String token) {
|
||||
MembreOrganisation lien = membreOrgRepository
|
||||
.find("tokenInvitation = ?1 and statutMembre = ?2", token, StatutMembre.INVITE)
|
||||
.firstResultOptional()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invitation introuvable ou déjà utilisée."));
|
||||
|
||||
if (lien.getDateExpirationInvitation() != null &&
|
||||
lien.getDateExpirationInvitation().isBefore(LocalDateTime.now())) {
|
||||
throw new IllegalStateException("Cette invitation a expiré.");
|
||||
}
|
||||
|
||||
lien.setStatutMembre(StatutMembre.EN_ATTENTE_VALIDATION);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setTokenInvitation(null); // Invalider le token après usage
|
||||
membreOrgRepository.persist(lien);
|
||||
|
||||
LOG.infof("Invitation acceptée par membre %s dans organisation %s",
|
||||
lien.getMembre().getId(), lien.getOrganisation().getId());
|
||||
|
||||
return lien;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Traitements automatiques (appelés par MemberLifecycleScheduler)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Expire les invitations dont la date limite est dépassée.
|
||||
* Les membres INVITE passent au statut RADIE.
|
||||
*
|
||||
* @return nombre d'invitations expirées
|
||||
*/
|
||||
@Transactional
|
||||
public int expirerInvitations() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
List<MembreOrganisation> expiredInvitations = membreOrgRepository.findInvitationsExpirees(now);
|
||||
|
||||
int count = 0;
|
||||
for (MembreOrganisation lien : expiredInvitations) {
|
||||
lien.setStatutMembre(StatutMembre.RADIE);
|
||||
lien.setDateChangementStatut(LocalDate.now());
|
||||
lien.setMotifStatut("Invitation expirée sans réponse");
|
||||
membreOrgRepository.persist(lien);
|
||||
count++;
|
||||
|
||||
LOG.infof("Invitation expirée : membre %s dans org %s",
|
||||
lien.getMembre().getId(), lien.getOrganisation().getId());
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
LOG.infof("Expiration des invitations : %d invitation(s) expirée(s)", count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie des rappels pour les invitations qui expirent dans les prochaines 24h.
|
||||
*
|
||||
* @return nombre de rappels envoyés
|
||||
*/
|
||||
@Transactional
|
||||
public int envoyerRappelsInvitation() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime dans24h = now.plusHours(INVITATION_REMINDER_HOURS);
|
||||
List<MembreOrganisation> aRappeler =
|
||||
membreOrgRepository.findInvitationsExpirantBientot(now, dans24h);
|
||||
|
||||
int count = 0;
|
||||
for (MembreOrganisation lien : aRappeler) {
|
||||
envoyerNotification(lien.getMembre(),
|
||||
"Rappel : votre invitation expire bientôt",
|
||||
"Votre invitation à rejoindre " + lien.getOrganisation().getNom() +
|
||||
" expire dans moins de 24h. Acceptez-la maintenant.");
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
LOG.infof("Rappels d'invitation : %d rappel(s) envoyé(s)", count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers privés
|
||||
// =========================================================================
|
||||
|
||||
private MembreOrganisation charger(UUID membreOrgId) {
|
||||
return membreOrgRepository.findByIdOptional(membreOrgId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Lien membre-organisation introuvable : " + membreOrgId));
|
||||
}
|
||||
|
||||
private void verifierTransitionAutorisee(StatutMembre current, StatutMembre... autorises) {
|
||||
for (StatutMembre autorise : autorises) {
|
||||
if (autorise.equals(current)) return;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Transition non autorisée depuis le statut " + current);
|
||||
}
|
||||
|
||||
private void envoyerNotification(Membre membre, String titre, String corps) {
|
||||
try {
|
||||
if (notificationService != null) {
|
||||
CreateNotificationRequest req = CreateNotificationRequest.builder()
|
||||
.typeNotification("SYSTEME")
|
||||
.priorite("NORMALE")
|
||||
.sujet(titre)
|
||||
.corps(corps)
|
||||
.membreId(membre.getId())
|
||||
.build();
|
||||
notificationService.creerNotification(req);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Impossible d'envoyer la notification à %s : %s", membre.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user