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}