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