add swarm-authelia-bridge: the only thing allowed to write swarm-authelia's users database

This commit is contained in:
damocles 2026-08-16 21:38:53 +02:00 committed by mara
commit fb5d461e52
10 changed files with 876 additions and 0 deletions

View file

@ -0,0 +1,312 @@
//! The canonical user store this bridge owns, and the authelia users
//! database rendered from it.
//!
//! Deliberately its own copy, not shared code with `swarmctl::users` even
//! though the YAML shape is identical (same "factor out later if
//! duplication actually bites" reasoning as `swarm-controller::forge`
//! mirroring `hive-c0re::forge` — see the agent-identity-at-swarm-level
//! design thread). One real difference from `swarmctl`'s copy: this
//! store lives **wherever this
//! bridge runs** (inside the `swarm-authelia` container, alongside
//! `authelia-swarm`'s own state), not under `swarm-controller`'s state
//! dir — the two are not guaranteed to be the same host once
//! `swarm-authelia` and `swarm-controller` split across hosts, and this
//! bridge only ever runs where `swarm-authelia` does.
//!
//! ⚠️ **Known limitation, not solved here**: `swarmctl` still writes its
//! own independent `users.json`/`users.yml` for human accounts, assuming
//! co-location with `swarm-controller`'s host. Two independent canonical
//! stores for the same physical `users.yml` is a real seam — tracked as a
//! follow-up (route `swarmctl` through this bridge too), not attempted in
//! this slice, whose scope is agent identities only.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs::{self, File, Permissions};
use std::io::Write as _;
use std::os::unix::fs::PermissionsExt as _;
use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
/// What the `swarm-authelia` module's first-boot unit writes into
/// `users.yml` when there is no database yet — same constant/shape
/// `swarmctl::users::SEED_USERS_FILE` uses, since both write the same
/// physical file format.
pub const SEED_USERS_FILE: &str = "users: {}";
/// The canonical store, serialised as JSON. `BTreeMap` for a stable,
/// diffable render — same rationale as `swarmctl::users::UserStore`.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UserStore {
pub users: BTreeMap<String, User>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct User {
pub displayname: String,
/// The argon2 **digest**, never a plaintext password.
pub password: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
}
/// Same conservative charset `swarmctl::users::validate_username` already
/// enforces — usernames are YAML map keys, log lines, and access-control
/// subjects, so keeping them to plain ASCII means nothing downstream ever
/// has to reason about a name that needs quoting.
pub fn validate_username(name: &str) -> Result<()> {
if name.is_empty() || name.len() > 64 {
bail!("username must be 1..=64 characters, got {}", name.len());
}
if !name.starts_with(|c: char| c.is_ascii_alphanumeric()) {
bail!("username must start with an ASCII letter or digit: {name:?}");
}
if let Some(bad) = name
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
{
bail!("username may only contain [A-Za-z0-9._-], found {bad:?} in {name:?}");
}
Ok(())
}
/// Reject control characters rather than escape them — same reasoning as
/// `swarmctl::users::reject_control_chars`: a display name or email
/// carrying one is a bug or an injection attempt in every real case.
fn reject_control_chars(field: &str, value: &str) -> Result<()> {
if let Some(c) = value.chars().find(|c| c.is_control()) {
bail!(
"{field} contains control character U+{:04X}; refusing to write it",
c as u32
);
}
Ok(())
}
/// A double-quoted YAML scalar — everything is quoted, including values
/// that would be fine bare, since an argon2 digest alone contains `$`,
/// `=`, `,` and `/`. Control characters are excluded upstream, so `"` and
/// `\` are the complete escape set.
fn quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(c),
}
}
out.push('"');
out
}
/// Render the store as authelia's block-style YAML users database.
/// Fallible for the same reason `swarmctl::users::render_yaml` is: it
/// re-validates every value it is about to emit, so "no control character
/// ever reaches the file" is a property of the one path that writes it.
pub fn render_yaml(store: &UserStore) -> Result<String> {
if store.users.is_empty() {
return Ok(format!("{SEED_USERS_FILE}\n"));
}
let mut out = String::from("users:\n");
for (name, user) in &store.users {
validate_username(name)?;
reject_control_chars("displayname", &user.displayname)?;
reject_control_chars("password digest", &user.password)?;
writeln!(out, " {name}:")?;
writeln!(out, " displayname: {}", quote(&user.displayname))?;
writeln!(out, " password: {}", quote(&user.password))?;
if let Some(email) = &user.email {
reject_control_chars("email", email)?;
writeln!(out, " email: {}", quote(email))?;
}
if !user.groups.is_empty() {
writeln!(out, " groups:")?;
for group in &user.groups {
reject_control_chars("group", group)?;
writeln!(out, " - {}", quote(group))?;
}
}
}
Ok(out)
}
/// Load the canonical store, or start an empty one if this bridge has
/// never written a user. Same overwrite guard as
/// `swarmctl::load_store`: starting empty means the next write
/// **overwrites** `users_file`, which is only safe when that file is
/// still the untouched first-boot seed.
pub fn load_store(store_path: &Path, users_file: &Path) -> Result<UserStore> {
match fs::read_to_string(store_path) {
Ok(raw) => serde_json::from_str(&raw)
.with_context(|| format!("parsing the user store at {}", store_path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
match fs::read_to_string(users_file) {
Ok(existing) if !is_untouched_seed(&existing) => bail!(
"no user store at {} but {} already holds users — refusing to \
overwrite it",
store_path.display(),
users_file.display()
),
Ok(_) | Err(_) => Ok(UserStore::default()),
}
}
Err(e) => Err(e).with_context(|| format!("reading {}", store_path.display())),
}
}
/// Whether an existing `users.yml` is safe to take over — i.e. it is the
/// untouched first-boot seed. Empty counts too: a zero-byte file holds
/// nothing to lose.
fn is_untouched_seed(contents: &str) -> bool {
let trimmed = contents.trim();
trimmed.is_empty() || trimmed == SEED_USERS_FILE
}
/// Write the store + the rendered users file. **No restart** (the load-
/// bearing difference from `swarmctl::publish`): this bridge relies on
/// authelia's `authentication_backend.file.watch`, confirmed working
/// against the pinned 4.39.20 build during this design work — a
/// restart would drop every active SSO session, which mara ruled out for
/// agent creation specifically (not a rare, human-initiated event).
pub fn publish(store_path: &Path, users_file: &Path, store: &UserStore) -> Result<()> {
let rendered = render_yaml(store)?;
let store_json = serde_json::to_string_pretty(store).context("serialising the user store")?;
// Store first — if the store lands and the users file doesn't, the
// next run re-renders and repairs it. The other order loses a user.
write_atomic(store_path, &format!("{store_json}\n"))?;
write_atomic(users_file, &rendered)
}
/// Replace `path`'s contents atomically. Unlike `swarmctl::write_atomic`,
/// this does **not** need to preserve a foreign owner via `chown` — this
/// process runs as `users_file`'s own owning user (see the module doc:
/// the whole point of this bridge is running as `authelia-swarm`), so the
/// temp file it creates is already correctly owned. Preserves the
/// existing mode, same reasoning as `swarmctl`'s version (conservative
/// default for a file of password hashes).
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
let dir = path
.parent()
.with_context(|| format!("{} has no parent directory", path.display()))?;
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let name = path
.file_name()
.with_context(|| format!("{} has no file name", path.display()))?;
let tmp = dir.join(format!(
".{}.swarm-authelia-bridge.tmp",
name.to_string_lossy()
));
let mode = fs::metadata(path)
.ok()
.map_or(0o600, |meta| meta.permissions().mode() & 0o7777);
let mut file = File::create(&tmp).with_context(|| format!("creating {}", tmp.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("writing {}", tmp.display()))?;
file.sync_all()
.with_context(|| format!("flushing {}", tmp.display()))?;
drop(file);
fs::set_permissions(&tmp, Permissions::from_mode(mode))
.with_context(|| format!("setting mode on {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("renaming {} into place", tmp.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn user(password: &str) -> User {
User {
displayname: "atlas".to_owned(),
password: password.to_owned(),
email: None,
groups: Vec::new(),
}
}
#[test]
fn an_empty_store_renders_the_seed_document() {
let out = render_yaml(&UserStore::default()).expect("renders");
assert_eq!(out, "users: {}\n");
assert!(is_untouched_seed(&out));
}
#[test]
fn an_argon2_digest_survives_quoting() {
let digest = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$aGFzaA+/w==";
let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), user(digest));
let out = render_yaml(&store).expect("renders");
assert!(out.contains(&format!("password: \"{digest}\"")));
}
#[test]
fn usernames_outside_the_conservative_set_are_refused() {
for bad in ["", "-leading", "has space", "quote\"d", "sla/sh"] {
assert!(
validate_username(bad).is_err(),
"{bad:?} should be rejected"
);
}
for good in ["atlas", "svc-agent_1", "a.b"] {
validate_username(good).unwrap_or_else(|e| panic!("{good:?} rejected: {e}"));
}
}
#[test]
fn a_control_character_is_refused_not_escaped() {
let mut u = user("$argon2id$x");
u.displayname = "bad\nname".to_owned();
let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), u);
let err = render_yaml(&store).expect_err("must refuse");
assert!(err.to_string().contains("control character"));
}
/// `write_atomic` round-trips through a real temp dir — the property
/// under test is the rename-into-place, not just the render.
#[test]
fn publish_writes_both_files_and_the_store_reloads() {
let dir = tempdir();
let store_path = dir.join("users.json");
let users_file = dir.join("users.yml");
fs::write(&users_file, SEED_USERS_FILE).unwrap();
let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), user("$argon2id$x"));
publish(&store_path, &users_file, &store).expect("publish");
let reloaded = load_store(&store_path, &users_file).expect("reload");
assert!(reloaded.users.contains_key("atlas"));
let yaml = fs::read_to_string(&users_file).unwrap();
assert!(yaml.contains("atlas"));
fs::remove_dir_all(&dir).ok();
}
/// Test-only temp dir under the OS temp root — disposable scratch for
/// one test's lifetime, not durable state (see `state-not-tmp`: this
/// is exactly the legitimate use, not a place we persist anything).
fn tempdir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"swarm-authelia-bridge-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
dir
}
}