hyperhive/swarm-authelia-bridge/src/store.rs
atlas 3700167279 feat(swarm-authelia-bridge): mark agent identities with a group, and answer for the set
The users database holds humans and agents in one namespace and nothing in
it said which was which, so a roster read had no predicate to read with.

Marks positively, at creation. The alternative — everyone who is not an
operator — fails in the direction that matters: an account created without
a group is an operator who cannot log in, a mistake the swarm UI docs
already warn about, and it would have rendered as an agent. The group is a
constant for the same reason the operator group it mirrors is one.

An identity that predates the marker gains it when agent creation runs
again, which is already the agreed migration for those; AlreadyExists
therefore reports that the subject was there, not that nothing was written.

ListAgentIdentities reads through this process because the store is owned
by a uid swarm-controller does not have — the same reason the write goes
through here — and answers with names alone, never the digests it sits next
to.
2026-08-20 00:15:48 +02:00

620 lines
26 KiB
Rust

//! Authelia's users database — **the** store, not a rendering of one.
//!
//! There used to be a private `users.json` here, canonical, with `users.yml`
//! rendered from it, while `swarmctl` kept its own pair against the *same*
//! physical file. Two canonical stores for one file is a seam, and it bit: a
//! writer whose own JSON was absent refused to write at all, unable to tell
//! "nothing here yet" from "someone else's users". The JSON bought nothing —
//! it was read back on every load, so the round-trip it seemed to avoid was
//! already being paid.
//!
//! ⚠️ The file is **round-tripped**, so comments and hand-formatting do not
//! survive a write; values and unmodelled keys do (see `extra`).
//!
//! Deliberately its own copy of the shape rather than code shared with
//! `swarmctl::users` — same "factor out if the duplication actually bites"
//! reasoning as `swarm-controller::forge` mirroring `hive-c0re::forge`. What
//! both copies must uphold is in `../README.md`.
use std::collections::BTreeMap;
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};
/// The canonical store — this *is* `users.yml`, deserialised. `BTreeMap`
/// for a stable, diffable file: authelia does not care about key order, but
/// a human reading `git diff` does.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UserStore {
pub users: BTreeMap<String, User>,
/// Top-level keys this binary does not model, carried through a
/// round-trip untouched.
///
/// Two processes write this file and neither is authoritative about the
/// other's fields. Without this, whichever writes second silently
/// deletes anything the first added — the same shape as the bug that
/// made one file canonical in the first place, one level down.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_norway::Value>,
}
#[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>,
/// Per-user keys this binary does not model (authelia's `disabled`, for
/// one). Same preservation rule as [`UserStore::extra`].
#[serde(flatten)]
pub extra: BTreeMap<String, serde_norway::Value>,
}
/// Names an agent may never take, because something else already answers to
/// them.
///
/// **Derived, not invented** — every entry is a literal that exists in the
/// code today. The first three are broker *recipients* (`send(to: …)`); the
/// rest are message *senders*, and they matter for a reason that is not
/// cosmetic: the harness switches on the sender name, so an agent called
/// `todo` would have its messages interpreted as todo-wakes and one called
/// `system` as helper events. That is a parse, not a display quirk.
///
/// Sorted so a reader can scan it; the lookup is a linear walk over eight
/// short strings on a path that already reads a file off disk.
pub const RESERVED_AGENT_NAMES: &[&str] = &[
"forge", "manager", "operator", "reminder", "root", "schedule", "system", "todo",
];
/// Refuse a name the swarm has already given a meaning.
///
/// ⚠️ **Deliberately NOT part of [`validate_username`].** That function runs
/// from [`validate`] over *every* user on *every* write, including humans
/// this bridge did not create. Folding the reserved list into it would mean
/// that a store already containing an operator called `operator` could never
/// be written again — a rule about what an agent may be *named* would have
/// become a rule about what the file may *contain*, and bricked it.
///
/// So this is called from the one path that names a new agent, and nowhere
/// else.
pub fn reject_reserved_name(name: &str) -> Result<()> {
if RESERVED_AGENT_NAMES.contains(&name) {
bail!(
"{name:?} is reserved: the swarm already routes messages to or from that name, so an \
agent with it would have its own messages misread. Reserved: {}",
RESERVED_AGENT_NAMES.join(", ")
);
}
Ok(())
}
/// The group that marks a subject as an agent.
///
/// One file holds humans and agents, and nothing else in it says which is
/// which — so the roster needs a predicate, and a positive mark is the only
/// one that fails safe. The alternative, *"everyone who is not an
/// operator"*, misfiles a **human**: an account created without a group is
/// an operator who cannot log in, a documented mistake, and it would read as
/// an agent.
///
/// A constant rather than an option, for the reason `swarm-authelia.nix`
/// already gives about the operator group it mirrors: a configurable name is
/// one more way for the rule and the account to disagree silently.
pub const AGENT_GROUP: &str = "agents";
/// Add the agent marker to an existing subject if it is missing; reports
/// whether anything changed, so the caller knows whether a write is owed.
///
/// Exists because an identity created before the marker did is otherwise
/// invisible to the roster forever. The agreed migration for those is *"run
/// agent creation again with the same name"*, and this is what makes that
/// heal rather than no-op.
pub fn mark_as_agent(store: &mut UserStore, name: &str) -> bool {
let Some(user) = store.users.get_mut(name) else {
return false;
};
if user.groups.iter().any(|g| g == AGENT_GROUP) {
return false;
}
user.groups.push(AGENT_GROUP.to_owned());
true
}
/// The agent names in the store, sorted.
///
/// Sorted because [`UserStore::users`] is a `BTreeMap`, so this is free and
/// a consumer diffing two answers sees real changes rather than reordering.
pub fn agent_names(store: &UserStore) -> Vec<String> {
store
.users
.iter()
.filter(|(_, user)| user.groups.iter().any(|g| g == AGENT_GROUP))
.map(|(name, _)| name.clone())
.collect()
}
/// 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(())
}
/// Render the store as authelia's YAML users database.
///
/// Serialisation is `serde_norway`'s — quoting an argon2 digest (`$`, `=`,
/// `,`, `/`) is exactly the kind of thing a real emitter gets right and a
/// hand-rolled one gets right until it doesn't.
///
/// ⚠️ **Still fallible, and that is the load-bearing part.** It re-validates
/// every value it is about to emit, so *"no control character ever reaches
/// this file"* stays a property of the **one path that writes it** rather
/// than a rule every caller has to remember. A bare `to_string(&store)`
/// would serialise perfectly and drop that guarantee silently.
pub fn render_yaml(store: &UserStore) -> Result<String> {
validate(store)?;
serde_norway::to_string(store).context("serialising the users database")
}
/// Everything [`render_yaml`] refuses to write. Separate so the rule can be
/// tested directly, and so a second writer can call it without going
/// through serialisation.
fn validate(store: &UserStore) -> Result<()> {
for (name, user) in &store.users {
validate_username(name)?;
reject_control_chars("displayname", &user.displayname)?;
reject_control_chars("password digest", &user.password)?;
if let Some(email) = &user.email {
reject_control_chars("email", email)?;
}
for group in &user.groups {
reject_control_chars("group", group)?;
}
}
Ok(())
}
/// Load the users database, or start empty when it does not exist yet.
///
/// ⚠️ **There is deliberately no overwrite guard here any more.** The old
/// one refused to write when a private JSON store was missing but
/// `users.yml` held users — it existed to police *two* stores that could
/// disagree, and with one store there is nothing to disagree with. Reading
/// the same file we are about to write means we cannot clobber users we did
/// not know about: we just read them.
///
/// An absent or empty file is an empty store, not an error: first boot is a
/// legitimate state, and the seed document authelia's own unit writes
/// (`users: {}`) deserialises to exactly that with no special case.
pub fn load_store(users_file: &Path) -> Result<UserStore> {
match fs::read_to_string(users_file) {
Ok(raw) if raw.trim().is_empty() => Ok(UserStore::default()),
Ok(raw) => serde_norway::from_str(&raw)
.with_context(|| format!("parsing the users database at {}", users_file.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(UserStore::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", users_file.display())),
}
}
/// 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(users_file: &Path, store: &mut UserStore) -> Result<()> {
fill_missing_emails(store);
// One file, so there is no longer an ordering question between two
// writes — the old version wrote the JSON store first so a crash
// between them left something to repair from. That whole failure mode
// belonged to having two files.
let rendered = render_yaml(store)?;
write_atomic(users_file, &rendered)
}
/// Domain for an address this bridge invents — the same one
/// `swarmctl::users` uses, and the same one `hive-c0re` already gives every
/// agent's forge account. Never routable, deliberately: nothing sends mail
/// here, the address exists so a relying party asking for an `email` claim
/// gets one.
const SYNTHETIC_EMAIL_DOMAIN: &str = "hyperhive.local";
/// Give every user an address before the file is written.
///
/// 🩸 **This bridge did not do it, and `swarmctl` did.** The fix for the
/// missing-email defect landed in `swarmctl` only, so every identity created
/// *here* landed in `users.yml` with no `email` — and a relying party that
/// asks for the claim does not degrade, it fails (grafana's OIDC login is
/// the measured case: it falls through to an endpoint authelia does not
/// implement and dies with an internal error). Two writers of one file
/// disagreeing about a required field is not a difference worth keeping, so
/// the rule now lives on both write paths.
fn fill_missing_emails(store: &mut UserStore) {
for (name, user) in &mut store.users {
if user.email.is_none() {
user.email = Some(format!("{name}@{SYNTHETIC_EMAIL_DOMAIN}"));
}
}
}
/// 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::*;
/// What the `swarm-authelia` module's first-boot unit writes into
/// `users.yml` when there is no database yet.
///
/// A test fixture rather than a production constant: nothing in this
/// binary writes a seed any more — the store deserialises whatever is
/// there and an absent file is an empty store. Keeping it `pub` in the
/// module would be a constant nothing reads, which is exactly the kind
/// of leftover that makes the next reader think the seed dance is still
/// load-bearing.
const SEED_USERS_FILE: &str = "users: {}";
fn user(password: &str) -> User {
User {
displayname: "atlas".to_owned(),
password: password.to_owned(),
email: None,
groups: Vec::new(),
extra: BTreeMap::new(),
}
}
fn agent(password: &str) -> User {
User {
groups: vec![AGENT_GROUP.to_owned()],
..user(password)
}
}
/// The group an operator carries — a literal here rather than a shared
/// constant, because this binary does not read the operator group and a
/// test that imported one would imply it did.
fn operator(password: &str) -> User {
User {
groups: vec!["admins".to_owned()],
..user(password)
}
}
/// The document `swarm-authelia`'s first-boot unit writes must load as
/// an empty store with no special case — otherwise a fresh hive's very
/// first `EnsureAgentIdentity` fails on a file we wrote ourselves.
#[test]
fn the_first_boot_seed_loads_as_an_empty_store() {
let store: UserStore = serde_norway::from_str(SEED_USERS_FILE).expect("seed parses");
assert!(store.users.is_empty());
assert!(store.extra.is_empty());
}
/// An argon2 digest is `$`, `=`, `,` and `/` — the reason a real
/// emitter replaced the hand-rolled one. Asserted by **round-trip**,
/// not by looking for quotes: how the emitter chooses to quote is its
/// business, that the value survives is ours.
#[test]
fn an_argon2_digest_survives_a_round_trip() {
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");
let back: UserStore = serde_norway::from_str(&out).expect("reparses");
assert_eq!(back.users["atlas"].password, digest);
}
/// A key neither writer models must survive the other's write. Without
/// this, two processes sharing one file silently delete each other's
/// fields — the same class of bug as the two stores this replaced.
#[test]
fn unknown_keys_survive_a_round_trip() {
let source = "\
users:
atlas:
displayname: atlas
password: \"$argon2id$x\"
disabled: true
some_future_top_level_key: 7
";
let store: UserStore = serde_norway::from_str(source).expect("parses");
let out = render_yaml(&store).expect("renders");
assert!(
out.contains("disabled"),
"a per-user key this binary does not model was dropped: {out}"
);
assert!(
out.contains("some_future_top_level_key"),
"a top-level key this binary does not model was dropped: {out}"
);
}
/// Every reserved word is refused, named individually rather than looped
/// over the constant — a test that iterates the list it is testing passes
/// just as happily when the list is empty.
#[test]
fn every_reserved_name_is_refused() {
for name in [
"operator", "manager", "root", "todo", "forge", "reminder", "system", "schedule",
] {
assert!(
reject_reserved_name(name).is_err(),
"{name:?} must be reserved"
);
}
}
/// The control: an ordinary agent name is not caught. Without this the
/// arm above would pass a function that refused everything.
#[test]
fn an_ordinary_name_is_not_reserved() {
for name in ["atlas", "iris", "damocles", "operator-2", "todos"] {
reject_reserved_name(name)
.unwrap_or_else(|e| panic!("{name:?} should be allowed: {e}"));
}
}
/// 🔑 The rule must NOT live in `validate_username`, because that runs
/// from `validate` over every user on every write — including humans this
/// bridge did not create. A store that already contains an operator named
/// `operator` has to stay writable, or a naming rule about *agents* would
/// silently become a rule about what the *file* may contain.
#[test]
fn a_reserved_name_already_in_the_store_does_not_block_writes() {
let mut store = UserStore::default();
store
.users
.insert("operator".to_owned(), user("$argon2id$a"));
render_yaml(&store).expect("an existing subject with a reserved name must stay writable");
assert!(
reject_reserved_name("operator").is_err(),
"...while still being refused as a NEW agent name"
);
}
/// The roster's whole job: one file holds humans and agents, and only
/// the marker separates them. Asserted with an operator present, because
/// a filter that returned everyone would pass a fixture of agents alone.
#[test]
fn the_roster_is_the_marked_subjects_and_not_the_file() {
let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), agent("$argon2id$a"));
store
.users
.insert("mara".to_owned(), operator("$argon2id$b"));
// An account with no group at all: the documented mistake of adding
// an operator without `--group`. It must NOT read as an agent.
store
.users
.insert("ungrouped".to_owned(), user("$argon2id$c"));
assert_eq!(agent_names(&store), ["atlas"]);
}
/// An identity that predates the marker is invisible to the roster until
/// agent creation runs again — which is the agreed migration, so it has
/// to actually change something.
#[test]
fn marking_an_existing_identity_is_a_change_once_and_never_again() {
let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), user("$argon2id$a"));
assert!(agent_names(&store).is_empty(), "fixture starts unmarked");
assert!(mark_as_agent(&mut store, "atlas"), "the first mark writes");
assert_eq!(agent_names(&store), ["atlas"]);
assert!(
!mark_as_agent(&mut store, "atlas"),
"a second call must report no change — otherwise every ensure \
rewrites the file and wakes authelia's watcher"
);
assert!(
!mark_as_agent(&mut store, "nobody"),
"marking a subject that does not exist creates nothing"
);
assert!(!store.users.contains_key("nobody"));
}
/// The marker is a group like any other, so it must survive the
/// round-trip that every write goes through — a marker that renders but
/// does not re-parse would make the roster empty after one restart.
#[test]
fn the_marker_survives_a_round_trip() {
let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), agent("$argon2id$a"));
let out = render_yaml(&store).expect("renders");
let back: UserStore = serde_norway::from_str(&out).expect("reparses");
assert_eq!(agent_names(&back), ["atlas"]);
}
#[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.
/// 🩸 The asymmetry this fixes: the earlier missing-email fix gave
/// `swarmctl`-created humans a synthetic address and left bridge-created
/// **agents** with none, so two writers of one file disagreed about a
/// field a relying party treats as required — grafana's OIDC login fails
/// outright without it rather than degrading.
///
/// Asserted through the real write path, because that is where the rule
/// lives: `handle` inserts a user with `email: None` and nothing between
/// there and the file would notice.
#[test]
fn an_agent_identity_reaches_the_file_with_an_email() {
let dir = tempdir();
let users_file = dir.join("users.yml");
let mut store = UserStore::default();
store.users.insert("atlas".to_owned(), user("$argon2id$x"));
assert!(
store.users["atlas"].email.is_none(),
"the fixture must start with the state `handle` creates"
);
publish(&users_file, &mut store).expect("publish");
let reloaded = load_store(&users_file).expect("reload");
assert_eq!(
reloaded.users["atlas"].email.as_deref(),
Some("atlas@hyperhive.local"),
"an agent must not land in the file without an email"
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn publish_then_load_round_trips_through_the_real_file() {
let dir = tempdir();
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(&users_file, &mut store).expect("publish");
let reloaded = load_store(&users_file).expect("reload");
assert!(reloaded.users.contains_key("atlas"));
fs::remove_dir_all(&dir).ok();
}
/// The bug this change closes: a `users.yml` holding users that this
/// binary did not write is **loaded**, not refused. The old code kept a
/// private store alongside it and bailed when the two disagreed.
#[test]
fn a_users_file_written_by_someone_else_is_read_not_refused() {
let dir = tempdir();
let users_file = dir.join("users.yml");
fs::write(
&users_file,
"users:\n mara:\n displayname: mara\n password: \"$argon2id$x\"\n",
)
.unwrap();
let mut store = load_store(&users_file).expect("must load a foreign users file");
assert!(store.users.contains_key("mara"));
// …and adding to it keeps the existing user rather than replacing.
store
.users
.insert("atlas".to_owned(), user("$argon2id$second"));
publish(&users_file, &mut store).expect("publish");
let reloaded = load_store(&users_file).expect("reload");
assert!(reloaded.users.contains_key("mara"), "existing user lost");
assert!(reloaded.users.contains_key("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
}
}