Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica

eidetica/auth/validation/
delegation.rs

1//! Delegation path resolution for authentication
2//!
3//! This module handles the complex logic of resolving delegation paths,
4//! including multi-tree traversal and permission clamping.
5
6use std::sync::Arc;
7
8use crate::{
9    Database, Instance, Result,
10    auth::{
11        errors::AuthError,
12        permission::clamp_permission,
13        settings::AuthSettings,
14        types::{DelegationStep, KeyHint, PermissionBounds, ResolvedAuth},
15    },
16    backend::BackendImpl,
17    entry::ID,
18};
19
20/// Delegation resolver for handling complex delegation paths
21pub struct DelegationResolver;
22
23impl DelegationResolver {
24    /// Create a new delegation resolver
25    pub fn new() -> Self {
26        Self
27    }
28
29    /// Resolve delegation path using flat list structure
30    ///
31    /// This iteratively processes each step in the delegation path,
32    /// applying permission clamping at each level. The final hint
33    /// is resolved in the last delegated tree's auth settings.
34    ///
35    /// Returns all matching ResolvedAuth entries. For name hints that match
36    /// multiple keys at the final step, all matches are returned with the
37    /// same permission clamping applied to each.
38    pub async fn resolve_delegation_path_with_depth(
39        &mut self,
40        steps: &[DelegationStep],
41        final_hint: &KeyHint,
42        auth_settings: &AuthSettings,
43        instance: &Instance,
44        _depth: usize,
45    ) -> Result<Vec<ResolvedAuth>> {
46        if steps.is_empty() {
47            return Err(AuthError::EmptyDelegationPath.into());
48        }
49
50        // Validate no global hints in delegation (must resolve to concrete key)
51        if final_hint.is_global() {
52            return Err(AuthError::InvalidDelegationStep {
53                reason: "Delegation paths cannot use global '*' hint".to_string(),
54            }
55            .into());
56        }
57
58        // Iterate through delegation steps
59        let mut current_auth_settings = auth_settings.clone();
60        let current_backend = Arc::clone(instance.backend().as_arc_backend_impl());
61        let mut cumulative_bounds: Option<PermissionBounds> = None;
62
63        // Process all delegation steps (tree traversal)
64        for step in steps {
65            // Load delegated tree (step.tree contains the root ID as a string)
66            let delegated_tree_ref = current_auth_settings.get_delegated_tree_by_str(&step.tree)?;
67
68            let root_id = delegated_tree_ref.tree.root.clone();
69            let delegated_tree = Database::open_unauthenticated(root_id.clone(), instance)
70                .map_err(|e| AuthError::DelegatedTreeLoadFailed {
71                    tree_id: root_id.to_string(),
72                    source: Box::new(e),
73                })?;
74
75            // Validate tips
76            let current_tips = current_backend.get_tips(&root_id).await.map_err(|e| {
77                AuthError::InvalidAuthConfiguration {
78                    reason: format!(
79                        "Failed to get current tips for delegated tree '{root_id}': {e}"
80                    ),
81                }
82            })?;
83
84            let tips_valid = self
85                .validate_tip_ancestry(&step.tips, &current_tips, &current_backend)
86                .await?;
87            if !tips_valid {
88                return Err(AuthError::InvalidDelegationTips {
89                    tree_id: root_id.to_string(),
90                    claimed_tips: step.tips.clone(),
91                }
92                .into());
93            }
94
95            // Get delegated tree's auth settings
96            let delegated_settings = delegated_tree.get_settings().await.map_err(|e| {
97                AuthError::InvalidAuthConfiguration {
98                    reason: format!("Failed to get delegated tree settings: {e}"),
99                }
100            })?;
101            current_auth_settings = delegated_settings.auth_snapshot().await.map_err(|e| {
102                AuthError::InvalidAuthConfiguration {
103                    reason: format!("Failed to get delegated tree auth settings: {e}"),
104                }
105            })?;
106
107            // Accumulate permission bounds
108            cumulative_bounds = Some(match cumulative_bounds {
109                Some(existing_bounds) => {
110                    // Combine bounds by taking the minimum of max permissions
111                    let new_max = std::cmp::min(
112                        existing_bounds.max,
113                        delegated_tree_ref.permission_bounds.max,
114                    );
115                    let new_min = match (
116                        existing_bounds.min,
117                        delegated_tree_ref.permission_bounds.min,
118                    ) {
119                        (Some(existing_min), Some(new_min)) => {
120                            Some(std::cmp::max(existing_min, new_min))
121                        }
122                        (Some(existing_min), None) => Some(existing_min),
123                        (None, Some(new_min)) => Some(new_min),
124                        (None, None) => None,
125                    };
126                    PermissionBounds {
127                        max: new_max,
128                        min: new_min,
129                    }
130                }
131                None => delegated_tree_ref.permission_bounds.clone(),
132            });
133        }
134
135        // After traversing all steps, resolve the final hint in the last tree's auth settings
136        let mut matches = current_auth_settings.resolve_hint(final_hint)?;
137        if matches.is_empty() {
138            return Err(AuthError::KeyNotFound {
139                key_name: format!("hint({:?})", final_hint.hint_type()),
140            }
141            .into());
142        }
143
144        // Apply accumulated permission bounds to all matches
145        if let Some(bounds) = cumulative_bounds {
146            for resolved in &mut matches {
147                resolved.effective_permission =
148                    clamp_permission(resolved.effective_permission, &bounds);
149            }
150        }
151
152        Ok(matches)
153    }
154
155    /// Validate tip ancestry using backend's DAG traversal
156    ///
157    /// This method checks if claimed tips are descendants of or equal to current tips
158    /// using the backend's DAG traversal capabilities.
159    ///
160    /// # Arguments
161    /// * `claimed_tips` - Tips claimed by the entry being validated
162    /// * `current_tips` - Current tips from the backend
163    /// * `backend` - Backend to use for DAG traversal
164    async fn validate_tip_ancestry(
165        &self,
166        claimed_tips: &[ID],
167        current_tips: &[ID],
168        backend: &Arc<dyn BackendImpl>,
169    ) -> Result<bool> {
170        // Fast path: If no current tips, accept any claimed tips (first entry in tree)
171        if current_tips.is_empty() {
172            return Ok(true);
173        }
174
175        // Fast path: If no claimed tips, that's invalid (should have at least some context)
176        if claimed_tips.is_empty() {
177            return Ok(false);
178        }
179
180        // Fast path: Check if all claimed tips are identical to current tips
181        if claimed_tips.len() == current_tips.len()
182            && claimed_tips.iter().all(|tip| current_tips.contains(tip))
183        {
184            return Ok(true);
185        }
186
187        // Check if each claimed tip is either:
188        // 1. Equal to a current tip, or
189        // 2. An ancestor of a current tip (meaning we're using older but valid state)
190        // 3. A descendant of a current tip (meaning we're ahead of current state)
191
192        // Validate each claimed tip
193        for claimed_tip in claimed_tips {
194            let mut is_valid = false;
195
196            // Fast path: Check if claimed tip equals any current tip
197            if current_tips.contains(claimed_tip) {
198                is_valid = true;
199            } else {
200                // TODO: For now, we'll use a simplified check and accept the claimed tips
201                // if they exist in the tree at all. A more sophisticated implementation
202                // would verify the actual ancestry relationships using the backend's
203                // DAG traversal methods.
204
205                // Try to get the entry to verify it exists in the tree
206                if backend.get(claimed_tip).await.is_ok() {
207                    is_valid = true;
208                }
209            }
210
211            if !is_valid {
212                return Ok(false);
213            }
214        }
215
216        Ok(true)
217    }
218}
219
220impl Default for DelegationResolver {
221    fn default() -> Self {
222        Self::new()
223    }
224}