//! The read agreement: which credentials a principal's own token may fetch. //! //! The mirror of [`crate::matrix`] and [`crate::queue`]. Those modules say //! 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. //! //! The document has one stanza per kind a hive reads, and they are not all //! scoped alike — which is the point rather than an inconsistency: //! //! ⚠️ The **agent** stanza grants read on *every* agent's credentials rather //! than on the ones that hive hosts. That is a decision, not an oversight: an //! agent's path does not name its hive, so a per-hive grant has to be //! enumerated and re-emitted, and an enumeration that can silently drift //! advertises a boundary it does not hold. A wide grant that says so beats a //! narrow one that only looks narrow. The narrower shapes, and what they would //! cost, are in `docs/trust-boundary/security.md`. //! //! ⚠️ The **service** stanza is wide for the same shape of reason: a swarm //! service's client is registered once per swarm, so its path names the service //! and never the host, and which hive runs a service is a `deploy.*` fact with //! no swarm-wide spelling to scope against. Cost: the same doc. //! //! The **hive** stanza has no such problem and is therefore narrow: that path //! names its principal, so scoping it to the reader's own name costs nothing //! and drifts nowhere. Do not widen it to match its neighbours — the asymmetry //! is the point. //! //! Rendering stays separate from writing so the text can be asserted with no //! store to talk to. use crate::{ Error, path::{Kind, MOUNT, ROOT, 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}")) } /// Namespace for an agent's own policy and cert-auth role. /// /// Deliberately *inside* [`HIVE_PREFIX`] rather than beside it. /// `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole of what /// the controller's grant lets it create (`swarm-bao.nix`'s /// `controllerPolicyText`), and the controller is the only principal that /// learns an agent exists — a name outside that prefix is one nothing can /// write. It costs no authority: the controller already holds `create`/`update` /// on `secret/data/swarm/agents/*`, so it can already replace every credential /// this policy grants read on. /// /// It still cannot collide with a hive's. [`hive_object_name`] renders /// `hive-`, so a collision needs a hive named `agent-` — and /// `nix/reserved-hive-fragments.nix` forbids the substring `agent` in any hive /// name (and `hive` too), applied to every entry of the swarm directory in /// `swarm.nix`. That guard was written for the queue's `hive--agent` /// 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. pub const AGENT_PREFIX: &str = "hive-agent-"; /// The policy and cert-auth role name for `agent`, and the common name of the /// certificate that authenticates it. One string, three objects: the role /// attaches the policy and matches the subject by spelling all three the same. /// /// Injective in `agent` — the prefix is fixed and [`checked_segment`] has /// already refused anything that could re-punctuate the suffix — and agent /// names are one swarm-wide namespace (an agent's path is /// `swarm/agents/`, with no hive segment to disambiguate two that /// matched), so two agents cannot land on one name. /// /// # Errors /// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`. pub fn agent_object_name(agent: &str) -> Result { checked_segment("agent", agent)?; Ok(format!("{AGENT_PREFIX}{agent}")) } /// One read stanza. The only shape this module emits, so "read-only" is a /// property of the renderer rather than of each call site. fn read_stanza(path: &str) -> String { format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n") } /// Render `hive`'s policy document: read on every agent's credentials, on this /// hive's own, and on the swarm services'. /// /// The name is the only input, and it is deploy-time — so the document is /// still a deploy-time object rather than derived state with a re-emission to /// get wrong. Nothing about which agents exist changes the text. /// /// Read-only: the controller mints these and never reads one back. /// /// ⚠️ The controller kind is deliberately absent: a hive has no business /// reading the credentials of the thing that provisions it. Adding it is a /// boundary decision, not a consequence of the namespace growing. /// /// The **service** kind is granted, and that was such a decision rather than a /// consequence: a service whose identity provider is on another host reads its /// own OIDC client secret with the certificate of the hive it runs on, that /// being the only identity such a host has — so every hive can read every /// service's. Bought and paid for in `docs/trust-boundary/security.md`. /// /// The hive's *own* kind is granted, and that is the decision the agent-only /// version of this grant said had to be made rather than assumed: a hive holds /// the queue credential its own agents authenticate with, so it has to read the /// one principal named after itself — and only that one, which is why the path /// interpolates the name instead of widening to the whole kind. /// /// # Errors /// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]` — the /// name is interpolated into a policy path, so a name that could close the /// stanza could grant itself anything. pub fn render(hive: &str) -> Result { checked_segment("hive", hive)?; let agents = read_stanza(&format!( "{MOUNT}/data/{ROOT}/{}/*", <&str>::from(Kind::Agent) )); let own = read_stanza(&format!( "{MOUNT}/data/{ROOT}/{}/{hive}/*", <&str>::from(Kind::Hive) )); let services = read_stanza(&format!( "{MOUNT}/data/{ROOT}/{}/*", <&str>::from(Kind::Service) )); Ok(format!("{agents}{own}{services}")) } /// Render `agent`'s policy document: read on that one agent's credentials, and /// on 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. /// /// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants /// read on `swarm/agents/*` — *every* agent's credentials, not the ones that /// hive hosts — and on `swarm/services/*` the same way; the module header says /// why each is a decision rather than an oversight. Neither reason transfers: /// an agent's path names the agent, so scoping costs nothing and drifts nowhere /// (the argument that keeps [`render`]'s hive stanza narrow), and an agent has /// 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. /// /// ⚠️ 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`). /// /// # 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. pub fn render_agent(agent: &str) -> Result { checked_segment("agent", agent)?; Ok(read_stanza(&format!( "{MOUNT}/data/{ROOT}/{}/{agent}/*", <&str>::from(Kind::Agent) ))) } /// 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//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//*` 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 { 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::*; #[test] fn the_document_grants_two_whole_prefixes_and_this_hive_alone() { assert_eq!( render("pr1ma").expect("a plain name is legal"), "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\ path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n\ path \"secret/data/swarm/services/*\" {\n capabilities = [\"read\"]\n}\n" ); } #[test] fn the_service_stanza_covers_a_service_this_hive_was_never_named_beside() { // The property the swarm-grafana delivery depends on: the host running a // swarm service reads that service's client secret with its own hive // certificate, and the path names the service rather than the host. A // stanza narrowed to the reader's name would 403 every such read. let p = render("pr1ma").expect("legal"); assert!(p.contains("path \"secret/data/swarm/services/*\"")); assert!( !p.contains("services/pr1ma"), "the service stanza is not scoped to the reader" ); } #[test] fn the_grant_is_read_only() { // A hive reads credentials; a hive that could write one could hand // itself an agent's identity. let p = render("pr1ma").expect("legal"); assert!(!p.contains("create")); assert!(!p.contains("update")); assert!(!p.contains("delete")); assert!(!p.contains("list")); } #[test] fn one_hives_document_does_not_reach_another_hives_path() { // Replaces `every_hive_gets_a_byte_identical_document`: the hive stanza // is per-reader now, so identical text is no longer the property. This // is what that test was protecting — that a document says only what the // deploy-time name puts in it. let a = render("alpha").expect("legal"); assert!(a.contains("swarm/hives/alpha/*")); assert!(!a.contains("beta")); assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow"); } #[test] fn the_same_name_still_renders_byte_identically() { // The half of the old property that survives: the text is a function of // the deploy-time name alone, so a re-emission cannot drift. assert_eq!( render("pr1ma").expect("legal"), render("pr1ma").expect("legal") ); } #[test] fn a_name_that_could_close_the_stanza_is_refused() { // The name reaches the document now, which it did not before — so the // injection case is live again in the policy TEXT, not just in the // policy's identifier. assert!(render("alpha/*\" { capabilities = [\"root\"] }").is_err()); assert!( hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(), "the object NAME is a place a name can do damage too" ); } #[test] fn the_legal_charset_is_actually_reachable() { // The control for the case above: if every name were refused, that // assertion would pass while proving nothing. assert!(hive_object_name("a-b_C9").is_ok()); assert!(render("a-b_C9").is_ok()); } #[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)); } #[test] fn an_agents_document_is_one_stanza_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" ); } #[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. 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/*")); } #[test] fn an_agents_document_reaches_nothing_but_that_agent() { // Stated as an exhaustive check over the kinds rather than as a list of // paths, so a kind added to `path::Kind` cannot be granted here by a // renderer nobody re-read. let p = render_agent("atlas").expect("legal"); for kind in Kind::ALL { let segment = <&str>::from(kind); let expected = kind == Kind::Agent; assert_eq!( p.contains(&format!("swarm/{segment}/")), expected, "kind {kind:?} in an agent's document: {p}" ); } 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}" ); } #[test] fn an_agents_grant_is_read_only() { // 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"] { assert!( !p.contains(capability), "an agent's document must not grant {capability}: {p}" ); } assert!(p.contains("capabilities = [\"read\"]")); } #[test] fn one_agents_document_does_not_reach_another_agents_path() { let a = render_agent("atlas").expect("legal"); assert!(a.contains("swarm/agents/atlas/*")); assert!(!a.contains("argus")); assert_eq!(a, render_agent("atlas").expect("legal")); } #[test] fn an_agent_name_that_could_close_the_stanza_is_refused() { // Same live injection surface as the hive renderer's: the name reaches // the document text, not just an identifier. assert!(render_agent("atlas/*\" { capabilities = [\"root\"] }").is_err()); assert!(render_agent("").is_err()); assert!(agent_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err()); // The control: if everything were refused the arms above would pass for // the wrong reason. assert!(render_agent("a-b_C9").is_ok()); assert!(agent_object_name("a-b_C9").is_ok()); } #[test] fn an_agents_object_name_sits_where_the_controller_can_write_it() { // `hive-*` is the whole of what `controllerPolicyText` lets the // controller create under `sys/policies/acl/` and `auth/cert/certs/`, // and the controller is the only principal that learns an agent exists. let n = agent_object_name("atlas").expect("legal"); assert_eq!(n, "hive-agent-atlas"); assert!(n.starts_with(HIVE_PREFIX)); assert!(n.starts_with(AGENT_PREFIX)); } #[test] fn an_agents_object_name_cannot_be_spelled_by_a_legal_hive_name() { // The collision argument, as an assertion rather than as prose: the // only hive name that renders an agent's object name is one carrying // the substring `agent`, which `nix/reserved-hive-fragments.nix` // forbids — so the guard this leans on is named in a test that fails if // the prefix is ever changed to something that guard does not cover. let agent = agent_object_name("atlas").expect("legal"); let colliding_hive = agent .strip_prefix(HIVE_PREFIX) .expect("an agent's name is inside the hive namespace"); assert_eq!(colliding_hive, "agent-atlas"); assert_eq!( hive_object_name(colliding_hive).expect("legal as a path segment"), agent, "this is the hive name a collision would need" ); assert!( colliding_hive.contains("agent"), "and it is unrepresentable only because a hive name may not contain `agent`" ); } #[test] fn an_agents_document_is_not_the_hives() { // The pin the two renderers need against each other: a later edit that // made `render_agent` delegate to `render`, or vice versa, would hand // an agent the hive's two wide stanzas. let agent = render_agent("atlas").expect("legal"); let hive = render("atlas").expect("legal"); assert_ne!(agent, hive); assert!(!agent.contains("swarm/services/")); assert!(!agent.contains("swarm/hives/")); // And the hive's is untouched by this module's growth: still the three // stanzas `the_document_grants_two_whole_prefixes_and_this_hive_alone` // pins byte for byte, none of them narrowed to make the agent's look // consistent with it. 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//*` 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//*` — 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)); } }