core_auth/
dispatcher.rs

1//! Authentication backend dispatcher.
2//!
3//! Dispatches vault unlock across registered authentication backends.
4//! Tries non-interactive backends first, falling back to password entry
5//! when no automatic method is available.
6
7use crate::backend::{AuthInteraction, VaultAuthBackend};
8use crate::password::PasswordBackend;
9use crate::ssh::SshAgentBackend;
10use crate::vault_meta::VaultMetadata;
11use core_types::{AuthCombineMode, TrustProfileName};
12use std::path::Path;
13
14/// Dispatches vault unlock across registered authentication backends.
15///
16/// Backend priority order:
17/// 1. SSH-agent (non-interactive, if enrolled and agent available)
18/// 2. Password (interactive fallback)
19pub struct AuthDispatcher {
20    backends: Vec<Box<dyn VaultAuthBackend>>,
21}
22
23impl AuthDispatcher {
24    #[must_use]
25    pub fn new() -> Self {
26        Self {
27            backends: vec![
28                Box::new(SshAgentBackend::new()),
29                Box::new(PasswordBackend::new()),
30            ],
31        }
32    }
33
34    /// Access all registered backends.
35    #[must_use]
36    pub fn backends(&self) -> &[Box<dyn VaultAuthBackend>] {
37        &self.backends
38    }
39
40    /// Determine which backends are applicable for a vault given its metadata.
41    ///
42    /// A backend is applicable if it is enrolled in the vault metadata AND
43    /// can currently perform an unlock.
44    pub async fn applicable_backends(
45        &self,
46        profile: &TrustProfileName,
47        config_dir: &Path,
48        meta: &VaultMetadata,
49    ) -> Vec<&dyn VaultAuthBackend> {
50        let mut applicable = Vec::new();
51        for backend in &self.backends {
52            if meta.has_factor(backend.factor_id()) && backend.can_unlock(profile, config_dir).await
53            {
54                applicable.push(backend.as_ref());
55            }
56        }
57        applicable
58    }
59
60    /// Find the first non-interactive backend that is enrolled AND available.
61    pub async fn find_auto_backend(
62        &self,
63        profile: &TrustProfileName,
64        config_dir: &Path,
65    ) -> Option<&dyn VaultAuthBackend> {
66        for backend in &self.backends {
67            if backend.requires_interaction() == AuthInteraction::None
68                && backend.is_enrolled(profile, config_dir)
69                && backend.can_unlock(profile, config_dir).await
70            {
71                return Some(backend.as_ref());
72            }
73        }
74        None
75    }
76
77    /// Determine if all required factors for a policy can be satisfied
78    /// without interaction (auto-unlock feasibility check).
79    pub async fn can_auto_unlock(
80        &self,
81        profile: &TrustProfileName,
82        config_dir: &Path,
83        meta: &VaultMetadata,
84    ) -> bool {
85        match &meta.auth_policy {
86            AuthCombineMode::Any => {
87                // Any single non-interactive backend suffices.
88                self.find_auto_backend(profile, config_dir).await.is_some()
89            }
90            AuthCombineMode::All | AuthCombineMode::Policy(_) => {
91                // All required factors must be non-interactive.
92                // Conservative: return false if any required factor needs interaction.
93                let applicable = self.applicable_backends(profile, config_dir, meta).await;
94                if applicable.is_empty() {
95                    return false;
96                }
97                applicable
98                    .iter()
99                    .all(|b| b.requires_interaction() == AuthInteraction::None)
100            }
101        }
102    }
103
104    /// Get the password backend (always available as fallback).
105    ///
106    /// # Panics
107    ///
108    /// Panics if the password backend was not registered (this is a programming
109    /// error — the constructor always registers it).
110    #[must_use]
111    pub fn password_backend(&self) -> &dyn VaultAuthBackend {
112        self.backends
113            .iter()
114            .find(|b| b.backend_id() == "password")
115            .map(std::convert::AsRef::as_ref)
116            .expect("password backend is always registered")
117    }
118}
119
120impl Default for AuthDispatcher {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn test_profile() -> TrustProfileName {
131        TrustProfileName::try_from("test-profile").unwrap()
132    }
133
134    #[tokio::test]
135    async fn find_auto_backend_returns_none_without_enrollment() {
136        let dispatcher = AuthDispatcher::new();
137        let profile = test_profile();
138        let result = dispatcher
139            .find_auto_backend(&profile, std::path::Path::new("/nonexistent"))
140            .await;
141        assert!(result.is_none());
142    }
143
144    #[test]
145    fn password_backend_always_available() {
146        let dispatcher = AuthDispatcher::new();
147        let backend = dispatcher.password_backend();
148        assert_eq!(backend.backend_id(), "password");
149        assert_eq!(
150            backend.requires_interaction(),
151            AuthInteraction::PasswordEntry
152        );
153    }
154
155    #[tokio::test]
156    async fn applicable_backends_empty_when_no_enrollment() {
157        let dispatcher = AuthDispatcher::new();
158        let profile = test_profile();
159        let meta = VaultMetadata::new_password(AuthCombineMode::Any);
160        // PasswordBackend.can_unlock requires .password-wrap file to exist.
161        let applicable = dispatcher
162            .applicable_backends(&profile, std::path::Path::new("/nonexistent"), &meta)
163            .await;
164        // Password has no .password-wrap at /nonexistent, so it's not applicable.
165        assert!(applicable.is_empty());
166    }
167
168    #[tokio::test]
169    async fn can_auto_unlock_false_without_enrollment() {
170        let dispatcher = AuthDispatcher::new();
171        let profile = test_profile();
172        let meta = VaultMetadata::new_password(AuthCombineMode::Any);
173        assert!(
174            !dispatcher
175                .can_auto_unlock(&profile, std::path::Path::new("/nonexistent"), &meta)
176                .await
177        );
178    }
179}