From a765108a9356d0b48b44edae32518e78a6bedfbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 08:09:58 +0000 Subject: [PATCH] ogar-az: Graph groups and their direct members A Graph group becomes one record of kind AzKind::Group (GROUP_SCHEMA_V1: naming, groupTypes, mailEnabled / securityEnabled as read, the cloud SID and the onPremises* evidence). Membership is read per group from /groups/{id}/members, direct members only so nesting stays the consumer's walk; users and groups are kept by @odata.type, other member types are counted and dropped, and a member without a type is refused. User and group records lay out their slots differently, so attr_str / attr_multi now return None for anything but a user record, and the group readers (group_str / group_multi / group_flag) for anything but a group. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-az/src/group.rs | 336 ++++++++++++++++++++++++++++++++++ crates/ogar-az/src/lib.rs | 18 +- crates/ogar-az/tests/group.rs | 139 ++++++++++++++ 3 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 crates/ogar-az/src/group.rs create mode 100644 crates/ogar-az/tests/group.rs diff --git a/crates/ogar-az/src/group.rs b/crates/ogar-az/src/group.rs new file mode 100644 index 0000000..255fd61 --- /dev/null +++ b/crates/ogar-az/src/group.rs @@ -0,0 +1,336 @@ +//! Entra groups and their direct memberships as Microsoft Graph shows them. +//! +//! A Graph `group` becomes one [`DirRecord`] of kind [`AzKind::Group`]: +//! +//! * WHO = the Graph `id` (the Entra object id); +//! * WHERE = the tenant id. Graph reports no on-premises distinguished name +//! for a group, so a group carries no OU, synchronized or not; +//! * WHAT = [`GROUP_SCHEMA_V1`], raw. +//! +//! Membership is a separate read per group (`GET /groups/{id}/members`), +//! which lists the **direct** members only; nesting is the consumer's walk +//! (`/transitiveMembers` would hand over a flattened answer and lose the +//! chain a right is inherited through). [`ingest_members_page`] keeps the +//! members that are users or groups and counts the rest (devices, service +//! principals, organizational contacts), which a directory of people does +//! not hold. +//! +//! `mailEnabled` and `securityEnabled` are kept as read; an absent flag is +//! unknown, never `false`. Read-only: every call is a `GET`, and nothing here +//! performs HTTP. + +use crate::{AzError, AzKind}; +use ogar_dir_core::{AttrDef, AttrKind, DirRecord, Guid128, SchemaFamily, SchemaId, ValuePool}; +use serde_json::Value; + +/// Encoder schema version of [`GROUP_SCHEMA_V1`]. +pub const GROUP_SCHEMA_VERSION: u16 = 1; +/// The group record's schema id. It shares the Graph family with the +/// `user` records; [`AzKind::Group`] tells the tables apart. +pub const GROUP_SCHEMA: SchemaId = SchemaId { + family: SchemaFamily::MsGraph, + version: GROUP_SCHEMA_VERSION, +}; + +/// The Graph `group` attribute table, v1: naming (`displayName`, `mail`, +/// `mailNickname`, `proxyAddresses`), kind (`groupTypes`, `mailEnabled`, +/// `securityEnabled`), the cloud SID, and the `onPremises*` evidence about an +/// AD counterpart. +pub const GROUP_SCHEMA_V1: &[AttrDef] = &[ + AttrDef { + name: "displayName", + slot: 0, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "description", + slot: 1, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "mail", + slot: 2, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "mailNickname", + slot: 3, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "proxyAddresses", + slot: 4, + kind: AttrKind::MultiStr, + since: 1, + }, + AttrDef { + name: "groupTypes", + slot: 5, + kind: AttrKind::MultiStr, + since: 1, + }, + AttrDef { + name: "securityIdentifier", + slot: 6, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "onPremisesSamAccountName", + slot: 7, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "onPremisesSecurityIdentifier", + slot: 8, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "onPremisesDomainName", + slot: 9, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "onPremisesLastSyncDateTime", + slot: 10, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "mailEnabled", + slot: 0, + kind: AttrKind::Bool, + since: 1, + }, + AttrDef { + name: "securityEnabled", + slot: 1, + kind: AttrKind::Bool, + since: 1, + }, + AttrDef { + name: "onPremisesSyncEnabled", + slot: 2, + kind: AttrKind::Bool, + since: 1, + }, +]; + +/// `$select` value for groups: `id` plus every attribute in table order. +pub fn group_select_query() -> String { + std::iter::once("id") + .chain(GROUP_SCHEMA_V1.iter().map(|a| a.name)) + .collect::>() + .join(",") +} + +/// The read-only list URL for groups (v1.0 endpoint). +pub fn groups_url(page_size: u16) -> String { + format!( + "https://graph.microsoft.com/v1.0/groups?$select={}&$top={}", + group_select_query(), + page_size.clamp(1, 999) + ) +} + +/// The read-only URL for one group's direct members. Only `id` is selected; +/// `@odata.type` is always returned and tells users from groups. +pub fn members_url(group: &Guid128, page_size: u16) -> String { + format!( + "https://graph.microsoft.com/v1.0/groups/{group}/members?$select=id&$top={}", + page_size.clamp(1, 999) + ) +} + +/// Encode one Graph group object. +pub fn encode_group( + obj: &serde_json::Map, + index: usize, + tenant: Guid128, + pool: &mut ValuePool, + observed_at_ms: i64, +) -> Result { + let id = obj + .get("id") + .and_then(Value::as_str) + .and_then(|s| Guid128::parse(s).ok()) + .ok_or(AzError::BadId(index))?; + let mut rec = DirRecord::new( + GROUP_SCHEMA, + AzKind::Group as u16, + id, + tenant, + observed_at_ms, + ); + let st = |e: &dyn std::fmt::Debug| AzError::Storage(format!("{e:?}")); + for def in GROUP_SCHEMA_V1 { + let Some(v) = obj.get(def.name).filter(|v| !v.is_null()) else { + continue; + }; + let bad = || AzError::BadType(index, def.name); + match def.kind { + AttrKind::Str => { + let r = pool + .push(v.as_str().ok_or_else(bad)?.as_bytes()) + .map_err(|e| st(&e))?; + rec.set_str(def.slot as usize, r).map_err(|e| st(&e))?; + } + AttrKind::MultiStr => { + let vals: Vec<&str> = v + .as_array() + .ok_or_else(bad)? + .iter() + .map(|x| x.as_str().ok_or_else(bad)) + .collect::>()?; + let r = pool.push_multi(&vals).map_err(|e| st(&e))?; + rec.set_str(def.slot as usize, r).map_err(|e| st(&e))?; + } + AttrKind::Bool => { + let b = v.as_bool().ok_or_else(bad)?; + rec.set_num(def.slot as usize, u32::from(b)) + .map_err(|e| st(&e))?; + } + // The table holds no other kind. + AttrKind::Bytes | AttrKind::Bag | AttrKind::Guid | AttrKind::U32 => { + return Err(bad()); + } + } + } + Ok(rec) +} + +/// Ingest one Graph group list-page body (`GET /groups?$select=…`). +pub fn ingest_groups_page( + body: &str, + tenant: Guid128, + pool: &mut ValuePool, + observed_at_ms: i64, +) -> Result { + let v: Value = serde_json::from_str(body).map_err(|_| AzError::NotAPage)?; + let items = v + .get("value") + .and_then(Value::as_array) + .ok_or(AzError::NotAPage)?; + let mut records = Vec::with_capacity(items.len()); + let mut ignored: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + let obj = item.as_object().ok_or(AzError::BadId(i))?; + for k in obj.keys() { + if k != "id" + && !k.starts_with('@') + && !GROUP_SCHEMA_V1.iter().any(|d| d.name == k) + && !ignored.contains(k) + { + ignored.push(k.clone()); + } + } + records.push(encode_group(obj, i, tenant, pool, observed_at_ms)?); + } + ignored.sort(); + Ok(crate::Page { + records, + next_link: next_link(&v), + ignored, + }) +} + +/// What a member is, from its `@odata.type`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MemberKind { + /// `#microsoft.graph.user`. + User, + /// `#microsoft.graph.group`: a nested group. + Group, +} + +/// One page of a group's direct members. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MembersPage { + /// The users and groups, in page order. + pub members: Vec<(Guid128, MemberKind)>, + /// Members of any other type (devices, service principals, + /// organizational contacts), not kept. + pub skipped: usize, + /// `@odata.nextLink`, if Graph returned one. + pub next_link: Option, +} + +/// Ingest one `GET /groups/{id}/members` page body. +/// +/// A member without a parseable `id` or without an `@odata.type` is an +/// error, never a guess: the type is what decides user from group. +pub fn ingest_members_page(body: &str) -> Result { + let v: Value = serde_json::from_str(body).map_err(|_| AzError::NotAPage)?; + let items = v + .get("value") + .and_then(Value::as_array) + .ok_or(AzError::NotAPage)?; + let mut page = MembersPage { + next_link: next_link(&v), + ..MembersPage::default() + }; + for (i, item) in items.iter().enumerate() { + let obj = item.as_object().ok_or(AzError::BadId(i))?; + let id = obj + .get("id") + .and_then(Value::as_str) + .and_then(|s| Guid128::parse(s).ok()) + .ok_or(AzError::BadId(i))?; + let kind = match obj.get("@odata.type").and_then(Value::as_str) { + Some("#microsoft.graph.user") => MemberKind::User, + Some("#microsoft.graph.group") => MemberKind::Group, + Some(_) => { + page.skipped += 1; + continue; + } + None => return Err(AzError::BadType(i, "@odata.type")), + }; + page.members.push((id, kind)); + } + Ok(page) +} + +fn next_link(v: &Value) -> Option { + v.get("@odata.nextLink") + .and_then(Value::as_str) + .map(str::to_string) +} + +fn is_group(rec: &DirRecord) -> bool { + rec.schema().family == SchemaFamily::MsGraph && rec.object_kind() == AzKind::Group as u16 +} + +fn def(name: &str, kind: AttrKind) -> Option<&'static AttrDef> { + GROUP_SCHEMA_V1 + .iter() + .find(|d| d.name == name && d.kind == kind) +} + +/// A single-valued string attribute of a group record. `None` for absent +/// values, unknown names and records that are not groups. +pub fn group_str<'p>(rec: &DirRecord, pool: &'p ValuePool, name: &str) -> Option<&'p str> { + let d = def(name, AttrKind::Str).filter(|_| is_group(rec))?; + std::str::from_utf8(pool.get(rec.str_ref(d.slot as usize)?)?).ok() +} + +/// A multi-valued string attribute of a group record. +pub fn group_multi<'p>(rec: &DirRecord, pool: &'p ValuePool, name: &str) -> Option> { + let d = def(name, AttrKind::MultiStr).filter(|_| is_group(rec))?; + pool.get_multi(rec.str_ref(d.slot as usize)?)? + .into_iter() + .map(|v| std::str::from_utf8(v).ok()) + .collect() +} + +/// A boolean attribute of a group record, `None` when it was not read. +pub fn group_flag(rec: &DirRecord, name: &str) -> Option { + let d = def(name, AttrKind::Bool).filter(|_| is_group(rec))?; + rec.num(d.slot as usize).map(|n| n != 0) +} diff --git a/crates/ogar-az/src/lib.rs b/crates/ogar-az/src/lib.rs index b29e2eb..965536b 100644 --- a/crates/ogar-az/src/lib.rs +++ b/crates/ogar-az/src/lib.rs @@ -27,6 +27,7 @@ use ogar_dir_core::{ use serde_json::Value; use std::collections::HashSet; +pub mod group; pub mod mailbox; /// Encoder schema version understood by this crate. @@ -299,6 +300,8 @@ pub enum AzKind { /// A user's Exchange Online mailbox as Graph reports it /// ([`mailbox::MAILBOX_SCHEMA_V1`]). Mailbox = 2, + /// A Graph `group` ([`group::GROUP_SCHEMA_V1`]). + Group = 3, } /// `$select` value for schema `version`: `id` plus every attribute defined at @@ -495,9 +498,13 @@ pub fn ingest_page( } /// Read a single-valued string slot by attribute name. `None` for absent -/// values and for attributes that are not [`AttrKind::Str`] (a multi-valued +/// values, for records that are not users (a group or mailbox record lays +/// out its slots differently) and for attributes that are not [`AttrKind::Str`] (a multi-valued /// slot is a length-prefixed blob, never a string — use [`attr_multi`]). pub fn attr_str<'p>(rec: &DirRecord, pool: &'p ValuePool, name: &str) -> Option<&'p str> { + if !is_user(rec) { + return None; + } let def = SCHEMA_V1 .iter() .find(|d| d.name == name && d.kind == AttrKind::Str)?; @@ -505,8 +512,11 @@ pub fn attr_str<'p>(rec: &DirRecord, pool: &'p ValuePool, name: &str) -> Option< } /// Read a multi-valued string slot by attribute name. `None` for absent -/// values and for attributes that are not [`AttrKind::MultiStr`]. +/// values, for records that are not users and for attributes that are not [`AttrKind::MultiStr`]. pub fn attr_multi<'p>(rec: &DirRecord, pool: &'p ValuePool, name: &str) -> Option> { + if !is_user(rec) { + return None; + } let def = SCHEMA_V1 .iter() .find(|d| d.name == name && d.kind == AttrKind::MultiStr)?; @@ -546,3 +556,7 @@ pub fn sync_edges( }) .collect() } + +fn is_user(rec: &DirRecord) -> bool { + rec.schema().family == SchemaFamily::MsGraph && rec.object_kind() == AzKind::User as u16 +} diff --git a/crates/ogar-az/tests/group.rs b/crates/ogar-az/tests/group.rs new file mode 100644 index 0000000..ccd1904 --- /dev/null +++ b/crates/ogar-az/tests/group.rs @@ -0,0 +1,139 @@ +//! Graph groups and their direct members. + +use ogar_az::group::{ + GROUP_SCHEMA_V1, MemberKind, group_flag, group_multi, group_select_query, group_str, + groups_url, ingest_groups_page, ingest_members_page, members_url, +}; +use ogar_az::{AzError, AzKind, attr_str}; +use ogar_dir_core::{Guid128, SchemaFamily, ValuePool, schema}; + +const TENANT: &str = "c0ffee00-1234-4abc-8def-000000000042"; +const SEC: &str = "a1a1a1a1-0000-4000-8000-000000000001"; +const DL: &str = "d1d1d1d1-0000-4000-8000-000000000002"; +const USER: &str = "7a3c9e11-2b44-4c6d-9e8f-0123456789ab"; + +fn g(s: &str) -> Guid128 { + Guid128::parse(s).unwrap() +} + +const GROUPS: &str = r#"{"@odata.context":"x","@odata.nextLink":"https://graph.microsoft.com/v1.0/groups?$skiptoken=abc", + "value":[ + {"id":"a1a1a1a1-0000-4000-8000-000000000001","displayName":"Finance","mail":"finance@example.de", + "mailNickname":"finance","mailEnabled":true,"securityEnabled":true,"groupTypes":[], + "proxyAddresses":["SMTP:finance@example.de","smtp:fin@example.de"], + "onPremisesSyncEnabled":true,"onPremisesSamAccountName":"Finance", + "onPremisesSecurityIdentifier":"S-1-5-21-1-2-3-1104","securityIdentifier":"S-1-12-1-1","visibility":null}, + {"id":"d1d1d1d1-0000-4000-8000-000000000002","displayName":"All staff","mail":"all@example.de", + "mailEnabled":true,"securityEnabled":false,"groupTypes":[],"onPremisesSyncEnabled":null, + "classification":"internal"} + ]}"#; + +#[test] +fn group_schema_is_well_formed_and_selected() { + schema::validate(GROUP_SCHEMA_V1).unwrap(); + let q = group_select_query(); + assert!(q.starts_with("id,")); + assert_eq!(q.split(',').count(), 1 + GROUP_SCHEMA_V1.len()); + assert!(groups_url(5000).ends_with("&$top=999")); + assert!(members_url(&g(SEC), 100).contains(&format!("/groups/{SEC}/members?$select=id"))); +} + +#[test] +fn a_groups_page_ingests() { + let mut pool = ValuePool::new(); + let page = ingest_groups_page(GROUPS, g(TENANT), &mut pool, 7).unwrap(); + assert_eq!(page.records.len(), 2); + assert!(page.next_link.unwrap().contains("$skiptoken")); + assert_eq!(page.ignored, ["classification", "visibility"]); + + let sec = &page.records[0]; + assert_eq!(sec.schema().family, SchemaFamily::MsGraph); + assert_eq!(sec.object_kind(), AzKind::Group as u16); + assert_eq!(sec.node_guid(), g(SEC)); + assert_eq!(sec.scope_guid(), g(TENANT)); + assert_eq!(sec.ou_hhtl(), None, "Graph reports no OU for a group"); + assert_eq!(group_str(sec, &pool, "displayName"), Some("Finance")); + assert_eq!(group_flag(sec, "mailEnabled"), Some(true)); + assert_eq!(group_flag(sec, "securityEnabled"), Some(true)); + assert_eq!( + group_multi(sec, &pool, "proxyAddresses").unwrap(), + ["SMTP:finance@example.de", "smtp:fin@example.de"] + ); + assert_eq!( + group_multi(sec, &pool, "groupTypes").unwrap(), + Vec::<&str>::new() + ); + assert_eq!( + group_str(sec, &pool, "onPremisesSecurityIdentifier"), + Some("S-1-5-21-1-2-3-1104") + ); + + let dl = &page.records[1]; + assert_eq!(dl.node_guid(), g(DL)); + assert_eq!(group_flag(dl, "securityEnabled"), Some(false)); + // null is unknown, never false. + assert_eq!(group_flag(dl, "onPremisesSyncEnabled"), None); + assert_eq!(group_multi(dl, &pool, "proxyAddresses"), None); +} + +// A group record lays out its slots differently from a user record, so each +// side's readers refuse the other's records rather than misread them. +#[test] +fn user_and_group_readers_do_not_cross() { + let mut pool = ValuePool::new(); + let page = ingest_groups_page(GROUPS, g(TENANT), &mut pool, 7).unwrap(); + let sec = &page.records[0]; + // Slot 2 of a group holds `mail`; the user table puts `givenName` there. + assert_eq!(attr_str(sec, &pool, "givenName"), None); + assert_eq!(attr_str(sec, &pool, "displayName"), None); + assert_eq!(group_str(sec, &pool, "mail"), Some("finance@example.de")); + + let users = include_str!("fixtures/users_page.json"); + let (mut d, mut p) = (ogar_dir_core::OuDictionary::new(), ValuePool::new()); + let user = &ogar_az::ingest_page(users, g(TENANT), &mut d, &mut p, 0) + .unwrap() + .records[0]; + assert!(attr_str(user, &p, "displayName").is_some()); + assert_eq!(group_str(user, &p, "displayName"), None); + assert_eq!(group_flag(user, "mailEnabled"), None); +} + +#[test] +fn members_keep_users_and_groups_and_count_the_rest() { + let body = format!( + r##"{{"@odata.nextLink":"next","value":[ + {{"@odata.type":"#microsoft.graph.user","id":"{USER}"}}, + {{"@odata.type":"#microsoft.graph.group","id":"{DL}"}}, + {{"@odata.type":"#microsoft.graph.device","id":"0e0e0e0e-0000-4000-8000-000000000003"}}, + {{"@odata.type":"#microsoft.graph.orgContact","id":"0c0c0c0c-0000-4000-8000-000000000004"}}]}}"## + ); + let page = ingest_members_page(&body).unwrap(); + assert_eq!( + page.members, + [(g(USER), MemberKind::User), (g(DL), MemberKind::Group)] + ); + assert_eq!(page.skipped, 2); + assert_eq!(page.next_link.as_deref(), Some("next")); +} + +#[test] +fn a_member_without_a_type_or_id_is_refused() { + let untyped = format!(r#"{{"value":[{{"id":"{USER}"}}]}}"#); + assert_eq!( + ingest_members_page(&untyped), + Err(AzError::BadType(0, "@odata.type")) + ); + let bad = r##"{"value":[{"@odata.type":"#microsoft.graph.user","id":"nope"}]}"##; + assert_eq!(ingest_members_page(bad), Err(AzError::BadId(0))); + assert_eq!(ingest_members_page("{}"), Err(AzError::NotAPage)); +} + +#[test] +fn a_wrongly_typed_group_property_is_refused() { + let mut pool = ValuePool::new(); + let bad = format!(r#"{{"value":[{{"id":"{SEC}","securityEnabled":"yes"}}]}}"#); + assert!(matches!( + ingest_groups_page(&bad, g(TENANT), &mut pool, 0), + Err(AzError::BadType(0, "securityEnabled")) + )); +}