dynamic_config_vault/lib.rs
1//! Read [`dynamic-config`] configuration from HashiCorp Vault.
2//!
3//! Vault's KV v2 store speaks plain HTTP, so this implements the **blocking**
4//! [`RemoteSource`] trait: nothing here needs an async runtime, and neither
5//! does using it.
6//!
7//! ```no_run
8//! use dynamic_config_vault::Vault;
9//!
10//! # struct DbConfigBuilder;
11//! # impl DbConfigBuilder {
12//! # fn init(&self) -> Result<(), dynamic_config::Error> { Ok(()) }
13//! # }
14//! # struct DbConfig;
15//! # impl DbConfig {
16//! # fn set_remote(_: Vault) {}
17//! # fn refresh_remote() -> Result<(), dynamic_config::Error> { Ok(()) }
18//! # fn builder(_: &str) -> DbConfigBuilder { DbConfigBuilder }
19//! # }
20//! DbConfig::set_remote(
21//! Vault::new("https://vault.internal:8200", "secret", "myapp/db")
22//! .with_token(std::env::var("VAULT_TOKEN")?),
23//! );
24//!
25//! // Fetching is explicit; the load that follows touches no network.
26//! DbConfig::refresh_remote()?;
27//! DbConfig::builder("db").init()?;
28//! # Ok::<(), Box<dyn std::error::Error>>(())
29//! ```
30//!
31//! # What it reads
32//!
33//! `GET {address}/v1/{mount}/data/{path}`, and takes `data.data` — the value
34//! half of a KV v2 response. That object becomes the configuration document,
35//! so a secret stored as `{"host": "db", "port": 5432}` maps onto a struct
36//! with those fields.
37//!
38//! The document is handed over as JSON with the section key wrapped around it,
39//! because Vault stores the section's *contents* rather than a whole
40//! configuration file.
41//!
42//! # Several paths as one section
43//!
44//! One section can be split across several secrets, and [`Keys`] says which:
45//!
46//! ```no_run
47//! # use dynamic_config_vault::{Keys, Vault};
48//! # let address = "https://vault.internal:8200";
49//! // Merged in the order given — later wins — and all under the one section key.
50//! let vault = Vault::new(
51//! address,
52//! "secret",
53//! Keys::several(["myapp/db-defaults", "myapp/db-credentials"]),
54//! );
55//! ```
56//!
57//! That is what Vault's shape actually offers, and the two halves of the
58//! sentence are worth separating:
59//!
60//! - **A named list is one request per path.** KV v2 reads one secret at a
61//! time — there is no batch read of a caller-chosen set — so the list is
62//! **not** read atomically: a write landing between two of the requests can
63//! produce a section that never existed as a whole. It is also one *audited*
64//! read per path per fetch, which somebody pays for.
65//! - **Every path lands under the same section key**, because that is what a
66//! Vault secret is: the contents of a section, not a document. So a list is
67//! layering — a shared secret and an override, or a public half and a
68//! restricted half whose difference is a policy on the path, which is the
69//! thing Vault has that the document stores do not.
70//!
71//! **There is deliberately no prefix form.** KV v2 has `LIST`, so the missing
72//! piece is not the protocol; it is the mapping. Folding a whole subtree into
73//! one section makes `myapp/db` and `myapp/server` collide on `host` — the
74//! ordinary layout, refused — and naming a sub-section after each secret's
75//! path would invent a convention no other store here has, and would make a
76//! list of one path mean something different from one path. A deployment that
77//! wants several sections installs one source per section, which is what it
78//! did before. [`dynamic-config-consul`] and [`dynamic-config-s3`] store whole
79//! documents, and read prefixes for that reason.
80//!
81//! Two consequences the multi-path form shares with the rest of the family:
82//!
83//! - **Provenance becomes store-grained.** The merged section is one layer, so
84//! `source_of` names the set rather than which path supplied a value.
85//! - **One unreadable path fails the whole fetch.** A section quietly missing
86//! half of itself is worse than a refresh that failed and left the last
87//! document serving.
88//!
89//! # Watching
90//!
91//! Vault is the one store here that cannot tell you when something changed:
92//! there is no watch, no blocking query, no stream. So [`Vault::watch`] polls —
93//! and says so, rather than dressing a timer up as a subscription.
94//!
95//! What it does *not* do is pull the secret every tick. KV v2 keeps a version
96//! counter in its metadata, so the loop asks the metadata endpoint for
97//! `current_version` and only reads the secret when that number moves. A secret
98//! that has not changed is never transferred, never decrypted, and never
99//! written to an audit log as a read.
100//!
101//! A **multi-path source cannot be watched**, and refuses at
102//! [`watch`](Vault::watch) rather than pretending to: the version counter it
103//! polls belongs to one secret, and a set of secrets has no counter of its own.
104//! Poll `refresh_remote()` on a timer instead.
105//!
106//! ```no_run
107//! # use dynamic_config::RemoteWatch;
108//! # use dynamic_config_vault::Vault;
109//! # use std::time::Duration;
110//! # struct Sink;
111//! # impl Sink {
112//! # fn apply(&self, _: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
113//! # }
114//! # fn example(vault: Vault) {
115//! # let sink = Sink;
116//! let watch = RemoteWatch::new();
117//! let watching = watch.watching();
118//!
119//! std::thread::spawn(move || {
120//! vault.watch(&watching, Duration::from_secs(30), move |document| sink.apply(document))
121//! });
122//!
123//! // Dropping `watch` — or calling `watch.stop()` — ends the loop.
124//! # }
125//! ```
126//!
127//!
128//! # Every failure branch of the watch loop, and what it reports
129//!
130//! A watch is the half of a store `dynamic-config` cannot see, and
131//! [`reporting_to`](Vault::reporting_to) is what lets it speak: the sink the
132//! loop already holds is told about every attempt that came back with
133//! nothing. Which attempts those are is a table rather than prose, because
134//! the question an operator asks is *which* silence is deliberate.
135//!
136//! Three rules decide the column, and they are the same three in all seven
137//! store crates:
138//!
139//! 1. **A failure the loop survives by retrying reports.** That is the case
140//! the whole feature exists for: the stream is down, the last delivery is
141//! old, and nothing else would ever say so out loud.
142//! 2. **A recovery that worked stays silent.** Only a delivery or a fetch
143//! clears the streak, so reporting a five-minute token turning over on a
144//! healthy cluster would drive `remote_up` to zero and leave it there.
145//! 3. **A refusal that never asked the store reports nowhere.** No format, a
146//! key shape that cannot be watched, material that will not build a
147//! client: `RemoteStatus::reachable()` is *whether the store answered the
148//! last time it was asked*, and these never ask. They are returned to the
149//! caller, who is the one holding the mistake — and a status cannot
150//! correct them, since it carries a kind and a path and no message.
151//!
152//! | Branch | Reports |
153//! |---|---|
154//! | the source reads several paths, so it cannot be watched | no — rule 3: nothing has been asked of Vault |
155//! | the version check fails and may yet come good — a sealed Vault, a network blip | **yes**, and the loop waits out the interval |
156//! | the mount is not KV v2, so there is no version to poll | **yes**, and the watch ends |
157//! | the read after a version move fails | **yes**, and `seen` is left where it was so the next tick tries again |
158//! | the first tick, or a version that has not moved | no — Vault answered |
159//! | `on_change` refuses the document | no — Vault answered; `apply` counted the delivery, and what the document did next is `ConfigStatus`'s half |
160//!
161//! [`dynamic-config`]: https://docs.rs/dynamic-config
162//! [`dynamic-config-consul`]: https://docs.rs/dynamic-config-consul
163//! [`dynamic-config-s3`]: https://docs.rs/dynamic-config-s3
164
165#![forbid(unsafe_code)]
166#![deny(missing_docs)]
167
168use std::time::Duration;
169
170use dynamic_config::{Error, Fetched, Format, RemoteSink, RemoteSource, Watching};
171use dynamic_config_store_core::attempts::Attempts;
172use dynamic_config_store_core::credential::Issued;
173use dynamic_config_store_core::documents::{self, Overlap};
174use dynamic_config_store_core::guarded;
175
176pub mod auth;
177mod tls;
178
179pub use auth::Auth;
180use auth::{Session, Token};
181
182/// A private certificate authority and a client certificate, as data.
183///
184/// The shared vocabulary all seven store crates take, so that reaching TLS
185/// never means naming `ureq`'s types — see [`with_tls`](Vault::with_tls).
186pub use dynamic_config_store_core::tls::TlsConfig;
187
188/// How long to wait for Vault before giving up.
189///
190/// A configuration fetch that hangs is worse than one that fails: the caller
191/// can retry a failure.
192const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
193
194/// What a source reads: one path, or several named ones.
195///
196/// Every constructor takes one, and a bare `&str` or `String` is
197/// [`Keys::one`] — so the single-path spelling every caller already wrote keeps
198/// working unchanged.
199///
200/// There is no prefix variant, and that is a decision rather than an omission:
201/// KV v2 has `LIST`, but a secret is a section's *contents*, so a subtree
202/// folded into one section collides on every field name two secrets share. The
203/// crate documentation says the whole of it.
204#[derive(Clone, Debug, PartialEq, Eq)]
205pub enum Keys {
206 /// One path, whose fields are the whole section.
207 One(String),
208 /// Several named paths, merged **in the order given — later wins**.
209 ///
210 /// The rule a list of `.file(..)` calls already teaches: the caller wrote
211 /// the list, so the list is the precedence. One request per path, because
212 /// KV v2 has no batch read of a caller-chosen set — so the set is **not**
213 /// read atomically, and every path costs an audited read per fetch.
214 Several(Vec<String>),
215}
216
217impl Keys {
218 /// One path, whose fields are the whole section.
219 #[must_use]
220 pub fn one(path: impl Into<String>) -> Self {
221 Self::One(path.into())
222 }
223
224 /// Several named paths, merged in the order given — later wins.
225 #[must_use]
226 pub fn several<I, S>(paths: I) -> Self
227 where
228 I: IntoIterator<Item = S>,
229 S: Into<String>,
230 {
231 Self::Several(paths.into_iter().map(Into::into).collect())
232 }
233
234 /// The paths as a slice, in the order they are read.
235 fn named(&self) -> &[String] {
236 match self {
237 Self::One(path) => std::slice::from_ref(path),
238 Self::Several(paths) => paths,
239 }
240 }
241
242 /// How a diagnostic names what this source reads.
243 ///
244 /// One path renders as the path itself, so every message a single-path
245 /// source has ever produced is unchanged.
246 fn describe(&self) -> String {
247 match self {
248 Self::One(path) => path.clone(),
249 Self::Several(paths) => format!("paths {}", paths.join(", ")),
250 }
251 }
252}
253
254impl From<&str> for Keys {
255 fn from(path: &str) -> Self {
256 Self::one(path)
257 }
258}
259
260impl From<String> for Keys {
261 fn from(path: String) -> Self {
262 Self::One(path)
263 }
264}
265
266impl From<&String> for Keys {
267 fn from(path: &String) -> Self {
268 Self::one(path)
269 }
270}
271
272/// A failed call, sorted by what a caller can do about it.
273///
274/// Sorted on `ureq`'s *typed* status, before anything becomes a string: an
275/// error message mentioning a path like `myapp/403` must not read as a
276/// refused token.
277enum CallError {
278 /// Vault said 403: the token is the problem, and a fresh login might be
279 /// the cure. Carries an [`ErrorKind::Auth`] error, because if the fresh
280 /// login is refused too then waiting will not help — which is the one
281 /// thing a watch loop needs to know.
282 ///
283 /// [`ErrorKind::Auth`]: dynamic_config::ErrorKind::Auth
284 Forbidden(Error),
285 /// Everything else — network, timeouts, a sealed Vault. A new token fixes
286 /// none of it, and a sealed Vault does un-seal, so it stays `Remote`.
287 /// Vault answers a denied or expired token with 403 and nothing else, so
288 /// there is no 401 arm to write here; a 401 in front of a Vault is a
289 /// proxy, and a proxy's verdict is not the store's.
290 Other(Error),
291}
292
293impl CallError {
294 fn into_error(self) -> Error {
295 match self {
296 Self::Forbidden(error) | Self::Other(error) => error,
297 }
298 }
299}
300
301/// Why a version check failed, sorted by whether waiting can help.
302enum CheckError {
303 /// The mount has no version counter: a v1 mount, or not a KV mount at
304 /// all. No number of retries will grow one.
305 NotKv2(Error),
306 /// The check itself failed — network, a sealed Vault, an expired token.
307 /// The next tick may well succeed, so the loop waits rather than ending —
308 /// and reports the attempt through
309 /// [`reporting_to`](Vault::reporting_to), because a tick that keeps
310 /// failing is the case a `remote_up` of 1 would be lying about.
311 Transient(Error),
312}
313
314/// A secret in Vault's KV v2 store, as a configuration source.
315///
316/// Not `Clone`: the session holds the current token, and two clones sharing a
317/// path while logging in separately would double the login traffic and halve
318/// the usefulness of the cache. Wrap it in an `Arc` if two places need one.
319pub struct Vault {
320 address: String,
321 mount: String,
322 keys: Keys,
323 key: String,
324 auth: Auth,
325 session: Session,
326 namespace: Option<String>,
327 timeout: Duration,
328 agent: Option<ureq::Agent>,
329 /// What [`with_tls`](Vault::with_tls) was given, translated into an agent
330 /// on first use.
331 tls: Option<TlsConfig>,
332 /// The fallback client, built once. A fresh agent per request would mean
333 /// a fresh connection pool per request — a TLS handshake per poll tick.
334 ///
335 /// A `Result`, because building it can now fail: a CA file that is not
336 /// there is discovered when the client is built, and the first request is
337 /// where a caller can be told. Cached either way, so a bad path does not
338 /// re-read a missing file once per poll tick. The failure is kept as its
339 /// message because `Error` is deliberately not `Clone`; every failure this
340 /// can hold is a `remote` one, so re-wrapping loses nothing.
341 default_agent: std::sync::OnceLock<Result<ureq::Agent, String>>,
342 /// Where [`watch`](Vault::watch) reports a tick that came back with
343 /// nothing; see [`reporting_to`](Vault::reporting_to). Nobody, by default,
344 /// which is what makes reporting free for a caller who never asked for it.
345 attempts: Attempts,
346}
347
348impl Vault {
349 /// A secret at `{mount}/{path}`, served by the Vault at `address`.
350 ///
351 /// `path` is a path — `"myapp/db"` — or a [`Keys`], for the several-paths
352 /// form.
353 ///
354 /// The document is wrapped under the section key the configuration type
355 /// uses — `"db"` by default, changed with [`with_key`](Self::with_key) —
356 /// because Vault stores a section's contents, not a whole file. Several
357 /// paths all land under that one key and merge, later winning.
358 pub fn new(
359 address: impl Into<String>,
360 mount: impl Into<String>,
361 path: impl Into<Keys>,
362 ) -> Self {
363 Self {
364 address: address.into().trim_end_matches('/').to_owned(),
365 mount: mount.into(),
366 keys: path.into(),
367 key: "db".to_owned(),
368 // No credentials until one is supplied; the first read then says so
369 // rather than sending an unauthenticated request and reporting
370 // Vault's answer to it.
371 auth: Auth::Token(String::new()),
372 session: Session::new(),
373 namespace: None,
374 timeout: DEFAULT_TIMEOUT,
375 agent: None,
376 tls: None,
377 default_agent: std::sync::OnceLock::new(),
378 attempts: Attempts::default(),
379 }
380 }
381
382 /// The section key to wrap the secret under.
383 ///
384 /// Must match the key the config type's `builder(..)` was given.
385 #[must_use]
386 pub fn with_key(mut self, key: impl Into<String>) -> Self {
387 self.key = key.into();
388 self
389 }
390
391 /// A token somebody already obtained.
392 ///
393 /// Shorthand for `with_auth(Auth::token(..))`. A renewable token is still
394 /// renewed; a token that stops working cannot be replaced, because there
395 /// are no credentials here to log in again with. Every other [`Auth`] can.
396 #[must_use]
397 pub fn with_token(self, token: impl Into<String>) -> Self {
398 self.with_auth(Auth::token(token))
399 }
400
401 /// How to obtain a token.
402 ///
403 /// ```no_run
404 /// # use dynamic_config_vault::{Auth, Vault};
405 /// // In Kubernetes, with no secret to distribute at all.
406 /// let vault = Vault::new("https://vault.internal:8200", "secret", "myapp/db")
407 /// .with_auth(Auth::kubernetes("myapp"));
408 ///
409 /// // Or AppRole, for a service outside it.
410 /// let vault = Vault::new("https://vault.internal:8200", "secret", "myapp/db")
411 /// .with_auth(Auth::app_role(
412 /// std::env::var("VAULT_ROLE_ID").unwrap(),
413 /// std::env::var("VAULT_SECRET_ID").unwrap(),
414 /// ));
415 /// ```
416 ///
417 /// Logging in is lazy: this reaches nothing, and the first read does it.
418 #[must_use]
419 pub fn with_auth(mut self, auth: Auth) -> Self {
420 self.auth = auth;
421 self.session.invalidate();
422 self
423 }
424
425 /// Uses an HTTP client the program already has.
426 ///
427 /// For a caller with its own proxy settings, a private CA, a client
428 /// certificate, or a connection pool it would rather not have a second copy
429 /// of. The agent's own timeout applies instead of
430 /// [`with_timeout`](Self::with_timeout).
431 ///
432 /// ```no_run
433 /// # use dynamic_config_vault::Vault;
434 /// # fn example(agent: ureq::Agent) {
435 /// let vault = Vault::new("https://vault.internal:8200", "secret", "myapp/db")
436 /// .with_agent(agent);
437 /// # }
438 /// ```
439 ///
440 /// The escape hatch, and it stays one: [`with_tls`](Self::with_tls) covers
441 /// a private CA and a client certificate, and everything else — a proxy, a
442 /// connection pool, an option this crate has never heard of — still lives
443 /// here. Setting both is refused rather than resolved; see
444 /// [`with_tls`](Self::with_tls).
445 #[must_use]
446 pub fn with_agent(mut self, agent: ureq::Agent) -> Self {
447 self.agent = Some(agent);
448 self
449 }
450
451 /// A private certificate authority, a client certificate, or both.
452 ///
453 /// The same three settings, spelled the same way, in all seven store
454 /// crates — and spelled as *data*, so nothing here names a `ureq` type:
455 ///
456 /// ```no_run
457 /// # use dynamic_config_vault::{TlsConfig, Vault};
458 /// let vault = Vault::new("https://vault.internal:8200", "secret", "myapp/db")
459 /// .with_token(std::env::var("VAULT_TOKEN").unwrap())
460 /// .with_tls(
461 /// TlsConfig::new()
462 /// .with_ca_certificate_file("/etc/ssl/private-ca.pem")
463 /// .with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key"),
464 /// );
465 /// ```
466 ///
467 /// Vault expresses all of it: a CA from a file or from bytes, and a client
468 /// certificate from either. A CA replaces the platform trust store rather
469 /// than adding to it — naming a private authority is saying the public
470 /// ones do not apply to this host — so a deployment that needs both puts
471 /// both in the file.
472 ///
473 /// There is no way to turn verification off. The reasoning is in
474 /// [`TlsConfig`]'s own documentation, and the short version is that the
475 /// answer to a self-signed server is to trust its certificate, not to stop
476 /// checking.
477 ///
478 /// **Nothing is read here.** The files are opened when the first request
479 /// builds the client, so a missing CA is an error naming the path rather
480 /// than a panic in a builder chain — the same laziness every other
481 /// constructor in this family has.
482 ///
483 /// # With `with_agent`
484 ///
485 /// Setting both is **refused**, at the first request, naming both calls.
486 /// An agent already carries a complete TLS configuration, so "apply this
487 /// too" has no meaning that is not a guess — and the guess that loses
488 /// silently discards a CA, which is the failure this whole surface exists
489 /// to prevent. Put the CA on the agent, or drop the agent.
490 #[must_use]
491 pub fn with_tls(mut self, tls: TlsConfig) -> Self {
492 self.tls = Some(tls);
493 // The cached fallback client baked in the old configuration.
494 self.default_agent = std::sync::OnceLock::new();
495 self
496 }
497
498 /// The Vault Enterprise namespace, if there is one.
499 #[must_use]
500 pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
501 self.namespace = Some(namespace.into());
502 self
503 }
504
505 /// How long a single fetch may take before it is given up on. Ten seconds
506 /// by default.
507 ///
508 /// The deadline for **one fetch attempt**, excluding retries the
509 /// underlying client performs — the same sentence every store in this
510 /// family answers to. `ureq` performs none of its own, so here the
511 /// deadline is the whole story.
512 ///
513 /// It bounds each request [`watch`](Self::watch) makes — the metadata
514 /// check, and the read that follows a version change — not the loop,
515 /// which runs until it is stopped.
516 #[must_use]
517 pub fn with_timeout(mut self, timeout: Duration) -> Self {
518 self.timeout = timeout;
519 // The cached fallback client baked in the old timeout.
520 self.default_agent = std::sync::OnceLock::new();
521 self
522 }
523
524 /// Reports the watch loop's *failed* attempts to `sink`.
525 ///
526 /// A watch loop is the half of a store `dynamic-config` cannot otherwise
527 /// see. [`RemoteSink::apply`] records a delivery, so a working watch keeps
528 /// [`RemoteStatus`] current — but a loop whose metadata poll is failing,
529 /// whose Vault is sealed or whose token was refused delivers nothing, and
530 /// without this says nothing: `dynamic_config_remote_up` would report the
531 /// last *delivery* rather than the last *attempt*, and a Vault that
532 /// stopped answering an hour ago would look healthy until something called
533 /// `refresh_remote`.
534 ///
535 /// That gap is wider here than anywhere else in this family, because this
536 /// watch is a poll of a *version counter*: a secret that has not changed
537 /// is never read, so a healthy Vault and a Vault whose token expired
538 /// yesterday deliver exactly the same thing — nothing.
539 ///
540 /// ```no_run
541 /// # use dynamic_config::Watching;
542 /// # use dynamic_config_vault::Vault;
543 /// # use std::time::Duration;
544 /// # struct DbConfig;
545 /// # impl DbConfig {
546 /// # fn remote_sink() -> dynamic_config::RemoteSink { unimplemented!() }
547 /// # }
548 /// # fn example(address: &str, watching: Watching) -> Result<(), dynamic_config::Error> {
549 /// let sink = DbConfig::remote_sink();
550 ///
551 /// Vault::new(address, "secret", "myapp/db")
552 /// .with_token(std::env::var("VAULT_TOKEN").unwrap())
553 /// .reporting_to(sink)
554 /// .watch(&watching, Duration::from_secs(30), move |document| sink.apply(document))
555 /// # }
556 /// ```
557 ///
558 /// One sink serves both halves, and it is taken **once, where the loop is
559 /// wired**: a sink is `Copy`, and the generation it captures there is what
560 /// fences a loop winding down after its source was replaced from charging
561 /// its failures to the replacement.
562 ///
563 /// A failure to report a failure never reaches the loop — reporting is
564 /// infallible and silent — and what it moves is deliberately narrow: the
565 /// failure streak and the last failure, never the fetch clock. So
566 /// `dynamic_config_remote_last_fetch_seconds` keeps ageing while
567 /// `dynamic_config_remote_up` goes to zero, which is the pair that says
568 /// both *the store is not answering* and *how stale what it last said has
569 /// become*.
570 ///
571 /// A [`fetch`](RemoteSource::fetch) needs none of this: a fetch records
572 /// itself, through the `Remote` that performed it.
573 ///
574 /// [`RemoteStatus`]: dynamic_config::RemoteStatus
575 #[must_use]
576 pub fn reporting_to(mut self, sink: RemoteSink) -> Self {
577 self.attempts = Attempts::to(sink);
578 self
579 }
580
581 /// Calls `on_change` when the secret's version moves, checking every
582 /// `interval`.
583 ///
584 /// Polling, because Vault offers nothing better — and *metadata* polling,
585 /// because reading a secret every thirty seconds to discover it has not
586 /// changed is a poor thing to do to a secrets store. Each tick reads
587 /// `{mount}/metadata/{path}` for `current_version`; only a new version
588 /// triggers a read of the secret itself.
589 ///
590 /// The current value is **not** delivered at startup, for the same reason a
591 /// file watcher does not report an edit when it starts. Fetch first if the
592 /// starting value matters, which it usually does:
593 ///
594 /// ```no_run
595 /// # use dynamic_config::{RemoteSource, RemoteWatch};
596 /// # use dynamic_config_vault::Vault;
597 /// # use std::time::Duration;
598 /// # struct Sink;
599 /// # impl Sink {
600 /// # fn apply(&self, _: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
601 /// # }
602 /// # fn example(vault: Vault, watching: dynamic_config::Watching) -> Result<(), dynamic_config::Error> {
603 /// # let sink = Sink;
604 /// sink.apply(vault.fetch()?)?;
605 /// vault.watch(&watching, Duration::from_secs(30), move |document| sink.apply(document))
606 /// # }
607 /// ```
608 ///
609 /// A failed check does not end the watch — an expired token, a sealed
610 /// Vault, a network blip — it waits out the interval and tries again. `stop`
611 /// is noticed within a quarter second regardless of how long `interval` is.
612 ///
613 /// Surviving a failure quietly is not the same as hiding it:
614 /// [`reporting_to`](Self::reporting_to) hands each failed attempt to a
615 /// [`RemoteSink`], so a loop that has been failing for an hour stops
616 /// reporting the store as healthy.
617 ///
618 /// # Errors
619 ///
620 /// If the source reads several paths: the counter this polls belongs to
621 /// one secret, and a set of secrets has none of its own. Or if the mount
622 /// turns out not to be KV v2: a v1 mount has no version counter, so every
623 /// tick would find "no change" and the watch would silently never fire — a
624 /// misconfiguration, reported as one. Or if `on_change` returns an error,
625 /// which ends the watch — so a caller that wants to survive a bad document
626 /// should log it and return `Ok`. Transport failures do not surface here;
627 /// they are retried.
628 pub fn watch<F>(
629 &self,
630 watching: &Watching,
631 interval: Duration,
632 mut on_change: F,
633 ) -> Result<(), Error>
634 where
635 F: FnMut(Fetched) -> Result<(), Error>,
636 {
637 // Refused up front, so a multi-path source fails at `watch` rather
638 // than on the first change, hours later. Returned and recorded
639 // nowhere: nothing has been asked of Vault yet, and `reachable()` is
640 // *whether the store answered the last time it was asked*.
641 self.single_path()?;
642
643 let mut seen: Option<u64> = None;
644
645 while watching.keep_going() {
646 match self.current_version() {
647 // The first tick records the version without firing: the value
648 // it names is the one the caller already has.
649 Ok(version) if seen.is_none() => seen = Some(version),
650
651 // A failed read leaves `seen` where it was on purpose, so the
652 // next tick tries again rather than skipping the change.
653 Ok(version) if seen != Some(version) => {
654 // The version is taken from the read itself, not from the
655 // check that preceded it: a write landing between the two
656 // would otherwise be delivered now and again on the next
657 // tick, as though the same document had changed twice.
658 match self.read() {
659 Ok((document, version)) => {
660 seen = Some(version);
661
662 // A callback that refuses is deliberately not a
663 // failed attempt: Vault answered and the secret
664 // arrived. What the callback then did with it is
665 // `ConfigStatus`'s business, and `RemoteSink::apply`
666 // already records it there.
667 guarded(&mut on_change, document, &self.describe())?;
668 }
669 // `seen` is deliberately left where it was, so the next
670 // tick reads again rather than skipping the change —
671 // and the attempt is recorded, because a counter that
672 // moved and a secret that will not come back is the
673 // shape of a policy that stopped allowing the read.
674 Err(error) => self.attempts.failed(&error),
675 }
676 }
677
678 // Not a KV v2 mount: there is no version counter to poll, so
679 // "retry next tick" would run forever and deliver nothing.
680 // A misconfiguration is reported, not waited out — and
681 // recorded on the way out, since a watch that has ended is a
682 // configuration that has stopped updating for good.
683 Err(CheckError::NotKv2(error)) => {
684 self.attempts.failed(&error);
685
686 return Err(error);
687 }
688
689 // The check failed and may yet come good: waited out rather
690 // than reported to the caller, recorded rather than swallowed.
691 // This is the arm that made a sealed Vault look healthy.
692 Err(CheckError::Transient(error)) => self.attempts.failed(&error),
693
694 // Unchanged: the store answered, and there is nothing to say.
695 Ok(_) => {}
696 }
697
698 watching.sleep_for(interval);
699 }
700
701 Ok(())
702 }
703
704 /// The one path this source reads, or an error saying it reads several.
705 ///
706 /// A watch here is a version counter being polled, and the counter belongs
707 /// to a secret: a set of secrets has no version of its own, and polling
708 /// one member's would fire on that member and miss every other. Reading
709 /// all of them every tick is a poll wearing a watch's name, which is the
710 /// thing `refresh_remote()` on a timer already is, honestly.
711 fn single_path(&self) -> Result<&str, Error> {
712 match &self.keys {
713 Keys::One(path) => Ok(path),
714 Keys::Several(_) => Err(Error::remote(format!(
715 "{}: a source that reads several paths cannot be watched; \
716 poll `refresh_remote()` on a timer instead",
717 self.describe()
718 ))),
719 }
720 }
721
722 /// What two of this source's paths supplying one field means.
723 ///
724 /// Only [`Overlap::LaterWins`] here: a caller who wrote the list wrote the
725 /// precedence with it, and there is no prefix form whose order nobody
726 /// chose.
727 fn overlap(&self) -> Overlap {
728 Overlap::LaterWins
729 }
730
731 /// The version counter KV v2 keeps beside the secret.
732 fn current_version(&self) -> Result<u64, CheckError> {
733 let path = self.single_path().map_err(CheckError::Transient)?;
734
735 let body = self
736 .get(&self.metadata_url(path), "metadata")
737 .map_err(CheckError::Transient)?;
738
739 body.get("data")
740 .and_then(|data| data.get("current_version"))
741 .and_then(serde_json::Value::as_u64)
742 .ok_or_else(|| {
743 CheckError::NotKv2(Error::remote(format!(
744 "{}: the metadata has no `data.current_version`; is this a KV v2 mount?",
745 self.describe()
746 )))
747 })
748 }
749
750 /// An authenticated GET, retried once if the token turned out to be dead.
751 ///
752 /// `what` names the thing being read, for the error message.
753 fn get(&self, url: &str, what: &str) -> Result<serde_json::Value, Error> {
754 match self.get_once(url, what) {
755 Err(CallError::Forbidden(_)) if self.can_relogin() => {
756 // The proactive renewal should have caught an expiring token,
757 // but clocks skew and leases get revoked. One fresh login and
758 // one retry — not a loop: if a new token is also refused, the
759 // policy is wrong and retrying would turn a clear failure into
760 // a hang.
761 self.session.invalidate();
762
763 self.get_once(url, what).map_err(CallError::into_error)
764 }
765 outcome => outcome.map_err(CallError::into_error),
766 }
767 }
768
769 /// Whether a refused token can be traded for a fresh one.
770 ///
771 /// Only a login can: `Auth::Token` was handed in from outside, and
772 /// invalidating it would just retry the identical string — one wasted
773 /// request per read against a broken policy.
774 fn can_relogin(&self) -> bool {
775 !matches!(self.auth, Auth::Token(_))
776 }
777
778 fn get_once(&self, url: &str, what: &str) -> Result<serde_json::Value, CallError> {
779 let token = self.token().map_err(CallError::Other)?;
780
781 // A TLS configuration that will not build is not a refused token, so
782 // it must not become one: `Other` is what keeps a bad CA path out of
783 // the relogin-and-retry path it would otherwise loop through.
784 let agent = self.agent().map_err(CallError::Other)?;
785
786 let mut request = agent.get(url).header("X-Vault-Token", &token);
787
788 if let Some(namespace) = &self.namespace {
789 request = request.header("X-Vault-Namespace", namespace);
790 }
791
792 request
793 .call()
794 .map_err(|error| {
795 // The message is the same either way; only the kind differs,
796 // and only the typed status decides it. `{error}` here is
797 // `ureq`'s own rendering of the status — never the request,
798 // so the `X-Vault-Token` header cannot ride along.
799 let described = format!("{}: {error}", self.describe());
800
801 match error {
802 ureq::Error::StatusCode(403) => CallError::Forbidden(Error::auth(described)),
803 _ => CallError::Other(Error::remote(described)),
804 }
805 })?
806 .body_mut()
807 .read_json()
808 .map_err(|error| {
809 CallError::Other(Error::remote(format!(
810 "{}: the {what} response was not JSON: {error}",
811 self.describe()
812 )))
813 })
814 }
815
816 /// The token to present, logging in or renewing if it is time.
817 fn token(&self) -> Result<String, Error> {
818 // A token supplied by the caller is used as it is: there is nothing to
819 // log in with, so the session would only wrap it.
820 if let Auth::Token(supplied) = &self.auth {
821 if supplied.is_empty() {
822 // `Auth` rather than `Remote`: nothing was ever sent, so the
823 // store is not the problem and no amount of retrying will
824 // produce a credential that was never supplied.
825 return Err(Error::auth(format!(
826 "{}: no credentials; call `with_token` or `with_auth`",
827 self.describe()
828 )));
829 }
830
831 return Ok(supplied.clone());
832 }
833
834 self.session
835 .token(|| self.login(), |token| self.renew(token))
836 }
837
838 /// Exchanges credentials for a token.
839 fn login(&self) -> Result<Issued<Token>, Error> {
840 let Some(path) = self.auth.path() else {
841 // Unreachable: `token()` handles `Auth::Token` before it gets here.
842 return Err(Error::remote(format!(
843 "{}: {} needs no login",
844 self.describe(),
845 self.auth.describe()
846 )));
847 };
848
849 let body = self.auth.body()?;
850 let url = format!("{}/v1/{path}", self.address);
851
852 let mut request = self.agent()?.post(&url);
853
854 if let Some(namespace) = &self.namespace {
855 request = request.header("X-Vault-Namespace", namespace);
856 }
857
858 let response: serde_json::Value = request
859 .send_json(&body)
860 .map_err(|error| {
861 let described = format!(
862 "{}: logging in with {} failed: {error}",
863 self.describe(),
864 self.auth.describe()
865 );
866
867 // On the *login* endpoint the request shape is ours and
868 // correct, so a 400 or a 403 is Vault saying these
869 // credentials are not accepted — a token that could not be
870 // obtained, which waiting does not fix. A 503 (sealed) or a
871 // network failure does fix itself, and stays `Remote`.
872 match error {
873 ureq::Error::StatusCode(400 | 403) => Error::auth(described),
874 _ => Error::remote(described),
875 }
876 })?
877 .body_mut()
878 .read_json()
879 .map_err(|error| {
880 Error::remote(format!(
881 "{}: the login response was not JSON: {error}",
882 self.describe()
883 ))
884 })?;
885
886 self.token_from(&response, "auth")
887 }
888
889 /// Extends the current token's lease.
890 fn renew(&self, token: &str) -> Result<Issued<Token>, Error> {
891 let url = format!("{}/v1/auth/token/renew-self", self.address);
892
893 let mut request = self.agent()?.post(&url).header("X-Vault-Token", token);
894
895 if let Some(namespace) = &self.namespace {
896 request = request.header("X-Vault-Namespace", namespace);
897 }
898
899 let response: serde_json::Value = request
900 .send_json(serde_json::json!({}))
901 .map_err(|error| {
902 Error::remote(format!("{}: renewal failed: {error}", self.describe()))
903 })?
904 .body_mut()
905 .read_json()
906 .map_err(|error| {
907 Error::remote(format!(
908 "{}: the renewal response was not JSON: {error}",
909 self.describe()
910 ))
911 })?;
912
913 // Renewal answers with the lease but not the token: it is the same one.
914 let mut renewed = self.token_from(&response, "auth")?;
915 renewed.value.secret = token.to_owned();
916
917 Ok(renewed)
918 }
919
920 /// Reads a token, its lease and whether it renews out of an `auth` block.
921 fn token_from(
922 &self,
923 response: &serde_json::Value,
924 field: &str,
925 ) -> Result<Issued<Token>, Error> {
926 let auth = response.get(field).ok_or_else(|| {
927 Error::remote(format!(
928 "{}: the response has no `{field}` block",
929 self.describe()
930 ))
931 })?;
932
933 let secret = auth
934 .get("client_token")
935 .and_then(serde_json::Value::as_str)
936 .unwrap_or_default()
937 .to_owned();
938
939 // Zero means "does not expire" in Vault's own vocabulary, not "expired".
940 let lease = auth
941 .get("lease_duration")
942 .and_then(serde_json::Value::as_u64)
943 .filter(|seconds| *seconds > 0)
944 .map(Duration::from_secs);
945
946 let renewable = auth
947 .get("renewable")
948 .and_then(serde_json::Value::as_bool)
949 .unwrap_or(false);
950
951 Ok(Issued {
952 value: Token::new(secret, renewable),
953 ttl: lease,
954 })
955 }
956
957 /// The HTTP client: the caller's if they supplied one, otherwise ours.
958 ///
959 /// Ours is built once and kept: an agent owns a connection pool and a TLS
960 /// session cache, and rebuilding it per request would pay a handshake per
961 /// poll tick.
962 fn agent(&self) -> Result<&ureq::Agent, Error> {
963 if let Some(agent) = &self.agent {
964 // Refused rather than resolved: an agent is already a complete TLS
965 // configuration, so applying a second one on top would mean
966 // silently dropping one of them, and the one that would be dropped
967 // is a CA the caller believes is pinned.
968 if self.tls.is_some() {
969 return Err(Error::remote(format!(
970 "{}: `with_agent` and `with_tls` were both called; \
971 an agent already carries its own TLS configuration, so \
972 this is refused rather than resolved — put the certificate \
973 authority on the agent, or drop the agent",
974 self.describe()
975 )));
976 }
977
978 return Ok(agent);
979 }
980
981 self.default_agent
982 .get_or_init(|| match &self.tls {
983 Some(tls) => tls::agent(tls, self.timeout, &self.describe())
984 .map_err(|error| error.to_string()),
985 None => Ok(ureq::Agent::config_builder()
986 .timeout_global(Some(self.timeout))
987 .build()
988 .new_agent()),
989 })
990 .as_ref()
991 .map_err(Error::remote)
992 }
993
994 fn url(&self, path: &str) -> String {
995 format!(
996 "{}/v1/{}/data/{}",
997 self.address,
998 self.mount,
999 path.trim_start_matches('/')
1000 )
1001 }
1002
1003 fn metadata_url(&self, path: &str) -> String {
1004 format!(
1005 "{}/v1/{}/metadata/{}",
1006 self.address,
1007 self.mount,
1008 path.trim_start_matches('/')
1009 )
1010 }
1011}
1012
1013impl Vault {
1014 /// The one secret a watch follows, and the version it was read at.
1015 ///
1016 /// The version comes from the same response as the values, so the two
1017 /// cannot disagree — which is the whole point of not asking twice.
1018 fn read(&self) -> Result<(Fetched, u64), Error> {
1019 let path = self.single_path()?;
1020 let (document, version) = self.read_one(path)?;
1021
1022 Ok((Fetched::new(document, Format::Json), version))
1023 }
1024
1025 /// One secret, wrapped under the section key, and its version.
1026 ///
1027 /// The wrapping happens per path rather than after the merge because that
1028 /// is what makes the merge mean something: two secrets supplying `host`
1029 /// collide at `db.host`, which is the path a reader of the configuration
1030 /// would name, not at a bare `host` that belongs to nothing.
1031 fn read_one(&self, path: &str) -> Result<(String, u64), Error> {
1032 let body = self.get(&self.url(path), "secret")?;
1033
1034 // KV v2 nests the values one level down; anything else is a v1 mount or
1035 // an error page, and either way not what was asked for.
1036 let values = body
1037 .get("data")
1038 .and_then(|data| data.get("data"))
1039 .ok_or_else(|| {
1040 Error::remote(format!(
1041 "{}: `{path}` answered without `data.data`; is this a KV v2 mount?",
1042 self.describe()
1043 ))
1044 })?;
1045
1046 let document = serde_json::json!({ &self.key: values });
1047
1048 // Absent on a v1 mount, which `data.data` has already ruled out; zero
1049 // is then a version that never matches a real one, so a watch keeps
1050 // reading rather than deciding nothing ever changes.
1051 let version = body
1052 .get("data")
1053 .and_then(|data| data.get("metadata"))
1054 .and_then(|metadata| metadata.get("version"))
1055 .and_then(serde_json::Value::as_u64)
1056 .unwrap_or(0);
1057
1058 Ok((document.to_string(), version))
1059 }
1060
1061 /// The `(path, document)` pairs this source reads, in merge order.
1062 ///
1063 /// A named list is one request per path and **every one of them must
1064 /// answer**: merging the two that did would leave a process running a
1065 /// section with half of itself quietly missing.
1066 fn documents(&self) -> Result<Vec<(String, String)>, Error> {
1067 let paths = self.keys.named();
1068 let mut documents = Vec::with_capacity(paths.len());
1069
1070 for path in paths {
1071 documents.push((path.clone(), self.read_one(path)?.0));
1072 }
1073
1074 Ok(documents)
1075 }
1076}
1077
1078// Hand-written, never derived: a derive would print every field, and the
1079// fields include credentials. `{:?}` reaching a log is an ordinary accident —
1080// a `dbg!`, a `tracing::debug!(?source)` — and an accident must not disclose
1081// a secret. The other store crates follow the same rule.
1082impl std::fmt::Debug for Vault {
1083 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1084 f.debug_struct("Vault")
1085 .field("address", &self.address)
1086 .field("mount", &self.mount)
1087 .field("keys", &self.keys)
1088 .field("key", &self.key)
1089 .field("namespace", &self.namespace)
1090 .field("auth", &self.auth)
1091 .finish_non_exhaustive()
1092 }
1093}
1094
1095impl RemoteSource for Vault {
1096 fn fetch(&self) -> Result<Fetched, Error> {
1097 let documents = self.documents()?;
1098
1099 // Read in call order, which is the order the rule wants — so nothing
1100 // is sorted here.
1101 documents::merged(&documents, Format::Json, self.overlap(), &self.describe())
1102 }
1103
1104 fn describe(&self) -> String {
1105 // The address too: a program with a staging Vault and a production
1106 // Vault should never have to guess which one refused it.
1107 format!(
1108 "vault {} {}/{}",
1109 self.address,
1110 self.mount,
1111 self.keys.describe()
1112 )
1113 }
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118 use super::*;
1119
1120 #[test]
1121 fn debug_never_prints_a_credential() {
1122 let source = Vault::new("http://vault:8200", "secret", "myapp/db")
1123 .with_auth(Auth::app_role("hunter2-role-id", "hunter2-secret-id"));
1124
1125 let printed = format!(
1126 "{source:?} {:?} {:?}",
1127 Auth::token("hunter2-token"),
1128 Auth::userpass("admin", "hunter2-password"),
1129 );
1130
1131 assert!(!printed.contains("hunter2-secret-id"), "{printed}");
1132 assert!(!printed.contains("hunter2-token"), "{printed}");
1133 assert!(!printed.contains("hunter2-password"), "{printed}");
1134 // The non-secret halves stay printable — that is what makes the
1135 // redaction usable rather than a black hole.
1136 assert!(printed.contains("hunter2-role-id"), "{printed}");
1137 assert!(printed.contains("admin"), "{printed}");
1138 }
1139}