//! 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> { 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 { 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> { 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 { // `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() ); } }