use thiserror::Error;
use crate::router_config::RouterConfig;
use crate::router_resolve::{PathResolver, ResolveError};
use crate::secret_path::SecretPath;
use crate::source::CredentialRef;
pub const SOURCE_CREDENTIALS_PREFIX: &str = "__sources/";
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CredentialGraphError {
#[error(
"source '{source_name}' declares its credential at '{path}', but credential paths must live under `{SOURCE_CREDENTIALS_PREFIX}`"
)]
BadCredentialPath {
source_name: String,
path: String,
},
#[error("source '{source_name}' credential at '{path}' is unroutable: {source_error}")]
UnroutableCredential {
source_name: String,
path: String,
#[source]
source_error: ResolveError,
},
#[error(
"source credential cycle detected: {}",
chain.join(" -> ")
)]
Cycle {
chain: Vec<String>,
},
#[error(
"source credential chain too deep (>1 hop): {}",
chain.join(" -> ")
)]
Deep {
chain: Vec<String>,
},
}
pub fn validate_source_credentials<F>(
config: &RouterConfig,
mut requires_credential: F,
) -> Result<(), CredentialGraphError>
where
F: FnMut(&str) -> Option<CredentialRef>,
{
let resolver = PathResolver::new(config);
for src in &config.sources {
let name = &src.name;
let cred = requires_credential(name);
match cred {
None => continue,
Some(CredentialRef::Sentinel(_)) => continue,
Some(CredentialRef::Path(p)) => {
ensure_credential_path_namespace(name, &p)?;
walk_chain(name, &p, &resolver, &mut requires_credential)?;
}
}
}
Ok(())
}
fn ensure_credential_path_namespace(
source_name: &str,
path: &SecretPath,
) -> Result<(), CredentialGraphError> {
if !path.as_str().starts_with(SOURCE_CREDENTIALS_PREFIX) {
return Err(CredentialGraphError::BadCredentialPath {
source_name: source_name.to_owned(),
path: path.to_string(),
});
}
Ok(())
}
fn walk_chain<F>(
start: &str,
cred_path: &SecretPath,
resolver: &PathResolver<'_>,
requires_credential: &mut F,
) -> Result<(), CredentialGraphError>
where
F: FnMut(&str) -> Option<CredentialRef>,
{
let mut chain: Vec<String> = vec![start.to_owned()];
let mut current_path = cred_path.clone();
let mut hop = 0usize;
loop {
let decision = resolver.resolve(¤t_path).map_err(|e| {
CredentialGraphError::UnroutableCredential {
source_name: start.to_owned(),
path: current_path.to_string(),
source_error: e,
}
})?;
let next = decision.source().to_owned();
hop += 1;
if chain.iter().any(|s| s == &next) {
chain.push(next);
return Err(CredentialGraphError::Cycle { chain });
}
chain.push(next.clone());
let next_cred = requires_credential(&next);
match next_cred {
None | Some(CredentialRef::Sentinel(_)) => {
if hop > 1 {
return Err(CredentialGraphError::Deep { chain });
}
return Ok(());
}
Some(CredentialRef::Path(p)) => {
if hop >= 1 {
ensure_credential_path_namespace(&next, &p)?;
current_path = p;
continue;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router_config::RouterConfig;
use std::collections::HashMap;
fn cfg(toml: &str) -> RouterConfig {
RouterConfig::parse(toml).expect("fixture config must parse")
}
fn p_internal(s: &str) -> SecretPath {
SecretPath::parse_internal(s).expect("internal path must parse")
}
fn req_map(
m: HashMap<String, Option<CredentialRef>>,
) -> impl FnMut(&str) -> Option<CredentialRef> {
move |name| m.get(name).cloned().unwrap_or(None)
}
#[test]
fn one_hop_chain_through_keychain_is_valid() {
let c = cfg(r#"
[[source]]
name = "vault-team"
type = "vault"
[[source]]
name = "keychain"
type = "keychain"
[[route]]
prefix = "__sources/"
source = "keychain"
"#);
let req = req_map(
[
(
"vault-team".to_owned(),
Some(CredentialRef::Path(p_internal(
"__sources/vault-team/deploy",
))),
),
("keychain".to_owned(), None),
]
.into_iter()
.collect(),
);
validate_source_credentials(&c, req).expect("one-hop chain should be valid");
}
#[test]
fn self_loop_is_a_cycle() {
let c = cfg(r#"
[[source]]
name = "vault-team"
type = "vault"
[secret."__sources/vault-team/deploy"]
source = "vault-team"
reference = "secret/data/__sources/vault-team/deploy"
"#);
let req = req_map(
[(
"vault-team".to_owned(),
Some(CredentialRef::Path(p_internal(
"__sources/vault-team/deploy",
))),
)]
.into_iter()
.collect(),
);
let err = validate_source_credentials(&c, req).unwrap_err();
match err {
CredentialGraphError::Cycle { chain } => {
assert_eq!(
chain,
vec!["vault-team".to_owned(), "vault-team".to_owned()]
);
}
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn two_node_cycle_detected() {
let c = cfg(r#"
[[source]]
name = "vault-a"
type = "vault"
[[source]]
name = "vault-b"
type = "vault"
[secret."__sources/vault-a/x"]
source = "vault-b"
reference = "secret/a/x"
[secret."__sources/vault-b/x"]
source = "vault-a"
reference = "secret/b/x"
"#);
let req = req_map(
[
(
"vault-a".to_owned(),
Some(CredentialRef::Path(p_internal("__sources/vault-a/x"))),
),
(
"vault-b".to_owned(),
Some(CredentialRef::Path(p_internal("__sources/vault-b/x"))),
),
]
.into_iter()
.collect(),
);
let err = validate_source_credentials(&c, req).unwrap_err();
match err {
CredentialGraphError::Cycle { chain } => {
assert_eq!(chain.first().unwrap(), "vault-a");
assert_eq!(chain.last().unwrap(), "vault-a");
assert!(chain.iter().any(|n| n == "vault-b"));
}
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn three_hop_chain_without_cycle_is_deep() {
let c = cfg(r#"
[[source]]
name = "vault-a"
type = "vault"
[[source]]
name = "vault-b"
type = "vault"
[[source]]
name = "keychain"
type = "keychain"
[secret."__sources/vault-a/x"]
source = "vault-b"
reference = "x"
[secret."__sources/vault-b/y"]
source = "keychain"
reference = "y"
"#);
let req = req_map(
[
(
"vault-a".to_owned(),
Some(CredentialRef::Path(p_internal("__sources/vault-a/x"))),
),
(
"vault-b".to_owned(),
Some(CredentialRef::Path(p_internal("__sources/vault-b/y"))),
),
("keychain".to_owned(), None),
]
.into_iter()
.collect(),
);
let err = validate_source_credentials(&c, req).unwrap_err();
match err {
CredentialGraphError::Deep { chain } => {
assert_eq!(
chain,
vec![
"vault-a".to_owned(),
"vault-b".to_owned(),
"keychain".to_owned()
]
);
}
other => panic!("expected Deep, got {other:?}"),
}
}
#[test]
fn no_source_with_credential_is_ok() {
let c = cfg(r#"
[[source]]
name = "keychain"
type = "keychain"
[[source]]
name = "env-store"
type = "env-store"
"#);
let req = req_map(HashMap::new());
validate_source_credentials(&c, req).unwrap();
}
#[test]
fn sentinel_credential_is_terminal() {
let c = cfg(r#"
[[source]]
name = "1p-personal"
type = "1password"
"#);
let req = req_map(
[(
"1p-personal".to_owned(),
Some(CredentialRef::Sentinel("biometric".to_owned())),
)]
.into_iter()
.collect(),
);
validate_source_credentials(&c, req).unwrap();
}
#[test]
fn credential_path_outside_internal_namespace_rejected() {
let c = cfg(r#"
[[source]]
name = "vault-team"
type = "vault"
[[source]]
name = "keychain"
type = "keychain"
[default]
source = "keychain"
"#);
let req = req_map(
[(
"vault-team".to_owned(),
Some(CredentialRef::Path(
SecretPath::parse("team/secret/token").unwrap(),
)),
)]
.into_iter()
.collect(),
);
let err = validate_source_credentials(&c, req).unwrap_err();
match err {
CredentialGraphError::BadCredentialPath { source_name, path } => {
assert_eq!(source_name, "vault-team");
assert_eq!(path, "team/secret/token");
}
other => panic!("expected BadCredentialPath, got {other:?}"),
}
}
#[test]
fn unroutable_credential_path_surfaces_resolve_error() {
let c = cfg(r#"
[[source]]
name = "vault-team"
type = "vault"
[[source]]
name = "keychain"
type = "keychain"
"#);
let req = req_map(
[(
"vault-team".to_owned(),
Some(CredentialRef::Path(p_internal(
"__sources/vault-team/deploy",
))),
)]
.into_iter()
.collect(),
);
let err = validate_source_credentials(&c, req).unwrap_err();
match err {
CredentialGraphError::UnroutableCredential {
source_name,
path,
source_error,
} => {
assert_eq!(source_name, "vault-team");
assert_eq!(path, "__sources/vault-team/deploy");
assert!(matches!(source_error, ResolveError::NoRoute { .. }));
}
other => panic!("expected UnroutableCredential, got {other:?}"),
}
}
#[test]
fn one_hop_chain_via_default_route_is_valid() {
let c = cfg(r#"
[[source]]
name = "vault-team"
type = "vault"
[[source]]
name = "keychain"
type = "keychain"
[default]
source = "keychain"
"#);
let req = req_map(
[
(
"vault-team".to_owned(),
Some(CredentialRef::Path(p_internal(
"__sources/vault-team/deploy",
))),
),
("keychain".to_owned(), None),
]
.into_iter()
.collect(),
);
validate_source_credentials(&c, req).unwrap();
}
}