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(¤t_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}