//! Wire types for the `swarm-authelia-bridge` socket. //! //! Both `swarm-authelia-bridge` (server) and `swarm-controller` (client) //! import these so the shapes stay in sync. No server or client protocol //! logic lives here, only the JSON contract carried as the body of the //! bridge's one HTTP endpoint (`POST /requests`, bearer-authenticated) — //! see `swarm-authelia-bridge`'s own docs for the transport. //! //! # Why a bridge at all, and why this shape //! //! `swarm-authelia`'s users database (`users.yml`) is owned by the //! `authelia-swarm` system user, a different uid than `swarm-controller`'s //! own — so `swarm-controller` cannot write it directly without either root //! (`CAP_CHOWN`) or a shared group, both rejected for the same reason //! `swarmctl`'s own README already rejected them for this exact file. The //! fix taken instead: run this bridge's own //! systemd unit as `User = "authelia-swarm";` — the literal name //! `swarm-authelia.nix` already derives, resolved by systemd at start, no //! numeric uid ever hand-pinned into nix eval — so the bridge simply *owns* //! the file it writes. Fully ordinary permissions, no capabilities, no root. //! //! **Per-operation, not wholesale-replace.** [`BridgeRequest::EnsureAgentIdentity`] //! asks for one user to exist; the bridge reads `users.yml`, changes what the //! request named, and writes it back. A caller never sends rendered YAML or a //! 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 /// file-replace API. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum BridgeRequest { /// Idempotently ensure `name` exists as an authelia subject — /// `swarm-controller`'s `SwarmNodeKind::CreateIdentity` node's entire /// job. Idempotence is load-bearing (agent creation's own design /// consensus): re-running this for an agent that already has an /// identity is a /// genuine no-op, reported as [`BridgeResponse::AlreadyExists`] — no /// password re-mint, no `users.yml` rewrite, nothing for authelia's /// `file.watch` to react to. EnsureAgentIdentity { /// The agent's name — becomes the authelia username verbatim. The /// bridge validates this server-side (same conservative charset /// `swarmctl::users::validate_username` already enforces); this /// crate carries the wire shape only, not the validation rule. name: String, }, /// Every agent identity the swarm holds — the roster. /// /// Reads the same file [`BridgeRequest::EnsureAgentIdentity`] writes, /// through the same process, for the same reason: the store is owned by /// a uid `swarm-controller` does not have. A reader that opened the file /// itself would also be a second parser of a format this bridge owns. /// /// Answers *"which agents exist"*, never *"which are healthy"* — those /// are separate sets on purpose, and an agent in the roster that has /// never reported is not the same as one that does not exist. ListAgentIdentities, } /// One roster entry. /// /// A record rather than a bare name because the roster is the set other /// swarm-level views are *complete against*, so an entry is a thing later /// facts attach to. It carries what identifies an agent and nothing else — /// notably not the user's groups (an authorisation detail this answer has no /// reason to publish) and never the password digest. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentIdentity { /// The authelia username, which is the agent's name. /// /// [`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`]. /// /// `#[serde(tag = "status")]` rather than a bare `Result`-shaped wrapper: an /// external tag reads directly as a named outcome on the wire /// (`{"status":"created",...}`), with no separate "was this an error" /// boolean to keep in sync with which variant it is. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum BridgeResponse { /// The agent had no identity yet; one was minted and `users.yml` was /// rewritten. Created, /// The agent already had an identity **and already carried the agent /// marker**, so nothing was minted and nothing was written. /// /// The uneventful case: re-running agent creation for an agent that is /// already an agent. AlreadyExists, /// A subject with this name existed **without** the agent marker, and /// this call added it. /// /// ⚠️ **Its own variant, not a flag on [`Self::AlreadyExists`], because /// the two cases carry different risk and a `bool` field is ignorable.** /// Adding a variant makes every existing `match` fail to compile until /// its author decides what to do about a heal; a field would let the /// dangerous case keep travelling as the safe one, which is how it went /// unreported before. /// /// Why the risk differs: the store cannot tell a pre-marker *agent* /// identity from a *human* operator account created without a group — /// both are simply `groups: []`. So this outcome is either the intended /// migration (re-running creation is the whole of that story) or an /// agent quietly joining a person's live SSO account. The caller is the /// only place with the context to tell those apart, so it has to be /// told the write happened. Healed, /// The roster, in answer to [`BridgeRequest::ListAgentIdentities`]. Agents { /// Sorted by name — the store is a `BTreeMap`, and a stable order /// means a consumer diffing two answers sees real changes only. agents: Vec, }, /// The request was rejected or the write failed. Carries a message /// for the caller to log/propagate, not a typed error enum: the /// failure modes here (bad username, authelia binary failed, disk /// full) have no caller-actionable distinction today — see /// `PrivResponse` in `hive-priv-sock` for the same reasoning. Error { message: String }, } #[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 /// cross-referencing this crate's source. /// /// **Every variant, deliberately.** A test that claims to pin the wire /// shape and covers all but one is worse than a narrower one: the next /// variant gets added with nothing to remind its author that this is /// where the shape is settled. #[test] fn response_variants_tag_on_status() { let created = serde_json::to_value(BridgeResponse::Created).unwrap(); assert_eq!(created, serde_json::json!({"status": "created"})); let exists = serde_json::to_value(BridgeResponse::AlreadyExists).unwrap(); assert_eq!(exists, serde_json::json!({"status": "already_exists"})); // Distinct on the wire from `already_exists`, which is the whole // point of it being a separate variant: a reader tailing these has // to be able to see a heal without knowing the Rust type. let healed = serde_json::to_value(BridgeResponse::Healed).unwrap(); assert_eq!(healed, serde_json::json!({"status": "healed"})); assert_ne!(healed, exists); let agents = serde_json::to_value(BridgeResponse::Agents { agents: vec![AgentIdentity { name: Ident::parse("atlas").expect("valid ident"), }], }) .unwrap(); assert_eq!( agents, serde_json::json!({"status": "agents", "agents": [{"name": "atlas"}]}) ); let err = serde_json::to_value(BridgeResponse::Error { message: "boom".to_owned(), }) .unwrap(); assert_eq!( err, serde_json::json!({"status": "error", "message": "boom"}) ); } #[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(), }; let json = serde_json::to_string(&req).unwrap(); let back: BridgeRequest = serde_json::from_str(&json).unwrap(); let BridgeRequest::EnsureAgentIdentity { name } = back else { panic!("round-tripped into a different variant: {back:?}"); }; assert_eq!(name, "atlas"); let json = serde_json::to_string(&BridgeRequest::ListAgentIdentities).unwrap(); let back: BridgeRequest = serde_json::from_str(&json).unwrap(); assert!(matches!(back, BridgeRequest::ListAgentIdentities)); } /// An empty roster must be a roster, not an absence: `{"agents":[]}` /// says "no agents exist", and a consumer that cannot tell that from a /// missing field will render a swarm it failed to read as an empty one. #[test] fn an_empty_roster_still_serialises_its_list() { let empty = serde_json::to_value(BridgeResponse::Agents { agents: vec![] }).unwrap(); assert_eq!(empty, serde_json::json!({"status": "agents", "agents": []})); } /// The roster entry carries the name and nothing else. Asserted on the /// serialised keys rather than on the struct, because the risk is a /// future field being added here and reaching the wire unnoticed — this /// answer is derived from a file of password digests. #[test] fn a_roster_entry_carries_only_the_name() { let entry = serde_json::to_value(AgentIdentity { name: Ident::parse("atlas").expect("valid ident"), }) .unwrap(); let keys: Vec<&String> = entry.as_object().expect("an object").keys().collect(); assert_eq!(keys, ["name"], "the roster names agents, nothing more"); } }