Test / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
765 lines
34 KiB
Rust
765 lines
34 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 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<Self> {
|
|
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<Invited> {
|
|
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<String>,
|
|
pub link_expires_ms: Option<i64>,
|
|
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<i64> {
|
|
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<String> {
|
|
serde_json::from_str::<Option<Vec<String>>>(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::<serde_json::Value>(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/<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,
|
|
}
|
|
}
|
|
|
|
/// The first entry's `name` in a `/v1/raw/search` answer, which is
|
|
/// `{"entries": [{"attrs": {"name": ["…"], …}}]}`.
|
|
fn name_from_search(body: &str) -> Option<String> {
|
|
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<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())?;
|
|
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<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 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<Option<String>> {
|
|
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<Invited> {
|
|
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<Invited> {
|
|
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<bool> {
|
|
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<Vec<String>> {
|
|
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<bool> {
|
|
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<i64>)> {
|
|
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<serde_json::Value>,
|
|
}
|
|
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<Invited> {
|
|
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<Invited> {
|
|
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<crate::answers::Answer>)> {
|
|
// 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<String> = {
|
|
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::<crate::answers::Answer>(&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::<crate::answers::Answer>(&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<String> = 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<i64>) -> 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);
|
|
}
|
|
}
|