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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user