From 03796f196474b48f3d101d5f39fb6f1be603e221 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 26 Oct 2026 08:57:24 +0100 Subject: [PATCH] add one shared templated hive policy keyed on the cert alias name --- docs/swarm/secrets.md | 12 +++ nix/module-eval/bao-grants.nix | 16 ++++ swarm-controller/src/read_policy.rs | 116 +++++++++++++++++++++++++--- swarm-secret-client/src/policy.rs | 100 +++++++++++++++++++++++- 4 files changed, 232 insertions(+), 12 deletions(-) diff --git a/docs/swarm/secrets.md b/docs/swarm/secrets.md index efa984b7..04fe931c 100644 --- a/docs/swarm/secrets.md +++ b/docs/swarm/secrets.md @@ -398,6 +398,18 @@ keeps working through its own policy, which stays attached until every agent has its entity. An agent's role gains `hive-agent` at its next certificate mint. +A hive's cert-auth role `hive-` likewise attaches a second policy, +`hive-hives`, shared by all hives. A hive's certificate carries its bare name as +the common name, which is the login's alias name, so hives need no entity: +the policy states the hive's own path as +`swarm/hives/{{identity.entity.aliases..name}}/*`, where `` +is the cert mount's accessor. The controller reads the accessor at its own start, +writes the policy, and attaches it when it rewrites the roles. No hive name can +spell `hive-hives`: it would be a hive called `hives`, and +`reserved-hive-fragments.nix` refuses the substring `hive`. When the controller +can't read the accessor or write the policy it logs a warning and the role +attaches the hive's own policy alone, which stays attached. + ### Per-principal identities Bao matches a cert-auth role on the certificate's subject, so a certificate is an diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 6ea52166..b92c2487 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -1591,6 +1591,22 @@ let # that the walk reads nothing. && pathsUnder "sys/policies/acl/" == [ "path \"sys/policies/acl/hive-*\" {" ]; } + { + # `read_policy.rs` writes the policy every hive's role attaches as + # `hive-hives` and the one every agent's role attaches as `hive-agent`. Both + # sit under the policy glob the controller already holds, so neither + # adds a grant. + name = "the controller's policy-write glob covers the shared hive and agent policies and not a name outside hive-*"; + ok = + let + s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script; + globs = map (l: lib.removeSuffix "\" {" (lib.removePrefix "path \"" l)) ( + lib.filter (l: lib.hasPrefix "path \"sys/policies/acl/" l) (map lib.trim (lib.splitString "\n" s)) + ); + covers = name: lib.any (g: lib.hasPrefix (lib.removeSuffix "*" g) "sys/policies/acl/${name}") globs; + in + covers "hive-hives" && covers "hive-agent" && !(covers "swarm-hives"); + } { # `matrix_account::hive_sender` writes every hive's sender token and # `forge::hive_token` every hive's forge token, and nothing else under diff --git a/swarm-controller/src/read_policy.rs b/swarm-controller/src/read_policy.rs index 0dbaf69e..dea5eff1 100644 --- a/swarm-controller/src/read_policy.rs +++ b/swarm-controller/src/read_policy.rs @@ -16,10 +16,11 @@ //! //! The document names the hive it is written for: one stanza is the same //! everywhere, the other is scoped to the reader's own name -//! ([`swarm_secret_client::policy`] explains the asymmetry). There is no shared -//! document to hoist this render up to — doing that hands every hive the stanza -//! naming one of them. The hive list is loaded once because a config change -//! means a redeploy. +//! ([`swarm_secret_client::policy`] explains the asymmetry). The same rule is +//! also written once for all hives, [`policy::SHARED_HIVE_POLICY`], with the +//! reader's own name taken from its login's alias on the cert mount instead of +//! written into the text; a hive's role attaches both. The hive list is loaded +//! once because a config change means a redeploy. //! //! Every agent's policy is rewritten by the same kind of pass //! ([`ensure_agent_policies`]), so a change to `policy::render_agent` reaches @@ -158,6 +159,7 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> { tracing::warn!(hive, error = %format!("{e:#}"), "writing this hive's read policy failed"); } } + let shared = write_shared_policy(&store).await; 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"); @@ -169,7 +171,7 @@ async fn provision(hives: &[String]) -> Result<(), Unreachable> { let ca = std::fs::read_to_string(&path) .map_err(|e| Unreachable(format!("reading the hive authority {path}: {e}")))?; for hive in hives { - if let Err(e) = write_role(&store, hive, &ca).await { + if let Err(e) = write_role(&store, hive, &ca, shared).await { tracing::warn!(hive, error = %format!("{e:#}"), "creating this hive's store role failed"); } } @@ -187,13 +189,78 @@ async fn write_policy_for(store: &SecretStore, hive: &str) -> Result<()> { Ok(()) } -/// The role for one hive: its own certificate's subject, its own policy. -async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> { +/// Write [`policy::SHARED_HIVE_POLICY`], rendered with the cert mount's +/// accessor. Answers whether it is in place: a hive's role attaches it only +/// then. +/// +/// Best effort. A hive's own policy grants it the same paths, so a store that +/// refuses the accessor read (the controller's policy may not carry it yet) or +/// the write costs the shared policy and nothing else. +async fn write_shared_policy(store: &SecretStore) -> bool { + let accessor = match store.auth_mount_accessor(DEFAULT_CERT_MOUNT).await { + Ok(accessor) => accessor, + Err(e) => { + tracing::warn!( + error = %e, + "the shared hive policy is not managed: the cert auth mount's accessor could not be read; hives keep their own policy" + ); + return false; + } + }; + let document = match policy::render_shared_hives(&accessor) { + Ok(document) => document, + Err(e) => { + tracing::warn!(error = %e, "the shared hive policy is not managed: the cert auth mount's accessor is not usable in a policy; hives keep their own policy"); + return false; + } + }; + match store + .write_policy(policy::SHARED_HIVE_POLICY, &document) + .await + { + Ok(()) => { + tracing::info!( + policy = policy::SHARED_HIVE_POLICY, + "shared hive policy in place" + ); + true + } + Err(e) => { + tracing::warn!( + policy = policy::SHARED_HIVE_POLICY, + error = %e, + "writing the shared hive policy failed; hives keep their own policy" + ); + false + } + } +} + +/// The policies `hive`'s role attaches: its own, and the shared one when that +/// is in place. +fn attached_policies(hive: &str, shared: bool) -> Result, Error> { + let mut attached = vec![policy::hive_object_name(hive)?]; + if shared { + attached.push(policy::SHARED_HIVE_POLICY.to_owned()); + } + Ok(attached) +} + +/// The role for one hive: its own certificate's subject, its own policy, and +/// the shared one when `shared`. +async fn write_role(store: &SecretStore, hive: &str, ca: &str, shared: bool) -> Result<()> { let name = policy::hive_object_name(hive)?; + let attached = attached_policies(hive, shared)?; 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]) + .write_cert_role( + DEFAULT_CERT_MOUNT, + &name, + ca, + hive, + &attached.iter().map(String::as_str).collect::>(), + ) .await .with_context(|| format!("writing the cert-auth role {name}"))?; tracing::info!(hive, role = %name, "hive store role in place"); @@ -314,8 +381,8 @@ mod tests { use std::{cell::Cell, time::Duration}; use super::{ - RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies, policy, - retry_until_ok, + RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies, + attached_policies, policy, retry_until_ok, }; #[test] @@ -378,4 +445,33 @@ mod tests { ); assert_eq!(written[1].1, policy::render_agent("argus").expect("legal")); } + + #[test] + fn a_hives_role_attaches_its_own_policy_and_the_shared_one() { + assert_eq!( + attached_policies("pr1ma", true).expect("legal"), + ["hive-pr1ma", policy::SHARED_HIVE_POLICY] + ); + } + + #[test] + fn without_the_shared_policy_the_role_keeps_the_hives_own() { + assert_eq!( + attached_policies("pr1ma", false).expect("legal"), + ["hive-pr1ma"] + ); + } + + #[test] + fn the_shared_text_serves_every_hive_what_its_own_policy_does() { + let shared = policy::render_shared_hives("auth_cert_0a1b2c3d").expect("legal accessor"); + let template = "{{identity.entity.aliases.auth_cert_0a1b2c3d.name}}"; + for hive in ["pr1ma", "beta", "gamma-2", "delta_3"] { + assert_eq!( + shared.replace(template, hive), + policy::render(hive).expect("legal"), + "{hive}" + ); + } + } } diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index 3e3e7635..d3a99688 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -151,15 +151,49 @@ fn list_stanza(path: &str) -> String { /// stanza could grant itself anything. pub fn render(hive: &str) -> Result { checked_segment("hive", hive)?; + Ok(hive_document(hive)) +} + +/// The text of [`render`] with `own` as the hive segment: a checked hive name, +/// or the template [`render_shared_hives`] puts there. +fn hive_document(own: &str) -> String { let agents = read_stanza(&format!( "{MOUNT}/data/{ROOT}/{}/*", <&str>::from(Kind::Agent) )); let own = read_stanza(&format!( - "{MOUNT}/data/{ROOT}/{}/{hive}/*", + "{MOUNT}/data/{ROOT}/{}/{own}/*", <&str>::from(Kind::Hive) )); - Ok(format!("{agents}{own}")) + format!("{agents}{own}") +} + +/// The one policy every hive's cert-auth role attaches next to its own. +/// +/// Inside [`HIVE_PREFIX`] so that the controller may write it. It is the object +/// name of a hive called `hives`, and `nix/reserved-hive-fragments.nix` forbids +/// the substring `hive` in every hive name, so no hive spells it. +pub const SHARED_HIVE_POLICY: &str = "hive-hives"; + +/// Render the policy [`SHARED_HIVE_POLICY`] holds: the text of [`render`], with +/// the hive's name taken from the alias of its certificate login on the cert +/// auth mount `mount_accessor`. +/// +/// A hive's certificate carries its bare name as the common name, and that is +/// the alias name, so the own-path stanza reads `swarm/hives//` for +/// whichever hive presents the token. A login whose alias is not a hive's name +/// reads a path where nothing is stored. The `agents/` and `hives/` segments +/// are fixed text, never taken from the identity. +/// +/// # Errors +/// [`Error::PathSegment`] when `mount_accessor` holds anything but +/// `[A-Za-z0-9_-]`: it is interpolated into the policy text, where it could +/// close the stanza. +pub fn render_shared_hives(mount_accessor: &str) -> Result { + checked_segment("mount accessor", mount_accessor)?; + Ok(hive_document(&format!( + "{{{{identity.entity.aliases.{mount_accessor}.name}}}}" + ))) } /// Render `agent`'s policy document: read on that one agent's credentials, list @@ -286,6 +320,68 @@ mod tests { ); } + #[test] + fn the_shared_hive_document_is_each_hives_own_with_the_alias_name_for_the_hive() { + let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor"); + assert_eq!( + shared, + "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\ + path \"secret/data/swarm/hives/{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}/*\" {\n capabilities = [\"read\"]\n}\n" + ); + // Same text as every hive's own document once the template is filled + // in, for hives that differ in length, case and punctuation. + for hive in ["pr1ma", "a", "Mixed_Case-1", "x".repeat(63).as_str()] { + assert_eq!( + shared.replace("{{identity.entity.aliases.auth_cert_1a2b3c4d.name}}", hive), + render(hive).expect("legal"), + "{hive}" + ); + } + } + + #[test] + fn the_shared_hive_document_names_no_hive_and_grants_no_wider_than_one() { + let shared = render_shared_hives("auth_cert_1a2b3c4d").expect("legal accessor"); + assert!(!shared.contains("pr1ma")); + assert!(!shared.contains("hives/*"), "{shared}"); + assert!(!shared.contains("create")); + assert!(!shared.contains("update")); + assert!(!shared.contains("delete")); + assert!(!shared.contains("list")); + // The control: the walk finds the stanzas that are there. + assert_eq!(stanza_paths(&shared).len(), 2, "{shared}"); + assert!( + stanza_paths(&shared) + .iter() + .any(|g| g.starts_with("swarm/hives/{{")) + ); + } + + #[test] + fn an_accessor_that_could_close_the_stanza_is_refused() { + for bad in ["", "a.b", "a}}b", "a\"b", "a/b", "a b"] { + assert!( + matches!(render_shared_hives(bad), Err(Error::PathSegment { .. })), + "{bad:?}" + ); + } + } + + #[test] + fn the_shared_hive_policy_name_is_writable_and_no_agents() { + assert!(SHARED_HIVE_POLICY.starts_with(HIVE_PREFIX)); + assert!( + agents_from_role_names(&[SHARED_HIVE_POLICY.to_owned()]).is_empty(), + "it must not be read back as an agent" + ); + assert_ne!(SHARED_HIVE_POLICY, SHARED_AGENT_POLICY); + // The only hive name that would render it carries `hive`. + assert_eq!( + hive_object_name("hives").expect("legal as a segment"), + SHARED_HIVE_POLICY + ); + } + #[test] fn a_hive_reaches_no_swarm_services_secret() { // A swarm service's OIDC client secret is read under a certificate that