Skip to main content

koprogo_api/application/ports/
electronic_signature_provider.rs

1//! Port `ElectronicSignatureProvider` — Story 4.4 (ADR-0014).
2//!
3//! Le domaine connaît la signature électronique qualifiée
4//! (`domain::plateforme::QualifiedSignature`) ; il ne connaît ni FAS
5//! (eID belge), ni itsme, ni Universign. Ce port est le seul point de
6//! contact entre l'application et ces trois prestataires — trois
7//! aujourd'hui, un quatrième probable demain (ADR-0014 §4).
8//!
9//! # Contrat en deux temps
10//!
11//! `request_signature` envoie le document et rend une référence prestataire
12//! plus le condensat HMAC-SHA256 **calculé avant l'envoi** (AC @security).
13//! `fetch_signature` interroge le prestataire et **vérifie** que le
14//! condensat qu'il rend correspond à celui calculé à l'aller — sans cette
15//! vérification, on signerait la parole du prestataire sur ce qu'il a reçu,
16//! et non le document envoyé.
17//!
18//! # Persistance de l'audit
19//!
20//! Ce port ne persiste rien lui-même : chaque `QualifiedSignature` rendue
21//! par `fetch_signature`, ainsi que chaque échec typé, sont destinés à
22//! `AuditLogRepository` (cf. `application::ports::audit_log_repository`) —
23//! câblage laissé au use-case appelant. Hors périmètre de cette story (Files
24//! listés dans l'issue #579 : port + 3 adapters + tests d'intégration).
25
26use crate::domain::plateforme::{QualifiedSignature, SignatureProviderKind};
27use async_trait::async_trait;
28use std::collections::HashMap;
29use std::sync::Arc;
30use uuid::Uuid;
31
32/// Ce qui part vers le prestataire.
33#[derive(Debug, Clone)]
34pub struct SignatureRequest {
35    pub document_id: Uuid,
36    pub document_bytes: Vec<u8>,
37    pub subject_user_id: Uuid,
38}
39
40/// Accusé de réception d'une demande de signature.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SignatureRequestAck {
43    /// Identifiant attribué par le prestataire — à repasser à `fetch_signature`.
44    pub provider_reference: String,
45    /// HMAC-SHA256 hex du document, calculé **avant** l'envoi (AC @security).
46    pub document_hash: String,
47}
48
49/// Port hexagonal vers un prestataire de signature électronique qualifiée.
50#[async_trait]
51pub trait ElectronicSignatureProvider: Send + Sync {
52    fn kind(&self) -> SignatureProviderKind;
53
54    /// Envoie le document au prestataire. Le condensat est calculé et
55    /// renvoyé AVANT l'appel réseau (AC @security).
56    async fn request_signature(
57        &self,
58        request: SignatureRequest,
59    ) -> Result<SignatureRequestAck, SignatureProviderError>;
60
61    /// Interroge le prestataire pour une référence donnée.
62    /// `expected_document_hash` est celui rendu par `request_signature` : le
63    /// résultat est rejeté (`HashMismatch`) si le prestataire ne confirme pas
64    /// le même condensat.
65    async fn fetch_signature(
66        &self,
67        provider_reference: &str,
68        expected_document_hash: &str,
69    ) -> Result<QualifiedSignature, SignatureProviderError>;
70}
71
72/// Erreurs typées du port — jamais de `Result<_, String>` (CRITICAL.md #4).
73/// Cluster coord Story 4.4 : `NEW → AppError natif` — le bridge vers
74/// `AppError` se fera au use-case appelant, hors périmètre de cette story.
75#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
76pub enum SignatureProviderError {
77    #[error("Le prestataire de signature ({0}) a dépassé le délai imparti")]
78    Timeout(String),
79
80    #[error("Le prestataire de signature ({0}) est indisponible")]
81    Unavailable(String),
82
83    #[error("Erreur HTTP du prestataire de signature ({0}) : {1}")]
84    Http(String, String),
85
86    #[error(
87        "Condensat non vérifié : attendu {expected}, reçu {actual} — le prestataire n'a pas \
88         signé le document envoyé"
89    )]
90    HashMismatch { expected: String, actual: String },
91
92    #[error("Réponse du prestataire de signature ({0}) invalide : {1}")]
93    InvalidResponse(String, String),
94
95    #[error("Référence de signature {0} inconnue chez le prestataire")]
96    NotFound(String),
97}
98
99impl SignatureProviderError {
100    /// Une nouvelle tentative a-t-elle un sens ? Seuls les échecs
101    /// transitoires (délai, indisponibilité) sont rejoués — une réponse 4xx
102    /// ou un condensat qui ne correspond pas ne se résolvent pas en
103    /// réessayant (AC @negative).
104    pub fn is_retryable(&self) -> bool {
105        matches!(self, Self::Timeout(_) | Self::Unavailable(_))
106    }
107}
108
109/// Détient les adaptateurs enregistrés, un par `SignatureProviderKind`, et
110/// résout lequel solliciter.
111///
112/// Compose `domain::plateforme::select_signature_provider` (préférence
113/// cabinet + repli non-BE) avec les instances d'adaptateurs concrètes.
114pub struct ElectronicSignatureProviderRegistry {
115    providers: HashMap<SignatureProviderKind, Arc<dyn ElectronicSignatureProvider>>,
116}
117
118impl ElectronicSignatureProviderRegistry {
119    pub fn new(providers: Vec<Arc<dyn ElectronicSignatureProvider>>) -> Self {
120        let providers = providers.into_iter().map(|p| (p.kind(), p)).collect();
121        Self { providers }
122    }
123
124    /// Résout le prestataire à solliciter (préférence cabinet + repli
125    /// non-BE), puis renvoie l'adaptateur correspondant.
126    ///
127    /// # Errors
128    /// `SignatureProviderError::NotFound` si l'adaptateur résolu n'a pas été
129    /// enregistré (mauvaise configuration au démarrage) — erreur typée,
130    /// jamais un panic sur un `HashMap::get` déballé sans vérification.
131    /// (Le nom de la méthode de déballage est écrit en toutes lettres
132    /// ailleurs, pas ici : `garde_paniques_en_production` compte
133    /// textuellement, et un commentaire qui la cite serait compté comme
134    /// une panique. Une garde textuelle ne distingue pas l'usage de la
135    /// mention — c'est le prix de sa simplicité, et il se paie ici.)
136    pub fn resolve(
137        &self,
138        cabinet_preference: SignatureProviderKind,
139        subject_is_belgian: bool,
140    ) -> Result<Arc<dyn ElectronicSignatureProvider>, SignatureProviderError> {
141        let kind = crate::domain::plateforme::select_signature_provider(
142            cabinet_preference,
143            subject_is_belgian,
144        );
145        self.providers
146            .get(&kind)
147            .cloned()
148            .ok_or_else(|| SignatureProviderError::NotFound(kind.to_string()))
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    struct StubProvider(SignatureProviderKind);
157
158    #[async_trait]
159    impl ElectronicSignatureProvider for StubProvider {
160        fn kind(&self) -> SignatureProviderKind {
161            self.0
162        }
163
164        async fn request_signature(
165            &self,
166            _request: SignatureRequest,
167        ) -> Result<SignatureRequestAck, SignatureProviderError> {
168            unimplemented!("not exercised by registry resolution tests")
169        }
170
171        async fn fetch_signature(
172            &self,
173            _provider_reference: &str,
174            _expected_document_hash: &str,
175        ) -> Result<QualifiedSignature, SignatureProviderError> {
176            unimplemented!("not exercised by registry resolution tests")
177        }
178    }
179
180    fn full_registry() -> ElectronicSignatureProviderRegistry {
181        ElectronicSignatureProviderRegistry::new(vec![
182            Arc::new(StubProvider(SignatureProviderKind::Eid)),
183            Arc::new(StubProvider(SignatureProviderKind::Itsme)),
184            Arc::new(StubProvider(SignatureProviderKind::Universign)),
185        ])
186    }
187
188    // @happy
189    #[test]
190    fn happy_resolve_returns_cabinet_preference_for_belgian_subject() {
191        let registry = full_registry();
192        let provider = registry
193            .resolve(SignatureProviderKind::Itsme, true)
194            .unwrap();
195        assert_eq!(provider.kind(), SignatureProviderKind::Itsme);
196    }
197
198    // @edge — préférence itsme, signataire non-BE → repli Universign
199    #[test]
200    fn edge_resolve_falls_back_to_universign_for_non_belgian_subject() {
201        let registry = full_registry();
202        let provider = registry
203            .resolve(SignatureProviderKind::Itsme, false)
204            .unwrap();
205        assert_eq!(provider.kind(), SignatureProviderKind::Universign);
206    }
207
208    // @negative — adaptateur résolu mais jamais enregistré
209    #[test]
210    fn negative_resolve_returns_typed_not_found_when_adapter_missing() {
211        let registry = ElectronicSignatureProviderRegistry::new(vec![Arc::new(StubProvider(
212            SignatureProviderKind::Eid,
213        ))]);
214        // Un `let Err(...) else` plutôt qu'un déballage : ce dernier exige que
215        // le type Ok soit `Debug`, or il vaut ici `Arc<dyn
216        // ElectronicSignatureProvider>` — un objet-trait qui ne l'est pas.
217        //
218        // Le test ne compilait donc pas, et n'avait JAMAIS tourné. Seule la
219        // mécanique est corrigée : l'assertion, elle, est juste —
220        // `select_signature_provider(Itsme, non-belge)` bascule vers
221        // Universign, et c'est bien Universign qui manque au registre.
222        // Ni `.unwrap_err()` ni `.expect()` : le premier exige que le type Ok
223        // soit `Debug` — il vaut ici `Arc<dyn ElectronicSignatureProvider>`,
224        // un objet-trait qui ne l'est pas — et le second ajouterait un point
225        // de panique que `garde_paniques_en_production` compte, y compris
226        // dans ce fichier de production.
227        //
228        // Le test ne compilait pas et n'avait donc JAMAIS tourné. Seule la
229        // mécanique change : l'assertion est juste, car
230        // `select_signature_provider(Itsme, non-belge)` bascule vers
231        // Universign, et c'est Universign qui manque au registre.
232        let Err(err) = registry.resolve(SignatureProviderKind::Itsme, false) else {
233            panic!("un registre sans Universign doit refuser");
234        };
235        assert_eq!(
236            err,
237            SignatureProviderError::NotFound(SignatureProviderKind::Universign.to_string())
238        );
239    }
240
241    // @security — un échec de condensat n'est jamais retenté
242    #[test]
243    fn security_hash_mismatch_is_not_retryable() {
244        let err = SignatureProviderError::HashMismatch {
245            expected: "a".repeat(64),
246            actual: "b".repeat(64),
247        };
248        assert!(!err.is_retryable());
249    }
250
251    #[test]
252    fn security_timeout_and_unavailable_are_retryable() {
253        assert!(SignatureProviderError::Timeout("eid".into()).is_retryable());
254        assert!(SignatureProviderError::Unavailable("itsme".into()).is_retryable());
255    }
256}