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 crate::{
7    Database, Instance, Result, Snapshot,
8    auth::{
9        errors::AuthError,
10        permission::clamp_permission,
11        settings::AuthSettings,
12        types::{DelegationStep, KeyHint, PermissionBounds, ResolvedAuth},
13    },
14    backend::Reachability,
15};
16
17/// Maximum number of steps in a single delegation path.
18///
19/// The path is wire-supplied and processed as a flat list, so its length is the
20/// delegation-chain depth, and checking it here bounds that depth before any
21/// backend work happens. That caps what an unauthenticated signature key can
22/// force the resolver to do before the authorization gate decides.
23const MAX_DELEGATION_STEPS: usize = 10;
24
25/// Maximum number of claimed tips per delegation step.
26///
27/// Tips are wire-supplied and each drives DAG traversal; bound the per-step
28/// fan-out. A legitimate tree frontier is small (concurrent heads only).
29const MAX_DELEGATION_TIPS: usize = 64;
30
31/// Delegation resolver for handling complex delegation paths
32pub struct DelegationResolver;
33
34impl DelegationResolver {
35    /// Create a new delegation resolver
36    pub fn new() -> Self {
37        Self
38    }
39
40    /// Resolve delegation path using flat list structure
41    ///
42    /// This iteratively processes each step in the delegation path,
43    /// applying permission clamping at each level. The final hint
44    /// is resolved in the last delegated tree's auth settings.
45    ///
46    /// Resolution is a flat loop, not recursion: the path arrives as a list, so
47    /// chain depth is `steps.len()` and is bounded up front by
48    /// [`MAX_DELEGATION_STEPS`]. Nothing here re-enters the resolver — the final
49    /// hint is looked up directly in the last tree's settings.
50    ///
51    /// Returns all matching ResolvedAuth entries. For name hints that match
52    /// multiple keys at the final step, all matches are returned with the
53    /// same permission clamping applied to each.
54    pub async fn resolve_delegation_path(
55        &mut self,
56        steps: &[DelegationStep],
57        final_hint: &KeyHint,
58        auth_settings: &AuthSettings,
59        instance: &Instance,
60    ) -> Result<Vec<ResolvedAuth>> {
61        if steps.is_empty() {
62            return Err(AuthError::EmptyDelegationPath.into());
63        }
64
65        // Bound the wire-supplied path length before doing any backend work.
66        if steps.len() > MAX_DELEGATION_STEPS {
67            return Err(AuthError::DelegationPathTooLong {
68                len: steps.len(),
69                max: MAX_DELEGATION_STEPS,
70            }
71            .into());
72        }
73
74        // Validate no global hints in delegation (must resolve to concrete key)
75        if final_hint.is_global() {
76            return Err(AuthError::InvalidDelegationStep {
77                reason: "Delegation paths cannot use global '*' hint".to_string(),
78            }
79            .into());
80        }
81
82        // Iterate through delegation steps
83        let mut current_auth_settings = auth_settings.clone();
84        let current_backend = instance
85            .backend()
86            .local_engine()
87            .expect("delegation validation requires local backend");
88        let mut cumulative_bounds: Option<PermissionBounds> = None;
89
90        // Process all delegation steps (tree traversal)
91        for step in steps {
92            // Bound the wire-supplied claimed tips before any backend traversal.
93            if step.tips.len() > MAX_DELEGATION_TIPS {
94                return Err(AuthError::DelegationTipsTooMany {
95                    tree_id: step.tree.clone(),
96                    len: step.tips.len(),
97                    max: MAX_DELEGATION_TIPS,
98                }
99                .into());
100            }
101
102            // Look up the delegation declaration in the *parent's* settings. The
103            // declaration carries `tree.tips` — the snapshot the parent tree has
104            // committed for this delegation — which is the monotonicity floor
105            // enforced below. Because the parent's auth settings here are taken
106            // at the validating entry's own settings snapshot, the floor is the
107            // historically-correct one, not a global "now".
108            let delegated_tree_ref = current_auth_settings.get_delegated_tree(&step.tree)?;
109
110            let root_id = delegated_tree_ref.tree.root.clone();
111            let delegated_tree = Database::open(instance, &root_id).await.map_err(|e| {
112                AuthError::DelegatedTreeLoadFailed {
113                    tree_id: root_id.clone(),
114                    source: Box::new(e),
115                }
116            })?;
117
118            // Tree-scoped membership + monotonicity floor. The claimed snapshot
119            // may not regress below the snapshot the parent committed for this
120            // delegation (`delegated_tree_ref.tree.tips`, the "floor"): every floor
121            // tip must be an ancestor-or-equal of the claimed tips.
122            // `check_targets_reachable_from` answers exactly that, and in doing so
123            // validates that each claimed tip is a real entry of this delegated
124            // tree (rejecting foreign or fabricated tips). It is bounded by the
125            // target floor height, so the cost tracks the floor distance rather
126            // than the whole tree on both the reachable and unreachable paths —
127            // which matters because this runs on every delegated-entry validation
128            // (and re-validation). Membership here is *presence in the tree*, not
129            // `VerificationStatus::Verified`: a delegation can legitimately resolve
130            // against a delegated tree whose entries are still unverified locally
131            // (e.g. just arrived over sync and not yet re-verified).
132            //
133            // The floor stops an entry time-travelling the delegated tree backwards
134            // to resurrect auth state the parent has already advanced past (e.g. a
135            // since-revoked key). Advancing the floor is an admin-gated `_settings`
136            // write on the parent tree.
137            //
138            // A three-state verdict keeps a *proven* regression (Unreachable →
139            // reject) distinct from "the delegated tree hasn't synced far enough
140            // to decide" (Indeterminate → surface a retriable error so the entry
141            // stays unverified and is re-checked once `missing` arrives, instead
142            // of being rejected as a forgery).
143            //
144            // FIXME(security): the floor is the only monotonicity guarantee today
145            // and is a known partial fix. It enforces neither strict per-entry
146            // non-regression (siblings above the floor may still differ) nor a
147            // forward-only gate on the committed pointer itself. Both remain to be
148            // done.
149            let floor = &delegated_tree_ref.tree.tips;
150            match current_backend
151                .check_targets_reachable_from(&root_id, &step.tips, floor)
152                .await
153                // A foreign (wrong-tree) claimed tip surfaces as a backend
154                // integrity error — treat it as an invalid delegation tip.
155                .map_err(|_| AuthError::InvalidDelegationTips {
156                    tree_id: root_id.clone(),
157                    claimed_tips: step.tips.clone(),
158                })? {
159                Reachability::Reachable => {}
160                Reachability::Unreachable => {
161                    return Err(AuthError::InvalidDelegationTips {
162                        tree_id: root_id.clone(),
163                        claimed_tips: step.tips.clone(),
164                    }
165                    .into());
166                }
167                Reachability::Indeterminate { missing } => {
168                    return Err(AuthError::DelegatedTreeUnsynced {
169                        tree_id: root_id.clone(),
170                        missing,
171                    }
172                    .into());
173                }
174            }
175
176            // Resolve the delegated tree's auth settings AS OF the claimed tips,
177            // not its live head: permissions are evaluated at the state the signer
178            // actually observed. This is safe now that the snapshot cannot regress
179            // below the committed floor. `new_transaction_at` re-validates the tips
180            // are in-tree (defence in depth) and is never committed — it is used
181            // purely as a read anchor at the pinned snapshot.
182            let pinned_txn = delegated_tree
183                .new_transaction_at(&Snapshot::from(step.tips.clone()))
184                .await
185                .map_err(|_| AuthError::InvalidDelegationTips {
186                    tree_id: root_id.clone(),
187                    claimed_tips: step.tips.clone(),
188                })?;
189            current_auth_settings =
190                pinned_txn
191                    .get_settings()?
192                    .auth_snapshot()
193                    .await
194                    .map_err(|e| AuthError::InvalidAuthConfiguration {
195                        reason: format!(
196                            "Failed to read delegated tree auth settings at claimed tips: {e}"
197                        ),
198                    })?;
199
200            // Accumulate permission bounds
201            cumulative_bounds = Some(match cumulative_bounds {
202                Some(existing_bounds) => {
203                    // Combine bounds by taking the minimum of max permissions
204                    let new_max = std::cmp::min(
205                        existing_bounds.max,
206                        delegated_tree_ref.permission_bounds.max,
207                    );
208                    let new_min = match (
209                        existing_bounds.min,
210                        delegated_tree_ref.permission_bounds.min,
211                    ) {
212                        (Some(existing_min), Some(new_min)) => {
213                            Some(std::cmp::max(existing_min, new_min))
214                        }
215                        (Some(existing_min), None) => Some(existing_min),
216                        (None, Some(new_min)) => Some(new_min),
217                        (None, None) => None,
218                    };
219                    PermissionBounds {
220                        max: new_max,
221                        min: new_min,
222                    }
223                }
224                None => delegated_tree_ref.permission_bounds.clone(),
225            });
226        }
227
228        // After traversing all steps, resolve the final hint in the last tree's auth settings
229        let mut matches = current_auth_settings.resolve_hint(final_hint)?;
230        if matches.is_empty() {
231            return Err(AuthError::KeyNotFound {
232                key_name: format!("hint({:?})", final_hint.hint_type()),
233            }
234            .into());
235        }
236
237        // Apply accumulated permission bounds to all matches
238        if let Some(bounds) = cumulative_bounds {
239            for resolved in &mut matches {
240                resolved.effective_permission =
241                    clamp_permission(resolved.effective_permission, &bounds);
242            }
243        }
244
245        Ok(matches)
246    }
247}
248
249impl Default for DelegationResolver {
250    fn default() -> Self {
251        Self::new()
252    }
253}