From 4816d1ac50c52f24e793a96deae0d9f1964173c5 Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:12:37 +0000 Subject: [PATCH] feat(security): ownership + protection anti-admin sur lifecycle membres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifierOwnershipEtProtectionAdmin() appelé sur les 5 endpoints lifecycle (radier-adhesion, archiver-adhesion, activer/suspendre/radier par membre): 1. Ownership: un ADMIN_ORGANISATION ne peut agir que sur les membres des organisations dont il est responsable (sinon 403). 2. Anti-admin: un ADMIN_ORGANISATION ne peut pas agir sur un autre ORGADMIN ou SUPERADMIN (sinon 403). 3. SUPER_ADMIN/ADMIN passent directement (accès total). Comble les failles SEC-01/SEC-02 de l'audit technique. --- .../server/resource/MembreResource.java | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java index df05161..42e4179 100644 --- a/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java +++ b/src/main/java/dev/lions/unionflow/server/resource/MembreResource.java @@ -11,6 +11,7 @@ import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.MembreOrganisation; import dev.lions.unionflow.server.repository.MembreOrganisationRepository; +import dev.lions.unionflow.server.repository.MembreRoleRepository; import dev.lions.unionflow.server.service.MemberLifecycleService; import dev.lions.unionflow.server.service.MembreKeycloakSyncService; import dev.lions.unionflow.server.service.MembreService; @@ -77,6 +78,9 @@ public class MembreResource { @Inject MembreOrganisationRepository membreOrgRepository; + @Inject + MembreRoleRepository membreRoleRepository; + @Inject io.quarkus.security.identity.SecurityIdentity securityIdentity; @@ -1119,6 +1123,10 @@ public class MembreResource { @Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId, Map body) { + MembreOrganisation lienCheck = membreOrgRepository.findByIdOptional(membreOrgId) + .orElseThrow(() -> new NotFoundException("Lien membre-organisation introuvable: " + membreOrgId)); + verifierOwnershipEtProtectionAdmin(lienCheck); + String motif = body != null ? body.get("motif") : null; UUID adminId = resolveCurrentAdminId(); try { @@ -1148,6 +1156,10 @@ public class MembreResource { @Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId, Map body) { + MembreOrganisation lienCheck = membreOrgRepository.findByIdOptional(membreOrgId) + .orElseThrow(() -> new NotFoundException("Lien membre-organisation introuvable: " + membreOrgId)); + verifierOwnershipEtProtectionAdmin(lienCheck); + String motif = body != null ? body.get("motif") : null; try { var lien = memberLifecycleService.archiverMembre(membreOrgId, motif); @@ -1223,6 +1235,7 @@ public class MembreResource { return Response.status(Response.Status.NOT_FOUND) .entity(Map.of("error", "Aucune adhésion trouvée")).build(); } + verifierOwnershipEtProtectionAdmin(lien); String motif = body != null ? body.get("motif") : null; UUID adminId = resolveCurrentAdminId(); try { @@ -1258,6 +1271,7 @@ public class MembreResource { return Response.status(Response.Status.NOT_FOUND) .entity(Map.of("error", "Aucune adhésion trouvée")).build(); } + verifierOwnershipEtProtectionAdmin(lien); String motif = body != null ? body.get("motif") : null; UUID adminId = resolveCurrentAdminId(); try { @@ -1292,6 +1306,7 @@ public class MembreResource { return Response.status(Response.Status.NOT_FOUND) .entity(Map.of("error", "Aucune adhésion trouvée")).build(); } + verifierOwnershipEtProtectionAdmin(lien); String motif = body != null ? body.get("motif") : null; var updated = memberLifecycleService.radierMembre(lien.getId(), resolveCurrentAdminId(), motif); return Response.ok(Map.of("statut", updated.getStatutMembre())).build(); @@ -1314,6 +1329,55 @@ public class MembreResource { return Response.ok(membreService.convertToResponse(membre)).build(); } + /** + * Vérifie qu'un ADMIN_ORGANISATION : + * 1. agit bien sur une organisation dont il est responsable (ownership), + * 2. ne tente pas d'agir sur un autre admin (ORGADMIN) ou super-admin (SUPERADMIN). + * + * Sans effet si l'appelant est SUPER_ADMIN ou ADMIN (ils ont accès total). + * + * @throws ForbiddenException si l'une des règles est violée + */ + private void verifierOwnershipEtProtectionAdmin(MembreOrganisation lien) { + java.util.Set roles = securityIdentity.getRoles(); + boolean isOrgAdminOnly = roles != null + && roles.contains("ADMIN_ORGANISATION") + && !roles.contains("ADMIN") + && !roles.contains("SUPER_ADMIN"); + + if (!isOrgAdminOnly) { + return; // SUPER_ADMIN / ADMIN : accès total, pas de vérification supplémentaire + } + + // ── 1. Ownership : l'org cible doit appartenir à l'admin connecté ────── + String email = securityIdentity.getPrincipal().getName(); + java.util.List orgIds = organisationService + .listerOrganisationsPourUtilisateur(email) + .stream() + .map(Organisation::getId) + .collect(java.util.stream.Collectors.toList()); + + if (lien.getOrganisation() == null || !orgIds.contains(lien.getOrganisation().getId())) { + LOG.warnf("ADMIN_ORGANISATION %s tente d'agir sur org %s qui n'est pas la sienne", + email, lien.getOrganisation() != null ? lien.getOrganisation().getId() : "null"); + throw new ForbiddenException("Vous n'êtes pas autorisé à gérer les membres de cette organisation."); + } + + // ── 2. Protection : interdire d'agir sur un admin ou super-admin ──────── + Membre cible = lien.getMembre(); + if (cible != null && cible.getId() != null) { + long adminCount = membreRoleRepository.count( + "membreOrganisation.membre.id = ?1 AND role.code IN (?2) AND actif = true", + cible.getId(), java.util.List.of("ORGADMIN", "SUPERADMIN")); + if (adminCount > 0) { + LOG.warnf("ADMIN_ORGANISATION %s tente d'agir sur l'admin/superadmin membre=%s", + email, cible.getId()); + throw new ForbiddenException( + "Vous ne pouvez pas modifier le statut d'un administrateur ou super-administrateur."); + } + } + } + /** Résout l'UUID de l'admin connecté depuis le JWT subject. */ private UUID resolveCurrentAdminId() { try {