Accounts the content mandates, a seed for the first person, and edits suggested by who is responsible
Test / test (pull_request) Successful in 51s

Every person a page names as responsible gets a Kanidm account at start
if they have none, and a mail saying they are listed as responsible for
asking that question, with the link to sign in; RESPONSIBLE_GROUP puts
them in one group. SEED_ADMIN_EMAIL invites the first person into the
most privileged group - the one whose desk may invite into the most -
once. Signed in, a responsible person may suggest a change to their
own pages; checked on the server, kept, published, and mailed to the
site's inbox.

Fixes an invite of someone new on a Kanidm with only the onboarding
account: the person is created with their address in one call, since
idm_people_on_boarding may create but not modify (it answered 403).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-29 15:44:20 +02:00
co-authored by Claude Opus 5.5
parent bd174224fe
commit 0886869607
12 changed files with 563 additions and 13 deletions
+110
View File
@@ -456,6 +456,13 @@ fn QuestionView(
}
/>
</div>
// The person the page names as responsible, signed in, may
// suggest a change to it; the server checks it is them.
{question
.responsible
.as_ref()
.filter(|r| is_responsible(user.as_ref(), &r.contact))
.map(|_| view! { <SuggestEdit question_id=question.id.clone()/> })}
{question
.responsible
.clone()
@@ -618,6 +625,47 @@ fn Announcements(current_id: String) -> impl IntoView {
/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one
/// out of what they see. Not a defense against a targeted scrape, just
/// against the generic bot crawl.
/// Whether the signed-in person is the one a page names as responsible:
/// their Kanidm address equals the page's `responsible.contact`
/// (`mailto:` off, case ignored).
pub fn is_responsible(user: Option<&User>, contact: &str) -> bool {
let contact = contact.trim().trim_start_matches("mailto:").trim().to_lowercase();
user.and_then(|u| u.email.as_deref()).is_some_and(|e| !contact.is_empty() && e.trim().to_lowercase() == contact)
}
/// A change suggested by the person responsible for the page: one
/// field, sent to the site's inbox. Shown only to them.
#[component]
fn SuggestEdit(question_id: String) -> impl IntoView {
let send = ServerAction::<SuggestPageEdit>::new();
let text = NodeRef::<leptos::html::Textarea>::new();
let sent = move || send.value().get().is_some_and(|r| r.is_ok());
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let value = text.get().map(|t| t.value()).unwrap_or_default();
if !value.trim().is_empty() {
send.dispatch(SuggestPageEdit { question_id: question_id.clone(), text: value });
}
};
view! {
<section class="suggest-edit">
<h2>{move || t(&lang().get(), "suggest_edit")}</h2>
<Show
when=sent
fallback=move || view! {
<p>{move || t(&lang().get(), "suggest_edit_help")}</p>
<form on:submit=on_submit.clone()>
<textarea node_ref=text rows="4" required=true></textarea>
<button type="submit" disabled=move || send.pending().get()>{move || t(&lang().get(), "suggest_edit_send")}</button>
</form>
}
>
<p>{move || t(&lang().get(), "suggest_edit_sent")}</p>
</Show>
</section>
}
}
#[component]
fn ResponsibleNote(responsible: Responsible) -> impl IntoView {
let (user, domain) = responsible
@@ -2433,6 +2481,49 @@ pub struct SubmitResult {
pub chain_hash: String,
}
/// A change to a page, suggested by the person it names as responsible.
/// Checked here, not trusted from the page: the session's address must
/// be the page's responsible contact. Kept in `portal_edit_suggestions`,
/// announced on `portal.edits.suggested`, and mailed to the site's inbox.
#[server(endpoint = "suggest_page_edit")]
pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(), ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::server::AppState;
let state = expect_context::<AppState>();
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
let text = text.trim().to_string();
if text.is_empty() || text.len() > 8000 {
return Err(ServerFnError::new("a suggestion is between one and 8000 characters"));
}
let question = state.questions.load().get(&question_id).cloned().ok_or_else(|| ServerFnError::new("no such page"))?;
let responsible = question.responsible.clone().ok_or_else(|| ServerFnError::new("this page names no one responsible"))?;
if !is_responsible(user.as_ref(), &responsible.contact) {
return Err(ServerFnError::new("only the person responsible for this page may suggest changes to it here"));
}
let user = user.expect("is_responsible needs a user");
let email = user.email.clone().unwrap_or_default();
let now_ms = chrono::Utc::now().timestamp_millis();
let record = serde_json::json!({
"question_id": question_id, "question": question.name,
"by": user.username, "email": email, "text": text, "submitted_ms": now_ms,
});
let store = match state.jetstream.get_key_value("portal_edit_suggestions").await {
Ok(store) => store,
Err(_) => state
.jetstream
.create_key_value(async_nats::jetstream::kv::Config { bucket: "portal_edit_suggestions".to_string(), ..Default::default() })
.await
.map_err(|e| ServerFnError::new(e.to_string()))?,
};
let bytes = serde_json::to_vec(&record).map_err(|e| ServerFnError::new(e.to_string()))?;
store.put(format!("{now_ms}-{}", user.username.replace(|c: char| !c.is_ascii_alphanumeric(), "_")), bytes.clone().into()).await.map_err(|e| ServerFnError::new(e.to_string()))?;
let _ = state.nats.publish("portal.edits.suggested", bytes.into()).await;
crate::mail::on_edit_suggestion(&state, &question_id, &question.name, &user.display_name, &email, &text).await;
Ok(())
}
#[server(endpoint = "submit_answer")]
pub async fn submit_answer(
question_id: String,
@@ -2681,3 +2772,22 @@ mod down_the_tree_tests {
assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/");
}
}
#[cfg(test)]
mod responsible_tests {
use super::is_responsible;
use crate::auth::User;
fn user(email: Option<&str>) -> User {
User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: vec![], email: email.map(String::from) }
}
#[test]
fn only_the_person_a_page_names_may_suggest_a_change_to_it() {
assert!(is_responsible(Some(&user(Some("kari@vel.no"))), "mailto:Kari@Vel.no"));
assert!(!is_responsible(Some(&user(Some("ola@vel.no"))), "kari@vel.no"));
assert!(!is_responsible(Some(&user(None)), "kari@vel.no"));
assert!(!is_responsible(None, "kari@vel.no"));
assert!(!is_responsible(Some(&user(Some(""))), ""));
}
}
+4
View File
@@ -14,6 +14,10 @@ pub struct User {
/// live, so a group change only takes effect on the next login.
#[serde(default)]
pub groups: Vec<String>,
/// The address Kanidm holds for them (the `email` claim), so a page
/// can know it is talking to the person it names as responsible.
#[serde(default)]
pub email: Option<String>,
}
pub const SESSION_USER_KEY: &str = "user";
+12
View File
@@ -34,6 +34,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
}
("en", "announcements") => "Announcements",
("en", "down_to") => "Back to ",
("en", "suggest_edit") => "Suggest a change to this page",
("en", "suggest_edit_help") => "You are listed as responsible for this question. Say what should change, and it goes to the people who keep the site.",
("en", "suggest_edit_send") => "Send the suggestion",
("en", "suggest_edit_sent") => "Thank you - your suggestion is sent.",
("en", "the_start") => "the start",
("no", "also_worth_asking") => "Også verdt å spørre",
@@ -54,6 +58,10 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
}
("no", "announcements") => "Kunngjøringer",
("no", "down_to") => "Tilbake til ",
("no", "suggest_edit") => "Foreslå en endring på denne siden",
("no", "suggest_edit_help") => "Du står oppført som ansvarlig for dette spørsmålet. Skriv hva som bør endres, så går det til dem som har siden.",
("no", "suggest_edit_send") => "Send forslaget",
("no", "suggest_edit_sent") => "Takk - forslaget er sendt.",
("no", "the_start") => "starten",
_ => return None,
@@ -89,6 +97,10 @@ mod tests {
"announcements",
"down_to",
"the_start",
"suggest_edit",
"suggest_edit_help",
"suggest_edit_send",
"suggest_edit_sent",
] {
assert!(lookup("en", key).is_some(), "en missing {key}");
assert!(lookup("no", key).is_some(), "no missing {key}");
+3
View File
@@ -31,6 +31,9 @@ pub mod people;
#[cfg(feature = "ssr")]
pub mod mail;
#[cfg(feature = "ssr")]
pub mod seed;
pub mod maps;
#[cfg(feature = "ssr")]
+93
View File
@@ -301,6 +301,99 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
}
}
/// The mail a person gets when the content names them responsible for
/// a question and they had no account: who they are to the site (the
/// person listed as asking that question), and the link that lets them
/// sign in and suggest changes to it. Built in, in the site's language.
pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::people::Invited, questions: &[String]) {
let site = state.site.load();
let Some(cfg) = &site.mail else {
tracing::warn!(user = %invited.username, "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told");
return;
};
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let quoted: Vec<String> = questions.iter().map(|q| if norwegian { format!("«{q}»") } else { format!("\u{201c}{q}\u{201d}") }).collect();
let list = match quoted.len() {
0 => String::new(),
1 => quoted[0].clone(),
n => format!("{} {} {}", quoted[..n - 1].join(", "), if norwegian { "og" } else { "and" }, quoted[n - 1]),
};
let (subject, body) = if norwegian {
(
"Du står som ansvarlig for et spørsmål hos {site_name}".to_string(),
"Hei {name},\n\nDu står oppført som ansvarlig for å stille {questions} hos {site_name}. Derfor har du fått en konto der, så du kan logge inn og foreslå endringer i det du står bak.\n\nVelg passord eller passnøkkel her, lenken virker fram til {expires}:\n\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}.".to_string(),
)
} else {
(
"You are listed as responsible for a question at {site_name}".to_string(),
"Hello {name},\n\nYou are listed as responsible for asking {questions} at {site_name}. So you now have an account there, to sign in and suggest changes to what you stand behind.\n\nSet your password or passkey here; the link works until {expires}:\n\n{link}\n\nYour username is {username}. Afterwards you sign in at {site}.".to_string(),
)
};
let now_ms = chrono::Utc::now().timestamp_millis();
let expires = invited
.link_expires_ms
.and_then(chrono::DateTime::<chrono::Utc>::from_timestamp_millis)
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_default();
let mut vars = serde_json::Map::new();
vars.insert("name".into(), invited.display_name.clone().into());
vars.insert("username".into(), invited.username.clone().into());
vars.insert("questions".into(), list.into());
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
vars.insert("site".into(), public_url().into());
vars.insert("site_name".into(), site_name(&site).into());
vars.insert("expires".into(), expires.into());
let text = render(&body, &vars);
let mail = Outgoing {
v: CONTRACT,
id: format!("responsible:{}:{now_ms}", invited.username),
site: site_name(&site),
from: cfg.from.clone(),
reply_to: cfg.reply_to.clone(),
to: invited.email.clone(),
subject: render(&subject, &vars),
html: to_html(&text),
text,
};
if let Err(e) = publish(&state.jetstream, &mail).await {
tracing::error!(user = %invited.username, "publishing the responsible mail failed: {e}");
}
}
/// A change suggested by the person responsible for a page, to the
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
/// suggester as reply-to so answering the mail answers them.
pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &str, question_name: &str, by_name: &str, by_email: &str, text: &str) {
let site = state.site.load();
let Some(cfg) = &site.mail else {
tracing::warn!(question_id, "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only");
return;
};
let to = cfg.reply_to.clone().unwrap_or_else(|| cfg.from.clone());
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
let (subject, lead) = if norwegian {
(format!("Forslag til endring: «{question_name}»"), format!("{by_name} ({by_email}), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id}:", public_url()))
} else {
(format!("Suggested change: \u{201c}{question_name}\u{201d}"), format!("{by_name} ({by_email}), listed as responsible for the question, suggests a change to {}{question_id}:", public_url()))
};
let body = format!("{lead}\n\n{text}");
let now_ms = chrono::Utc::now().timestamp_millis();
let mail = Outgoing {
v: CONTRACT,
id: format!("suggestion:{question_id}:{now_ms}"),
site: site_name(&site),
from: cfg.from.clone(),
reply_to: Some(by_email.to_string()),
to,
subject,
html: to_html(&body),
text: body,
};
if let Err(e) = publish(&state.jetstream, &mail).await {
tracing::error!(question_id, "publishing the suggestion mail failed: {e}");
}
}
/// The invite mail: the one-time link to the person just onboarded.
/// Built in, since portal knows everything about it; `mail.invite` in
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
+2
View File
@@ -108,6 +108,8 @@ async fn main() -> anyhow::Result<()> {
// windows (see announce.rs).
tokio::spawn(portal::announce::run(state.clone()));
tokio::spawn(portal::deadlines::run(state.clone()));
// The first person in, when SEED_ADMIN_EMAIL names one (seed.rs).
tokio::spawn(portal::seed::run(state.clone()));
// Replies to case mail, matched to their record by whatever reads
// the mailbox (uhhm/gdo over JMAP) and turned into notes here. A
+29 -13
View File
@@ -109,34 +109,50 @@ impl Kanidm {
/// username, is only added to the group; their credentials are
/// left alone and no link is made.
pub async fn invite(&self, name: &str, email: &str, group: &str) -> anyhow::Result<Invited> {
self.onboard(name, email, Some(group)).await
}
/// An account and nothing more: for a person the content names as
/// responsible for a page, who needs to be able to sign in whether
/// or not they sit at any desk. `group` is empty in what comes back.
pub async fn account(&self, name: &str, email: &str) -> anyhow::Result<Invited> {
self.onboard(name, email, None).await
}
/// Whether Kanidm already holds a person with this address.
pub async fn has_person_with_mail(&self, email: &str) -> anyhow::Result<bool> {
Ok(self.find_by_mail(email).await?.is_some())
}
async fn onboard(&self, name: &str, email: &str, group: Option<&str>) -> anyhow::Result<Invited> {
let existing = self.find_by_mail(email).await?;
let username = existing.clone().unwrap_or_else(|| username_for(email));
let created = if existing.is_some() || self.person_exists(&username).await? {
false
} else {
// The address goes in with the person, in one call: the
// onboarding account may create a person but not modify one
// (idm_people_on_boarding), so a separate PUT of `mail`
// afterwards is refused with 403 and the invite fails.
let (status, text) = self
.call(
openidconnect::reqwest::Method::POST,
"/v1/person",
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name] } })),
Some(serde_json::json!({ "attrs": { "name": [username], "displayname": [name], "mail": [email] } })),
)
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: creating {username}: http {status}: {text}");
}
let (status, text) = self
.call(openidconnect::reqwest::Method::PUT, &format!("/v1/person/{username}/_attr/mail"), Some(serde_json::json!([email])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: setting mail on {username}: http {status}: {text}");
}
true
};
let (status, text) = self
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
if let Some(group) = group {
let (status, text) = self
.call(openidconnect::reqwest::Method::POST, &format!("/v1/group/{group}/_attr/member"), Some(serde_json::json!([username])))
.await?;
if !(200..300).contains(&status) {
anyhow::bail!("kanidm: adding {username} to {group}: http {status}: {text}");
}
}
let (reset_link, link_expires_ms) = if created {
// 24 hours: the longest Kanidm allows, since the link travels
@@ -163,7 +179,7 @@ impl Kanidm {
username,
display_name: name.to_string(),
email: email.to_string(),
group: group.to_string(),
group: group.unwrap_or_default().to_string(),
reset_link,
link_expires_ms,
created,
+272
View File
@@ -0,0 +1,272 @@
//! 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"));
}
}
+2
View File
@@ -241,11 +241,13 @@ pub async fn callback(
// raw compact JWT string "header.payload.signature".
let groups = extract_groups_claim(&id_token);
let email = claims.email().map(|e| e.as_str().trim().to_lowercase());
let user = User {
sub: claims.subject().as_str().to_string(),
username,
display_name,
groups,
email,
};
// Rotate the session id on privilege change, then store the user.