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
+32 -6
View File
@@ -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<String> },
/// 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
phone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
email: Option<String>,
},
/// 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<Entity> = Vec::new();
let principal_uid = match principal {
Principal::User { sub, groups } => {
Principal::User { sub, groups, .. } => {
let parents: HashSet<EntityUid> = 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<arc_swap::ArcSwap<Policy>>,
state: Option<crate::server::AppState>,
) {
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::<Query>(&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]