koprogo_api/infrastructure/web/handlers/
security_incident_handlers.rs

1use crate::infrastructure::web::{AppState, AuthenticatedUser};
2use actix_web::{get, post, put, web, HttpRequest, HttpResponse, Responder};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6/// Extract client IP address from request
7fn 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
22/// Extract user agent from request
23fn 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 /api/v1/admin/security-incidents — SuperAdmin only
112#[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 /api/v1/admin/security-incidents — SuperAdmin only
168#[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 /api/v1/admin/security-incidents/overdue — SuperAdmin only
204#[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 /api/v1/admin/security-incidents/:id — SuperAdmin only
234#[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 /api/v1/admin/security-incidents/:id/report-apd — SuperAdmin only
261#[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}