Skip to main content

mockforge_http/auth/
risk_engine.rs

1//! Risk assessment engine for authentication
2//!
3//! This module provides risk-based authentication challenges including
4//! MFA prompts, device challenges, and blocked login simulation.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10
11pub use mockforge_foundation::auth_risk_types::{RiskAction, RiskAssessment, RiskFactor};
12
13/// Risk engine configuration
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RiskEngineConfig {
16    /// MFA threshold (0.0 - 1.0)
17    pub mfa_threshold: f64,
18    /// Device challenge threshold (0.0 - 1.0)
19    pub device_challenge_threshold: f64,
20    /// Blocked login threshold (0.0 - 1.0)
21    pub blocked_login_threshold: f64,
22    /// Risk factor weights
23    pub risk_factors: HashMap<String, f64>,
24    /// Risk rules (conditions -> actions)
25    pub risk_rules: Vec<RiskRule>,
26}
27
28/// Risk rule
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct RiskRule {
31    /// Condition (e.g., "risk_score > 0.9")
32    pub condition: String,
33    /// Action to take
34    pub action: RiskAction,
35}
36
37impl Default for RiskEngineConfig {
38    fn default() -> Self {
39        let mut risk_factors = HashMap::new();
40        risk_factors.insert("new_device".to_string(), 0.3);
41        risk_factors.insert("unusual_location".to_string(), 0.4);
42        risk_factors.insert("suspicious_activity".to_string(), 0.5);
43
44        let risk_rules = vec![
45            RiskRule {
46                condition: "risk_score > 0.9".to_string(),
47                action: RiskAction::Block,
48            },
49            RiskRule {
50                condition: "risk_score > 0.7".to_string(),
51                action: RiskAction::RequireMfa,
52            },
53            RiskRule {
54                condition: "risk_score > 0.5".to_string(),
55                action: RiskAction::DeviceChallenge,
56            },
57        ];
58
59        Self {
60            mfa_threshold: 0.7,
61            device_challenge_threshold: 0.5,
62            blocked_login_threshold: 0.9,
63            risk_factors,
64            risk_rules,
65        }
66    }
67}
68
69/// Risk engine state
70#[derive(Debug, Clone)]
71pub struct RiskEngine {
72    /// Configuration
73    pub config: RiskEngineConfig,
74    /// Simulated risk scores (user_id -> risk_score override)
75    pub simulated_risks: Arc<RwLock<HashMap<String, Option<f64>>>>,
76    /// Simulated risk factors (user_id -> risk_factors override)
77    pub simulated_factors: Arc<RwLock<HashMap<String, HashMap<String, f64>>>>,
78}
79
80impl RiskEngine {
81    /// Create new risk engine
82    pub fn new(config: RiskEngineConfig) -> Self {
83        Self {
84            config,
85            simulated_risks: Arc::new(RwLock::new(HashMap::new())),
86            simulated_factors: Arc::new(RwLock::new(HashMap::new())),
87        }
88    }
89
90    /// Assess risk for an authentication request
91    pub async fn assess_risk(
92        &self,
93        user_id: &str,
94        risk_factors: &HashMap<String, f64>,
95    ) -> RiskAssessment {
96        // Check for simulated risk score override
97        let simulated_risk = {
98            let risks = self.simulated_risks.read().await;
99            risks.get(user_id).copied().flatten()
100        };
101
102        if let Some(risk_score) = simulated_risk {
103            return self.create_assessment_from_score(risk_score);
104        }
105
106        // Check for simulated risk factors override
107        let factors_to_use = {
108            let simulated = self.simulated_factors.read().await;
109            if let Some(simulated_factors) = simulated.get(user_id) {
110                simulated_factors.clone()
111            } else {
112                risk_factors.clone()
113            }
114        };
115
116        // Calculate risk score from factors
117        let mut risk_factors_vec = Vec::new();
118        let mut total_score = 0.0;
119
120        for (name, value) in factors_to_use {
121            let weight = self.config.risk_factors.get(&name).copied().unwrap_or(0.0);
122            let contribution = weight * value;
123            total_score += contribution;
124
125            risk_factors_vec.push(RiskFactor {
126                name: name.clone(),
127                weight,
128                value,
129                contribution,
130            });
131        }
132
133        // Clamp score to 0.0 - 1.0
134        let risk_score = total_score.clamp(0.0, 1.0);
135
136        // Determine recommended action
137        let recommended_action = self.determine_action(risk_score);
138
139        RiskAssessment {
140            risk_score,
141            risk_factors: risk_factors_vec,
142            recommended_action,
143        }
144    }
145
146    /// Create assessment from a risk score (for simulation)
147    fn create_assessment_from_score(&self, risk_score: f64) -> RiskAssessment {
148        let recommended_action = self.determine_action(risk_score);
149
150        RiskAssessment {
151            risk_score,
152            risk_factors: vec![],
153            recommended_action,
154        }
155    }
156
157    /// Determine action based on risk score
158    fn determine_action(&self, risk_score: f64) -> RiskAction {
159        // Check risk rules first
160        for rule in &self.config.risk_rules {
161            if self.evaluate_condition(&rule.condition, risk_score) {
162                return rule.action.clone();
163            }
164        }
165
166        // Fallback to threshold-based logic
167        if risk_score >= self.config.blocked_login_threshold {
168            RiskAction::Block
169        } else if risk_score >= self.config.mfa_threshold {
170            RiskAction::RequireMfa
171        } else if risk_score >= self.config.device_challenge_threshold {
172            RiskAction::DeviceChallenge
173        } else {
174            RiskAction::Allow
175        }
176    }
177
178    /// Evaluate a risk condition
179    fn evaluate_condition(&self, condition: &str, risk_score: f64) -> bool {
180        // Check multi-character operators before single-character ones
181        // to avoid `>=` being incorrectly matched by `>`.
182        if condition.contains(">=") {
183            let parts: Vec<&str> = condition.split(">=").collect();
184            if parts.len() == 2 {
185                if let Ok(threshold) = parts[1].trim().parse::<f64>() {
186                    return risk_score >= threshold;
187                }
188            }
189        } else if condition.contains("<=") {
190            let parts: Vec<&str> = condition.split("<=").collect();
191            if parts.len() == 2 {
192                if let Ok(threshold) = parts[1].trim().parse::<f64>() {
193                    return risk_score <= threshold;
194                }
195            }
196        } else if condition.contains("==") {
197            let parts: Vec<&str> = condition.split("==").collect();
198            if parts.len() == 2 {
199                if let Ok(threshold) = parts[1].trim().parse::<f64>() {
200                    return (risk_score - threshold).abs() < 0.001;
201                }
202            }
203        } else if condition.contains('>') {
204            let parts: Vec<&str> = condition.split('>').collect();
205            if parts.len() == 2 {
206                if let Ok(threshold) = parts[1].trim().parse::<f64>() {
207                    return risk_score > threshold;
208                }
209            }
210        } else if condition.contains('<') {
211            let parts: Vec<&str> = condition.split('<').collect();
212            if parts.len() == 2 {
213                if let Ok(threshold) = parts[1].trim().parse::<f64>() {
214                    return risk_score < threshold;
215                }
216            }
217        }
218
219        false
220    }
221
222    /// Set simulated risk score for a user
223    pub async fn set_simulated_risk(&self, user_id: String, risk_score: Option<f64>) {
224        let mut risks = self.simulated_risks.write().await;
225        if let Some(score) = risk_score {
226            risks.insert(user_id, Some(score));
227        } else {
228            risks.remove(&user_id);
229        }
230    }
231
232    /// Set simulated risk factors for a user
233    pub async fn set_simulated_factors(&self, user_id: String, factors: HashMap<String, f64>) {
234        let mut simulated = self.simulated_factors.write().await;
235        simulated.insert(user_id, factors);
236    }
237
238    /// Clear simulated risk for a user
239    pub async fn clear_simulated_risk(&self, user_id: &str) {
240        let mut risks = self.simulated_risks.write().await;
241        risks.remove(user_id);
242        let mut factors = self.simulated_factors.write().await;
243        factors.remove(user_id);
244    }
245}
246
247impl Default for RiskEngine {
248    fn default() -> Self {
249        Self::new(RiskEngineConfig::default())
250    }
251}