Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/client.rs
atlas 4007fc965d swarm-controller: create each hive's cert-auth role at startup
A hive holds an mTLS pair and a policy naming what it may read, and still
cannot log in: nothing creates the role that maps its certificate to that
policy. The one pre-shared credential in the system therefore buys no
access.

Minting happens here rather than in nix, which was the first plan. Nix
mints from the store's own container, and that path is gated on the
bootstrap token -- so onboarding a hive later would mean placing the one
genuinely pre-shared secret again. Doing it from the controller costs a
public certificate authority as an input and makes the bootstrap token
one-time.

A startup pass, not a hook: the hive list is loaded once and a config
change means a redeploy, so the roles are as static as the list. Only the
policy is derived from something that moves.

The subject is the hive's name because glue-bao-tls.nix mints a hive's
client leaf with its name as the CN, and cert auth matches on that.

Per-hive failures are logged and skipped, matching the queue, bridge and
forge connects above it: a controller whose store is unreachable still
serves everything else, and the next start retries.

Not covered by a test: ensure_hive_roles is IO from end to end, and the
seam that would make it assertable is the one the read-grant sink already
has. Said here rather than implied by a green suite.
2026-09-10 00:25:07 +02:00

277 lines
10 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(())
}
/// Create or replace the cert-auth role `name`, so that a certificate
/// issued by `certificate` and carrying `common_name` logs in with
/// `policy`.
///
/// `certificate` is the authority by value, not a path: the store keeps
/// its own copy inside the role and never reads a file of ours.
///
/// # Errors
/// [`Error::Vault`] when the token's policy does not cover
/// `auth/<mount>/certs/<name>`, or the store rejects the authority.
pub async fn write_cert_role(
&self,
mount: &str,
name: &str,
certificate: &str,
common_name: &str,
policy: &str,
) -> Result<(), Error> {
let mut opts =
vaultrs::api::auth::cert::requests::CreateCaCertificateRoleRequest::builder();
opts.allowed_common_names(vec![common_name.to_owned()])
.token_policies(vec![policy.to_owned()]);
vaultrs::auth::cert::ca_cert_role::set(
&self.inner,
mount,
name,
certificate,
Some(&mut opts),
)
.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:?}"),
}
}
}