Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarmctl/src/users.rs
atlas 69ae23f801 swarmctl: add user reset-password
authelia's file backend has no self-service reset (no SMTP notifier),
so the only way a human account got a new password after the old one
was forgotten was hand-editing users.yml as root. `user add` already
hashes a password into the file; this verb does the same for an
existing user instead of refusing on the name.

Mirrors `user add`'s UX exactly: no password flag, authelia generates
and hashes it (never crosses argv), and it's printed once and never
stored. Refuses on an unknown user before ever invoking authelia. Same
publish path as add/update, so the same atomic write and no-restart
(authelia watches the file) behaviour apply.

Split the digest-replacement into users::reset_password so it's
testable without a command line or a running authelia, same pattern
as apply_update.
2026-09-29 12:31:21 +02:00

695 lines
28 KiB
Rust

//! The swarm's SSO user store — authelia's own `users.yml`, read and
//! written directly.
//!
//! **One file.** There used to be a private `users.json` here, canonical,
//! with `users.yml` rendered from it — and `swarm-authelia-bridge` kept its
//! own pair against the *same* physical `users.yml`. Two canonical stores
//! for one file is a seam, and it bit: a writer whose own store was missing
//! could not tell *"nothing here yet"* from *"someone else's users"*, and
//! refused to write at all.
//!
//! The JSON bought nothing. The old module doc argued it let this crate
//! exist without a YAML parser — but `load_store` read the JSON back on
//! every run, so the round-trip was already being paid; the two files
//! differed only in *format*.
//!
//! ⚠️ Consequence, deliberate: this file is **round-tripped**, so comments
//! and hand-formatting do not survive a write. An operator editing it
//! directly keeps their *values* and loses their *comments*. Unknown keys
//! survive (see `extra`), so a field this binary does not model is not
//! deleted by it.
use std::collections::BTreeMap;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
/// The canonical store — this *is* `users.yml`, deserialised.
///
/// `BTreeMap` rather than `HashMap` so the file 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>,
/// 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 whatever 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** (`$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>,
/// 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>,
}
/// 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(())
}
/// Domain for an address this crate invents.
///
/// The same one `hive-c0re` already gives every agent's forge account
/// (`forge::users::agent_email`) and every hyperhive-authored git commit. A
/// deployment-derived domain was considered and rejected: it would have to be
/// passed in from config, and an operator who is already supplying a domain
/// may as well supply the whole address — while a *second* convention for
/// synthetic identities is a thing to keep in sync forever.
///
/// Never routable, and that is correct rather than a compromise. Nothing
/// sends mail here; the address exists so that a relying party asking for an
/// `email` claim gets one.
const SYNTHETIC_EMAIL_DOMAIN: &str = "hyperhive.local";
/// The address a user with no explicit one is rendered as.
///
/// Every user needs an email in the rendered file, because a relying party
/// that asks for the `email` claim and gets nothing does not degrade — it
/// fails. Grafana's OIDC login is the measured case: with no email claim it
/// falls through to `<api_url>/emails`, a GitHub-ism authelia does not
/// implement, and the login dies with `InternalError` rather than anything
/// naming the missing field.
///
/// Synthesised on the **write path** and stored, because the file *is* the
/// store now: "rendered but not persisted" no longer has anywhere to live.
/// mara ruled it directly — *"email in yml is what is already there and
/// correct"* — so a synthesised address is simply the user's address, and
/// the next `user update --email` overwrites it like any other value.
fn synthetic_email(username: &str) -> String {
format!("{username}@{SYNTHETIC_EMAIL_DOMAIN}")
}
/// Give every user an address before the store is written.
///
/// Returns the names it filled, so a caller can say what it did rather than
/// changing the file silently.
///
/// On the write path rather than at creation, because users also arrive by
/// being *read* — from a file another writer produced, or one an operator
/// edited. Fixing them where they enter would need every entry point to
/// remember; fixing them where they leave cannot be forgotten.
pub fn fill_missing_emails(store: &mut UserStore) -> Vec<String> {
let mut filled = Vec::new();
for (name, user) in &mut store.users {
if user.email.is_none() {
user.email = Some(synthetic_email(name));
filled.push(name.clone());
}
}
filled
}
/// Serialise 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-runs
/// validation over every value it is about to emit, which is what makes
/// *"no control character ever reaches this file"* a property of the **one
/// path that writes it** rather than a rule every call site must remember.
/// A bare `to_string(&store)` serialises perfectly and drops that 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, 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(())
}
/// 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)
}
/// Replace a user's password digest, leaving every other field untouched.
///
/// A one-line function, but pulled out for the same reason [`apply_update`]
/// is: the caller has already looked the user up (and reports "no such
/// user" itself, the same way `user update` does), so what's left here is
/// exactly the part that's testable without a command line or a running
/// authelia — the digest comes from generating a real password, which
/// isn't.
pub fn reset_password(user: &mut User, digest: String) {
user.password = digest;
}
/// 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(", ")
}
}
#[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(),
extra: BTreeMap::new(),
}
}
#[test]
fn an_empty_store_round_trips_as_an_empty_store() {
let out = render_yaml(&UserStore::default()).expect("empty store renders");
let back: UserStore = serde_norway::from_str(&out).expect("re-reads");
assert!(
back.users.is_empty(),
"removing the last user must leave a file the next run can load, got:\n{out}"
);
}
/// The digest is the value most likely to break a naive emitter: it
/// carries `$`, `=`, `,` and `/`, and `,` in particular terminates a
/// YAML flow scalar. Asserted through a **round-trip** rather than
/// against a literal quoting style: which scalars a real emitter chooses
/// to quote is its business, and pinning the spelling would make this
/// test fail on a serializer upgrade that broke nothing.
#[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("mara".to_owned(), user(digest));
let out = render_yaml(&store).expect("renders");
let back: UserStore = serde_norway::from_str(&out).expect("re-reads");
assert_eq!(
back.users["mara"].password, digest,
"digest must survive verbatim, got:\n{out}"
);
}
/// Two processes write this file and neither knows the other's fields,
/// so anything unmodelled has to survive being read and written back —
/// otherwise the second writer silently deletes the first's work, which
/// is the same class of bug as the two-canonical-stores seam this change
/// removed, one level down.
#[test]
fn unknown_keys_survive_a_round_trip() {
let raw = "\
theme: dark
users:
mara:
displayname: \"mara\"
password: \"$argon2id$x\"
disabled: true
";
let mut store: UserStore = serde_norway::from_str(raw).expect("parses");
let out = render_yaml(&store).expect("renders");
assert!(
out.contains("theme"),
"a top-level key this binary does not model was dropped:\n{out}"
);
assert!(
out.contains("disabled"),
"a per-user key this binary does not model was dropped:\n{out}"
);
// ...and it must still survive once we have touched the entry the
// way a real verb does.
fill_missing_emails(&mut store);
let out = render_yaml(&store).expect("renders");
assert!(out.contains("disabled"), "dropped after a write:\n{out}");
}
/// mara: *"email in yml is what is already there and correct"* — with the
/// file as the store, an address is filled in on the write path and kept,
/// rather than invented afresh by each render.
#[test]
fn a_user_with_no_email_gets_one_stored() {
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), user("$argon2id$x"));
let filled = fill_missing_emails(&mut store);
assert_eq!(filled, vec!["mara".to_owned()]);
assert_eq!(
store.users["mara"].email.as_deref(),
Some("mara@hyperhive.local")
);
// Idempotent: a second pass must not report a change, or every
// publish would print a note about a user it did not touch.
assert!(fill_missing_emails(&mut store).is_empty());
}
#[test]
fn an_empty_group_list_is_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("groups"),
"an empty group list must not appear:\n{out}"
);
}
/// The earlier missing-email property, moved from the renderer to the
/// write path along with the synthesis itself: a user who supplied an
/// address keeps it, and no invented one appears beside it.
#[test]
fn a_supplied_email_is_never_replaced_by_the_synthetic_one() {
let mut u = user("$argon2id$x");
u.email = Some("real@elsewhere.example".to_owned());
let mut store = UserStore::default();
store.users.insert("mara".to_owned(), u);
assert!(
fill_missing_emails(&mut store).is_empty(),
"a user with an address is not missing one"
);
assert_eq!(
store.users["mara"].email.as_deref(),
Some("real@elsewhere.example"),
"the supplied address must win"
);
let out = render_yaml(&store).expect("renders");
assert!(
!out.contains("mara@hyperhive.local"),
"the synthetic address must not also appear:\n{out}"
);
}
/// The synthetic address goes through the same validation as a supplied
/// one. A username is already constrained to `[A-Za-z0-9._-]`, so this
/// cannot currently fail — which is exactly why it is worth pinning: the
/// day username rules loosen, the renderer must still refuse rather than
/// quietly emit whatever it built.
#[test]
fn the_synthetic_address_is_built_from_the_username_and_domain() {
assert_eq!(synthetic_email("mara"), "mara@hyperhive.local");
}
/// Was `groups_render_as_a_block_sequence`, a byte-for-byte assertion on
/// the hand-rolled emitter's output — including which scalars it chose to
/// quote. That is the serializer's business now, and pinning it would
/// fail on an upgrade that broke nothing. What has to hold is that the
/// values come back, in order.
#[test]
fn a_users_groups_survive_a_round_trip_in_order() {
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");
let back: UserStore = serde_norway::from_str(&out).expect("re-reads");
let mara = &back.users["mara"];
assert_eq!(mara.displayname, "Test User");
assert_eq!(mara.password, "$argon2id$x");
assert_eq!(mara.email.as_deref(), Some("mara@example.com"));
assert_eq!(mara.groups, ["admins", "operators"], "order is meaningful");
}
/// 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 whole point of resetting one user's credential: the other
/// entries in the store, including their own passwords, must come out
/// exactly as they went in.
#[test]
fn reset_password_changes_only_the_targeted_users_digest() {
let mut store = UserStore::default();
store
.users
.insert("mara".to_owned(), user("$argon2id$old-mara"));
store
.users
.insert("atlas".to_owned(), user("$argon2id$old-atlas"));
reset_password(
store.users.get_mut("mara").expect("mara is in the store"),
"$argon2id$new-mara".to_owned(),
);
assert_eq!(store.users["mara"].password, "$argon2id$new-mara");
assert_eq!(
store.users["atlas"].password, "$argon2id$old-atlas",
"an unrelated user's password must not change"
);
}
/// Replaces `only_the_untouched_seed_reads_as_takeable`. That test
/// pinned the seed check, which existed to decide whether overwriting
/// `users.yml` was safe — a question that only arose because a *second*
/// store claimed to be canonical. There is no overwrite to gate now: the
/// file is read before it is written.
///
/// What still has to hold is that the first-boot seed and a zero-byte
/// file both mean "no users yet" rather than an error, so a fresh
/// deployment is not stranded.
#[test]
fn the_first_boot_seed_and_an_empty_file_both_mean_no_users() {
let seeded: UserStore = serde_norway::from_str("users: {}").expect("the seed parses");
assert!(seeded.users.is_empty());
// The empty-file case is handled before deserialisation (an empty
// document is not valid YAML for this type), so it is asserted where
// it lives — `main::load_store` — rather than reproduced here.
assert!(
serde_norway::from_str::<UserStore>("").is_err(),
"if this ever starts parsing, load_store's empty-file arm is \
redundant rather than load-bearing, and should be revisited"
);
}
}