#3414 fixed the missing-email defect in swarmctl only, so every identity created by the bridge landed in users.yml with no `email`. A relying party that asks for the claim does not degrade, it fails -- grafana's OIDC login is the measured case (#3393). Two writers of one file disagreeing about a field one of them treats as required is not a difference worth keeping, and with the file shared the result also depended on which tool wrote last. Mutation-checked: removing the fill turns the new test red and nothing else.
436 lines
18 KiB
Rust
436 lines
18 KiB
Rust
//! The canonical user store this bridge owns, and the authelia users
|
|
//! database rendered from it.
|
|
//!
|
|
//! Deliberately its own copy, not shared code with `swarmctl::users` even
|
|
//! though the YAML shape is identical (same "factor out later if
|
|
//! duplication actually bites" reasoning as `swarm-controller::forge`
|
|
//! mirroring `hive-c0re::forge` — see the agent-identity-at-swarm-level
|
|
//! design thread). One real difference from `swarmctl`'s copy: this
|
|
//! store lives **wherever this
|
|
//! bridge runs** (inside the `swarm-authelia` container, alongside
|
|
//! `authelia-swarm`'s own state), not under `swarm-controller`'s state
|
|
//! dir — the two are not guaranteed to be the same host once
|
|
//! `swarm-authelia` and `swarm-controller` split across hosts, and this
|
|
//! bridge only ever runs where `swarm-authelia` does.
|
|
//!
|
|
//! # `users.yml` is the store, not a rendering of one
|
|
//!
|
|
//! There used to be a private `users.json` here, canonical, with `users.yml`
|
|
//! rendered from it — and `swarmctl` had its own pair against the *same*
|
|
//! physical `users.yml`. Two canonical stores for one file is a seam, and it
|
|
//! bit: a bridge whose own JSON was absent refused to write at all, because
|
|
//! it could not 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 two files
|
|
//! differed only in *format*. One file removes the class: there is no second
|
|
//! store to disagree with, and the overwrite guard that policed them has
|
|
//! nothing left to do.
|
|
//!
|
|
//! ⚠️ Consequence, deliberate: this file is now **round-tripped**, so
|
|
//! comments and hand-formatting in it do not survive a write. An operator
|
|
//! editing it directly gets their *values* kept and their *comments* dropped.
|
|
//! Unknown keys are preserved (see `extra` below) so a field this binary does
|
|
//! not know about is not deleted by it.
|
|
|
|
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>,
|
|
}
|
|
|
|
/// 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: 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<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.** #3414 fixed the
|
|
/// missing-email defect 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, #3393). 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(),
|
|
}
|
|
}
|
|
|
|
/// 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}"
|
|
);
|
|
}
|
|
|
|
#[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: #3414 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
|
|
/// (#3393) 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
|
|
}
|
|
}
|