hyperhive/swarmctl/src/users.rs
atlas 63fc54edc5 refactor(#3393): stop restarting authelia from swarmctl
authelia now watches the users file, so the restart is redundant -- and it
was the wrong shape twice over. It could fail: a login was refused for a
user whose record was already correct on disk, with nothing in either log
implicating the reload. And it only ever worked for this writer --
swarm-authelia-bridge writes the same file and cannot restart anything,
since running unprivileged inside the container is the whole reason it may
write it at all. A reload that depends on which process did the writing is
not a reload.

--machine/--unit and their two env vars existed solely to name a
systemctl -M target, so they go with it. That drops two required settings
from the operator surface.

The three objections previously recorded against watch are all answered
now, and are kept next to the decision rather than deleted: the key is
verified against the pinned build (validate-config accepts it and rejects
a misspelling), the watch is on the directory so a rename is observed, and
partial reads are structurally impossible because every writer of this
file goes through write_atomic.
2026-08-17 19:22:27 +02:00

548 lines
20 KiB
Rust

//! The swarm's SSO user store, and the authelia users database rendered
//! from it.
//!
//! **Two files, and only one of them is authoritative.** `users.json` is
//! ours and canonical; `users.yml` is a rendered artifact for authelia,
//! written and never read back.
//!
//! That split is not a preference, it is what lets this crate exist
//! without a YAML parser: the workspace has none, and adding one costs a
//! crates.io fetch, a lock update and a vendor hash — for a schema we
//! fully control and only ever emit. Rendering in one direction needs no
//! parser at all.
//!
//! The tempting shortcut — *"JSON is a subset of YAML, so just write JSON
//! into the `.yml` and read it back with `serde_json`"* — is rejected on
//! purpose. authelia **refuses to start** on a users file it cannot
//! parse, so it fronts the whole SSO provider's boot, and "go-yaml
//! almost certainly accepts flow style" is not a claim worth betting that
//! on without running it. Block style is what upstream's own examples
//! show.
use std::collections::BTreeMap;
use std::fmt::Write as _;
use anyhow::{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.
///
/// Matched (after trimming) so a first `user add` can take the file over
/// without a flag, while anything *else* already in place is treated as
/// content somebody meant to be there. Overwriting a live SSO user
/// database because a canonical store happened to be missing is the one
/// unrecoverable mistake available here.
pub const SEED_USERS_FILE: &str = "users: {}";
/// The canonical store, serialised as JSON.
///
/// `BTreeMap` rather than `HashMap` so the rendered YAML is stable
/// between runs: a diffable artifact is worth more than the ordering
/// being meaningless, and a random-order rewrite makes every change look
/// like a whole-file change.
#[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** (`$argon2id$v=19$...`), never a plaintext
/// password. Nothing in this crate stores or logs a plaintext: the
/// one that exists is printed to the operator once and dropped.
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>,
}
/// Usernames are map **keys** in a YAML document, and they also reach
/// logs, session cookies and access-control rules. Keeping them to a
/// conservative ASCII set means the emitter never has to reason about a
/// key that needs quoting, and an authelia-side surprise can't be
/// something we handed it.
///
/// Deliberately tighter than authelia allows. Loosening later is a
/// one-line change; discovering that a name broke an access-control rule
/// in production is not.
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 in free text rather than escaping them.
///
/// The emitter below could encode them, but a display name or email
/// carrying a control character is a bug or an injection attempt in every
/// real case, and refusing is both simpler to reason about and impossible
/// to get subtly wrong.
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: the
/// argon2 digest alone contains `$`, `=`, `,` and `/`, and deciding
/// per-value which of those need quoting is exactly the kind of judgement
/// that is right until one day it isn't. Control characters are excluded
/// upstream by [`reject_control_chars`], 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 because it re-runs validation over every value it is about to
/// emit. That is not belt-and-braces: it makes "no control character ever
/// reaches the file" a property of the *only* path that writes it, rather
/// than a rule the call sites have to remember.
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)
}
/// A requested change to an existing user.
///
/// A plain struct rather than the CLI's `Args` type, so the merge rules in
/// [`apply_update`] can be tested without building a command line — and so
/// the single place that decides what *update* means has no opinion about
/// how it is spelled.
#[derive(Debug, Default)]
pub struct UserUpdate {
pub displayname: Option<String>,
pub email: Option<String>,
pub add_groups: Vec<String>,
pub remove_groups: Vec<String>,
}
/// Apply `update` to `user`, returning one line per change actually made.
///
/// **Removals are strict; everything else is idempotent.** That asymmetry
/// is the whole safety argument of this function, so it is deliberate
/// rather than an oversight:
///
/// - `--remove-group` on a group the user does not have **fails**. A
/// revocation that reports success without revoking is the one outcome
/// here nobody re-checks — you typo the group, the command says ok, and
/// the account keeps the access you believe you took away.
/// - Setting an attribute to the value it already holds, or adding a group
/// the user is already in, is **not** an error: the end state matches the
/// intent, and refusing would make the multi-attribute call this verb
/// exists for brittle — "set these four things" should not fail because
/// one of them was already right.
///
/// A command that changes *nothing at all* still fails, because it would
/// otherwise rewrite both files and restart the SSO provider to no effect.
///
/// On failure the `user` it was handed may be **partially mutated** — the
/// guarantee is not in-place atomicity but that the caller publishes
/// nothing on an error, so neither file and neither process ever sees a
/// half-applied update. Say it plainly rather than implying a rollback
/// this doesn't do.
///
/// Note what cannot be validated here: group names are free-form strings
/// with no registry, so a typo'd `--add-group` creates a group nothing
/// references, and the user silently gains no access. The caller prints the
/// resulting group list for exactly that reason — it is the only signal
/// available.
pub fn apply_update(user: &mut User, update: &UserUpdate) -> Result<Vec<String>> {
if let Some(dup) = update
.add_groups
.iter()
.find(|g| update.remove_groups.contains(g))
{
bail!("group {dup:?} is both added and removed; refusing to guess an order");
}
let mut changes = Vec::new();
if let Some(name) = &update.displayname
&& *name != user.displayname
{
reject_control_chars("displayname", name)?;
changes.push(format!("displayname: {:?} -> {name:?}", user.displayname));
user.displayname.clone_from(name);
}
if let Some(email) = &update.email
&& user.email.as_deref() != Some(email.as_str())
{
reject_control_chars("email", email)?;
changes.push(match &user.email {
Some(old) => format!("email: {old:?} -> {email:?}"),
None => format!("email: unset -> {email:?}"),
});
user.email = Some(email.clone());
}
for group in &update.remove_groups {
let Some(at) = user.groups.iter().position(|g| g == group) else {
bail!(
"user is not in group {group:?}, so there is nothing to revoke \
(groups: {})",
fmt_groups(&user.groups)
);
};
user.groups.remove(at);
changes.push(format!("removed from group {group:?}"));
}
for group in &update.add_groups {
if user.groups.iter().any(|g| g == group) {
continue;
}
reject_control_chars("group", group)?;
user.groups.push(group.clone());
changes.push(format!("added to group {group:?}"));
}
if changes.is_empty() {
bail!("nothing to change — every requested value is already set");
}
Ok(changes)
}
/// Group list for a message, so an empty one reads as a word rather than
/// as a missing value.
pub fn fmt_groups(groups: &[String]) -> String {
if groups.is_empty() {
"none".to_owned()
} else {
groups.join(", ")
}
}
/// Whether an existing `users.yml` is safe to take over when no canonical
/// store exists yet — i.e. it is the untouched first-boot seed.
///
/// Empty counts: a zero-byte file holds no users to destroy, and authelia
/// would refuse to start on it anyway, so treating it as untouched turns
/// a dead deployment into a working one instead of demanding a flag.
pub fn is_untouched_seed(contents: &str) -> bool {
let trimmed = contents.trim();
trimmed.is_empty() || trimmed == SEED_USERS_FILE
}
#[cfg(test)]
mod tests {
use super::*;
fn user(password: &str) -> User {
User {
displayname: "Test User".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("empty store renders");
assert_eq!(out, "users: {}\n");
assert!(
is_untouched_seed(&out),
"the empty rendering must itself read as the seed, or removing the \
last user would produce a file the next run refuses to take over"
);
}
/// The digest is the value most likely to break a naive emitter: it
/// carries `$`, `=`, `,` and `/`, and `,` in particular terminates a
/// YAML flow scalar.
#[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("mara".to_owned(), user(digest));
let out = render_yaml(&store).expect("renders");
assert!(
out.contains(&format!("password: \"{digest}\"")),
"digest must be emitted verbatim inside double quotes, got:\n{out}"
);
}
#[test]
fn quoting_escapes_backslash_and_quote() {
assert_eq!(quote(r#"a"b\c"#), r#""a\"b\\c""#);
}
#[test]
fn optional_fields_are_omitted_rather_than_emitted_empty() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let out = render_yaml(&store).expect("renders");
assert!(
!out.contains("email"),
"absent email must not appear:\n{out}"
);
assert!(
!out.contains("groups"),
"an empty group list must not appear:\n{out}"
);
}
#[test]
fn groups_render_as_a_block_sequence() {
let mut u = user("$argon2id$x");
u.email = Some("mara@example.com".to_owned());
u.groups = vec!["admins".to_owned(), "operators".to_owned()];
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), u);
let out = render_yaml(&store).expect("renders");
assert_eq!(
out,
concat!(
"users:\n",
" mara:\n",
" displayname: \"Test User\"\n",
" password: \"$argon2id$x\"\n",
" email: \"mara@example.com\"\n",
" groups:\n",
" - \"admins\"\n",
" - \"operators\"\n",
)
);
}
/// Ordering is a property of the artifact, not an accident: an
/// unordered rewrite makes every one-user change look like a
/// whole-file change to anyone diffing it.
#[test]
fn users_render_in_a_stable_order() {
let mut store = UserStore::default();
for name in ["zoe", "atlas", "mara"] {
store.users.insert(name.to_owned(), user("$argon2id$x"));
}
let out = render_yaml(&store).expect("renders");
let order: Vec<&str> = out
.lines()
.filter_map(|l| l.strip_prefix(" ").and_then(|l| l.strip_suffix(':')))
.collect();
assert_eq!(order, ["atlas", "mara", "zoe"]);
}
#[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("mara".to_owned(), u);
let err = render_yaml(&store).expect_err("a control character must not render");
assert!(
err.to_string().contains("control character"),
"unexpected error: {err}"
);
}
#[test]
fn usernames_outside_the_conservative_set_are_refused() {
for bad in ["", "-leading", "has space", "quote\"d", "sla/sh", "üni"] {
assert!(
validate_username(bad).is_err(),
"{bad:?} should have been rejected"
);
}
for good in ["mara", "atlas", "svc-agent_1", "a.b"] {
validate_username(good).unwrap_or_else(|e| panic!("{good:?} rejected: {e}"));
}
}
fn with_groups(groups: &[&str]) -> User {
let mut u = user("$argon2id$x");
u.groups = groups.iter().map(|g| (*g).to_owned()).collect();
u
}
/// The verb exists to change several things in one call, so the
/// composed case is the one that has to work.
#[test]
fn one_update_can_change_several_attributes() {
let mut u = with_groups(&["users"]);
let changes = apply_update(
&mut u,
&UserUpdate {
displayname: Some("Mara".to_owned()),
email: Some("mara@example.com".to_owned()),
add_groups: vec!["admins".to_owned()],
remove_groups: vec!["users".to_owned()],
},
)
.expect("applies");
assert_eq!(u.displayname, "Mara");
assert_eq!(u.email.as_deref(), Some("mara@example.com"));
assert_eq!(u.groups, ["admins"]);
assert_eq!(changes.len(), 4, "every change is reported: {changes:?}");
}
/// ⭐ The asymmetry that is the point of this function. A revocation
/// that reports success without revoking is the failure nobody
/// re-checks — so a `--remove-group` naming a group the user is not
/// in must fail, and must leave the user untouched.
#[test]
fn removing_a_group_the_user_lacks_fails_and_changes_nothing() {
let mut u = with_groups(&["admins"]);
let err = apply_update(
&mut u,
&UserUpdate {
// The realistic shape: a typo for `admins`.
remove_groups: vec!["admin".to_owned()],
..UserUpdate::default()
},
)
.expect_err("a no-op revocation must not report success");
assert!(err.to_string().contains("nothing to revoke"), "{err}");
assert_eq!(u.groups, ["admins"], "the user must be untouched");
}
/// The other half of the asymmetry: setting what is already set is
/// fine, because the end state matches the intent. Refusing would
/// make "set these four things" fail when one was already right.
#[test]
fn already_satisfied_additions_are_not_errors() {
let mut u = with_groups(&["admins"]);
u.displayname = "Mara".to_owned();
let changes = apply_update(
&mut u,
&UserUpdate {
displayname: Some("Mara".to_owned()),
add_groups: vec!["admins".to_owned(), "ops".to_owned()],
..UserUpdate::default()
},
)
.expect("a partially-satisfied update still applies the rest");
assert_eq!(u.groups, ["admins", "ops"], "no duplicate `admins`");
assert_eq!(
changes.len(),
1,
"only the real change reports: {changes:?}"
);
}
/// A command that changes nothing would still rewrite both files and
/// restart the SSO provider, so it is an error rather than a no-op.
#[test]
fn an_update_that_changes_nothing_fails() {
let mut u = with_groups(&["admins"]);
let err = apply_update(
&mut u,
&UserUpdate {
add_groups: vec!["admins".to_owned()],
..UserUpdate::default()
},
)
.expect_err("a no-op must not rewrite the user database");
assert!(err.to_string().contains("nothing to change"), "{err}");
}
#[test]
fn adding_and_removing_the_same_group_is_refused() {
let mut u = with_groups(&["admins"]);
let err = apply_update(
&mut u,
&UserUpdate {
add_groups: vec!["admins".to_owned()],
remove_groups: vec!["admins".to_owned()],
..UserUpdate::default()
},
)
.expect_err("contradictory flags must not pick a winner silently");
assert!(err.to_string().contains("both added and removed"), "{err}");
}
/// The update path writes the same file `render_yaml` validates, so a
/// control character has to be refused *before* it reaches the store —
/// not at render time, with the store already mutated.
#[test]
fn a_control_character_is_refused_by_the_update_path_too() {
let mut u = with_groups(&[]);
let err = apply_update(
&mut u,
&UserUpdate {
displayname: Some("bad\nname".to_owned()),
..UserUpdate::default()
},
)
.expect_err("must refuse");
assert!(err.to_string().contains("control character"), "{err}");
assert_eq!(u.displayname, "Test User", "the user must be untouched");
}
/// The seed check gates an irreversible overwrite, so it has to be
/// tolerant of trailing whitespace and intolerant of everything else.
#[test]
fn only_the_untouched_seed_reads_as_takeable() {
assert!(is_untouched_seed("users: {}"));
assert!(is_untouched_seed("users: {}\n"));
assert!(!is_untouched_seed("users:\n mara:\n"));
assert!(
is_untouched_seed(""),
"a zero-byte file has no users to lose, and authelia will not start \
on it — refusing here would strand the deployment"
);
}
}