Skip to main content

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}