//! 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 credential resets for ordinary people and //! nothing else, so a leaked token cannot touch an admin. //! //! **An invite is a case.** Each one is a record in `portal_invites`: //! `open` while the link waits, `done` once the person is in, `expired` //! when the link ran out unused. Nobody has to keep that current by //! hand: signing in to the site closes a person's invites, and a sweep //! moves the ones whose link has run out (`sweep_invites`). From //! `expired` a desk may send it again - the move back to `open` makes a //! fresh link and mails it (`on_reopened`). #![cfg(feature = "ssr")] use serde::{Deserialize, Serialize}; pub const INVITES_BUCKET: &str = "portal_invites"; /// Where people are kept. Kanidm makes accounts and holds groups; with /// memberships (`memberships.rs`) portal holds the groups itself and /// the identity provider only says who someone is. `PEOPLE_BACKEND` /// picks; unset, a `KANIDM_API_TOKEN` means Kanidm and its absence /// means memberships, so a site with no Kanidm token can still invite. pub enum People { Kanidm(Kanidm), Memberships, } impl People { /// `None` only when Kanidm is asked for and not configured. pub fn from_env() -> Option { match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() { Some("memberships") => Some(People::Memberships), Some("kanidm") => Kanidm::from_env().map(People::Kanidm), Some(other) => { tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships"); Some(People::Memberships) } None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)), } } /// What is missing when `from_env` says no. pub const NOT_CONFIGURED: &'static str = "invites are not configured on this instance (PEOPLE_BACKEND=kanidm needs KANIDM_URL and KANIDM_API_TOKEN)"; /// Puts a person in a group, making them known if they were not: /// through Kanidm an account and a one-time link, through /// memberships a record that becomes theirs when they sign in. /// `by` is who asked, for the record. pub async fn invite(&self, state: &crate::server::AppState, name: &str, who: &crate::memberships::Identity, group: &str, by: &str) -> anyhow::Result { match self { People::Kanidm(kanidm) => { let email = who.email.as_deref().ok_or_else(|| anyhow::anyhow!("Kanidm knows people by their address, and this one has none"))?; kanidm.invite(name, email, group).await } People::Memberships => { let mut invited = crate::memberships::grant(state, group, name, who, by).await?; // A Kanidm beside memberships makes the person, not the // member: an account with no group, and the one-time // link, so someone invited by address can sign in // before the provider is one everyone already has. if let (Some(kanidm), Some(email)) = (Kanidm::from_env(), who.email.as_deref()) { match kanidm.account(name, email).await { Ok(account) => { invited.username = account.username; invited.reset_link = account.reset_link; invited.link_expires_ms = account.link_expires_ms; invited.created = account.created; } Err(e) => tracing::warn!(group, %email, "the membership is on file, but no Kanidm account could be made: {e}"), } } Ok(invited) } } } /// Takes a person out of a group. Through Kanidm only what portal /// put there (see `Invited.already_member`); memberships remember. pub async fn remove(&self, state: &crate::server::AppState, who: &crate::memberships::Identity, username: &str, group: &str, by: &str) -> anyhow::Result<()> { match self { People::Kanidm(kanidm) => kanidm.remove_member(group, username).await, People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()), } } pub fn is_kanidm(&self) -> bool { matches!(self, People::Kanidm(_)) } } pub const INVITED_SUBJECT: &str = "portal.people.invited"; /// The states an invite moves between, besides the schema's initial /// `open` (see `BUILTIN_AGGREGATES_YAML`). pub const INVITE_EXPIRED: &str = "expired"; pub const INVITE_DONE: &str = "done"; /// What an invite made at start is recorded under, where a desk invite /// has the name of the alternative that made it. pub const SEED_ALTERNATIVE: &str = "Invited at start"; /// Stamped on a move nobody pressed, in place of a username. pub const BY: &str = "portal"; /// How long a link lives: the longest Kanidm allows, since it travels /// by email and is read when the person gets to it. const LINK_SECONDS: i64 = 86_400; /// 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, pub link_expires_ms: Option, pub created: bool, /// They were in the group before this invite put them there, so /// the membership is not portal's to take back. #[serde(default)] pub already_member: bool, } /// When a link runs out, from Kanidm's `expiry_time`: seconds since /// the epoch in 1.11, an RFC 3339 string before that. fn expiry_ms(value: &serde_json::Value) -> Option { match value { serde_json::Value::Number(n) => n.as_i64().map(|s| s * 1000), serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s).ok().map(|t| t.timestamp_millis()), _ => None, } } /// A group's members as bare account names (`kari`, not /// `kari@id.example.no`). Kanidm answers an empty group with `null`. fn members_from(body: &str) -> Vec { serde_json::from_str::>>(body) .ok() .flatten() .unwrap_or_default() .into_iter() .map(|m| m.split('@').next().unwrap_or_default().to_string()) .collect() } /// Whether a `_credential/_status` answer shows a credential. Only a /// yes means anything: a person with nothing but a passkey is answered /// with an empty list too. fn has_credentials_from(body: &str) -> bool { serde_json::from_str::(body) .ok() .and_then(|v| v.get("creds").and_then(|c| c.as_array()).map(|c| !c.is_empty())) .unwrap_or(false) } /// Whether a `GET /v1/person/` 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 { match status { 200 => Some(body.trim() != "null" && !body.trim().is_empty()), 404 => Some(false), _ => None, } } /// 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, /// What this site's groups are called in a Kanidm it shares with /// other sites: `KANIDM_GROUP_PREFIX=klingenberg_` makes the /// content's `kunder` the group `klingenberg_kunder` there. The /// OAuth2 client's claim map takes the prefix off again, so content /// and policy never see it. Empty for a Kanidm of the site's own. group_prefix: 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 { 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())?; let group_prefix = std::env::var("KANIDM_GROUP_PREFIX").unwrap_or_default().trim().to_string(); Some(Kanidm { base, token, group_prefix }) } async fn call(&self, method: openidconnect::reqwest::Method, path: &str, body: Option) -> 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 { 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 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?; // Refused is an answer, and the username decides. Anything else // that is not a yes is Kanidm not answering: guessing then could // give someone a second account. if (400..500).contains(&status) { tracing::warn!(status, "kanidm: searching people by mail is not permitted for this token; matching by username instead"); return Ok(None); } if !(200..300).contains(&status) { anyhow::bail!("kanidm: searching people by mail: http {status}"); } Ok(name_from_search(&text)) } /// The whole onboarding, in the order that leaves the least behind /// 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 { self.onboard(name, email, Some(group)).await } /// An account and nothing more: for a person the content names as /// responsible for a page, who needs to be able to sign in whether /// or not they sit at any desk. `group` is empty in what comes back. pub async fn account(&self, name: &str, email: &str) -> anyhow::Result { self.onboard(name, email, None).await } /// Whether Kanidm already holds a person with this address. pub async fn has_person_with_mail(&self, email: &str) -> anyhow::Result { Ok(self.find_by_mail(email).await?.is_some()) } /// Who is in a group, by account name. pub async fn members(&self, group: &str) -> anyhow::Result> { let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), None).await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: reading the members of {group}: http {status}: {text}"); } Ok(members_from(&text)) } /// Takes a person out of a group. Theirs to do for a group the /// service account manages, like adding them was. pub async fn remove_member(&self, group: &str, username: &str) -> anyhow::Result<()> { let (status, text) = self .call(openidconnect::reqwest::Method::DELETE, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), Some(serde_json::json!([username]))) .await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: removing {username} from {group}: http {status}: {text}"); } Ok(()) } /// Whether Kanidm shows a credential for this person. `false` is /// "not that it can see", never "none": see `has_credentials_from`. pub async fn has_credentials(&self, username: &str) -> anyhow::Result { let (status, text) = self.call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_status"), None).await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: credential status of {username}: http {status}"); } Ok(has_credentials_from(&text)) } /// A one-time link where the person sets their own password or /// passkey, and when it runs out. pub async fn reset_link(&self, username: &str) -> anyhow::Result<(String, Option)> { let (status, text) = self .call(openidconnect::reqwest::Method::GET, &format!("/v1/person/{username}/_credential/_update_intent/{LINK_SECONDS}"), 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, } let intent: Intent = serde_json::from_str(&text)?; Ok((format!("{}/ui/reset?token={}", self.base, intent.token), intent.expiry_time.as_ref().and_then(expiry_ms))) } async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result { 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 { // The address goes in with the person, in one call: the // onboarding account may create a person but not modify one // (idm_people_on_boarding), so a separate PUT of `mail` // afterwards is refused with 403 and the invite fails. let (status, text) = self .call( openidconnect::reqwest::Method::POST, "/v1/person", Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name], "mail": [email] } })), ) .await?; if !(200..300).contains(&status) { anyhow::bail!("kanidm: creating {username}: http {status}: {text}"); } true }; let mut already_member = false; if let Some(group) = group { // Someone new is in no group. For anyone else, whether they // were here before is what says whose the membership is; a // failed read says "not before", which errs toward portal // tidying up after itself. already_member = !created && self.members(group).await.is_ok_and(|m| m.contains(&username)); let (status, text) = self .call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{}{group}/_attr/member", self.group_prefix), 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 { let (link, expires) = self.reset_link(&username).await?; (Some(link), expires) } else { (None, None) }; Ok(Invited { username, display_name: name.to_string(), email: email.to_string(), group: group.unwrap_or_default().to_string(), reset_link, link_expires_ms, created, already_member, }) } } /// When an open invite's link runs out: what Kanidm said, or a link's /// lifetime after it was last sent for a record that does not say. fn link_runs_out(answer: &crate::answers::Answer) -> i64 { answer .responses .get("invited") .and_then(|i| i.get("link_expires_ms")) .and_then(|v| v.as_i64()) .unwrap_or_else(|| answer.decided_ms.unwrap_or(answer.submitted_ms) + LINK_SECONDS * 1000) } /// The account an invite record is about. fn invited_of(answer: &crate::answers::Answer) -> Option { answer.responses.get("invited").and_then(|v| serde_json::from_value(v.clone()).ok()) } /// Where a waiting invite goes now, if anywhere. Pure: what is known /// about the person comes in as `is_in`. /// /// An invite that made no account has no link to wait for: the person /// had their way in already, and was only given a place. pub fn invite_due(answer: &crate::answers::Answer, is_in: bool, now_ms: i64) -> Option<&'static str> { if answer.state != crate::answers::OPEN_STATE { return None; } let made_an_account = invited_of(answer).is_some_and(|i| i.created); if is_in || !made_an_account { Some(INVITE_DONE) } else if now_ms >= link_runs_out(answer) { Some(INVITE_EXPIRED) } else { None } } /// An invite nobody at a desk made - the first person, someone the /// content names as responsible - on file like any other, so the desk /// that reads `portal_invites` sees it, and can send it again. pub async fn record(state: &crate::server::AppState, invited: &Invited, responsible_for: &[String]) { let now_ms = chrono::Utc::now().timestamp_millis(); let mut responses = serde_json::json!({ "name": invited.display_name, "email": invited.email, "invited": invited, }); if !responsible_for.is_empty() { responses["responsible_for"] = serde_json::json!(responsible_for); } let id = crate::chain::hash_node("/", &[], &responses, now_ms); if let Err(e) = crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), INVITES_BUCKET, id, "/", SEED_ALTERNATIVE, &responses, now_ms, 0).await { tracing::warn!(user = %invited.username, "the invite is sent, but could not be put on file for a desk: {e}"); } } /// Moves an invite record, the way a desk button would: through the /// event log, onto the projection, and out on the answers subject. async fn move_invite(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: crate::answers::Answer, target: &str) { let aggregates = state.aggregates.load(); let Some(schema) = aggregates.get(INVITES_BUCKET).filter(|s| s.allowed(&answer.state).iter().any(|t| t == target)) else { // A content repo may declare the bucket itself, without this // state. Then the invite stays where its own graph has it. return; }; let now_ms = chrono::Utc::now().timestamp_millis(); let payload = serde_json::json!({ "to": target, "item": answer.id, "by": BY }); if let Err(e) = crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms).await { tracing::warn!(id = %answer.id, target, "an invite could not be moved: {e}"); return; } answer.state = target.to_string(); answer.decided_ms = Some(now_ms); answer.decided_by = Some(BY.to_string()); // The link is a way in for as long as it is on file; one that is // used or run out is only something to leak. if let Some(invited) = answer.responses.get_mut("invited").and_then(|i| i.as_object_mut()) { invited.insert("reset_link".into(), serde_json::Value::Null); } if let Ok(data) = serde_json::to_vec(&answer) { let _ = store.put(&answer.id, data.into()).await; } let parent_hashes = vec![answer.id.clone()]; let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms); let event = crate::events::AnswerSubmitted { chain_hash, parent_hashes, question_id: answer.question_id.clone(), alternative: target.to_string(), responses: payload, timestamp_ms: now_ms, storyline: answer.storyline, }; if let Err(e) = crate::events::emit_answer_submitted(&state.nats, &event).await { tracing::warn!(id = %answer.id, target, "the invite was moved but not published: {e}"); } tracing::info!(id = %answer.id, target, "an invite moved on its own"); } /// Every invite on file that is still waiting in one of `states`. async fn invites_in(state: &crate::server::AppState, states: &[&str]) -> Option<(async_nats::jetstream::kv::Store, Vec)> { // Made on first write: a site that has invited nobody has no bucket. let store = state.jetstream.get_key_value(INVITES_BUCKET).await.ok()?; let keys: Vec = { use futures::TryStreamExt; store.keys().await.ok()?.try_collect().await.unwrap_or_default() }; let mut found = Vec::new(); for id in keys { let Ok(Some(bytes)) = store.get(&id).await else { continue }; let Ok(answer) = serde_json::from_slice::(&bytes) else { continue }; if states.contains(&answer.state.as_str()) { found.push(answer); } } Some((store, found)) } /// Runs for the life of the process, every ten minutes: a link lives a /// day, so an invite is never shown as waiting long after it stopped. pub async fn run(state: crate::server::AppState) { let mut tick = tokio::time::interval(std::time::Duration::from_secs(10 * 60)); loop { tick.tick().await; sweep_invites(&state).await; } } /// One pass over the waiting invites: done for whoever Kanidm shows a /// credential for, expired for a link that ran out. pub async fn sweep_invites(state: &crate::server::AppState) { let Some((store, waiting)) = invites_in(state, &[crate::answers::OPEN_STATE]).await else { return }; let kanidm = Kanidm::from_env(); let now_ms = chrono::Utc::now().timestamp_millis(); for answer in waiting { let is_in = match (&kanidm, invited_of(&answer)) { (Some(kanidm), Some(invited)) if invited.created => kanidm.has_credentials(&invited.username).await.unwrap_or(false), _ => false, }; if let Some(target) = invite_due(&answer, is_in, now_ms) { move_invite(state, &store, answer, target).await; } } } /// Whether an invite was this person's: the account it made, or the /// address it went to for someone whose account goes by another name. pub fn is_for(invited: &Invited, user: &crate::auth::User) -> bool { let address = invited.email.trim().to_lowercase(); invited.username == user.username || (!address.is_empty() && user.email.as_deref().is_some_and(|e| e.trim().to_lowercase() == address)) } /// Someone signed in: whatever invites were waiting for them, or had /// run out, are done. This is the one sure sign a link was used - /// they are here. pub async fn on_signed_in(state: crate::server::AppState, user: crate::auth::User) { let Some((store, waiting)) = invites_in(&state, &[crate::answers::OPEN_STATE, INVITE_EXPIRED]).await else { return }; for answer in waiting { if invited_of(&answer).is_some_and(|invited| is_for(&invited, &user)) { move_invite(&state, &store, answer, INVITE_DONE).await; } } } /// A desk sent an expired invite again (`expired` to `open`): a fresh /// link, on the record and in the mail. The same mail as the first /// time, which for someone the content names as responsible is the one /// that says so. pub async fn on_reopened(state: &crate::server::AppState, id: &str) { let Ok(store) = state.jetstream.get_key_value(INVITES_BUCKET).await else { return }; let Ok(Some(bytes)) = store.get(id).await else { return }; let Ok(mut answer) = serde_json::from_slice::(&bytes) else { return }; let Some(mut invited) = invited_of(&answer) else { tracing::warn!(id, "an invite was sent again, but the record names no account - nothing sent"); return; }; let Some(kanidm) = Kanidm::from_env() else { tracing::warn!(id, "an invite was sent again, but invites are not configured on this instance (KANIDM_API_TOKEN) - nothing sent"); return; }; let (link, expires) = match kanidm.reset_link(&invited.username).await { Ok(made) => made, Err(e) => { tracing::error!(id, user = %invited.username, "a new link could not be made: {e}"); return; } }; invited.reset_link = Some(link); invited.link_expires_ms = expires; if let Some(map) = answer.responses.as_object_mut() { map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default()); } if let Ok(data) = serde_json::to_vec(&answer) { let _ = store.put(id, data.into()).await; } let responsible_for: Vec = answer .responses .get("responsible_for") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); if responsible_for.is_empty() { crate::mail::on_invite(state, &invited).await; } else { crate::mail::on_responsible(state, &invited, &responsible_for).await; } tracing::info!(id, user = %invited.username, "an invite was sent again"); } /// 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 } } /// The person a record is about, for a state that grants a group: their /// name, and a phone number or an address to know them by. Required by /// the lint of any form that fills such a bucket (see /// `validate_questions`), so a record missing them is one that predates /// the rule, and is reported rather than guessed at. pub fn who_for(responses: &serde_json::Value) -> Option<(String, crate::memberships::Identity)> { let name = responses .get("name") .or_else(|| responses.get("navn")) .and_then(|v| v.as_str()) .map(str::trim) .filter(|s| !s.is_empty()) .map(String::from)?; let who = crate::memberships::Identity::from_responses(responses); who.key().is_some().then_some((name, who)) } /// A record entered `state`: if that state grants a group, put the /// person the record is about in it. The decision the desk already /// pressed is the whole ceremony - an approval that means membership /// should not also need somebody to remember to invite them. /// /// Best-effort, exactly like the mail beside it: the transition is /// committed either way, and an instance with no Kanidm configured /// simply grants nothing and says so once per occurrence. pub async fn on_state( state: &crate::server::AppState, bucket: &str, id: &str, to_state: &str, responses: &serde_json::Value, ) { let Some(group) = state .aggregates .load() .get(bucket) .and_then(|s| s.grants_for(to_state).map(str::to_string)) else { return; }; let Some(people) = People::from_env() else { tracing::warn!(bucket, id, to_state, group, "this state grants a group, but {} - nobody was added", People::NOT_CONFIGURED); return; }; let Some((name, who)) = who_for(responses) else { tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and no phone number or email - nobody was added"); return; }; let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).await { Ok(invited) => invited, Err(e) => { tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}"); return; } }; if let Ok(bytes) = serde_json::to_vec(&invited) { let _ = state.nats.publish(INVITED_SUBJECT, bytes.into()).await; } crate::mail::on_invite(state, &invited).await; tracing::info!(bucket, id, to_state, group, user = %invited.username, created = invited.created, "the decision granted a group"); } #[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_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_link_runs_out_when_kanidm_says_in_either_spelling() { assert_eq!(expiry_ms(&serde_json::json!(1790793550)), Some(1790793550000)); assert_eq!(expiry_ms(&serde_json::json!("2026-09-30T12:00:00Z")), Some(1790769600000)); assert_eq!(expiry_ms(&serde_json::json!(null)), None); } #[test] fn members_are_account_names_and_an_empty_group_is_null() { assert_eq!(members_from(r#"["kari@id.vel.no","ola@id.vel.no"]"#), vec!["kari", "ola"]); assert!(members_from("null").is_empty()); assert!(members_from("nonsense").is_empty()); } #[test] fn only_a_listed_credential_counts() { assert!(has_credentials_from(r#"{"creds":[{"type_":"PasswordMfa"}]}"#)); assert!(!has_credentials_from(r#"{"creds":[]}"#)); assert!(!has_credentials_from("null")); } fn invite(state: &str, created: bool, expires: Option) -> crate::answers::Answer { crate::answers::Answer { id: "a".into(), question_id: "/styret".into(), alternative: "Inviter".into(), responses: serde_json::json!({ "name": "Kari", "email": "kari@vel.no", "invited": { "username": "kari", "display_name": "Kari", "email": "kari@vel.no", "group": "styret", "reset_link": "https://id/ui/reset?token=x", "link_expires_ms": expires, "created": created, }}), state: state.into(), submitted_ms: 1_000, storyline: 0, decided_ms: None, decided_by: None, notes: Vec::new(), } } #[test] fn a_waiting_invite_is_done_when_they_are_in_and_expired_when_the_link_ran_out() { let day = LINK_SECONDS * 1000; // Still within the link's life, and nobody has seen them: wait. assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 4_999), None); assert_eq!(invite_due(&invite("open", true, Some(5_000)), false, 5_000), Some(INVITE_EXPIRED)); // In is in, however long the link had left. assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 10), Some(INVITE_DONE)); assert_eq!(invite_due(&invite("open", true, Some(5_000)), true, 9_000), Some(INVITE_DONE)); // A record from before links carried their end: a day from when it was sent. assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day - 1), None); assert_eq!(invite_due(&invite("open", true, None), false, 1_000 + day), Some(INVITE_EXPIRED)); // Someone who had an account was only given a place: nothing to wait for. assert_eq!(invite_due(&invite("open", false, None), false, 10), Some(INVITE_DONE)); // Only a waiting invite moves. assert_eq!(invite_due(&invite("expired", true, Some(5_000)), false, 9_000), None); assert_eq!(invite_due(&invite("done", true, Some(5_000)), true, 9_000), None); } #[test] fn an_invite_is_theirs_by_account_or_by_address() { let invited: Invited = serde_json::from_value(invite("open", true, None).responses["invited"].clone()).unwrap(); let user = |username: &str, email: Option<&str>| crate::auth::User { sub: "s".into(), username: username.into(), display_name: String::new(), groups: vec![], phone: None, email: email.map(String::from), }; assert!(is_for(&invited, &user("kari", None))); assert!(is_for(&invited, &user("kari.n", Some("Kari@Vel.no")))); assert!(!is_for(&invited, &user("ola", Some("ola@vel.no")))); assert!(!is_for(&invited, &user("ola", None))); } #[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); } }