hyperhive/swarm-secret-client/src/lib.rs
atlas 676c45bc93 swarm: mint, publish and login-verify an agent's store identity at create
`swarm/agents/<agent>/bao-mtls` did not exist, and neither did any
per-agent identity at the secret store: `policy::agent_object_name`,
`render_agent` and `render_agent_with_queue` had been written and never
called outside their own tests. An agent's only "per-agent" secret today
is read under the HIVE's certificate, through a wide grant on
`swarm/agents/*` — so "per-agent" was presentational.

The swarm now mints the certificate, so no hive ever needs the capability
to mint one. `swarm-controller` is the service that does it: it already
logs in to the store, and its existing grant already covers exactly the
three objects written here (`create/update` on
`secret/data/swarm/agents/*`, `sys/policies/acl/hive-*` and
`auth/cert/certs/hive-*`). No new bao grant, and nothing co-located — a
cert-auth role pins its authority by value, per role, so the controller
issues from its own CA on its own host and pins that CA in the role it
writes. No existing role changes.

The mint node does not report success on a write. After publishing it
connects again, with the leaf it just issued and under the role it just
wrote, and reads the path back — so the policy, the role, the common name
and the leaf are exercised in production on every agent creation. A
certificate this code mints that the role this code writes will not accept
turns the job node red at creation time instead of surfacing later as an
agent container that cannot start.

`TriggerDeploy` gains an `after_any` edge on the mint, not `after_ok`: a
hive cannot pass down a certificate the swarm has not published, but a
host with no authority configured must still create agents exactly as it
does today.

The private key is generated in memory and never written to disk on the
controller — `SecretStore::connect_with_identity` takes the PEM the minter
is already holding, so nothing is written out purely to be logged in with.

Refs #4137
2026-09-18 15:05:24 +02:00

90 lines
3.6 KiB
Rust

//! The swarm's secret-store client: where a credential lives, and how both ends
//! reach it.
//!
//! The HTTP is [`vaultrs`]'s job. What this crate owns is the *agreements* —
//! the rules every path obeys ([`path`]), the translation from this
//! deployment's environment into a logged-in client ([`client`]), and, per kind
//! of secret, the path it lives at together with the fields it holds
//! ([`matrix`], [`queue`], [`mtls`]). Each of those is a thing the controller
//! and a hive must say identically, so it is said once here.
//!
//! [`policy`] is the same kind of agreement seen from the other side: which of
//! those paths a given principal's own token may read — a hive's, and an
//! agent's, which are two documents because they are two shapes of grant rather
//! than one with a name in it. It belongs here rather than in the controller
//! because the grant and the path are one statement — spelled differently they
//! produce a 403 that names neither.
//!
//! [`client`] is deliberately ignorant of all of it: it moves whatever type a
//! caller names, so a second kind of secret is a new module beside [`matrix`]
//! and not another field on a struct shared with it.
//!
//! [`mtls`] is the one module about reaching the store rather than about a
//! value inside it, and its doc explains why that is not circular.
pub mod client;
pub mod matrix;
pub mod mtls;
pub mod path;
pub mod policy;
pub mod queue;
pub use client::SecretStore;
/// What can go wrong between "we have a client certificate" and "we have the
/// credential".
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A name that would have addressed something other than what the caller
/// meant. See [`path`].
#[error("{kind} name {value:?} is not a single path segment of [A-Za-z0-9_-]")]
PathSegment {
/// Which name was rejected. A principal's kind in the singular
/// (`agent`, `hive`, `service`, `controller`) when the name addresses
/// one, or what the name is to the secret otherwise — `account`, for
/// a matrix credential.
kind: &'static str,
/// The offending value, quoted in the message because the caller
/// usually got it from config and needs to see which one.
value: String,
},
/// A variable the store's address or identity comes from is unset or
/// empty. Named rather than defaulted: a wrong store address fails much
/// later and much less clearly than a missing one.
#[error("{0} is unset or empty")]
MissingEnv(&'static str),
/// A client-certificate file named by the environment could not be read.
#[error("reading {path} (from {var}): {source}")]
Identity {
/// The variable that named the file.
var: &'static str,
/// The path it named.
path: String,
/// The underlying IO failure.
source: std::io::Error,
},
/// The address would not parse into a URL the client can use.
#[error("the store's settings are unusable: {0}")]
Settings(String),
/// The store refused us, was unreachable, or answered something we could
/// not parse.
#[error(transparent)]
Vault(#[from] Box<vaultrs::error::ClientError>),
/// The client certificate and key did not form a usable identity, or the
/// CA bundle did not parse.
#[error("building the TLS identity: {0}")]
Tls(#[source] reqwest::Error),
}
impl From<vaultrs::error::ClientError> for Error {
fn from(e: vaultrs::error::ClientError) -> Self {
// Boxed because `ClientError` is large enough that carrying it inline
// makes every `Result` in the crate pay for the rare arm.
Self::Vault(Box::new(e))
}
}