1use 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#[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#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
62pub enum FactorClass {
63 PhishingResistant,
65
66 Phishable,
68}
69
70#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
72pub enum FactorType {
73 WebAuthn,
75
76 HardwareToken,
78
79 Totp,
81
82 Sms,
84
85 Email,
87
88 PushNotification,
90
91 Biometric,
93
94 BackupCode,
96}
97
98impl FactorType {
99 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 pub fn strength_score(&self) -> u8 {
114 match self {
115 FactorType::WebAuthn => 100,
116 FactorType::HardwareToken => 95,
117 FactorType::Biometric => 75, FactorType::Totp => 70,
119 FactorType::PushNotification => 60,
120 FactorType::Email => 40,
121 FactorType::Sms => 30, FactorType::BackupCode => 50, }
124 }
125
126 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 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, 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 pub fn nist_aal(&self) -> u8 {
155 match self {
156 FactorType::WebAuthn | FactorType::HardwareToken => 3, FactorType::Totp | FactorType::Biometric => 2, FactorType::Sms | FactorType::Email | FactorType::PushNotification => 1, FactorType::BackupCode => 1,
160 }
161 }
162}
163
164#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
166pub enum OperationRiskLevel {
167 Low = 1,
169
170 Medium = 2,
172
173 High = 3,
175
176 Critical = 4,
178}
179
180impl OperationRiskLevel {
181 pub fn min_strength_score(&self) -> u8 {
183 match self {
184 OperationRiskLevel::Low => 30, OperationRiskLevel::Medium => 60, OperationRiskLevel::High => 75, OperationRiskLevel::Critical => 95, }
189 }
190
191 pub fn requires_phishing_resistant(&self) -> bool {
193 matches!(self, OperationRiskLevel::Critical)
194 }
195
196 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
209pub enum UserRole {
210 User,
212
213 PowerUser,
215
216 Admin,
218
219 SuperAdmin,
221
222 Custom(String),
224}
225
226impl UserRole {
227 pub fn requires_webauthn(&self) -> bool {
229 matches!(self, UserRole::Admin | UserRole::SuperAdmin)
230 }
231
232 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, }
241 }
242}
243
244#[derive(Clone, Debug, Serialize, Deserialize)]
246pub struct FactorStrengthPolicy {
247 pub tenant_id: TenantId,
249
250 pub name: String,
252
253 pub enforce_phishing_resistant: bool,
255
256 pub min_factor_strength: u8,
258
259 pub require_webauthn_for_admins: bool,
261
262 pub operation_risk_levels: HashMap<String, OperationRiskLevel>,
264
265 pub role_requirements: HashMap<String, u8>, pub allow_phishable_fallback: bool,
270
271 pub show_security_warnings: bool,
273
274 pub upgrade_grace_period_days: i64,
276
277 pub enable_factor_promotion: bool,
279
280 pub created_at: DateTime<Utc>,
282
283 pub updated_at: DateTime<Utc>,
285}
286
287impl FactorStrengthPolicy {
288 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, 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 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; policy.allow_phishable_fallback = false;
314 policy.upgrade_grace_period_days = 30;
315 policy
316 }
317
318 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; 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 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 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 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 pub fn get_operation_risk(&self, operation: &str) -> OperationRiskLevel {
365 self.operation_risk_levels
366 .get(operation)
367 .copied()
368 .unwrap_or(OperationRiskLevel::Medium) }
370}
371
372#[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 pub fn strength_score(&self) -> u8 {
386 self.factor_type.strength_score()
387 }
388
389 pub fn classification(&self) -> FactorClass {
391 self.factor_type.classification()
392 }
393
394 pub fn is_phishing_resistant(&self) -> bool {
396 self.classification() == FactorClass::PhishingResistant
397 }
398}
399
400#[derive(Clone, Debug, Serialize, Deserialize)]
402pub struct FactorRecommendation {
403 pub suitable_factors: Vec<FactorId>,
405
406 pub recommended_factor: Option<FactorId>,
408
409 pub unsuitable_factors: Vec<(FactorId, String)>, pub warnings: Vec<String>,
414
415 pub upgrade_recommendations: Vec<String>,
417}
418
419#[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>, }
430
431#[async_trait]
433pub trait FactorStrengthStorage: Send + Sync {
434 async fn get_policy(
436 &self,
437 tenant_id: &TenantId,
438 ) -> Result<FactorStrengthPolicy, FactorStrengthError>;
439
440 async fn save_policy(&self, policy: &FactorStrengthPolicy) -> Result<(), FactorStrengthError>;
442
443 async fn get_user_factors(
445 &self,
446 user_id: &UserId,
447 ) -> Result<Vec<EnrolledFactor>, FactorStrengthError>;
448
449 async fn record_factor_usage(
451 &self,
452 factor_id: &FactorId,
453 operation: &str,
454 risk_level: OperationRiskLevel,
455 ) -> Result<(), FactorStrengthError>;
456
457 async fn is_in_grace_period(&self, user_id: &UserId) -> Result<bool, FactorStrengthError>;
459
460 async fn record_promotion_shown(
462 &self,
463 user_id: &UserId,
464 promotion_type: &str,
465 ) -> Result<(), FactorStrengthError>;
466}
467
468pub 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 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
546pub struct FactorStrengthManager<S: FactorStrengthStorage> {
548 storage: S,
549}
550
551impl<S: FactorStrengthStorage> FactorStrengthManager<S> {
552 pub fn new(storage: S) -> Self {
554 Self { storage }
555 }
556
557 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 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 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 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 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 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 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 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 info!(
686 "Enforcing factor strength for operation '{}' at risk level {:?}",
687 operation, operation_risk
688 );
689
690 self.storage
692 .record_factor_usage(factor_id, operation, operation_risk)
693 .await?;
694
695 Ok(EnforcementResult {
697 allowed: true,
698 factor_id: factor_id.clone(),
699 factor_type: FactorType::WebAuthn, strength_score: 100,
701 is_phishing_resistant: true,
702 warnings: vec![],
703 reason: None,
704 })
705 }
706
707 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 pub async fn save_policy(
749 &self,
750 policy: &FactorStrengthPolicy,
751 ) -> Result<(), FactorStrengthError> {
752 self.storage.save_policy(policy).await
753 }
754}
755
756#[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 let result = policy.is_factor_allowed(FactorType::Sms, OperationRiskLevel::Critical);
835 assert!(result.is_err());
836
837 let result = policy.is_factor_allowed(FactorType::WebAuthn, OperationRiskLevel::Critical);
839 assert!(result.is_ok());
840
841 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}