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 `<api_url>/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 `<username>@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.
This commit is contained in:
parent
58588a6866
commit
346a6b1b4c
1 changed files with 85 additions and 9 deletions
|
|
@ -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 `<api_url>/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<String> {
|
|||
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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue