diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 00454f5b..dbe17243 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -1623,11 +1623,15 @@ async fn main() -> Result<()> { let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone()); let hives = load_hives(); - // Before serving, because a hive whose policy does not exist cannot read - // anything this daemon writes for it. Same "log and carry on" shape as - // every connect above. - read_policy::ensure_hive_policies(&hives.iter().map(|h| h.name.clone()).collect::>()) - .await; + // Before serving, because a hive whose role does not exist cannot log in, + // and one whose policy does not exist logs in able to read nothing — + // either way it cannot collect what this daemon writes for it. Policy + // first: the role names the policy, so writing it the other way round + // leaves a window where the role points at nothing. Same "log and carry + // on" shape as every connect above. + let hive_names: Vec = hives.iter().map(|h| h.name.clone()).collect(); + read_policy::ensure_hive_policies(&hive_names).await; + read_policy::ensure_hive_roles(&hive_names).await; let state = AppState { hives: Arc::new(hives), diff --git a/swarm-controller/src/read_policy.rs b/swarm-controller/src/read_policy.rs index f00297e4..70769cbb 100644 --- a/swarm-controller/src/read_policy.rs +++ b/swarm-controller/src/read_policy.rs @@ -1,22 +1,28 @@ -//! Writing the policy a hive's own certificate logs in with. +//! Provisioning a hive's access to the swarm's secret store: the policy its +//! certificate carries, and the cert-auth role that hands it that policy. //! //! The controller writes a credential; the hive fetches it with its own -//! certificate. Nothing said which paths that certificate may read, so the -//! read half of every delivery answers 403. +//! certificate. Without the role the certificate authenticates to nothing, and +//! without the policy the token it gets may read nothing — either way the read +//! half of a delivery answers 403. //! -//! A startup pass, not a hook: the document is the same for every hive and -//! does not depend on which agents exist ([`swarm_secret_client::policy`] -//! explains why it is that wide), so there is nothing to keep in step with -//! anything. Per-hive failures are reported and skipped — one unwritable -//! policy should not take down a daemon that serves everything else, and the -//! next start retries it. +//! Both are startup passes rather than hooks. The policy document is the same +//! for every hive and depends on nothing ([`swarm_secret_client::policy`] +//! explains why it is that wide), and the hive list is loaded once because a +//! config change means a redeploy. Per-hive failures are reported and skipped — +//! one hive that cannot be provisioned should not take down a daemon that +//! serves everything else, and the next start retries it. use anyhow::{Context, Result}; -use swarm_secret_client::{SecretStore, policy}; +use swarm_secret_client::{SecretStore, client::DEFAULT_CERT_MOUNT, policy}; + +/// File holding the authority hives are issued from, as +/// `swarm-controller.nix` names it. +pub const ENV_HIVE_CLIENT_CA: &str = "SWARM_CONTROLLER_HIVE_CLIENT_CA_FILE"; /// Give every hive in `hives` the read policy its certificate will carry. /// -/// Does nothing when this deployment has no store identity, which is the +/// Does nothing when this deployment has no store to reach, which is the /// shape a controller without a secret store runs in. pub async fn ensure_hive_policies(hives: &[String]) { if hives.is_empty() { @@ -46,3 +52,48 @@ async fn write_one(store: &SecretStore, hive: &str) -> Result<()> { tracing::info!(hive, policy = %name, "hive read policy in place"); Ok(()) } + +/// Create each hive's cert-auth role, so the certificate it already holds logs +/// in and carries the policy written above. +/// +/// Does nothing when no authority is configured, which is the deployment that +/// has not onboarded a hive yet. +pub async fn ensure_hive_roles(hives: &[String]) { + let Some(path) = std::env::var_os(ENV_HIVE_CLIENT_CA) else { + tracing::info!("no hive certificate authority configured; hive roles are not managed here"); + return; + }; + let path = path.to_string_lossy().into_owned(); + let ca = match std::fs::read_to_string(&path) { + Ok(ca) => ca, + Err(e) => { + tracing::warn!(path, error = %e, var = ENV_HIVE_CLIENT_CA, "reading the hive authority failed; hive roles are not created"); + return; + } + }; + let store = match crate::store::connect().await { + Ok(store) => store, + Err(e) => { + tracing::warn!(error = %e, "connecting to the swarm secret store failed; hive roles are not created"); + return; + } + }; + for hive in hives { + if let Err(e) = write_role(&store, hive, &ca).await { + tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed"); + } + } +} + +/// The role for one hive: its own certificate's subject, its own policy. +async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> { + let name = policy::hive_object_name(hive)?; + store + // The subject is the hive's name: `glue-bao-tls.nix` mints a hive's + // client leaf with its name as the CN, and cert auth matches on that. + .write_cert_role(DEFAULT_CERT_MOUNT, &name, ca, hive, &name) + .await + .with_context(|| format!("writing the cert-auth role {name}"))?; + tracing::info!(hive, role = %name, "hive store role in place"); + Ok(()) +} diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs index fcd9bda1..be8d6a9b 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -167,6 +167,39 @@ impl SecretStore { 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//certs/`, 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)]