1use 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)]
32pub struct CreateSecurityIncidentRequest {
33 pub severity: String, pub incident_type: String, pub title: String,
36 pub description: String,
37 pub data_categories_affected: Vec<String>,
38 pub affected_subjects_count: Option<i32>,
39}
40
41#[derive(Debug, Deserialize)]
43pub struct ReportToApdRequest {
44 pub apd_reference_number: Option<String>,
45 pub investigation_notes: Option<String>,
46}
47
48#[derive(Debug, Serialize)]
50pub struct SecurityIncidentDto {
51 pub id: String,
52 pub organization_id: String,
53 pub severity: String,
54 pub incident_type: String,
55 pub title: String,
56 pub description: String,
57 pub data_categories_affected: Vec<String>,
58 pub affected_subjects_count: Option<i32>,
59 pub discovery_at: String,
60 pub notification_at: Option<String>,
61 pub apd_reference_number: Option<String>,
62 pub status: String,
63 pub reported_by: String,
64 pub investigation_notes: Option<String>,
65 pub root_cause: Option<String>,
66 pub remediation_steps: Option<String>,
67 pub created_at: String,
68 pub updated_at: String,
69 pub hours_since_discovery: f64,
70}
71
72#[derive(Debug, Serialize)]
74pub struct SecurityIncidentsResponse {
75 pub incidents: Vec<SecurityIncidentDto>,
76 pub total: i64,
77}
78
79#[derive(Debug, Deserialize)]
81pub struct SecurityIncidentsQuery {
82 pub page: Option<i64>,
83 pub per_page: Option<i64>,
84 pub severity: Option<String>,
85 pub status: Option<String>,
86}
87
88#[post("/admin/security-incidents")]
98pub async fn create_security_incident(
99 req: HttpRequest,
100 data: web::Data<AppState>,
101 auth: AuthenticatedUser,
102 body: web::Json<CreateSecurityIncidentRequest>,
103) -> impl Responder {
104 if auth.role != "superadmin" {
106 return HttpResponse::Forbidden().json(serde_json::json!({
107 "error": "Access denied. SuperAdmin role required."
108 }));
109 }
110
111 if body.title.is_empty() || body.description.is_empty() {
113 return HttpResponse::BadRequest().json(serde_json::json!({
114 "error": "title and description are required"
115 }));
116 }
117
118 let valid_severities = ["critical", "high", "medium", "low"];
119 if !valid_severities.contains(&body.severity.as_str()) {
120 return HttpResponse::BadRequest().json(serde_json::json!({
121 "error": "Invalid severity. Must be: critical, high, medium, or low"
122 }));
123 }
124
125 let _valid_statuses = ["detected", "investigating"];
126 let status = "detected".to_string();
127
128 let ip_address = extract_ip_address(&req);
130 let user_agent = extract_user_agent(&req);
131
132 match data.pool.acquire().await {
134 Ok(mut conn) => {
135 match sqlx::query_as!(
136 SecurityIncidentRow,
137 r#"
138 INSERT INTO security_incidents (
139 organization_id,
140 severity,
141 incident_type,
142 title,
143 description,
144 data_categories_affected,
145 affected_subjects_count,
146 discovery_at,
147 status,
148 reported_by
149 )
150 VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9)
151 RETURNING
152 id, organization_id, severity, incident_type, title, description,
153 data_categories_affected, affected_subjects_count, discovery_at,
154 notification_at, apd_reference_number, status, reported_by,
155 investigation_notes, root_cause, remediation_steps, created_at, updated_at
156 "#,
157 auth.organization_id,
158 body.severity,
159 body.incident_type,
160 body.title,
161 body.description,
162 &body.data_categories_affected,
163 body.affected_subjects_count,
164 status,
165 auth.user_id
166 )
167 .fetch_one(&mut *conn)
168 .await
169 {
170 Ok(row) => {
171 let hours_since = calculate_hours_since(&row.discovery_at);
172 let incident_dto = SecurityIncidentDto {
173 id: row.id.to_string(),
174 organization_id: row.organization_id.to_string(),
175 severity: row.severity,
176 incident_type: row.incident_type,
177 title: row.title,
178 description: row.description,
179 data_categories_affected: row.data_categories_affected.unwrap_or_default(),
180 affected_subjects_count: row.affected_subjects_count,
181 discovery_at: row.discovery_at.to_rfc3339(),
182 notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
183 apd_reference_number: row.apd_reference_number,
184 status: row.status,
185 reported_by: row.reported_by.to_string(),
186 investigation_notes: row.investigation_notes,
187 root_cause: row.root_cause,
188 remediation_steps: row.remediation_steps,
189 created_at: row.created_at.to_rfc3339(),
190 updated_at: row.updated_at.to_rfc3339(),
191 hours_since_discovery: hours_since,
192 };
193
194 let audit_entry = crate::infrastructure::audit::AuditLogEntry::new(
196 crate::infrastructure::audit::AuditEventType::SecurityIncidentReported,
197 Some(auth.user_id),
198 auth.organization_id,
199 )
200 .with_resource("SecurityIncident", row.id)
201 .with_client_info(ip_address, user_agent)
202 .with_metadata(serde_json::json!({
203 "severity": body.severity,
204 "incident_type": body.incident_type,
205 "affected_subjects": body.affected_subjects_count
206 }));
207
208 let audit_logger = data.audit_logger.clone();
209 tokio::spawn(async move {
210 audit_logger.log(&audit_entry).await;
211 });
212
213 HttpResponse::Created().json(incident_dto)
214 }
215 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
216 "error": format!("Failed to create incident: {}", e)
217 })),
218 }
219 }
220 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
221 "error": format!("Database connection error: {}", e)
222 })),
223 }
224}
225
226#[get("/admin/security-incidents")]
241pub async fn list_security_incidents(
242 data: web::Data<AppState>,
243 auth: AuthenticatedUser,
244 query: web::Query<SecurityIncidentsQuery>,
245) -> impl Responder {
246 if auth.role != "superadmin" {
248 return HttpResponse::Forbidden().json(serde_json::json!({
249 "error": "Access denied. SuperAdmin role required."
250 }));
251 }
252
253 let page = query.page.unwrap_or(1).max(1);
254 let per_page = query.per_page.unwrap_or(20).clamp(1, 100);
255 let offset = (page - 1) * per_page;
256
257 match data.pool.acquire().await {
258 Ok(mut conn) => {
259 let base_query = if let Some(ref severity) = query.severity {
261 if let Some(ref status) = query.status {
262 sqlx::query_as!(
263 SecurityIncidentRow,
264 r#"
265 SELECT
266 id, organization_id, severity, incident_type, title, description,
267 data_categories_affected, affected_subjects_count, discovery_at,
268 notification_at, apd_reference_number, status, reported_by,
269 investigation_notes, root_cause, remediation_steps, created_at, updated_at
270 FROM security_incidents
271 WHERE organization_id = $1 AND severity = $2 AND status = $3
272 ORDER BY discovery_at DESC
273 LIMIT $4 OFFSET $5
274 "#,
275 auth.organization_id,
276 severity,
277 status,
278 per_page,
279 offset
280 )
281 .fetch_all(&mut *conn)
282 .await
283 } else {
284 sqlx::query_as!(
285 SecurityIncidentRow,
286 r#"
287 SELECT
288 id, organization_id, severity, incident_type, title, description,
289 data_categories_affected, affected_subjects_count, discovery_at,
290 notification_at, apd_reference_number, status, reported_by,
291 investigation_notes, root_cause, remediation_steps, created_at, updated_at
292 FROM security_incidents
293 WHERE organization_id = $1 AND severity = $2
294 ORDER BY discovery_at DESC
295 LIMIT $3 OFFSET $4
296 "#,
297 auth.organization_id,
298 severity,
299 per_page,
300 offset
301 )
302 .fetch_all(&mut *conn)
303 .await
304 }
305 } else if let Some(ref status) = query.status {
306 sqlx::query_as!(
307 SecurityIncidentRow,
308 r#"
309 SELECT
310 id, organization_id, severity, incident_type, title, description,
311 data_categories_affected, affected_subjects_count, discovery_at,
312 notification_at, apd_reference_number, status, reported_by,
313 investigation_notes, root_cause, remediation_steps, created_at, updated_at
314 FROM security_incidents
315 WHERE organization_id = $1 AND status = $2
316 ORDER BY discovery_at DESC
317 LIMIT $3 OFFSET $4
318 "#,
319 auth.organization_id,
320 status,
321 per_page,
322 offset
323 )
324 .fetch_all(&mut *conn)
325 .await
326 } else {
327 sqlx::query_as!(
328 SecurityIncidentRow,
329 r#"
330 SELECT
331 id, organization_id, severity, incident_type, title, description,
332 data_categories_affected, affected_subjects_count, discovery_at,
333 notification_at, apd_reference_number, status, reported_by,
334 investigation_notes, root_cause, remediation_steps, created_at, updated_at
335 FROM security_incidents
336 WHERE organization_id = $1
337 ORDER BY discovery_at DESC
338 LIMIT $2 OFFSET $3
339 "#,
340 auth.organization_id,
341 per_page,
342 offset
343 )
344 .fetch_all(&mut *conn)
345 .await
346 };
347
348 match base_query {
349 Ok(rows) => {
350 let count_result = sqlx::query_scalar!(
352 "SELECT COUNT(*) as count FROM security_incidents WHERE organization_id = $1",
353 auth.organization_id
354 )
355 .fetch_one(&mut *conn)
356 .await;
357
358 let total = count_result.unwrap_or(Some(0)).unwrap_or(0);
359
360 let incidents: Vec<SecurityIncidentDto> = rows
361 .into_iter()
362 .map(|row| {
363 let hours_since = calculate_hours_since(&row.discovery_at);
364 SecurityIncidentDto {
365 id: row.id.to_string(),
366 organization_id: row.organization_id.to_string(),
367 severity: row.severity,
368 incident_type: row.incident_type,
369 title: row.title,
370 description: row.description,
371 data_categories_affected: row
372 .data_categories_affected
373 .unwrap_or_default(),
374 affected_subjects_count: row.affected_subjects_count,
375 discovery_at: row.discovery_at.to_rfc3339(),
376 notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
377 apd_reference_number: row.apd_reference_number,
378 status: row.status,
379 reported_by: row.reported_by.to_string(),
380 investigation_notes: row.investigation_notes,
381 root_cause: row.root_cause,
382 remediation_steps: row.remediation_steps,
383 created_at: row.created_at.to_rfc3339(),
384 updated_at: row.updated_at.to_rfc3339(),
385 hours_since_discovery: hours_since,
386 }
387 })
388 .collect();
389
390 HttpResponse::Ok().json(SecurityIncidentsResponse { incidents, total })
391 }
392 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
393 "error": format!("Failed to fetch incidents: {}", e)
394 })),
395 }
396 }
397 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
398 "error": format!("Database connection error: {}", e)
399 })),
400 }
401}
402
403#[get("/admin/security-incidents/{incident_id}")]
413pub async fn get_security_incident(
414 data: web::Data<AppState>,
415 auth: AuthenticatedUser,
416 path: web::Path<Uuid>,
417) -> impl Responder {
418 if auth.role != "superadmin" {
420 return HttpResponse::Forbidden().json(serde_json::json!({
421 "error": "Access denied. SuperAdmin role required."
422 }));
423 }
424
425 let incident_id = path.into_inner();
426
427 match data.pool.acquire().await {
428 Ok(mut conn) => {
429 match sqlx::query_as!(
430 SecurityIncidentRow,
431 r#"
432 SELECT
433 id, organization_id, severity, incident_type, title, description,
434 data_categories_affected, affected_subjects_count, discovery_at,
435 notification_at, apd_reference_number, status, reported_by,
436 investigation_notes, root_cause, remediation_steps, created_at, updated_at
437 FROM security_incidents
438 WHERE id = $1 AND organization_id = $2
439 "#,
440 incident_id,
441 auth.organization_id
442 )
443 .fetch_optional(&mut *conn)
444 .await
445 {
446 Ok(Some(row)) => {
447 let hours_since = calculate_hours_since(&row.discovery_at);
448 let incident_dto = SecurityIncidentDto {
449 id: row.id.to_string(),
450 organization_id: row.organization_id.to_string(),
451 severity: row.severity,
452 incident_type: row.incident_type,
453 title: row.title,
454 description: row.description,
455 data_categories_affected: row.data_categories_affected.unwrap_or_default(),
456 affected_subjects_count: row.affected_subjects_count,
457 discovery_at: row.discovery_at.to_rfc3339(),
458 notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
459 apd_reference_number: row.apd_reference_number,
460 status: row.status,
461 reported_by: row.reported_by.to_string(),
462 investigation_notes: row.investigation_notes,
463 root_cause: row.root_cause,
464 remediation_steps: row.remediation_steps,
465 created_at: row.created_at.to_rfc3339(),
466 updated_at: row.updated_at.to_rfc3339(),
467 hours_since_discovery: hours_since,
468 };
469
470 HttpResponse::Ok().json(incident_dto)
471 }
472 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
473 "error": "Security incident not found"
474 })),
475 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
476 "error": format!("Failed to fetch incident: {}", e)
477 })),
478 }
479 }
480 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
481 "error": format!("Database connection error: {}", e)
482 })),
483 }
484}
485
486#[put("/admin/security-incidents/{incident_id}/report-apd")]
498pub async fn report_incident_to_apd(
499 data: web::Data<AppState>,
500 auth: AuthenticatedUser,
501 path: web::Path<Uuid>,
502 body: web::Json<ReportToApdRequest>,
503) -> impl Responder {
504 if auth.role != "superadmin" {
506 return HttpResponse::Forbidden().json(serde_json::json!({
507 "error": "Access denied. SuperAdmin role required."
508 }));
509 }
510
511 let incident_id = path.into_inner();
512
513 if body.apd_reference_number.is_none() || body.apd_reference_number.as_ref().unwrap().is_empty()
514 {
515 return HttpResponse::BadRequest().json(serde_json::json!({
516 "error": "apd_reference_number is required"
517 }));
518 }
519
520 match data.pool.acquire().await {
521 Ok(mut conn) => {
522 match sqlx::query_scalar!(
524 "SELECT notification_at FROM security_incidents WHERE id = $1 AND organization_id = $2",
525 incident_id,
526 auth.organization_id
527 )
528 .fetch_optional(&mut *conn)
529 .await
530 {
531 Ok(Some(Some(_))) => {
532 HttpResponse::Conflict().json(serde_json::json!({
533 "error": "Incident already reported to APD"
534 }))
535 }
536 Ok(Some(None)) => {
537 match sqlx::query_as!(
539 SecurityIncidentRow,
540 r#"
541 UPDATE security_incidents
542 SET notification_at = now(),
543 apd_reference_number = $1,
544 status = 'reported',
545 investigation_notes = $2
546 WHERE id = $3 AND organization_id = $4
547 RETURNING
548 id, organization_id, severity, incident_type, title, description,
549 data_categories_affected, affected_subjects_count, discovery_at,
550 notification_at, apd_reference_number, status, reported_by,
551 investigation_notes, root_cause, remediation_steps, created_at, updated_at
552 "#,
553 body.apd_reference_number,
554 body.investigation_notes,
555 incident_id,
556 auth.organization_id
557 )
558 .fetch_one(&mut *conn)
559 .await
560 {
561 Ok(row) => {
562 let hours_since = calculate_hours_since(&row.discovery_at);
563 let incident_dto = SecurityIncidentDto {
564 id: row.id.to_string(),
565 organization_id: row.organization_id.to_string(),
566 severity: row.severity,
567 incident_type: row.incident_type,
568 title: row.title,
569 description: row.description,
570 data_categories_affected: row.data_categories_affected.unwrap_or_default(),
571 affected_subjects_count: row.affected_subjects_count,
572 discovery_at: row.discovery_at.to_rfc3339(),
573 notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
574 apd_reference_number: row.apd_reference_number,
575 status: row.status,
576 reported_by: row.reported_by.to_string(),
577 investigation_notes: row.investigation_notes,
578 root_cause: row.root_cause,
579 remediation_steps: row.remediation_steps,
580 created_at: row.created_at.to_rfc3339(),
581 updated_at: row.updated_at.to_rfc3339(),
582 hours_since_discovery: hours_since,
583 };
584
585 HttpResponse::Ok().json(incident_dto)
586 }
587 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
588 "error": format!("Failed to update incident: {}", e)
589 })),
590 }
591 }
592 Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
593 "error": "Security incident not found"
594 })),
595 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
596 "error": format!("Database query failed: {}", e)
597 })),
598 }
599 }
600 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
601 "error": format!("Database connection error: {}", e)
602 })),
603 }
604}
605
606#[get("/admin/security-incidents/overdue")]
615pub async fn list_overdue_incidents(
616 data: web::Data<AppState>,
617 auth: AuthenticatedUser,
618) -> impl Responder {
619 if auth.role != "superadmin" {
621 return HttpResponse::Forbidden().json(serde_json::json!({
622 "error": "Access denied. SuperAdmin role required."
623 }));
624 }
625
626 match data.pool.acquire().await {
627 Ok(mut conn) => {
628 match sqlx::query_as!(
629 SecurityIncidentRow,
630 r#"
631 SELECT
632 id, organization_id, severity, incident_type, title, description,
633 data_categories_affected, affected_subjects_count, discovery_at,
634 notification_at, apd_reference_number, status, reported_by,
635 investigation_notes, root_cause, remediation_steps, created_at, updated_at
636 FROM security_incidents
637 WHERE organization_id = $1
638 AND notification_at IS NULL
639 AND status IN ('detected', 'investigating', 'contained')
640 AND discovery_at < (NOW() - INTERVAL '72 hours')
641 ORDER BY discovery_at ASC
642 "#,
643 auth.organization_id
644 )
645 .fetch_all(&mut *conn)
646 .await
647 {
648 Ok(rows) => {
649 let total = rows.len() as i64;
650 let incidents: Vec<SecurityIncidentDto> = rows
651 .into_iter()
652 .map(|row| {
653 let hours_since = calculate_hours_since(&row.discovery_at);
654 SecurityIncidentDto {
655 id: row.id.to_string(),
656 organization_id: row.organization_id.to_string(),
657 severity: row.severity,
658 incident_type: row.incident_type,
659 title: row.title,
660 description: row.description,
661 data_categories_affected: row
662 .data_categories_affected
663 .unwrap_or_default(),
664 affected_subjects_count: row.affected_subjects_count,
665 discovery_at: row.discovery_at.to_rfc3339(),
666 notification_at: row.notification_at.map(|dt| dt.to_rfc3339()),
667 apd_reference_number: row.apd_reference_number,
668 status: row.status,
669 reported_by: row.reported_by.to_string(),
670 investigation_notes: row.investigation_notes,
671 root_cause: row.root_cause,
672 remediation_steps: row.remediation_steps,
673 created_at: row.created_at.to_rfc3339(),
674 updated_at: row.updated_at.to_rfc3339(),
675 hours_since_discovery: hours_since,
676 }
677 })
678 .collect();
679
680 HttpResponse::Ok().json(SecurityIncidentsResponse { incidents, total })
681 }
682 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
683 "error": format!("Failed to fetch overdue incidents: {}", e)
684 })),
685 }
686 }
687 Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
688 "error": format!("Database connection error: {}", e)
689 })),
690 }
691}
692
693fn calculate_hours_since(discovery_at: &chrono::DateTime<chrono::Utc>) -> f64 {
695 let now = chrono::Utc::now();
696 let duration = now.signed_duration_since(*discovery_at);
697 duration.num_seconds() as f64 / 3600.0
698}
699
700struct SecurityIncidentRow {
702 id: uuid::Uuid,
703 organization_id: uuid::Uuid,
704 severity: String,
705 incident_type: String,
706 title: String,
707 description: String,
708 data_categories_affected: Option<Vec<String>>,
709 affected_subjects_count: Option<i32>,
710 discovery_at: chrono::DateTime<chrono::Utc>,
711 notification_at: Option<chrono::DateTime<chrono::Utc>>,
712 apd_reference_number: Option<String>,
713 status: String,
714 reported_by: uuid::Uuid,
715 investigation_notes: Option<String>,
716 root_cause: Option<String>,
717 remediation_steps: Option<String>,
718 created_at: chrono::DateTime<chrono::Utc>,
719 updated_at: chrono::DateTime<chrono::Utc>,
720}
721
722#[cfg(test)]
723mod tests {
724 #[test]
725 fn test_handler_structure_create_incident() {
726 }
728
729 #[test]
730 fn test_handler_structure_list_incidents() {
731 }
733
734 #[test]
735 fn test_handler_structure_get_incident() {
736 }
738
739 #[test]
740 fn test_handler_structure_report_to_apd() {
741 }
743
744 #[test]
745 fn test_handler_structure_overdue_incidents() {
746 }
748}