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(), ¤t_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}