//! 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, /// 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, } #[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, #[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, } /// 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 { 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 { 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 { 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 } }