273 lines
13 KiB
Rust
273 lines
13 KiB
Rust
//! 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"));
|
||
|
|
}
|
||
|
|
}
|