koprogo_api/infrastructure/web/handlers/
security_incident_handlers.rs1use crate::infrastructure::web::{AppState, AuthenticatedUser};
2use actix_web::{get, post, put, web, HttpRequest, HttpResponse, Responder};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6fn extract_ip_address(req: &HttpRequest) -> Option<String> {
8 req.headers()
9 .get("X-Forwarded-For")
10 .and_then(|h| h.to_str().ok())
11 .map(|s| s.split(',').next().unwrap_or("").trim().to_string())
12 .filter(|s| !s.is_empty())
13 .or_else(|| {
14 req.headers()
15 .get("X-Real-IP")
16 .and_then(|h| h.to_str().ok())
17 .map(|s| s.to_string())
18 })
19 .or_else(|| req.peer_addr().map(|addr| addr.ip().to_string()))
20}
21
22fn extract_user_agent(req: &HttpRequest) -> Option<String> {
24 req.headers()
25 .get("User-Agent")
26 .and_then(|h| h.to_str().ok())
27 .map(|s| s.to_string())
28}
29
30#[derive(Debug, Deserialize)]
31pub struct CreateSecurityIncidentRequest {
32 pub severity: String,
33 pub incident_type: String,
34 pub title: String,
35 pub description: String,
36 pub data_categories_affected: Vec<String>,
37 pub affected_subjects_count: Option<i32>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct ReportToApdRequest {
42 pub apd_reference_number: Option<String>,
43 pub investigation_notes: Option<String>,
44}
45
46#[derive(Debug, Serialize)]
47pub struct SecurityIncidentDto {
48 pub id: String,
49 pub organization_id: String,
50 pub severity: String,
51 pub incident_type: String,
52 pub title: String,
53 pub description: String,
54 pub data_categories_affected: Vec<String>,
55 pub affected_subjects_count: Option<i32>,
56 pub discovery_at: String,
57 pub notification_at: Option<String>,
58 pub apd_reference_number: Option<String>,
59 pub status: String,
60 pub reported_by: String,
61 pub investigation_notes: Option<String>,
62 pub root_cause: Option<String>,
63 pub remediation_steps: Option<String>,
64 pub created_at: String,
65 pub updated_at: String,
66 pub hours_since_discovery: f64,
67}
68
69#[derive(Debug, Serialize)]
70pub struct SecurityIncidentsResponse {
71 pub incidents: Vec<SecurityIncidentDto>,
72 pub total: i64,
73}
74
75#[derive(Debug, Deserialize)]
76pub struct SecurityIncidentsQuery {
77 pub page: Option<i64>,
78 pub per_page: Option<i64>,
79 pub severity: Option<String>,
80 pub status: Option<String>,
81}
82
83fn to_dto(inc: crate::domain::entities::SecurityIncident) -> SecurityIncidentDto {
84 let hours = inc.hours_since_discovery();
85 SecurityIncidentDto {
86 id: inc.id.to_string(),
87 organization_id: inc
88 .organization_id
89 .map(|u| u.to_string())
90 .unwrap_or_default(),
91 severity: inc.severity,
92 incident_type: inc.incident_type,
93 title: inc.title,
94 description: inc.description,
95 data_categories_affected: inc.data_categories_affected,
96 affected_subjects_count: inc.affected_subjects_count,
97 discovery_at: inc.discovery_at.to_rfc3339(),
98 notification_at: inc.notification_at.map(|dt| dt.to_rfc3339()),
99 apd_reference_number: inc.apd_reference_number,
100 status: inc.status,
101 reported_by: inc.reported_by.to_string(),
102 investigation_notes: inc.investigation_notes,
103 root_cause: inc.root_cause,
104 remediation_steps: inc.remediation_steps,
105 created_at: inc.created_at.to_rfc3339(),
106 updated_at: inc.updated_at.to_rfc3339(),
107 hours_since_discovery: hours,
108 }
109}
110
111#[post("/admin/security-incidents")]
113pub async fn create_security_incident(
114 req: HttpRequest,
115 data: web::Data<AppState>,
116 auth: AuthenticatedUser,
117 body: web::Json<CreateSecurityIncidentRequest>,
118) -> impl Responder {
119 if auth.role != "superadmin" {
120 return HttpResponse::Forbidden().json(serde_json::json!({
121 "error": "Access denied. SuperAdmin role required."
122 }));
123 }
124
125 let ip_address = extract_ip_address(&req);
126 let user_agent = extract_user_agent(&req);
127
128 match data
129 .security_incident_use_cases
130 .create(
131 auth.organization_id,
132 auth.user_id,
133 body.severity.clone(),
134 body.incident_type.clone(),
135 body.title.clone(),
136 body.description.clone(),
137 body.data_categories_affected.clone(),
138 body.affected_subjects_count,
139 )
140 .await
141 {
142 Ok(incident) => {
143 let audit_entry = crate::infrastructure::audit::AuditLogEntry::new(
144 crate::infrastructure::audit::AuditEventType::SecurityIncidentReported,
145 Some(auth.user_id),
146 auth.organization_id,
147 )
148 .with_resource("SecurityIncident", incident.id)
149 .with_client_info(ip_address, user_agent)
150 .with_metadata(serde_json::json!({
151 "severity": &incident.severity,
152 "incident_type": &incident.incident_type,
153 "affected_subjects": incident.affected_subjects_count
154 }));
155
156 let audit_logger = data.audit_logger.clone();
157 tokio::spawn(async move {
158 audit_logger.log(&audit_entry).await;
159 });
160
161 HttpResponse::Created().json(to_dto(incident))
162 }
163 Err(e) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
164 }
165}
166
167#[get("/admin/security-incidents")]
169pub async fn list_security_incidents(
170 data: web::Data<AppState>,
171 auth: AuthenticatedUser,
172 query: web::Query<SecurityIncidentsQuery>,
173) -> impl Responder {
174 if auth.role != "superadmin" {
175 return HttpResponse::Forbidden().json(serde_json::json!({
176 "error": "Access denied. SuperAdmin role required."
177 }));
178 }
179
180 let page = query.page.unwrap_or(1).max(1);
181 let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
182
183 match data
184 .security_incident_use_cases
185 .find_all(
186 auth.organization_id,
187 query.severity.clone(),
188 query.status.clone(),
189 page,
190 per_page,
191 )
192 .await
193 {
194 Ok((incidents, total)) => HttpResponse::Ok().json(SecurityIncidentsResponse {
195 incidents: incidents.into_iter().map(to_dto).collect(),
196 total,
197 }),
198 Err(e) => HttpResponse::InternalServerError()
199 .json(serde_json::json!({ "error": format!("Failed to fetch incidents: {}", e) })),
200 }
201}
202
203#[get("/admin/security-incidents/overdue")]
205pub async fn list_overdue_incidents(
206 data: web::Data<AppState>,
207 auth: AuthenticatedUser,
208) -> impl Responder {
209 if auth.role != "superadmin" {
210 return HttpResponse::Forbidden().json(serde_json::json!({
211 "error": "Access denied. SuperAdmin role required."
212 }));
213 }
214
215 match data
216 .security_incident_use_cases
217 .find_overdue(auth.organization_id)
218 .await
219 {
220 Ok(incidents) => {
221 let total = incidents.len() as i64;
222 HttpResponse::Ok().json(SecurityIncidentsResponse {
223 incidents: incidents.into_iter().map(to_dto).collect(),
224 total,
225 })
226 }
227 Err(e) => HttpResponse::InternalServerError().json(
228 serde_json::json!({ "error": format!("Failed to fetch overdue incidents: {}", e) }),
229 ),
230 }
231}
232
233#[get("/admin/security-incidents/{incident_id}")]
235pub async fn get_security_incident(
236 data: web::Data<AppState>,
237 auth: AuthenticatedUser,
238 path: web::Path<Uuid>,
239) -> impl Responder {
240 if auth.role != "superadmin" {
241 return HttpResponse::Forbidden().json(serde_json::json!({
242 "error": "Access denied. SuperAdmin role required."
243 }));
244 }
245
246 let incident_id = path.into_inner();
247 match data
248 .security_incident_use_cases
249 .find_by_id(incident_id, auth.organization_id)
250 .await
251 {
252 Ok(Some(incident)) => HttpResponse::Ok().json(to_dto(incident)),
253 Ok(None) => HttpResponse::NotFound()
254 .json(serde_json::json!({ "error": "Security incident not found" })),
255 Err(e) => HttpResponse::InternalServerError()
256 .json(serde_json::json!({ "error": format!("Failed to fetch incident: {}", e) })),
257 }
258}
259
260#[put("/admin/security-incidents/{incident_id}/report-apd")]
262pub async fn report_incident_to_apd(
263 data: web::Data<AppState>,
264 auth: AuthenticatedUser,
265 path: web::Path<Uuid>,
266 body: web::Json<ReportToApdRequest>,
267) -> impl Responder {
268 if auth.role != "superadmin" {
269 return HttpResponse::Forbidden().json(serde_json::json!({
270 "error": "Access denied. SuperAdmin role required."
271 }));
272 }
273
274 let incident_id = path.into_inner();
275 let apd_ref = body.apd_reference_number.clone().unwrap_or_default();
276
277 match data
278 .security_incident_use_cases
279 .report_to_apd(
280 incident_id,
281 auth.organization_id,
282 apd_ref,
283 body.investigation_notes.clone(),
284 )
285 .await
286 {
287 Ok(Some(incident)) => HttpResponse::Ok().json(to_dto(incident)),
288 Ok(None) => HttpResponse::NotFound()
289 .json(serde_json::json!({ "error": "Security incident not found" })),
290 Err(e) if e == "already_reported" => HttpResponse::Conflict()
291 .json(serde_json::json!({ "error": "Incident already reported to APD" })),
292 Err(e) => HttpResponse::BadRequest().json(serde_json::json!({ "error": e })),
293 }
294}