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.Organisation;
|
||||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
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.MemberLifecycleService;
|
||||||
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
|
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
|
||||||
import dev.lions.unionflow.server.service.MembreService;
|
import dev.lions.unionflow.server.service.MembreService;
|
||||||
@@ -77,6 +78,9 @@ public class MembreResource {
|
|||||||
@Inject
|
@Inject
|
||||||
MembreOrganisationRepository membreOrgRepository;
|
MembreOrganisationRepository membreOrgRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreRoleRepository membreRoleRepository;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
@@ -1119,6 +1123,10 @@ public class MembreResource {
|
|||||||
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
||||||
Map<String, String> body) {
|
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;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
UUID adminId = resolveCurrentAdminId();
|
UUID adminId = resolveCurrentAdminId();
|
||||||
try {
|
try {
|
||||||
@@ -1148,6 +1156,10 @@ public class MembreResource {
|
|||||||
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
@Parameter(description = "UUID du lien membre-organisation") @PathParam("membreOrgId") UUID membreOrgId,
|
||||||
Map<String, String> body) {
|
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;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
try {
|
try {
|
||||||
var lien = memberLifecycleService.archiverMembre(membreOrgId, motif);
|
var lien = memberLifecycleService.archiverMembre(membreOrgId, motif);
|
||||||
@@ -1223,6 +1235,7 @@ public class MembreResource {
|
|||||||
return Response.status(Response.Status.NOT_FOUND)
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
||||||
}
|
}
|
||||||
|
verifierOwnershipEtProtectionAdmin(lien);
|
||||||
String motif = body != null ? body.get("motif") : null;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
UUID adminId = resolveCurrentAdminId();
|
UUID adminId = resolveCurrentAdminId();
|
||||||
try {
|
try {
|
||||||
@@ -1258,6 +1271,7 @@ public class MembreResource {
|
|||||||
return Response.status(Response.Status.NOT_FOUND)
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
||||||
}
|
}
|
||||||
|
verifierOwnershipEtProtectionAdmin(lien);
|
||||||
String motif = body != null ? body.get("motif") : null;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
UUID adminId = resolveCurrentAdminId();
|
UUID adminId = resolveCurrentAdminId();
|
||||||
try {
|
try {
|
||||||
@@ -1292,6 +1306,7 @@ public class MembreResource {
|
|||||||
return Response.status(Response.Status.NOT_FOUND)
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
.entity(Map.of("error", "Aucune adhésion trouvée")).build();
|
||||||
}
|
}
|
||||||
|
verifierOwnershipEtProtectionAdmin(lien);
|
||||||
String motif = body != null ? body.get("motif") : null;
|
String motif = body != null ? body.get("motif") : null;
|
||||||
var updated = memberLifecycleService.radierMembre(lien.getId(), resolveCurrentAdminId(), motif);
|
var updated = memberLifecycleService.radierMembre(lien.getId(), resolveCurrentAdminId(), motif);
|
||||||
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
|
return Response.ok(Map.of("statut", updated.getStatutMembre())).build();
|
||||||
@@ -1314,6 +1329,55 @@ public class MembreResource {
|
|||||||
return Response.ok(membreService.convertToResponse(membre)).build();
|
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. */
|
/** Résout l'UUID de l'admin connecté depuis le JWT subject. */
|
||||||
private UUID resolveCurrentAdminId() {
|
private UUID resolveCurrentAdminId() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user