diff --git a/swarm-secret-client/src/lib.rs b/swarm-secret-client/src/lib.rs index a146e0cd..d54d5d68 100644 --- a/swarm-secret-client/src/lib.rs +++ b/swarm-secret-client/src/lib.rs @@ -8,6 +8,11 @@ //! ([`matrix`]). 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 hive's own token may read. 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. @@ -15,6 +20,7 @@ pub mod client; pub mod matrix; pub mod path; +pub mod policy; pub use client::SecretStore; diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs new file mode 100644 index 00000000..7d6af585 --- /dev/null +++ b/swarm-secret-client/src/policy.rs @@ -0,0 +1,146 @@ +//! The read agreement: which credentials a hive's own token may fetch. +//! +//! The mirror of [`crate::matrix`]. That module says where a credential lives; +//! this one says who is allowed to read it, and the two have to agree on the +//! same path or a delivery fails with a 403 that names nothing. +//! +//! Rendering is separate from writing on purpose: the text is a pure function +//! of a hive name and its agent set, so the shape that matters can be asserted +//! without a store to talk to. + +use std::fmt::Write as _; + +use crate::{ + Error, + path::{AGENT_PREFIX, MOUNT, checked_segment}, +}; + +/// Namespace for a hive's own policy and cert-auth role. +/// +/// The controller's own grant is scoped to `hive-*` for both, so this prefix is +/// the difference between a hive the controller may provision and a policy it +/// must not be able to rewrite — including its own. +pub const HIVE_PREFIX: &str = "hive-"; + +/// The policy and cert-auth role name for `hive`. One name, both objects: the +/// role attaches the policy by spelling it identically. +/// +/// # Errors +/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`. +pub fn hive_object_name(hive: &str) -> Result { + checked_segment("hive", hive)?; + Ok(format!("{HIVE_PREFIX}{hive}")) +} + +/// Render the policy granting `hive` read on exactly the agents it hosts. +/// +/// One stanza per agent rather than a prefix grant: an agent's credential path +/// does not name the hive hosting it (agents migrate), so "this hive's agents" +/// has no prefix expression and has to be enumerated. +/// +/// An empty `agents` renders an empty policy, which grants nothing. That is the +/// correct reading of a hive with no agents, and it fails closed. +/// +/// # Errors +/// [`Error::PathSegment`] when `hive` or any agent name holds anything but +/// `[A-Za-z0-9_-]` — which is what stops a name from closing the stanza and +/// opening a wider one. +pub fn render(hive: &str, agents: &[&str]) -> Result { + checked_segment("hive", hive)?; + let mut out = String::new(); + for agent in agents { + checked_segment("agent", agent)?; + let _ = writeln!( + out, + "path \"{MOUNT}/data/{AGENT_PREFIX}/{agent}/*\" {{\n capabilities = [\"read\"]\n}}" + ); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_agent_renders_one_read_stanza_under_the_agent_prefix() { + let p = render("pr1ma", &["atlas"]).expect("both segments are legal"); + assert_eq!( + p, + "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n" + ); + } + + #[test] + fn every_hosted_agent_gets_its_own_stanza_and_nothing_else_does() { + let p = render("pr1ma", &["atlas", "iris"]).expect("legal"); + assert_eq!(p.matches("path \"").count(), 2, "one stanza per agent"); + assert!(p.contains("/atlas/*")); + assert!(p.contains("/iris/*")); + assert!(!p.contains("argus"), "an agent not passed is not granted"); + } + + #[test] + fn the_grant_is_read_only_and_never_a_prefix_over_all_agents() { + // Both halves of what makes this a least-privilege policy rather than + // the broad grant that was considered and rejected. + let p = render("pr1ma", &["atlas"]).expect("legal"); + assert!(!p.contains("create")); + assert!(!p.contains("update")); + assert!(!p.contains("delete")); + assert!( + !p.contains(&format!("{MOUNT}/data/{AGENT_PREFIX}/*")), + "a wildcard directly under the agent prefix would grant every agent" + ); + } + + #[test] + fn a_name_cannot_close_the_stanza_and_open_a_wider_one() { + // The reason `checked_segment` runs before the name reaches HCL: these + // are policy injection, not path traversal, and a `contains("..")` + // check catches none of them. + for bad in [ + "atlas/*\" { capabilities = [\"root\"] }\npath \"secret/data", + "*", + "../argus", + "a b", + "", + ] { + assert!( + render("pr1ma", &[bad]).is_err(), + "agent name {bad:?} must be refused" + ); + assert!( + render(bad, &["atlas"]).is_err(), + "hive name {bad:?} must be refused" + ); + } + } + + #[test] + fn the_legal_charset_is_actually_reachable() { + // The control for the case above: if every name were refused, that test + // would pass while proving nothing. + assert!(render("a-b_C9", &["d-e_F0"]).is_ok()); + assert!(hive_object_name("a-b_C9").is_ok()); + } + + #[test] + fn a_hive_with_no_agents_grants_nothing() { + let p = render("pr1ma", &[]).expect("a hive may legitimately host none"); + assert!( + p.is_empty(), + "no stanza means no capability, which is closed" + ); + } + + #[test] + fn the_object_name_sits_inside_the_namespace_the_controller_may_write() { + // `hive-` is what the controller's own policy scopes both + // `sys/policies/acl/` and `auth/cert/certs/` to, so a name outside it + // is one the controller cannot create at all. + let n = hive_object_name("pr1ma").expect("legal"); + assert_eq!(n, "hive-pr1ma"); + assert!(n.starts_with(HIVE_PREFIX)); + } +}