From fc97c237dc4da2073ea5953c4b7b099bac1f4693 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 26 Sep 2026 01:05:53 +0200 Subject: [PATCH] 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..`, 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. --- docs/getting-started/setup.md | 5 +- docs/swarm/credentials.md | 6 +- docs/tools/swarmctl-cli.md | 5 +- swarm-controller/src/agent_identity.rs | 20 ++-- swarm-controller/src/main.rs | 125 +++----------------- swarm-queue-client/src/agent_token.rs | 155 +++++++++++++++++++++++++ swarm-queue-client/src/lib.rs | 4 + swarm-secret-client/src/queue.rs | 53 ++++----- swarmctl/src/agent.rs | 20 +--- swarmctl/src/main.rs | 42 +++---- 10 files changed, 234 insertions(+), 201 deletions(-) create mode 100644 swarm-queue-client/src/agent_token.rs diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index b00365c6..5522b5ec 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -33,9 +33,8 @@ its identity then. Ruth doesn't: hive-c0re creates her on its own at startup, so she needs her identity minted by hand, once. ```bash -# On the swarm-controller host: give ruth her store identity. is the -# name of the hive she runs on. -swarmctl agent mint-identity ruth --hive +# On the swarm-controller host: give ruth her store identity. +swarmctl agent mint-identity ruth # On ruth's hive: re-apply her container config, which is when hive-c0re # hands the new identity to the container. diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index d816e502..0ec0292b 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -101,12 +101,10 @@ mint therefore never receives one, and nothing will ever come back around to it. Re-run the mint for one agent with: ```sh -swarmctl agent mint-identity --hive +swarmctl agent mint-identity ``` -`--hive` has no default: neither the CLI nor the controller keeps a roster of -which agent runs where, and the credentials this mints name a hive. The queue -secret half is idempotent — an agent that already has one keeps exactly the +The queue secret half is idempotent — an agent that already has one keeps exactly the value it holds, so running this against an already-migrated agent doesn't drop its queue connection. The certificate half isn't: the agent gets a fresh leaf and picks it up on its next boot. diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 031d536f..39d69995 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -92,7 +92,7 @@ Queue a re-mint of an existing agent's identity at the swarm's secret store. Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome. -**Usage:** `swarmctl agent mint-identity [OPTIONS] --hive ` +**Usage:** `swarmctl agent mint-identity [OPTIONS] ` ###### **Arguments:** @@ -100,9 +100,6 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo ###### **Options:** -* `--hive ` — The hive that agent runs on. - - Required, and deliberately not defaulted: the credentials this mints name a hive, and neither this CLI nor the controller keeps a roster of which agent is on which hive. Naming the wrong one gives the agent an identity scoped to a hive it doesn't run on. The controller checks the value against the swarm's hive roster and names the known hives if it misses. * `--controller-socket ` — swarm-controller's unix socket. Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`. diff --git a/swarm-controller/src/agent_identity.rs b/swarm-controller/src/agent_identity.rs index 6b240973..1d31e019 100644 --- a/swarm-controller/src/agent_identity.rs +++ b/swarm-controller/src/agent_identity.rs @@ -136,7 +136,7 @@ fn generate_queue_secret() -> Result { /// Anything that stops one of those five steps, with the step named. A /// failure here fails the job node and nothing else — the agent is still /// created, without a store identity. -pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> { +pub async fn mint_and_verify(agent: &str) -> Result<()> { let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?; let name = policy::agent_object_name(agent)?; let path = mtls::identity_path(agent)?; @@ -161,18 +161,15 @@ pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> { .read_optional(&queue_path) .await .with_context(|| format!("checking whether {queue_path} already holds a credential"))?; - // The secret survives a re-run; the principal it names does not get to. - // An object whose `hive` disagrees with the hive this node was invoked - // with would grant its holder subjects on the wrong hive, so it is - // corrected — but by rewriting the two name fields around the *same* - // `value`, which is a correction no live connection notices. + // The secret survives a re-run. An object naming a different agent is + // corrected by rewriting the name around the *same* `value`, which no live + // connection notices. let wanted = queue::AgentCredential { value: match &existing { Some(existing) => existing.value.clone(), None => generate_queue_secret()?, }, agent: agent.to_owned(), - hive: hive.to_owned(), }; if existing.as_ref() == Some(&wanted) { tracing::info!( @@ -187,9 +184,8 @@ pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> { .with_context(|| format!("publishing the agent queue credential at {queue_path}"))?; tracing::info!( agent, - hive, %queue_path, - // Never "rotated": the secret is the same one, only the names + // Never "rotated": the secret is the same one, only the name // around it moved. corrected = existing.is_some(), "agent queue credential published" @@ -280,9 +276,9 @@ async fn read_back_as_agent( .read(queue_path) .await .with_context(|| format!("reading {queue_path} back under {role}'s own token"))?; - // The two name fields are compared as well as the secret: they are what - // the verifying end will grant subjects from, so a mismatch here is the - // same class of fault as an unreadable path. + // The agent is compared as well as the secret: the verifying end refuses + // an object naming a different agent than the path, so a mismatch here is + // the same class of fault as an unreadable path. if read_back != *queue_credential { bail!("the store returned a different object at {queue_path} than the one just published"); } diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index d20d50df..a256aa1d 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -100,11 +100,10 @@ enum SwarmNodeKind { /// `agent_identity::mint_and_verify` — including why this node does not /// report success on a write. /// - /// Carries the hive for a different reason than `TriggerDeploy` does: - /// not as an address, but because the agent's queue credential names the - /// hive it may take subjects on, so it cannot be written without knowing - /// which hive the agent belongs to. - MintAgentIdentity { hive: String, agent: String }, + /// Carries no hive: neither the certificate, the queue credential nor + /// the agent's policy names one, so an agent keeps one store identity + /// whichever hive it runs on. + MintAgentIdentity { agent: String }, /// Make sure `agent` holds a live forge access token in the swarm secret /// store, minting one with the forge's admin API when it does not. See /// `forge::agent_token` — including why a rotation is a delete then a @@ -124,8 +123,7 @@ enum SwarmNodeKind { /// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state /// store, so a freshly created agent does not start driving turns the /// moment it's deployed — the operator has to explicitly flip it to `Up`. - /// Carries the hive for the same reason `TriggerDeploy`/`MintAgentIdentity` - /// do: the wanted-state bucket is keyed per hive. + /// Carries the hive because the wanted-state bucket is keyed per hive. SetAgentWanted { hive: String, agent: String }, /// Tell `hive` to rebuild `agent`, by publishing on the swarm's deploy /// subject. The one node kind whose effect leaves this host. @@ -166,12 +164,12 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { | SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::AddRepoMember { agent } | SwarmNodeKind::InitAgentConfigRepo { agent } + | SwarmNodeKind::MintAgentIdentity { agent } | SwarmNodeKind::MintAgentForgeToken { agent } | SwarmNodeKind::MintAgentMatrixAccount { agent } => { serde_json::json!({ "agent": agent }) } SwarmNodeKind::TriggerDeploy { hive, agent } - | SwarmNodeKind::MintAgentIdentity { hive, agent } | SwarmNodeKind::SetAgentWanted { hive, agent } => { serde_json::json!({ "agent": agent, "hive": hive }) } @@ -314,7 +312,7 @@ async fn run_swarm_node( Err(e) => Outcome::Failed(format!("{e:#}")), }, }, - SwarmNodeKind::MintAgentIdentity { hive, agent } => mint_identity(&agent, &hive).await, + SwarmNodeKind::MintAgentIdentity { agent } => mint_identity(&agent).await, SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await, SwarmNodeKind::MintAgentMatrixAccount { agent } => { mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await @@ -340,10 +338,10 @@ async fn run_swarm_node( /// The `MintAgentIdentity` arm, lifted out so `run_swarm_node` stays under /// `clippy::too_many_lines`. -async fn mint_identity(agent: &str, hive: &str) -> hive_jobq::scheduler::Outcome { +async fn mint_identity(agent: &str) -> hive_jobq::scheduler::Outcome { use hive_jobq::scheduler::Outcome; - match agent_identity::mint_and_verify(agent, hive).await { + match agent_identity::mint_and_verify(agent).await { Ok(()) => Outcome::Done, Err(e) => Outcome::Failed(format!("{e:#}")), } @@ -1512,7 +1510,6 @@ fn declare_agent_job( // authelia. let mint_identity = b .node(SwarmNodeKind::MintAgentIdentity { - hive: hive.to_owned(), agent: agent.to_owned(), }) .after_ok(create_identity); @@ -1670,20 +1667,6 @@ async fn get_agent_config_pr( Ok(Json(cache.get(&name))) } -/// Body of `POST /api/agents/{name}/identity`. -#[derive(Deserialize, ToSchema)] -struct MintAgentIdentityRequest { - /// The hive this agent belongs to. - /// - /// Required, for the same reason [`CreateAgentRequest`]'s is: the - /// credentials this mints name a hive, and the controller has nowhere to - /// look one up — agents are created on hives at runtime and this daemon - /// keeps no roster of which agent is where. An operator naming the wrong - /// one would hand the agent subjects on a hive it does not run on, so it - /// is asked for rather than guessed at. - hive: String, -} - /// Success body of `POST /api/agents/{name}/identity`. #[derive(Clone, Debug, Serialize, ToSchema)] struct MintAgentIdentityResponse { @@ -1712,10 +1695,9 @@ struct MintAgentIdentityResponse { post, path = "/api/agents/{name}/identity", params(("name" = String, Path, description = "agent name")), - request_body = MintAgentIdentityRequest, responses( (status = 200, description = "mint queued", body = MintAgentIdentityResponse), - (status = 400, description = "`name` or `hive` is not a valid identifier, or `hive` is not in this swarm (problem+json)", body = String), + (status = 400, description = "`name` is not a valid identifier (problem+json)", body = String), (status = 500, description = "the job could not be queued (problem+json)", body = String), ), tag = "agents" @@ -1723,28 +1705,12 @@ struct MintAgentIdentityResponse { async fn mint_agent_identity( State(state): State, Path(name): Path, - Json(req): Json, ) -> Result, problem_details::ProblemDetails> { - // Both names are interpolated into store paths and policy documents - // downstream, so both are validated here as well as there. + // The name is interpolated into store paths and policy documents + // downstream, so it is validated here as well as there. let agent = hive_types::Ident::parse(&name) .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? .into_string(); - let hive = hive_types::Ident::parse(&req.hive) - .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? - .into_string(); - if !state.hives.iter().any(|h| h.name == hive) { - let known: Vec<&str> = state.hives.iter().map(|h| h.name.as_str()).collect(); - let known = if known.is_empty() { - "(none configured)".to_owned() - } else { - known.join(", ") - }; - return Err(error_problem( - axum::http::StatusCode::BAD_REQUEST, - &format!("hive {hive:?} is not in this swarm — known hives: {known}"), - )); - } // No reserved-name or collision warnings here, unlike `create_agent`: // those answer "is this name available", and this route is only ever @@ -1757,7 +1723,6 @@ async fn mint_agent_identity( .insert_job(None, |b| { vec![ b.node(SwarmNodeKind::MintAgentIdentity { - hive: hive.clone(), agent: agent.clone(), }) .guid(), @@ -2803,39 +2768,6 @@ mod tests { assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin); } - /// The backfill route's roster check, asserted by effect for the same - /// reason its sibling above is: a refusal that queued first would still - /// re-mint the agent's certificate, which every running agent on the - /// named hive picks up on its next boot. - #[tokio::test] - async fn a_backfill_for_a_hive_outside_the_roster_queues_nothing() { - let (state, sched) = state_with_roster(); - - let err = super::mint_agent_identity( - axum::extract::State(state), - axum::extract::Path("atlas".to_owned()), - axum::Json(super::MintAgentIdentityRequest { - hive: "pr1maa".to_owned(), - }), - ) - .await - .expect_err("a hive outside the roster must be refused"); - - let rendered = format!("{err:?}"); - assert!( - rendered.contains("pr1ma"), - "the refusal should name the known hives, got: {rendered}" - ); - - let queued = sched - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .graph() - .nodes() - .count(); - assert_eq!(queued, 0, "a refused backfill must queue no work"); - } - /// A name that is not an identifier is refused before it can reach a /// store path. `create_agent` gets this for free from the roster check /// on the hive; the agent name has no roster to check against, so this @@ -2848,9 +2780,6 @@ mod tests { super::mint_agent_identity( axum::extract::State(state), axum::extract::Path("../beta".to_owned()), - axum::Json(super::MintAgentIdentityRequest { - hive: "pr1ma".to_owned(), - }), ) .await .expect_err("a traversal in the agent name must be refused"); @@ -2876,12 +2805,9 @@ mod tests { let queued = super::mint_agent_identity( axum::extract::State(state), axum::extract::Path("atlas".to_owned()), - axum::Json(super::MintAgentIdentityRequest { - hive: "pr1ma".to_owned(), - }), ) .await - .expect("a hive in the roster must be accepted"); + .expect("a valid agent name must be accepted"); let guard = sched .lock() @@ -2893,10 +2819,9 @@ mod tests { assert!( matches!( &node.payload, - SwarmNodeKind::MintAgentIdentity { hive, agent } - if hive == "pr1ma" && agent == "atlas" + SwarmNodeKind::MintAgentIdentity { agent } if agent == "atlas" ), - "the one node must be the mint, carrying both names: {:?}", + "the one node must be the mint, for the named agent: {:?}", node.payload ); // The id the operator is told to watch has to be the node that was @@ -3025,7 +2950,6 @@ mod tests { let id = sched .append( SwarmNodeKind::MintAgentIdentity { - hive: "pr1ma".to_owned(), agent: "atlas".to_owned(), }, Vec::new(), @@ -3192,25 +3116,6 @@ mod tests { ); } - /// The node carries the hive, and the viewer has to see it. The `data` - /// match is an or-pattern on purpose (see its own comment), and this is - /// the assertion that the new variant joined the two-field arm rather - /// than the agent-only one — a viewer silently missing the hive is the - /// failure that comment describes having already happened once. - #[test] - fn a_mint_node_renders_both_the_agent_and_the_hive() { - use hive_jobq_wire::WireNode as _; - - let kind = SwarmNodeKind::MintAgentIdentity { - hive: "pr1ma".to_owned(), - agent: "atlas".to_owned(), - }; - assert_eq!(kind.label(), "mint_agent_identity"); - let data = kind.data(1); - assert_eq!(data["agent"], "atlas"); - assert_eq!(data["hive"], "pr1ma"); - } - /// The ordering the operator's ruling requires: a hive cannot pass down /// a certificate the swarm has not published, so the deploy message must /// not leave before the mint is terminal. diff --git a/swarm-queue-client/src/agent_token.rs b/swarm-queue-client/src/agent_token.rs new file mode 100644 index 00000000..01a6a18b --- /dev/null +++ b/swarm-queue-client/src/agent_token.rs @@ -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//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 `.` +/// 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 `.` 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 { + 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 `.` after it. +#[must_use] +pub fn parse_agent_token(token: &str) -> Option, 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 `.`. + #[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}"); + } +} diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 077eca8b..20c5a666 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -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. /// diff --git a/swarm-secret-client/src/queue.rs b/swarm-secret-client/src/queue.rs index 3bd5a781..852906e0 100644 --- a/swarm-secret-client/src/queue.rs +++ b/swarm-secret-client/src/queue.rs @@ -22,6 +22,9 @@ //! 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}; @@ -57,14 +60,11 @@ pub fn agent_queue_path(agent: &str) -> Result { Ok(format!("{prefix}/queue")) } -/// What [`agent_queue_path`] holds: the secret, and the principal it proves. +/// What [`agent_queue_path`] holds: the secret, and the agent 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--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. +/// 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 @@ -74,13 +74,6 @@ pub struct AgentCredential { /// 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. @@ -198,7 +191,6 @@ mod tests { 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!( @@ -212,29 +204,39 @@ mod tests { 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"); + assert!(json.get("hive").is_none(), "{json}"); } - /// 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. + /// A stored object may carry a `hive` field. It is ignored, and the object + /// decodes. #[test] - fn an_agent_object_missing_a_principal_does_not_decode() { - assert!( - serde_json::from_str::(r#"{"value":"s","agent":"atlas"}"#).is_err() + 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::(r#"{"value":"s"}"#).is_err()); assert!( serde_json::from_str::(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] @@ -249,7 +251,6 @@ mod tests { 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::(&agent_json).is_err()); diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index d22a4aab..5cada5d1 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -51,13 +51,6 @@ struct CreateAgentResponse { warnings: Vec, } -/// Body of `POST /api/agents/{name}/identity`. Mirrors the controller's own -/// `MintAgentIdentityRequest` — see this module's doc comment. -#[derive(Serialize)] -struct MintIdentityRequest<'a> { - hive: &'a str, -} - /// Success body of `POST /api/agents/{name}/identity`. #[derive(Deserialize)] struct MintIdentityResponse { @@ -117,11 +110,10 @@ pub(crate) fn parse_ident(value: &str, what: &str) -> Result { /// /// Synchronous for the same reason [`create`] is, and built on the same /// round trip. -pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()> { +pub(crate) fn mint_identity(socket: &Path, name: &str) -> Result<()> { // Client-side first, so a typo is a local error rather than a 400 the - // operator waits for. The controller validates both again. + // operator waits for. The controller validates it again. let name = parse_ident(name, "agent name")?; - let hive = parse_ident(hive, "hive")?; let rt = tokio::runtime::Builder::new_current_thread() .enable_io() @@ -130,15 +122,15 @@ pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()> let resp: MintIdentityResponse = rt.block_on(post( socket, &format!("/api/agents/{name}/identity"), - &MintIdentityRequest { hive: &hive }, + &serde_json::json!({}), "mint-identity", ))?; println!("queued: job node {}", resp.node_id); println!( - "agent {name:?} will have its identity re-minted on hive {hive:?} once the job graph \ - runs; `swarmctl` does not wait for it. An existing queue secret is kept as it is; the \ - store certificate is re-minted and the agent picks the new one up on its next boot" + "agent {name:?} will have its identity re-minted once the job graph runs; `swarmctl` \ + does not wait for it. An existing queue secret is kept as it is; the store \ + certificate is re-minted and the agent picks the new one up on its next boot" ); Ok(()) } diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index d1c032eb..eb041618 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -242,16 +242,6 @@ struct AgentMintForgeTokenArgs { struct AgentMintIdentityArgs { /// Name of an agent that already exists. name: String, - /// The hive that agent runs on. - /// - /// Required, and deliberately not defaulted: the credentials this mints - /// name a hive, and neither this CLI nor the controller keeps a roster of - /// which agent is on which hive. Naming the wrong one gives the agent an - /// identity scoped to a hive it doesn't run on. The controller checks - /// the value against the swarm's hive roster and names the known hives if - /// it misses. - #[arg(long, value_name = "HIVE")] - hive: String, /// swarm-controller's unix socket. /// /// Supplied by the nix module that installs this binary, from the same @@ -357,7 +347,7 @@ fn main() -> Result<()> { command: AgentVerb::MintIdentity(args), } => { let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; - agent::mint_identity(&socket, &args.name, &args.hive) + agent::mint_identity(&socket, &args.name) } // Same socket-resolution reasoning as `Create` above. Verb::Agent { @@ -732,20 +722,10 @@ mod tests { assert!(args.controller_socket.is_none()); } - /// The backfill verb takes the same two names as `create`, and `--hive` - /// is required on it for the same reason: it is an address nobody can - /// infer. #[test] - fn the_backfill_verb_takes_an_agent_and_a_hive() { - let cli = Cli::try_parse_from([ - "swarmctl", - "agent", - "mint-identity", - "scribe", - "--hive", - "alpha", - ]) - .expect("the minimal form parses"); + fn the_backfill_verb_takes_an_agent_and_no_hive() { + let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"]) + .expect("the minimal form parses"); let Verb::Agent { command: AgentVerb::MintIdentity(args), } = cli.command @@ -753,12 +733,18 @@ mod tests { panic!("expected `agent mint-identity`"); }; assert_eq!(args.name, "scribe"); - assert_eq!(args.hive, "alpha"); assert!(args.controller_socket.is_none()); - assert!( - Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"]).is_err(), - "an omitted hive must not be defaulted" + Cli::try_parse_from([ + "swarmctl", + "agent", + "mint-identity", + "scribe", + "--hive", + "a" + ]) + .is_err(), + "the identity has no hive, so the verb must not take one" ); }