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}