Every agent on a hive authenticates to the swarm queue with the same hive-scoped secret today, so at the auth callout one agent is indistinguishable from its co-hived neighbours and no subject can be scoped to one of them. Mint a secret per agent instead, at swarm level, into secret/swarm/agents/<agent>/queue -- inside the stanza every agent's ACL document already grants, so no policy changes and no existing agent's document is rewritten. It is written by the same node that already mints the agent's certificate, and read back under the agent's own token before that node reports success. The secret is not derived from the agent's mTLS identity: the two credentials answer different questions and coupling their lifetimes would mean renewing either implied renewing the other. Nothing here rotates a queue secret -- a re-run keeps the existing value and only corrects the principal it names, because this function is re-run deliberately against agents that are already connected. Revoking one means deleting the path. Nothing reads the new credential yet; this is the minting half.
262 lines
10 KiB
Rust
262 lines
10 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.
|
|
|
|
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"))
|
|
}
|
|
|
|
/// What [`agent_queue_path`] holds: the secret, and the principal it proves.
|
|
///
|
|
/// Both names ride **in the object** rather than being parsed back out of a
|
|
/// composite principal string. Hive and agent names draw from the same
|
|
/// alphabet (`hive_types::Ident`, `[a-z0-9-]`), so a principal spelled
|
|
/// `hive-<hive>-agent-<agent>` parses two ways for a name containing `-agent-`
|
|
/// — and an ambiguous principal parse in an authorisation path is a caller that
|
|
/// authenticates fine and is handed somebody else's grant.
|
|
#[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,
|
|
|
|
/// The hive that agent belongs to.
|
|
///
|
|
/// Here because the verifying end has no roster to look it up in, and
|
|
/// because the subjects an agent is granted are hive-templated — without
|
|
/// this field the verifier would know *who* is connecting and not *where*.
|
|
pub hive: String,
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
|
|
#[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(),
|
|
hive: "alpha".to_owned(),
|
|
};
|
|
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(),
|
|
hive: "alpha".to_owned(),
|
|
})
|
|
.expect("serialises");
|
|
assert_eq!(json["value"], "s3cr3t");
|
|
assert_eq!(json["agent"], "atlas");
|
|
assert_eq!(json["hive"], "alpha");
|
|
}
|
|
|
|
/// Neither name is optional. An object missing one is not a usable
|
|
/// credential — a verifier holding `None` for the hive can only guess at
|
|
/// the subjects to grant, and guessing is the failure this shape exists to
|
|
/// prevent.
|
|
#[test]
|
|
fn an_agent_object_missing_a_principal_does_not_decode() {
|
|
assert!(
|
|
serde_json::from_str::<AgentCredential>(r#"{"value":"s","agent":"atlas"}"#).is_err()
|
|
);
|
|
assert!(
|
|
serde_json::from_str::<AgentCredential>(r#"{"value":"s","hive":"alpha"}"#).is_err()
|
|
);
|
|
}
|
|
|
|
/// The two kinds are different objects at different paths, and neither
|
|
/// 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(),
|
|
hive: "alpha".to_owned(),
|
|
})
|
|
.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"),
|
|
);
|
|
}
|
|
}
|