Skip to main content

devboy_storage/
router_credentials.rs

1//! Source-credential recursion check per [ADR-021] §4.
2//!
3//! A source `A` that declares
4//! [`SecretSource::requires_credential`](crate::source::SecretSource::requires_credential)
5//! `= Some(CredentialRef::Path(...))` must have its credential
6//! resolved through a source `B` whose `requires_credential()` is
7//! `None`. The router enforces this at *configuration load*, before
8//! any `get()` is dispatched, so the user gets a structured error
9//! rather than a runtime stack-overflow on the first secret read.
10//!
11//! ## Why one hop?
12//!
13//! The reasoning from ADR-021 §4: a Vault token cannot itself be
14//! stored in Vault, because reading it would require Vault to
15//! already be unlockable. The keychain (and the local-vault from
16//! ADR-023, once unlocked) are the only sources that may hold
17//! source-credentials, because they have no `requires_credential()`
18//! of their own.
19//!
20//! Anything deeper than one hop is either a misconfiguration the
21//! user did not realise, or a literal cycle. Both fail the load
22//! with a typed error from this module.
23//!
24//! ## What the validator checks
25//!
26//! For every source `A` in [`RouterConfig::sources`] whose
27//! `requires_credential()` is `Some(CredentialRef::Path(p))`:
28//!
29//! 1. `p` must live under the reserved `__sources/` namespace.
30//!    Anything else is rejected with [`CredentialGraphError::BadCredentialPath`]
31//!    so users can't accidentally route source-credentials through
32//!    their normal manifest.
33//! 2. `p` must resolve through the configured router rules to some
34//!    source `B`.
35//! 3. Walk the chain `A → B → C → …`. The first node whose
36//!    `requires_credential()` is `None` (or
37//!    `Some(CredentialRef::Sentinel)` — a sentinel means the
38//!    source handles its own auth and is treated as terminal)
39//!    closes the chain.
40//! 4. If we revisit a node, the chain is a cycle —
41//!    [`CredentialGraphError::Cycle`].
42//! 5. Otherwise, if the chain is longer than one hop —
43//!    [`CredentialGraphError::Deep`].
44//!
45//! `Sentinel`-typed credentials are *not* graph edges; the source
46//! plugin interprets them natively (`biometric`,
47//! `default-profile`). They terminate the walk with no traversal.
48//!
49//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-external-secret-sources.md
50//! [`RouterConfig::sources`]: crate::router_config::RouterConfig::sources
51
52use thiserror::Error;
53
54use crate::router_config::RouterConfig;
55use crate::router_resolve::{PathResolver, ResolveError};
56use crate::secret_path::SecretPath;
57use crate::source::CredentialRef;
58
59/// Reserved prefix for source-authentication credential paths.
60/// Anything outside this namespace is rejected as a credential
61/// path; per ADR-021 §5 only `__sources/<source>/<profile>` paths
62/// may carry source credentials.
63pub const SOURCE_CREDENTIALS_PREFIX: &str = "__sources/";
64
65// =============================================================================
66// Errors
67// =============================================================================
68
69/// Failure modes for [`validate_source_credentials`].
70#[derive(Debug, Clone, PartialEq, Eq, Error)]
71pub enum CredentialGraphError {
72    /// A source declared a credential at `path`, but `path` is not
73    /// under the reserved [`SOURCE_CREDENTIALS_PREFIX`]. Per
74    /// ADR-021 §5: source credentials must live under `__sources/`.
75    //
76    // `source_name` is intentionally not named `source` — thiserror
77    // would interpret a field named `source` as the `#[source]`
78    // chain root (cf. `UnroutableCredential::source_error`).
79    #[error(
80        "source '{source_name}' declares its credential at '{path}', but credential paths must live under `{SOURCE_CREDENTIALS_PREFIX}`"
81    )]
82    BadCredentialPath {
83        /// The source whose `requires_credential()` returned this
84        /// path.
85        source_name: String,
86        /// The offending path.
87        path: String,
88    },
89
90    /// The credential path failed to resolve through the router.
91    /// Usually means the user did not configure routing for
92    /// `__sources/<source>/...` and there is no `[default]`.
93    #[error("source '{source_name}' credential at '{path}' is unroutable: {source_error}")]
94    UnroutableCredential {
95        /// The source whose credential is unroutable.
96        source_name: String,
97        /// The credential path.
98        path: String,
99        /// Underlying resolver error.
100        #[source]
101        source_error: ResolveError,
102    },
103
104    /// `E_SOURCE_CREDENTIAL_CYCLE` — the credential graph contains
105    /// a cycle. Reading any secret would loop forever.
106    #[error(
107        "source credential cycle detected: {}",
108        chain.join(" -> ")
109    )]
110    Cycle {
111        /// Source names visited, in order, ending with the source
112        /// that closed the cycle.
113        chain: Vec<String>,
114    },
115
116    /// `E_SOURCE_CREDENTIAL_DEEP` — the credential chain is longer
117    /// than one hop. Per ADR-021 §4, only one level of indirection
118    /// is allowed (Vault tokens live in keychain, not in another
119    /// Vault).
120    #[error(
121        "source credential chain too deep (>1 hop): {}",
122        chain.join(" -> ")
123    )]
124    Deep {
125        /// Source names visited, in order. The first is the source
126        /// whose credential is mis-routed; the rest are downstream
127        /// dependencies.
128        chain: Vec<String>,
129    },
130}
131
132// =============================================================================
133// Validator
134// =============================================================================
135
136/// Validate the source-credential graph defined by `config` and the
137/// `requires_credential` lookup for each defined source.
138///
139/// `requires_credential` maps a source name to its
140/// [`SecretSource::requires_credential`](crate::source::SecretSource::requires_credential)
141/// return value. The router constructs sources first, then passes
142/// `|name| sources[name].requires_credential()` here. Tests stub
143/// the function with a static map.
144///
145/// Returns `Ok(())` when every source either has no credential, is
146/// satisfied by a sentinel, or routes through exactly one
147/// credential-free source. Otherwise returns the appropriate
148/// [`CredentialGraphError`].
149pub fn validate_source_credentials<F>(
150    config: &RouterConfig,
151    mut requires_credential: F,
152) -> Result<(), CredentialGraphError>
153where
154    F: FnMut(&str) -> Option<CredentialRef>,
155{
156    let resolver = PathResolver::new(config);
157
158    for src in &config.sources {
159        let name = &src.name;
160        let cred = requires_credential(name);
161        match cred {
162            None => continue,
163            Some(CredentialRef::Sentinel(_)) => continue,
164            Some(CredentialRef::Path(p)) => {
165                ensure_credential_path_namespace(name, &p)?;
166                walk_chain(name, &p, &resolver, &mut requires_credential)?;
167            }
168        }
169    }
170
171    Ok(())
172}
173
174fn ensure_credential_path_namespace(
175    source_name: &str,
176    path: &SecretPath,
177) -> Result<(), CredentialGraphError> {
178    if !path.as_str().starts_with(SOURCE_CREDENTIALS_PREFIX) {
179        return Err(CredentialGraphError::BadCredentialPath {
180            source_name: source_name.to_owned(),
181            path: path.to_string(),
182        });
183    }
184    Ok(())
185}
186
187/// Walk the chain starting from `start` whose credential lives at
188/// `cred_path`. The chain length is counted in **edges** — a valid
189/// one-hop chain has length 1.
190fn walk_chain<F>(
191    start: &str,
192    cred_path: &SecretPath,
193    resolver: &PathResolver<'_>,
194    requires_credential: &mut F,
195) -> Result<(), CredentialGraphError>
196where
197    F: FnMut(&str) -> Option<CredentialRef>,
198{
199    // `chain` records every source name we've visited so we can
200    // (a) detect cycles and (b) include a useful trace in error
201    // messages.
202    let mut chain: Vec<String> = vec![start.to_owned()];
203    let mut current_path = cred_path.clone();
204    let mut hop = 0usize;
205
206    loop {
207        let decision = resolver.resolve(&current_path).map_err(|e| {
208            CredentialGraphError::UnroutableCredential {
209                source_name: start.to_owned(),
210                path: current_path.to_string(),
211                source_error: e,
212            }
213        })?;
214        let next = decision.source().to_owned();
215        hop += 1;
216
217        // Cycle: revisiting a source we already saw — including
218        // `start` itself if hop == 1 closes back to it.
219        if chain.iter().any(|s| s == &next) {
220            chain.push(next);
221            return Err(CredentialGraphError::Cycle { chain });
222        }
223        chain.push(next.clone());
224
225        let next_cred = requires_credential(&next);
226        match next_cred {
227            None | Some(CredentialRef::Sentinel(_)) => {
228                // Terminal. Valid only if hop == 1; otherwise the
229                // chain went too deep before closing.
230                if hop > 1 {
231                    return Err(CredentialGraphError::Deep { chain });
232                }
233                return Ok(());
234            }
235            Some(CredentialRef::Path(p)) => {
236                if hop >= 1 {
237                    // We already consumed the one allowed hop and
238                    // the next node still needs a credential —
239                    // either we eventually cycle back (caught on
240                    // the next iteration) or we walk forever
241                    // through credential-bearing sources, which is
242                    // DEEP. Continue the walk so the loop can tell
243                    // them apart.
244                    ensure_credential_path_namespace(&next, &p)?;
245                    current_path = p;
246                    continue;
247                }
248            }
249        }
250    }
251}
252
253// =============================================================================
254// Tests
255// =============================================================================
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::router_config::RouterConfig;
261    use std::collections::HashMap;
262
263    fn cfg(toml: &str) -> RouterConfig {
264        RouterConfig::parse(toml).expect("fixture config must parse")
265    }
266
267    fn p_internal(s: &str) -> SecretPath {
268        SecretPath::parse_internal(s).expect("internal path must parse")
269    }
270
271    /// Convenience — build a `requires_credential` function over a
272    /// static map. `None` entries mean credential-free.
273    fn req_map(
274        m: HashMap<String, Option<CredentialRef>>,
275    ) -> impl FnMut(&str) -> Option<CredentialRef> {
276        move |name| m.get(name).cloned().unwrap_or(None)
277    }
278
279    // -- 1) Valid one-hop chain -----------------------------------
280
281    #[test]
282    fn one_hop_chain_through_keychain_is_valid() {
283        // vault-team needs a credential; it routes to keychain
284        // (credential-free) via a `[[route]]` for the `__sources/`
285        // namespace.
286        let c = cfg(r#"
287            [[source]]
288            name = "vault-team"
289            type = "vault"
290
291            [[source]]
292            name = "keychain"
293            type = "keychain"
294
295            [[route]]
296            prefix = "__sources/"
297            source = "keychain"
298            "#);
299        let req = req_map(
300            [
301                (
302                    "vault-team".to_owned(),
303                    Some(CredentialRef::Path(p_internal(
304                        "__sources/vault-team/deploy",
305                    ))),
306                ),
307                ("keychain".to_owned(), None),
308            ]
309            .into_iter()
310            .collect(),
311        );
312        validate_source_credentials(&c, req).expect("one-hop chain should be valid");
313    }
314
315    // -- 2) Self-cycle -------------------------------------------
316
317    #[test]
318    fn self_loop_is_a_cycle() {
319        // vault-team requires a credential whose path resolves
320        // back to vault-team itself (via [secret] override).
321        let c = cfg(r#"
322            [[source]]
323            name = "vault-team"
324            type = "vault"
325
326            [secret."__sources/vault-team/deploy"]
327            source = "vault-team"
328            reference = "secret/data/__sources/vault-team/deploy"
329            "#);
330        let req = req_map(
331            [(
332                "vault-team".to_owned(),
333                Some(CredentialRef::Path(p_internal(
334                    "__sources/vault-team/deploy",
335                ))),
336            )]
337            .into_iter()
338            .collect(),
339        );
340        let err = validate_source_credentials(&c, req).unwrap_err();
341        match err {
342            CredentialGraphError::Cycle { chain } => {
343                assert_eq!(
344                    chain,
345                    vec!["vault-team".to_owned(), "vault-team".to_owned()]
346                );
347            }
348            other => panic!("expected Cycle, got {other:?}"),
349        }
350    }
351
352    // -- 3) Two-node cycle ---------------------------------------
353
354    #[test]
355    fn two_node_cycle_detected() {
356        // A → B (via [secret] override of __sources/A/...)
357        // B → A (via [secret] override of __sources/B/...)
358        let c = cfg(r#"
359            [[source]]
360            name = "vault-a"
361            type = "vault"
362
363            [[source]]
364            name = "vault-b"
365            type = "vault"
366
367            [secret."__sources/vault-a/x"]
368            source = "vault-b"
369            reference = "secret/a/x"
370
371            [secret."__sources/vault-b/x"]
372            source = "vault-a"
373            reference = "secret/b/x"
374            "#);
375        let req = req_map(
376            [
377                (
378                    "vault-a".to_owned(),
379                    Some(CredentialRef::Path(p_internal("__sources/vault-a/x"))),
380                ),
381                (
382                    "vault-b".to_owned(),
383                    Some(CredentialRef::Path(p_internal("__sources/vault-b/x"))),
384                ),
385            ]
386            .into_iter()
387            .collect(),
388        );
389        let err = validate_source_credentials(&c, req).unwrap_err();
390        match err {
391            CredentialGraphError::Cycle { chain } => {
392                // Either A->B->A or B->A->B depending on iteration
393                // order over config.sources (Vec, declaration
394                // order). With the config above we hit vault-a
395                // first.
396                assert_eq!(chain.first().unwrap(), "vault-a");
397                assert_eq!(chain.last().unwrap(), "vault-a");
398                assert!(chain.iter().any(|n| n == "vault-b"));
399            }
400            other => panic!("expected Cycle, got {other:?}"),
401        }
402    }
403
404    // -- 4) Deep chain (no cycle) --------------------------------
405
406    #[test]
407    fn three_hop_chain_without_cycle_is_deep() {
408        // A requires __sources/A/x → routes to B (B requires
409        // __sources/B/y → routes to C (C credential-free)).
410        let c = cfg(r#"
411            [[source]]
412            name = "vault-a"
413            type = "vault"
414
415            [[source]]
416            name = "vault-b"
417            type = "vault"
418
419            [[source]]
420            name = "keychain"
421            type = "keychain"
422
423            [secret."__sources/vault-a/x"]
424            source = "vault-b"
425            reference = "x"
426
427            [secret."__sources/vault-b/y"]
428            source = "keychain"
429            reference = "y"
430            "#);
431        let req = req_map(
432            [
433                (
434                    "vault-a".to_owned(),
435                    Some(CredentialRef::Path(p_internal("__sources/vault-a/x"))),
436                ),
437                (
438                    "vault-b".to_owned(),
439                    Some(CredentialRef::Path(p_internal("__sources/vault-b/y"))),
440                ),
441                ("keychain".to_owned(), None),
442            ]
443            .into_iter()
444            .collect(),
445        );
446        let err = validate_source_credentials(&c, req).unwrap_err();
447        match err {
448            CredentialGraphError::Deep { chain } => {
449                assert_eq!(
450                    chain,
451                    vec![
452                        "vault-a".to_owned(),
453                        "vault-b".to_owned(),
454                        "keychain".to_owned()
455                    ]
456                );
457            }
458            other => panic!("expected Deep, got {other:?}"),
459        }
460    }
461
462    // -- 5) Nothing to check --------------------------------------
463
464    #[test]
465    fn no_source_with_credential_is_ok() {
466        let c = cfg(r#"
467            [[source]]
468            name = "keychain"
469            type = "keychain"
470
471            [[source]]
472            name = "env-store"
473            type = "env-store"
474            "#);
475        let req = req_map(HashMap::new());
476        validate_source_credentials(&c, req).unwrap();
477    }
478
479    // -- 6) Sentinel terminates without traversal -----------------
480
481    #[test]
482    fn sentinel_credential_is_terminal() {
483        // 1Password's biometric session is a Sentinel — the source
484        // handles its own auth, so the validator should not try to
485        // route the credential.
486        let c = cfg(r#"
487            [[source]]
488            name = "1p-personal"
489            type = "1password"
490            "#);
491        let req = req_map(
492            [(
493                "1p-personal".to_owned(),
494                Some(CredentialRef::Sentinel("biometric".to_owned())),
495            )]
496            .into_iter()
497            .collect(),
498        );
499        validate_source_credentials(&c, req).unwrap();
500    }
501
502    // -- 7) Path outside __sources/ -------------------------------
503
504    #[test]
505    fn credential_path_outside_internal_namespace_rejected() {
506        // ADR-020 path validation forbids `__*` outside
507        // `parse_internal`, so we have to use a regular path here
508        // (which is exactly the case we want to reject).
509        let c = cfg(r#"
510            [[source]]
511            name = "vault-team"
512            type = "vault"
513            [[source]]
514            name = "keychain"
515            type = "keychain"
516
517            [default]
518            source = "keychain"
519            "#);
520        let req = req_map(
521            [(
522                "vault-team".to_owned(),
523                Some(CredentialRef::Path(
524                    SecretPath::parse("team/secret/token").unwrap(),
525                )),
526            )]
527            .into_iter()
528            .collect(),
529        );
530        let err = validate_source_credentials(&c, req).unwrap_err();
531        match err {
532            CredentialGraphError::BadCredentialPath { source_name, path } => {
533                assert_eq!(source_name, "vault-team");
534                assert_eq!(path, "team/secret/token");
535            }
536            other => panic!("expected BadCredentialPath, got {other:?}"),
537        }
538    }
539
540    // -- 8) Unroutable credential ---------------------------------
541
542    #[test]
543    fn unroutable_credential_path_surfaces_resolve_error() {
544        // No [default], no [[route]] for `__sources/`, no
545        // [secret]. The path is unroutable.
546        let c = cfg(r#"
547            [[source]]
548            name = "vault-team"
549            type = "vault"
550            [[source]]
551            name = "keychain"
552            type = "keychain"
553            "#);
554        let req = req_map(
555            [(
556                "vault-team".to_owned(),
557                Some(CredentialRef::Path(p_internal(
558                    "__sources/vault-team/deploy",
559                ))),
560            )]
561            .into_iter()
562            .collect(),
563        );
564        let err = validate_source_credentials(&c, req).unwrap_err();
565        match err {
566            CredentialGraphError::UnroutableCredential {
567                source_name,
568                path,
569                source_error,
570            } => {
571                assert_eq!(source_name, "vault-team");
572                assert_eq!(path, "__sources/vault-team/deploy");
573                assert!(matches!(source_error, ResolveError::NoRoute { .. }));
574            }
575            other => panic!("expected UnroutableCredential, got {other:?}"),
576        }
577    }
578
579    // -- 9) Default-routed __sources/ ------------------------------
580
581    #[test]
582    fn one_hop_chain_via_default_route_is_valid() {
583        // No explicit __sources/ route; the default catches it.
584        let c = cfg(r#"
585            [[source]]
586            name = "vault-team"
587            type = "vault"
588
589            [[source]]
590            name = "keychain"
591            type = "keychain"
592
593            [default]
594            source = "keychain"
595            "#);
596        let req = req_map(
597            [
598                (
599                    "vault-team".to_owned(),
600                    Some(CredentialRef::Path(p_internal(
601                        "__sources/vault-team/deploy",
602                    ))),
603                ),
604                ("keychain".to_owned(), None),
605            ]
606            .into_iter()
607            .collect(),
608        );
609        validate_source_credentials(&c, req).unwrap();
610    }
611}