//! 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, /// 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, } #[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, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub groups: Vec, /// Per-user keys this binary does not model (authelia's `disabled`, for /// one). Same preservation rule as [`UserStore::extra`]. #[serde(flatten)] pub extra: BTreeMap, } /// 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 `/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 { 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 { 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, pub email: Option, pub add_groups: Vec, pub remove_groups: Vec, } /// 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> { 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(", ") } } #[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"); } /// 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::("").is_err(), "if this ever starts parsing, load_store's empty-file arm is \ redundant rather than load-bearing, and should be revisited" ); } }