`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.
232 lines
11 KiB
Rust
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");
|
|
}
|
|
}
|