From e7cdec5523c188a801f6cc2a22233dd0668be7fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:52:15 +0000 Subject: [PATCH 1/3] ogar-az: read Exchange Online mailboxes through Graph; ogar-dir-sim: deployment shapes ogar-az::mailbox names the Graph reads a mailbox takes (mailboxSettings, settings/exchange, admin/exchange/mailboxes folders and items), each a GET with its least-privileged application permission, and encodes one user's results into a DirRecord of kind Mailbox: userPurpose and primaryMailboxId raw, plus the mailbox GUID decoded strictly from the observed MBX:{guid}@{tenant} spelling. No HTTP, no writes. ogar-dir-sim::deployment adds ExchangeDeployment (on-premises, online, hybrid: which source is authoritative for what) and MailboxPurpose (Graph's userPurpose, mapped to RemoteKind). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-az/src/lib.rs | 7 +- crates/ogar-az/src/mailbox.rs | 263 ++++++++++++++++++ .../tests/fixtures/mailbox_settings.json | 10 + crates/ogar-az/tests/mailbox.rs | 202 ++++++++++++++ crates/ogar-dir-sim/src/deployment.rs | 137 +++++++++ crates/ogar-dir-sim/src/lib.rs | 2 + 6 files changed, 620 insertions(+), 1 deletion(-) create mode 100644 crates/ogar-az/src/mailbox.rs create mode 100644 crates/ogar-az/tests/fixtures/mailbox_settings.json create mode 100644 crates/ogar-az/tests/mailbox.rs create mode 100644 crates/ogar-dir-sim/src/deployment.rs diff --git a/crates/ogar-az/src/lib.rs b/crates/ogar-az/src/lib.rs index d8ced71b..b29e2eb6 100644 --- a/crates/ogar-az/src/lib.rs +++ b/crates/ogar-az/src/lib.rs @@ -27,6 +27,8 @@ use ogar_dir_core::{ use serde_json::Value; use std::collections::HashSet; +pub mod mailbox; + /// Encoder schema version understood by this crate. pub const SCHEMA_VERSION: u16 = 2; /// This crate's schema id. @@ -288,12 +290,15 @@ pub fn cloud_of(ad: &str) -> Option<&'static SyncedAttribute> { }) } -/// AZ object kinds (family-scoped codes). Only users are ingested in v1. +/// AZ object kinds (family-scoped codes). #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(u16)] pub enum AzKind { /// A Graph `user`. User = 1, + /// A user's Exchange Online mailbox as Graph reports it + /// ([`mailbox::MAILBOX_SCHEMA_V1`]). + Mailbox = 2, } /// `$select` value for schema `version`: `id` plus every attribute defined at diff --git a/crates/ogar-az/src/mailbox.rs b/crates/ogar-az/src/mailbox.rs new file mode 100644 index 00000000..5e61a5d9 --- /dev/null +++ b/crates/ogar-az/src/mailbox.rs @@ -0,0 +1,263 @@ +//! Exchange Online mailboxes as Microsoft Graph shows them. +//! +//! Graph does not report a mailbox as a property of the `user`: it takes +//! separate reads, each with its own permission. This module names those +//! reads ([`Pull`]), the least-privileged application permission each one +//! needs ([`Permission`]), and encodes what they return into one +//! [`DirRecord`] per user (object kind [`AzKind::Mailbox`]): +//! +//! * WHO = the Graph user `id` (the Entra object id, which Exchange Online +//! reports as the mailbox's `ExternalDirectoryObjectId`); +//! * WHERE = the tenant id; +//! * WHAT = [`MAILBOX_SCHEMA_V1`]: `mailboxSettings.userPurpose` and +//! `settings/exchange.primaryMailboxId`, raw, plus the mailbox GUID +//! decoded from `primaryMailboxId`. +//! +//! Every pull is a `GET`. Nothing here writes to Graph, and nothing here +//! performs HTTP: the caller fetches with its own token and hands the +//! response bodies in. +//! +//! ## `primaryMailboxId` +//! +//! Graph documents `primaryMailboxId` only as "the unique identifier for the +//! user's primary mailbox". The spelling observed in tenants is +//! `MBX:{mailbox GUID}@{tenant id}`, and for a primary mailbox that GUID is +//! its `ExchangeGuid`. That reading is not a documented contract, so it is +//! decoded strictly ([`mailbox_guid_of`]): two complete GUIDs, and the +//! tenant part must equal the record's tenant. Any other spelling keeps the +//! raw value and decodes no GUID; it is never guessed. + +use crate::{AzError, AzKind}; +use ogar_dir_core::{AttrDef, AttrKind, DirRecord, Guid128, SchemaFamily, SchemaId, ValuePool}; +use serde_json::Value; + +/// Encoder schema version of [`MAILBOX_SCHEMA_V1`]. +pub const MAILBOX_SCHEMA_VERSION: u16 = 1; +/// The mailbox record's schema id. It shares the Graph family with the +/// `user` records; [`AzKind::Mailbox`] tells the two tables apart. +pub const MAILBOX_SCHEMA: SchemaId = SchemaId { + family: SchemaFamily::MsGraph, + version: MAILBOX_SCHEMA_VERSION, +}; + +/// The mailbox attribute table, v1. +pub const MAILBOX_SCHEMA_V1: &[AttrDef] = &[ + AttrDef { + name: "userPurpose", + slot: 0, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "primaryMailboxId", + slot: 1, + kind: AttrKind::Str, + since: 1, + }, + AttrDef { + name: "mailboxGuid", + slot: 0, + kind: AttrKind::Guid, + since: 1, + }, +]; + +const PURPOSE: usize = 0; +const MAILBOX_ID: usize = 1; +const MAILBOX_GUID: usize = 0; + +/// A Graph application permission a pull needs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Permission { + /// `User.Read.All`. + UserReadAll, + /// `MailboxSettings.Read`. + MailboxSettingsRead, + /// `MailboxFolder.Read.All`. + MailboxFolderReadAll, + /// `MailboxItem.Read.All`. + MailboxItemReadAll, +} + +impl Permission { + /// The permission's name as Entra lists it. + pub fn name(self) -> &'static str { + match self { + Self::UserReadAll => "User.Read.All", + Self::MailboxSettingsRead => "MailboxSettings.Read", + Self::MailboxFolderReadAll => "MailboxFolder.Read.All", + Self::MailboxItemReadAll => "MailboxItem.Read.All", + } + } +} + +/// One read against Graph v1.0. All are `GET`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pull<'a> { + /// `GET /users/{id}/mailboxSettings`: carries `userPurpose`. + MailboxSettings { + /// The user. + user: Guid128, + }, + /// `GET /users/{id}/settings/exchange`: carries `primaryMailboxId`. + ExchangeSettings { + /// The user. + user: Guid128, + }, + /// `GET /admin/exchange/mailboxes/{mailboxId}/folders`. + MailboxFolders { + /// `primaryMailboxId` as read. + mailbox: &'a str, + }, + /// `GET /admin/exchange/mailboxes/{mailboxId}/folders/{folderId}/items`. + MailboxItems { + /// `primaryMailboxId` as read. + mailbox: &'a str, + /// The folder id as read. + folder: &'a str, + }, +} + +impl Pull<'_> { + /// The request URL. A mailbox or folder id is percent-encoded. + pub fn url(&self) -> String { + const BASE: &str = "https://graph.microsoft.com/v1.0"; + match *self { + Self::MailboxSettings { user } => format!("{BASE}/users/{user}/mailboxSettings"), + Self::ExchangeSettings { user } => format!("{BASE}/users/{user}/settings/exchange"), + Self::MailboxFolders { mailbox } => { + format!( + "{BASE}/admin/exchange/mailboxes/{}/folders", + encode(mailbox) + ) + } + Self::MailboxItems { mailbox, folder } => format!( + "{BASE}/admin/exchange/mailboxes/{}/folders/{}/items", + encode(mailbox), + encode(folder) + ), + } + } + + /// The least-privileged application permission the pull needs. + pub fn permission(&self) -> Permission { + match self { + Self::MailboxSettings { .. } => Permission::MailboxSettingsRead, + Self::ExchangeSettings { .. } => Permission::UserReadAll, + Self::MailboxFolders { .. } => Permission::MailboxFolderReadAll, + Self::MailboxItems { .. } => Permission::MailboxItemReadAll, + } + } +} + +/// Percent-encode everything outside RFC 3986's unreserved set. +fn encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +/// The mailbox GUID in a `primaryMailboxId`, when it is spelled +/// `MBX:{guid}@{tenant}` with two complete, unbraced GUIDs and the tenant +/// part equals `tenant`. Anything else is `None`. +pub fn mailbox_guid_of(primary_mailbox_id: &str, tenant: Guid128) -> Option { + let rest = primary_mailbox_id.strip_prefix("MBX:")?; + let (guid, at) = rest.split_once('@')?; + if guid.len() != 36 || at.len() != 36 { + return None; + } + (Guid128::parse(at).ok()? == tenant) + .then(|| Guid128::parse(guid).ok()) + .flatten() +} + +/// What one user's mailbox reads returned. A body that was not fetched is +/// `None`; the record then leaves those attributes absent. +#[derive(Clone, Copy, Debug)] +pub struct MailboxBodies<'a> { + /// The user (Graph `id`). + pub user: Guid128, + /// The `GET /users/{id}/mailboxSettings` body. + pub mailbox_settings: Option<&'a str>, + /// The `GET /users/{id}/settings/exchange` body. + pub exchange_settings: Option<&'a str>, +} + +/// Encode one user's mailbox reads into a [`DirRecord`] of kind +/// [`AzKind::Mailbox`]. +pub fn encode_mailbox( + bodies: &MailboxBodies<'_>, + tenant: Guid128, + pool: &mut ValuePool, + observed_at_ms: i64, +) -> Result { + let mut rec = DirRecord::new( + MAILBOX_SCHEMA, + AzKind::Mailbox as u16, + bodies.user, + tenant, + observed_at_ms, + ); + let st = |e: &dyn std::fmt::Debug| AzError::Storage(format!("{e:?}")); + if let Some(body) = bodies.mailbox_settings + && let Some(p) = string_field(body, "userPurpose")? + { + let r = pool.push(p.as_bytes()).map_err(|e| st(&e))?; + rec.set_str(PURPOSE, r).map_err(|e| st(&e))?; + } + if let Some(body) = bodies.exchange_settings + && let Some(id) = string_field(body, "primaryMailboxId")? + { + let r = pool.push(id.as_bytes()).map_err(|e| st(&e))?; + rec.set_str(MAILBOX_ID, r).map_err(|e| st(&e))?; + if let Some(g) = mailbox_guid_of(&id, tenant) { + rec.set_guid(MAILBOX_GUID, g).map_err(|e| st(&e))?; + } + } + Ok(rec) +} + +/// A top-level string property of a response body: `None` when absent or +/// `null`, an error when the body is not an object or the value is not a +/// string. +fn string_field(body: &str, name: &'static str) -> Result, AzError> { + let v: Value = serde_json::from_str(body).map_err(|_| AzError::NotAPage)?; + let o = v.as_object().ok_or(AzError::NotAPage)?; + match o.get(name) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s.clone())), + Some(_) => Err(AzError::BadType(0, name)), + } +} + +/// `userPurpose` as read. +pub fn user_purpose<'p>(rec: &DirRecord, pool: &'p ValuePool) -> Option<&'p str> { + read_str(rec, pool, PURPOSE) +} + +/// `primaryMailboxId` as read. +pub fn primary_mailbox_id<'p>(rec: &DirRecord, pool: &'p ValuePool) -> Option<&'p str> { + read_str(rec, pool, MAILBOX_ID) +} + +/// The mailbox GUID decoded from `primaryMailboxId`. +pub fn mailbox_guid(rec: &DirRecord) -> Option { + is_mailbox(rec).then(|| rec.guid(MAILBOX_GUID)).flatten() +} + +fn is_mailbox(rec: &DirRecord) -> bool { + rec.schema().family == SchemaFamily::MsGraph && rec.object_kind() == AzKind::Mailbox as u16 +} + +fn read_str<'p>(rec: &DirRecord, pool: &'p ValuePool, slot: usize) -> Option<&'p str> { + if !is_mailbox(rec) { + return None; + } + std::str::from_utf8(pool.get(rec.str_ref(slot)?)?).ok() +} diff --git a/crates/ogar-az/tests/fixtures/mailbox_settings.json b/crates/ogar-az/tests/fixtures/mailbox_settings.json new file mode 100644 index 00000000..a14660f3 --- /dev/null +++ b/crates/ogar-az/tests/fixtures/mailbox_settings.json @@ -0,0 +1,10 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users('3f2504e0-4f89-11d3-9a0c-0305e82c3302')/mailboxSettings", + "archiveFolder": "AAMkAGI2TGuLAAA=", + "timeZone": "W. Europe Standard Time", + "language": { "locale": "de-DE", "displayName": "German (Germany)" }, + "dateFormat": "dd.MM.yyyy", + "timeFormat": "HH:mm", + "userPurpose": "shared", + "delegateMeetingMessageDeliveryOptions": "sendToDelegateOnly" +} diff --git a/crates/ogar-az/tests/mailbox.rs b/crates/ogar-az/tests/mailbox.rs new file mode 100644 index 00000000..8f40fdff --- /dev/null +++ b/crates/ogar-az/tests/mailbox.rs @@ -0,0 +1,202 @@ +//! The Graph mailbox reads: which `GET`, which permission, and what is kept. + +use ogar_az::mailbox::{ + MAILBOX_SCHEMA_V1, MailboxBodies, Permission, Pull, encode_mailbox, mailbox_guid, + mailbox_guid_of, primary_mailbox_id, user_purpose, +}; +use ogar_az::{AzError, AzKind, select_query}; +use ogar_dir_core::{Guid128, ValuePool, schema}; + +const TENANT: &str = "c0ffee00-1234-4abc-8def-000000000042"; +const USER: &str = "3f2504e0-4f89-11d3-9a0c-0305e82c3302"; +const MAILBOX: &str = "8a6e2f11-0b3c-4d5e-9f70-112233445566"; +const SETTINGS: &str = include_str!("fixtures/mailbox_settings.json"); + +fn g(s: &str) -> Guid128 { + Guid128::parse(s).unwrap() +} + +fn exchange_settings(id: &str) -> String { + format!(r#"{{"@odata.context":"x","primaryMailboxId":"{id}"}}"#) +} + +#[test] +fn mailbox_schema_is_well_formed_and_not_selected_with_users() { + schema::validate(MAILBOX_SCHEMA_V1).unwrap(); + // Graph returns these from their own endpoints, never in a users page. + let q = select_query(ogar_az::SCHEMA_VERSION); + for d in MAILBOX_SCHEMA_V1 { + assert!(!q.split(',').any(|n| n == d.name), "{}", d.name); + } +} + +// Every pull is a read, each with the least-privileged application +// permission Microsoft documents for it. +#[test] +fn each_pull_names_its_get_and_its_permission() { + let user = g(USER); + let table = [ + ( + Pull::MailboxSettings { user }, + format!("https://graph.microsoft.com/v1.0/users/{USER}/mailboxSettings"), + "MailboxSettings.Read", + ), + ( + Pull::ExchangeSettings { user }, + format!("https://graph.microsoft.com/v1.0/users/{USER}/settings/exchange"), + "User.Read.All", + ), + ( + Pull::MailboxFolders { + mailbox: "MBX:a@b", + }, + "https://graph.microsoft.com/v1.0/admin/exchange/mailboxes/MBX%3Aa%40b/folders" + .to_string(), + "MailboxFolder.Read.All", + ), + ( + Pull::MailboxItems { + mailbox: "MBX:a@b", + folder: "AAMk/AG=", + }, + "https://graph.microsoft.com/v1.0/admin/exchange/mailboxes/MBX%3Aa%40b/folders/AAMk%2FAG%3D/items" + .to_string(), + "MailboxItem.Read.All", + ), + ]; + for (pull, url, perm) in table { + assert_eq!(pull.url(), url); + assert_eq!(pull.permission().name(), perm); + } + assert_ne!( + Permission::MailboxFolderReadAll.name(), + Permission::MailboxItemReadAll.name() + ); +} + +// The mailbox GUID is decoded only from the full `MBX:{guid}@{tenant}` +// spelling, and only for the record's own tenant. +#[test] +fn the_mailbox_guid_is_decoded_strictly() { + let tenant = g(TENANT); + let good = format!("MBX:{MAILBOX}@{TENANT}"); + assert_eq!(mailbox_guid_of(&good, tenant), Some(g(MAILBOX))); + assert_eq!( + mailbox_guid_of(&good.to_uppercase().replace("MBX:", "MBX:"), tenant), + Some(g(MAILBOX)) + ); + let other_tenant = "c0ffee00-1234-4abc-8def-000000000043"; + for bad in [ + // Microsoft's shortened documentation example. + "MBX:e0643f21@a7809c93".to_string(), + format!("MBX:{MAILBOX}@{other_tenant}"), + format!("MBX:{{{MAILBOX}}}@{TENANT}"), + format!("{MAILBOX}@{TENANT}"), + format!("mbx:{MAILBOX}@{TENANT}"), + format!("MBX:{MAILBOX}"), + String::new(), + ] { + assert_eq!(mailbox_guid_of(&bad, tenant), None, "{bad}"); + } +} + +#[test] +fn both_reads_land_in_one_mailbox_record() { + let mut pool = ValuePool::new(); + let ex = exchange_settings(&format!("MBX:{MAILBOX}@{TENANT}")); + let rec = encode_mailbox( + &MailboxBodies { + user: g(USER), + mailbox_settings: Some(SETTINGS), + exchange_settings: Some(&ex), + }, + g(TENANT), + &mut pool, + 7, + ) + .unwrap(); + assert_eq!(rec.node_guid(), g(USER)); + assert_eq!(rec.scope_guid(), g(TENANT)); + assert_eq!(rec.object_kind(), AzKind::Mailbox as u16); + assert_eq!(user_purpose(&rec, &pool), Some("shared")); + let raw = format!("MBX:{MAILBOX}@{TENANT}"); + assert_eq!(primary_mailbox_id(&rec, &pool), Some(raw.as_str())); + assert_eq!(mailbox_guid(&rec), Some(g(MAILBOX))); +} + +// A read not made leaves its attributes absent; a primaryMailboxId whose +// spelling does not decode is kept raw, without a GUID. +#[test] +fn missing_or_undecodable_reads_stay_absent() { + let mut pool = ValuePool::new(); + let none = encode_mailbox( + &MailboxBodies { + user: g(USER), + mailbox_settings: None, + exchange_settings: None, + }, + g(TENANT), + &mut pool, + 0, + ) + .unwrap(); + assert_eq!(user_purpose(&none, &pool), None); + assert_eq!(primary_mailbox_id(&none, &pool), None); + assert_eq!(mailbox_guid(&none), None); + + let foreign = exchange_settings(&format!( + "MBX:{MAILBOX}@c0ffee00-1234-4abc-8def-000000000043" + )); + let rec = encode_mailbox( + &MailboxBodies { + user: g(USER), + mailbox_settings: Some(r#"{"userPurpose":null}"#), + exchange_settings: Some(&foreign), + }, + g(TENANT), + &mut pool, + 0, + ) + .unwrap(); + assert_eq!(user_purpose(&rec, &pool), None); + assert!(primary_mailbox_id(&rec, &pool).is_some()); + assert_eq!(mailbox_guid(&rec), None); +} + +#[test] +fn malformed_bodies_are_refused() { + let mut pool = ValuePool::new(); + let enc = |settings: &str, pool: &mut ValuePool| { + encode_mailbox( + &MailboxBodies { + user: g(USER), + mailbox_settings: Some(settings), + exchange_settings: None, + }, + g(TENANT), + pool, + 0, + ) + }; + assert_eq!(enc("not json", &mut pool), Err(AzError::NotAPage)); + assert_eq!(enc("[]", &mut pool), Err(AzError::NotAPage)); + assert_eq!( + enc(r#"{"userPurpose":3}"#, &mut pool), + Err(AzError::BadType(0, "userPurpose")) + ); +} + +// The accessors read mailbox records only: a user record's slots mean +// something else. +#[test] +fn accessors_ignore_user_records() { + let mut pool = ValuePool::new(); + let page = include_str!("fixtures/users_page.json"); + let mut dict = ogar_dir_core::OuDictionary::new(); + let users = ogar_az::ingest_page(page, g(TENANT), &mut dict, &mut pool, 0).unwrap(); + let user = &users.records[0]; + assert!(ogar_az::attr_str(user, &pool, "userPrincipalName").is_some()); + assert_eq!(user_purpose(user, &pool), None); + assert_eq!(primary_mailbox_id(user, &pool), None); + assert_eq!(mailbox_guid(user), None); +} diff --git a/crates/ogar-dir-sim/src/deployment.rs b/crates/ogar-dir-sim/src/deployment.rs new file mode 100644 index 00000000..c768d8c3 --- /dev/null +++ b/crates/ogar-dir-sim/src/deployment.rs @@ -0,0 +1,137 @@ +//! Which Exchange the simulated directory has, and what each source decides. +//! +//! The same object reads differently depending on where its mailbox can +//! live. [`ExchangeDeployment`] names the three shapes and says which +//! observation is authoritative for what; [`MailboxPurpose`] is the mailbox +//! type as Microsoft Graph reports it (`mailboxSettings.userPurpose`), which +//! is the cloud side's counterpart of the on-premises recipient type. + +use crate::exchange::RemoteKind; + +/// Where mailboxes live. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExchangeDeployment { + /// Exchange Server only. AD carries every recipient type; there is no + /// cloud mailbox, so a remote mailbox has nowhere to deliver. + OnPremises, + /// Exchange Online only. A user is mail-enabled when Exchange Online has + /// a mailbox for it; the mailbox type is its [`MailboxPurpose`], and its + /// `ExchangeGuid` exists only in the cloud. The `msExch*` attributes are + /// not read, even when users are synchronized from AD. + Online, + /// Both, joined by Entra Connect. AD carries the recipient type (a remote + /// mailbox for a cloud-hosted one); Exchange Online confirms the mailbox + /// exists and carries its cloud `ExchangeGuid`, which must match the + /// on-premises `msExchMailboxGuid` for the mailbox to be migratable. + Hybrid, +} + +impl ExchangeDeployment { + /// Whether the AD recipient attributes (`msExchRemoteRecipientType`, + /// `msExchRecipientDisplayType`, `msExchRecipientTypeDetails`, + /// `targetAddress`, `msExchMailboxGuid`) are authoritative. + pub fn reads_on_premises_recipient(self) -> bool { + matches!(self, Self::OnPremises | Self::Hybrid) + } + + /// Whether the Exchange Online mailbox is observed and decides delivery + /// to a cloud-hosted recipient. + pub fn reads_cloud_mailbox(self) -> bool { + matches!(self, Self::Online | Self::Hybrid) + } + + /// Whether both sides carry an `ExchangeGuid` to compare. + pub fn compares_exchange_guid(self) -> bool { + self == Self::Hybrid + } +} + +/// `mailboxSettings.userPurpose`: what a cloud mailbox is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailboxPurpose { + /// A user mailbox. + User, + /// A linked mailbox (its owner is an account in another forest). + Linked, + /// A shared mailbox. + Shared, + /// A room. + Room, + /// Equipment. + Equipment, + /// Graph's `others`: a mailbox of a type Graph does not name. + Others, +} + +impl MailboxPurpose { + /// Decode Graph's spelling. A value this vocabulary does not know, + /// including Graph's `unknownFutureValue`, is `None`: the raw value + /// stays with the observation and is never guessed. + pub fn from_graph(s: &str) -> Option { + Some(match s { + "user" => Self::User, + "linked" => Self::Linked, + "shared" => Self::Shared, + "room" => Self::Room, + "equipment" => Self::Equipment, + "others" => Self::Others, + _ => return None, + }) + } + + /// The remote mailbox kind a hybrid AD object carries for a cloud + /// mailbox of this purpose. A linked mailbox and `others` have no remote + /// mailbox kind. + pub fn remote_kind(self) -> Option { + match self { + Self::User => Some(RemoteKind::User), + Self::Shared => Some(RemoteKind::Shared), + Self::Room => Some(RemoteKind::Room), + Self::Equipment => Some(RemoteKind::Equipment), + Self::Linked | Self::Others => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_deployment_reads_its_own_sources() { + use ExchangeDeployment::*; + let table = [ + (OnPremises, true, false, false), + (Online, false, true, false), + (Hybrid, true, true, true), + ]; + for (d, onprem, cloud, compare) in table { + assert_eq!(d.reads_on_premises_recipient(), onprem, "{d:?}"); + assert_eq!(d.reads_cloud_mailbox(), cloud, "{d:?}"); + assert_eq!(d.compares_exchange_guid(), compare, "{d:?}"); + } + } + + #[test] + fn graph_purposes_decode_exactly_and_map_to_remote_kinds() { + let table = [ + ("user", MailboxPurpose::User, Some(RemoteKind::User)), + ("linked", MailboxPurpose::Linked, None), + ("shared", MailboxPurpose::Shared, Some(RemoteKind::Shared)), + ("room", MailboxPurpose::Room, Some(RemoteKind::Room)), + ( + "equipment", + MailboxPurpose::Equipment, + Some(RemoteKind::Equipment), + ), + ("others", MailboxPurpose::Others, None), + ]; + for (s, p, k) in table { + assert_eq!(MailboxPurpose::from_graph(s), Some(p), "{s}"); + assert_eq!(p.remote_kind(), k, "{s}"); + } + for s in ["unknownFutureValue", "User", "", "mailUser"] { + assert_eq!(MailboxPurpose::from_graph(s), None, "{s}"); + } + } +} diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index f573fbb5..d7db5846 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -16,6 +16,7 @@ //! Design: `docs/DIRECTORY-SIMULATION-POC.md`. pub mod change; +pub mod deployment; pub mod exchange; pub mod identity; pub mod plan; @@ -25,6 +26,7 @@ pub mod violation; pub use change::{ Attribute, Change, KeyId, NodeKind, NodeState, Refusal, ValueId, effective_active, normalize, }; +pub use deployment::{ExchangeDeployment, MailboxPurpose}; pub use exchange::{ AddressTemplate, ArchiveState, ExchangeIdentity, LifecycleRefusal, MailboxState, ROUTING, Recipient, RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp, routing_parts, From 0ceb20840cf02a94fbbd97e41a5cfc38e4d4c829 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:52:26 +0000 Subject: [PATCH 2/3] ogar-az: test upper-case GUIDs without a no-op replace Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-az/tests/mailbox.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ogar-az/tests/mailbox.rs b/crates/ogar-az/tests/mailbox.rs index 8f40fdff..8f54e952 100644 --- a/crates/ogar-az/tests/mailbox.rs +++ b/crates/ogar-az/tests/mailbox.rs @@ -82,7 +82,10 @@ fn the_mailbox_guid_is_decoded_strictly() { let good = format!("MBX:{MAILBOX}@{TENANT}"); assert_eq!(mailbox_guid_of(&good, tenant), Some(g(MAILBOX))); assert_eq!( - mailbox_guid_of(&good.to_uppercase().replace("MBX:", "MBX:"), tenant), + mailbox_guid_of( + &format!("MBX:{}@{}", MAILBOX.to_uppercase(), TENANT.to_uppercase()), + tenant + ), Some(g(MAILBOX)) ); let other_tenant = "c0ffee00-1234-4abc-8def-000000000043"; From fc1c34886492d898df287c1795123057abfa0743 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:53:09 +0000 Subject: [PATCH 3/3] =?UTF-8?q?ogar-az:=20mailbox=5Fingest=20example;=20do?= =?UTF-8?q?cs:=20Graph=20mailbox=20reads=20(adapters=20=C2=A710)=20and=20d?= =?UTF-8?q?eployment=20shapes=20(plan=20V15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-az/examples/mailbox_ingest.rs | 68 +++++++++++++++++++++++ docs/DIRECTORY-ADAPTERS-POC.md | 35 ++++++++++++ docs/DIRECTORY-SIMULATION-POC.md | 15 +++++ 3 files changed, 118 insertions(+) create mode 100644 crates/ogar-az/examples/mailbox_ingest.rs diff --git a/crates/ogar-az/examples/mailbox_ingest.rs b/crates/ogar-az/examples/mailbox_ingest.rs new file mode 100644 index 00000000..a1968b1c --- /dev/null +++ b/crates/ogar-az/examples/mailbox_ingest.rs @@ -0,0 +1,68 @@ +//! Read-only lab ingest of one user's Exchange Online mailbox via Graph. +//! +//! This example performs no HTTP. Print the reads and the permission each +//! needs, fetch them with any client holding a token, then encode: +//! +//! ```sh +//! cargo run -q -p ogar-az --example mailbox_ingest -- --pulls +//! curl -s -H "Authorization: Bearer $TOKEN" "" > settings.json +//! curl -s -H "Authorization: Bearer $TOKEN" "" > exchange.json +//! cargo run -q -p ogar-az --example mailbox_ingest -- settings.json exchange.json +//! ``` + +use ogar_az::mailbox::{self, MailboxBodies, Pull}; +use ogar_dir_core::{Guid128, ValuePool}; + +fn guid(s: &str) -> Guid128 { + Guid128::parse(s).expect("expected a GUID") +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + match args.as_slice() { + [flag, user] if flag == "--pulls" => { + let user = guid(user); + for p in [ + Pull::MailboxSettings { user }, + Pull::ExchangeSettings { user }, + ] { + println!("GET {} ({})", p.url(), p.permission().name()); + } + } + [user, tenant, settings, exchange] => { + let (settings, exchange) = ( + std::fs::read_to_string(settings).expect("read settings"), + std::fs::read_to_string(exchange).expect("read exchange"), + ); + let mut pool = ValuePool::new(); + let rec = mailbox::encode_mailbox( + &MailboxBodies { + user: guid(user), + mailbox_settings: Some(&settings), + exchange_settings: Some(&exchange), + }, + guid(tenant), + &mut pool, + 0, + ) + .expect("encode"); + println!( + "{} purpose={:?} primaryMailboxId={:?} mailboxGuid={:?}", + rec.node_guid(), + mailbox::user_purpose(&rec, &pool), + mailbox::primary_mailbox_id(&rec, &pool), + mailbox::mailbox_guid(&rec), + ); + if let Some(id) = mailbox::primary_mailbox_id(&rec, &pool) { + let f = Pull::MailboxFolders { mailbox: id }; + println!("next: GET {} ({})", f.url(), f.permission().name()); + } + } + _ => { + eprintln!( + "usage: mailbox_ingest --pulls | " + ); + std::process::exit(2); + } + } +} diff --git a/docs/DIRECTORY-ADAPTERS-POC.md b/docs/DIRECTORY-ADAPTERS-POC.md index 4e728edb..fcfd2b78 100644 --- a/docs/DIRECTORY-ADAPTERS-POC.md +++ b/docs/DIRECTORY-ADAPTERS-POC.md @@ -175,3 +175,38 @@ The crate performs no HTTP and no writes; follow `next_link` for more pages. **Not yet run against a real tenant** (no credentials in the build environment); verified against a synthetic Graph page fixture only. AD side: `ldapsearch -LLL … objectGUID …` / `ldifde` output through `ogar_ad::ldif::parse`. + +## 10. Exchange Online mailboxes through Graph (2026-10-10) + +Graph has no mailbox property on `user`; a mailbox takes its own reads, +each with its own permission. `ogar_az::mailbox` names them as `Pull`s, +all `GET` against v1.0, each with the least-privileged application +permission Microsoft documents: + +| pull | path | permission | +|---|---|---| +| `MailboxSettings` | `/users/{id}/mailboxSettings` | `MailboxSettings.Read` | +| `ExchangeSettings` | `/users/{id}/settings/exchange` | `User.Read.All` | +| `MailboxFolders` | `/admin/exchange/mailboxes/{mailboxId}/folders` | `MailboxFolder.Read.All` | +| `MailboxItems` | `/admin/exchange/mailboxes/{mailboxId}/folders/{folderId}/items` | `MailboxItem.Read.All` | + +There is no `Mailbox.ReadWrite.All`; the mailbox-content permissions are +`MailboxFolder.*` and `MailboxItem.*`, and the read variants suffice for +everything here. A granted write scope is not used: no pull writes. + +`encode_mailbox` turns one user's settings reads into a `DirRecord` of kind +`AzKind::Mailbox` (family `MsGraph`, `MAILBOX_SCHEMA_V1`): WHO = the Graph +user id, which Exchange Online reports as the mailbox's +`ExternalDirectoryObjectId`; WHERE = the tenant; WHAT = `userPurpose` and +`primaryMailboxId` raw, plus `mailboxGuid` in a guid slot. + +`primaryMailboxId` is documented only as an opaque identifier. The spelling +observed in tenants is `MBX:{mailbox GUID}@{tenant id}`, and for a primary +mailbox that GUID is its `ExchangeGuid`; Microsoft's own example +(`MBX:e0643f21@a7809c93`) is shortened. `mailbox_guid_of` therefore decodes +only two complete GUIDs whose tenant part equals the record's tenant; any +other spelling is kept raw with no GUID. **Not yet run against a real +tenant**; the reading that the GUID is the `ExchangeGuid` must be confirmed +against `Get-EXOMailbox -Properties ExchangeGuid` before anything compares +it. `examples/mailbox_ingest.rs` prints the pulls and encodes saved bodies. + diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index b3976e29..e1d7b0f3 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -357,5 +357,20 @@ no-population-intermediate rule. uniqueness across all objects, Exchange only across mail-enabled ones; whether the dir-sim owner gate should follow the wider Entra scope is undecided. +- **V15 — Which Exchange is simulated (2026-10-10).** `ExchangeDeployment` + names three shapes and what each reads as authoritative: + - `OnPremises`: the AD recipient attributes decide; there is no cloud + mailbox, so a remote mailbox delivers nowhere. + - `Online`: Exchange Online decides; a user is mail-enabled when it has + a cloud mailbox, its type is `MailboxPurpose` (Graph's + `mailboxSettings.userPurpose`), and its `ExchangeGuid` exists only in + the cloud. `msExch*` is not read, even for synchronized users. + - `Hybrid`: AD carries the recipient type, the cloud confirms the mailbox + and carries the `ExchangeGuid` compared under V12. + `MailboxPurpose` maps user / shared / room / equipment onto `RemoteKind`; + linked and `others` have no remote kind, and an unknown value stays raw. + The cloud side is read through Graph (`DIRECTORY-ADAPTERS-POC.md` §10). + **Open:** the lance-graph side does not consume the deployment yet; the + simulator still behaves as `Hybrid`. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first.