//! A logged-in handle on the store, built from this deployment's environment. use serde::{Serialize, de::DeserializeOwned}; use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder}; use crate::{Error, path::MOUNT}; /// The store's address. pub const ENV_ADDR: &str = "BAO_ADDR"; /// PEM client certificate presented to the store's listener. pub const ENV_CLIENT_CERT: &str = "BAO_CLIENT_CERT"; /// PEM private key for [`ENV_CLIENT_CERT`]. pub const ENV_CLIENT_KEY: &str = "BAO_CLIENT_KEY"; /// CA bundle the store's own certificate is verified against. Optional: /// absent means the system trust store, which is what a deployment with a /// real CA wants and what a self-signed one must not be left with. pub const ENV_CACERT: &str = "BAO_CACERT"; /// The auth mount a cert login goes through, unless a caller says otherwise. pub const DEFAULT_CERT_MOUNT: &str = "cert"; /// Where the store is and which identity we present to it. /// /// Separate from the connect so the environment can be checked without one: /// every arm below is a misconfiguration an operator has to read an error /// about, and none of them needs a reachable store to happen. #[derive(Debug, PartialEq, Eq)] pub struct Settings { address: String, cert_path: String, key_path: String, ca_path: Option, } impl Settings { /// Read the `BAO_*` variables from the process environment. /// /// # Errors /// [`Error::MissingEnv`] naming the first variable that is unset or empty. pub fn from_env() -> Result { Self::from_lookup(|k| std::env::var(k).ok()) } /// [`Settings::from_env`] against an arbitrary lookup. /// /// [`vaultrs`]'s own defaults are deliberately not used: it looks for /// `VAULT_ADDR` / `VAULT_CLIENT_CERT` / `VAULT_CLIENT_KEY`, and every unit /// in this tree sets the `BAO_` spellings. Falling through to those /// defaults yields a client with **no identity**, which fails at the TLS /// handshake rather than anywhere that names the cause. /// /// # Errors /// [`Error::MissingEnv`] naming the first variable that is unset or empty. pub fn from_lookup(get: impl Fn(&str) -> Option) -> Result { let required = |var: &'static str| -> Result { get(var) .filter(|v| !v.is_empty()) .ok_or(Error::MissingEnv(var)) }; Ok(Self { address: required(ENV_ADDR)?, cert_path: required(ENV_CLIENT_CERT)?, key_path: required(ENV_CLIENT_KEY)?, ca_path: get(ENV_CACERT).filter(|v| !v.is_empty()), }) } } /// A client that has already exchanged its certificate for a token. pub struct SecretStore { inner: VaultClient, } fn read_file(var: &'static str, path: &str) -> Result, Error> { std::fs::read(path).map_err(|source| Error::Identity { var, path: path.to_owned(), source, }) } impl SecretStore { /// Connect using the `BAO_*` environment and log in with the certificate /// auth method, returning a handle that already holds a token. /// /// `cert_role` is the role configured on the store's `cert` mount; it /// selects which policy the returned token carries. /// /// # Errors /// As [`SecretStore::connect`], plus [`Error::MissingEnv`]. pub async fn from_env(cert_role: &str) -> Result { Self::connect(&Settings::from_env()?, cert_role, DEFAULT_CERT_MOUNT).await } /// Present `settings`' identity to the store and log in at `cert_mount`. /// /// # Errors /// [`Error::Identity`] when a named file cannot be read, [`Error::Tls`] /// when the cert/key pair does not form a usable identity, /// [`Error::Settings`] when the address will not parse, and /// [`Error::Vault`] when the store refuses the login — which is what an /// unconfigured `cert` mount looks like from here. pub async fn connect( settings: &Settings, cert_role: &str, cert_mount: &str, ) -> Result { // One PEM blob holding both, which is the shape `Identity::from_pem` // wants; the two stay separate on disk because the key is the half // that gets `0600`. let mut pem = read_file(ENV_CLIENT_CERT, &settings.cert_path)?; pem.push(b'\n'); pem.extend_from_slice(&read_file(ENV_CLIENT_KEY, &settings.key_path)?); let identity = reqwest::Identity::from_pem(&pem).map_err(Error::Tls)?; let mut builder = VaultClientSettingsBuilder::default(); builder .address(settings.address.clone()) .identity(Some(identity)); if let Some(ca) = &settings.ca_path { builder.ca_certs(vec![ca.clone()]); } let built = builder .build() .map_err(|e| Error::Settings(e.to_string()))?; let mut inner = VaultClient::new(built)?; let auth = vaultrs::auth::cert::login(&inner, cert_mount, cert_role).await?; inner.set_token(&auth.client_token); Ok(Self { inner }) } /// Read the object stored at `path`, decoded as `T`. /// /// The shape belongs to whoever owns the path — see [`crate::matrix`] for /// the one kind of secret that exists — so this moves bytes and knows no /// fields. /// /// # Errors /// [`Error::Vault`] when the path does not exist, the token's policy does /// not cover it, or the stored object does not decode as `T`. pub async fn read(&self, path: &str) -> Result { Ok(vaultrs::kv2::read(&self.inner, MOUNT, path).await?) } /// Write `value` at `path`, creating a new version. /// /// # Errors /// [`Error::Vault`] when the token's policy does not cover the path. pub async fn write(&self, path: &str, value: &T) -> Result<(), Error> { vaultrs::kv2::set(&self.inner, MOUNT, path, value).await?; Ok(()) } /// Replace the ACL policy named `name` with `policy`. /// /// A whole-document write, not a merge: the store has no other verb, and /// the caller renders the document from the current agent set anyway, so /// a stanza that is gone from the render is meant to be gone from the /// grant. Render the text with [`crate::policy`] rather than by hand. /// /// # Errors /// [`Error::Vault`] when the token's own policy does not cover /// `sys/policies/acl/` — which is what a controller scoped to the /// `hive-*` namespace gets for any other name. pub async fn write_policy(&self, name: &str, policy: &str) -> Result<(), Error> { vaultrs::sys::policy::set(&self.inner, name, policy).await?; Ok(()) } } #[cfg(test)] mod tests { use super::*; /// A lookup standing in for a fully-configured unit's environment. fn full(k: &str) -> Option { match k { ENV_ADDR => Some("https://store.invalid:8200".to_owned()), ENV_CLIENT_CERT => Some("/run/c.pem".to_owned()), ENV_CLIENT_KEY => Some("/run/k.pem".to_owned()), _ => None, } } #[test] fn a_complete_environment_is_accepted() { // The control: without this, every assertion below could be passing // because `from_lookup` rejects everything. let s = Settings::from_lookup(full).expect("every required variable is set"); assert_eq!(s.address, "https://store.invalid:8200"); assert_eq!(s.ca_path, None, "an absent CA is the system trust store"); } #[test] fn each_required_variable_is_named_when_it_is_the_missing_one() { for var in [ENV_ADDR, ENV_CLIENT_CERT, ENV_CLIENT_KEY] { let e = Settings::from_lookup(|k| if k == var { None } else { full(k) }) .expect_err("one required variable is absent"); assert!( matches!(e, Error::MissingEnv(v) if v == var), "dropping {var} should name {var}, got {e:?}" ); } } #[test] fn an_empty_variable_is_as_absent_as_an_unset_one() { // systemd writes `Environment=BAO_CACERT=` for an unset nix option, so // empty is the shape this actually arrives in. let e = Settings::from_lookup(|k| { if k == ENV_ADDR { Some(String::new()) } else { full(k) } }) .expect_err("an empty address is not an address"); assert!(matches!(e, Error::MissingEnv(ENV_ADDR)), "got {e:?}"); let s = Settings::from_lookup(|k| { if k == ENV_CACERT { Some(String::new()) } else { full(k) } }) .expect("an empty CA is optional, not fatal"); assert_eq!(s.ca_path, None); } #[test] fn an_unreadable_cert_names_the_file_and_the_variable() { let e = read_file(ENV_CLIENT_CERT, "/nonexistent/cert.pem") .expect_err("the file does not exist"); match e { Error::Identity { var, ref path, .. } => { assert_eq!(var, ENV_CLIENT_CERT); assert_eq!(path, "/nonexistent/cert.pem"); } other => panic!("wanted an Identity error, got {other:?}"), } } }