swarm-bao: agent certificates issued by a store-generated agent CA
An agent's store identity was signed in swarm-controller's memory by a CA a controller-host unit generated on disk, and the listener never trusted that CA. Agent leaves now come from the store itself: a `pki-agents` PKI mount whose root openbao generates internally, so the agent CA's key never exists outside the store. - swarm-bao-agent-pki (new, store host, as the bao granter): enables and tunes the mount, generates the root once (guarded on an empty issuer list, no replace branch), upserts the `swarm-agent` role (client certificates named `hive-agent-*` only, 90 days), caches the CA at /var/lib/swarm-bao-tls/agent-ca.pem and composes the listener bundle. - The listener's tls_client_ca_file is a new listener-client-ca.pem (client-ca.pem, then the agent CA). Host cert-auth roles still pin client-ca.pem, so an agent leaf satisfies no host role. swarm-bao-certs composes the same bundle before openbao starts. - openbao reads tls_client_ca_file only at start, so when the bundle changed after openbao started, swarm-bao-agent-pki restarts openbao.service in the container; under `seal = "shamir"` it prints the step instead. Once swarm-bao-certs has a cached CA, later boots start openbao with it and do not restart. - The controller policy gains exactly `update` on pki-agents/issue/swarm-agent. mint_and_verify now asks that role for the leaf (the store generates the key), writes the agent's cert-auth role pinning the issuing CA bao returned, and writes the agent's policy as render_agent alone: the hive-shared queue credential stanza is gone. - deploy.bao.agentPkiRoleName (must start `swarm-`, asserted with the other pki role names); swarm-controller gets SWARM_CONTROLLER_AGENT_PKI_MOUNT/_ROLE from the deploy.bao options. Deleted: swarm-controller-agent-ca and its options (agentCaFile, agentCaKeyFile), env, LoadCredential entries and assertion; agent_identity's Authority, rcgen signing and validity window; the rcgen and time dependencies of swarm-controller (rcgen leaves the workspace); policy::render_agent_with_queue and its tests. The CN-prefix assertion policy.rs said was owed is not: agent and host roles pin different CAs. Migration is re-creating each agent after deploy; that overwrites the stale role and policy. Closes #4756
This commit is contained in:
parent
5cd7f866f4
commit
6170e74a31
16 changed files with 894 additions and 925 deletions
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
use rustify_derive::Endpoint;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
|
||||
use vaultrs::{
|
||||
api::pki::{requests::GenerateCertificateRequest, responses::GenerateCertificateResponse},
|
||||
client::{Client, VaultClient, VaultClientSettingsBuilder},
|
||||
};
|
||||
|
||||
use crate::{Error, path::MOUNT};
|
||||
use crate::{Error, mtls, path::MOUNT};
|
||||
|
||||
/// The store's address.
|
||||
pub const ENV_ADDR: &str = "BAO_ADDR";
|
||||
|
|
@ -261,6 +264,30 @@ impl SecretStore {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Have the PKI role `role` on `mount` issue a client certificate for
|
||||
/// `common_name`, returning it with its key and the issuing CA.
|
||||
///
|
||||
/// The store generates the key: it exists in the store's answer and in the
|
||||
/// returned value, nowhere else. What the certificate may carry (names,
|
||||
/// usages, lifetime) is the role's to decide, so nothing but the name is
|
||||
/// asked for here.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Vault`] when the token's policy does not cover
|
||||
/// `<mount>/issue/<role>` or the role refuses `common_name`, and
|
||||
/// [`Error::IncompleteIssue`] when the answer lacks any of the three.
|
||||
pub async fn issue_client_certificate(
|
||||
&self,
|
||||
mount: &str,
|
||||
role: &str,
|
||||
common_name: &str,
|
||||
) -> Result<mtls::Credential, Error> {
|
||||
let issued =
|
||||
vaultrs::api::exec_with_result(&self.inner, issue_request(mount, role, common_name))
|
||||
.await?;
|
||||
credential_from_issue(issued)
|
||||
}
|
||||
|
||||
/// The names of every cert-auth role under `mount`, or none when the
|
||||
/// mount has no roles at all.
|
||||
///
|
||||
|
|
@ -296,6 +323,45 @@ struct WriteAclPolicy {
|
|||
policy: String,
|
||||
}
|
||||
|
||||
/// The request [`SecretStore::issue_client_certificate`] sends.
|
||||
///
|
||||
/// The name stays out of the SANs so the certificate carries exactly one name,
|
||||
/// the one the cert-auth role matches. PKCS#8 because that is the key shape
|
||||
/// every reader of the published identity already parses.
|
||||
fn issue_request(mount: &str, role: &str, common_name: &str) -> GenerateCertificateRequest {
|
||||
GenerateCertificateRequest {
|
||||
mount: mount.to_owned(),
|
||||
role: role.to_owned(),
|
||||
common_name: Some(common_name.to_owned()),
|
||||
exclude_cn_from_sans: Some(true),
|
||||
private_key_format: Some("pkcs8".to_owned()),
|
||||
..GenerateCertificateRequest::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The store's answer, moved straight into the redacting
|
||||
/// [`mtls::Credential`]. `GenerateCertificateResponse` derives `Debug` and
|
||||
/// holds the private key, so it is consumed here and never formatted.
|
||||
///
|
||||
/// `issuing_ca` rather than `ca_chain` because it is the one certificate a
|
||||
/// cert-auth role pins: the authority that signed this leaf.
|
||||
fn credential_from_issue(issued: GenerateCertificateResponse) -> Result<mtls::Credential, Error> {
|
||||
for (field, value) in [
|
||||
("certificate", &issued.certificate),
|
||||
("private_key", &issued.private_key),
|
||||
("issuing_ca", &issued.issuing_ca),
|
||||
] {
|
||||
if value.trim().is_empty() {
|
||||
return Err(Error::IncompleteIssue(field));
|
||||
}
|
||||
}
|
||||
Ok(mtls::Credential {
|
||||
cert: issued.certificate,
|
||||
key: issued.private_key,
|
||||
ca: issued.issuing_ca,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -408,4 +474,84 @@ mod tests {
|
|||
the store's copy of the document disagree with its own name"
|
||||
);
|
||||
}
|
||||
|
||||
/// The controller's grant names exactly this path with `update`, which is
|
||||
/// what a POST needs; any other path or verb is a 403.
|
||||
#[test]
|
||||
fn an_issue_request_posts_to_the_roles_issue_path() {
|
||||
use rustify::endpoint::Endpoint as _;
|
||||
|
||||
let request = issue_request("pki-agents", "swarm-agent", "hive-agent-atlas");
|
||||
assert_eq!(request.path(), "pki-agents/issue/swarm-agent");
|
||||
assert!(
|
||||
matches!(request.method(), rustify::enums::RequestMethod::POST),
|
||||
"{:?}",
|
||||
request.method()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_issue_request_asks_for_the_name_and_nothing_the_role_decides() {
|
||||
use rustify::endpoint::Endpoint as _;
|
||||
|
||||
let body = issue_request("pki-agents", "swarm-agent", "hive-agent-atlas")
|
||||
.body()
|
||||
.expect("the body serialises")
|
||||
.expect("an issue request sends one");
|
||||
let sent: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
|
||||
assert_eq!(sent["common_name"], "hive-agent-atlas");
|
||||
assert_eq!(sent["exclude_cn_from_sans"], true);
|
||||
assert_eq!(sent["private_key_format"], "pkcs8");
|
||||
for decided_by_the_role in ["ttl", "alt_names", "ip_sans", "uri_sans", "other_sans"] {
|
||||
assert!(
|
||||
sent.get(decided_by_the_role)
|
||||
.is_none_or(serde_json::Value::is_null),
|
||||
"{decided_by_the_role} is the role's to set: {sent}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn issued() -> GenerateCertificateResponse {
|
||||
GenerateCertificateResponse {
|
||||
ca_chain: None,
|
||||
certificate: "LEAF".to_owned(),
|
||||
expiration: None,
|
||||
issuing_ca: "AGENT-CA".to_owned(),
|
||||
private_key: "SECRET-KEY-BYTES".to_owned(),
|
||||
private_key_type: "ec".to_owned(),
|
||||
serial_number: "01".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_answer_becomes_the_published_credential() {
|
||||
let credential = credential_from_issue(issued()).expect("every field is present");
|
||||
assert_eq!(credential.cert, "LEAF");
|
||||
assert_eq!(credential.key, "SECRET-KEY-BYTES");
|
||||
assert_eq!(credential.ca, "AGENT-CA", "the role pins the issuing CA");
|
||||
assert!(
|
||||
!format!("{credential:?}").contains("SECRET-KEY-BYTES"),
|
||||
"the key must not reach a formatted value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_missing_any_part_of_the_identity_is_refused_by_name() {
|
||||
type Blank = fn(&mut GenerateCertificateResponse);
|
||||
let cases: [(&str, Blank); 3] = [
|
||||
("certificate", |r| r.certificate.clear()),
|
||||
("private_key", |r| r.private_key = " \n".to_owned()),
|
||||
("issuing_ca", |r| r.issuing_ca.clear()),
|
||||
];
|
||||
for (field, blank) in cases {
|
||||
let mut answer = issued();
|
||||
blank(&mut answer);
|
||||
let e = credential_from_issue(answer).expect_err("an incomplete identity");
|
||||
assert!(
|
||||
matches!(e, Error::IncompleteIssue(f) if f == field),
|
||||
"blanking {field} gave {e:?}"
|
||||
);
|
||||
assert!(!e.to_string().contains("SECRET-KEY-BYTES"), "{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use crate::{
|
|||
///
|
||||
/// A flat leaf under the agent's prefix, like its controller-minted siblings
|
||||
/// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so
|
||||
/// the agent's own read stanza ([`crate::policy::render_agent_with_queue`])
|
||||
/// the agent's own read stanza ([`crate::policy::render_agent`])
|
||||
/// already covers it.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
@ -89,7 +89,7 @@ mod tests {
|
|||
// policy is rendered elsewhere. If the path ever moved out from under
|
||||
// it, the agent's fetch would 403 at boot, naming neither.
|
||||
let path = agent_token_path("atlas").expect("legal");
|
||||
let policy = crate::policy::render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
let policy = crate::policy::render_agent("atlas").expect("legal");
|
||||
let covered = policy.lines().any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
|
|
@ -104,7 +104,7 @@ mod tests {
|
|||
// Control for the test above: the prefix match must actually be
|
||||
// discriminating, or it proves nothing.
|
||||
let path = agent_token_path("atlas").expect("legal");
|
||||
let policy = crate::policy::render_agent_with_queue("argus", "pr1ma").expect("legal");
|
||||
let policy = crate::policy::render_agent("argus").expect("legal");
|
||||
let covered = policy.lines().any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
|
|
|
|||
|
|
@ -80,6 +80,11 @@ pub enum Error {
|
|||
/// CA bundle did not parse.
|
||||
#[error("building the TLS identity: {0}")]
|
||||
Tls(#[source] reqwest::Error),
|
||||
|
||||
/// The store answered an issue request with a field empty that a usable
|
||||
/// identity needs. Names the field, never its value.
|
||||
#[error("the store issued a certificate whose {0} is empty")]
|
||||
IncompleteIssue(&'static str),
|
||||
}
|
||||
|
||||
impl From<vaultrs::error::ClientError> for Error {
|
||||
|
|
|
|||
|
|
@ -70,13 +70,12 @@ pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
|||
/// client ids; this is a second identifier family leaning on it, which is why
|
||||
/// the fragment list is what to read before renaming either.
|
||||
///
|
||||
/// ⚠️ The controller's and publisher's subjects are *not* covered by that: their
|
||||
/// policy names are literals outside `hive-`, but their **common names** are
|
||||
/// operator-set options (`deploy.bao.controllerCommonName`,
|
||||
/// `secretPublisherCommonName`) that nothing here can see, and an operator may
|
||||
/// spell one `hive-agent-atlas`. Whichever change first mints an agent leaf
|
||||
/// owes the assertion that neither starts with this prefix — `swarm.nix`'s
|
||||
/// `certAuthCns` is where the mirror-image check for hive names lives.
|
||||
/// Host principals' **common names** are operator-set options
|
||||
/// (`deploy.bao.controllerCommonName`, `secretPublisherCommonName`, …) that
|
||||
/// may spell this prefix, and that is harmless: an agent's cert-auth role pins
|
||||
/// the store's agent CA (`deploy.bao.agentPkiMountPath`), which signs no host
|
||||
/// leaf, and every host role pins `deploy.bao.clientCaFile`, which signs no
|
||||
/// agent leaf. A CN match alone logs nobody in.
|
||||
pub const AGENT_PREFIX: &str = "hive-agent-";
|
||||
|
||||
/// The policy and cert-auth role name for `agent`, and the common name of the
|
||||
|
|
@ -203,43 +202,6 @@ pub fn render_agent(agent: &str) -> Result<String, Error> {
|
|||
)))
|
||||
}
|
||||
|
||||
/// Render `agent`'s policy document: read on that one agent's credentials and
|
||||
/// on the hive's shared queue credential.
|
||||
///
|
||||
/// Extends [`render_agent`] with a second stanza granting read on
|
||||
/// `swarm/hives/<hive>/queue/agent`. The queue credential is **hive-shared,
|
||||
/// not per-agent** — every agent in a hive authenticates to the queue with the
|
||||
/// same client secret (`queue.rs:1-8`), so a policy scoped strictly to
|
||||
/// `agents/<agent>/*` cannot read it and an in-container pull would fail. That
|
||||
/// hive-shared credential is already handed to every agent container on that
|
||||
/// hive by the host today, so this grant adds no new authority — it merely
|
||||
/// makes the existing capability reachable through the agent's own token
|
||||
/// instead of requiring the credential to be delivered out of band.
|
||||
///
|
||||
/// ⚠️ **Every agent in a hive can read that hive's queue credential.** This is
|
||||
/// not new authority (the host already provides this exact value to all agents
|
||||
/// on the hive), but it is a documented property: an agent policy grants read
|
||||
/// on a path shared across every agent on its hive, not on a path unique to
|
||||
/// that agent alone.
|
||||
///
|
||||
/// Read-only, for the same reason [`render_agent`]'s is: an agent that could
|
||||
/// write credentials could hand itself an identity it was never issued.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` or `hive` holds anything but
|
||||
/// `[A-Za-z0-9_-]` — both are interpolated into policy paths, so a name that
|
||||
/// could close a stanza could grant itself anything.
|
||||
pub fn render_agent_with_queue(agent: &str, hive: &str) -> Result<String, Error> {
|
||||
checked_segment("agent", agent)?;
|
||||
let agent_stanza = read_stanza(&format!(
|
||||
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
|
||||
<&str>::from(Kind::Agent)
|
||||
));
|
||||
let queue_path = crate::queue::agent_client_path(hive)?;
|
||||
let queue_stanza = read_stanza(&format!("{MOUNT}/data/{queue_path}"));
|
||||
Ok(format!("{agent_stanza}{queue_stanza}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -430,6 +392,22 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_document_is_exactly_its_own_read_stanza() {
|
||||
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
||||
// second agent) is exactly what a presence check misses.
|
||||
assert_eq!(
|
||||
render_agent("atlas").expect("legal"),
|
||||
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
|
||||
);
|
||||
// The control: the pin discriminates between agents.
|
||||
assert!(
|
||||
!render_agent("other")
|
||||
.expect("legal")
|
||||
.contains("agents/atlas/")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_grant_is_read_only() {
|
||||
// An agent that could write its own credentials could hand itself an
|
||||
|
|
@ -517,102 +495,6 @@ mod tests {
|
|||
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_document_with_queue_grants_both_paths() {
|
||||
// The happy path: the document grants read on the agent's own namespace
|
||||
// and on the hive's queue credential.
|
||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
assert!(
|
||||
p.contains("path \"secret/data/swarm/agents/atlas/*\""),
|
||||
"must grant the agent's own path: {p}"
|
||||
);
|
||||
let expected_queue_path = format!(
|
||||
"path \"{MOUNT}/data/{}\"",
|
||||
crate::queue::agent_client_path("pr1ma").expect("legal")
|
||||
);
|
||||
assert!(
|
||||
p.contains(&expected_queue_path),
|
||||
"must grant the hive's queue credential: {p}"
|
||||
);
|
||||
assert_eq!(
|
||||
p.matches("path \"").count(),
|
||||
2,
|
||||
"two stanzas, one for the agent and one for the queue: {p}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agents_document_with_queue_is_read_only() {
|
||||
// An agent that could write the queue credential could hand every agent
|
||||
// on its hive an identity they were never issued.
|
||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
for capability in ["create", "update", "delete", "list", "sudo", "patch"] {
|
||||
assert!(!p.contains(capability), "must not grant {capability}: {p}");
|
||||
}
|
||||
assert!(p.contains("capabilities = [\"read\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_name_with_traversal_in_the_queue_variant_is_refused() {
|
||||
// The agent parameter is an injection surface in both renderers, so
|
||||
// refusing a traversal here proves the new one validates it.
|
||||
assert!(
|
||||
render_agent_with_queue("atlas/*\" { capabilities = [\"root\"] }", "pr1ma").is_err()
|
||||
);
|
||||
assert!(render_agent_with_queue("", "pr1ma").is_err());
|
||||
// The control: legal names still work.
|
||||
assert!(render_agent_with_queue("a-b_C9", "pr1ma").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hive_name_with_traversal_in_the_queue_variant_is_refused() {
|
||||
// The hive parameter is a second injection surface that only the queue
|
||||
// variant introduces, so this test proves that new parameter is
|
||||
// validated. A name that could close the stanza could grant the agent
|
||||
// anything.
|
||||
assert!(
|
||||
render_agent_with_queue("atlas", "pr1ma/*\" { capabilities = [\"root\"] }").is_err()
|
||||
);
|
||||
assert!(render_agent_with_queue("atlas", "").is_err());
|
||||
assert!(
|
||||
render_agent_with_queue("atlas", "../services/swarm-grafana").is_err(),
|
||||
"a path traversal that could reach a different kind"
|
||||
);
|
||||
// The control: legal names still work.
|
||||
assert!(render_agent_with_queue("atlas", "a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_queue_variant_does_not_widen_the_agent_stanza() {
|
||||
// The queue grant must not cause the agent stanza to widen from
|
||||
// `agents/<agent>/*` to `agents/*` — that would give every agent every
|
||||
// other agent's credentials.
|
||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
assert!(
|
||||
!p.contains("swarm/agents/*"),
|
||||
"must not grant the whole agent prefix: {p}"
|
||||
);
|
||||
assert!(p.contains("swarm/agents/atlas/*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_queue_variant_does_not_grant_the_whole_hive_prefix() {
|
||||
// The queue stanza must grant only the queue credential path, not
|
||||
// `hives/<hive>/*` — the latter would give the agent read on every
|
||||
// secret of the hive that hosts it.
|
||||
let p = render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
assert!(
|
||||
!p.contains("swarm/hives/*"),
|
||||
"must not grant the whole hive prefix: {p}"
|
||||
);
|
||||
assert!(
|
||||
!p.contains("swarm/hives/pr1ma/*"),
|
||||
"must not grant the hive's whole path: {p}"
|
||||
);
|
||||
let expected_queue_path = crate::queue::agent_client_path("pr1ma").expect("legal");
|
||||
assert!(p.contains(&expected_queue_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_agent_roles_come_back_and_without_their_prefix() {
|
||||
let roles: Vec<String> = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue