Test / test (pull_request) Successful in 1m9s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
456 lines
21 KiB
Rust
456 lines
21 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.
|
|
//!
|
|
//! **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<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. 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 };
|
|
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,
|
|
mail: None,
|
|
fields: Default::default(),
|
|
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);
|
|
// 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"));
|
|
}
|
|
}
|