Files
portal/src/seed.rs
T

273 lines
13 KiB
Rust
Raw Normal View History

//! 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. Kanidm and gdo may still be coming
//! up alongside portal, so a failed attempt is retried for a while.
//!
//! **Everyone the content names as responsible.** A page's
//! `responsible: { name, contact }` is a person answering for that
//! question, so they get an account: at every start, 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`).
#![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<String, Question>) -> Option<String> {
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::<String>();
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.
pub fn responsible_people(questions: &HashMap<String, Question>) -> BTreeMap<String, (String, Vec<String>)> {
let mut ids: Vec<&String> = questions.keys().collect();
ids.sort();
let mut out: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
for id in ids {
let q = &questions[id];
let Some(r) = &q.responsible else { 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<String> {
let c = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
(c.contains('@') && !c.contains(char::is_whitespace)).then_some(c)
}
/// The first person in, then everyone the content names as responsible.
pub async fn run(state: crate::server::AppState) {
seed_admin(&state).await;
responsible(&state).await;
}
async fn seed_bucket(state: &crate::server::AppState) -> Option<async_nats::jetstream::kv::Store> {
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
}
},
}
}
async fn responsible(state: &crate::server::AppState) {
let people = responsible_people(&state.questions.load());
if people.is_empty() {
return;
}
let Some(kanidm) = crate::people::Kanidm::from_env() else {
tracing::info!(people = people.len(), "the content names responsible people, but invites are not configured (KANIDM_API_TOKEN) - no accounts made");
return;
};
let Some(store) = seed_bucket(state).await else { return };
let group = std::env::var("RESPONSIBLE_GROUP").ok().map(|g| g.trim().to_string()).filter(|g| !g.is_empty());
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 };
// 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. 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 {
Some(group) => kanidm.invite(&name, &email, group).await,
None => 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),
},
};
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::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) => tracing::warn!(%email, "could not set up a responsible person, leaving them for the next start: {e}"),
}
}
}
/// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one.
async fn seed_admin(state: &crate::server::AppState) {
let Some(email) = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@')) else {
return;
};
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(kanidm) = crate::people::Kanidm::from_env() else {
tracing::warn!(group, "SEED_ADMIN_EMAIL is set, but invites are not configured on this instance (KANIDM_URL, KANIDM_API_TOKEN) - nobody was seeded");
return;
};
// Kanidm may be starting beside us: a minute apart, for twenty.
let mut attempt = 0;
let invited = loop {
attempt += 1;
match kanidm.invite(&name, &email, &group).await {
Ok(invited) => break invited,
Err(e) if attempt < 20 => {
tracing::warn!(group, attempt, "seeding the first person failed, trying again in a minute: {e}");
tokio::time::sleep(std::time::Duration::from_secs(60)).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");
}
}
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<String, Question> = [
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<String, Question> = [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<String, Question> = [page("/", None, &[])].into_iter().collect();
assert!(most_privileged_group(&none).is_none());
}
#[test]
fn everyone_named_responsible_is_found_once_by_their_address() {
let mut qs: HashMap<String, Question> = 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::<Vec<_>>(), ["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);
}
#[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"));
}
}