diff --git a/CHANGELOG.md b/CHANGELOG.md index aa45082..ad4f751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## Unreleased + +- Memberships: groups kept by portal itself (`src/memberships.rs`, `docs/memberships.md`). 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; `removed` by a desk. A person is their phone number first (`+4791234567`, `PHONE_COUNTRY` for bare numbers), their address second, the provider's subject third, and one record per group and person however it was keyed. At sign-in the active memberships join the `groups` claim, so Cedar sees the same parents as before. `PEOPLE_BACKEND=memberships` switches a site; unset, a `KANIDM_API_TOKEN` means Kanidm and no token means memberships. `SEED_ADMIN_PHONE` seeds by number. The sign-in asks for the `phoneNumber` scope and reads `phone_number` from userinfo. +- The NATS access service answers for a person named by `sub`, `phone` or `email` with no groups listed: their memberships are looked up first. +- `who_for` (the person a `grants` is about) takes `navn` as well as `name`, and a phone number in place of an address. +- The invite mail for someone who needs no link says where to sign in. + ## 0.5.6 (2026-09-30) - A group may hold a page's responsibility: `responsible: { name, contact, group: styret }`. No account or mail for the inbox, and any signed-in member of the group gets the suggest-a-change card. Before this, a board's shared address was onboarded as a person. diff --git a/docs/README.md b/docs/README.md index e88965d..46fef68 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ engine does with it. | [Content reference](content-reference.md) | Every key a content repo may write, and what it does. | | [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. | | [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. | +| [Memberships](memberships.md) | Groups kept by portal itself: a membership as a case, a person as a phone number first, and the provider only saying who. | | [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. | | [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. | diff --git a/docs/access.md b/docs/access.md index c71ca41..ab00b1d 100644 --- a/docs/access.md +++ b/docs/access.md @@ -4,6 +4,14 @@ Who may do what to what is compiled from content on every load into one Cedar policy set, and every server entry point asks that one policy. Identity stays Kanidm's; portal only decides. +## Where the groups come from + +The policy names groups; it does not hold them. A `User` entity's +parents are the person's groups, and those come from the sign-in: the +`groups` claim the provider sent, joined with the person's active +[memberships](memberships.md) when portal keeps them itself. Cedar +sees one list either way. + ## What content compiles to | content | rule | @@ -42,6 +50,11 @@ Other applications ask the same question over NATS request-reply on and context the server uses internally — so an automation acting as a group gets exactly the answer a person in that group would. +A principal may also name a person by `sub`, `phone` or `email` and +list no groups; portal then looks their [memberships](memberships.md) +up before deciding. That is what makes the policy usable for someone +who has never opened the site. + ## The submitter A person who sent a form has no account. Their credential is the link: diff --git a/docs/getting-started.md b/docs/getting-started.md index 39737bf..0ae0aae 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,7 +16,11 @@ reads that and does the rest at start. ([Operations](operations.md) has adding a site). - A Kanidm of the site's own. Group names below are exactly the ones the content uses, with no prefix; that is only safe when the Kanidm - serves this site alone. + serves this site alone. (Or no Kanidm at all: with + `PEOPLE_BACKEND=memberships` portal keeps the groups itself and any + OIDC provider that can say who someone is will do; see + [Memberships](memberships.md). The rest of this page is the Kanidm + way.) - Mail: `mail: { from }` in `site.yaml`, and gdo on the host. Without it accounts are still made, but nobody hears about them. diff --git a/docs/memberships.md b/docs/memberships.md new file mode 100644 index 0000000..dace816 --- /dev/null +++ b/docs/memberships.md @@ -0,0 +1,106 @@ +# Memberships + +Who belongs to which group, kept by portal itself. + +A site's groups are named in its content: `qualifies` on a page, +`requires_group` on a list, `invite: { group }` on a desk, `grants` on +a state. Until now the groups themselves lived in Kanidm, and portal +read them from the sign-in token. With memberships they live in portal, +as records in the built-in `portal_memberships` bucket, and the +identity provider only has to say who someone is. Cedar sees no +difference: a person's active memberships are the parents of their +`User` entity, exactly as a `groups` claim was. + +## A membership is a case + +One record per group and person: + +| state | means | how it gets there | +|---|---|---| +| `invited` | a place is held for them | a desk invite, a state that `grants`, the first person, someone the content names as responsible | +| `active` | they are in | they signed in, and the provider's subject, phone number or address matched the record | +| `removed` | a desk took them out | a button on the desk that lists the bucket; inviting them again reopens the record | + +The record carries `name`, `group`, `by` (who or what put them there: +a username, `seed`, `responsible`, or the case as `bucket:id`), and +what is known of the person: `phone`, `email`, `sub`. At sign-in +everything the provider verified is written onto every record that +matched, so a record made from an address learns the phone number, +and the next sign-in matches on the subject before anything else. + +Because it is a case, a desk lists it like any other bucket: + +```yaml + resource: + source: { kind: kv, bucket: portal_memberships } + requires_group: styret + transitions: + - { from: invited, to: removed, label: Withdraw } + - { from: active, to: removed, label: Take out } + - { from: removed, to: invited, label: Invite again } +``` + +## A person is their phone number first + +A provider that verifies phone numbers (Vipps) makes the number the +surest thing a record can hold. So a person is known by, in order: +their phone number, their email address, the provider's subject. A +form that collects `phone`, `telefon`, `tel` or `mobil` gives the +first; `email` or `epost` the second. Numbers are kept as `+4791234567` +whatever way they were typed; a bare national number gets the site's +country (`PHONE_COUNTRY`, `+47` unless set). Addresses are lowercased. + +A record matches a person on any one of the three, so a desk that +invited by address and a decision that granted by number are talking +about one person, and there is one record. + +## Switching a site to memberships + +``` +PEOPLE_BACKEND=memberships +``` + +Unset, a `KANIDM_API_TOKEN` means Kanidm and no token means +memberships. With memberships: + +- A desk invite writes an `invited` record and mails "log in at the + site, and your place is there". No account is made: the provider + makes people, portal makes members. So the provider has to be one + everyone can already sign in to, which is what Vipps Login is for a + Norwegian association, and what Kanidm is not. +- `grants` writes a record for the person the case is about, from the + form's name and phone number or address. +- `SEED_ADMIN_PHONE` or `SEED_ADMIN_EMAIL` writes the first person's. +- The people the content names as responsible get theirs in + `RESPONSIBLE_GROUP`, and lose it when the content stops naming them. +- The `groups` claim, if the provider sends one, still counts: a + person's groups are the union. + +The sign-in flow asks for the `phoneNumber` scope and reads +`phone_number` from userinfo; a provider without either gives nothing, +and matching falls back to the address. + +## Asking who belongs + +Other applications ask portal the same access question they always +could, over NATS on `portal.access..may`. A principal that names +a person and lists no groups is looked up: + +```json +{ "principal": { "kind": "user", "sub": "vipps:abc", "phone": "912 34 567" }, + "action": "transition", + "resource": { "kind": "bucket", "name": "hazards" }, + "context": { "from": "open", "to": "fixed" } } +``` + +The answer is Cedar's, with the rule ids that granted it, for a person +who may never have opened the site. That is the whole authorization +engine: the provider says who, memberships say what they belong to, +the policy compiled from content says what they may do. + +## What Kanidm still does + +For a site on Kanidm nothing changes: invites make accounts and add to +Kanidm groups, and `portal_invites` tracks the one-time links. The two +backends can be swapped by the one variable; the content and the +policy are the same either way. diff --git a/docs/operations.md b/docs/operations.md index 67888cf..dd7b815 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -69,8 +69,10 @@ values. | `NATS_URL` | events, projections, sessions, content reload, access questions | | `CONTENT_REPO`, `CONTENT_BRANCH` | the repo that is the site | | `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET` | sign-in | -| `KANIDM_API_TOKEN` | invites and `grants:`; unset, neither happens | -| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) | +| `PEOPLE_BACKEND` | `kanidm` or `memberships` (see [Memberships](memberships.md)); unset, Kanidm when `KANIDM_API_TOKEN` is set, memberships otherwise | +| `KANIDM_API_TOKEN` | invites and `grants:` through Kanidm | +| `PHONE_COUNTRY` | the calling code a bare phone number gets, `+47` unless set | +| `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PHONE`, `SEED_ADMIN_NAME`, `SEED_GROUP` | the first person in, invited once at start into the most privileged group (or `SEED_GROUP`) | | `RESPONSIBLE_GROUP` | the group every person a page names as `responsible` joins, at start and whenever the content changes, and leaves when no page names them any more; they get an account either way | | `PUBLIC_URL`, `SITE_NAME` | what the site calls itself in links and mail | | `GITEA_API_TOKEN` | authenticated resource pulls | diff --git a/docs/state-machines.md b/docs/state-machines.md index 46dbcc5..68c3ff3 100644 --- a/docs/state-machines.md +++ b/docs/state-machines.md @@ -87,9 +87,12 @@ also need somebody to remember to invite them afterwards. The lint requires every form filling that bucket to collect a required `name` and a required `email`, because by the time a desk presses -approve there is nobody left to ask. An instance with no Kanidm -configured grants nothing and says so in the log; the transition -itself always stands. +approve there is nobody left to ask. With memberships as the people +backend a phone number does as well as an address, and the grant is a +record in `portal_memberships` rather than a Kanidm call (see +[Memberships](memberships.md)). An instance with neither configured +grants nothing and says so in the log; the transition itself always +stands. ### after diff --git a/src/access.rs b/src/access.rs index c03bca8..0f912fb 100644 --- a/src/access.rs +++ b/src/access.rs @@ -51,7 +51,17 @@ action invite appliesTo { principal: [User], resource: [Group] }; #[serde(tag = "kind", rename_all = "snake_case")] pub enum Principal { /// A signed-in person, or nobody (`groups` empty, `sub` "anonymous"). - User { sub: String, groups: Vec }, + /// Over NATS, a question may name the person by phone number or + /// address instead of listing their groups, and portal looks their + /// memberships up (see `serve`). + User { + sub: String, + groups: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + phone: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + email: Option, + }, /// Whoever holds one record's own link and knows the email on it. Submitter { record: String, email: String }, /// A machine with a shared token. @@ -60,12 +70,12 @@ pub enum Principal { impl Principal { pub fn anonymous() -> Self { - Principal::User { sub: "anonymous".into(), groups: Vec::new() } + Principal::User { sub: "anonymous".into(), groups: Vec::new(), phone: None, email: None } } pub fn from_user(user: Option<&crate::auth::User>) -> Self { match user { - Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone() }, + Some(u) => Principal::User { sub: u.sub.clone(), groups: u.groups.clone(), phone: u.phone.clone(), email: u.email.clone() }, None => Principal::anonymous(), } } @@ -311,7 +321,7 @@ impl Policy { }; let mut entities: Vec = Vec::new(); let principal_uid = match principal { - Principal::User { sub, groups } => { + Principal::User { sub, groups, .. } => { let parents: HashSet = groups.iter().map(|g| uid("Group", g)).collect(); for g in &parents { entities.push(Entity::new_no_attrs(g.clone(), HashSet::new())); @@ -444,6 +454,7 @@ pub async fn serve( nats: async_nats::Client, site: String, access: std::sync::Arc>, + state: Option, ) { use futures::StreamExt; let subject = subject_for(&site); @@ -458,7 +469,22 @@ pub async fn serve( while let Some(msg) = sub.next().await { let Some(reply) = msg.reply else { continue }; let decision = match serde_json::from_slice::(&msg.payload) { - Ok(q) => decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context), + Ok(mut q) => { + // A question that names a person and lists no groups is + // asking portal what they belong to: their memberships, + // matched on subject, phone number or address. + if let (Principal::User { sub, groups, phone, email }, Some(state)) = (&mut q.principal, &state) { + if groups.is_empty() && sub != "anonymous" { + let who = crate::memberships::Identity { + sub: Some(sub.clone()), + 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.trim().to_lowercase()), + }; + *groups = crate::memberships::groups_of(&crate::memberships::all(state).await, &who); + } + } + decide_and_publish(&nats, &access.load(), &q.principal, q.action, &q.resource, q.context) + } Err(e) => Decision { allow: false, reasons: vec![format!("malformed question: {e}")] }, }; let bytes = serde_json::to_vec(&decision).unwrap_or_else(|_| b"{\"allow\":false,\"reasons\":[]}".to_vec()); @@ -487,7 +513,7 @@ mod tests { } fn user(groups: &[&str]) -> Principal { - Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect() } + Principal::User { sub: "u".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: None } } #[test] diff --git a/src/app.rs b/src/app.rs index 49057d7..664675f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2652,6 +2652,16 @@ pub async fn my_suggestions(question_id: String) -> Result, Se Ok(mine) } +/// Who a principal is, for a record: the username, or the service. +#[cfg(feature = "ssr")] +fn principal_name(principal: &crate::access::Principal) -> String { + match principal { + crate::access::Principal::User { sub, .. } => sub.clone(), + crate::access::Principal::Submitter { email, .. } => email.clone(), + crate::access::Principal::Service { name } => name.clone(), + } +} + #[server(endpoint = "submit_answer")] pub async fn submit_answer( question_id: String, @@ -2743,21 +2753,22 @@ pub async fn submit_answer( if !may.allow { return Err(ServerFnError::new("not authorized to invite into this group")); } - let kanidm = crate::people::Kanidm::from_env() - .ok_or_else(|| ServerFnError::new("invites are not configured on this instance (KANIDM_API_TOKEN)"))?; - let field = |name: &str| responses.get(name).and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()).map(String::from); - let (name, email) = match (field("name"), field("email")) { - (Some(n), Some(e)) => (n, e), - _ => return Err(ServerFnError::new("an invite needs a name and an email")), + let people = crate::people::People::from_env().ok_or_else(|| ServerFnError::new(crate::people::People::NOT_CONFIGURED))?; + let Some((name, who)) = crate::people::who_for(&responses) else { + return Err(ServerFnError::new("an invite needs a name, and a phone number or an email")); }; - let invited = kanidm - .invite(&name, &email, &invite.group) + let invited = people + .invite(&state, &name, &who, &invite.group, &principal_name(&principal)) .await .map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?; if let Some(map) = responses.as_object_mut() { map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default()); } - record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string()); + // Through Kanidm the invite is its own case, with a link that + // runs out; a membership is the case already. + if people.is_kanidm() { + record_as.get_or_insert_with(|| crate::people::INVITES_BUCKET.to_string()); + } if let Ok(bytes) = serde_json::to_vec(&invited) { let _ = state.nats.publish(crate::people::INVITED_SUBJECT, bytes.into()).await; } @@ -2908,7 +2919,7 @@ mod responsible_tests { use crate::content::Responsible; fn user(email: Option<&str>, groups: &[&str]) -> User { - User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: groups.iter().map(|g| g.to_string()).collect(), email: email.map(String::from) } + User { sub: "s".into(), username: "kari".into(), display_name: "Kari".into(), groups: groups.iter().map(|g| g.to_string()).collect(), phone: None, email: email.map(String::from) } } fn named(contact: &str, group: Option<&str>) -> Responsible { diff --git a/src/auth.rs b/src/auth.rs index aabbbc0..d0005fd 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -14,6 +14,10 @@ pub struct User { /// live, so a group change only takes effect on the next login. #[serde(default)] pub groups: Vec, + /// The phone number the provider verified (`phone_number`, from + /// userinfo), when it gives one: what a membership is keyed by first. + #[serde(default)] + pub phone: Option, /// 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)] diff --git a/src/content/mod.rs b/src/content/mod.rs index 4f4be98..297d65d 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -1865,7 +1865,7 @@ alternatives: fn a_signed_in_person_is_not_asked_who_they_are() { let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n"; let q: Question = serde_yaml::from_str(yaml).unwrap(); - let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], email: Some("kari@vel.no".into()) }; + let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], phone: None, email: Some("kari@vel.no".into()) }; let mut signed_in = q.clone(); fill_from_user(&mut signed_in, Some(&user)); let reqs = &signed_in.alternatives[0].features[0].requirements; diff --git a/src/content/validate.rs b/src/content/validate.rs index ba944d2..3775e5f 100644 --- a/src/content/validate.rs +++ b/src/content/validate.rs @@ -64,6 +64,17 @@ aggregates: transitions: open: [done, expired] expired: [open, done] + - bucket: portal_memberships + attended_by: the desks that list who is in a group; portal itself moves invited to active at sign-in + initial: invited + states: + invited: { event: invited } + active: { event: active } + removed: { event: removed } + transitions: + invited: [active, removed] + active: [removed] + removed: [invited, active] - bucket: portal_edit_suggestions attended_by: whoever keeps the content; every suggestion is mailed to the site's inbox too initial: open diff --git a/src/lib.rs b/src/lib.rs index 90aedbc..64afb9f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,8 @@ pub mod mail; #[cfg(feature = "ssr")] pub mod seed; +#[cfg(feature = "ssr")] +pub mod memberships; pub mod maps; diff --git a/src/mail.rs b/src/mail.rs index c6c012b..ddeebfa 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -565,7 +565,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people: ), (None, false, true) => ( "Du har fått plass i {group} på {site_name}".to_string(), - "Hei {name},\n\nKontoen din på {site} har nå plass i {group}. Logg inn som vanlig.".to_string(), + "Hei {name},\n\nDu har fått plass i {group} på {site_name}. Logg inn på {site}, så er plassen din der.".to_string(), ), (None, true, false) => ( "You are invited into {group} at {site_name}".to_string(), @@ -573,7 +573,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people: ), (None, false, false) => ( "You now have a place in {group} at {site_name}".to_string(), - "Hello {name},\n\nYour account at {site} now has a place in {group}. Sign in as usual.".to_string(), + "Hello {name},\n\nYou now have a place in {group} at {site_name}. Sign in at {site}, and it is there.".to_string(), ), }; let now_ms = chrono::Utc::now().timestamp_millis(); diff --git a/src/main.rs b/src/main.rs index 2359701..0126df6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,9 +86,6 @@ async fn main() -> anyhow::Result<()> { site.clone(), access.clone(), )); - // The same decisions, answerable by other apps over NATS. - tokio::spawn(portal::access::serve(nats.clone(), site_name_for_subjects(), access.clone())); - let state = AppState { leptos_options: leptos_options.clone(), nats, @@ -104,6 +101,9 @@ async fn main() -> anyhow::Result<()> { garage, }; + // The same decisions, answerable by other apps over NATS, for a + // person named by phone number or address as well as by groups. + tokio::spawn(portal::access::serve(state.nats.clone(), site_name_for_subjects(), state.access.clone(), Some(state.clone()))); // Announced pages: keep their desk records current, close ended // windows (see announce.rs). tokio::spawn(portal::announce::run(state.clone())); diff --git a/src/memberships.rs b/src/memberships.rs new file mode 100644 index 0000000..6892db1 --- /dev/null +++ b/src/memberships.rs @@ -0,0 +1,399 @@ +//! 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, + }; + 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, + 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).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, + 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"]); + } +} diff --git a/src/people.rs b/src/people.rs index 1696555..e7dfda7 100644 --- a/src/people.rs +++ b/src/people.rs @@ -23,6 +23,61 @@ use serde::{Deserialize, Serialize}; pub const INVITES_BUCKET: &str = "portal_invites"; + +/// Where people are kept. Kanidm makes accounts and holds groups; with +/// memberships (`memberships.rs`) portal holds the groups itself and +/// the identity provider only says who someone is. `PEOPLE_BACKEND` +/// picks; unset, a `KANIDM_API_TOKEN` means Kanidm and its absence +/// means memberships, so a site with no Kanidm token can still invite. +pub enum People { + Kanidm(Kanidm), + Memberships, +} + +impl People { + /// `None` only when Kanidm is asked for and not configured. + pub fn from_env() -> Option { + match std::env::var("PEOPLE_BACKEND").ok().map(|b| b.trim().to_lowercase()).as_deref() { + Some("memberships") => Some(People::Memberships), + Some("kanidm") => Kanidm::from_env().map(People::Kanidm), + Some(other) => { + tracing::warn!(backend = other, "PEOPLE_BACKEND is neither kanidm nor memberships; using memberships"); + Some(People::Memberships) + } + None => Some(Kanidm::from_env().map(People::Kanidm).unwrap_or(People::Memberships)), + } + } + + /// What is missing when `from_env` says no. + pub const NOT_CONFIGURED: &'static str = "invites are not configured on this instance (PEOPLE_BACKEND=kanidm needs KANIDM_URL and KANIDM_API_TOKEN)"; + + /// Puts a person in a group, making them known if they were not: + /// through Kanidm an account and a one-time link, through + /// memberships a record that becomes theirs when they sign in. + /// `by` is who asked, for the record. + pub async fn invite(&self, state: &crate::server::AppState, name: &str, who: &crate::memberships::Identity, group: &str, by: &str) -> anyhow::Result { + match self { + People::Kanidm(kanidm) => { + let email = who.email.as_deref().ok_or_else(|| anyhow::anyhow!("Kanidm knows people by their address, and this one has none"))?; + kanidm.invite(name, email, group).await + } + People::Memberships => crate::memberships::grant(state, group, name, who, by).await, + } + } + + /// Takes a person out of a group. Through Kanidm only what portal + /// put there (see `Invited.already_member`); memberships remember. + pub async fn remove(&self, state: &crate::server::AppState, who: &crate::memberships::Identity, username: &str, group: &str, by: &str) -> anyhow::Result<()> { + match self { + People::Kanidm(kanidm) => kanidm.remove_member(group, username).await, + People::Memberships => crate::memberships::revoke(state, group, who, by).await.map(|_| ()), + } + } + + pub fn is_kanidm(&self) -> bool { + matches!(self, People::Kanidm(_)) + } +} pub const INVITED_SUBJECT: &str = "portal.people.invited"; /// The states an invite moves between, besides the schema's initial /// `open` (see `BUILTIN_AGGREGATES_YAML`). @@ -515,21 +570,21 @@ pub fn username_for(email: &str) -> String { } } -/// The person a record is about, for a state that grants a group. -/// Both are required by the lint of any form that fills such a bucket -/// (see `validate_questions`), so a record missing either is one that -/// predates the rule, and is reported rather than guessed at. -pub fn who_for(responses: &serde_json::Value) -> Option<(String, String)> { - let field = |name: &str| { - responses - .get(name) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(String::from) - }; - let email = field("email").filter(|e| e.contains('@'))?; - Some((field("name")?, email)) +/// The person a record is about, for a state that grants a group: their +/// name, and a phone number or an address to know them by. Required by +/// the lint of any form that fills such a bucket (see +/// `validate_questions`), so a record missing them is one that predates +/// the rule, and is reported rather than guessed at. +pub fn who_for(responses: &serde_json::Value) -> Option<(String, crate::memberships::Identity)> { + let name = responses + .get("name") + .or_else(|| responses.get("navn")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from)?; + let who = crate::memberships::Identity::from_responses(responses); + who.key().is_some().then_some((name, who)) } /// A record entered `state`: if that state grants a group, put the @@ -555,15 +610,15 @@ pub async fn on_state( else { return; }; - let Some(kanidm) = Kanidm::from_env() else { - tracing::warn!(bucket, id, to_state, group, "this state grants a group, but invites are not configured on this instance (KANIDM_API_TOKEN) - nobody was added"); + let Some(people) = People::from_env() else { + tracing::warn!(bucket, id, to_state, group, "this state grants a group, but {} - nobody was added", People::NOT_CONFIGURED); return; }; - let Some((name, email)) = who_for(responses) else { - tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and email - nobody was added"); + let Some((name, who)) = who_for(responses) else { + tracing::warn!(bucket, id, to_state, group, "this state grants a group, but the record carries no name and no phone number or email - nobody was added"); return; }; - let invited = match kanidm.invite(&name, &email, &group).await { + let invited = match people.invite(state, &name, &who, &group, &format!("{bucket}:{id}")).await { Ok(invited) => invited, Err(e) => { tracing::error!(bucket, id, to_state, group, "granting the group failed: {e}"); @@ -662,6 +717,7 @@ mod tests { username: username.into(), display_name: String::new(), groups: vec![], + phone: None, email: email.map(String::from), }; assert!(is_for(&invited, &user("kari", None))); diff --git a/src/seed.rs b/src/seed.rs index c13ad86..275a437 100644 --- a/src/seed.rs +++ b/src/seed.rs @@ -208,9 +208,9 @@ async fn responsible(state: &crate::server::AppState) -> usize { if people.is_empty() && group.is_none() { return 0; } - let Some(kanidm) = crate::people::Kanidm::from_env() else { + 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 invites are not configured (KANIDM_API_TOKEN) - no accounts made"); + tracing::info!(people = people.len(), "the content names responsible people, but {} - no accounts made", crate::people::People::NOT_CONFIGURED); } return 0; }; @@ -222,13 +222,15 @@ async fn responsible(state: &crate::server::AppState) -> usize { 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. 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 { + // 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; @@ -236,6 +238,7 @@ async fn responsible(state: &crate::server::AppState) -> usize { Ok(false) => kanidm.account(&name, email).await, Err(e) => Err(e), }, + (None, crate::people::People::Memberships) => continue, }; match made { Ok(invited) => { @@ -277,7 +280,8 @@ async fn responsible(state: &crate::server::AppState) -> usize { } for (key, invited) in no_longer_responsible(&on_file, &people, seed_admin_email().as_deref()) { if !invited.already_member { - if let Err(e) = kanidm.remove_member(&group, &invited.username).await { + 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; @@ -290,11 +294,23 @@ async fn responsible(state: &crate::server::AppState) -> usize { left } -/// The first person in, once, if `SEED_ADMIN_EMAIL` asks for one. +/// 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 Some(email) = std::env::var("SEED_ADMIN_EMAIL").ok().map(|e| e.trim().to_string()).filter(|e| e.contains('@')) else { + 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()) @@ -316,15 +332,15 @@ async fn seed_admin(state: &crate::server::AppState) { 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"); + 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 kanidm.invite(&name, &email, &group).await { + 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}"); diff --git a/src/server/oidc.rs b/src/server/oidc.rs index 1b2aed3..27e04c0 100644 --- a/src/server/oidc.rs +++ b/src/server/oidc.rs @@ -11,10 +11,10 @@ use axum::{ response::Redirect, }; use openidconnect::{ - core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata}, + core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims}, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, - TokenResponse, + OAuth2TokenResponse, TokenResponse, }; use serde::Deserialize; use tower_sessions::Session; @@ -50,6 +50,12 @@ const NONCE_KEY: &str = "oidc_nonce"; const REDIRECT_KEY: &str = "oidc_post_login_redirect"; impl Oidc { + /// Sign-in disabled: what a content-only instance has, and what a + /// test builds an `AppState` with. + pub fn disabled() -> Self { + Self { inner: None } + } + /// Discovers the provider and builds the client from environment: /// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`. /// With `KANIDM_URL` unset, sign-in is disabled instead of fatal - @@ -153,6 +159,9 @@ pub async fn login( .add_scope(Scope::new("openid".to_string())) .add_scope(Scope::new("profile".to_string())) .add_scope(Scope::new("email".to_string())) + // A provider that verifies phone numbers (Vipps) gives one for + // this scope; Kanidm ignores a scope it does not know. + .add_scope(Scope::new("phoneNumber".to_string())) .set_pkce_challenge(pkce_challenge) .url(); @@ -242,13 +251,33 @@ pub async fn callback( let groups = extract_groups_claim(&id_token); let email = claims.email().map(|e| e.as_str().trim().to_lowercase()); - let user = User { + // The verified phone number lives at userinfo, not in the ID token, + // for providers that give one (Vipps). A provider that has none, or + // no userinfo endpoint, leaves it empty; nothing depends on it. + let phone = match oidc.client.user_info(token_response.access_token().clone(), None) { + Ok(request) => request + .request_async(&oidc.http) + .await + .ok() + .and_then(|info: CoreUserInfoClaims| info.phone_number().map(|p| p.as_str().to_string())), + Err(_) => None, + }; + let mut user = User { sub: claims.subject().as_str().to_string(), username, display_name, groups, + phone, email, }; + // What they belong to, as portal keeps it: every invitation for + // them becomes a membership now, and their active memberships join + // whatever groups the provider itself asserted. + for group in crate::memberships::on_signed_in(&state, &user).await { + if !user.groups.contains(&group) { + user.groups.push(group); + } + } // Rotate the session id on privilege change, then store the user. session.cycle_id().await.map_err(internal)?; diff --git a/tests/jetstream.rs b/tests/jetstream.rs index e7204ac..682a8ad 100644 --- a/tests/jetstream.rs +++ b/tests/jetstream.rs @@ -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())); +}