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.
This commit is contained in:
atlas 2026-09-09 16:51:39 +02:00 committed by mara
commit 4007fc965d
3 changed files with 104 additions and 16 deletions

View file

@ -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::<Vec<_>>())
.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<String> = 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),

View file

@ -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(())
}