diff --git a/Cargo.lock b/Cargo.lock index abcdcced..2e0bf188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4544,6 +4544,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "hive-types", "reqwest", "serde", "serde_json", @@ -4558,6 +4559,7 @@ dependencies = [ name = "swarm-authelia-bridge-sock" version = "0.1.0" dependencies = [ + "hive-types", "serde", "serde_json", ] diff --git a/swarm-authelia-bridge-sock/Cargo.toml b/swarm-authelia-bridge-sock/Cargo.toml index 5f86c108..cdfd5bef 100644 --- a/swarm-authelia-bridge-sock/Cargo.toml +++ b/swarm-authelia-bridge-sock/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true readme = "README.md" [dependencies] +hive-types.workspace = true serde.workspace = true [dev-dependencies] diff --git a/swarm-authelia-bridge-sock/src/lib.rs b/swarm-authelia-bridge-sock/src/lib.rs index 849961cd..0f3a2fa5 100644 --- a/swarm-authelia-bridge-sock/src/lib.rs +++ b/swarm-authelia-bridge-sock/src/lib.rs @@ -25,6 +25,7 @@ //! file blob — that would invite a last-writer-wins race between independent //! callers and duplicate the rendering logic on both sides of the wire. +use hive_types::Ident; use serde::{Deserialize, Serialize}; /// A request to the bridge — see the module doc for why this isn't a @@ -69,7 +70,20 @@ pub enum BridgeRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentIdentity { /// The authelia username, which is the agent's name. - pub name: String, + /// + /// [`Ident`] rather than a bare `String`, so a consumer gets the same + /// serde-checked parsing at the socket boundary that every other + /// agent-name field in the crate suite does. + /// + /// ⚠️ **It is deliberately NARROWER than what the store accepts.** + /// `Ident` is `[a-z0-9-]`; the users database also admits `.`, `_` and + /// uppercase, because it holds humans too. Every *agent* name is a valid + /// `Ident` by construction — agent creation parses one before the job is + /// queued — so the narrowing costs nothing for real agents and refuses to + /// describe a human who was hand-added to the agent group as though they + /// were one. The reader skips such an entry and logs it; see the bridge's + /// `list`. + pub name: Ident, } /// The bridge's answer to a [`BridgeRequest`]. @@ -109,6 +123,7 @@ pub enum BridgeResponse { #[cfg(test)] mod tests { use super::{AgentIdentity, BridgeRequest, BridgeResponse}; + use hive_types::Ident; /// Pins the external-tag wire shape — a reader off the wire (or a log /// line) should be able to tell the outcomes apart without @@ -128,7 +143,7 @@ mod tests { let agents = serde_json::to_value(BridgeResponse::Agents { agents: vec![AgentIdentity { - name: "atlas".to_owned(), + name: Ident::parse("atlas").expect("valid ident"), }], }) .unwrap(); @@ -149,6 +164,11 @@ mod tests { #[test] fn request_round_trips() { + // Still a `String`: the REQUEST carries whatever the caller asked for + // and the bridge validates it server-side, which is what lets an + // illegal name be refused with a message rather than failing to + // deserialise. Only the ROSTER ENTRY is an `Ident`, because that one + // describes something the store already accepted. let req = BridgeRequest::EnsureAgentIdentity { name: "atlas".to_owned(), }; @@ -180,7 +200,7 @@ mod tests { #[test] fn a_roster_entry_carries_only_the_name() { let entry = serde_json::to_value(AgentIdentity { - name: "atlas".to_owned(), + name: Ident::parse("atlas").expect("valid ident"), }) .unwrap(); let keys: Vec<&String> = entry.as_object().expect("an object").keys().collect(); diff --git a/swarm-authelia-bridge/Cargo.toml b/swarm-authelia-bridge/Cargo.toml index cb177d95..2d257cfa 100644 --- a/swarm-authelia-bridge/Cargo.toml +++ b/swarm-authelia-bridge/Cargo.toml @@ -9,6 +9,7 @@ name = "swarm-authelia-bridge" path = "src/main.rs" [dependencies] +hive-types.workspace = true anyhow.workspace = true axum.workspace = true reqwest.workspace = true diff --git a/swarm-authelia-bridge/src/main.rs b/swarm-authelia-bridge/src/main.rs index c80be1c4..c65b0a41 100644 --- a/swarm-authelia-bridge/src/main.rs +++ b/swarm-authelia-bridge/src/main.rs @@ -312,7 +312,26 @@ fn list(state: &AppState) -> Result { Ok(BridgeResponse::Agents { agents: store::agent_names(&user_store) .into_iter() - .map(|name| AgentIdentity { name }) + .filter_map(|name| match hive_types::Ident::parse(&name) { + Ok(ident) => Some(AgentIdentity { name: ident }), + // The store's charset is wider than an agent name's, because + // it holds humans too. Every agent this bridge creates is a + // legal `Ident` by construction, so reaching here means a + // subject was hand-added to the agent group that cannot be an + // agent. Skipped rather than failing the whole roster — one + // bad row must not make every other agent invisible — but + // logged, because silently shrinking an answer is how a + // roster starts lying about who is missing. + Err(reason) => { + tracing::warn!( + subject = %name, + %reason, + "subject carries the agent group but is not a legal agent name; \ + omitting it from the roster" + ); + None + } + }) .collect(), }) } diff --git a/swarm-controller/src/auth.rs b/swarm-controller/src/auth.rs index cbc1cd2e..f08cbb7a 100644 --- a/swarm-controller/src/auth.rs +++ b/swarm-controller/src/auth.rs @@ -77,9 +77,10 @@ impl AuthBridge { /// facts, and only one of them is a valid render. pub async fn list_agent_identities(&self) -> Result> { match self.request(&BridgeRequest::ListAgentIdentities).await? { - BridgeResponse::Agents { agents } => { - Ok(agents.into_iter().map(|entry| entry.name).collect()) - } + BridgeResponse::Agents { agents } => Ok(agents + .into_iter() + .map(|entry| entry.name.into_string()) + .collect()), other => { anyhow::bail!("swarm-authelia-bridge answered a roster request with {other:?}") }