`swarm_queue_client::agent_token::format_agent_token` / `parse_agent_token` are the spelling an agent presents its own queue secret in, `swarm-agent.<agent>.<secret>`, and the one the auth-callout responder reads back. The prefix is what separates it from an OIDC access token, which may itself contain `.`. Parsing distinguishes "not an agent token" (no prefix) from "a malformed one"; the error names the problem and never the value. The module is store-free, so the agent formats its token without linking the secret-store client. `swarm_secret_client::queue::AgentCredential` loses `hive`: an agent's identity is not tied to a hive, and nothing reads the field. Objects already in the store carry it and still decode, since unknown fields are ignored; a test parses one. The controller stops writing it. With the credential no longer naming a hive, and the agent's policy naming none since #4762, nothing in the mint consumes one. `hive` goes from `mint_and_verify`, from the `MintAgentIdentity` node, and from `POST /api/agents/{name}/identity`, which now takes no body and no longer checks a hive against the roster; a caller that still sends one is not refused, the body is ignored. `swarmctl agent mint-identity` loses `--hive`, so passing it is now a usage error.
263 lines
10 KiB
Rust
263 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.
|
|
//!
|
|
//! 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<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 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,
|
|
}
|
|
|
|
/// 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(),
|
|
};
|
|
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(),
|
|
})
|
|
.expect("serialises");
|
|
assert_eq!(json["value"], "s3cr3t");
|
|
assert_eq!(json["agent"], "atlas");
|
|
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(),
|
|
}
|
|
);
|
|
}
|
|
|
|
/// 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(),
|
|
})
|
|
.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"),
|
|
);
|
|
}
|
|
}
|