//! A logged-in handle on the store, built from this deployment's environment. use serde::{Deserialize, Serialize}; 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"; #[derive(Serialize, Deserialize)] struct Credential { /// `glue-matrix-bao-token.nix` reads the store with /// `bao kv get -field=value`, so this name is load-bearing for a reader /// this crate does not control. Renaming it silently breaks that unit. value: String, } /// 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 credential stored at `path`. /// /// # Errors /// [`Error::Vault`] when the path does not exist, the token's policy does /// not cover it, or the stored object has no `value` field. pub async fn read(&self, path: &str) -> Result { let c: Credential = vaultrs::kv2::read(&self.inner, MOUNT, path).await?; Ok(c.value) } /// Write `value` as the credential 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: &str) -> Result<(), Error> { let body = Credential { value: value.to_owned(), }; vaultrs::kv2::set(&self.inner, MOUNT, path, &body).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:?}"), } } #[test] fn the_value_field_matches_what_the_nix_reader_asks_for() { // The literal is the point: `bao kv get -field=value` is the other end // of this agreement and lives in a file no Rust test can reach, so the // name is pinned here rather than derived from the struct. let json = serde_json::to_string(&Credential { value: "t".to_owned(), }) .expect("a struct of one String serialises"); assert_eq!(json, r#"{"value":"t"}"#); } }