diff --git a/src/main/java/dev/lions/unionflow/server/entity/AuditTrailOperation.java b/src/main/java/dev/lions/unionflow/server/entity/AuditTrailOperation.java new file mode 100644 index 0000000..ddea524 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/AuditTrailOperation.java @@ -0,0 +1,104 @@ +package dev.lions.unionflow.server.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * Entrée d'audit trail enrichi (SYSCOHADA + AUDSCGIE OHADA). + * + *
Trace les opérations financières, le lifecycle membres, les changements de configuration, + * avec le contexte multi-org (rôle actif + organisation active) + vérifications de séparation des + * pouvoirs (SoD). + * + *
Cette entité ne dérive PAS de {@link BaseEntity} car elle représente un enregistrement + * immuable d'historique : ses propres champs d'audit ({@code operationAt}, {@code userId}) sont + * la donnée à tracer. + * + * @since 2026-04-25 — exigences SYSCOHADA + Instruction BCEAO 003-03-2025 (audit KYC) + */ +@Entity +@Table(name = "audit_trail_operations") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AuditTrailOperation { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", updatable = false, nullable = false) + private UUID id; + + // Acteur + @NotNull + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "user_email", length = 255) + private String userEmail; + + @Column(name = "role_actif", length = 50) + private String roleActif; + + @Column(name = "organisation_active_id") + private UUID organisationActiveId; + + // Action + @NotBlank + @Column(name = "action_type", nullable = false, length = 50) + private String actionType; + + @NotBlank + @Column(name = "entity_type", nullable = false, length = 100) + private String entityType; + + @Column(name = "entity_id") + private UUID entityId; + + @Column(name = "description", length = 500) + private String description; + + // Contexte + @Column(name = "ip_address", length = 45) + private String ipAddress; + + @Column(name = "user_agent", length = 500) + private String userAgent; + + @Column(name = "request_id") + private UUID requestId; + + // Données (JSONB) + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload_avant", columnDefinition = "jsonb") + private String payloadAvant; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "payload_apres", columnDefinition = "jsonb") + private String payloadApres; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", columnDefinition = "jsonb") + private String metadata; + + // SoD + @Column(name = "sod_check_passed") + private Boolean sodCheckPassed; + + @Column(name = "sod_violations", length = 500) + private String sodViolations; + + @NotNull + @Column(name = "operation_at", nullable = false) + @Builder.Default + private LocalDateTime operationAt = LocalDateTime.now(); +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/BeneficiaireEffectif.java b/src/main/java/dev/lions/unionflow/server/entity/BeneficiaireEffectif.java new file mode 100644 index 0000000..7414bd7 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/BeneficiaireEffectif.java @@ -0,0 +1,168 @@ +package dev.lions.unionflow.server.entity; + +import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite; +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Bénéficiaire effectif (UBO — Ultimate Beneficial Owner) lié à un dossier KYC. + * + *
Implémente l'obligation introduite par l'Instruction BCEAO 003-03-2025 du 18 mars + * 2025 : identification, vérification et connaissance du client par les institutions + * financières — vérification systématique des bénéficiaires effectifs obligatoire (approche par + * les risques). + * + *
Un bénéficiaire effectif est, selon la directive UEMOA et le GAFI/FATF, toute personne + * physique qui : + * + *
Ces enregistrements doivent être conservés 10 ans après la clôture de la + * relation d'affaires (directive 02/2015/CM/UEMOA). + * + * @since 2026-04-25 — Instruction BCEAO 003-03-2025 (KYC + UBO) + */ +@Entity +@Table( + name = "beneficiaires_effectifs", + indexes = { + @Index(name = "idx_ubo_kyc_dossier", columnList = "kyc_dossier_id"), + @Index(name = "idx_ubo_organisation_cible", columnList = "organisation_cible_id"), + @Index(name = "idx_ubo_pays", columnList = "pays_residence") + }) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = true) +public class BeneficiaireEffectif extends BaseEntity { + + /** Dossier KYC auquel ce bénéficiaire effectif est rattaché. */ + @NotNull + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "kyc_dossier_id", nullable = false) + private KycDossier kycDossier; + + /** + * Organisation cible dont cette personne est bénéficiaire effectif (utile en cas de KYC client + * personne morale — la chaîne de contrôle UBO peut traverser plusieurs entités). + */ + @Column(name = "organisation_cible_id") + private UUID organisationCibleId; + + /** Lien vers le membre UnionFlow correspondant si applicable (UBO interne au système). */ + @Column(name = "membre_id") + private UUID membreId; + + // Identité + + @NotBlank + @Column(name = "nom", nullable = false, length = 100) + private String nom; + + @NotBlank + @Column(name = "prenoms", nullable = false, length = 200) + private String prenoms; + + @NotNull + @Column(name = "date_naissance", nullable = false) + private LocalDate dateNaissance; + + @Column(name = "lieu_naissance", length = 200) + private String lieuNaissance; + + @NotBlank + @Column(name = "nationalite", nullable = false, length = 3) + private String nationalite; // ISO 3166-1 alpha-3 + + @Column(name = "pays_residence", length = 3) + private String paysResidence; + + // Pièce d'identité + + @Enumerated(EnumType.STRING) + @Column(name = "type_piece_identite", length = 30) + private TypePieceIdentite typePieceIdentite; + + @Column(name = "numero_piece_identite", length = 50) + private String numeroPieceIdentite; + + @Column(name = "date_expiration_piece") + private LocalDate dateExpirationPiece; + + // Contrôle + + /** + * Pourcentage de détention en capital (0-100). Si {@code >= 25} → UBO direct selon GAFI. + * Peut être null si le contrôle est exercé autrement (mandat, accord d'actionnaires). + */ + @DecimalMin("0.00") + @DecimalMax("100.00") + @Column(name = "pourcentage_capital", precision = 5, scale = 2) + private BigDecimal pourcentageCapital; + + /** Pourcentage des droits de vote (0-100). */ + @DecimalMin("0.00") + @DecimalMax("100.00") + @Column(name = "pourcentage_droits_vote", precision = 5, scale = 2) + private BigDecimal pourcentageDroitsVote; + + /** + * Nature du contrôle exercé : DETENTION_CAPITAL, DROITS_VOTE, CONTROLE_DE_FAIT, + * BENEFICIAIRE_ULTIME, MANDAT_REPRESENTATION. + */ + @NotBlank + @Column(name = "nature_controle", nullable = false, length = 50) + private String natureControle; + + // Politique d'exposition (PEP) + + @Column(name = "est_pep", nullable = false) + @Builder.Default + private boolean estPep = false; + + @Column(name = "pep_categorie", length = 100) + private String pepCategorie; + + @Column(name = "pep_pays", length = 3) + private String pepPays; + + @Column(name = "pep_fonction", length = 200) + private String pepFonction; + + // Sanctions / vigilance + + @Column(name = "presence_listes_sanctions", nullable = false) + @Builder.Default + private boolean presenceListesSanctions = false; + + @Column(name = "details_listes_sanctions", length = 1000) + private String detailsListesSanctions; + + // Vérification + + @Column(name = "verifie_par_id") + private UUID verifieParId; + + @Column(name = "date_verification") + private java.time.LocalDateTime dateVerification; + + @Column(name = "source_verification", length = 200) + private String sourceVerification; + + @Column(name = "notes", length = 2000) + private String notes; +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/Membre.java b/src/main/java/dev/lions/unionflow/server/entity/Membre.java index a8e0565..bd83e69 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/Membre.java +++ b/src/main/java/dev/lions/unionflow/server/entity/Membre.java @@ -63,6 +63,20 @@ public class Membre extends BaseEntity { @Column(name = "fcm_token", length = 500) private String fcmToken; + /** + * Numéro CMU (Couverture Maladie Universelle) Côte d'Ivoire — auto-déclaré par le membre. + * + *
Obligatoire pour les organisations de type {@code MUTUELLE_SANTE} (Loi 2014-131 + * exige enrôlement CMU comme préalable à toute mutuelle complémentaire). Format CNAM : + * 11 caractères alphanumériques. La vérification de la validité se fait manuellement + * (admin) faute d'API publique CNAM disponible au 2026-04-25. + * + * @since 2026-04-25 — passage CMU à cotisation obligatoire 1er jan 2026 + */ + @Pattern(regexp = "^[A-Z0-9]{11}$|^$", message = "Le numéro CMU doit faire 11 caractères alphanumériques majuscules") + @Column(name = "numero_cmu", length = 11) + private String numeroCMU; + @Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)") @Column(name = "telephone_wave", length = 20) private String telephoneWave; diff --git a/src/main/java/dev/lions/unionflow/server/entity/Organisation.java b/src/main/java/dev/lions/unionflow/server/entity/Organisation.java index eeb84ac..cd1b96b 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/Organisation.java +++ b/src/main/java/dev/lions/unionflow/server/entity/Organisation.java @@ -210,6 +210,30 @@ public class Organisation extends BaseEntity { @Column(name = "modules_actifs", length = 1000) private String modulesActifs; + /** + * Référentiel comptable applicable à cette organisation. + * + *
Détermine quel plan comptable est appliqué et quels états financiers sont générés + * (bilan, compte de résultat, annexes). Mappage par défaut depuis {@code typeOrganisation} + * via {@link ReferentielComptable#defaultFor(String)} ; l'admin peut overrider manuellement. + * + * @since 2026-04-25 — découverte SYCEBNL (11ᵉ Acte uniforme OHADA en vigueur 1er jan 2024) + */ + @Enumerated(EnumType.STRING) + @Column(name = "referentiel_comptable", nullable = false, length = 20) + @Builder.Default + private ReferentielComptable referentielComptable = ReferentielComptable.SYSCOHADA; + + /** + * UUID du membre désigné comme Compliance Officer de l'organisation (rôle obligatoire selon + * Instruction BCEAO 001-03-2025). Doit être rattaché à la direction générale, distinct du + * trésorier (séparation des pouvoirs). + * + * @since 2026-04-25 — Instruction BCEAO 001-03-2025 (LBC/FT) + */ + @Column(name = "compliance_officer_id") + private UUID complianceOfficerId; + // Relations /** Adhésions des membres à cette organisation */ diff --git a/src/main/java/dev/lions/unionflow/server/entity/ReferentielComptable.java b/src/main/java/dev/lions/unionflow/server/entity/ReferentielComptable.java new file mode 100644 index 0000000..cee5a18 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/ReferentielComptable.java @@ -0,0 +1,68 @@ +package dev.lions.unionflow.server.entity; + +/** + * Référentiel comptable applicable à une {@link Organisation}. + * + *
OHADA dispose désormais de plusieurs référentiels selon la nature de l'entité : + * + *
Le mapping par défaut depuis {@code Organisation.typeOrganisation} se trouve dans + * {@link #defaultFor(String)}. L'admin peut overrider manuellement (cas hybrides). + * + *
Voir : {@code unionflow/docs/COMPLIANCE_OHADA_SYCEBNL.md} et {@code
+ * unionflow/docs/COMPLIANCE_OHADA_SYSCOHADA.md}.
+ *
+ * @since 2026-04-25
+ */
+public enum ReferentielComptable {
+ /** Système Comptable OHADA révisé (entités commerciales / coopératives lucratives). */
+ SYSCOHADA,
+
+ /**
+ * Système Comptable OHADA des Entités à But Non Lucratif (mutuelles sociales, associations,
+ * ONG, fondations, Lions Clubs, syndicats). Acte uniforme entré en vigueur 1er janvier 2024.
+ */
+ SYCEBNL,
+
+ /**
+ * Plan Comptable des Systèmes Financiers Décentralisés UMOA. Pour SFD article 44 (encours ≥ 2
+ * Md FCFA = catégorie III, commissaire aux comptes obligatoire agréé OHADA, sélection soumise
+ * approbation Commission Bancaire UMOA).
+ */
+ PCSFD_UMOA;
+
+ /**
+ * Retourne le référentiel par défaut suggéré pour un {@code typeOrganisation}. L'admin peut
+ * overrider manuellement à la création/édition d'une organisation.
+ *
+ * @param typeOrganisation valeur de {@link Organisation#getTypeOrganisation()}
+ * @return référentiel par défaut, jamais null (fallback {@link #SYSCOHADA})
+ */
+ public static ReferentielComptable defaultFor(String typeOrganisation) {
+ if (typeOrganisation == null) {
+ return SYSCOHADA;
+ }
+ return switch (typeOrganisation.toUpperCase()) {
+ case "MUTUELLE_SANTE",
+ "ASSOCIATION",
+ "LIONS_CLUB",
+ "ONG",
+ "FONDATION",
+ "SYNDICAT",
+ "ORDRE_PROFESSIONNEL",
+ "PROJET_DEVELOPPEMENT" ->
+ SYCEBNL;
+ case "SFD_TIER_1", "SFD_CATEGORIE_III" -> PCSFD_UMOA;
+ default -> SYSCOHADA;
+ };
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java b/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java
new file mode 100644
index 0000000..52ce672
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java
@@ -0,0 +1,50 @@
+package dev.lions.unionflow.server.repository;
+
+import dev.lions.unionflow.server.entity.AuditTrailOperation;
+import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
+import jakarta.enterprise.context.ApplicationScoped;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Repository Panache pour l'audit trail enrichi.
+ *
+ * @since 2026-04-25
+ */
+@ApplicationScoped
+public class AuditTrailOperationRepository
+ implements PanacheRepositoryBase Enregistre toutes les opérations sensibles (financières, lifecycle membres, configurations)
+ * avec leur contexte multi-org complet (rôle actif, organisation active, vérifications SoD).
+ *
+ * Usage typique dans un service métier :
+ *
+ * Implémente les règles SoD exigées par :
+ *
+ * Les règles principales :
+ *
+ * Règle fondamentale : celui qui crée ne peut pas valider (4-eyes).
+ *
+ * @param creatorUserId UUID du créateur de l'opération (de la dépense, du paiement, etc.)
+ * @param validatorUserId UUID de l'utilisateur qui tente de valider
+ * @param entityType nom de l'entité (pour audit trail)
+ * @param entityId UUID de l'entité ciblée
+ * @return {@link SoDCheckResult#PASS} si la validation est autorisée ; sinon {@link
+ * SoDCheckResult#VIOLATION}
+ */
+ public SoDCheckResult checkValidationDistinct(UUID creatorUserId, UUID validatorUserId,
+ String entityType, UUID entityId) {
+ if (creatorUserId == null || validatorUserId == null) {
+ return SoDCheckResult.PASS; // pas de contexte = on laisse passer (audit doit alerter)
+ }
+ if (creatorUserId.equals(validatorUserId)) {
+ String violation = String.format(
+ "SoD VIOLATION: même utilisateur (%s) a créé ET valide cette opération sur %s/%s",
+ validatorUserId, entityType, entityId);
+ LOG.warn(violation);
+ auditTrail.log(entityType, entityId, "SOD_OVERRIDE",
+ "Tentative de validation par le créateur",
+ null, null, null, false, violation);
+ return new SoDCheckResult(false, violation);
+ }
+ return SoDCheckResult.PASS;
+ }
+
+ /**
+ * Vérifie qu'un utilisateur n'a pas une combinaison de rôles incompatibles.
+ *
+ * Combinaisons interdites par défaut :
+ *
+ * Ces seuils déclenchent :
+ *
+ * Référence : Instruction
+ * BCEAO 001-03-2025.
+ *
+ * @since 2026-04-25
+ */
+public final class AmlSeuils {
+
+ private AmlSeuils() {}
+
+ /**
+ * Seuil intra-UEMOA (entre deux pays UMOA) au-delà duquel la transaction est soumise à
+ * surveillance et obligations de déclaration : 10 000 000 FCFA.
+ *
+ * Source : Instruction BCEAO 002-03-2025 du 18 mars 2025.
+ */
+ public static final BigDecimal SEUIL_INTRA_UEMOA_FCFA = new BigDecimal("10000000");
+
+ /**
+ * Seuil entrée/sortie territoire UEMOA (transaction transfrontalière hors UEMOA) déclenchant
+ * surveillance renforcée : 5 000 000 FCFA.
+ *
+ * Source : Instruction BCEAO 002-03-2025 du 18 mars 2025.
+ */
+ public static final BigDecimal SEUIL_ENTREE_SORTIE_UEMOA_FCFA = new BigDecimal("5000000");
+
+ /**
+ * Seuil unique par opération espèce (paiement direct cash) déclenchant identification
+ * renforcée : 1 000 000 FCFA. Dérivé de la pratique GAFI (USD 15 000
+ * équivalent).
+ */
+ public static final BigDecimal SEUIL_OPERATION_ESPECE_FCFA = new BigDecimal("1000000");
+
+ /**
+ * Seuil cumulé sur 7 jours glissants pour détection de structuration (smurfing) :
+ * 5 000 000 FCFA (8 fois le seuil unique). Configurable.
+ */
+ public static final BigDecimal SEUIL_CUMUL_HEBDO_STRUCTURATION_FCFA = new BigDecimal("5000000");
+
+ /** Pays UEMOA (ISO 3166-1 alpha-3). */
+ public static final java.util.Set{@code
+ * @Inject AuditTrailService auditTrail;
+ *
+ * public Cotisation enregistrerPaiement(...) {
+ * Cotisation c = ...
+ * auditTrail.log("Cotisation", c.getId(), "PAYMENT_CONFIRMED",
+ * "Paiement confirmé via " + provider, c);
+ * return c;
+ * }
+ * }
+ *
+ * @since 2026-04-25
+ */
+@ApplicationScoped
+public class AuditTrailService {
+
+ private static final Logger LOG = Logger.getLogger(AuditTrailService.class);
+
+ @Inject AuditTrailOperationRepository repository;
+ @Inject OrganisationContextHolder context;
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /**
+ * Enregistre une entrée d'audit trail à partir du contexte courant.
+ *
+ * @param entityType nom de l'entité (ex: "Cotisation", "Membre", "EcritureComptable")
+ * @param entityId UUID de l'entité ciblée (peut être null pour actions globales)
+ * @param actionType type d'action (cf. CHECK SQL : CREATE, UPDATE, DELETE, APPROVE, ...)
+ * @param description description libre courte (≤ 500 caractères)
+ * @param payloadApres entité après modification (sérialisée JSON, peut être null)
+ */
+ @Transactional
+ public void log(String entityType, UUID entityId, String actionType, String description,
+ Object payloadApres) {
+ log(entityType, entityId, actionType, description, null, payloadApres, null, null, null);
+ }
+
+ /**
+ * Enregistre une entrée d'audit trail avec snapshot avant/après et résultat SoD.
+ */
+ @Transactional
+ public void log(String entityType, UUID entityId, String actionType, String description,
+ Object payloadAvant, Object payloadApres, Object metadata,
+ Boolean sodCheckPassed, String sodViolations) {
+ try {
+ AuditTrailOperation entry = AuditTrailOperation.builder()
+ .userId(context.getCurrentUserId())
+ .userEmail(context.getCurrentUserEmail())
+ .roleActif(context.getRoleActif())
+ .organisationActiveId(context.getOrganisationId())
+ .actionType(actionType)
+ .entityType(entityType)
+ .entityId(entityId)
+ .description(description)
+ .payloadAvant(toJson(payloadAvant))
+ .payloadApres(toJson(payloadApres))
+ .metadata(toJson(metadata))
+ .sodCheckPassed(sodCheckPassed)
+ .sodViolations(sodViolations)
+ .operationAt(LocalDateTime.now())
+ .build();
+ repository.persist(entry);
+ } catch (Exception e) {
+ // Fail-soft : l'audit trail ne doit jamais bloquer une opération métier.
+ // Les violations sont loguées et peuvent être détectées via les logs applicatifs.
+ LOG.errorf(e,
+ "Audit trail log failed: entityType=%s entityId=%s actionType=%s description=%s",
+ entityType, entityId, actionType, description);
+ }
+ }
+
+ /** Variante sans payload — pour les actions simples (LOGIN, LOGOUT, EXPORT...). */
+ @Transactional
+ public void logSimple(String entityType, UUID entityId, String actionType, String description) {
+ log(entityType, entityId, actionType, description, null);
+ }
+
+ private String toJson(Object o) {
+ if (o == null) return null;
+ try {
+ return objectMapper.writeValueAsString(o);
+ } catch (Exception e) {
+ LOG.warnf("Audit trail JSON serialization failed for %s : %s",
+ o.getClass().getSimpleName(), e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java
index 6061d39..7c537d9 100644
--- a/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java
+++ b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java
@@ -28,6 +28,39 @@ public class OrganisationContextHolder {
private Organisation organisation;
private boolean resolved = false;
+ /** Rôle actif sélectionné par le user pour cette requête (header X-Active-Role). */
+ private String roleActif;
+
+ /** UUID de l'utilisateur courant (sub du JWT). */
+ private UUID currentUserId;
+
+ /** Email de l'utilisateur courant (claim email du JWT). */
+ private String currentUserEmail;
+
+ public String getRoleActif() {
+ return roleActif;
+ }
+
+ public void setRoleActif(String roleActif) {
+ this.roleActif = roleActif;
+ }
+
+ public UUID getCurrentUserId() {
+ return currentUserId;
+ }
+
+ public void setCurrentUserId(UUID currentUserId) {
+ this.currentUserId = currentUserId;
+ }
+
+ public String getCurrentUserEmail() {
+ return currentUserEmail;
+ }
+
+ public void setCurrentUserEmail(String currentUserEmail) {
+ this.currentUserEmail = currentUserEmail;
+ }
+
public UUID getOrganisationId() {
return organisationId;
}
diff --git a/src/main/java/dev/lions/unionflow/server/security/SoDPermissionChecker.java b/src/main/java/dev/lions/unionflow/server/security/SoDPermissionChecker.java
new file mode 100644
index 0000000..bd2d7803
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/security/SoDPermissionChecker.java
@@ -0,0 +1,150 @@
+package dev.lions.unionflow.server.security;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import java.util.Set;
+import java.util.UUID;
+import org.jboss.logging.Logger;
+
+/**
+ * Vérificateur de séparation des pouvoirs (Separation of Duties — SoD).
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @since 2026-04-25
+ */
+@ApplicationScoped
+public class SoDPermissionChecker {
+
+ private static final Logger LOG = Logger.getLogger(SoDPermissionChecker.class);
+
+ @Inject AuditTrailService auditTrail;
+
+ /**
+ * Vérifie qu'un utilisateur peut valider une opération qu'un autre a créée.
+ *
+ *
+ *
+ *
+ * @param userId UUID de l'utilisateur
+ * @param userRoles ensemble des rôles actuels de l'utilisateur dans l'organisation active
+ * @return résultat de la vérification
+ */
+ public SoDCheckResult checkRoleCombination(UUID userId, Set
+ *
+ *
+ *