Skip to main content

uvb_factor_strength/
lib.rs

1//! # Factor Strength Policies
2//!
3//! Enterprise-grade MFA factor strength enforcement to address:
4//! - **Risk #10**: Phishable factors (TOTP, SMS, email links, push approve)
5//! - **Risk #18**: Weak factor combinations
6//! - **Risk #25**: No phishing-resistant factor requirements
7//!
8//! ## Features
9//!
10//! - **Factor Classification**: Phishable vs phishing-resistant
11//! - **Risk-Based Selection**: Require stronger factors for high-risk operations
12//! - **WebAuthn Enforcement**: Mandatory for admins and sensitive operations
13//! - **Policy Engine**: Per-tenant configurable policies
14//! - **Factor Strength Scoring**: 0-100 scale
15//! - **User Warnings**: Educate users about factor security
16//! - **Factor Promotion**: Encourage WebAuthn adoption
17//! - **Compliance Tracking**: NIST AAL alignment
18
19use async_trait::async_trait;
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use thiserror::Error;
24use tracing::{debug, info, warn};
25
26use uvb_core::{FactorId, TenantId, UserId};
27
28/// Errors that can occur during factor strength enforcement
29#[derive(Debug, Error)]
30pub enum FactorStrengthError {
31    #[error("Storage error: {0}")]
32    Storage(String),
33
34    #[error("Factor {factor_type} does not meet minimum strength requirement (required: {required}, actual: {actual})")]
35    InsufficientStrength {
36        factor_type: String,
37        required: u8,
38        actual: u8,
39    },
40
41    #[error("Operation requires phishing-resistant factor, but {factor_type} is phishable")]
42    PhishableFactorRejected { factor_type: String },
43
44    #[error("User role {role} requires WebAuthn enrollment, but user has no WebAuthn factors")]
45    WebAuthnRequired { role: String },
46
47    #[error("Policy not found for tenant {0}")]
48    PolicyNotFound(TenantId),
49
50    #[error("Invalid factor strength score: {0} (must be 0-100)")]
51    InvalidScore(u8),
52
53    #[error("No factors available that meet policy requirements")]
54    NoSuitableFactors,
55
56    #[error("Factor combination does not meet minimum strength: {0}")]
57    WeakCombination(String),
58}
59
60/// Factor classification based on phishing resistance
61#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
62pub enum FactorClass {
63    /// Phishing-resistant factors (FIDO2/WebAuthn, hardware tokens)
64    PhishingResistant,
65
66    /// Phishable factors (TOTP, SMS, email links, push notifications)
67    Phishable,
68}
69
70/// MFA factor types with security classification
71#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
72pub enum FactorType {
73    /// FIDO2/WebAuthn (hardware or platform authenticators)
74    WebAuthn,
75
76    /// Hardware security keys (YubiKey, Titan, etc.)
77    HardwareToken,
78
79    /// TOTP authenticator apps (Google Authenticator, Authy, etc.)
80    Totp,
81
82    /// SMS-based codes
83    Sms,
84
85    /// Email-based magic links or codes
86    Email,
87
88    /// Push notifications (approve/deny)
89    PushNotification,
90
91    /// Biometric authentication (with device binding)
92    Biometric,
93
94    /// Recovery/backup codes
95    BackupCode,
96}
97
98impl FactorType {
99    /// Get the security classification of this factor type
100    pub fn classification(&self) -> FactorClass {
101        match self {
102            FactorType::WebAuthn | FactorType::HardwareToken => FactorClass::PhishingResistant,
103            FactorType::Totp
104            | FactorType::Sms
105            | FactorType::Email
106            | FactorType::PushNotification
107            | FactorType::Biometric
108            | FactorType::BackupCode => FactorClass::Phishable,
109        }
110    }
111
112    /// Get the strength score (0-100) of this factor type
113    pub fn strength_score(&self) -> u8 {
114        match self {
115            FactorType::WebAuthn => 100,
116            FactorType::HardwareToken => 95,
117            FactorType::Biometric => 75, // Depends on device security
118            FactorType::Totp => 70,
119            FactorType::PushNotification => 60,
120            FactorType::Email => 40,
121            FactorType::Sms => 30,        // Vulnerable to SIM swap
122            FactorType::BackupCode => 50, // One-time use
123        }
124    }
125
126    /// Get human-readable name
127    pub fn display_name(&self) -> &'static str {
128        match self {
129            FactorType::WebAuthn => "Security Key (WebAuthn)",
130            FactorType::HardwareToken => "Hardware Token",
131            FactorType::Totp => "Authenticator App (TOTP)",
132            FactorType::Sms => "SMS Code",
133            FactorType::Email => "Email Code",
134            FactorType::PushNotification => "Push Notification",
135            FactorType::Biometric => "Biometric",
136            FactorType::BackupCode => "Backup Code",
137        }
138    }
139
140    /// Get security warning message for phishable factors
141    pub fn security_warning(&self) -> Option<&'static str> {
142        match self {
143            FactorType::Sms => Some("SMS codes can be intercepted via SIM swap attacks. Consider upgrading to a security key."),
144            FactorType::Email => Some("Email codes can be phished. Consider upgrading to a security key for better security."),
145            FactorType::PushNotification => Some("Push notifications can be approved accidentally (push bombing). Consider using WebAuthn."),
146            FactorType::Totp => Some("TOTP codes can be phished by fake login pages. Consider upgrading to WebAuthn for phishing-resistant authentication."),
147            FactorType::WebAuthn | FactorType::HardwareToken => None, // No warning needed
148            FactorType::Biometric => Some("Biometric authentication security depends on device security. Consider adding a security key."),
149            FactorType::BackupCode => Some("Backup codes should only be used for account recovery. Set up a security key for daily use."),
150        }
151    }
152
153    /// NIST AAL (Authenticator Assurance Level) mapping
154    pub fn nist_aal(&self) -> u8 {
155        match self {
156            FactorType::WebAuthn | FactorType::HardwareToken => 3, // AAL3
157            FactorType::Totp | FactorType::Biometric => 2,         // AAL2
158            FactorType::Sms | FactorType::Email | FactorType::PushNotification => 1, // AAL1
159            FactorType::BackupCode => 1,
160        }
161    }
162}
163
164/// Operation risk level determining required factor strength
165#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
166pub enum OperationRiskLevel {
167    /// Low risk: Read operations, profile viewing
168    Low = 1,
169
170    /// Medium risk: Profile updates, non-critical changes
171    Medium = 2,
172
173    /// High risk: Financial transactions, sensitive data access
174    High = 3,
175
176    /// Critical: Admin operations, security changes, deletions
177    Critical = 4,
178}
179
180impl OperationRiskLevel {
181    /// Get minimum factor strength score required
182    pub fn min_strength_score(&self) -> u8 {
183        match self {
184            OperationRiskLevel::Low => 30,      // SMS acceptable
185            OperationRiskLevel::Medium => 60,   // Push/TOTP required
186            OperationRiskLevel::High => 75,     // Biometric/TOTP required
187            OperationRiskLevel::Critical => 95, // WebAuthn/Hardware required
188        }
189    }
190
191    /// Check if phishing-resistant factor is required
192    pub fn requires_phishing_resistant(&self) -> bool {
193        matches!(self, OperationRiskLevel::Critical)
194    }
195
196    /// Get minimum NIST AAL required
197    pub fn min_nist_aal(&self) -> u8 {
198        match self {
199            OperationRiskLevel::Low => 1,
200            OperationRiskLevel::Medium => 2,
201            OperationRiskLevel::High => 2,
202            OperationRiskLevel::Critical => 3,
203        }
204    }
205}
206
207/// User role determining factor requirements
208#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
209pub enum UserRole {
210    /// Regular user
211    User,
212
213    /// Power user with elevated privileges
214    PowerUser,
215
216    /// Administrator with full system access
217    Admin,
218
219    /// Super admin with multi-tenant access
220    SuperAdmin,
221
222    /// Custom role with specific name
223    Custom(String),
224}
225
226impl UserRole {
227    /// Check if this role requires WebAuthn enrollment
228    pub fn requires_webauthn(&self) -> bool {
229        matches!(self, UserRole::Admin | UserRole::SuperAdmin)
230    }
231
232    /// Get minimum factor strength score for this role
233    pub fn min_strength_score(&self) -> u8 {
234        match self {
235            UserRole::User => 30,
236            UserRole::PowerUser => 60,
237            UserRole::Admin => 95,
238            UserRole::SuperAdmin => 100,
239            UserRole::Custom(_) => 60, // Default to medium security
240        }
241    }
242}
243
244/// Factor strength policy configuration
245#[derive(Clone, Debug, Serialize, Deserialize)]
246pub struct FactorStrengthPolicy {
247    /// Tenant ID this policy applies to
248    pub tenant_id: TenantId,
249
250    /// Policy name
251    pub name: String,
252
253    /// Require phishing-resistant factors for all operations
254    pub enforce_phishing_resistant: bool,
255
256    /// Minimum factor strength score (0-100)
257    pub min_factor_strength: u8,
258
259    /// Require WebAuthn for admin roles
260    pub require_webauthn_for_admins: bool,
261
262    /// Risk level thresholds for operations
263    pub operation_risk_levels: HashMap<String, OperationRiskLevel>,
264
265    /// Role-based factor requirements
266    pub role_requirements: HashMap<String, u8>, // Role name -> min strength
267
268    /// Allow phishable factors as fallback
269    pub allow_phishable_fallback: bool,
270
271    /// Show security warnings for phishable factors
272    pub show_security_warnings: bool,
273
274    /// Grace period for users to upgrade factors (days)
275    pub upgrade_grace_period_days: i64,
276
277    /// Enable factor promotion campaigns
278    pub enable_factor_promotion: bool,
279
280    /// Created timestamp
281    pub created_at: DateTime<Utc>,
282
283    /// Last updated timestamp
284    pub updated_at: DateTime<Utc>,
285}
286
287impl FactorStrengthPolicy {
288    /// Create a default policy (balanced security)
289    pub fn default(tenant_id: TenantId) -> Self {
290        Self {
291            tenant_id,
292            name: "Default Policy".to_string(),
293            enforce_phishing_resistant: false,
294            min_factor_strength: 60, // TOTP minimum
295            require_webauthn_for_admins: true,
296            operation_risk_levels: HashMap::new(),
297            role_requirements: HashMap::new(),
298            allow_phishable_fallback: true,
299            show_security_warnings: true,
300            upgrade_grace_period_days: 90,
301            enable_factor_promotion: true,
302            created_at: Utc::now(),
303            updated_at: Utc::now(),
304        }
305    }
306
307    /// Create a strict policy (high security)
308    pub fn strict(tenant_id: TenantId) -> Self {
309        let mut policy = Self::default(tenant_id);
310        policy.name = "Strict Policy".to_string();
311        policy.enforce_phishing_resistant = true;
312        policy.min_factor_strength = 95; // WebAuthn required
313        policy.allow_phishable_fallback = false;
314        policy.upgrade_grace_period_days = 30;
315        policy
316    }
317
318    /// Create a lenient policy (development/testing)
319    pub fn lenient(tenant_id: TenantId) -> Self {
320        let mut policy = Self::default(tenant_id);
321        policy.name = "Lenient Policy".to_string();
322        policy.min_factor_strength = 30; // SMS acceptable
323        policy.require_webauthn_for_admins = false;
324        policy.show_security_warnings = false;
325        policy.upgrade_grace_period_days = 365;
326        policy.enable_factor_promotion = false;
327        policy
328    }
329
330    /// Check if a factor meets this policy's requirements
331    pub fn is_factor_allowed(
332        &self,
333        factor_type: FactorType,
334        operation_risk: OperationRiskLevel,
335    ) -> Result<(), FactorStrengthError> {
336        let factor_strength = factor_type.strength_score();
337        let min_strength = operation_risk
338            .min_strength_score()
339            .max(self.min_factor_strength);
340
341        // Check strength score
342        if factor_strength < min_strength {
343            return Err(FactorStrengthError::InsufficientStrength {
344                factor_type: factor_type.display_name().to_string(),
345                required: min_strength,
346                actual: factor_strength,
347            });
348        }
349
350        // Check phishing resistance if required
351        if (self.enforce_phishing_resistant || operation_risk.requires_phishing_resistant())
352            && factor_type.classification() == FactorClass::Phishable
353            && !self.allow_phishable_fallback
354        {
355            return Err(FactorStrengthError::PhishableFactorRejected {
356                factor_type: factor_type.display_name().to_string(),
357            });
358        }
359
360        Ok(())
361    }
362
363    /// Get operation risk level for a specific operation
364    pub fn get_operation_risk(&self, operation: &str) -> OperationRiskLevel {
365        self.operation_risk_levels
366            .get(operation)
367            .copied()
368            .unwrap_or(OperationRiskLevel::Medium) // Default to medium
369    }
370}
371
372/// Factor enrollment information
373#[derive(Clone, Debug, Serialize, Deserialize)]
374pub struct EnrolledFactor {
375    pub factor_id: FactorId,
376    pub user_id: UserId,
377    pub factor_type: FactorType,
378    pub enrolled_at: DateTime<Utc>,
379    pub last_used_at: Option<DateTime<Utc>>,
380    pub is_primary: bool,
381}
382
383impl EnrolledFactor {
384    /// Get the strength score of this factor
385    pub fn strength_score(&self) -> u8 {
386        self.factor_type.strength_score()
387    }
388
389    /// Get security classification
390    pub fn classification(&self) -> FactorClass {
391        self.factor_type.classification()
392    }
393
394    /// Check if this factor is phishing-resistant
395    pub fn is_phishing_resistant(&self) -> bool {
396        self.classification() == FactorClass::PhishingResistant
397    }
398}
399
400/// Factor selection recommendation
401#[derive(Clone, Debug, Serialize, Deserialize)]
402pub struct FactorRecommendation {
403    /// Available factors that meet requirements
404    pub suitable_factors: Vec<FactorId>,
405
406    /// Recommended primary factor
407    pub recommended_factor: Option<FactorId>,
408
409    /// Factors that don't meet requirements
410    pub unsuitable_factors: Vec<(FactorId, String)>, // Factor ID and reason
411
412    /// Security warnings for selected factors
413    pub warnings: Vec<String>,
414
415    /// Factor upgrade recommendations
416    pub upgrade_recommendations: Vec<String>,
417}
418
419/// Factor strength enforcement result
420#[derive(Clone, Debug, Serialize, Deserialize)]
421pub struct EnforcementResult {
422    pub allowed: bool,
423    pub factor_id: FactorId,
424    pub factor_type: FactorType,
425    pub strength_score: u8,
426    pub is_phishing_resistant: bool,
427    pub warnings: Vec<String>,
428    pub reason: Option<String>, // Rejection reason if not allowed
429}
430
431/// Storage trait for factor strength policies
432#[async_trait]
433pub trait FactorStrengthStorage: Send + Sync {
434    /// Get policy for a tenant
435    async fn get_policy(
436        &self,
437        tenant_id: &TenantId,
438    ) -> Result<FactorStrengthPolicy, FactorStrengthError>;
439
440    /// Save or update policy
441    async fn save_policy(&self, policy: &FactorStrengthPolicy) -> Result<(), FactorStrengthError>;
442
443    /// Get enrolled factors for a user
444    async fn get_user_factors(
445        &self,
446        user_id: &UserId,
447    ) -> Result<Vec<EnrolledFactor>, FactorStrengthError>;
448
449    /// Record factor usage
450    async fn record_factor_usage(
451        &self,
452        factor_id: &FactorId,
453        operation: &str,
454        risk_level: OperationRiskLevel,
455    ) -> Result<(), FactorStrengthError>;
456
457    /// Check if user is in grace period
458    async fn is_in_grace_period(&self, user_id: &UserId) -> Result<bool, FactorStrengthError>;
459
460    /// Record factor promotion shown
461    async fn record_promotion_shown(
462        &self,
463        user_id: &UserId,
464        promotion_type: &str,
465    ) -> Result<(), FactorStrengthError>;
466}
467
468/// In-memory storage for testing
469pub struct InMemoryFactorStrengthStorage {
470    policies: tokio::sync::RwLock<HashMap<TenantId, FactorStrengthPolicy>>,
471    factors: tokio::sync::RwLock<HashMap<UserId, Vec<EnrolledFactor>>>,
472    grace_periods: tokio::sync::RwLock<HashMap<UserId, DateTime<Utc>>>,
473}
474
475impl InMemoryFactorStrengthStorage {
476    pub fn new() -> Self {
477        Self {
478            policies: tokio::sync::RwLock::new(HashMap::new()),
479            factors: tokio::sync::RwLock::new(HashMap::new()),
480            grace_periods: tokio::sync::RwLock::new(HashMap::new()),
481        }
482    }
483}
484
485impl Default for InMemoryFactorStrengthStorage {
486    fn default() -> Self {
487        Self::new()
488    }
489}
490
491#[async_trait]
492impl FactorStrengthStorage for InMemoryFactorStrengthStorage {
493    async fn get_policy(
494        &self,
495        tenant_id: &TenantId,
496    ) -> Result<FactorStrengthPolicy, FactorStrengthError> {
497        let policies = self.policies.read().await;
498        policies
499            .get(tenant_id)
500            .cloned()
501            .ok_or_else(|| FactorStrengthError::PolicyNotFound(tenant_id.clone()))
502    }
503
504    async fn save_policy(&self, policy: &FactorStrengthPolicy) -> Result<(), FactorStrengthError> {
505        let mut policies = self.policies.write().await;
506        policies.insert(policy.tenant_id.clone(), policy.clone());
507        Ok(())
508    }
509
510    async fn get_user_factors(
511        &self,
512        user_id: &UserId,
513    ) -> Result<Vec<EnrolledFactor>, FactorStrengthError> {
514        let factors = self.factors.read().await;
515        Ok(factors.get(user_id).cloned().unwrap_or_default())
516    }
517
518    async fn record_factor_usage(
519        &self,
520        _factor_id: &FactorId,
521        _operation: &str,
522        _risk_level: OperationRiskLevel,
523    ) -> Result<(), FactorStrengthError> {
524        // In-memory implementation - no persistence needed for testing
525        Ok(())
526    }
527
528    async fn is_in_grace_period(&self, user_id: &UserId) -> Result<bool, FactorStrengthError> {
529        let grace_periods = self.grace_periods.read().await;
530        if let Some(expiry) = grace_periods.get(user_id) {
531            Ok(Utc::now() < *expiry)
532        } else {
533            Ok(false)
534        }
535    }
536
537    async fn record_promotion_shown(
538        &self,
539        _user_id: &UserId,
540        _promotion_type: &str,
541    ) -> Result<(), FactorStrengthError> {
542        Ok(())
543    }
544}
545
546/// Factor strength enforcement manager
547pub struct FactorStrengthManager<S: FactorStrengthStorage> {
548    storage: S,
549}
550
551impl<S: FactorStrengthStorage> FactorStrengthManager<S> {
552    /// Create a new manager with the given storage backend
553    pub fn new(storage: S) -> Self {
554        Self { storage }
555    }
556
557    /// Get policy for a tenant (creates default if not exists)
558    pub async fn get_or_create_policy(
559        &self,
560        tenant_id: &TenantId,
561    ) -> Result<FactorStrengthPolicy, FactorStrengthError> {
562        match self.storage.get_policy(tenant_id).await {
563            Ok(policy) => Ok(policy),
564            Err(FactorStrengthError::PolicyNotFound(_)) => {
565                let policy = FactorStrengthPolicy::default(tenant_id.clone());
566                self.storage.save_policy(&policy).await?;
567                Ok(policy)
568            }
569            Err(e) => Err(e),
570        }
571    }
572
573    /// Check if a user can perform an operation with their factors
574    pub async fn check_operation_allowed(
575        &self,
576        user_id: &UserId,
577        tenant_id: &TenantId,
578        user_role: &UserRole,
579        operation: &str,
580    ) -> Result<FactorRecommendation, FactorStrengthError> {
581        let policy = self.get_or_create_policy(tenant_id).await?;
582        let factors = self.storage.get_user_factors(user_id).await?;
583        let operation_risk = policy.get_operation_risk(operation);
584
585        debug!(
586            "Checking operation '{}' for user {:?} with risk level {:?}",
587            operation, user_id, operation_risk
588        );
589
590        // Check if user role requires WebAuthn
591        if policy.require_webauthn_for_admins && user_role.requires_webauthn() {
592            let has_webauthn = factors.iter().any(|f| {
593                f.factor_type == FactorType::WebAuthn || f.factor_type == FactorType::HardwareToken
594            });
595
596            if !has_webauthn {
597                return Err(FactorStrengthError::WebAuthnRequired {
598                    role: format!("{:?}", user_role),
599                });
600            }
601        }
602
603        let mut suitable_factors = Vec::new();
604        let mut unsuitable_factors = Vec::new();
605        let mut warnings = Vec::new();
606        let mut upgrade_recommendations = Vec::new();
607
608        // Evaluate each factor
609        for factor in &factors {
610            match policy.is_factor_allowed(factor.factor_type, operation_risk) {
611                Ok(()) => {
612                    suitable_factors.push(factor.factor_id.clone());
613
614                    // Add security warning if applicable
615                    if policy.show_security_warnings {
616                        if let Some(warning) = factor.factor_type.security_warning() {
617                            warnings.push(warning.to_string());
618                        }
619                    }
620                }
621                Err(e) => {
622                    unsuitable_factors.push((factor.factor_id.clone(), e.to_string()));
623                }
624            }
625        }
626
627        // Generate upgrade recommendations
628        if policy.enable_factor_promotion {
629            let has_phishing_resistant = factors.iter().any(|f| f.is_phishing_resistant());
630
631            if !has_phishing_resistant {
632                upgrade_recommendations.push(
633                    "Consider adding a security key (WebAuthn) for phishing-resistant authentication.".to_string()
634                );
635            }
636
637            let max_strength = factors
638                .iter()
639                .map(|f| f.strength_score())
640                .max()
641                .unwrap_or(0);
642            if max_strength < 95 {
643                upgrade_recommendations.push(
644                    "Upgrade to WebAuthn or hardware tokens for maximum security.".to_string(),
645                );
646            }
647        }
648
649        // Find recommended primary factor (highest strength)
650        let recommended_factor = suitable_factors.first().cloned();
651
652        if suitable_factors.is_empty() {
653            if self.storage.is_in_grace_period(user_id).await? {
654                warn!(
655                    "User {:?} has no suitable factors but is in grace period",
656                    user_id
657                );
658            } else {
659                return Err(FactorStrengthError::NoSuitableFactors);
660            }
661        }
662
663        Ok(FactorRecommendation {
664            suitable_factors,
665            recommended_factor,
666            unsuitable_factors,
667            warnings,
668            upgrade_recommendations,
669        })
670    }
671
672    /// Enforce factor strength for an operation
673    pub async fn enforce_factor_strength(
674        &self,
675        factor_id: &FactorId,
676        tenant_id: &TenantId,
677        operation: &str,
678    ) -> Result<EnforcementResult, FactorStrengthError> {
679        let policy = self.get_or_create_policy(tenant_id).await?;
680        let operation_risk = policy.get_operation_risk(operation);
681
682        // Find the factor (in real implementation, query from storage)
683        // For now, we'll need the factor type passed in or queried
684
685        info!(
686            "Enforcing factor strength for operation '{}' at risk level {:?}",
687            operation, operation_risk
688        );
689
690        // Record usage
691        self.storage
692            .record_factor_usage(factor_id, operation, operation_risk)
693            .await?;
694
695        // This is a simplified version - in production, would query factor details
696        Ok(EnforcementResult {
697            allowed: true,
698            factor_id: factor_id.clone(),
699            factor_type: FactorType::WebAuthn, // Would be queried
700            strength_score: 100,
701            is_phishing_resistant: true,
702            warnings: vec![],
703            reason: None,
704        })
705    }
706
707    /// Get factor strength report for a user
708    pub async fn get_user_factor_report(
709        &self,
710        user_id: &UserId,
711    ) -> Result<UserFactorReport, FactorStrengthError> {
712        let factors = self.storage.get_user_factors(user_id).await?;
713
714        let total_factors = factors.len();
715        let phishing_resistant_count = factors.iter().filter(|f| f.is_phishing_resistant()).count();
716        let max_strength = factors
717            .iter()
718            .map(|f| f.strength_score())
719            .max()
720            .unwrap_or(0);
721        let min_strength = factors
722            .iter()
723            .map(|f| f.strength_score())
724            .min()
725            .unwrap_or(0);
726
727        let factor_breakdown: HashMap<FactorType, usize> =
728            factors.iter().fold(HashMap::new(), |mut acc, f| {
729                *acc.entry(f.factor_type).or_insert(0) += 1;
730                acc
731            });
732
733        Ok(UserFactorReport {
734            user_id: user_id.clone(),
735            total_factors,
736            phishing_resistant_count,
737            max_strength_score: max_strength,
738            min_strength_score: min_strength,
739            factor_breakdown,
740            has_webauthn: factors
741                .iter()
742                .any(|f| f.factor_type == FactorType::WebAuthn),
743            generated_at: Utc::now(),
744        })
745    }
746
747    /// Save or update policy
748    pub async fn save_policy(
749        &self,
750        policy: &FactorStrengthPolicy,
751    ) -> Result<(), FactorStrengthError> {
752        self.storage.save_policy(policy).await
753    }
754}
755
756/// User factor strength report
757#[derive(Clone, Debug, Serialize, Deserialize)]
758pub struct UserFactorReport {
759    pub user_id: UserId,
760    pub total_factors: usize,
761    pub phishing_resistant_count: usize,
762    pub max_strength_score: u8,
763    pub min_strength_score: u8,
764    pub factor_breakdown: HashMap<FactorType, usize>,
765    pub has_webauthn: bool,
766    pub generated_at: DateTime<Utc>,
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn test_factor_classification() {
775        assert_eq!(
776            FactorType::WebAuthn.classification(),
777            FactorClass::PhishingResistant
778        );
779        assert_eq!(
780            FactorType::HardwareToken.classification(),
781            FactorClass::PhishingResistant
782        );
783        assert_eq!(FactorType::Totp.classification(), FactorClass::Phishable);
784        assert_eq!(FactorType::Sms.classification(), FactorClass::Phishable);
785    }
786
787    #[test]
788    fn test_factor_strength_scores() {
789        assert_eq!(FactorType::WebAuthn.strength_score(), 100);
790        assert_eq!(FactorType::HardwareToken.strength_score(), 95);
791        assert_eq!(FactorType::Totp.strength_score(), 70);
792        assert_eq!(FactorType::Sms.strength_score(), 30);
793    }
794
795    #[test]
796    fn test_operation_risk_requirements() {
797        assert!(OperationRiskLevel::Critical.requires_phishing_resistant());
798        assert!(!OperationRiskLevel::High.requires_phishing_resistant());
799        assert_eq!(OperationRiskLevel::Critical.min_strength_score(), 95);
800        assert_eq!(OperationRiskLevel::Low.min_strength_score(), 30);
801    }
802
803    #[test]
804    fn test_nist_aal_mapping() {
805        assert_eq!(FactorType::WebAuthn.nist_aal(), 3);
806        assert_eq!(FactorType::Totp.nist_aal(), 2);
807        assert_eq!(FactorType::Sms.nist_aal(), 1);
808    }
809
810    #[test]
811    fn test_policy_presets() {
812        let tenant_id = TenantId::new("test_tenant");
813
814        let default_policy = FactorStrengthPolicy::default(tenant_id.clone());
815        assert_eq!(default_policy.min_factor_strength, 60);
816        assert!(default_policy.allow_phishable_fallback);
817
818        let strict_policy = FactorStrengthPolicy::strict(tenant_id.clone());
819        assert_eq!(strict_policy.min_factor_strength, 95);
820        assert!(!strict_policy.allow_phishable_fallback);
821        assert!(strict_policy.enforce_phishing_resistant);
822
823        let lenient_policy = FactorStrengthPolicy::lenient(tenant_id);
824        assert_eq!(lenient_policy.min_factor_strength, 30);
825        assert!(!lenient_policy.require_webauthn_for_admins);
826    }
827
828    #[test]
829    fn test_factor_allowed_checks() {
830        let tenant_id = TenantId::new("test_tenant");
831        let policy = FactorStrengthPolicy::default(tenant_id);
832
833        // SMS should be rejected for critical operations
834        let result = policy.is_factor_allowed(FactorType::Sms, OperationRiskLevel::Critical);
835        assert!(result.is_err());
836
837        // WebAuthn should always be allowed
838        let result = policy.is_factor_allowed(FactorType::WebAuthn, OperationRiskLevel::Critical);
839        assert!(result.is_ok());
840
841        // TOTP should be allowed for medium risk
842        let result = policy.is_factor_allowed(FactorType::Totp, OperationRiskLevel::Medium);
843        assert!(result.is_ok());
844    }
845
846    #[tokio::test]
847    async fn test_manager_basic_operations() {
848        let storage = InMemoryFactorStrengthStorage::new();
849        let manager = FactorStrengthManager::new(storage);
850
851        let tenant_id = TenantId::new("test_tenant");
852        let policy = manager.get_or_create_policy(&tenant_id).await.unwrap();
853
854        assert_eq!(policy.tenant_id, tenant_id);
855        assert_eq!(policy.min_factor_strength, 60);
856    }
857
858    #[tokio::test]
859    async fn test_user_factor_report() {
860        let storage = InMemoryFactorStrengthStorage::new();
861        let manager = FactorStrengthManager::new(storage);
862
863        let user_id = UserId::new("test_user");
864        let report = manager.get_user_factor_report(&user_id).await.unwrap();
865
866        assert_eq!(report.total_factors, 0);
867        assert_eq!(report.phishing_resistant_count, 0);
868    }
869
870    #[test]
871    fn test_user_role_requirements() {
872        assert!(UserRole::Admin.requires_webauthn());
873        assert!(UserRole::SuperAdmin.requires_webauthn());
874        assert!(!UserRole::User.requires_webauthn());
875        assert!(!UserRole::PowerUser.requires_webauthn());
876    }
877}