feat(security): ownership + protection anti-admin sur lifecycle membres

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.
This commit is contained in:
dahoud
2026-04-15 20:12:37 +00:00
parent 78d8fd7cd8
commit 4816d1ac50

View File

@@ -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<String, String> 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<String, String> 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<String> 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<UUID> 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 {