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}