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>
183 lines
7.5 KiB
Rust
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);
|
|
}
|
|
}
|