use crate::client::{VaultClient, VaultResponse};
use crate::lease::Lease;
use rustlavel_core::{Config, Error, Json, Result};
use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
pub const PREFIX: &str = "vault:";
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretRef {
pub path: String,
pub field: String,
}
impl SecretRef {
pub fn is_reference(value: &str) -> bool {
value.starts_with(PREFIX)
}
pub fn parse(value: &str) -> Result<SecretRef> {
let rest = value.strip_prefix(PREFIX).ok_or_else(|| {
Error::msg(format!("`{value}` is not a secret reference; one starts with `{PREFIX}`"))
})?;
if rest.contains('|') {
return Err(Error::msg(format!(
"`{value}` looks like it carries a fallback, and there is deliberately no \
fallback syntax. A default that stands in for a secret the store did not give \
up means booting on the wrong credential and failing later somewhere else; if \
the value is harmless enough to have a default, it is not a secret and belongs \
in configuration"
)));
}
let (path, field) = rest.split_once('#').ok_or_else(|| {
Error::msg(format!(
"`{value}` names a path but no field. A reference reads \
`vault:secret/data/myapp#database_url` — the part after `#` is the field \
inside the secret"
))
})?;
let (path, field) = (path.trim(), field.trim());
if path.is_empty() || field.is_empty() {
return Err(Error::msg(format!(
"`{value}` is not a complete secret reference: it needs a path and a field, as \
in `vault:secret/data/myapp#database_url`"
)));
}
Ok(SecretRef { path: path.to_string(), field: field.to_string() })
}
}
impl std::fmt::Display for SecretRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{PREFIX}{}#{}", self.path, self.field)
}
}
#[derive(Clone)]
pub struct Secret {
path: String,
fields: BTreeMap<String, String>,
lease: Lease,
}
impl Secret {
pub fn new(
path: impl Into<String>,
fields: BTreeMap<String, String>,
lease: Lease,
) -> Secret {
Secret { path: path.into(), fields, lease }
}
pub fn from_response(path: &str, response: &VaultResponse) -> Secret {
Secret {
path: path.to_string(),
fields: fields_of(&response.data),
lease: response.lease.clone(),
}
}
pub fn path(&self) -> &str {
&self.path
}
pub fn field(&self, name: &str) -> Option<&str> {
self.fields.get(name).map(String::as_str)
}
pub fn names(&self) -> Vec<&str> {
self.fields.keys().map(String::as_str).collect()
}
pub fn lease(&self) -> &Lease {
&self.lease
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secret")
.field("path", &self.path)
.field("fields", &self.names())
.field("values", &"<redacted>")
.finish()
}
}
pub trait SecretSource: Send + Sync {
fn read<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Secret>>;
}
impl SecretSource for VaultClient {
fn read<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Secret>> {
Box::pin(async move { Ok(Secret::from_response(path, &self.get(path).await?)) })
}
}
pub struct Resolver<'a> {
source: &'a dyn SecretSource,
fetched: BTreeMap<String, Secret>,
}
impl<'a> Resolver<'a> {
pub fn new(source: &'a dyn SecretSource) -> Resolver<'a> {
Resolver { source, fetched: BTreeMap::new() }
}
pub fn fetches(&self) -> usize {
self.fetched.len()
}
pub async fn value(&mut self, key: &str, value: &str) -> Result<Option<String>> {
if !SecretRef::is_reference(value) {
return Ok(None);
}
let reference = parse_for(key, value)?;
Ok(Some(self.field(key, &reference).await?))
}
pub async fn pairs(&mut self, pairs: &mut BTreeMap<String, String>) -> Result<usize> {
let found: Vec<(String, String)> = pairs
.iter()
.filter(|(_, value)| SecretRef::is_reference(value))
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
for (key, raw) in &found {
let reference = parse_for(key, raw)?;
let value = self.field(key, &reference).await?;
pairs.insert(key.clone(), value);
}
Ok(found.len())
}
pub async fn config(&mut self, config: &Config, roots: &[&str]) -> Result<usize> {
let mut resolved = 0;
for root in roots {
let Some(tree) = config.get(root) else { continue };
let mut found = Vec::new();
collect(root, &tree, &mut found);
if found.is_empty() {
continue;
}
let mut values = BTreeMap::new();
for (key, raw) in &found {
let reference = parse_for(key, raw)?;
values.insert(key.clone(), self.field(key, &reference).await?);
}
resolved += values.len();
config.set(root, substitute(root, tree, &values));
}
Ok(resolved)
}
async fn field(&mut self, key: &str, reference: &SecretRef) -> Result<String> {
if !self.fetched.contains_key(&reference.path) {
let secret = self.source.read(&reference.path).await.map_err(|error| {
Error::msg(format!("could not resolve `{key}` from `{reference}`: {error}"))
})?;
self.fetched.insert(reference.path.clone(), secret);
}
let secret = &self.fetched[&reference.path];
secret.field(&reference.field).map(str::to_string).ok_or_else(|| {
let names = secret.names();
let holds = if names.is_empty() {
"nothing at all".to_string()
} else {
names.join(", ")
};
Error::msg(format!(
"`{key}` refers to `{reference}`, but the secret at `{}` has no field \
`{}` — it holds: {holds}",
reference.path, reference.field
))
})
}
}
pub async fn resolve_env(source: &dyn SecretSource) -> Result<usize> {
let mut pairs: BTreeMap<String, String> = std::env::vars()
.filter(|(_, value)| SecretRef::is_reference(value))
.collect();
if pairs.is_empty() {
return Ok(0);
}
let resolved = Resolver::new(source).pairs(&mut pairs).await?;
for (key, value) in &pairs {
unsafe { std::env::set_var(key, value) };
}
Ok(resolved)
}
fn parse_for(key: &str, value: &str) -> Result<SecretRef> {
SecretRef::parse(value).map_err(|error| Error::msg(format!("`{key}`: {error}")))
}
fn fields_of(data: &Json) -> BTreeMap<String, String> {
let inner = match (data.get("data"), data.get("metadata")) {
(Some(nested @ Json::Object(_)), Some(_)) => nested,
_ => data,
};
inner
.as_object()
.map(|map| {
map.iter()
.filter_map(|(name, value)| text(value).map(|value| (name.clone(), value)))
.collect()
})
.unwrap_or_default()
}
fn text(value: &Json) -> Option<String> {
match value {
Json::String(value) => Some(value.clone()),
Json::Number(value) => Some(value.to_string()),
Json::Bool(value) => Some(value.to_string()),
_ => None,
}
}
fn collect(prefix: &str, value: &Json, out: &mut Vec<(String, String)>) {
match value {
Json::String(value) if SecretRef::is_reference(value) => {
out.push((prefix.to_string(), value.clone()));
}
Json::Object(map) => {
for (name, value) in map {
collect(&format!("{prefix}.{name}"), value, out);
}
}
Json::Array(items) => {
for (index, value) in items.iter().enumerate() {
collect(&format!("{prefix}.{index}"), value, out);
}
}
_ => {}
}
}
fn substitute(prefix: &str, value: Json, values: &BTreeMap<String, String>) -> Json {
match value {
Json::String(ref text) if SecretRef::is_reference(text) => match values.get(prefix) {
Some(resolved) => Json::String(resolved.clone()),
None => value,
},
Json::Object(map) => Json::Object(
map.into_iter()
.map(|(name, value)| {
let child = substitute(&format!("{prefix}.{name}"), value, values);
(name, child)
})
.collect(),
),
Json::Array(items) => Json::Array(
items
.into_iter()
.enumerate()
.map(|(index, value)| substitute(&format!("{prefix}.{index}"), value, values))
.collect(),
),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fake::Fake;
#[test]
fn a_value_that_merely_contains_a_colon_is_not_a_reference() {
assert!(!SecretRef::is_reference("postgres://user:pass@db:5432/app"));
assert!(!SecretRef::is_reference("redis://cache:6379"));
assert!(!SecretRef::is_reference("https://vault.internal:8200"));
assert!(!SecretRef::is_reference("not-vault:secret/data/x#y"));
assert!(SecretRef::is_reference("vault:secret/data/x#y"));
}
#[test]
fn parses_a_reference_into_a_path_and_a_field() {
let parsed = SecretRef::parse("vault:secret/data/myapp#database_url").unwrap();
assert_eq!(parsed.path, "secret/data/myapp");
assert_eq!(parsed.field, "database_url");
assert_eq!(parsed.to_string(), "vault:secret/data/myapp#database_url");
}
#[test]
fn a_reference_with_no_field_says_what_one_looks_like() {
let error = SecretRef::parse("vault:secret/data/myapp").unwrap_err().to_string();
assert!(error.contains("no field"), "got {error}");
assert!(error.contains("vault:secret/data/myapp#database_url"), "got {error}");
}
#[test]
fn an_empty_path_or_field_is_malformed() {
assert!(SecretRef::parse("vault:#field").is_err());
assert!(SecretRef::parse("vault:secret/data/x#").is_err());
assert!(SecretRef::parse("vault:").is_err());
}
#[test]
fn a_fallback_is_refused_and_the_error_argues_the_case() {
let error =
SecretRef::parse("vault:secret/data/myapp#password|hunter2").unwrap_err().to_string();
assert!(error.contains("fallback"), "got {error}");
assert!(error.contains("wrong credential"), "the error has to make the case: {error}");
assert!(SecretRef::parse("vault:secret/data/myapp|hunter2").unwrap_err().to_string().contains("fallback"));
}
fn store() -> crate::fake::FakeVault {
Fake::new()
.secret(
"secret/data/myapp",
[
("database_url", "postgres://app:s3cr3t@db:5432/app"),
("username", "app"),
("password", "s3cr3t"),
],
)
.secret("secret/data/mail", [("password", "mailpass")])
.store()
}
#[tokio::test]
async fn a_reference_resolves_into_configuration() {
let store = store();
let config = Config::new();
config.set("database.url", "vault:secret/data/myapp#database_url");
config.set("database.pool", 10);
let resolved = Resolver::new(&store).config(&config, &["database"]).await.unwrap();
assert_eq!(resolved, 1);
assert_eq!(config.string("database.url", ""), "postgres://app:s3cr3t@db:5432/app");
assert_eq!(config.int("database.pool", 0), 10, "everything else is left alone");
}
#[tokio::test]
async fn several_keys_from_one_path_are_fetched_once() {
let store = store();
let config = Config::new();
config.set("database.username", "vault:secret/data/myapp#username");
config.set("database.password", "vault:secret/data/myapp#password");
config.set("database.url", "vault:secret/data/myapp#database_url");
config.set("mail.password", "vault:secret/data/mail#password");
let mut resolver = Resolver::new(&store);
let resolved = resolver.config(&config, &["database", "mail"]).await.unwrap();
assert_eq!(resolved, 4);
assert_eq!(resolver.fetches(), 2, "two paths, four keys");
store.assert_read_once("secret/data/myapp");
store.assert_read_once("secret/data/mail");
assert_eq!(config.string("database.password", ""), "s3cr3t");
}
#[tokio::test]
async fn an_unresolvable_reference_stops_the_boot_and_names_both() {
let store = Fake::new().missing("secret/data/nope").store();
let config = Config::new();
config.set("database.url", "vault:secret/data/nope#database_url");
let error = Resolver::new(&store)
.config(&config, &["database"])
.await
.expect_err("an unresolved secret must not boot")
.to_string();
assert!(error.contains("database.url"), "the key has to be named: {error}");
assert!(error.contains("secret/data/nope"), "the path has to be named: {error}");
assert_eq!(config.string("database.url", ""), "vault:secret/data/nope#database_url");
}
#[tokio::test]
async fn a_missing_field_says_which_fields_the_secret_does_have() {
let store = store();
let config = Config::new();
config.set("database.url", "vault:secret/data/myapp#dsn");
let error =
Resolver::new(&store).config(&config, &["database"]).await.unwrap_err().to_string();
assert!(error.contains("no field `dsn`"), "got {error}");
assert!(error.contains("database_url"), "the names it does hold are the useful part: {error}");
assert!(!error.contains("s3cr3t"), "an error must never carry a value: {error}");
}
#[tokio::test]
async fn a_malformed_reference_is_rejected_before_anything_is_fetched() {
let store = store();
let config = Config::new();
config.set("database.url", "vault:secret/data/myapp");
let error =
Resolver::new(&store).config(&config, &["database"]).await.unwrap_err().to_string();
assert!(error.contains("database.url"), "got {error}");
assert!(error.contains("no field"), "got {error}");
store.assert_reads(0);
}
#[tokio::test]
async fn resolves_a_flat_map_the_way_a_dotenv_file_comes_in() {
let store = store();
let mut pairs = BTreeMap::from([
("DATABASE_URL".to_string(), "vault:secret/data/myapp#database_url".to_string()),
("MAIL_PASSWORD".to_string(), "vault:secret/data/mail#password".to_string()),
("APP_URL".to_string(), "https://example.com:443".to_string()),
]);
let resolved = Resolver::new(&store).pairs(&mut pairs).await.unwrap();
assert_eq!(resolved, 2);
assert_eq!(pairs["DATABASE_URL"], "postgres://app:s3cr3t@db:5432/app");
assert_eq!(pairs["MAIL_PASSWORD"], "mailpass");
assert_eq!(pairs["APP_URL"], "https://example.com:443");
}
#[tokio::test]
async fn a_reference_nested_in_an_object_or_an_array_is_found_and_kept_in_shape() {
let store = store();
let config = Config::new();
config.set(
"database.connections.pgsql",
Json::object([("password", Json::from("vault:secret/data/myapp#password"))]),
);
config.set(
"database.hosts",
Json::Array(vec![
Json::from("db-1"),
Json::from("vault:secret/data/myapp#username"),
]),
);
Resolver::new(&store).config(&config, &["database"]).await.unwrap();
assert_eq!(config.string("database.connections.pgsql.password", ""), "s3cr3t");
let hosts = config.get("database.hosts").unwrap();
assert!(matches!(hosts, Json::Array(_)), "an array must stay an array: {hosts:?}");
assert_eq!(hosts.as_array().unwrap()[1].as_str(), Some("app"));
}
#[tokio::test]
async fn reads_a_version_1_secret_as_readily_as_a_version_2_one() {
let store = Fake::new()
.answers(
"kv/legacy",
Json::object([("data", Json::object([("password", Json::from("v1pass"))]))]),
)
.store();
let mut pairs =
BTreeMap::from([("PASSWORD".to_string(), "vault:kv/legacy#password".to_string())]);
Resolver::new(&store).pairs(&mut pairs).await.unwrap();
assert_eq!(pairs["PASSWORD"], "v1pass");
}
#[tokio::test]
async fn a_number_or_a_flag_in_a_secret_becomes_its_text() {
let store = Fake::new()
.answers(
"secret/data/db",
Json::object([(
"data",
Json::object([
("data", Json::object([("port", Json::from(5432)), ("tls", Json::from(true))])),
("metadata", Json::object([("version", Json::from(1))])),
]),
)]),
)
.store();
let mut pairs = BTreeMap::from([
("PORT".to_string(), "vault:secret/data/db#port".to_string()),
("TLS".to_string(), "vault:secret/data/db#tls".to_string()),
]);
Resolver::new(&store).pairs(&mut pairs).await.unwrap();
assert_eq!(pairs["PORT"], "5432");
assert_eq!(pairs["TLS"], "true");
}
#[tokio::test]
async fn a_secret_never_prints_its_values() {
let store = store();
let secret = store.read("secret/data/myapp").await.unwrap();
let printed = format!("{secret:?}");
assert!(!printed.contains("s3cr3t"), "a value reached a log: {printed}");
assert!(printed.contains("redacted"));
assert!(printed.contains("password"), "the names are what makes it useful: {printed}");
}
#[test]
fn resolves_references_in_the_process_environment() {
static ENVIRONMENT: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = ENVIRONMENT.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("RUSTLAVEL_VAULT_TEST_URL", "vault:secret/data/myapp#database_url");
std::env::set_var("RUSTLAVEL_VAULT_TEST_PLAIN", "redis://cache:6379");
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime for one test");
let resolved = runtime.block_on(async { resolve_env(&store()).await.unwrap() });
assert!(resolved >= 1);
assert_eq!(
std::env::var("RUSTLAVEL_VAULT_TEST_URL").unwrap(),
"postgres://app:s3cr3t@db:5432/app"
);
assert_eq!(std::env::var("RUSTLAVEL_VAULT_TEST_PLAIN").unwrap(), "redis://cache:6379");
unsafe {
std::env::remove_var("RUSTLAVEL_VAULT_TEST_URL");
std::env::remove_var("RUSTLAVEL_VAULT_TEST_PLAIN");
}
}
#[tokio::test]
async fn a_configuration_root_that_holds_nothing_is_not_an_error() {
let store = store();
let config = Config::new();
assert_eq!(Resolver::new(&store).config(&config, &["database"]).await.unwrap(), 0);
store.assert_reads(0);
}
}