Skip to main content

koprogo_api/domain/plateforme/
security_incident.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Sévérité d'un incident de sécurité (GDPR Art. 33)
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum IncidentSeverity {
8    Critical,
9    High,
10    Medium,
11    Low,
12}
13
14impl std::fmt::Display for IncidentSeverity {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        match self {
17            IncidentSeverity::Critical => write!(f, "critical"),
18            IncidentSeverity::High => write!(f, "high"),
19            IncidentSeverity::Medium => write!(f, "medium"),
20            IncidentSeverity::Low => write!(f, "low"),
21        }
22    }
23}
24
25impl std::str::FromStr for IncidentSeverity {
26    type Err = String;
27    fn from_str(s: &str) -> Result<Self, Self::Err> {
28        match s.to_lowercase().as_str() {
29            "critical" => Ok(IncidentSeverity::Critical),
30            "high" => Ok(IncidentSeverity::High),
31            "medium" => Ok(IncidentSeverity::Medium),
32            "low" => Ok(IncidentSeverity::Low),
33            _ => Err(format!("Invalid severity: {}", s)),
34        }
35    }
36}
37
38/// Statut d'un incident de sécurité
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub enum IncidentStatus {
41    Detected,
42    Investigating,
43    Contained,
44    Reported, // Notifié à l'APD (Art. 33 GDPR — délai 72h)
45    Closed,
46}
47
48impl std::fmt::Display for IncidentStatus {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            IncidentStatus::Detected => write!(f, "detected"),
52            IncidentStatus::Investigating => write!(f, "investigating"),
53            IncidentStatus::Contained => write!(f, "contained"),
54            IncidentStatus::Reported => write!(f, "reported"),
55            IncidentStatus::Closed => write!(f, "closed"),
56        }
57    }
58}
59
60impl std::str::FromStr for IncidentStatus {
61    type Err = String;
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        match s.to_lowercase().as_str() {
64            "detected" => Ok(IncidentStatus::Detected),
65            "investigating" => Ok(IncidentStatus::Investigating),
66            "contained" => Ok(IncidentStatus::Contained),
67            "reported" => Ok(IncidentStatus::Reported),
68            "closed" => Ok(IncidentStatus::Closed),
69            _ => Err(format!("Invalid incident status: {}", s)),
70        }
71    }
72}
73
74/// Incident de sécurité (GDPR Art. 33 — notification APD dans les 72h)
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SecurityIncident {
77    pub id: Uuid,
78    pub organization_id: Option<Uuid>,
79    pub severity: String,
80    pub incident_type: String,
81    pub title: String,
82    pub description: String,
83    pub data_categories_affected: Vec<String>,
84    pub affected_subjects_count: Option<i32>,
85    pub discovery_at: DateTime<Utc>,
86    pub notification_at: Option<DateTime<Utc>>,
87    pub apd_reference_number: Option<String>,
88    pub status: String,
89    pub reported_by: Uuid,
90    pub investigation_notes: Option<String>,
91    pub root_cause: Option<String>,
92    pub remediation_steps: Option<String>,
93    pub created_at: DateTime<Utc>,
94    pub updated_at: DateTime<Utc>,
95}
96
97impl SecurityIncident {
98    pub fn new(
99        organization_id: Option<Uuid>,
100        reported_by: Uuid,
101        severity: String,
102        incident_type: String,
103        title: String,
104        description: String,
105        data_categories_affected: Vec<String>,
106        affected_subjects_count: Option<i32>,
107    ) -> Result<Self, String> {
108        if title.is_empty() {
109            return Err("title is required".to_string());
110        }
111        if description.is_empty() {
112            return Err("description is required".to_string());
113        }
114        severity.parse::<IncidentSeverity>()?;
115
116        let now = Utc::now();
117        Ok(Self {
118            id: Uuid::new_v4(),
119            organization_id,
120            severity,
121            incident_type,
122            title,
123            description,
124            data_categories_affected,
125            affected_subjects_count,
126            discovery_at: now,
127            notification_at: None,
128            apd_reference_number: None,
129            status: IncidentStatus::Detected.to_string(),
130            reported_by,
131            investigation_notes: None,
132            root_cause: None,
133            remediation_steps: None,
134            created_at: now,
135            updated_at: now,
136        })
137    }
138
139    /// Heures depuis la découverte (délai APD Art. 33 GDPR = 72h)
140    pub fn hours_since_discovery(&self) -> f64 {
141        let duration = Utc::now().signed_duration_since(self.discovery_at);
142        duration.num_seconds() as f64 / 3600.0
143    }
144
145    /// Vrai si l'incident dépasse 72h sans notification APD
146    pub fn is_overdue_for_apd(&self) -> bool {
147        self.notification_at.is_none() && self.hours_since_discovery() > 72.0
148    }
149}
150
151#[cfg(test)]
152mod tests_art_33_rgpd {
153    use super::*;
154
155    fn incident(organization_id: Option<Uuid>) -> Result<SecurityIncident, String> {
156        SecurityIncident::new(
157            organization_id,
158            Uuid::new_v4(),
159            "high".to_string(),
160            "data_breach".to_string(),
161            "Fuite de base de données".to_string(),
162            "Accès non autorisé constaté sur le réplica de lecture".to_string(),
163            vec!["identite".to_string(), "coordonnees".to_string()],
164            Some(1200),
165        )
166    }
167
168    /// RGPD art. 33 : la notification incombe au **responsable du traitement**.
169    ///
170    /// Pour une violation qui touche une seule copropriété, c'est son syndic.
171    #[test]
172    fn happy_un_incident_rattache_a_une_organisation_est_valable() {
173        let org = Uuid::new_v4();
174        let i = incident(Some(org)).expect("incident valide");
175        assert_eq!(i.organization_id, Some(org));
176    }
177
178    /// Pour une violation de la plateforme elle-même — fuite de base,
179    /// compromission d'infrastructure — le responsable est l'exploitant, qui
180    /// n'est rattaché à aucune organisation.
181    ///
182    /// C'est le cas que la contrainte `NOT NULL` interdisait, alors que le
183    /// domaine le modélisait déjà : un superadmin, seul rôle autorisé sur ces
184    /// endpoints et seul dont le jeton porte `organization_id = NULL`, ne
185    /// pouvait déclarer aucune violation. Les endpoints de notification
186    /// étaient inutilisables par la seule personne censée s'en servir.
187    #[test]
188    fn security_un_incident_transverse_a_la_plateforme_est_valable() {
189        let i = incident(None).expect("un incident transverse est licite");
190        assert_eq!(
191            i.organization_id, None,
192            "l'absence d'organisation n'est pas une donnée manquante, c'est \
193             l'information : l'incident dépasse une copropriété"
194        );
195    }
196
197    #[test]
198    fn negative_un_incident_sans_titre_reste_refuse() {
199        let refus = SecurityIncident::new(
200            None,
201            Uuid::new_v4(),
202            "high".to_string(),
203            "data_breach".to_string(),
204            String::new(),
205            "Description".to_string(),
206            vec![],
207            None,
208        );
209        assert!(
210            refus.is_err(),
211            "la nullabilité de l'organisation n'assouplit rien d'autre"
212        );
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_new_incident_valid() {
222        let org_id = Uuid::new_v4();
223        let user_id = Uuid::new_v4();
224        let incident = SecurityIncident::new(
225            Some(org_id),
226            user_id,
227            "high".to_string(),
228            "data_breach".to_string(),
229            "Test incident".to_string(),
230            "Description".to_string(),
231            vec!["email".to_string()],
232            Some(10),
233        );
234        assert!(incident.is_ok());
235        let inc = incident.unwrap();
236        assert_eq!(inc.status, "detected");
237        assert!(inc.notification_at.is_none());
238    }
239
240    #[test]
241    fn test_new_incident_empty_title() {
242        let result = SecurityIncident::new(
243            None,
244            Uuid::new_v4(),
245            "low".to_string(),
246            "unauthorized_access".to_string(),
247            "".to_string(),
248            "desc".to_string(),
249            vec![],
250            None,
251        );
252        assert!(result.is_err());
253        assert!(result.unwrap_err().contains("title"));
254    }
255
256    #[test]
257    fn test_new_incident_invalid_severity() {
258        let result = SecurityIncident::new(
259            None,
260            Uuid::new_v4(),
261            "extreme".to_string(),
262            "malware".to_string(),
263            "title".to_string(),
264            "desc".to_string(),
265            vec![],
266            None,
267        );
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn test_hours_since_discovery() {
273        let org_id = Uuid::new_v4();
274        let incident = SecurityIncident::new(
275            Some(org_id),
276            Uuid::new_v4(),
277            "critical".to_string(),
278            "data_breach".to_string(),
279            "Test".to_string(),
280            "Desc".to_string(),
281            vec![],
282            None,
283        )
284        .unwrap();
285        let hours = incident.hours_since_discovery();
286        assert!(hours >= 0.0 && hours < 0.1); // just created
287    }
288
289    #[test]
290    fn test_is_overdue_for_apd_new_incident() {
291        let incident = SecurityIncident::new(
292            None,
293            Uuid::new_v4(),
294            "high".to_string(),
295            "breach".to_string(),
296            "title".to_string(),
297            "desc".to_string(),
298            vec![],
299            None,
300        )
301        .unwrap();
302        // New incident is not overdue yet
303        assert!(!incident.is_overdue_for_apd());
304    }
305
306    #[test]
307    fn test_severity_parse() {
308        assert!("critical".parse::<IncidentSeverity>().is_ok());
309        assert!("high".parse::<IncidentSeverity>().is_ok());
310        assert!("invalid".parse::<IncidentSeverity>().is_err());
311    }
312
313    #[test]
314    fn test_status_display() {
315        assert_eq!(IncidentStatus::Detected.to_string(), "detected");
316        assert_eq!(IncidentStatus::Reported.to_string(), "reported");
317    }
318}