fix(sprint-17): security hardening + migration SQL fixes
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 2m53s

Security:
- DashboardWebSocketEndpoint: add @Authenticated (was publicly accessible)
- MembreResource: add @RolesAllowed on 4 endpoints (rechercher, lister, stats, export)
- Membre: CascadeType.ALL → PERSIST+MERGE (prevent cascade delete historique)
- Membre: getNomComplet() null-safe

Bug fixes:
- DeviseConversionService: BUG-01 division-by-zero → TauxIntrouvableException
- AlerteLcbFtService/Resource: RBAC fixes
- OrganisationResource: minor fix

Migrations:
- V39: fix column name utilisateur_id → membre_id in RLS policy (kyc_dossier)
- V46: CREATE TABLE types_aide (was ALTER on non-existent table → was failing)
- V49: multi-devise schema corrections
This commit is contained in:
dahoud
2026-04-28 10:59:03 +00:00
parent 40710f32a0
commit c4a58c726b
14 changed files with 112 additions and 60 deletions

View File

@@ -170,9 +170,9 @@ public class Membre extends BaseEntity {
// ── Relations ────────────────────────────────────────────────────────────
/** Adhésions à des organisations */
/** Adhésions à des organisations — CascadeType.REMOVE exclu intentionnellement pour conserver l'historique */
@JsonIgnore
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@OneToMany(mappedBy = "membre", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
@Builder.Default
private List<MembreOrganisation> membresOrganisations = new ArrayList<>();
@@ -194,7 +194,7 @@ public class Membre extends BaseEntity {
// ── Méthodes métier ───────────────────────────────────────────────────────
public String getNomComplet() {
return prenom + " " + nom;
return (prenom != null ? prenom : "") + " " + (nom != null ? nom : "");
}
public boolean isMajeur() {

View File

@@ -3,6 +3,7 @@ package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.lcbft.AlerteLcbFtResponse;
import dev.lions.unionflow.server.entity.AlerteLcbFt;
import dev.lions.unionflow.server.repository.AlerteLcbFtRepository;
import dev.lions.unionflow.server.service.AlerteLcbFtService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
@@ -34,6 +35,9 @@ public class AlerteLcbFtResource {
@Inject
AlerteLcbFtRepository alerteLcbFtRepository;
@Inject
AlerteLcbFtService alerteLcbFtService;
/**
* Récupère les alertes LCB-FT avec filtres et pagination.
*/
@@ -106,25 +110,21 @@ public class AlerteLcbFtResource {
@PathParam("id") String id,
Map<String, String> body
) {
AlerteLcbFt alerte = alerteLcbFtRepository.findById(UUID.fromString(id));
if (alerte == null) {
throw new NotFoundException("Alerte non trouvée");
}
alerte.setTraitee(true);
alerte.setDateTraitement(LocalDateTime.now());
UUID traiteParId = null;
String traiteParStr = body.get("traitePar");
if (traiteParStr != null && !traiteParStr.isBlank()) {
try {
alerte.setTraitePar(UUID.fromString(traiteParStr));
traiteParId = UUID.fromString(traiteParStr);
} catch (IllegalArgumentException e) {
throw new BadRequestException("traitePar doit être un UUID valide");
}
}
alerte.setCommentaireTraitement(body.get("commentaire"));
alerteLcbFtRepository.persist(alerte);
AlerteLcbFt alerte = alerteLcbFtService.traiterAlerte(
UUID.fromString(id), traiteParId, body.get("commentaire"));
if (alerte == null) {
throw new NotFoundException("Alerte non trouvée");
}
return Response.ok(mapToResponse(alerte)).build();
}

View File

@@ -25,7 +25,7 @@ import org.jboss.logging.Logger;
@Path("/api/comptabilite")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "TRESORIER" })
@Tag(name = "Comptabilité", description = "Gestion comptable : comptes, journaux et écritures comptables")
public class ComptabiliteResource {
@@ -45,7 +45,7 @@ public class ComptabiliteResource {
* @return Compte créé
*/
@POST
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "TRESORIER" })
@Path("/comptes")
public Response creerCompteComptable(@Valid CreateCompteComptableRequest request) {
try {
@@ -117,7 +117,7 @@ public class ComptabiliteResource {
* @return Journal créé
*/
@POST
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "TRESORIER" })
@Path("/journaux")
public Response creerJournalComptable(@Valid CreateJournalComptableRequest request) {
try {

View File

@@ -1,5 +1,6 @@
package dev.lions.unionflow.server.resource;
import io.quarkus.security.Authenticated;
import io.quarkus.websockets.next.OnClose;
import io.quarkus.websockets.next.OnOpen;
import io.quarkus.websockets.next.OnTextMessage;
@@ -12,6 +13,7 @@ import org.jboss.logging.Logger;
* Les clients mobiles et web se connectent ici pour recevoir les mises à jour.
* Types de messages supportés : stats_update, new_activity, event_update, notification, pong
*/
@Authenticated
@WebSocket(path = "/ws/dashboard")
public class DashboardWebSocketEndpoint {

View File

@@ -514,6 +514,7 @@ public class MembreResource {
@GET
@Path("/recherche")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "SECRETAIRE"})
@Operation(summary = "Rechercher des membres par nom ou prénom")
@APIResponse(responseCode = "200", description = "Résultats de la recherche")
public Response rechercherMembres(
@@ -542,6 +543,7 @@ public class MembreResource {
@GET
@Path("/stats")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "SECRETAIRE"})
@Operation(summary = "Obtenir les statistiques avancées des membres")
@APIResponse(responseCode = "200", description = "Statistiques complètes des membres")
public Response obtenirStatistiques() {
@@ -552,6 +554,7 @@ public class MembreResource {
@GET
@Path("/autocomplete/villes")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "SECRETAIRE"})
@Operation(summary = "Obtenir la liste des villes pour autocomplétion")
@APIResponse(responseCode = "200", description = "Liste des villes distinctes")
public Response obtenirVilles(
@@ -563,6 +566,7 @@ public class MembreResource {
@GET
@Path("/autocomplete/professions")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MODERATEUR", "SECRETAIRE"})
@Operation(summary = "Obtenir la liste des professions pour autocomplétion")
@APIResponse(responseCode = "200", description = "Liste des professions distinctes")
public Response obtenirProfessions(

View File

@@ -141,7 +141,7 @@ public class OrganisationResource {
/** Récupère toutes les organisations actives */
@GET
@jakarta.annotation.security.PermitAll // ✅ Accès public pour inscription
@Authenticated
@Operation(
summary = "Lister les organisations",
description = "Récupère la liste des organisations actives avec pagination")

View File

@@ -115,6 +115,23 @@ public class AlerteLcbFtService {
}
}
/**
* Marque une alerte comme traitée.
*
* @return l'alerte mise à jour, ou {@code null} si elle n'existe pas
*/
@Transactional
public AlerteLcbFt traiterAlerte(UUID alerteId, UUID traiteParId, String commentaire) {
AlerteLcbFt alerte = alerteLcbFtRepository.findById(alerteId);
if (alerte == null) return null;
alerte.setTraitee(true);
alerte.setDateTraitement(LocalDateTime.now());
alerte.setTraitePar(traiteParId);
alerte.setCommentaireTraitement(commentaire);
alerteLcbFtRepository.persist(alerte);
return alerte;
}
/**
* Génère une alerte pour justification manquante.
*/

View File

@@ -82,7 +82,7 @@ public class DeviseConversionService {
// 2. Inverse exact
Optional<TauxChange> inverse = repository.trouverExact(cible, source, date);
if (inverse.isPresent()) {
return BigDecimal.ONE.divide(inverse.get().getTaux(), 8, RoundingMode.HALF_UP);
return inverserTaux(inverse.get().getTaux(), cible, source, date);
}
// 3. Pivot via XOF
@@ -105,7 +105,7 @@ public class DeviseConversionService {
}
Optional<TauxChange> recentInverse = repository.trouverPlusRecent(cible, source, date);
if (recentInverse.isPresent()) {
return BigDecimal.ONE.divide(recentInverse.get().getTaux(), 8, RoundingMode.HALF_UP);
return inverserTaux(recentInverse.get().getTaux(), cible, source, date);
}
throw new TauxIntrouvableException(
@@ -119,7 +119,7 @@ public class DeviseConversionService {
Optional<TauxChange> inverse = repository.trouverExact(cible, source, date);
if (inverse.isPresent()) {
return BigDecimal.ONE.divide(inverse.get().getTaux(), 8, RoundingMode.HALF_UP);
return inverserTaux(inverse.get().getTaux(), cible, source, date);
}
Optional<TauxChange> recent = repository.trouverPlusRecent(source, cible, date);
@@ -127,13 +127,22 @@ public class DeviseConversionService {
Optional<TauxChange> recentInverse = repository.trouverPlusRecent(cible, source, date);
if (recentInverse.isPresent()) {
return BigDecimal.ONE.divide(recentInverse.get().getTaux(), 8, RoundingMode.HALF_UP);
return inverserTaux(recentInverse.get().getTaux(), cible, source, date);
}
throw new TauxIntrouvableException(
"Pivot impossible : " + source + "" + cible + " (date " + date + ")");
}
/** Calcule 1/taux en levant TauxIntrouvableException si taux est zéro. */
private BigDecimal inverserTaux(BigDecimal taux, Devise from, Devise to, LocalDate date) {
if (taux.compareTo(BigDecimal.ZERO) == 0) {
throw new TauxIntrouvableException(
"Taux " + from + "" + to + " est zéro (date: " + date + ")");
}
return BigDecimal.ONE.divide(taux, 8, RoundingMode.HALF_UP);
}
/** Vide le cache (utilisé après import batch de nouveaux taux). */
public void invaliderCache() {
cache.clear();

View File

@@ -110,7 +110,7 @@ DO $$ BEGIN
USING (
EXISTS (
SELECT 1 FROM membres_organisations mo
WHERE mo.utilisateur_id = kyc_dossier.membre_id
WHERE mo.membre_id = kyc_dossier.membre_id
AND mo.organisation_id = current_setting('app.current_org_id', true)::uuid
AND mo.actif = true
)

View File

@@ -8,26 +8,26 @@
-- 1. Rôles standards manquants pour gouvernance OHADA / SoD
-- Insertion dans roles existant (V13__Seed_Standard_Roles.sql avait posé la base)
INSERT INTO roles (id, nom, description, categorie, niveau_hierarchie, actif, cree_le, version)
INSERT INTO roles (id, nom, code, libelle, description, categorie, niveau_hierarchique, type_role, actif, date_creation, date_modification, cree_par, modifie_par, version)
VALUES
(gen_random_uuid(), 'PRESIDENT',
(gen_random_uuid(), 'PRESIDENT', 'PRESIDENT', 'Président',
'Président de l''organisation : représentant légal, signataire PV AG/CA, convoque les instances. Distinct d''ADMIN_ORGANISATION (rôle technique).',
'GOUVERNANCE', 1, TRUE, CURRENT_TIMESTAMP, 0),
(gen_random_uuid(), 'VICE_PRESIDENT',
'GOUVERNANCE', 1, 'SYSTEME', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system', 'system', 0),
(gen_random_uuid(), 'VICE_PRESIDENT', 'VICE_PRESIDENT', 'Vice-Président',
'Vice-président : suppléance statutaire du président (OHADA AUDSCGIE).',
'GOUVERNANCE', 2, TRUE, CURRENT_TIMESTAMP, 0),
(gen_random_uuid(), 'CONTROLEUR_INTERNE',
'GOUVERNANCE', 2, 'SYSTEME', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system', 'system', 0),
(gen_random_uuid(), 'CONTROLEUR_INTERNE', 'CONTROLEUR_INTERNE', 'Contrôleur Interne',
'Contrôleur interne : obligatoire SFD UMOA (BCEAO Circulaire 03-2017/CB/C). Supervise risques, conformité, audit interne.',
'CONTROLE', 3, TRUE, CURRENT_TIMESTAMP, 0),
(gen_random_uuid(), 'ANIMATEUR_ZONE',
'CONTROLE', 3, 'SYSTEME', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system', 'system', 0),
(gen_random_uuid(), 'ANIMATEUR_ZONE', 'ANIMATEUR_ZONE', 'Animateur de Zone',
'Animateur de zone / délégué régional : enquête sociale demandes d''aide, lien terrain.',
'OPERATIONNEL', 4, TRUE, CURRENT_TIMESTAMP, 0),
(gen_random_uuid(), 'SECRETAIRE_ADJOINT',
'OPERATIONNEL', 4, 'SYSTEME', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system', 'system', 0),
(gen_random_uuid(), 'SECRETAIRE_ADJOINT', 'SECRETAIRE_ADJOINT', 'Secrétaire Adjoint',
'Secrétaire adjoint : suppléance secrétaire général.',
'GOUVERNANCE', 5, TRUE, CURRENT_TIMESTAMP, 0),
(gen_random_uuid(), 'TRESORIER_ADJOINT',
'GOUVERNANCE', 5, 'SYSTEME', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system', 'system', 0),
(gen_random_uuid(), 'TRESORIER_ADJOINT', 'TRESORIER_ADJOINT', 'Trésorier Adjoint',
'Trésorier adjoint : suppléance trésorier (séparation des pouvoirs maintenue).',
'GOUVERNANCE', 5, TRUE, CURRENT_TIMESTAMP, 0)
'GOUVERNANCE', 5, 'SYSTEME', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'system', 'system', 0)
ON CONFLICT (nom) DO NOTHING;
-- 2. Audit trail enrichi (P0-NEW-19) — SYSCOHADA + AUDSCGIE OHADA

View File

@@ -92,11 +92,30 @@ ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS reference_paiement VARCHAR(10
CREATE INDEX IF NOT EXISTS idx_demande_aide_etape ON demandes_aide (etape);
CREATE INDEX IF NOT EXISTS idx_demande_aide_animateur ON demandes_aide (animateur_zone_id) WHERE animateur_zone_id IS NOT NULL;
-- Plafonds : extension types_aide existants
ALTER TABLE types_aide ADD COLUMN IF NOT EXISTS plafond_annuel_membre NUMERIC(15,2);
ALTER TABLE types_aide ADD COLUMN IF NOT EXISTS plafond_enveloppe_annuelle NUMERIC(15,2);
ALTER TABLE types_aide ADD COLUMN IF NOT EXISTS delai_max_traitement_jours INTEGER NOT NULL DEFAULT 30;
ALTER TABLE types_aide ADD COLUMN IF NOT EXISTS justificatifs_requis JSONB; -- liste de strings
-- Plafonds : table types_aide (n'existait pas avant V46 — type_aide était inline VARCHAR dans demandes_aide)
CREATE TABLE IF NOT EXISTS types_aide (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organisation_id UUID, -- null = type global plateforme
code VARCHAR(50) NOT NULL UNIQUE,
libelle VARCHAR(200) NOT NULL,
description TEXT,
-- Plafonds (P1-NEW-3)
plafond_annuel_membre NUMERIC(15,2),
plafond_enveloppe_annuelle NUMERIC(15,2),
delai_max_traitement_jours INTEGER NOT NULL DEFAULT 30,
justificatifs_requis JSONB, -- liste de strings
cree_le TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
cree_par UUID,
modifie_le TIMESTAMP,
modifie_par UUID,
version BIGINT NOT NULL DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX IF NOT EXISTS idx_types_aide_org ON types_aide (organisation_id) WHERE organisation_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_types_aide_code ON types_aide (code);
COMMENT ON COLUMN demandes_aide.etape IS
'5 étapes du workflow v2 : DEPOSE → ENQUETE (animateur zone) → AVIS_COMITE (commission solidarité) '

View File

@@ -50,7 +50,7 @@ VALUES
ON CONFLICT (devise_source, devise_cible, date_validite) DO NOTHING;
-- ── Extension Membre (diaspora) ─────────────────────────────────────────────
ALTER TABLE membres
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS pays_residence VARCHAR(3),
ADD COLUMN IF NOT EXISTS numero_passeport VARCHAR(50),
ADD COLUMN IF NOT EXISTS numero_fiscal_etranger VARCHAR(50),
@@ -58,27 +58,27 @@ ALTER TABLE membres
ADD COLUMN IF NOT EXISTS devise_preferee VARCHAR(3) NOT NULL DEFAULT 'XOF';
CREATE INDEX IF NOT EXISTS idx_membre_diaspora
ON membres (est_diaspora) WHERE est_diaspora = TRUE;
ON utilisateurs (est_diaspora) WHERE est_diaspora = TRUE;
COMMENT ON COLUMN membres.pays_residence IS
COMMENT ON COLUMN utilisateurs.pays_residence IS
'ISO-3 (FRA, USA, CAN, GBR...). NULL = résident UEMOA. Différent de nationalite.';
COMMENT ON COLUMN membres.numero_passeport IS
COMMENT ON COLUMN utilisateurs.numero_passeport IS
'Passeport pour non-résidents (CNI insuffisante). Vérification ARTCI.';
COMMENT ON COLUMN membres.numero_fiscal_etranger IS
COMMENT ON COLUMN utilisateurs.numero_fiscal_etranger IS
'NIF/SSN/SIN — pour reporting fiscal accord bilatéral CI-pays résidence.';
-- ── Extension KycDossier (non-résident) ─────────────────────────────────────
ALTER TABLE kyc_dossiers
ALTER TABLE kyc_dossier
ADD COLUMN IF NOT EXISTS pays_origine_fonds VARCHAR(3),
ADD COLUMN IF NOT EXISTS justificatif_residence_etrangere VARCHAR(500),
ADD COLUMN IF NOT EXISTS niveau_due_diligence VARCHAR(20) NOT NULL DEFAULT 'STANDARD'
CHECK (niveau_due_diligence IN ('SIMPLIFIE', 'STANDARD', 'RENFORCE'));
CREATE INDEX IF NOT EXISTS idx_kyc_due_diligence
ON kyc_dossiers (niveau_due_diligence)
ON kyc_dossier (niveau_due_diligence)
WHERE niveau_due_diligence = 'RENFORCE';
COMMENT ON COLUMN kyc_dossiers.niveau_due_diligence IS
COMMENT ON COLUMN kyc_dossier.niveau_due_diligence IS
'Instr. BCEAO 001-03-2025 : RENFORCE pour non-résidents, PEP, et risque pays grey-list FATF';
COMMENT ON COLUMN kyc_dossiers.pays_origine_fonds IS
COMMENT ON COLUMN kyc_dossier.pays_origine_fonds IS
'ISO-3 — origine des fonds pour transferts internationaux (anti-blanchiment).';

View File

@@ -154,12 +154,10 @@ class WaveRedirectResourceMockEnabledTest {
cotisation.setMontantDu(new BigDecimal("5000"));
cotisation.setStatut("EN_ATTENTE");
EntityManager em = mock(EntityManager.class);
when(entityManager.find(IntentionPaiement.class, intentionId)).thenReturn(intention);
when(entityManager.find(eq(Cotisation.class), eq(cotisationId))).thenReturn(cotisation);
when(entityManager.merge(any(Cotisation.class))).thenReturn(cotisation);
doNothing().when(intentionPaiementRepository).persist(any(IntentionPaiement.class));
when(intentionPaiementRepository.getEntityManager()).thenReturn(em);
when(em.find(eq(Cotisation.class), eq(cotisationId))).thenReturn(cotisation);
when(em.merge(any(Cotisation.class))).thenReturn(cotisation);
given()
.redirects().follow(false)
@@ -170,7 +168,7 @@ class WaveRedirectResourceMockEnabledTest {
.statusCode(anyOf(equalTo(301), equalTo(302), equalTo(303)))
.header("Location", containsString("success"));
verify(em).merge(any(Cotisation.class));
verify(entityManager).merge(any(Cotisation.class));
}
// -----------------------------------------------------------------------
@@ -350,12 +348,10 @@ class WaveRedirectResourceMockEnabledTest {
cotisation.setMontantDu(new BigDecimal("7500"));
cotisation.setStatut("EN_ATTENTE");
EntityManager em = mock(EntityManager.class);
when(entityManager.find(IntentionPaiement.class, intentionId)).thenReturn(intention);
when(entityManager.find(eq(Cotisation.class), eq(cotisationId))).thenReturn(cotisation);
when(entityManager.merge(any(Cotisation.class))).thenReturn(cotisation);
doNothing().when(intentionPaiementRepository).persist(any(IntentionPaiement.class));
when(intentionPaiementRepository.getEntityManager()).thenReturn(em);
when(em.find(eq(Cotisation.class), eq(cotisationId))).thenReturn(cotisation);
when(em.merge(any(Cotisation.class))).thenReturn(cotisation);
given()
.redirects().follow(false)
@@ -366,7 +362,7 @@ class WaveRedirectResourceMockEnabledTest {
.statusCode(anyOf(equalTo(301), equalTo(302), equalTo(303)))
.header("Location", containsString("success"));
verify(em).merge(any(Cotisation.class));
verify(entityManager).merge(any(Cotisation.class));
}
// -----------------------------------------------------------------------

View File

@@ -10,6 +10,11 @@ quarkus.log.category."dev.lions.unionflow.server.service.MembreImportExportServi
wave.api.key=test-key
wave.api.secret=test-secret
# Pointe sur le Keycloak local déjà démarré → désactive OIDC DevServices (Testcontainers)
# Sans cette propriété Quarkus essaie de pull l'image Keycloak à chaque build, ce qui bloque.
quarkus.oidc.auth-server-url=http://localhost:8180/realms/unionflow
quarkus.oidc.client-id=unionflow-server
# Configuration OIDC client "admin-service" factice pour les tests
# Nécessaire pour que AdminUserServiceClient (@OidcClientFilter("admin-service")) puisse être mocké par @InjectMock
quarkus.oidc-client.admin-service.auth-server-url=http://localhost:8180/realms/unionflow