Files
portal/src/people.rs
T
Bendik Aagaard LynghaugandClaude Fable 5.1 f36d88a69c people: a missing person is 200 + null in Kanidm 1.11, not 404
The existence check went by status alone, so every invitee looked like
an existing account: creation was skipped and the group step failed.
Read the body; `null` means nobody there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 14:33:25 +02:00

183 lines
7.5 KiB
Rust

//! Onboarding a person into Kanidm from a desk, without an admin
//! session: create the account, record their email, add them to the
//! group, and mint a one-time link where they set their own password
//! or passkey. Kanidm has no self-registration by design; a member of
//! the business vouching for each person is the intended shape, and
//! this is that, as a form.
//!
//! Talks to Kanidm's REST API with a service-account token
//! (`KANIDM_API_TOKEN`, read-write) through the bounded client. The
//! service account belongs to `idm_people_on_boarding`, which may
//! create persons and issue first-credential resets and nothing else,
//! so a leaked token cannot touch existing credentials or admins.
#![cfg(feature = "ssr")]
use serde::{Deserialize, Serialize};
pub const INVITES_BUCKET: &str = "portal_invites";
pub const INVITED_SUBJECT: &str = "portal.people.invited";
/// What an invite produced: the account, and the link that sets it up.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invited {
pub username: String,
pub display_name: String,
pub email: String,
pub group: String,
/// Absent when the person already had an account: they were added
/// to the group and their credentials were left alone.
pub reset_link: Option<String>,
pub link_expires_ms: Option<i64>,
pub created: bool,
}
/// Whether a `GET /v1/person/<name>` answer means the person exists.
/// Kanidm 1.11 answers a missing person with 200 and the body `null`
/// rather than 404, so the status alone would report everyone as
/// existing and no account would ever get created. `None` for an
/// answer that is neither.
fn person_lookup_found(status: u16, body: &str) -> Option<bool> {
match status {
200 => Some(body.trim() != "null" && !body.trim().is_empty()),
404 => Some(false),
_ => None,
}
}
pub struct Kanidm {
base: String,
token: String,
}
impl Kanidm {
/// `None` when `KANIDM_API_TOKEN` is unset: invites fail closed with
/// a clear message instead of a half-made account.
pub fn from_env() -> Option<Self> {
let base = std::env::var("KANIDM_URL").ok()?.trim_end_matches('/').to_string();
let token = std::env::var("KANIDM_API_TOKEN").ok().filter(|t| !t.is_empty())?;
Some(Kanidm { base, token })
}
async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option<serde_json::Value>) -> anyhow::Result<(u16, String)> {
let mut req = crate::http::client()
.request(method, format!("{}{path}", self.base))
.bearer_auth(&self.token)
.header("content-type", "application/json");
if let Some(body) = body {
req = req.body(serde_json::to_vec(&body)?);
}
let response = req.send().await?;
let status = response.status().as_u16();
let text = crate::http::text(response, crate::http::GITEA_CAP).await?;
Ok((status, text))
}
async fn person_exists(&self, username: &str) -> anyhow::Result<bool> {
let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}"), None).await?;
person_lookup_found(status, &text).ok_or_else(|| anyhow::anyhow!("kanidm: looking up {username}: http {status}"))
}
/// The whole onboarding, in the order that leaves the least behind
/// if a step fails: create, then email, then group, then the link.
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result<Invited> {
let username = username_for(email);
let created = if self.person_exists(&username).await? {
false
} else {
let (status, text) = self
.call(
openidconnect::reqwest::Method::POST,
"/v1/person",
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name] } })),
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: creating {username}: http {status}: {text}");
}
let (status, text) = self
.call(openidconnect::reqwest::Method::PUT, &format!("/v1/person/{username}/_attr/mail"), Some(serde_json::json!([email])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: setting mail on {username}: http {status}: {text}");
}
true
};
let (status, text) = self
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
}
let (reset_link, link_expires_ms) = if created {
// 24 hours: the longest Kanidm allows, since the link travels
// by email and is read when the person gets to it.
let (status, text) = self
.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/86400"), None)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: reset token for {username}: http {status}: {text}");
}
#[derive(Deserialize)]
struct Intent {
token: String,
#[serde(default)]
expiry_time: Option<serde_json::Value>,
}
let intent: Intent = serde_json::from_str(&text)?;
let expires = intent.expiry_time.as_ref().and_then(|v| v.as_str()).and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).map(|t| t.timestamp_millis());
(Some(format!("{}/ui/reset?token={}", self.base, intent.token)), expires)
} else {
(None, None)
};
Ok(Invited {
username,
display_name: name.to_string(),
email: email.to_string(),
group: group.to_string(),
reset_link,
link_expires_ms,
created,
})
}
}
/// A Kanidm account name from an email: the local part, lowercased,
/// anything outside `[a-z0-9._-]` folded to `-`, led by a letter.
pub fn username_for(email: &str) -> String {
let local = email.split('@').next().unwrap_or(email).to_lowercase();
let mut out: String = local
.chars()
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-') { c } else { '-' })
.collect();
while out.starts_with(|c: char| !c.is_ascii_lowercase()) && !out.is_empty() {
out.remove(0);
}
if out.is_empty() {
"person".to_string()
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usernames_are_the_email_local_part_made_safe() {
assert_eq!(username_for("jorn@westra-engineering.no"), "jorn");
assert_eq!(username_for("Ole.Nordmann+site@Example.com"), "ole.nordmann-site");
assert_eq!(username_for("42@x.no"), "person");
assert_eq!(username_for("Ærlig@x.no"), "rlig");
}
#[test]
fn a_null_person_answer_means_nobody_there() {
assert_eq!(person_lookup_found(200, "null"), Some(false));
assert_eq!(person_lookup_found(200, "null\n"), Some(false));
assert_eq!(person_lookup_found(200, r#"{"attrs":{"name":["bl"]}}"#), Some(true));
assert_eq!(person_lookup_found(404, ""), Some(false));
assert_eq!(person_lookup_found(401, "unauthorized"), None);
}
}