1use crate::client::{VaultClient, VaultResponse};
29use crate::lease::Lease;
30use rustlavel_core::{Config, Error, Json, Result};
31use std::collections::BTreeMap;
32use std::future::Future;
33use std::pin::Pin;
34
35pub const PREFIX: &str = "vault:";
37
38pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SecretRef {
44 pub path: String,
45 pub field: String,
46}
47
48impl SecretRef {
49 pub fn is_reference(value: &str) -> bool {
55 value.starts_with(PREFIX)
56 }
57
58 pub fn parse(value: &str) -> Result<SecretRef> {
60 let rest = value.strip_prefix(PREFIX).ok_or_else(|| {
61 Error::msg(format!("`{value}` is not a secret reference; one starts with `{PREFIX}`"))
62 })?;
63
64 if rest.contains('|') {
67 return Err(Error::msg(format!(
68 "`{value}` looks like it carries a fallback, and there is deliberately no \
69 fallback syntax. A default that stands in for a secret the store did not give \
70 up means booting on the wrong credential and failing later somewhere else; if \
71 the value is harmless enough to have a default, it is not a secret and belongs \
72 in configuration"
73 )));
74 }
75
76 let (path, field) = rest.split_once('#').ok_or_else(|| {
77 Error::msg(format!(
78 "`{value}` names a path but no field. A reference reads \
79 `vault:secret/data/myapp#database_url` — the part after `#` is the field \
80 inside the secret"
81 ))
82 })?;
83
84 let (path, field) = (path.trim(), field.trim());
85 if path.is_empty() || field.is_empty() {
86 return Err(Error::msg(format!(
87 "`{value}` is not a complete secret reference: it needs a path and a field, as \
88 in `vault:secret/data/myapp#database_url`"
89 )));
90 }
91
92 Ok(SecretRef { path: path.to_string(), field: field.to_string() })
93 }
94}
95
96impl std::fmt::Display for SecretRef {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 write!(f, "{PREFIX}{}#{}", self.path, self.field)
99 }
100}
101
102#[derive(Clone)]
109pub struct Secret {
110 path: String,
111 fields: BTreeMap<String, String>,
112 lease: Lease,
113}
114
115impl Secret {
116 pub fn new(
117 path: impl Into<String>,
118 fields: BTreeMap<String, String>,
119 lease: Lease,
120 ) -> Secret {
121 Secret { path: path.into(), fields, lease }
122 }
123
124 pub fn from_response(path: &str, response: &VaultResponse) -> Secret {
126 Secret {
127 path: path.to_string(),
128 fields: fields_of(&response.data),
129 lease: response.lease.clone(),
130 }
131 }
132
133 pub fn path(&self) -> &str {
134 &self.path
135 }
136
137 pub fn field(&self, name: &str) -> Option<&str> {
138 self.fields.get(name).map(String::as_str)
139 }
140
141 pub fn names(&self) -> Vec<&str> {
144 self.fields.keys().map(String::as_str).collect()
145 }
146
147 pub fn lease(&self) -> &Lease {
148 &self.lease
149 }
150
151 pub fn is_empty(&self) -> bool {
152 self.fields.is_empty()
153 }
154}
155
156impl std::fmt::Debug for Secret {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 f.debug_struct("Secret")
160 .field("path", &self.path)
161 .field("fields", &self.names())
162 .field("values", &"<redacted>")
163 .finish()
164 }
165}
166
167pub trait SecretSource: Send + Sync {
174 fn read<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Secret>>;
175}
176
177impl SecretSource for VaultClient {
178 fn read<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Secret>> {
179 Box::pin(async move { Ok(Secret::from_response(path, &self.get(path).await?)) })
180 }
181}
182
183pub struct Resolver<'a> {
185 source: &'a dyn SecretSource,
186 fetched: BTreeMap<String, Secret>,
187}
188
189impl<'a> Resolver<'a> {
190 pub fn new(source: &'a dyn SecretSource) -> Resolver<'a> {
191 Resolver { source, fetched: BTreeMap::new() }
192 }
193
194 pub fn fetches(&self) -> usize {
196 self.fetched.len()
197 }
198
199 pub async fn value(&mut self, key: &str, value: &str) -> Result<Option<String>> {
201 if !SecretRef::is_reference(value) {
202 return Ok(None);
203 }
204 let reference = parse_for(key, value)?;
205 Ok(Some(self.field(key, &reference).await?))
206 }
207
208 pub async fn pairs(&mut self, pairs: &mut BTreeMap<String, String>) -> Result<usize> {
213 let found: Vec<(String, String)> = pairs
214 .iter()
215 .filter(|(_, value)| SecretRef::is_reference(value))
216 .map(|(key, value)| (key.clone(), value.clone()))
217 .collect();
218
219 for (key, raw) in &found {
220 let reference = parse_for(key, raw)?;
221 let value = self.field(key, &reference).await?;
222 pairs.insert(key.clone(), value);
223 }
224
225 Ok(found.len())
226 }
227
228 pub async fn config(&mut self, config: &Config, roots: &[&str]) -> Result<usize> {
236 let mut resolved = 0;
237
238 for root in roots {
239 let Some(tree) = config.get(root) else { continue };
240
241 let mut found = Vec::new();
242 collect(root, &tree, &mut found);
243 if found.is_empty() {
244 continue;
245 }
246
247 let mut values = BTreeMap::new();
248 for (key, raw) in &found {
249 let reference = parse_for(key, raw)?;
250 values.insert(key.clone(), self.field(key, &reference).await?);
251 }
252
253 resolved += values.len();
254 config.set(root, substitute(root, tree, &values));
258 }
259
260 Ok(resolved)
261 }
262
263 async fn field(&mut self, key: &str, reference: &SecretRef) -> Result<String> {
266 if !self.fetched.contains_key(&reference.path) {
267 let secret = self.source.read(&reference.path).await.map_err(|error| {
268 Error::msg(format!("could not resolve `{key}` from `{reference}`: {error}"))
269 })?;
270 self.fetched.insert(reference.path.clone(), secret);
271 }
272
273 let secret = &self.fetched[&reference.path];
274 secret.field(&reference.field).map(str::to_string).ok_or_else(|| {
275 let names = secret.names();
276 let holds = if names.is_empty() {
277 "nothing at all".to_string()
278 } else {
279 names.join(", ")
280 };
281 Error::msg(format!(
282 "`{key}` refers to `{reference}`, but the secret at `{}` has no field \
283 `{}` — it holds: {holds}",
284 reference.path, reference.field
285 ))
286 })
287 }
288}
289
290pub async fn resolve_env(source: &dyn SecretSource) -> Result<usize> {
307 let mut pairs: BTreeMap<String, String> = std::env::vars()
308 .filter(|(_, value)| SecretRef::is_reference(value))
309 .collect();
310 if pairs.is_empty() {
311 return Ok(0);
312 }
313
314 let resolved = Resolver::new(source).pairs(&mut pairs).await?;
315 for (key, value) in &pairs {
316 unsafe { std::env::set_var(key, value) };
326 }
327
328 Ok(resolved)
329}
330
331fn parse_for(key: &str, value: &str) -> Result<SecretRef> {
336 SecretRef::parse(value).map_err(|error| Error::msg(format!("`{key}`: {error}")))
337}
338
339fn fields_of(data: &Json) -> BTreeMap<String, String> {
346 let inner = match (data.get("data"), data.get("metadata")) {
347 (Some(nested @ Json::Object(_)), Some(_)) => nested,
348 _ => data,
349 };
350
351 inner
352 .as_object()
353 .map(|map| {
354 map.iter()
355 .filter_map(|(name, value)| text(value).map(|value| (name.clone(), value)))
356 .collect()
357 })
358 .unwrap_or_default()
359}
360
361fn text(value: &Json) -> Option<String> {
365 match value {
366 Json::String(value) => Some(value.clone()),
367 Json::Number(value) => Some(value.to_string()),
368 Json::Bool(value) => Some(value.to_string()),
369 _ => None,
370 }
371}
372
373fn collect(prefix: &str, value: &Json, out: &mut Vec<(String, String)>) {
375 match value {
376 Json::String(value) if SecretRef::is_reference(value) => {
377 out.push((prefix.to_string(), value.clone()));
378 }
379 Json::Object(map) => {
380 for (name, value) in map {
381 collect(&format!("{prefix}.{name}"), value, out);
382 }
383 }
384 Json::Array(items) => {
385 for (index, value) in items.iter().enumerate() {
386 collect(&format!("{prefix}.{index}"), value, out);
387 }
388 }
389 _ => {}
390 }
391}
392
393fn substitute(prefix: &str, value: Json, values: &BTreeMap<String, String>) -> Json {
395 match value {
396 Json::String(ref text) if SecretRef::is_reference(text) => match values.get(prefix) {
397 Some(resolved) => Json::String(resolved.clone()),
398 None => value,
399 },
400 Json::Object(map) => Json::Object(
401 map.into_iter()
402 .map(|(name, value)| {
403 let child = substitute(&format!("{prefix}.{name}"), value, values);
404 (name, child)
405 })
406 .collect(),
407 ),
408 Json::Array(items) => Json::Array(
409 items
410 .into_iter()
411 .enumerate()
412 .map(|(index, value)| substitute(&format!("{prefix}.{index}"), value, values))
413 .collect(),
414 ),
415 other => other,
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::fake::Fake;
423
424 #[test]
425 fn a_value_that_merely_contains_a_colon_is_not_a_reference() {
426 assert!(!SecretRef::is_reference("postgres://user:pass@db:5432/app"));
429 assert!(!SecretRef::is_reference("redis://cache:6379"));
430 assert!(!SecretRef::is_reference("https://vault.internal:8200"));
431 assert!(!SecretRef::is_reference("not-vault:secret/data/x#y"));
432 assert!(SecretRef::is_reference("vault:secret/data/x#y"));
433 }
434
435 #[test]
436 fn parses_a_reference_into_a_path_and_a_field() {
437 let parsed = SecretRef::parse("vault:secret/data/myapp#database_url").unwrap();
438
439 assert_eq!(parsed.path, "secret/data/myapp");
440 assert_eq!(parsed.field, "database_url");
441 assert_eq!(parsed.to_string(), "vault:secret/data/myapp#database_url");
442 }
443
444 #[test]
445 fn a_reference_with_no_field_says_what_one_looks_like() {
446 let error = SecretRef::parse("vault:secret/data/myapp").unwrap_err().to_string();
447
448 assert!(error.contains("no field"), "got {error}");
449 assert!(error.contains("vault:secret/data/myapp#database_url"), "got {error}");
450 }
451
452 #[test]
453 fn an_empty_path_or_field_is_malformed() {
454 assert!(SecretRef::parse("vault:#field").is_err());
455 assert!(SecretRef::parse("vault:secret/data/x#").is_err());
456 assert!(SecretRef::parse("vault:").is_err());
457 }
458
459 #[test]
460 fn a_fallback_is_refused_and_the_error_argues_the_case() {
461 let error =
462 SecretRef::parse("vault:secret/data/myapp#password|hunter2").unwrap_err().to_string();
463
464 assert!(error.contains("fallback"), "got {error}");
465 assert!(error.contains("wrong credential"), "the error has to make the case: {error}");
466 assert!(SecretRef::parse("vault:secret/data/myapp|hunter2").unwrap_err().to_string().contains("fallback"));
468 }
469
470 fn store() -> crate::fake::FakeVault {
471 Fake::new()
472 .secret(
473 "secret/data/myapp",
474 [
475 ("database_url", "postgres://app:s3cr3t@db:5432/app"),
476 ("username", "app"),
477 ("password", "s3cr3t"),
478 ],
479 )
480 .secret("secret/data/mail", [("password", "mailpass")])
481 .store()
482 }
483
484 #[tokio::test]
485 async fn a_reference_resolves_into_configuration() {
486 let store = store();
487 let config = Config::new();
488 config.set("database.url", "vault:secret/data/myapp#database_url");
489 config.set("database.pool", 10);
490
491 let resolved = Resolver::new(&store).config(&config, &["database"]).await.unwrap();
492
493 assert_eq!(resolved, 1);
494 assert_eq!(config.string("database.url", ""), "postgres://app:s3cr3t@db:5432/app");
495 assert_eq!(config.int("database.pool", 0), 10, "everything else is left alone");
496 }
497
498 #[tokio::test]
499 async fn several_keys_from_one_path_are_fetched_once() {
500 let store = store();
501 let config = Config::new();
502 config.set("database.username", "vault:secret/data/myapp#username");
503 config.set("database.password", "vault:secret/data/myapp#password");
504 config.set("database.url", "vault:secret/data/myapp#database_url");
505 config.set("mail.password", "vault:secret/data/mail#password");
506
507 let mut resolver = Resolver::new(&store);
508 let resolved = resolver.config(&config, &["database", "mail"]).await.unwrap();
509
510 assert_eq!(resolved, 4);
511 assert_eq!(resolver.fetches(), 2, "two paths, four keys");
512 store.assert_read_once("secret/data/myapp");
513 store.assert_read_once("secret/data/mail");
514 assert_eq!(config.string("database.password", ""), "s3cr3t");
515 }
516
517 #[tokio::test]
518 async fn an_unresolvable_reference_stops_the_boot_and_names_both() {
519 let store = Fake::new().missing("secret/data/nope").store();
520 let config = Config::new();
521 config.set("database.url", "vault:secret/data/nope#database_url");
522
523 let error = Resolver::new(&store)
524 .config(&config, &["database"])
525 .await
526 .expect_err("an unresolved secret must not boot")
527 .to_string();
528
529 assert!(error.contains("database.url"), "the key has to be named: {error}");
530 assert!(error.contains("secret/data/nope"), "the path has to be named: {error}");
531 assert_eq!(config.string("database.url", ""), "vault:secret/data/nope#database_url");
533 }
534
535 #[tokio::test]
536 async fn a_missing_field_says_which_fields_the_secret_does_have() {
537 let store = store();
538 let config = Config::new();
539 config.set("database.url", "vault:secret/data/myapp#dsn");
540
541 let error =
542 Resolver::new(&store).config(&config, &["database"]).await.unwrap_err().to_string();
543
544 assert!(error.contains("no field `dsn`"), "got {error}");
545 assert!(error.contains("database_url"), "the names it does hold are the useful part: {error}");
546 assert!(!error.contains("s3cr3t"), "an error must never carry a value: {error}");
547 }
548
549 #[tokio::test]
550 async fn a_malformed_reference_is_rejected_before_anything_is_fetched() {
551 let store = store();
552 let config = Config::new();
553 config.set("database.url", "vault:secret/data/myapp");
554
555 let error =
556 Resolver::new(&store).config(&config, &["database"]).await.unwrap_err().to_string();
557
558 assert!(error.contains("database.url"), "got {error}");
559 assert!(error.contains("no field"), "got {error}");
560 store.assert_reads(0);
561 }
562
563 #[tokio::test]
564 async fn resolves_a_flat_map_the_way_a_dotenv_file_comes_in() {
565 let store = store();
566 let mut pairs = BTreeMap::from([
567 ("DATABASE_URL".to_string(), "vault:secret/data/myapp#database_url".to_string()),
568 ("MAIL_PASSWORD".to_string(), "vault:secret/data/mail#password".to_string()),
569 ("APP_URL".to_string(), "https://example.com:443".to_string()),
570 ]);
571
572 let resolved = Resolver::new(&store).pairs(&mut pairs).await.unwrap();
573
574 assert_eq!(resolved, 2);
575 assert_eq!(pairs["DATABASE_URL"], "postgres://app:s3cr3t@db:5432/app");
576 assert_eq!(pairs["MAIL_PASSWORD"], "mailpass");
577 assert_eq!(pairs["APP_URL"], "https://example.com:443");
578 }
579
580 #[tokio::test]
581 async fn a_reference_nested_in_an_object_or_an_array_is_found_and_kept_in_shape() {
582 let store = store();
583 let config = Config::new();
584 config.set(
585 "database.connections.pgsql",
586 Json::object([("password", Json::from("vault:secret/data/myapp#password"))]),
587 );
588 config.set(
589 "database.hosts",
590 Json::Array(vec![
591 Json::from("db-1"),
592 Json::from("vault:secret/data/myapp#username"),
593 ]),
594 );
595
596 Resolver::new(&store).config(&config, &["database"]).await.unwrap();
597
598 assert_eq!(config.string("database.connections.pgsql.password", ""), "s3cr3t");
599 let hosts = config.get("database.hosts").unwrap();
600 assert!(matches!(hosts, Json::Array(_)), "an array must stay an array: {hosts:?}");
601 assert_eq!(hosts.as_array().unwrap()[1].as_str(), Some("app"));
602 }
603
604 #[tokio::test]
605 async fn reads_a_version_1_secret_as_readily_as_a_version_2_one() {
606 let store = Fake::new()
609 .answers(
610 "kv/legacy",
611 Json::object([("data", Json::object([("password", Json::from("v1pass"))]))]),
612 )
613 .store();
614
615 let mut pairs =
616 BTreeMap::from([("PASSWORD".to_string(), "vault:kv/legacy#password".to_string())]);
617 Resolver::new(&store).pairs(&mut pairs).await.unwrap();
618
619 assert_eq!(pairs["PASSWORD"], "v1pass");
620 }
621
622 #[tokio::test]
623 async fn a_number_or_a_flag_in_a_secret_becomes_its_text() {
624 let store = Fake::new()
625 .answers(
626 "secret/data/db",
627 Json::object([(
628 "data",
629 Json::object([
630 ("data", Json::object([("port", Json::from(5432)), ("tls", Json::from(true))])),
631 ("metadata", Json::object([("version", Json::from(1))])),
632 ]),
633 )]),
634 )
635 .store();
636
637 let mut pairs = BTreeMap::from([
638 ("PORT".to_string(), "vault:secret/data/db#port".to_string()),
639 ("TLS".to_string(), "vault:secret/data/db#tls".to_string()),
640 ]);
641 Resolver::new(&store).pairs(&mut pairs).await.unwrap();
642
643 assert_eq!(pairs["PORT"], "5432");
644 assert_eq!(pairs["TLS"], "true");
645 }
646
647 #[tokio::test]
648 async fn a_secret_never_prints_its_values() {
649 let store = store();
650 let secret = store.read("secret/data/myapp").await.unwrap();
651
652 let printed = format!("{secret:?}");
653 assert!(!printed.contains("s3cr3t"), "a value reached a log: {printed}");
654 assert!(printed.contains("redacted"));
655 assert!(printed.contains("password"), "the names are what makes it useful: {printed}");
656 }
657
658 #[test]
665 fn resolves_references_in_the_process_environment() {
666 static ENVIRONMENT: std::sync::Mutex<()> = std::sync::Mutex::new(());
667 let _guard = ENVIRONMENT.lock().unwrap_or_else(|e| e.into_inner());
668
669 unsafe {
672 std::env::set_var("RUSTLAVEL_VAULT_TEST_URL", "vault:secret/data/myapp#database_url");
673 std::env::set_var("RUSTLAVEL_VAULT_TEST_PLAIN", "redis://cache:6379");
674 }
675
676 let runtime = tokio::runtime::Builder::new_current_thread()
677 .enable_all()
678 .build()
679 .expect("a runtime for one test");
680 let resolved = runtime.block_on(async { resolve_env(&store()).await.unwrap() });
681
682 assert!(resolved >= 1);
683 assert_eq!(
684 std::env::var("RUSTLAVEL_VAULT_TEST_URL").unwrap(),
685 "postgres://app:s3cr3t@db:5432/app"
686 );
687 assert_eq!(std::env::var("RUSTLAVEL_VAULT_TEST_PLAIN").unwrap(), "redis://cache:6379");
688
689 unsafe {
691 std::env::remove_var("RUSTLAVEL_VAULT_TEST_URL");
692 std::env::remove_var("RUSTLAVEL_VAULT_TEST_PLAIN");
693 }
694 }
695
696 #[tokio::test]
697 async fn a_configuration_root_that_holds_nothing_is_not_an_error() {
698 let store = store();
699 let config = Config::new();
700
701 assert_eq!(Resolver::new(&store).config(&config, &["database"]).await.unwrap(), 0);
702 store.assert_reads(0);
703 }
704}