hyperhive/swarm-secret-client/src/queue.rs
atlas b32c92a446 swarm-secret-client: say why the queue credential's client id is required
Pinning a decision rather than changing behaviour, because I was one
edit away from reversing it and left no reason on the field.

The sibling `matrix::Credential::homeserver` is an `Option`, which reads
like the house style to copy. It is not: that field is optional because
it was added to objects already in the store, and KV2 keeps those
versions forever. This path has never been written, so there is nothing
to stay compatible with.

Making it optional would also defeat the field. The id rides with the
secret so a reader never has to spell `hive-<name>-agent` itself, and
the only thing a reader holding `None` can do is exactly that. An object
without an id is not a usable credential, so failing to decode is the
behaviour we want.

Refs #3853
2026-09-12 11:22:33 +02:00

100 lines
3.6 KiB
Rust

//! The queue agreement: where a hive's agent-container credential lives in the
//! store, and what the object at that path holds.
//!
//! The sibling of [`crate::matrix`], and it differs from it in one way worth
//! reading before using either: a matrix credential is keyed per **agent**,
//! this one per **hive**. Agents are created at runtime, so the queue
//! identity they present is minted once per hive at deploy time and says which
//! hive an agent belongs to, never which agent.
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"))
}
/// 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");
}
}