hyperhive/swarm-controller/src/auth.rs
atlas d58be6834b fix(#3344): type the roster entry's name as Ident, per review
A bare String in a crate whose whole suite types validated names as
hive_types::Ident, so a consumer had to re-derive at the boundary what every
sibling field gets checked for free.

Ident is strictly narrower than the store's charset — [a-z0-9-] against the
[A-Za-z0-9._-] the users database allows, because that file holds humans too.
Every agent name is a legal Ident by construction (creation parses one before
the job is queued), so the narrowing costs real agents nothing. What it does
mean is that a human hand-added to the agent group cannot be described as an
agent: the reader omits that row and logs it, rather than failing the whole
roster or quietly shrinking the answer.

The REQUEST keeps its String. That side carries what a caller asked for, and
validating it server-side is what lets a bad name be refused with a message
instead of failing to deserialise.
2026-08-20 00:15:48 +02:00

138 lines
5.7 KiB
Rust

//! Client for `swarm-authelia-bridge` — this daemon's only access to the
//! swarm's authelia users database, in either direction (see that crate's
//! README for why the file cannot be touched from here).
//!
//! Authenticated with THIS daemon's own queue OIDC identity
//! (`SWARM_CONTROLLER_OIDC_*`, the same one `swarm-queue-client` mints for
//! the queue connection) — "one identity per principal" means a second
//! op that needs to prove who this process is reuses the identity it
//! already has rather than provisioning a new one. A fresh token is
//! minted per call for the same reason the queue client mints one per
//! connection attempt: no window in which this process holds a token
//! that outlives its intended use.
//!
//! `None` when this deployment did not wire a bridge up — the bridge only
//! exists on hosts that also run `swarm-authelia`, so a controller split
//! from it can neither create an identity nor read the roster.
//!
//! Each caller answers that absence in its own terms rather than this
//! module inventing a shared one: `SwarmNodeKind::CreateIdentity` fails
//! the job explicitly, and the roster endpoint refuses. Neither substitutes
//! an empty answer, which is the failure mode a default here would create.
use anyhow::{Context, Result};
use swarm_authelia_bridge_sock::{BridgeRequest, BridgeResponse};
/// A configured connection to `swarm-authelia-bridge`.
#[derive(Clone)]
pub struct AuthBridge {
http: reqwest::Client,
base_url: String,
queue_cfg: swarm_queue_client::QueueConfig,
}
impl AuthBridge {
/// Read `SWARM_CONTROLLER_AUTH_BRIDGE_URL`; `Ok(None)` when unset.
///
/// The queue identity (`SWARM_CONTROLLER_OIDC_*`) is not optional once
/// the bridge URL is set: the nix module sets `queueEnv` unconditionally
/// for every controller (the queue is required, not just co-located
/// service), so a bridge URL with no queue identity to authenticate
/// with is a deployment bug, not an absent-feature case — hence the
/// hard error rather than a second `None`.
pub fn from_env() -> Result<Option<Self>> {
let Ok(base_url) = std::env::var("SWARM_CONTROLLER_AUTH_BRIDGE_URL") else {
return Ok(None);
};
let queue_cfg = swarm_queue_client::QueueConfig::from_env("SWARM_CONTROLLER")
.context("reading the queue OIDC identity the auth bridge authenticates with")?
.ok_or_else(|| {
anyhow::anyhow!(
"SWARM_CONTROLLER_AUTH_BRIDGE_URL is set but SWARM_CONTROLLER_OIDC_* is \
not — the bridge is authenticated with this daemon's queue identity, so \
that identity must exist first"
)
})?;
Ok(Some(Self {
http: reqwest::Client::new(),
base_url,
queue_cfg,
}))
}
/// Idempotently ensure `name` exists as an authelia subject.
pub async fn ensure_agent_identity(&self, name: &str) -> Result<BridgeResponse> {
self.request(&BridgeRequest::EnsureAgentIdentity {
name: name.to_owned(),
})
.await
}
/// The swarm's agent roster, straight from the identity store.
///
/// Returns the names rather than the whole [`BridgeResponse`]: every
/// other variant is a protocol error for this request, and a caller that
/// had to match them would be free to treat one as an empty roster. An
/// empty roster and a bridge that answered something else are different
/// facts, and only one of them is a valid render.
pub async fn list_agent_identities(&self) -> Result<Vec<String>> {
match self.request(&BridgeRequest::ListAgentIdentities).await? {
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:?}")
}
}
}
/// One authenticated round-trip to the bridge.
///
/// A fresh token per call, deliberately — see the module doc. Shared by
/// every operation so the auth and the error handling cannot drift
/// between them; a second copy is how one path ends up treating a 500 as
/// an answer.
async fn request(&self, req: &BridgeRequest) -> Result<BridgeResponse> {
// `mint_token_for` builds its own HTTP client (trusting the queue's
// configured CA, if any) — deliberately not `self.http`, which is
// the bridge's own client and has nothing to do with authelia's
// token endpoint's trust anchors.
let token = swarm_queue_client::mint_token_for(&self.queue_cfg)
.await
.context("minting a bearer token for swarm-authelia-bridge")?;
let response = self
.http
.post(format!("{}/requests", self.base_url))
.bearer_auth(token)
.json(req)
.send()
.await
.context("calling swarm-authelia-bridge")?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("swarm-authelia-bridge refused the request ({status}): {body}");
}
serde_json::from_str(&body).context("parsing swarm-authelia-bridge's response")
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The common case: no bridge wired up for this deployment.
#[test]
fn absent_url_is_not_an_error() {
assert!(std::env::var("SWARM_CONTROLLER_AUTH_BRIDGE_URL").is_err());
assert!(
AuthBridge::from_env()
.expect("absent is not an error")
.is_none()
);
}
}