Watch
0
0
Fork
You've already forked hyperhive
0

swarm-queue-client: one agent-token spelling, and no hive in AgentCredential

`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.
This commit is contained in:
atlas 2026-09-26 01:05:53 +02:00
commit fc97c237dc
10 changed files with 234 additions and 201 deletions

View file

@ -0,0 +1,155 @@
//! The one spelling of the token an agent presents its own queue secret in,
//! shared by the agent that formats it and the auth-callout responder that
//! parses it back:
//! [`AGENT_TOKEN_PREFIX`](crate::agent_token::AGENT_TOKEN_PREFIX), the agent's
//! name, `.`, the secret.
//!
//! The secret itself lives at `swarm/agents/<agent>/queue` in the swarm's
//! secret store (`swarm_secret_client::queue`). This module knows nothing of
//! the store, so an agent formats its token without linking a store client.
/// Marks an `auth_token` as an agent's own credential.
///
/// An OIDC access token may itself contain `.`, so the `<agent>.<secret>`
/// shape alone does not tell the two apart. Authelia's access tokens start
/// `authelia_at_`.
pub const AGENT_TOKEN_PREFIX: &str = "swarm-agent.";
/// An agent's own credential as presented at the queue.
///
/// No `Debug`: `secret` is the credential itself.
pub struct AgentToken<'a> {
/// The agent the presenter claims to be. Unproven until `secret` is
/// checked against that agent's stored credential.
pub agent: &'a str,
/// The presented secret.
pub secret: &'a str,
}
/// A token that is not `<agent>.<secret>` after its prefix, or parts that
/// would not make one. Carries the reason only: a token holds a secret.
#[derive(Debug, thiserror::Error)]
#[error("malformed agent token: {0}")]
pub struct Malformed(pub &'static str);
/// Spell `agent`'s credential as the token it presents at the queue.
///
/// # Errors
/// [`Malformed`] when `agent` or `secret` is empty or holds anything outside
/// `[A-Za-z0-9_-]`. Either would make [`parse_agent_token`] split the token
/// differently than it was joined.
pub fn format_agent_token(agent: &str, secret: &str) -> Result<String, Malformed> {
check_agent(agent)?;
check_secret(secret)?;
Ok(format!("{AGENT_TOKEN_PREFIX}{agent}.{secret}"))
}
/// Read a presented token back into an [`AgentToken`].
///
/// `None` when `token` does not start with [`AGENT_TOKEN_PREFIX`]: it is some
/// other kind of token, not a malformed one of these. `Some(Err(_))` when it
/// does and is not exactly `<agent>.<secret>` after it.
#[must_use]
pub fn parse_agent_token(token: &str) -> Option<Result<AgentToken<'_>, Malformed>> {
let rest = token.strip_prefix(AGENT_TOKEN_PREFIX)?;
Some(
rest.split_once('.')
.ok_or(Malformed("no `.` between the agent and the secret"))
.and_then(|(agent, secret)| {
check_agent(agent)?;
check_secret(secret)?;
Ok(AgentToken { agent, secret })
}),
)
}
fn is_segment(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
/// The alphabet a store path segment allows, so the name cannot widen a
/// subject with `.`, `*` or `>`, or address another agent's path.
fn check_agent(agent: &str) -> Result<(), Malformed> {
if is_segment(agent) {
Ok(())
} else {
Err(Malformed("the agent is not a single [A-Za-z0-9_-] segment"))
}
}
/// The alphabet the controller mints secrets in, base64url without padding.
/// The error never carries the value.
fn check_secret(secret: &str) -> Result<(), Malformed> {
if is_segment(secret) {
Ok(())
} else {
Err(Malformed(
"the secret is empty or holds a byte outside [A-Za-z0-9_-]",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_agent_token_round_trips() {
let token = format_agent_token("atlas", "Ab9_-z").expect("legal");
assert_eq!(token, "swarm-agent.atlas.Ab9_-z");
let parsed = parse_agent_token(&token)
.expect("carries the prefix")
.expect("well-formed");
assert_eq!(parsed.agent, "atlas");
assert_eq!(parsed.secret, "Ab9_-z");
}
/// Anything without the prefix belongs to the OIDC path, including a
/// token that happens to look like `<agent>.<secret>`.
#[test]
fn a_token_without_the_prefix_is_not_an_agent_token() {
for token in ["authelia_at_abc.def", "atlas.s3cr3t", "", "swarm-agent"] {
assert!(parse_agent_token(token).is_none(), "{token:?}");
}
}
#[test]
fn a_malformed_agent_token_is_refused() {
for token in [
"swarm-agent.",
"swarm-agent.atlas",
"swarm-agent.atlas.",
"swarm-agent..s3cr3t",
"swarm-agent.atlas.s3.cr3t",
"swarm-agent.at*las.s3cr3t",
"swarm-agent.at>las.s3cr3t",
"swarm-agent.at/las.s3cr3t",
"swarm-agent.atlas.s3cr3t=",
"swarm-agent.atlas.s3 cr3t",
] {
assert!(
matches!(parse_agent_token(token), Some(Err(_))),
"{token:?} must be refused"
);
}
}
/// What formats always parses back into the same two parts.
#[test]
fn formatting_refuses_what_parsing_would_split_differently() {
assert!(format_agent_token("at.las", "s3cr3t").is_err());
assert!(format_agent_token("atlas", "s3.cr3t").is_err());
assert!(format_agent_token("atlas", "").is_err());
assert!(format_agent_token("", "s3cr3t").is_err());
}
#[test]
fn a_malformed_secret_is_not_echoed_in_the_error() {
let Some(Err(e)) = parse_agent_token("swarm-agent.atlas.hunter2!") else {
panic!("must be refused");
};
assert!(!e.to_string().contains("hunter2"), "{e}");
}
}

View file

@ -178,6 +178,10 @@ pub mod wanted;
/// inside it. See the module doc for why the two must not merge.
pub mod agent_status;
/// The token an agent presents its own queue secret in. Store-free, so an agent
/// formats it without linking the secret-store client.
pub mod agent_token;
/// The subject the swarm controller publishes on when the hive-wide knowledge
/// repository has changed. One writer, many readers — every hive subscribes.
///