Memberships: groups kept by portal itself, a person by phone number first
Test / test (pull_request) Successful in 58s

A membership is a case in the built-in portal_memberships bucket:
invited by a desk, a grants, the seed or the responsible pass; active
from the person's first sign-in, when the provider's subject, phone
number or address matches the record; removed by a desk, and invited
again after that. One record per group and person however it was
keyed, and at sign-in every record that matched learns what the
provider verified. The active memberships join the groups claim, so
Cedar sees the same parents as before.

PEOPLE_BACKEND picks Kanidm or memberships; unset, a KANIDM_API_TOKEN
means Kanidm. The NATS access service looks a person's groups up when
a question names them by sub, phone or email and lists none. The
sign-in asks for the phoneNumber scope and reads it from userinfo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 13:28:28 +02:00
co-authored by Claude Fable 5.1
parent b9761cec63
commit f047e21c4a
20 changed files with 849 additions and 64 deletions
+95
View File
@@ -153,3 +153,98 @@ async fn a_session_survives_the_process_that_made_it() {
assert!(next_boot.load(&record.id).await.expect("load after delete").is_none());
next_boot.delete(&collide.id).await.ok();
}
/// A membership is a case portal keeps itself: invited by a desk or a
/// decision, active from the person's first sign-in, removed by a desk,
/// and invited again after that. What the person belongs to is read
/// back from the same records, matched on phone number or address.
#[tokio::test]
async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back() {
use portal::memberships::{self, Identity, ACTIVE, INVITED, REMOVED};
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
return;
};
let nats = async_nats::connect(&url).await.expect("connect");
let js = async_nats::jetstream::new(nats.clone());
ensure_stream(&js).await.expect("event stream");
// A clean bucket per run.
let _ = js.delete_key_value(memberships::BUCKET).await;
let aggregates = portal::content::with_builtin_aggregates(Default::default());
let questions = std::collections::HashMap::new();
let access = portal::access::Policy::from_content(&questions, &aggregates);
let state = portal::server::AppState {
leptos_options: leptos::config::LeptosOptions::builder().output_name("test").build(),
nats,
jetstream: js,
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(questions)),
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(aggregates)),
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(Default::default())),
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
gitea_base: String::new(),
content_raw_base: String::new(),
content_branch: "main".into(),
oidc: std::sync::Arc::new(portal::server::oidc::Oidc::disabled()),
garage: None,
};
// Groups of this run's own: the event log outlives the bucket, and
// a record id seen before would replay its old history.
let run = chrono::Utc::now().timestamp_millis();
let styret = format!("styret{run}");
let medlemmer = format!("medlemmer{run}");
let (styret, medlemmer) = (styret.as_str(), medlemmer.as_str());
// A desk invites Kari by phone number; a decision grants her a
// second group by address. Both are invitations until she is seen.
let by_phone = Identity { sub: None, phone: Some("+4791234567".into()), email: None };
let by_mail = Identity { sub: None, phone: None, email: Some("kari@vel.no".into()) };
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant");
assert!(first.created && first.reset_link.is_none());
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk").await.expect("grant again");
assert!(!again.created, "a second invite into the same group finds the first");
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc").await.expect("grant by mail");
let records = memberships::all(&state).await;
assert_eq!(records.len(), 2);
assert!(records.iter().all(|r| r.state == INVITED));
assert!(memberships::groups_of(&records, &by_phone).is_empty(), "an invitation is not yet a group");
// She signs in: the provider knows her subject, phone and address.
let kari = portal::auth::User {
sub: "vipps-abc".into(),
username: "kari".into(),
display_name: "Kari".into(),
groups: vec![],
phone: Some("912 34 567".into()),
email: Some("Kari@Vel.no".into()),
};
let mut groups = memberships::on_signed_in(&state, &kari).await;
groups.sort();
assert_eq!(groups, vec![medlemmer, styret]);
let records = memberships::all(&state).await;
assert!(records.iter().all(|r| r.state == ACTIVE));
// Both records now know her by everything the provider verified.
for r in &records {
let held = memberships::identity_of(r);
assert_eq!(held.sub.as_deref(), Some("vipps-abc"));
assert_eq!(held.phone.as_deref(), Some("+4791234567"));
assert_eq!(held.email.as_deref(), Some("kari@vel.no"));
}
// A desk takes her out of styret; the record stays, as removed.
assert!(memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke"));
assert!(!memberships::revoke(&state, styret, &by_phone, "ola").await.expect("revoke twice"), "already out");
let records = memberships::all(&state).await;
let styret_record = records.iter().find(|r| memberships::group_of(r) == styret).unwrap();
assert_eq!(styret_record.state, REMOVED);
assert_eq!(memberships::groups_of(&records, &Identity { sub: Some("vipps-abc".into()), phone: None, email: None }), vec![medlemmer]);
// Invited again: the same record, invited, and hers at the next sign-in.
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk").await.expect("re-grant");
assert!(back.created);
let records = memberships::all(&state).await;
assert_eq!(records.iter().find(|r| memberships::group_of(r) == styret).unwrap().state, INVITED);
let groups = memberships::on_signed_in(&state, &kari).await;
assert!(groups.contains(&styret.to_string()));
}