Files
portal/src/seed.rs
T

454 lines
21 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.
//!
//! **Everyone the content names as responsible.** A page's
//! `responsible: { name, contact }` is a person answering for that
2026-09-30 08:17:27 +02:00
//! 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<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
2026-09-30 08:17:27 +02:00
/// 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<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 };
2026-09-30 08:17:27 +02:00
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<String> {
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<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
}
},
}
}
fn seed_admin_email() -> Option<String> {
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<String, (String, Vec<String>)>,
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<String> = {
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::<crate::people::Invited>(&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<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());
}
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<String, (String, Vec<String>)> = [("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<_>>(), 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<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);
2026-09-30 08:17:27 +02:00
// 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"));
}
}