hyperhive/swarm-authelia-bridge-sock/src/lib.rs
atlas a248db1fa2 feat(swarm-authelia-bridge): report a heal as its own outcome
`EnsureAgentIdentity` answered `AlreadyExists` whether it had added the
agent marker to an existing subject or done nothing at all, and the
controller discarded the answer outright. So the one case worth telling
a human about — a subject that was NOT an agent a moment ago — could not
survive the socket, let alone reach a log.

`Healed` is a variant rather than a field on `AlreadyExists` because a
field is ignorable: adding a variant makes every existing match fail to
compile until its author decides what a heal means. That is the property
the old shape lacked.

The store cannot tell a pre-marker agent identity from a human operator
account created without a group — both are `groups: []`. So this is
either the intended migration or an agent joining a person's live SSO
account, and only the caller has the context to tell them apart.

Gated by state/gate-3549-heal.sh (8 arms + mutation): the mutation
collapses Healed back and reddens the discriminating arm while leaving
the anti-noise arm green. The W' control asserts exactly one warn in the
whole run, so a build that warned on every routine ensure would fail.
2026-08-23 19:00:41 +02:00

232 lines
11 KiB
Rust

//! 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<AgentIdentity>,
},
/// 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");
}
}