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
14
swarm-authelia-bridge-sock/Cargo.toml
Normal file
14
swarm-authelia-bridge-sock/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "swarm-authelia-bridge-sock"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
readme = "README.md"
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
23
swarm-authelia-bridge-sock/README.md
Normal file
23
swarm-authelia-bridge-sock/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# swarm-authelia-bridge-sock
|
||||
|
||||
Wire types for the **`swarm-authelia-bridge` socket** — the contract between
|
||||
`swarm-authelia-bridge` (server, runs alongside `swarm-authelia`) and
|
||||
`swarm-controller` (client).
|
||||
|
||||
## Why it's its own crate
|
||||
|
||||
Same rationale as `hive-priv-sock` (which this mirrors in spirit, though the
|
||||
transport differs — this bridge is network-facing HTTP, not a unix socket,
|
||||
since it has to reach a possibly-split-host `swarm-controller`): the bridge
|
||||
is a narrowly-scoped, unprivileged-but-file-owning helper, and splitting the
|
||||
wire contract out of any larger crate keeps both its own dependency
|
||||
footprint and its interface small enough to audit at a glance. No server or
|
||||
client logic here, only the request/response shapes both sides import.
|
||||
|
||||
## Shape
|
||||
|
||||
One operation today: idempotently ensure an agent exists as an authelia
|
||||
subject. Deliberately **not** a wholesale-replace-the-file API — the bridge
|
||||
owns both `users.json` (canonical) and rendering `users.yml` internally; a
|
||||
caller only ever asks for one user to exist, never sends rendered YAML or a
|
||||
file blob. See `swarm-authelia-bridge/README.md` for the helper itself.
|
||||
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