//! Who belongs to which group, kept by portal itself. //! //! A membership is a case: one record per (group, person) in the //! built-in `portal_memberships` bucket, `invited` until the person is //! seen, `active` from then on, `removed` when a desk takes them out. //! A desk invite writes one, a state that `grants` a group writes one, //! the first person and the people the content names as responsible //! get theirs at start. Nothing here talks to an identity provider: //! the provider says who someone is, and this says what they belong //! to. At sign-in, the person's active memberships become their groups, //! and the access policy (Cedar, `access.rs`) sees them as the parents //! of the `User` entity, exactly as it saw a `groups` claim before. //! //! **A person is their phone number first, their email second.** A //! provider that verifies phone numbers (Vipps) makes the number the //! surest thing a record can hold; a form that collected an address //! still matches on it. A record's key is the group and whichever of //! the two it has, so a second invite of the same person into the same //! group finds the first. #![cfg(feature = "ssr")] use serde::{Deserialize, Serialize}; use crate::answers::Answer; pub const BUCKET: &str = "portal_memberships"; pub const INVITED: &str = "invited"; pub const ACTIVE: &str = "active"; pub const REMOVED: &str = "removed"; /// Stamped on moves nobody at a desk made. pub const BY: &str = "portal"; /// The country a phone number without one is in, as a calling code. fn default_country() -> String { std::env::var("PHONE_COUNTRY").ok().filter(|c| !c.trim().is_empty()).unwrap_or_else(|| "+47".to_string()) } /// Who a person is, as far as a record or a session knows. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Identity { #[serde(default, skip_serializing_if = "Option::is_none")] pub sub: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub phone: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub email: Option, } /// A phone number as one string for everyone: digits with a leading /// `+` and a country code, `+4791234567`. Spaces, dots and dashes go; /// `00` becomes `+`; a bare national number gets the site's country. /// `None` for anything that is not a number. pub fn normalize_phone(raw: &str, country: &str) -> Option { let mut digits: String = raw.trim().chars().filter(|c| c.is_ascii_digit()).collect(); let trimmed = raw.trim(); if digits.is_empty() || digits.len() < 5 || digits.len() > 15 { return None; } if trimmed.starts_with('+') { return Some(format!("+{digits}")); } if let Some(rest) = digits.strip_prefix("00") { return Some(format!("+{rest}")); } if trimmed.starts_with('(') && !trimmed.contains('+') { digits = digits.trim_start_matches('0').to_string(); } Some(format!("{}{digits}", country.trim())) } fn normalize_email(raw: &str) -> Option { let e = raw.trim().trim_start_matches("mailto:").trim().to_lowercase(); (e.contains('@') && !e.contains(char::is_whitespace)).then_some(e) } impl Identity { /// From what a form collected: the first phone-like field and the /// first email-like field, by the names forms use for them. pub fn from_responses(responses: &serde_json::Value) -> Self { let field = |names: &[&str]| { names .iter() .find_map(|n| responses.get(*n).and_then(|v| v.as_str())) .map(str::trim) .filter(|s| !s.is_empty()) .map(String::from) }; let country = default_country(); Identity { sub: None, phone: field(&["phone", "telefon", "tel", "mobil", "mobile", "phone_number"]).and_then(|p| normalize_phone(&p, &country)), email: field(&["email", "epost", "e-post", "mail"]).and_then(|e| normalize_email(&e)), } } pub fn from_user(user: &crate::auth::User) -> Self { let country = default_country(); Identity { sub: Some(user.sub.clone()), phone: user.phone.as_deref().and_then(|p| normalize_phone(p, &country)), email: user.email.as_deref().and_then(normalize_email), } } /// One person given as one string: a phone number if it reads as /// one, else an address. pub fn from_contact(contact: &str) -> Self { let country = default_country(); match normalize_email(contact) { Some(email) => Identity { sub: None, phone: None, email: Some(email) }, None => Identity { sub: None, phone: normalize_phone(contact, &country), email: None }, } } /// What a membership record is keyed by: the phone number, else the /// address, else the provider's subject. `None` when there is /// nothing to know the person by. pub fn key(&self) -> Option { self.phone .as_deref() .or(self.email.as_deref()) .or(self.sub.as_deref()) .map(|k| k.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()) } /// Whether a record about `other` is about this person: the same /// subject, phone number or address, any one of them. pub fn matches(&self, other: &Identity) -> bool { let same = |a: &Option, b: &Option| a.is_some() && a == b; same(&self.sub, &other.sub) || same(&self.phone, &other.phone) || same(&self.email, &other.email) } /// What is worth writing on a record from a login: everything the /// provider verified that the record did not have. fn merged_from(&self, seen: &Identity) -> Identity { Identity { sub: self.sub.clone().or_else(|| seen.sub.clone()), phone: self.phone.clone().or_else(|| seen.phone.clone()), email: self.email.clone().or_else(|| seen.email.clone()), } } } /// One record's id: the group and the person's key. pub fn record_id(group: &str, key: &str) -> String { format!("{}.{key}", group.trim()) } /// The identity a record holds. pub fn identity_of(answer: &Answer) -> Identity { Identity { sub: answer.responses.get("sub").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), phone: answer.responses.get("phone").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), email: answer.responses.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), } } pub fn group_of(answer: &Answer) -> String { answer.responses.get("group").and_then(|v| v.as_str()).unwrap_or_default().to_string() } /// The groups `who` is an active member of, from every record. pub fn groups_of(records: &[Answer], who: &Identity) -> Vec { let mut groups: Vec = records .iter() .filter(|a| a.state == ACTIVE && identity_of(a).matches(who)) .map(group_of) .filter(|g| !g.is_empty()) .collect(); groups.sort(); groups.dedup(); groups } async fn store(state: &crate::server::AppState) -> anyhow::Result { match state.jetstream.get_key_value(BUCKET).await { Ok(store) => Ok(store), Err(_) => Ok(state .jetstream .create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_string(), ..Default::default() }) .await?), } } /// Every membership record. pub async fn all(state: &crate::server::AppState) -> Vec { let Ok(store) = state.jetstream.get_key_value(BUCKET).await else { return Vec::new() }; let keys: Vec = { use futures::TryStreamExt; match store.keys().await { Ok(keys) => keys.try_collect().await.unwrap_or_default(), Err(_) => Vec::new(), } }; let mut out = Vec::new(); for key in keys { if let Ok(Some(bytes)) = store.get(&key).await { if let Ok(answer) = serde_json::from_slice::(&bytes) { out.push(answer); } } } out } /// Moves one record the way a desk button would: through the event /// log, onto the projection, out on the answers subject. async fn move_to(state: &crate::server::AppState, store: &async_nats::jetstream::kv::Store, mut answer: Answer, target: &str, by: &str) -> anyhow::Result { let aggregates = state.aggregates.load(); let schema = aggregates.get(BUCKET).ok_or_else(|| anyhow::anyhow!("no {BUCKET} schema"))?; let now_ms = chrono::Utc::now().timestamp_millis(); let payload = serde_json::json!({ "to": target, "item": answer.id, "by": by }); crate::answers::transition_or_reseed(&state.jetstream, schema, &answer.id, target, &answer.state, payload.clone(), now_ms) .await .map_err(|e| anyhow::anyhow!("{e}"))?; answer.state = target.to_string(); answer.decided_ms = Some(now_ms); answer.decided_by = Some(by.to_string()); store.put(&answer.id, serde_json::to_vec(&answer)?.into()).await?; let parent_hashes = vec![answer.id.clone()]; let chain_hash = crate::chain::hash_node(&answer.question_id, &parent_hashes, &payload, now_ms); let event = crate::events::AnswerSubmitted { chain_hash, parent_hashes, question_id: answer.question_id.clone(), alternative: target.to_string(), responses: payload, timestamp_ms: now_ms, storyline: answer.storyline, }; let _ = crate::events::emit_answer_submitted(&state.nats, &event).await; Ok(answer) } /// Puts a person in a group, or finds them there. A new record starts /// `invited`; a removed one is invited again; an invited or active one /// is left as it is, so a second grant costs nothing. `created` in /// what comes back says whether the person had a place already. pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who: &Identity, by: &str) -> anyhow::Result { let key = who.key().ok_or_else(|| anyhow::anyhow!("a membership needs a phone number or an address to know the person by"))?; let store = store(state).await?; // The person's record in this group, however it was keyed: a desk // that invited by address and a decision that granted by phone // number are talking about one person once a sign-in has joined // the two, and must not make two. let existing: Option = all(state).await.into_iter().find(|a| group_of(a) == group.trim() && identity_of(a).matches(who)); let id = existing.as_ref().map(|a| a.id.clone()).unwrap_or_else(|| record_id(group, &key)); let now_ms = chrono::Utc::now().timestamp_millis(); let invited = |created: bool| crate::people::Invited { username: key.clone(), display_name: name.to_string(), email: who.email.clone().unwrap_or_default(), group: group.to_string(), reset_link: None, link_expires_ms: None, created, mail: None, fields: Default::default(), already_member: !created, }; match existing { Some(answer) if answer.state == REMOVED => { move_to(state, &store, answer, INVITED, by).await?; Ok(invited(true)) } Some(_) => Ok(invited(false)), None => { let responses = serde_json::json!({ "name": name, "group": group, "by": by, "phone": who.phone, "email": who.email, "sub": who.sub, }); crate::answers::store_answer(&state.jetstream, &state.aggregates.load(), BUCKET, id, "/", "Membership", &responses, now_ms, 0).await?; Ok(invited(true)) } } } /// Takes a person out of a group: the record moves to `removed` and /// stays, so the desk can see who was in and put them back. pub async fn revoke(state: &crate::server::AppState, group: &str, who: &Identity, by: &str) -> anyhow::Result { let Some(key) = who.key() else { return Ok(false) }; let store = store(state).await?; let id = record_id(group, &key); let Some(answer) = store.get(&id).await?.and_then(|b| serde_json::from_slice::(&b).ok()) else { return Ok(false); }; if answer.state == REMOVED { return Ok(false); } move_to(state, &store, answer, REMOVED, by).await?; Ok(true) } /// Someone signed in: every invitation that was for them is now a /// membership, with what the provider verified written on the record, /// and their active groups come back to be their groups for the /// session. Their `sub` is remembered, so the next login matches on /// it before anything else. pub async fn on_signed_in(state: &crate::server::AppState, user: &crate::auth::User) -> Vec { let who = Identity::from_user(user); let records = all(state).await; let Ok(store) = store(state).await else { return groups_of(&records, &who) }; let mut settled = Vec::with_capacity(records.len()); for answer in records { let held = identity_of(&answer); if !held.matches(&who) { settled.push(answer); continue; } let merged = held.merged_from(&who); let mut answer = answer; if merged != held { if let Some(map) = answer.responses.as_object_mut() { map.insert("sub".into(), serde_json::json!(merged.sub)); map.insert("phone".into(), serde_json::json!(merged.phone)); map.insert("email".into(), serde_json::json!(merged.email)); } if let Ok(bytes) = serde_json::to_vec(&answer) { let _ = store.put(&answer.id, bytes.into()).await; } } if answer.state == INVITED { match move_to(state, &store, answer.clone(), ACTIVE, BY).await { Ok(active) => { tracing::info!(group = %group_of(&active), user = %user.username, "an invitation became a membership"); settled.push(active); } Err(e) => { tracing::warn!(id = %answer.id, "an invitation could not become a membership: {e}"); settled.push(answer); } } } else { settled.push(answer); } } groups_of(&settled, &who) } #[cfg(test)] mod tests { use super::*; fn record(group: &str, state: &str, phone: Option<&str>, email: Option<&str>, sub: Option<&str>) -> Answer { Answer { id: record_id(group, "k"), question_id: "/".into(), alternative: "Membership".into(), responses: serde_json::json!({ "group": group, "phone": phone, "email": email, "sub": sub, "name": "X" }), state: state.into(), submitted_ms: 0, storyline: 0, decided_ms: None, decided_by: None, notes: Vec::new(), } } #[test] fn a_phone_number_is_one_string_for_everyone() { assert_eq!(normalize_phone("912 34 567", "+47"), Some("+4791234567".into())); assert_eq!(normalize_phone("+47 912-34-567", "+47"), Some("+4791234567".into())); assert_eq!(normalize_phone("004791234567", "+47"), Some("+4791234567".into())); assert_eq!(normalize_phone("91234567", "+46"), Some("+4691234567".into())); assert_eq!(normalize_phone("kari@vel.no", "+47"), None); assert_eq!(normalize_phone("123", "+47"), None); } #[test] fn a_person_is_their_phone_first_and_their_address_second() { let both = Identity::from_responses(&serde_json::json!({ "name": "Kari", "telefon": "912 34 567", "email": "Kari@Vel.no" })); assert_eq!(both.key().as_deref(), Some("_4791234567")); assert_eq!(both.email.as_deref(), Some("kari@vel.no")); let mail_only = Identity::from_responses(&serde_json::json!({ "epost": "Kari@Vel.no" })); assert_eq!(mail_only.key().as_deref(), Some("kari_vel_no")); assert_eq!(Identity::from_contact("mailto:Post@Vel.no").email.as_deref(), Some("post@vel.no")); assert_eq!(Identity::from_contact("912 34 567").phone.as_deref(), Some("+4791234567")); assert!(Identity::default().key().is_none()); } #[test] fn a_record_is_about_a_person_by_any_one_of_sub_phone_or_email() { let kari = Identity { sub: Some("abc".into()), phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) }; assert!(Identity { sub: None, phone: Some("+4791234567".into()), email: None }.matches(&kari)); assert!(Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) }.matches(&kari)); assert!(Identity { sub: Some("abc".into()), phone: None, email: Some("old@vel.no".into()) }.matches(&kari)); assert!(!Identity { sub: None, phone: Some("+4799999999".into()), email: Some("ola@vel.no".into()) }.matches(&kari)); assert!(!Identity::default().matches(&kari)); } #[test] fn only_active_memberships_are_groups() { let kari = Identity { sub: None, phone: Some("+4791234567".into()), email: Some("kari@vel.no".into()) }; let records = vec![ record("styret", ACTIVE, Some("+4791234567"), None, None), record("kasserer", INVITED, None, Some("kari@vel.no"), None), record("trafikk", REMOVED, Some("+4791234567"), None, None), record("medlemmer", ACTIVE, None, Some("kari@vel.no"), None), record("styret", ACTIVE, Some("+4799999999"), None, None), ]; assert_eq!(groups_of(&records, &kari), vec!["medlemmer", "styret"]); } }