Files
portal/src/memberships.rs
T
Bendik LynghaugandClaude Opus 5 44c7ee6870
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 2m9s
Publish release / publish (push) Successful in 1m52s
Render an Apple configuration profile from the server's PACC document
iOS is the one client none of the mail autoconfiguration reaches: Apple
Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so
adding an account means the device probing for ports - and a probe that
meets a filtered port waits out a TCP timeout and reports the account as
unverifiable, which the person reads as a wrong password.

A profile states the hosts and the ports instead, and carries mail,
calendars and contacts in one install. It invents none of them: site.yaml
names a PACC document - the standard defined to be fetched from the mail
domain itself - and every host, port and collection path comes from
there, so the server is described in one place and the profile follows it.

The document holds no address, username or password. iOS asks for those
at install, which means one profile serves everyone with an account and
it can be fetched by anyone: it says no more than PACC already says in
public. Identifiers are derived from the site and the payload rather than
drawn at random, because iOS replaces a profile whose ids it knows and
stacks a duplicate when they differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
2026-10-07 11:18:46 +02:00

578 lines
19 KiB
Rust

//! 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
/// 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<String> {
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<String> {
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<String> {
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<String>, b: &Option<String>| 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<String> {
let mut groups: Vec<String> = 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<async_nats::jetstream::kv::Store> {
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<Answer> {
let Ok(store) = state.jetstream.get_key_value(BUCKET).await else {
return Vec::new();
};
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 out = Vec::new();
for key in keys {
if let Ok(Some(bytes)) = store.get(&key).await {
if let Ok(answer) = serde_json::from_slice::<Answer>(&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<Answer> {
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<crate::people::Invited> {
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<Answer> = 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<bool> {
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::<Answer>(&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<String> {
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(),
finished: false,
}
}
#[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"]);
}
}