authelia 4.39.20 exits at startup on `users: {}` ("users: non zero value
required"), and the first-boot unit seeded exactly that, so a swarm with
no users crash-looped authelia and answered 502 until `swarmctl user add`
ran.
The first-boot unit now writes one subject, `swarm.placeholder`, when
the users database is absent, empty, or exactly `users: {}`:
- `disabled: true` — authelia returns "user not found" for a disabled
user before any password check (file_user_provider.go,
CheckUserPassword).
- password: an argon2id digest with an all-zero key. It decodes (authelia
rejects a non-digest at startup) and no known password hashes to it.
- the `.` keeps it out of agent names (`[a-z0-9-]`), and `swarmctl user
add` refuses it as already existing. Neither writer removes users, and
both round-trip `disabled`.
A file with any user in it is never touched.
The docs that described the crash-loop (sso.md, gateway.md, setup.md,
the sso-unavailable error page) now describe the placeholder; the
writers' load_store docs and the seed fixtures follow. module-eval
nats-authelia asserts the seed branch.
633 lines
26 KiB
Rust
633 lines
26 KiB
Rust
//! 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<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 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<String, serde_norway::Value>,
|
|
}
|
|
|
|
#[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<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>,
|
|
}
|
|
|
|
/// 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<String> {
|
|
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<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, 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, and so is
|
|
/// `users: {}`, with no special case. The `swarm-authelia` module seeds an
|
|
/// empty database with one disabled placeholder subject, which loads like any
|
|
/// other user.
|
|
pub fn load_store(users_file: &Path) -> Result<UserStore> {
|
|
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 the user set is empty: one disabled placeholder.
|
|
/// A copy of `placeholderUsersFile` in
|
|
/// `nix/host-modules/swarm-authelia.nix`; a test fixture because nothing
|
|
/// in this binary writes a seed.
|
|
const SEED_USERS_FILE: &str = r"users:
|
|
swarm.placeholder:
|
|
displayname: placeholder (cannot log in)
|
|
email: swarm.placeholder@hyperhive.local
|
|
disabled: true
|
|
password: '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
|
";
|
|
|
|
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 with
|
|
/// no special case — otherwise a fresh hive's very first
|
|
/// `EnsureAgentIdentity` fails on a file we wrote ourselves — and its
|
|
/// placeholder must stay out of the roster and stay disabled through a
|
|
/// write.
|
|
#[test]
|
|
fn the_first_boot_seed_loads_and_its_placeholder_stays_disabled() {
|
|
let store: UserStore = serde_norway::from_str(SEED_USERS_FILE).expect("seed parses");
|
|
assert_eq!(store.users.len(), 1);
|
|
assert!(agent_names(&store).is_empty());
|
|
|
|
let out = render_yaml(&store).expect("renders");
|
|
let back: UserStore = serde_norway::from_str(&out).expect("reparses");
|
|
assert_eq!(
|
|
back.users["swarm.placeholder"].extra.get("disabled"),
|
|
Some(&serde_norway::Value::Bool(true)),
|
|
"dropped after a write:\n{out}"
|
|
);
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|