110 lines
4.9 KiB
Rust
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");
|
|
}
|
|
}
|