Skip to main content

mockforge_intelligence/handlers/
threat_modeling.rs

1//! HTTP handlers for contract threat modeling
2//!
3//! **Internal / API-only.** No admin UI consumes these endpoints. They are
4//! intended for automated security pipelines and external threat-modeling
5//! tools. Do not build speculative UI for these routes without a
6//! stakeholder-defined use case.
7//!
8//! This module provides endpoints for security threat assessments.
9
10// ThreatAnalyzer stays in core (OpenApiSpec + LLM dep).
11#![allow(deprecated)]
12
13use crate::threat_modeling::ThreatAnalyzer;
14use axum::{
15    extract::{Path, Query, State},
16    http::StatusCode,
17    response::Json,
18};
19use mockforge_foundation::threat_modeling_types::ThreatAssessment;
20use mockforge_openapi::OpenApiSpec;
21use serde::Deserialize;
22use std::sync::Arc;
23
24#[cfg(feature = "database")]
25use chrono::{DateTime, Utc};
26#[cfg(feature = "database")]
27use uuid::Uuid;
28
29#[cfg(feature = "database")]
30use crate::database::Database;
31
32/// Helper function to map database row to ThreatAssessment
33#[cfg(feature = "database")]
34fn map_row_to_threat_assessment(
35    row: &sqlx::postgres::PgRow,
36) -> Result<ThreatAssessment, sqlx::Error> {
37    use mockforge_foundation::threat_modeling_types::{
38        AggregationLevel, RemediationSuggestion, ThreatCategory, ThreatFinding, ThreatLevel,
39    };
40    use sqlx::Row;
41
42    // Parse basic fields
43    let workspace_id: Option<Uuid> = row.try_get("workspace_id")?;
44    let service_id: Option<String> = row.try_get("service_id")?;
45    let service_name: Option<String> = row.try_get("service_name")?;
46    let endpoint: Option<String> = row.try_get("endpoint")?;
47    let method: Option<String> = row.try_get("method")?;
48    let aggregation_level_str: String = row.try_get("aggregation_level")?;
49    let threat_level_str: String = row.try_get("threat_level")?;
50    let threat_score: f64 = row.try_get("threat_score")?;
51    let assessed_at: DateTime<Utc> = row.try_get("assessed_at")?;
52
53    // Parse aggregation level
54    let aggregation_level = match aggregation_level_str.as_str() {
55        "workspace" => AggregationLevel::Workspace,
56        "service" => AggregationLevel::Service,
57        "endpoint" => AggregationLevel::Endpoint,
58        _ => return Err(sqlx::Error::Decode("Invalid aggregation_level".into())),
59    };
60
61    // Parse threat level
62    let threat_level = match threat_level_str.as_str() {
63        "low" => ThreatLevel::Low,
64        "medium" => ThreatLevel::Medium,
65        "high" => ThreatLevel::High,
66        "critical" => ThreatLevel::Critical,
67        _ => return Err(sqlx::Error::Decode("Invalid threat_level".into())),
68    };
69
70    // Parse JSONB columns
71    let threat_categories_json: serde_json::Value = row.try_get("threat_categories")?;
72    let threat_categories: Vec<ThreatCategory> =
73        serde_json::from_value(threat_categories_json).unwrap_or_default();
74
75    let findings_json: serde_json::Value = row.try_get("findings")?;
76    let findings: Vec<ThreatFinding> = serde_json::from_value(findings_json).unwrap_or_default();
77
78    let remediations_json: serde_json::Value = row.try_get("remediation_suggestions")?;
79    let remediation_suggestions: Vec<RemediationSuggestion> =
80        serde_json::from_value(remediations_json).unwrap_or_default();
81
82    Ok(ThreatAssessment {
83        workspace_id: workspace_id.map(|u| u.to_string()),
84        service_id,
85        service_name,
86        endpoint,
87        method,
88        aggregation_level,
89        threat_level,
90        threat_score,
91        threat_categories,
92        findings,
93        remediation_suggestions,
94        assessed_at,
95    })
96}
97
98/// State for threat modeling handlers
99#[derive(Clone)]
100pub struct ThreatModelingState {
101    /// Threat analyzer
102    pub analyzer: Arc<ThreatAnalyzer>,
103    /// Webhook configs for notifications (optional)
104    pub webhook_configs: Vec<crate::incidents::integrations::WebhookConfig>,
105    /// Database connection (optional)
106    #[cfg(feature = "database")]
107    pub database: Option<Database>,
108}
109
110impl ThreatModelingState {
111    /// Create handler state without a persistence connection.
112    ///
113    /// This constructor works when dependency feature unification enables database support.
114    pub fn new(
115        analyzer: Arc<ThreatAnalyzer>,
116        webhook_configs: Vec<crate::incidents::integrations::WebhookConfig>,
117    ) -> Self {
118        Self {
119            analyzer,
120            webhook_configs,
121            #[cfg(feature = "database")]
122            database: None,
123        }
124    }
125
126    /// Attach the optional persistence connection used by these handlers.
127    #[cfg(feature = "database")]
128    pub fn with_database(mut self, database: Option<Database>) -> Self {
129        self.database = database;
130        self
131    }
132}
133
134/// Get workspace-level threat assessment
135///
136/// GET /api/v1/threats/workspace/{workspace_id}
137#[cfg(feature = "database")]
138pub async fn get_workspace_threats(
139    State(state): State<ThreatModelingState>,
140    Path(workspace_id): Path<String>,
141) -> Result<Json<ThreatAssessment>, StatusCode> {
142    let pool = match state.database.as_ref().and_then(|db| db.pool()) {
143        Some(pool) => pool,
144        None => return Err(StatusCode::SERVICE_UNAVAILABLE),
145    };
146
147    // Query latest assessment from database
148    let row = sqlx::query(
149        "SELECT * FROM contract_threat_assessments
150         WHERE workspace_id = $1 AND aggregation_level = 'workspace'
151         ORDER BY assessed_at DESC LIMIT 1",
152    )
153    .bind(&workspace_id)
154    .fetch_optional(pool)
155    .await
156    .map_err(|e| {
157        tracing::error!("Failed to query workspace threats: {}", e);
158        StatusCode::INTERNAL_SERVER_ERROR
159    })?;
160
161    match row {
162        Some(row) => match map_row_to_threat_assessment(&row) {
163            Ok(assessment) => Ok(Json(assessment)),
164            Err(e) => {
165                tracing::error!("Failed to map threat assessment: {}", e);
166                Err(StatusCode::INTERNAL_SERVER_ERROR)
167            }
168        },
169        None => Err(StatusCode::NOT_FOUND),
170    }
171}
172
173/// Get workspace-level threat assessment (no database)
174///
175/// GET /api/v1/threats/workspace/{workspace_id}
176#[cfg(not(feature = "database"))]
177pub async fn get_workspace_threats(
178    State(_state): State<ThreatModelingState>,
179    Path(_workspace_id): Path<String>,
180) -> Result<Json<ThreatAssessment>, StatusCode> {
181    Err(StatusCode::SERVICE_UNAVAILABLE)
182}
183
184/// Get service-level threat assessment
185///
186/// GET /api/v1/threats/service/{service_id}
187#[cfg(feature = "database")]
188pub async fn get_service_threats(
189    State(state): State<ThreatModelingState>,
190    Path(service_id): Path<String>,
191) -> Result<Json<ThreatAssessment>, StatusCode> {
192    let pool = match state.database.as_ref().and_then(|db| db.pool()) {
193        Some(pool) => pool,
194        None => return Err(StatusCode::SERVICE_UNAVAILABLE),
195    };
196
197    let row = sqlx::query(
198        "SELECT * FROM contract_threat_assessments
199         WHERE service_id = $1 AND aggregation_level = 'service'
200         ORDER BY assessed_at DESC LIMIT 1",
201    )
202    .bind(&service_id)
203    .fetch_optional(pool)
204    .await
205    .map_err(|e| {
206        tracing::error!("Failed to query service threats: {}", e);
207        StatusCode::INTERNAL_SERVER_ERROR
208    })?;
209
210    match row {
211        Some(row) => match map_row_to_threat_assessment(&row) {
212            Ok(assessment) => Ok(Json(assessment)),
213            Err(e) => {
214                tracing::error!("Failed to map threat assessment: {}", e);
215                Err(StatusCode::INTERNAL_SERVER_ERROR)
216            }
217        },
218        None => Err(StatusCode::NOT_FOUND),
219    }
220}
221
222/// Get service-level threat assessment (no database)
223///
224/// GET /api/v1/threats/service/{service_id}
225#[cfg(not(feature = "database"))]
226pub async fn get_service_threats(
227    State(_state): State<ThreatModelingState>,
228    Path(_service_id): Path<String>,
229) -> Result<Json<ThreatAssessment>, StatusCode> {
230    Err(StatusCode::SERVICE_UNAVAILABLE)
231}
232
233/// Get endpoint-level threat assessment
234///
235/// GET /api/v1/threats/endpoint/{endpoint}
236#[cfg(feature = "database")]
237pub async fn get_endpoint_threats(
238    State(state): State<ThreatModelingState>,
239    Path(endpoint): Path<String>,
240    Query(params): Query<serde_json::Value>,
241) -> Result<Json<ThreatAssessment>, StatusCode> {
242    let pool = match state.database.as_ref().and_then(|db| db.pool()) {
243        Some(pool) => pool,
244        None => return Err(StatusCode::SERVICE_UNAVAILABLE),
245    };
246
247    let method = params.get("method").and_then(|v| v.as_str()).unwrap_or("%");
248
249    let row = sqlx::query(
250        "SELECT * FROM contract_threat_assessments
251         WHERE endpoint = $1 AND method LIKE $2 AND aggregation_level = 'endpoint'
252         ORDER BY assessed_at DESC LIMIT 1",
253    )
254    .bind(&endpoint)
255    .bind(method)
256    .fetch_optional(pool)
257    .await
258    .map_err(|e| {
259        tracing::error!("Failed to query endpoint threats: {}", e);
260        StatusCode::INTERNAL_SERVER_ERROR
261    })?;
262
263    match row {
264        Some(row) => match map_row_to_threat_assessment(&row) {
265            Ok(assessment) => Ok(Json(assessment)),
266            Err(e) => {
267                tracing::error!("Failed to map threat assessment: {}", e);
268                Err(StatusCode::INTERNAL_SERVER_ERROR)
269            }
270        },
271        None => Err(StatusCode::NOT_FOUND),
272    }
273}
274
275/// Get endpoint-level threat assessment (no database)
276///
277/// GET /api/v1/threats/endpoint/{endpoint}
278#[cfg(not(feature = "database"))]
279pub async fn get_endpoint_threats(
280    State(_state): State<ThreatModelingState>,
281    Path(_endpoint): Path<String>,
282    Query(_params): Query<serde_json::Value>,
283) -> Result<Json<ThreatAssessment>, StatusCode> {
284    Err(StatusCode::SERVICE_UNAVAILABLE)
285}
286
287/// Request to trigger threat assessment
288#[derive(Debug, Deserialize)]
289pub struct AssessThreatsRequest {
290    /// OpenAPI spec (YAML/JSON)
291    pub spec: String,
292    /// Workspace ID
293    pub workspace_id: Option<String>,
294    /// Service ID
295    pub service_id: Option<String>,
296    /// Service name
297    pub service_name: Option<String>,
298    /// Endpoint (optional)
299    pub endpoint: Option<String>,
300    /// Method (optional)
301    pub method: Option<String>,
302}
303
304/// Trigger threat assessment
305///
306/// POST /api/v1/threats/assess
307pub async fn assess_threats(
308    State(state): State<ThreatModelingState>,
309    Json(request): Json<AssessThreatsRequest>,
310) -> Result<Json<ThreatAssessment>, StatusCode> {
311    // Parse OpenAPI spec
312    let spec = match OpenApiSpec::from_string(&request.spec, None) {
313        Ok(spec) => spec,
314        Err(_) => return Err(StatusCode::BAD_REQUEST),
315    };
316
317    // Run threat analysis
318    match state
319        .analyzer
320        .analyze_contract(
321            &spec,
322            request.workspace_id.clone(),
323            request.service_id.clone(),
324            request.service_name.clone(),
325            request.endpoint.clone(),
326            request.method.clone(),
327        )
328        .await
329    {
330        Ok(assessment) => {
331            // Store assessment in database
332            #[cfg(feature = "database")]
333            if let Some(pool) = state.database.as_ref().and_then(|db| db.pool()) {
334                if let Err(e) = store_threat_assessment(pool, &assessment).await {
335                    tracing::warn!("Failed to store threat assessment: {}", e);
336                }
337            }
338
339            // Trigger webhook notifications
340            trigger_threat_assessment_webhooks(&state.webhook_configs, &assessment).await;
341
342            Ok(Json(assessment))
343        }
344        Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
345    }
346}
347
348/// Store threat assessment in database
349#[cfg(feature = "database")]
350async fn store_threat_assessment(
351    pool: &sqlx::PgPool,
352    assessment: &ThreatAssessment,
353) -> Result<(), sqlx::Error> {
354    let id = Uuid::new_v4();
355    let workspace_uuid = assessment.workspace_id.as_ref().and_then(|id| Uuid::parse_str(id).ok());
356    let service_uuid = assessment.service_id.as_ref().and_then(|id| Uuid::parse_str(id).ok());
357
358    // Store main assessment
359    sqlx::query(
360        r#"
361        INSERT INTO contract_threat_assessments (
362            id, workspace_id, service_id, service_name, endpoint, method, aggregation_level,
363            threat_level, threat_score, threat_categories, findings, remediation_suggestions,
364            assessed_at, last_updated, created_at
365        ) VALUES (
366            $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
367        )
368        ON CONFLICT (workspace_id, service_id, endpoint, method, aggregation_level)
369        DO UPDATE SET
370            threat_level = EXCLUDED.threat_level,
371            threat_score = EXCLUDED.threat_score,
372            threat_categories = EXCLUDED.threat_categories,
373            findings = EXCLUDED.findings,
374            remediation_suggestions = EXCLUDED.remediation_suggestions,
375            assessed_at = EXCLUDED.assessed_at,
376            last_updated = EXCLUDED.last_updated
377        "#,
378    )
379    .bind(id)
380    .bind(workspace_uuid)
381    .bind(service_uuid)
382    .bind(assessment.service_name.as_deref())
383    .bind(assessment.endpoint.as_deref())
384    .bind(assessment.method.as_deref())
385    .bind(format!("{:?}", assessment.aggregation_level))
386    .bind(format!("{:?}", assessment.threat_level))
387    .bind(assessment.threat_score)
388    .bind(serde_json::to_value(&assessment.threat_categories).unwrap_or_default())
389    .bind(serde_json::to_value(&assessment.findings).unwrap_or_default())
390    .bind(serde_json::to_value(&assessment.remediation_suggestions).unwrap_or_default())
391    .bind(assessment.assessed_at)
392    .bind(Utc::now())
393    .bind(assessment.assessed_at)
394    .execute(pool)
395    .await?;
396
397    // Store individual findings
398    for finding in &assessment.findings {
399        let finding_id = Uuid::new_v4();
400        sqlx::query(
401            r#"
402            INSERT INTO threat_findings (
403                id, assessment_id, finding_type, severity, description, field_path,
404                context, remediation_suggestion, remediation_code_example, confidence,
405                ai_generated_remediation, detected_at, created_at, updated_at
406            ) VALUES (
407                $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
408            )
409            "#,
410        )
411        .bind(finding_id)
412        .bind(id)
413        .bind(format!("{:?}", finding.finding_type))
414        .bind(format!("{:?}", finding.severity))
415        .bind(&finding.description)
416        .bind(finding.field_path.as_deref())
417        .bind(serde_json::to_value(&finding.context).unwrap_or_default())
418        .bind(None::<String>) // remediation_suggestion from remediation_suggestions
419        .bind(None::<String>) // remediation_code_example
420        .bind(finding.confidence)
421        .bind(false) // ai_generated_remediation
422        .bind(Utc::now())
423        .bind(Utc::now())
424        .bind(Utc::now())
425        .execute(pool)
426        .await?;
427    }
428
429    Ok(())
430}
431
432/// List all threat findings
433///
434/// GET /api/v1/threats/findings
435#[cfg(feature = "database")]
436pub async fn list_findings(
437    State(state): State<ThreatModelingState>,
438    Query(_params): Query<serde_json::Value>,
439) -> Result<Json<serde_json::Value>, StatusCode> {
440    let pool = match state.database.as_ref().and_then(|db| db.pool()) {
441        Some(pool) => pool,
442        None => {
443            return Ok(Json(serde_json::json!({
444                "findings": []
445            })));
446        }
447    };
448
449    let rows = sqlx::query(
450        "SELECT tf.*, ta.workspace_id, ta.service_id, ta.endpoint, ta.method
451         FROM threat_findings tf
452         JOIN contract_threat_assessments ta ON tf.assessment_id = ta.id
453         ORDER BY tf.detected_at DESC LIMIT 100",
454    )
455    .fetch_all(pool)
456    .await
457    .map_err(|e| {
458        tracing::error!("Failed to query threat findings: {}", e);
459        StatusCode::INTERNAL_SERVER_ERROR
460    })?;
461
462    // Map rows to findings
463    use sqlx::Row;
464    let mut findings = Vec::new();
465    for row in rows {
466        let finding_id: Uuid = row.try_get("id").map_err(|e| {
467            tracing::error!("Failed to get finding id from row: {}", e);
468            StatusCode::INTERNAL_SERVER_ERROR
469        })?;
470        let finding_type_str: String = row.try_get("finding_type").map_err(|e| {
471            tracing::error!("Failed to get finding_type from row: {}", e);
472            StatusCode::INTERNAL_SERVER_ERROR
473        })?;
474        let severity_str: String = row.try_get("severity").map_err(|e| {
475            tracing::error!("Failed to get severity from row: {}", e);
476            StatusCode::INTERNAL_SERVER_ERROR
477        })?;
478        let description: String = row.try_get("description").map_err(|e| {
479            tracing::error!("Failed to get description from row: {}", e);
480            StatusCode::INTERNAL_SERVER_ERROR
481        })?;
482        let field_path: Option<String> = row.try_get("field_path").map_err(|e| {
483            tracing::error!("Failed to get field_path from row: {}", e);
484            StatusCode::INTERNAL_SERVER_ERROR
485        })?;
486        let context_json: serde_json::Value = row.try_get("context").map_err(|e| {
487            tracing::error!("Failed to get context from row: {}", e);
488            StatusCode::INTERNAL_SERVER_ERROR
489        })?;
490        let confidence: f64 = row.try_get("confidence").map_err(|e| {
491            tracing::error!("Failed to get confidence from row: {}", e);
492            StatusCode::INTERNAL_SERVER_ERROR
493        })?;
494
495        use mockforge_foundation::threat_modeling_types::{ThreatCategory, ThreatLevel};
496        use std::collections::HashMap;
497
498        let _finding_type = match finding_type_str.as_str() {
499            "pii_exposure" => ThreatCategory::PiiExposure,
500            "dos_risk" => ThreatCategory::DoSRisk,
501            "error_leakage" => ThreatCategory::ErrorLeakage,
502            "schema_inconsistency" => ThreatCategory::SchemaInconsistency,
503            "unbounded_arrays" => ThreatCategory::UnboundedArrays,
504            "missing_rate_limits" => ThreatCategory::MissingRateLimits,
505            "stack_trace_leakage" => ThreatCategory::StackTraceLeakage,
506            "sensitive_data_exposure" => ThreatCategory::SensitiveDataExposure,
507            "insecure_schema_design" => ThreatCategory::InsecureSchemaDesign,
508            "missing_validation" => ThreatCategory::MissingValidation,
509            "excessive_optional_fields" => ThreatCategory::ExcessiveOptionalFields,
510            _ => continue, // Skip invalid finding types
511        };
512
513        let _severity = match severity_str.as_str() {
514            "low" => ThreatLevel::Low,
515            "medium" => ThreatLevel::Medium,
516            "high" => ThreatLevel::High,
517            "critical" => ThreatLevel::Critical,
518            _ => continue, // Skip invalid severity
519        };
520
521        let context: HashMap<String, serde_json::Value> =
522            serde_json::from_value(context_json).unwrap_or_default();
523
524        findings.push(serde_json::json!({
525            "id": finding_id.to_string(),
526            "finding_type": finding_type_str,
527            "severity": severity_str,
528            "description": description,
529            "field_path": field_path,
530            "context": context,
531            "confidence": confidence,
532        }));
533    }
534
535    Ok(Json(serde_json::json!({
536        "findings": findings,
537        "total": findings.len()
538    })))
539}
540
541/// List threat findings (no database)
542///
543/// GET /api/v1/threats/findings
544#[cfg(not(feature = "database"))]
545pub async fn list_findings(
546    State(_state): State<ThreatModelingState>,
547    Query(_params): Query<serde_json::Value>,
548) -> Result<Json<serde_json::Value>, StatusCode> {
549    Ok(Json(serde_json::json!({
550        "findings": []
551    })))
552}
553
554/// Get remediation suggestions
555///
556/// GET /api/v1/threats/remediations
557#[cfg(feature = "database")]
558pub async fn get_remediations(
559    State(state): State<ThreatModelingState>,
560    Query(_params): Query<serde_json::Value>,
561) -> Result<Json<serde_json::Value>, StatusCode> {
562    let pool = match state.database.as_ref().and_then(|db| db.pool()) {
563        Some(pool) => pool,
564        None => {
565            return Ok(Json(serde_json::json!({
566                "remediations": []
567            })));
568        }
569    };
570
571    // Query remediations from assessments
572    let rows = sqlx::query(
573        "SELECT remediation_suggestions FROM contract_threat_assessments
574         WHERE remediation_suggestions IS NOT NULL AND jsonb_array_length(remediation_suggestions) > 0
575         ORDER BY assessed_at DESC LIMIT 50",
576    )
577    .fetch_all(pool)
578    .await
579    .map_err(|e| {
580        tracing::error!("Failed to query remediations: {}", e);
581        StatusCode::INTERNAL_SERVER_ERROR
582    })?;
583
584    // Extract and flatten remediation suggestions
585    use sqlx::Row;
586    let mut remediations = Vec::new();
587    for row in rows {
588        let remediations_json: serde_json::Value =
589            row.try_get("remediation_suggestions").map_err(|e| {
590                tracing::error!("Failed to get remediation_suggestions from row: {}", e);
591                StatusCode::INTERNAL_SERVER_ERROR
592            })?;
593        if let serde_json::Value::Array(remediation_array) = remediations_json {
594            for remediation in remediation_array {
595                remediations.push(remediation);
596            }
597        }
598    }
599
600    Ok(Json(serde_json::json!({
601        "remediations": remediations,
602        "total": remediations.len()
603    })))
604}
605
606/// Get remediation suggestions (no database)
607///
608/// GET /api/v1/threats/remediations
609#[cfg(not(feature = "database"))]
610pub async fn get_remediations(
611    State(_state): State<ThreatModelingState>,
612    Query(_params): Query<serde_json::Value>,
613) -> Result<Json<serde_json::Value>, StatusCode> {
614    Ok(Json(serde_json::json!({
615        "remediations": []
616    })))
617}
618
619/// Trigger webhook notifications for threat assessment
620async fn trigger_threat_assessment_webhooks(
621    webhook_configs: &[crate::incidents::integrations::WebhookConfig],
622    assessment: &ThreatAssessment,
623) {
624    use crate::incidents::integrations::send_webhook;
625    use serde_json::json;
626
627    for webhook in webhook_configs {
628        if !webhook.enabled {
629            continue;
630        }
631
632        let event_type = "threat.assessment.completed";
633        if !webhook.events.is_empty() && !webhook.events.contains(&event_type.to_string()) {
634            continue;
635        }
636
637        let payload = json!({
638            "event": event_type,
639            "assessment": {
640                "workspace_id": assessment.workspace_id,
641                "service_id": assessment.service_id,
642                "service_name": assessment.service_name,
643                "endpoint": assessment.endpoint,
644                "method": assessment.method,
645                "threat_level": format!("{:?}", assessment.threat_level),
646                "threat_score": assessment.threat_score,
647                "findings_count": assessment.findings.len(),
648                "assessed_at": assessment.assessed_at,
649            }
650        });
651
652        let webhook_clone = webhook.clone();
653        tokio::spawn(async move {
654            if let Err(e) = send_webhook(&webhook_clone, &payload).await {
655                tracing::warn!("Failed to send threat assessment webhook: {}", e);
656            }
657        });
658    }
659}
660
661/// Create router for threat modeling endpoints
662pub fn threat_modeling_router(state: ThreatModelingState) -> axum::Router {
663    use axum::routing::{get, post};
664    use axum::Router;
665
666    Router::new()
667        .route("/api/v1/threats/workspace/{workspace_id}", get(get_workspace_threats))
668        .route("/api/v1/threats/service/{service_id}", get(get_service_threats))
669        .route("/api/v1/threats/endpoint/{endpoint}", get(get_endpoint_threats))
670        .route("/api/v1/threats/assess", post(assess_threats))
671        .route("/api/v1/threats/findings", get(list_findings))
672        .route("/api/v1/threats/remediations", get(get_remediations))
673        .with_state(state)
674}