Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/queue.rs
atlas 2fc63d844b name the mint, not the publisher, as the writer of the client secrets
Finishes the sweep of prose that said the publisher unit writes or carries the OIDC client secrets: rust docs, nix comments and option descriptions, test names and docs.
2026-10-30 16:57:58 +01:00

295 lines
11 KiB
Rust

//! The queue agreements: where a queue credential lives in the store, and what
//! the object at that path holds.
//!
//! An agent's credential, [`agent_queue_path`] / [`AgentCredential`], is 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.
//!
//! 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_agent_verify::agent_token`.
use serde::{Deserialize, Serialize};
use crate::{
Error,
path::{Kind, principal_prefix},
};
/// 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/<agent>/*`
/// ([`crate::policy::render_shared_agent`]), so a credential here needs no new grant
/// and no policy rewrite.
///
/// # 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<String, Error> {
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 lists the path
/// authelia's mint writes it at, 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<String, Error> {
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-<hive>`
/// identity hive-c0re publishes the hive's status to the swarm queue with.
///
/// Under the hive's own prefix, which the hive's policy reads
/// ([`crate::policy::render_shared_hives`]).
///
/// The nix half is `swarm-secret-publisher.nix`, which lists the path authelia's
/// mint writes it at.
///
/// # Errors
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`.
pub fn hive_client_path(hive: &str) -> Result<String, Error> {
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 [`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<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
/// Spelled out in full because the path layout in `swarm-secret-publisher.nix`
/// and `swarm-bao.nix` is written out by hand there too.
#[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::effective_hive("alpha");
// Every stanza is `path "secret/data/<prefix>*" { … }`.
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 client IS under a hive stanza, so the
// assertion above can fail at all.
let own = hive_client_path("alpha").expect("legal");
assert!(reads(&own), "{own} should be readable under {granted:?}");
}
/// Spelled out in full because the path layout in
/// `swarm-secret-publisher.nix`, where authelia's mint takes it from, is
/// written out by hand there too.
#[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::effective_hive("alpha");
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::effective_agent("atlas");
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::<AgentCredential>(&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::<AgentCredential>(r#"{"value":"s"}"#).is_err());
assert!(
serde_json::from_str::<AgentCredential>(r#"{"value":"s","hive":"alpha"}"#).is_err()
);
}
}