hyperhive/swarmctl/src/users.rs
atlas 24ee0990a2 feat(3201): swarmctl user update — change an existing subject's attributes
`user add` refuses on an existing name, so the `--group` flag it takes at
creation time could not be added afterwards at all: repairing an account
meant hand-editing both users.json and the rendered users.yml as root.
mara, on #3167: "i will not edit those files by hand, we will have the
same issues elsewhere".

The merge rules live in users.rs as a pure function over a UserUpdate, so
they are testable without a command line, a container or a running
authelia — main.rs's arm only loads, applies, publishes and prints.

Removals are strict and everything else is idempotent, which is the one
asymmetry here and is deliberate: a --remove-group naming a group the
user does not have fails, because a revocation that reports success
without revoking is the outcome nobody re-checks; while refusing an
already-satisfied set would make the multi-attribute call this verb
exists for break whenever one of the values was already right.

A command that changes nothing at all still fails — it would otherwise
rewrite both files and restart the SSO provider to no effect.

Passwords are out of scope: regenerating a credential is a different
intent from editing an attribute, and folded together an attribute edit
can invalidate a login by accident.

Extracts publish() from user_add so both verbs share the
render -> store -> users.yml -> restart ordering and the comment that
explains why that order, rather than the second verb copying it.
2026-08-12 19:23:31 +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 restart authelia");
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"
);
}
}