koprogo_api/domain/entities/
security_incident.rs1use 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 {
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); }
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 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}