From 346a6b1b4ce488e9de3c3be1dc0b8442f9646e68 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 17 Aug 2026 21:12:27 +0200 Subject: [PATCH] fix(#3393): render an email for every swarm user, synthesised when none was given Authelia serves no `email` claim for a user without one, and a relying party that wants that claim does not degrade -- grafana falls through to `/emails`, a GitHub-ism authelia does not implement, and the login dies with InternalError naming nothing useful. So an absent address is a broken login rather than a sparse profile. Uses `@hyperhive.local`, the domain hive-c0re already gives every agent's forge account and every hyperhive-authored commit. Not deployment- derived: that would have to be plumbed in from config, and an operator already supplying a domain may as well supply the whole address. Synthesised in the renderer, never in the store: users.json stays honest that none was supplied, an operator who later sets a real one is not fighting an invented value, and existing users are fixed by the next render with no migration step. A supplied address always wins. The old test asserting an absent email is omitted pinned exactly the behaviour that broke the login; it is split so the group half keeps its meaning and the email half states the new contract. --- swarmctl/src/users.rs | 94 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/swarmctl/src/users.rs b/swarmctl/src/users.rs index 400d89a3..a423d10d 100644 --- a/swarmctl/src/users.rs +++ b/swarmctl/src/users.rs @@ -122,6 +122,37 @@ fn quote(s: &str) -> String { out } +/// 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 in the **renderer**, never written to the store: `users.json` +/// stays honest that no address was supplied, so an operator who later sets a +/// real one is not fighting a value swarmctl invented, and every existing user +/// is fixed by the next render with no migration step. +fn synthetic_email(username: &str) -> String { + format!("{username}@{SYNTHETIC_EMAIL_DOMAIN}") +} + /// Render the store as authelia's block-style YAML users database. /// /// Fallible because it re-runs validation over every value it is about to @@ -142,10 +173,15 @@ pub fn render_yaml(store: &UserStore) -> Result { writeln!(out, " {name}:")?; writeln!(out, " displayname: {}", quote(&user.displayname))?; writeln!(out, " password: {}", quote(&user.password))?; - if let Some(email) = &user.email { - reject_control_chars("email", email)?; - writeln!(out, " email: {}", quote(email))?; - } + // Unconditional: an absent email is the failure mode, not a valid + // rendering. The synthetic address is validated on the same path as + // a supplied one so neither can smuggle a control character. + let email = match &user.email { + Some(supplied) => supplied.clone(), + None => synthetic_email(name), + }; + reject_control_chars("email", &email)?; + writeln!(out, " email: {}", quote(&email))?; if !user.groups.is_empty() { writeln!(out, " groups:")?; for group in &user.groups { @@ -325,21 +361,61 @@ mod tests { } #[test] - fn optional_fields_are_omitted_rather_than_emitted_empty() { + 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("email"), - "absent email must not appear:\n{out}" - ); assert!( !out.contains("groups"), "an empty group list must not appear:\n{out}" ); } + /// Email is deliberately NOT in the test above any more. It used to + /// assert that an absent one is omitted, which pinned the behaviour that + /// broke grafana's login: authelia serves no `email` claim, and a relying + /// party that wants one fails rather than degrading. + #[test] + fn a_user_with_no_email_still_renders_one_from_the_shared_domain() { + let mut store = UserStore::default(); + store.users.insert("mara".to_owned(), user("$argon2id$x")); + + let out = render_yaml(&store).expect("renders"); + assert!( + out.contains(r#"email: "mara@hyperhive.local""#), + "a user with no email must still render one:\n{out}" + ); + } + + #[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); + + let out = render_yaml(&store).expect("renders"); + assert!( + out.contains(r#"email: "real@elsewhere.example""#), + "the supplied address must win:\n{out}" + ); + 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"); + } + #[test] fn groups_render_as_a_block_sequence() { let mut u = user("$argon2id$x");