//! The read agreement: which credentials a hive'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}")) } /// 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}")) } #[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)); } }