//! Accounts the content mandates, made at start. //! //! **The first person in.** A site with its own Kanidm starts with nobody //! on any desk, and every later person is invited from a desk - so the //! first one has to come from somewhere else. `SEED_ADMIN_EMAIL` names //! them: at start, portal invites that address into the site's most //! privileged group and mails them the one-time link, exactly as a desk //! invite would. From there they invite everyone else themselves. //! //! - `SEED_ADMIN_EMAIL` - who; unset, nothing happens. //! - `SEED_ADMIN_NAME` - their display name; defaults to the address's //! local part. //! - `SEED_GROUP` - the group; defaults to the one whose desk may invite //! into the most groups (the board, on a vel), read from the content. //! //! Once, not on every start: a done seed is remembered in the //! `portal_seed` bucket, keyed by address and group, so a restart or an //! upgrade never mails a second link. Changing `SEED_ADMIN_EMAIL` or //! `SEED_GROUP` seeds the new pair. //! //! **Everyone the content names as responsible.** A page's //! `responsible: { name, contact }` is a person answering for that //! question - unless `group` says it is a group's, in which case the //! address is an inbox and nobody is made for it. A person gets an //! account: at start, and again whenever the //! content changes, each responsible address Kanidm does not already //! hold is given one, and mailed that they are listed as responsible //! for asking those questions, with the link to sign in. Signed in, //! their own pages offer them a way to suggest changes. //! `RESPONSIBLE_GROUP` names a group they all join - on the vel, //! `redaktor`, which is also who may sign in to the project Gitea - and //! people who already had an account are added to it without a mail. //! A desk is still a desk invite. Each address is onboarded once per //! group (`portal_seed`). //! //! **And no longer.** Someone the content has stopped naming leaves //! `RESPONSIBLE_GROUP` in the same pass, if portal is who put them //! there. Their account is theirs and stays. //! //! Both run side by side and try again a minute apart, twenty times: //! Kanidm and gdo may still be coming up alongside portal. Every //! invite made here is put on file in `portal_invites` like a desk's, //! so a link that runs out unused shows up where someone can send it //! again. #![cfg(feature = "ssr")] use std::collections::{BTreeMap, BTreeSet, HashMap}; use crate::content::Question; pub const SEED_BUCKET: &str = "portal_seed"; /// The group whose members may invite into the most groups: a page /// gated to group G carrying an `invite: { group: X }` alternative /// means G may bring people into X. Ties go to the name that sorts /// first, so the choice is stable across starts. `None` when no page /// invites anyone. pub fn most_privileged_group(questions: &HashMap) -> Option { let mut reach: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); for q in questions.values() { let Some(group) = q.qualifies.as_deref() else { continue }; for alt in &q.alternatives { if let Some(invite) = &alt.invite { reach.entry(group).or_default().insert(invite.group.as_str()); } } } reach .iter() .max_by(|(a, x), (b, y)| x.len().cmp(&y.len()).then_with(|| b.cmp(a))) .map(|(group, _)| group.to_string()) } /// The bucket key for one seed: stable, and within NATS key rules. pub fn seed_key(email: &str, group: &str) -> String { let clean = |s: &str| s.trim().to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect::(); format!("{}.{}", clean(group), clean(email)) } /// Everyone the content names as responsible, by address (lowercased, /// `mailto:` taken off): their name as the first page gives it, and the /// questions they answer for, in page order. A page a group answers /// for (`responsible.group`) names nobody: the board's inbox is not a /// person, and its members have their accounts through the desk. pub fn responsible_people(questions: &HashMap) -> BTreeMap)> { let mut ids: Vec<&String> = questions.keys().collect(); ids.sort(); let mut out: BTreeMap)> = BTreeMap::new(); for id in ids { let q = &questions[id]; let Some(r) = &q.responsible else { continue }; if r.group.as_deref().is_some_and(|g| !g.trim().is_empty()) { continue; } let email = contact_email(&r.contact); let Some(email) = email else { continue }; let entry = out.entry(email).or_insert_with(|| (r.name.trim().to_string(), Vec::new())); if !entry.1.contains(&q.name) { entry.1.push(q.name.clone()); } } out } /// A responsible `contact` as an address: `mailto:` off, lowercased; /// `None` for a phone number or anything else that is not one. pub fn contact_email(contact: &str) -> Option { let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase(); (c.contains('@') && !c.contains(char::is_whitespace)).then_some(c) } /// A failed attempt is tried again this many times, this far apart: /// Kanidm and gdo may still be coming up beside portal. const ATTEMPTS: u32 = 20; const APART: std::time::Duration = std::time::Duration::from_secs(60); /// One pass over the responsible people at a time: a reload may arrive /// while the pass from the start is still at it, and two at once would /// both find the same person missing and invite them twice. static PASS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); /// The first person in and everyone the content names as responsible, /// side by side, so neither waits on the other's retries - and the /// responsible again whenever the content changes, for the life of the /// process. pub async fn run(state: crate::server::AppState) { // Subscribed before the first pass, so content that changes while // it runs is not missed. let reloads = state.nats.subscribe(crate::content::CONTENT_RELOADED_SUBJECT).await; tokio::join!(seed_admin(&state), responsible_until_done(&state), async { let mut reloads = match reloads { Ok(reloads) => reloads, Err(e) => { tracing::error!("could not listen for changed content: {e} - a new responsible person waits for the next start"); return; } }; use futures::StreamExt; while reloads.next().await.is_some() { responsible_until_done(&state).await; } }); } /// Passes over the responsible people until one leaves nobody behind. /// Each pass reads the content afresh, so what it retries is what the /// content says by then. async fn responsible_until_done(state: &crate::server::AppState) { for attempt in 1..=ATTEMPTS { let left = { let _one_at_a_time = PASS.lock().await; responsible(state).await }; if left == 0 { return; } if attempt == ATTEMPTS { tracing::error!(left, "some responsible people could not be set up, for good - until the content changes or portal starts again"); return; } tracing::warn!(left, attempt, "some responsible people could not be set up, trying again in a minute"); tokio::time::sleep(APART).await; } } async fn seed_bucket(state: &crate::server::AppState) -> Option { match state.jetstream.get_key_value(SEED_BUCKET).await { Ok(store) => Some(store), Err(_) => match state.jetstream.create_key_value(async_nats::jetstream::kv::Config { bucket: SEED_BUCKET.to_string(), ..Default::default() }).await { Ok(store) => Some(store), Err(e) => { tracing::error!("the seed bucket could not be made: {e} - making no accounts, so as not to make them twice"); None } }, } } fn seed_admin_email() -> Option { std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_lowercase()).filter(|e| e.contains('@')) } /// Who portal put in the responsible group and the content no longer /// names: their key in the seed bucket, and the account. The first /// person is never among them - with `SEED_GROUP` the same group, their /// place there is from the seed, not from a page. pub fn no_longer_responsible( on_file: &[(String, crate::people::Invited)], people: &BTreeMap)>, seed_admin: Option<&str>, ) -> Vec<(String, crate::people::Invited)> { on_file .iter() .filter(|(_, invited)| { let email = invited.email.trim().to_lowercase(); !people.contains_key(&email) && seed_admin != Some(email.as_str()) }) .cloned() .collect() } /// One pass: an account for everyone the content names, and out of the /// group for everyone it has stopped naming. Returns how many people /// it could not finish with. async fn responsible(state: &crate::server::AppState) -> usize { let people = responsible_people(&state.questions.load()); let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()); if people.is_empty() && group.is_none() { return 0; } let Some(people_backend) = crate::people::People::from_env() else { if !people.is_empty() { tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED); } return 0; }; let Some(store) = seed_bucket(state).await else { return 0 }; let mut left = 0; for (email, (name, questions)) in &people { let key = seed_key(email, group.as_deref().unwrap_or("responsible")); if let Ok(Some(_)) = store.get(&key).await { continue; } let name = if name.is_empty() { email.split('@').next().unwrap_or("").to_string() } else { name.clone() }; let who = crate::memberships::Identity::from_contact(email); // With a group, one call makes the account if there is none and // adds them either way; without one, only a missing account is // made (Kanidm; a membership is nothing without a group). Either // way a person is mailed only when an account was made for // them: someone who had one already has their way in. let made = match (&group, &people_backend) { (Some(group), people_backend) => people_backend.invite(state, &name, &who, group, "responsible").await, (None, crate::people::People::Kanidm(kanidm)) => match kanidm.has_person_with_mail(email).await { Ok(true) => { let _ = store.put(&key, "existing".into()).await; continue; } Ok(false) => kanidm.account(&name, email).await, Err(e) => Err(e), }, (None, crate::people::People::Memberships) => continue, }; match made { Ok(invited) => { if let Ok(bytes) = serde_json::to_vec(&invited) { let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await; let _ = store.put(&key, bytes.into()).await; } if invited.created { crate::people::record(state, &invited, questions).await; crate::mail::on_responsible(state, &invited, questions).await; } tracing::info!(user = %invited.username, created = invited.created, group = group.as_deref().unwrap_or(""), "a person the content names as responsible has their account"); } Err(e) => { left += 1; tracing::warn!(%email, "could not set up a responsible person: {e}"); } } } // Being responsible ends when the content stops saying so. The // account stays - it is theirs - but the place in the group, and // whatever else that group opens, goes with the page. Only what // portal gave: someone who was in the group before is left there. let Some(group) = group else { return left }; let prefix = seed_key("", &group); let keys: Vec = { use futures::TryStreamExt; match store.keys().await { Ok(keys) => keys.try_collect().await.unwrap_or_default(), Err(_) => Vec::new(), } }; let mut on_file = Vec::new(); for key in keys.into_iter().filter(|k| k.starts_with(&prefix)) { let Ok(Some(bytes)) = store.get(&key).await else { continue }; let Ok(invited) = serde_json::from_slice::(&bytes) else { continue }; on_file.push((key, invited)); } for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) { if !invited.already_member { let who = crate::memberships::Identity::from_contact(&invited.email); if let Err(e) = people_backend.remove(state, &who, &invited.username, &group, "responsible").await { left += 1; tracing::warn!(user = %invited.username, group, "could not take someone no longer responsible out of the group: {e}"); continue; } } // Forgotten, so that being named again is being set up again. let _ = store.purge(&key).await; tracing::info!(user = %invited.username, group, removed = !invited.already_member, "the content no longer names this person as responsible"); } left } /// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one - /// or `SEED_ADMIN_PHONE`, for a site whose provider knows people by /// their number. async fn seed_admin(state: &crate::server::AppState) { let phone = std::env::var("SEED_ADMIN_PHONE").ok().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()); let email = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@')); let (Some(contact), who) = ( phone.clone().or_else(|| email.clone()), crate::memberships::Identity { sub: None, phone: phone.as_deref().and_then(|p| crate::memberships::normalize_phone(p, &std::env::var("PHONE_COUNTRY").unwrap_or_else(|_| "+47".into()))), email: email.as_deref().map(|e| e.to_lowercase()), }, ) else { return; }; let email = contact; let name = std::env::var("SEED_ADMIN_NAME") .ok() .map(|n| n.trim().to_string()) .filter(|n| !n.is_empty()) .unwrap_or_else(|| email.split('@').next().unwrap_or("admin").to_string()); let group = match std::env::var("SEED_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty()) { Some(g) => g, None => match most_privileged_group(&state.questions.load()) { Some(g) => g, None => { tracing::warn!("SEED_ADMIN_EMAIL is set, but no page invites anyone and SEED_GROUP is unset - nobody to seed into"); return; } }, }; let key = seed_key(&email, &group); let Some(store) = seed_bucket(state).await else { return }; if let Ok(Some(_)) = store.get(&key).await { tracing::info!(group, "the first person was already invited into this group; not again"); return; } let Some(people) = crate::people::People::from_env() else { tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but {} - nobody was seeded", crate::people::People::NOT_CONFIGURED); return; }; let mut attempt = 0; let invited = loop { attempt += 1; match people.invite(state, &name, &who, &group, "seed").await { Ok(invited) => break invited, Err(e) if attempt < ATTEMPTS => { tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}"); tokio::time::sleep(APART).await; } Err(e) => { tracing::error!(group, "seeding the first person failed for good: {e}"); return; } } }; if let Ok(bytes) = serde_json::to_vec(&invited) { let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.clone().into()).await; if let Err(e) = store.put(&key, bytes.into()).await { tracing::error!(group, "the first person is invited, but it could not be remembered: {e} - a restart would invite them again"); } } if invited.created { crate::people::record(state, &invited, &[]).await; } crate::mail::on_invite(state, &invited).await; tracing::info!(group, user = %invited.username, created = invited.created, "the first person is invited"); } #[cfg(test)] mod tests { use super::*; fn page(id: &str, qualifies: Option<&str>, invites: &[&str]) -> (String, Question) { let alternatives: String = invites .iter() .map(|g| format!(" - name: Invite into {g}\n invite: {{ group: {g} }}\n")) .collect(); let yaml = format!("name: Q?\n{}alternatives:\n{}", qualifies.map(|q| format!("qualifies: {q}\n")).unwrap_or_default(), if alternatives.is_empty() { " - name: A\n".to_string() } else { alternatives }); let mut q: Question = serde_yaml::from_str(&yaml).unwrap(); q.id = id.to_string(); (id.to_string(), q) } #[test] fn the_first_person_goes_where_the_most_invitations_can_be_made() { let qs: HashMap = [ page("/", None, &[]), page("/styret", Some("styret"), &["styret", "kasserer", "trafikk", "komiteer"]), page("/kasserer", Some("kasserer"), &["kasserer"]), page("/trafikk", Some("trafikk"), &["trafikk"]), ] .into_iter() .collect(); assert_eq!(most_privileged_group(&qs).as_deref(), Some("styret")); // A tie goes to the name that sorts first, every time. let tie: HashMap = [page("/b", Some("b"), &["x"]), page("/a", Some("a"), &["y"])].into_iter().collect(); assert_eq!(most_privileged_group(&tie).as_deref(), Some("a")); // No invitations anywhere: nobody to seed into without SEED_GROUP. let none: HashMap = [page("/", None, &[])].into_iter().collect(); assert!(most_privileged_group(&none).is_none()); } fn on_file(email: &str, already_member: bool) -> (String, crate::people::Invited) { ( seed_key(email, "redaktor"), crate::people::Invited { username: email.split('@').next().unwrap().to_string(), display_name: String::new(), email: email.to_string(), group: "redaktor".to_string(), reset_link: None, link_expires_ms: None, created: true, already_member, }, ) } #[test] fn whoever_the_content_stopped_naming_leaves_but_never_the_first_person() { let people: BTreeMap)> = [("kari@vel.no".to_string(), ("Kari".to_string(), vec!["Hva?".to_string()]))].into_iter().collect(); let filed = vec![on_file("kari@vel.no", false), on_file("Ola@vel.no", false), on_file("leder@vel.no", false)]; let gone = no_longer_responsible(&filed, &people, Some("leder@vel.no")); assert_eq!(gone.iter().map(|(_, i)| i.username.as_str()).collect::>(), vec!["Ola"]); assert_eq!(gone[0].0, "redaktor.ola_vel_no"); // Nobody named at all: everyone portal put there leaves. assert_eq!(no_longer_responsible(&filed, &BTreeMap::new(), None).len(), 3); } #[test] fn everyone_named_responsible_is_found_once_by_their_address() { let mut qs: HashMap = HashMap::new(); for (id, name, contact) in [("/", "Hva gjør vi?", "mailto:Styret@Vel.no"), ("/melde-fra", "Hva vil du melde?", "styret@vel.no"), ("/b", "Ringe?", "+47 900 00 000"), ("/c", "Trafikk?", "trafikk@vel.no")] { let mut q: Question = serde_yaml::from_str(&format!("name: \"{name}\"\nresponsible: {{ name: Styret, contact: \"{contact}\" }}\nalternatives:\n - name: A\n")).unwrap(); q.id = id.to_string(); qs.insert(id.to_string(), q); } let people = responsible_people(&qs); assert_eq!(people.keys().collect::>(), ["styret@vel.no", "trafikk@vel.no"]); assert_eq!(people["styret@vel.no"].1, ["Hva gjør vi?", "Hva vil du melde?"]); assert_eq!(contact_email("+47 900 00 000"), None); // A group's inbox is nobody: its members have the desk. let mut held: Question = serde_yaml::from_str("name: \"Hva?\"\nresponsible: { name: Styret, contact: post@vel.no, group: styret }\nalternatives:\n - name: A\n").unwrap(); held.id = "/d".into(); qs.insert("/d".into(), held); assert!(!responsible_people(&qs).contains_key("post@vel.no")); } #[test] fn a_seed_is_remembered_by_address_and_group() { assert_eq!(seed_key(" Kari.Lien@Tomtervel.no ", "styret"), "styret.kari_lien_tomtervel_no"); assert_ne!(seed_key("kari@x.no", "styret"), seed_key("kari@x.no", "kasserer")); } }