Skip to main content

rustlavel_vault/
plugin.rs

1//! Turning the secret store on.
2//!
3//! ```ignore
4//! #[tokio::main]
5//! async fn main() -> Result<()> {
6//!     let vault = Vault::from_env()?.resolving("database").resolving("mail");
7//!
8//!     let app = App::new()?;
9//!     // Before anything reads configuration: swap every `vault:` reference
10//!     // for the secret it names, or refuse to start.
11//!     vault.resolve(app.config()).await?;
12//!
13//!     app.plugin(vault).serve().await
14//! }
15//! ```
16//!
17//! Resolution is a step the application takes, not something the plugin does
18//! behind its back, and that is deliberate. `Plugin::register` is synchronous —
19//! reading a secret is not — and more importantly a boot that carried on
20//! because a secret could not be fetched is the exact failure this package
21//! exists to prevent. Written this way, the `?` is visible in `main`.
22//!
23//! What registering does do is put the client and the [`Renewer`] where the
24//! rest of the application can reach them, and say so loudly when the store is
25//! being talked to in a way that leaks the token.
26
27use crate::client::VaultClient;
28use crate::renew::Renewer;
29use crate::resolve::{self, Resolver};
30use rustlavel_core::{Config, Result};
31use rustlavel_http::plugin::{Plugin, Setup};
32use std::sync::Arc;
33
34/// The environment variables Vault and OpenBao read to skip certificate
35/// verification. Set, they turn TLS into an unauthenticated tunnel.
36const SKIP_VERIFY: &[&str] = &["VAULT_SKIP_VERIFY", "BAO_SKIP_VERIFY"];
37
38/// The secret store, as an application enables it.
39///
40/// | Config key       | Meaning                                                    |
41/// |------------------|------------------------------------------------------------|
42/// | `vault.resolve`  | Configuration roots to search for references, as an array. |
43pub struct Vault {
44    client: VaultClient,
45    roots: Vec<String>,
46    renewer: Arc<Renewer>,
47    plain_http_is_fine: bool,
48}
49
50impl Vault {
51    pub fn new(client: VaultClient) -> Vault {
52        Vault {
53            client,
54            roots: Vec::new(),
55            renewer: Arc::new(Renewer::new()),
56            plain_http_is_fine: false,
57        }
58    }
59
60    /// Take the address, token and namespace from the environment, the way
61    /// every other Vault and OpenBao tool does.
62    pub fn from_env() -> Result<Vault> {
63        Ok(Vault::new(VaultClient::from_env()?))
64    }
65
66    /// Also search this configuration subtree for `vault:` references.
67    ///
68    /// Named rather than discovered because [`Config`] cannot be enumerated,
69    /// and because a resolver that walked everything would read secrets for
70    /// keys nobody asked it to touch.
71    pub fn resolving(mut self, root: impl Into<String>) -> Vault {
72        self.roots.push(root.into());
73        self
74    }
75
76    /// Do not warn about a plain-HTTP address.
77    ///
78    /// Spelled out in full because of what it means: the token goes over the
79    /// wire in clear text, and a token is the whole store. Loopback addresses
80    /// never warn in the first place, so this is only for a network you have
81    /// decided to trust.
82    pub fn even_over_http(mut self) -> Vault {
83        self.plain_http_is_fine = true;
84        self
85    }
86
87    pub fn client(&self) -> &VaultClient {
88        &self.client
89    }
90
91    /// The renewer this plugin registers.
92    ///
93    /// Whatever issues a lease — a login, a dynamic database credential — hands
94    /// it here, and it is kept alive until the application stops.
95    pub fn renewer(&self) -> &Arc<Renewer> {
96        &self.renewer
97    }
98
99    /// Swap every `vault:` reference in configuration for the secret it names,
100    /// and say how many were replaced.
101    ///
102    /// The roots come from [`Vault::resolving`], or from `vault.resolve` in
103    /// configuration when the builder named none — a value written in
104    /// `main.rs` is a decision and a value in a config file is a default.
105    pub async fn resolve(&self, config: &Config) -> Result<usize> {
106        let roots = if self.roots.is_empty() { configured_roots(config) } else { self.roots.clone() };
107        if roots.is_empty() {
108            return Ok(0);
109        }
110
111        let borrowed: Vec<&str> = roots.iter().map(String::as_str).collect();
112        let resolved = Resolver::new(&self.client).config(config, &borrowed).await?;
113
114        if resolved > 0 {
115            rustlavel_core::info!(
116                "resolved {resolved} configuration {} from the secret store",
117                plural(resolved)
118            );
119        }
120        Ok(resolved)
121    }
122
123    /// Swap every `vault:` reference in the process environment.
124    ///
125    /// For an application whose secrets arrive as `DATABASE_URL=vault:…`. Call
126    /// it before the application boots, so `.env` and the configuration
127    /// defaults see values rather than references.
128    pub async fn resolve_env(&self) -> Result<usize> {
129        let resolved = resolve::resolve_env(&self.client).await?;
130        if resolved > 0 {
131            rustlavel_core::info!(
132                "resolved {resolved} environment {} from the secret store",
133                plural(resolved)
134            );
135        }
136        Ok(resolved)
137    }
138
139    /// Say so when the store is being reached in a way that gives the token
140    /// away. Warnings, not refusals: refusing to register would leave the
141    /// application with no secrets at all, which is a worse outcome than a
142    /// loud log line somebody can act on.
143    fn warn_about_unsafe_settings(&self, config: &Config) {
144        if !self.plain_http_is_fine && sends_the_token_in_the_clear(self.client.address()) {
145            rustlavel_core::warn!(
146                "the secret store address is `{}`, which is plain HTTP over a network. The token \
147                 travels in clear text and a token is the whole store — anyone who can read the \
148                 traffic can read every secret it guards. Use https, or `.even_over_http()` if \
149                 the address really is behind a TLS proxy on this host.",
150                self.client.address()
151            );
152        }
153
154        for name in SKIP_VERIFY {
155            if std::env::var(name).is_ok_and(|value| truthy(&value)) {
156                rustlavel_core::warn!(
157                    "{name} is set, so the certificate the secret store presents is not checked. \
158                     That makes the connection encrypted but not authenticated: anything that \
159                     can intercept it can present its own certificate, take the token and answer \
160                     with secrets of its own choosing."
161                );
162            }
163        }
164
165        if config.is_production() && !self.client.has_token() {
166            rustlavel_core::warn!(
167                "no token for the secret store. Every read will be refused until something \
168                 authenticates — set VAULT_TOKEN, or log in before the first read."
169            );
170        }
171    }
172}
173
174impl Plugin for Vault {
175    fn name(&self) -> &'static str {
176        "vault"
177    }
178
179    fn register(self: Box<Self>, setup: &mut Setup<'_>) {
180        self.warn_about_unsafe_settings(setup.config);
181
182        // Both as state, so a handler resolves the client with
183        // `req.state::<VaultClient>()` and whatever issues a lease can put it
184        // on the renewer without threading either through the application.
185        let renewer = self.renewer.clone();
186        setup.state(self.client.clone());
187        setup.state(renewer);
188    }
189}
190
191/// Deliberately hand-written: the client's own `Debug` redacts the token, and
192/// deriving here would be one refactor away from printing it.
193impl std::fmt::Debug for Vault {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        f.debug_struct("Vault")
196            .field("client", &self.client)
197            .field("roots", &self.roots)
198            .field("renewer", &self.renewer)
199            .finish()
200    }
201}
202
203fn configured_roots(config: &Config) -> Vec<String> {
204    config.list("vault.resolve")
205}
206
207/// Whether an address means the token crosses a network unencrypted.
208///
209/// Loopback is exempt, and that is not a loophole: `http://127.0.0.1:8200` is
210/// the standard shape of a Vault Agent or a TLS-terminating sidecar, the
211/// traffic never leaves the host, and warning about it would train people to
212/// ignore the warning that matters.
213fn sends_the_token_in_the_clear(address: &str) -> bool {
214    let Some(rest) = address.strip_prefix("http://") else { return false };
215    let host = rest.split(['/', '?']).next().unwrap_or(rest);
216    let host = host.rsplit_once(':').map_or(host, |(head, _)| head);
217
218    !matches!(host.trim_matches(['[', ']']), "localhost" | "::1")
219        && !host.starts_with("127.")
220}
221
222fn plural(count: usize) -> &'static str {
223    if count == 1 { "value" } else { "values" }
224}
225
226fn truthy(value: &str) -> bool {
227    matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::fake::Fake;
234    use rustlavel_core::{Context, ContextBuilder, Json};
235    use rustlavel_http::Router;
236
237    fn register(vault: Vault, config: &Config) -> Context {
238        let mut router = Router::new();
239        let mut context: Option<ContextBuilder> = Some(Context::builder().config(config.clone()));
240        let mut setup = Setup { router: &mut router, config, context: &mut context };
241
242        Box::new(vault).register(&mut setup);
243
244        context.expect("context builder").build()
245    }
246
247    #[test]
248    fn registers_the_client_and_the_renewer_so_handlers_can_reach_them() {
249        let store = Fake::new().secret("secret/data/x", [("a", "1")]).store();
250        let context = register(Vault::new(store.client().clone()), &Config::new());
251
252        assert!(context.state::<VaultClient>().is_some(), "no client for a handler to use");
253        assert!(context.state::<Arc<Renewer>>().is_some(), "nothing to hand a lease to");
254    }
255
256    #[test]
257    fn a_plain_http_address_on_a_network_is_unsafe_and_loopback_is_not() {
258        assert!(sends_the_token_in_the_clear("http://vault.internal:8200"));
259        assert!(sends_the_token_in_the_clear("http://10.0.0.5:8200/"));
260        assert!(!sends_the_token_in_the_clear("https://vault.internal:8200"));
261        // The Vault Agent and TLS sidecar shape, which is safe and common.
262        assert!(!sends_the_token_in_the_clear("http://127.0.0.1:8200"));
263        assert!(!sends_the_token_in_the_clear("http://localhost:8200"));
264        assert!(!sends_the_token_in_the_clear("http://[::1]:8200"));
265    }
266
267    #[test]
268    fn a_skip_verify_setting_is_recognised_however_it_is_spelled() {
269        assert!(truthy("1"));
270        assert!(truthy("TRUE"));
271        assert!(truthy(" yes "));
272        assert!(!truthy("0"));
273        assert!(!truthy(""));
274    }
275
276    #[tokio::test]
277    async fn resolves_the_roots_it_was_told_about_and_leaves_the_rest() {
278        let store = Fake::new()
279            .secret("secret/data/myapp", [("password", "s3cr3t")])
280            .secret("secret/data/other", [("password", "unused")])
281            .store();
282
283        let config = Config::new();
284        config.set("database.password", "vault:secret/data/myapp#password");
285        config.set("mail.password", "vault:secret/data/other#password");
286
287        let vault = Vault::new(store.client().clone()).resolving("database");
288        assert_eq!(vault.resolve(&config).await.unwrap(), 1);
289
290        assert_eq!(config.string("database.password", ""), "s3cr3t");
291        assert_eq!(config.string("mail.password", ""), "vault:secret/data/other#password");
292        store.assert_not_read("secret/data/other");
293    }
294
295    #[tokio::test]
296    async fn configuration_supplies_the_roots_when_the_builder_named_none() {
297        let store = Fake::new().secret("secret/data/myapp", [("password", "s3cr3t")]).store();
298
299        let config = Config::new();
300        config.set("vault.resolve", Json::Array(vec![Json::from("database")]));
301        config.set("database.password", "vault:secret/data/myapp#password");
302
303        assert_eq!(Vault::new(store.client().clone()).resolve(&config).await.unwrap(), 1);
304        assert_eq!(config.string("database.password", ""), "s3cr3t");
305    }
306
307    #[tokio::test]
308    async fn resolving_nothing_reads_nothing() {
309        let store = Fake::new().store();
310
311        assert_eq!(Vault::new(store.client().clone()).resolve(&Config::new()).await.unwrap(), 0);
312        store.assert_reads(0);
313    }
314
315    #[tokio::test]
316    async fn an_unresolvable_reference_refuses_to_boot() {
317        let store = Fake::new().denies("secret/data/myapp").store();
318        let config = Config::new();
319        config.set("database.password", "vault:secret/data/myapp#password");
320
321        let error = Vault::new(store.client().clone())
322            .resolving("database")
323            .resolve(&config)
324            .await
325            .expect_err("a refused secret must stop the boot")
326            .to_string();
327
328        assert!(error.contains("database.password"), "got {error}");
329        assert!(error.contains("secret/data/myapp"), "got {error}");
330    }
331
332    #[test]
333    fn the_plugin_never_prints_the_token() {
334        let store = Fake::new().store();
335        let printed = format!("{:?}", Vault::new(store.client().clone()));
336
337        assert!(!printed.contains("fake-token"), "got {printed}");
338        assert!(printed.contains("redacted"));
339    }
340}