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