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

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 = instance
61            .backend()
62            .local_engine()
63            .expect("delegation validation requires local backend");
64        let mut cumulative_bounds: Option<PermissionBounds> = None;
65
66        // Process all delegation steps (tree traversal)
67        for step in steps {
68            // Load delegated tree (step.tree contains the root ID)
69            let delegated_tree_ref = current_auth_settings.get_delegated_tree(&step.tree)?;
70
71            let root_id = delegated_tree_ref.tree.root.clone();
72            let delegated_tree = Database::open(instance, &root_id).await.map_err(|e| {
73                AuthError::DelegatedTreeLoadFailed {
74                    tree_id: root_id.clone(),
75                    source: Box::new(e),
76                }
77            })?;
78
79            // Validate tips
80            let current_snapshot = current_backend.snapshot(&root_id).await.map_err(|e| {
81                AuthError::InvalidAuthConfiguration {
82                    reason: format!(
83                        "Failed to get current tips for delegated tree '{root_id}': {e}"
84                    ),
85                }
86            })?;
87
88            let tips_valid = self
89                .validate_tip_ancestry(&step.tips, current_snapshot.tips(), &current_backend)
90                .await?;
91            if !tips_valid {
92                return Err(AuthError::InvalidDelegationTips {
93                    tree_id: root_id.clone(),
94                    claimed_tips: step.tips.clone(),
95                }
96                .into());
97            }
98
99            // Get delegated tree's auth settings
100            let delegated_settings = delegated_tree.get_settings().await.map_err(|e| {
101                AuthError::InvalidAuthConfiguration {
102                    reason: format!("Failed to get delegated tree settings: {e}"),
103                }
104            })?;
105            current_auth_settings = delegated_settings.auth_snapshot().await.map_err(|e| {
106                AuthError::InvalidAuthConfiguration {
107                    reason: format!("Failed to get delegated tree auth settings: {e}"),
108                }
109            })?;
110
111            // Accumulate permission bounds
112            cumulative_bounds = Some(match cumulative_bounds {
113                Some(existing_bounds) => {
114                    // Combine bounds by taking the minimum of max permissions
115                    let new_max = std::cmp::min(
116                        existing_bounds.max,
117                        delegated_tree_ref.permission_bounds.max,
118                    );
119                    let new_min = match (
120                        existing_bounds.min,
121                        delegated_tree_ref.permission_bounds.min,
122                    ) {
123                        (Some(existing_min), Some(new_min)) => {
124                            Some(std::cmp::max(existing_min, new_min))
125                        }
126                        (Some(existing_min), None) => Some(existing_min),
127                        (None, Some(new_min)) => Some(new_min),
128                        (None, None) => None,
129                    };
130                    PermissionBounds {
131                        max: new_max,
132                        min: new_min,
133                    }
134                }
135                None => delegated_tree_ref.permission_bounds.clone(),
136            });
137        }
138
139        // After traversing all steps, resolve the final hint in the last tree's auth settings
140        let mut matches = current_auth_settings.resolve_hint(final_hint)?;
141        if matches.is_empty() {
142            return Err(AuthError::KeyNotFound {
143                key_name: format!("hint({:?})", final_hint.hint_type()),
144            }
145            .into());
146        }
147
148        // Apply accumulated permission bounds to all matches
149        if let Some(bounds) = cumulative_bounds {
150            for resolved in &mut matches {
151                resolved.effective_permission =
152                    clamp_permission(resolved.effective_permission, &bounds);
153            }
154        }
155
156        Ok(matches)
157    }
158
159    /// Validate tip ancestry using backend's DAG traversal
160    ///
161    /// This method checks if claimed tips are descendants of or equal to current tips
162    /// using the backend's DAG traversal capabilities.
163    ///
164    /// # Arguments
165    /// * `claimed_tips` - Tips claimed by the entry being validated
166    /// * `current_tips` - Current tips from the backend
167    /// * `backend` - Backend to use for DAG traversal
168    async fn validate_tip_ancestry(
169        &self,
170        claimed_tips: &[ID],
171        current_tips: &[ID],
172        backend: &Arc<dyn BackendImpl>,
173    ) -> Result<bool> {
174        // Fast path: If no current tips, accept any claimed tips (first entry in tree)
175        if current_tips.is_empty() {
176            return Ok(true);
177        }
178
179        // Fast path: If no claimed tips, that's invalid (should have at least some context)
180        if claimed_tips.is_empty() {
181            return Ok(false);
182        }
183
184        // Fast path: Check if all claimed tips are identical to current tips
185        if claimed_tips.len() == current_tips.len()
186            && claimed_tips.iter().all(|tip| current_tips.contains(tip))
187        {
188            return Ok(true);
189        }
190
191        // Check if each claimed tip is either:
192        // 1. Equal to a current tip, or
193        // 2. An ancestor of a current tip (meaning we're using older but valid state)
194        // 3. A descendant of a current tip (meaning we're ahead of current state)
195
196        // Validate each claimed tip
197        for claimed_tip in claimed_tips {
198            let mut is_valid = false;
199
200            // Fast path: Check if claimed tip equals any current tip
201            if current_tips.contains(claimed_tip) {
202                is_valid = true;
203            } else {
204                // TODO: For now, we'll use a simplified check and accept the claimed tips
205                // if they exist in the tree at all. A more sophisticated implementation
206                // would verify the actual ancestry relationships using the backend's
207                // DAG traversal methods.
208
209                // Try to get the entry to verify it exists in the tree
210                if backend.get(claimed_tip).await.is_ok() {
211                    is_valid = true;
212                }
213            }
214
215            if !is_valid {
216                return Ok(false);
217            }
218        }
219
220        Ok(true)
221    }
222}
223
224impl Default for DelegationResolver {
225    fn default() -> Self {
226        Self::new()
227    }
228}