//! The queue agreements: where a queue credential lives in the store, and what //! the object at that path holds. //! //! **Two kinds live here, and which is which is the thing to get right.** //! //! - [`agent_client_path`] / [`Credential`] are keyed per **hive**: one OIDC //! client minted per hive at deploy time, shared by every agent container on //! it. It says which hive a caller belongs to, never which agent. //! - [`agent_queue_path`] / [`AgentCredential`] are keyed per **agent**: a //! secret the swarm mints for one agent, at swarm level, with no hive in the //! chain. It is what makes one agent distinguishable from its co-hived //! neighbours. //! //! They are separate objects rather than one with a nullable field because they //! are minted by different principals on different events — the hive-scoped one //! by authelia at deploy time, the per-agent one by `swarm-controller` when an //! agent is created — and an object whose shape depends on who wrote it is a //! reader that has to guess. //! //! The per-agent secret is deliberately **not** derived from the agent's mTLS //! identity (the leaf at [`crate::mtls::identity_path`]). That leaf is for //! reaching the store and nothing else; deriving a queue identity from it would //! couple the two credentials' lifetimes, so that renewing one would mean //! renewing the other. //! //! The token an agent presents that secret in at the queue is spelled by //! `swarm_queue_client::agent_token`, which needs no store client. use serde::{Deserialize, Serialize}; use crate::{ Error, path::{Kind, principal_prefix}, }; /// The path holding the client secret that agent containers on `hive` present /// to the swarm queue. /// /// # Errors /// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`, /// which is what keeps one hive's name from addressing another hive's secret. pub fn agent_client_path(hive: &str) -> Result { let prefix = principal_prefix(Kind::Hive, hive)?; Ok(format!("{prefix}/queue/agent")) } /// The path holding the secret **one agent** presents to the swarm queue. /// /// Under the agent's own principal prefix, and that is the whole reason for /// this spelling rather than a new top-level one: every agent's ACL document /// already grants read on `swarm/agents//*` /// ([`crate::policy::render_agent`]), so a credential here needs no new grant, /// no policy re-render, and no rewrite of any existing agent's document. /// /// # Errors /// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, /// which is what keeps one agent's name from addressing another agent's secret. pub fn agent_queue_path(agent: &str) -> Result { let prefix = principal_prefix(Kind::Agent, agent)?; Ok(format!("{prefix}/queue")) } /// The path holding `swarm-controller`'s own OIDC client secret: the identity /// it connects to the queue with, and mints its other bearer tokens from. /// /// Under [`Kind::Controller`] because no hive's policy reads that kind, and /// this client may read every hive's status. /// /// The nix half is `swarm-secret-publisher.nix`, which writes it, and /// `swarm-bao.nix`'s `controllerPolicyText`, which grants the read. /// /// # Errors /// Never in practice: the name segment is a constant. The `Result` is /// [`principal_prefix`]'s. pub fn controller_client_path() -> Result { let prefix = principal_prefix(Kind::Controller, crate::matrix::CONTROLLER)?; Ok(format!("{prefix}/oidc/client")) } /// What [`controller_client_path`] holds. No client id beside the value: the /// controller's id is a swarm-wide option both the writer and the reader /// already have. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ControllerCredential { /// The secret itself, under the field name every kind here uses. pub value: String, } /// The path holding `hive`'s own OIDC client secret: the `hive-` /// identity hive-c0re publishes the hive's status to the swarm queue with. /// /// Not [`agent_client_path`], which is the agents' client on the same hive. /// Both sit under the hive's own prefix, which the hive's policy reads /// ([`crate::policy::render`]). /// /// The nix half is `swarm-secret-publisher.nix`, which writes it. /// /// # Errors /// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`. pub fn hive_client_path(hive: &str) -> Result { let prefix = principal_prefix(Kind::Hive, hive)?; Ok(format!("{prefix}/oidc/client")) } /// What [`hive_client_path`] holds. No client id beside the value: the reader /// is told its id by its own environment, as the controller is. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HiveClientCredential { /// The secret itself, under the field name every kind here uses. pub value: String, } /// What [`agent_queue_path`] holds: the secret, and the agent it proves. /// /// No hive: an agent's identity is not tied to one, and the subjects the /// verifier grants are keyed on the agent alone. Objects written with a `hive` /// field still decode, because unknown fields are ignored. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentCredential { /// The secret itself. Named to match [`Credential::value`] and /// [`crate::matrix::Credential::value`] so a nix-side reader spells /// `bao kv get -field=value` for every kind. pub value: String, /// The agent this secret authenticates. pub agent: String, /// When `value` was minted, in unix seconds. `swarm-controller` re-mints /// the secret once this is old enough, and fills in `None` with the time it /// first sees it, leaving `value` alone. /// /// `Option` because objects written before this field existed lack it and /// must still decode; skipped when `None` so such an object re-serialises /// unchanged. #[serde(default, skip_serializing_if = "Option::is_none")] pub minted_at: Option, } /// What the path holds: the client secret, plus the client id it belongs to. /// /// The id rides with the secret for the same reason the homeserver rides with /// a matrix token — a credential has to be reconstructable from the store /// alone. Deriving it on the reading side instead would mean spelling /// `hive--agent` in a second place, and the authelia module's own option /// says what a split spelling costs: every agent is denied as a timeout. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Credential { /// The secret itself. Named to match [`crate::matrix::Credential::value`] /// so a nix-side reader spells `bao kv get -field=value` for either kind. pub value: String, /// The OIDC client id the secret authenticates. /// /// Required, unlike [`crate::matrix::Credential::homeserver`]: that field /// is optional because it was added to objects already in the store, and /// this path is new. An object without an id is not a usable credential, /// so failing to decode is the wanted behaviour — the alternative is a /// reader holding `None` whose only recourse is to spell the id itself. pub client_id: String, } #[cfg(test)] mod tests { use super::*; #[test] fn a_hive_name_lands_under_its_own_principal_prefix() { assert_eq!( agent_client_path("alpha").expect("a plain name is legal"), "swarm/hives/alpha/queue/agent" ); } #[test] fn a_traversal_in_the_hive_name_is_refused() { let e = agent_client_path("../beta").expect_err("a traversal is not"); assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}"); } #[test] fn two_hives_never_share_a_path() { assert_ne!( agent_client_path("alpha").expect("legal"), agent_client_path("beta").expect("legal") ); } #[test] fn the_object_round_trips_through_the_store_representation() { let c = Credential { value: "s3cr3t".to_owned(), client_id: "hive-alpha-agent".to_owned(), }; let json = serde_json::to_string(&c).expect("serialises"); assert_eq!( serde_json::from_str::(&json).expect("deserialises"), c ); } #[test] fn the_field_names_the_nix_reader_asks_for_are_the_ones_written() { let json = serde_json::to_value(Credential { value: "s3cr3t".to_owned(), client_id: "hive-alpha-agent".to_owned(), }) .expect("serialises"); assert_eq!(json["value"], "s3cr3t"); assert_eq!(json["client_id"], "hive-alpha-agent"); } /// Spelled out in full because `swarm-secret-publisher.nix` and /// `swarm-bao.nix` write the same string by hand. #[test] fn the_controller_client_lands_under_the_controller() { assert_eq!( controller_client_path().expect("a constant segment"), "swarm/controller/swarm-controller/oidc/client" ); } #[test] fn no_hive_policy_reaches_the_controller_client() { let doc = crate::policy::render("alpha").expect("a plain name is legal"); // Every stanza is `path "secret/data/*" { … }`. let granted: Vec<&str> = doc .lines() .filter_map(|l| l.strip_prefix("path \"secret/data/")) .filter_map(|p| p.strip_suffix("*\" {")) .collect(); let reads = |path: &str| granted.iter().any(|g| path.starts_with(g)); let path = controller_client_path().expect("a constant segment"); assert!(!reads(&path), "{path} is readable under {granted:?}"); // The control: the hive's own agent client IS under a hive stanza, so // the assertion above can fail at all. let own = agent_client_path("alpha").expect("legal"); assert!(reads(&own), "{own} should be readable under {granted:?}"); } /// Spelled out in full because `swarm-secret-publisher.nix` writes the /// same string by hand. #[test] fn a_hives_own_client_lands_under_its_own_principal_prefix() { assert_eq!( hive_client_path("alpha").expect("a plain name is legal"), "swarm/hives/alpha/oidc/client" ); } #[test] fn a_hives_own_policy_reads_its_client_and_no_other_hives() { let doc = crate::policy::render("alpha").expect("a plain name is legal"); let granted: Vec<&str> = doc .lines() .filter_map(|l| l.strip_prefix("path \"secret/data/")) .filter_map(|p| p.strip_suffix("*\" {")) .collect(); let reads = |path: &str| granted.iter().any(|g| path.starts_with(g)); let own = hive_client_path("alpha").expect("legal"); assert!(reads(&own), "{own} should be readable under {granted:?}"); let other = hive_client_path("beta").expect("legal"); assert!(!reads(&other), "{other} is readable under {granted:?}"); } #[test] fn the_hive_client_objects_field_name_is_the_one_the_publisher_writes() { let json = serde_json::to_value(HiveClientCredential { value: "s3cr3t".to_owned(), }) .expect("serialises"); assert_eq!(json, serde_json::json!({ "value": "s3cr3t" })); } #[test] fn the_controller_objects_field_name_is_the_one_the_publisher_writes() { let json = serde_json::to_value(ControllerCredential { value: "s3cr3t".to_owned(), }) .expect("serialises"); assert_eq!(json, serde_json::json!({ "value": "s3cr3t" })); } #[test] fn an_agent_name_lands_under_its_own_principal_prefix() { assert_eq!( agent_queue_path("atlas").expect("a plain name is legal"), "swarm/agents/atlas/queue" ); } #[test] fn a_traversal_in_the_agent_name_is_refused() { let e = agent_queue_path("../beta").expect_err("a traversal is not"); assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}"); } #[test] fn two_agents_never_share_a_path() { assert_ne!( agent_queue_path("atlas").expect("legal"), agent_queue_path("argus").expect("legal") ); } /// The reason this path was chosen over a new top-level prefix: it is /// already inside the stanza every agent's own ACL document grants, so no /// policy has to change for the credential to be readable by the one agent /// it belongs to. #[test] fn the_agents_existing_policy_already_covers_its_queue_path() { let document = crate::policy::render_agent("atlas").expect("legal"); let path = agent_queue_path("atlas").expect("legal"); let stanza = path .rsplit_once('/') .map(|(prefix, _)| format!("secret/data/{prefix}/*")) .expect("the path has a parent"); assert!(document.contains(&stanza), "{document}"); } #[test] fn the_agent_object_round_trips_through_the_store_representation() { let c = AgentCredential { value: "s3cr3t".to_owned(), agent: "atlas".to_owned(), minted_at: Some(1_790_000_000), }; let json = serde_json::to_string(&c).expect("serialises"); assert_eq!( serde_json::from_str::(&json).expect("deserialises"), c ); } #[test] fn the_agent_objects_field_names_are_the_ones_a_nix_reader_asks_for() { let json = serde_json::to_value(AgentCredential { value: "s3cr3t".to_owned(), agent: "atlas".to_owned(), minted_at: Some(1_790_000_000), }) .expect("serialises"); assert_eq!(json["value"], "s3cr3t"); assert_eq!(json["agent"], "atlas"); assert_eq!(json["minted_at"], 1_790_000_000); assert!(json.get("hive").is_none(), "{json}"); } /// A stored object may carry a `hive` field. It is ignored, and the object /// decodes. #[test] fn a_stored_agent_object_that_still_names_a_hive_decodes() { let c: AgentCredential = serde_json::from_str(r#"{"value":"s3cr3t","agent":"atlas","hive":"alpha"}"#) .expect("a stored object carrying `hive` decodes"); assert_eq!( c, AgentCredential { value: "s3cr3t".to_owned(), agent: "atlas".to_owned(), minted_at: None, } ); } /// An object with no mint time decodes, and re-serialises without a /// `null` in its place. #[test] fn an_agent_object_without_a_mint_time_decodes_and_round_trips_unchanged() { let raw = r#"{"value":"s3cr3t","agent":"atlas"}"#; let c: AgentCredential = serde_json::from_str(raw).expect("decodes"); assert_eq!(c.minted_at, None); assert_eq!(serde_json::to_string(&c).expect("serialises"), raw); } /// The agent is required: it is what the verifier checks the presented /// name against. #[test] fn an_agent_object_missing_its_agent_does_not_decode() { assert!(serde_json::from_str::(r#"{"value":"s"}"#).is_err()); assert!( serde_json::from_str::(r#"{"value":"s","hive":"alpha"}"#).is_err() ); } /// decodes as the other — the property that keeps a reader from picking up /// a hive-shared credential where a per-agent one was meant. #[test] fn the_hive_credential_and_the_agent_credential_are_not_interchangeable() { let hive_json = serde_json::to_string(&Credential { value: "s".to_owned(), client_id: "hive-alpha-agent".to_owned(), }) .expect("serialises"); assert!(serde_json::from_str::(&hive_json).is_err()); let agent_json = serde_json::to_string(&AgentCredential { value: "s".to_owned(), agent: "atlas".to_owned(), minted_at: None, }) .expect("serialises"); assert!(serde_json::from_str::(&agent_json).is_err()); assert_ne!( agent_queue_path("atlas").expect("legal"), agent_client_path("atlas").expect("legal"), ); } }