From 2ac34b61671e094ad1dbc13d143654abf8ee64c2 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 23 Sep 2026 22:18:28 +0200 Subject: [PATCH] Release 0.3.46 An invite finds an existing account by its email and only adds it to the group. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- src/people.rs | 44 +++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2ccbd..78bc459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## 0.3.46 (2026-09-23) + +- An invite first looks the email up in Kanidm (`/v1/raw/search` on `mail`, when the service account may read people: `idm_people_pii_read`) and only adds an existing account, under whatever username, to the group; no second account, no reset link. + ## 0.3.45 (2026-09-23) - A mail stream that cannot be created at boot (subjects owned by an older stream) is a warning, not a failed start; 0.3.44 took the kasse sites down for two minutes on the rename. diff --git a/Cargo.lock b/Cargo.lock index 9cdbfe5..1f40f2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3265,7 +3265,7 @@ dependencies = [ [[package]] name = "portal" -version = "0.3.45" +version = "0.3.46" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 7ef928c..906986f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "portal" -version = "0.3.45" +version = "0.3.46" edition = "2021" [lib] diff --git a/src/people.rs b/src/people.rs index fd5b5ea..66895bd 100644 --- a/src/people.rs +++ b/src/people.rs @@ -44,6 +44,13 @@ fn person_lookup_found(status: u16, body: &str) -> Option { } } +/// The first entry's `name` in a `/v1/raw/search` answer, which is +/// `{"entries": [{"attrs": {"name": ["…"], …}}]}`. +fn name_from_search(body: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(body).ok()?; + v.get("entries")?.as_array()?.first()?.get("attrs")?.get("name")?.as_array()?.first()?.as_str().map(str::to_string) +} + pub struct Kanidm { base: String, token: String, @@ -77,11 +84,34 @@ impl Kanidm { person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}")) } + /// The account that already carries this email, if the token may + /// see it (the service account in `idm_people_pii_read`). None + /// when nobody has it, or when the search is not permitted: then + /// the username derived from the email decides, as before. + async fn find_by_mail(&self, email: &str) -> anyhow::Result> { + let (status, text) = self + .call( + openidconnect::reqwest::Method::POST, + "/v1/raw/search", + Some(serde_json::json!({ "filter": { "eq": ["mail", email.trim().to_lowercase()] } })), + ) + .await?; + if !(200..300).contains(&status) { + tracing::warn!(status, "kanidm: searching people by mail is not permitted for this token; matching by username instead"); + return Ok(None); + } + Ok(name_from_search(&text)) + } + /// The whole onboarding, in the order that leaves the least behind - /// if a step fails: create, then email, then group, then the link. + /// if a step fails: find or create, then email, then group, then + /// the link. A person who already has an account, under whatever + /// username, is only added to the group; their credentials are + /// left alone and no link is made. pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result { - let username = username_for(email); - let created = if self.person_exists(&username).await? { + let existing = self.find_by_mail(email).await?; + let username = existing.clone().unwrap_or_else(|| username_for(email)); + let created = if existing.is_some() || self.person_exists(&username).await? { false } else { let (status, text) = self @@ -171,6 +201,14 @@ mod tests { assert_eq!(username_for("Ærlig@x.no"), "rlig"); } + #[test] + fn a_search_by_mail_yields_the_accounts_own_name() { + assert_eq!(name_from_search(r#"{"entries":[{"attrs":{"name":["ole.n"],"mail":["ole@x.no"]}}]}"#), Some("ole.n".into())); + assert_eq!(name_from_search(r#"{"entries":[]}"#), None); + assert_eq!(name_from_search("null"), None); + assert_eq!(name_from_search("not json"), None); + } + #[test] fn a_null_person_answer_means_nobody_there() { assert_eq!(person_lookup_found(200, "null"), Some(false));