diff --git a/docs/swarm/secrets.md b/docs/swarm/secrets.md index 9be8f5bf..8e4df7a5 100644 --- a/docs/swarm/secrets.md +++ b/docs/swarm/secrets.md @@ -332,7 +332,10 @@ store, and `swarm-controller`, already logged in under its own host leaf, asks that mount's one role for a `hive-agent-` client certificate at agent creation. Host roles never pin that CA and agent roles pin only it, so an agent's certificate opens that agent's own `swarm/agents//*` and -nothing else. +nothing else: `read` on the values there and `list` on their names. +`swarm-controller` writes that policy at agent creation and rewrites every +agent's at its own start, so a change to it reaches existing agents with the +next controller restart. ### Per-principal identities diff --git a/swarm-controller/src/agent_identity.rs b/swarm-controller/src/agent_identity.rs index c36f1e6b..c1396a02 100644 --- a/swarm-controller/src/agent_identity.rs +++ b/swarm-controller/src/agent_identity.rs @@ -118,8 +118,8 @@ pub(crate) fn generate_queue_secret() -> Result { /// 3. publish a queue secret at [`queue::agent_queue_path`] — the agent's own /// identity at the swarm queue, minted here so that the credential an agent /// presents names *it* rather than its hive; -/// 4. write the ACL document [`policy::render_agent`] renders — read on this -/// one agent's paths and nothing else; +/// 4. write the ACL document [`policy::render_agent`] renders — read and list on +/// this one agent's paths and nothing else; /// 5. write the cert-auth role that ties the three together, pinning the CA /// the store named as this leaf's issuer. /// diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 42f1e4bd..2c978a8c 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -2836,6 +2836,9 @@ async fn main() -> Result<()> { // abandoned, since the two of them boot together. let hive_names: Vec = hives.iter().map(|h| h.name.clone()).collect(); read_policy::ensure_hive_access(hive_names).await; + // Agents keep the policy they were minted with until this rewrites it, so + // what `policy::render_agent` grants today reaches them only from here. + read_policy::ensure_agent_policies().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 ac759db3..533c49fb 100644 --- a/swarm-controller/src/read_policy.rs +++ b/swarm-controller/src/read_policy.rs @@ -20,6 +20,11 @@ //! 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. +//! +//! Every agent's policy is rewritten by the same kind of pass +//! ([`ensure_agent_policies`]), so a change to `policy::render_agent` reaches +//! agents minted before it. Its roster is the store's `hive-agent-*` cert-auth +//! roles. use std::{future::Future, time::Duration}; @@ -192,11 +197,106 @@ async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> { Ok(()) } +/// Rewrite every agent's policy from [`policy::render_agent`], with the retry +/// [`ensure_hive_access`] has and for the same reason. +/// +/// Returns once the first pass is done. When that pass cannot reach the store, +/// it repeats in the background for up to [`RETRY_WINDOW`]. +pub async fn ensure_agent_policies() { + if let Err(Unreachable(reason)) = rewrite_agent_policies().await { + tracing::warn!( + reason, + retry_in = ?RETRY_INTERVAL, + "agent policy rewrite could not reach the swarm secret store; retrying in the background" + ); + tokio::spawn(async { + let reached = retry_until_ok(RETRY_INTERVAL, RETRY_ATTEMPTS, || async { + let outcome = rewrite_agent_policies().await; + if let Err(Unreachable(reason)) = &outcome { + tracing::debug!(reason, "agent policy rewrite: store still unreachable"); + } + outcome + }) + .await; + if let Some(attempt) = reached { + tracing::info!(attempt, "agent policy rewrite reached the store"); + } else { + tracing::warn!( + attempts = RETRY_ATTEMPTS, + window = ?RETRY_WINDOW, + "giving up on the agent policy rewrite; agents keep the policy they were minted with until this daemon is restarted" + ); + } + }); + } +} + +/// One pass: log in once, list the agents, write each one's policy. +/// +/// The policy only. An agent's cert-auth role and certificate are +/// `agent_identity::mint_and_verify`'s, and that reissues the certificate, so +/// it is not what a pass on every start may call. +/// +/// # Errors +/// [`Unreachable`] when the store cannot be reached or will not list its +/// roles: either way no agent was written, and a later attempt may get +/// further. A deployment with no store configured is `Ok`. +async fn rewrite_agent_policies() -> Result<(), Unreachable> { + let store = match crate::store::connect().await { + Ok(store) => store, + Err(Error::MissingEnv(var)) => { + tracing::info!( + var, + "no secret store configured; agent policies are not managed here" + ); + return Ok(()); + } + Err(e) => return Err(Unreachable(e.to_string())), + }; + let roles = store + .list_cert_roles(DEFAULT_CERT_MOUNT) + .await + .map_err(|e| Unreachable(format!("listing the store's cert-auth roles: {e}")))?; + let policies = agent_policies(&roles); + let mut written = 0_usize; + for (name, document) in &policies { + match store.write_policy(name, document).await { + Ok(()) => written += 1, + Err(e) => { + tracing::warn!(policy = %name, error = %e, "rewriting this agent's policy failed"); + } + } + } + tracing::info!(written, agents = policies.len(), "agent policies rewritten"); + Ok(()) +} + +/// The policy to write for each agent among `roles`, a listing of cert-auth +/// roles: its name, which is the role's own, and its document. +/// +/// A role that is not an agent's — a hive's `hive-`, a host's — gets +/// nothing: the same `write_policy` call with an agent's document would +/// narrow that principal to one agent's subtree. +fn agent_policies(roles: &[String]) -> Vec<(String, String)> { + policy::agents_from_role_names(roles) + .iter() + .filter_map(|agent| { + Some(( + policy::agent_object_name(agent).ok()?, + policy::render_agent(agent).ok()?, + )) + }) + .collect() +} + #[cfg(test)] mod tests { use std::{cell::Cell, time::Duration}; - use super::{RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, retry_until_ok}; + use super::{ + RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies, policy, + retry_until_ok, + }; #[test] fn the_retry_bound_is_the_window_it_claims() { @@ -235,4 +335,27 @@ mod tests { assert_eq!(reached, None, "a store that never appears is given up on"); assert_eq!(calls.get(), 5, "one attempt per interval, and no more"); } + + #[test] + fn the_pass_writes_agents_policies_under_their_role_names_and_no_one_elses() { + let roles: Vec = [ + "hive-agent-atlas", + "hive-pr1ma", + "swarm-controller", + "hive-agent-argus", + ] + .map(str::to_owned) + .to_vec(); + let written = agent_policies(&roles); + let names: Vec<&str> = written.iter().map(|(name, _)| name.as_str()).collect(); + // The role names an agent's policy by its own name, so a policy written + // under any other name is one the role does not attach. + assert_eq!(names, ["hive-agent-atlas", "hive-agent-argus"]); + assert_eq!( + written[0].1, + policy::render_agent("atlas").expect("legal"), + "the prefix is stripped before rendering, or the document names `agent-atlas`" + ); + assert_eq!(written[1].1, policy::render_agent("argus").expect("legal")); + } } diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index febbd9d6..b17c3fec 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -114,12 +114,19 @@ pub fn agents_from_role_names(names: &[String]) -> Vec { .collect() } -/// One read stanza. The only shape this module emits, so "read-only" is a -/// property of the renderer rather than of each call site. +/// One read stanza. With [`list_stanza`], the only shapes this module emits, so +/// "writes nothing" is a property of the renderers rather than of each call +/// site. fn read_stanza(path: &str) -> String { format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n") } +/// One list stanza: the key names under a `metadata/` path. Neither a value +/// nor the metadata itself, which take `read` on `data/` and on `metadata/`. +fn list_stanza(path: &str) -> String { + format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n") +} + /// Render `hive`'s policy document: read on every agent's credentials, on this /// hive's own, and on the swarm services'. /// @@ -166,15 +173,16 @@ pub fn render(hive: &str) -> Result { Ok(format!("{agents}{own}{services}")) } -/// Render `agent`'s policy document: read on that one agent's credentials, and -/// on nothing else at all. +/// Render `agent`'s policy document: read on that one agent's credentials, list +/// on their names (how `hive-matrix-daemon` finds its linked accounts), and +/// nothing else at all. /// -/// One stanza, and the single interpolated name in it is the whole document: an -/// agent authenticating with its own certificate gets a token that fetches -/// `swarm/agents//…` and is refused every other path in the store. That -/// is the point — the reason to give an agent an identity is that it then -/// depends on its hive for one file (the certificate) rather than for every -/// credential it uses. +/// Two stanzas over one subtree, and the single interpolated name is the whole +/// document: an agent authenticating with its own certificate gets a token that +/// fetches `swarm/agents//…`, lists the keys under it, and is refused +/// every other path in the store. That is the point — the reason to give an +/// agent an identity is that it then depends on its hive for one file (the +/// certificate) rather than for every credential it uses. /// /// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants /// read on `swarm/agents/*` — *every* agent's credentials, not the ones that @@ -185,23 +193,22 @@ pub fn render(hive: &str) -> Result { /// no business with a service's OIDC secret, another agent's credentials, a /// hive's, or the controller's. Narrow here does **not** narrow the hive's: a /// hive still reads this agent's secrets, and closing that is its own decision. -/// Read-only, for the reason the hive's is: an agent that could write its own -/// credentials could hand itself an identity it was never issued. +/// Nothing here writes, for the reason the hive's does not: an agent that could +/// write its own credentials could hand itself an identity it was never issued. /// -/// ⚠️ Unlike [`render`]'s, this name is not deploy-time at every layer: nothing -/// in `nix/host-modules/` knows which agents exist — hive-c0re creates them at -/// runtime into its own meta flake (`hive_c0re::meta`). +/// ⚠️ Not deploy-time: hive-c0re creates agents at runtime (`hive_c0re::meta`). +/// swarm-controller rewrites every agent's document at its own start, so an +/// edit here reaches existing agents with the next controller restart. /// /// # Errors -/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]` — it -/// is interpolated into a policy path, so a name that could close the stanza -/// could grant itself anything. +/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`: the +/// name is interpolated into the policy text, where it could close the stanza. pub fn render_agent(agent: &str) -> Result { checked_segment("agent", agent)?; - Ok(read_stanza(&format!( - "{MOUNT}/data/{ROOT}/{}/{agent}/*", - <&str>::from(Kind::Agent) - ))) + let kind = <&str>::from(Kind::Agent); + let read = read_stanza(&format!("{MOUNT}/data/{ROOT}/{kind}/{agent}/*")); + let list = list_stanza(&format!("{MOUNT}/metadata/{ROOT}/{kind}/{agent}/*")); + Ok(format!("{read}{list}")) } #[cfg(test)] @@ -349,26 +356,30 @@ mod tests { assert!(n.starts_with(HIVE_PREFIX)); } + /// What `render_agent("atlas")` must render, byte for byte. + const ATLAS_DOCUMENT: &str = "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n\ + path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n"; + #[test] - fn an_agents_document_is_one_stanza_naming_that_agent() { + fn an_agents_document_is_two_stanzas_naming_that_agent() { assert_eq!( render_agent("atlas").expect("a plain name is legal"), - "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n" + ATLAS_DOCUMENT ); } #[test] fn an_agents_document_does_not_grant_the_whole_agent_prefix() { - // The arm the whole change exists for. `render`'s agent stanza is - // `agents/*` on purpose, and inheriting one character of that here - // would give every agent every other agent's credentials while the - // document still read as per-agent. + // `render`'s agent stanza is `agents/*` on purpose, and inheriting one + // character of that here would give every agent every other agent's + // credentials while the document still read as per-agent. let p = render_agent("atlas").expect("legal"); assert!( !p.contains("swarm/agents/*"), "the agent stanza must not widen to the kind: {p}" ); - assert!(p.contains("swarm/agents/atlas/*")); + assert!(p.contains("secret/data/swarm/agents/atlas/*")); + assert!(p.contains("secret/metadata/swarm/agents/atlas/*")); } #[test] @@ -389,19 +400,16 @@ mod tests { assert!(!p.contains("argus"), "no other principal is named"); assert_eq!( p.matches("path \"").count(), - 1, - "one stanza, or the document grants something unaccounted for: {p}" + 2, + "two stanzas, or the document grants something unaccounted for: {p}" ); } #[test] - fn an_agents_document_is_exactly_its_own_read_stanza() { + fn an_agents_document_is_exactly_its_own_two_stanzas() { // 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" - ); + assert_eq!(render_agent("atlas").expect("legal"), ATLAS_DOCUMENT); // The control: the pin discriminates between agents. assert!( !render_agent("other") @@ -411,18 +419,42 @@ mod tests { } #[test] - fn an_agents_grant_is_read_only() { + fn an_agents_grant_writes_nothing() { // An agent that could write its own credentials could hand itself an // identity it was never issued — and `create`/`update` on that path is // exactly what the controller holds, so the wall is the capability. let p = render_agent("atlas").expect("legal"); - for capability in ["create", "update", "delete", "list", "sudo", "patch"] { + for capability in ["create", "update", "delete", "sudo", "patch"] { assert!( !p.contains(capability), "an agent's document must not grant {capability}: {p}" ); } - assert!(p.contains("capabilities = [\"read\"]")); + } + + #[test] + fn an_agent_reads_values_and_lists_names_and_nothing_crosswise() { + // `read` on `data/` is the values; `list` on `metadata/` is the key + // names. `read` on `metadata/` (version history, deletion times) is not + // granted, nor is `list` anywhere but `metadata/`. + let p = render_agent("atlas").expect("legal"); + let stanzas: Vec<&str> = p.split_inclusive("}\n").collect(); + assert_eq!(stanzas.len(), 2, "{p}"); + for stanza in stanzas { + let on_data = stanza.starts_with("path \"secret/data/"); + let on_metadata = stanza.starts_with("path \"secret/metadata/"); + assert!(on_data != on_metadata, "{stanza}"); + assert_eq!( + stanza.contains("capabilities = [\"read\"]"), + on_data, + "{stanza}" + ); + assert_eq!( + stanza.contains("capabilities = [\"list\"]"), + on_metadata, + "{stanza}" + ); + } } #[test]