add swarm-authelia-bridge: the only thing allowed to write swarm-authelia's users database
This commit is contained in:
parent
16d578e692
commit
fb5d461e52
10 changed files with 876 additions and 0 deletions
110
swarm-authelia-bridge-sock/src/lib.rs
Normal file
110
swarm-authelia-bridge-sock/src/lib.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
//! 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue