koprogo_api/domain/entities/
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 {
153    use super::*;
154
155    #[test]
156    fn test_new_incident_valid() {
157        let org_id = Uuid::new_v4();
158        let user_id = Uuid::new_v4();
159        let incident = SecurityIncident::new(
160            Some(org_id),
161            user_id,
162            "high".to_string(),
163            "data_breach".to_string(),
164            "Test incident".to_string(),
165            "Description".to_string(),
166            vec!["email".to_string()],
167            Some(10),
168        );
169        assert!(incident.is_ok());
170        let inc = incident.unwrap();
171        assert_eq!(inc.status, "detected");
172        assert!(inc.notification_at.is_none());
173    }
174
175    #[test]
176    fn test_new_incident_empty_title() {
177        let result = SecurityIncident::new(
178            None,
179            Uuid::new_v4(),
180            "low".to_string(),
181            "unauthorized_access".to_string(),
182            "".to_string(),
183            "desc".to_string(),
184            vec![],
185            None,
186        );
187        assert!(result.is_err());
188        assert!(result.unwrap_err().contains("title"));
189    }
190
191    #[test]
192    fn test_new_incident_invalid_severity() {
193        let result = SecurityIncident::new(
194            None,
195            Uuid::new_v4(),
196            "extreme".to_string(),
197            "malware".to_string(),
198            "title".to_string(),
199            "desc".to_string(),
200            vec![],
201            None,
202        );
203        assert!(result.is_err());
204    }
205
206    #[test]
207    fn test_hours_since_discovery() {
208        let org_id = Uuid::new_v4();
209        let incident = SecurityIncident::new(
210            Some(org_id),
211            Uuid::new_v4(),
212            "critical".to_string(),
213            "data_breach".to_string(),
214            "Test".to_string(),
215            "Desc".to_string(),
216            vec![],
217            None,
218        )
219        .unwrap();
220        let hours = incident.hours_since_discovery();
221        assert!(hours >= 0.0 && hours < 0.1); // just created
222    }
223
224    #[test]
225    fn test_is_overdue_for_apd_new_incident() {
226        let incident = SecurityIncident::new(
227            None,
228            Uuid::new_v4(),
229            "high".to_string(),
230            "breach".to_string(),
231            "title".to_string(),
232            "desc".to_string(),
233            vec![],
234            None,
235        )
236        .unwrap();
237        // New incident is not overdue yet
238        assert!(!incident.is_overdue_for_apd());
239    }
240
241    #[test]
242    fn test_severity_parse() {
243        assert!("critical".parse::<IncidentSeverity>().is_ok());
244        assert!("high".parse::<IncidentSeverity>().is_ok());
245        assert!("invalid".parse::<IncidentSeverity>().is_err());
246    }
247
248    #[test]
249    fn test_status_display() {
250        assert_eq!(IncidentStatus::Detected.to_string(), "detected");
251        assert_eq!(IncidentStatus::Reported.to_string(), "reported");
252    }
253}