hyperhive/swarm-secret-client/src/client.rs
atlas c590447e8f swarm-secret-client: give the store handle a policy write
The renderer beside this produces a hive's read policy; nothing could put
one in the store. `read`/`write` are KV v2 verbs and a policy is not a
secret, so this is a second endpoint rather than another path.

A whole-document write, because the store has no merge verb: the caller
renders from the current agent set, so a stanza missing from the render
is a grant that is meant to be gone.
2026-09-09 18:40:41 +02:00

244 lines
9.2 KiB
Rust

//! 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<String>,
}
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, Error> {
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<String>) -> Result<Self, Error> {
let required = |var: &'static str| -> Result<String, Error> {
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<Vec<u8>, 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, Error> {
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<Self, Error> {
// 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<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
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<T: Serialize + Sync>(&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/<name>` — 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<String> {
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:?}"),
}
}
}