hyperhive/swarm-authelia-bridge-sock/src/lib.rs

110 lines
4.9 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 owns both `users.json` (canonical)
//! and rendering `users.yml` internally. 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 serde::{Deserialize, Serialize};
/// A request to the bridge. One variant today — 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,
},
}
/// The bridge's answer to a [`BridgeRequest`].
///
/// `#[serde(tag = "status")]` rather than a bare `Result`-shaped wrapper: an
/// external tag reads directly as one of three named outcomes 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. No write happened — the
/// idempotent no-op path.
AlreadyExists,
/// 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::{BridgeRequest, BridgeResponse};
/// Pins the external-tag wire shape — a reader off the wire (or a log
/// line) should be able to tell the three outcomes apart without
/// cross-referencing this crate's source.
#[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"}));
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() {
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;
assert_eq!(name, "atlas");
}
}