1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub enum IncidentStatus {
41 Detected,
42 Investigating,
43 Contained,
44 Reported, 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#[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 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 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 #[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 #[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); }
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 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}