Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/queue.rs
atlas 87b1f94ac6 move agent_token from swarm-queue-client to swarm-agent-verify
per #5471, verifiers stop depending on the queue client. the module is
store-free and needs only thiserror, so swarm-agent-verify gains one
dependency and loses its swarm-queue-client edge. consumers import it
from swarm_agent_verify; swarm-logs and swarm-metrics, whose only use of
swarm-queue-client was this module, drop that dependency, as does
swarm-logs-auth.
2026-10-18 16:58:39 +02:00

409 lines
16 KiB
Rust

//! 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_agent_verify::agent_token`.
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<String, Error> {
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/<agent>/*`
/// ([`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<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 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<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.
///
/// 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<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 [`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<i64>,
}
/// 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-<name>-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::<Credential>(&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/<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 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::<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()
);
}
/// 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::<AgentCredential>(&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::<Credential>(&agent_json).is_err());
assert_ne!(
agent_queue_path("atlas").expect("legal"),
agent_client_path("atlas").expect("legal"),
);
}
}