From ace33c99ad99ab58e5c254895d87c722cc8aa8fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:57:36 +0000 Subject: [PATCH 1/4] ogar-dir-sim: mailbox hosts and locations; ogar-az: the user's OneDrive ExchangeDeployment becomes Deployment { on_premises host, online }: Exchange Server or Stalwart on-premises, Exchange Online in the cloud. host_of says where a recipient's mailbox is, the question a mail server answers before accepting a message as local or relaying it. Stalwart has no ExchangeGuid, so only Exchange Server with Exchange Online compares one. MailboxLocation and MailboxLocationType carry what Get-MailboxLocation reports (TenantGUID\MailboxGUID, the pair Graph spells MBX:{guid}@{tenant}). ogar-az adds Pull::UserDrive (GET /users/{id}/drive, Files.Read.All, delegated per Microsoft) and user_drive, which accepts a drive only when its owner is the user, so Spear can address a user's OneDrive from the directory id. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-az/src/mailbox.rs | 73 +++++++- crates/ogar-az/tests/mailbox.rs | 32 ++++ crates/ogar-dir-sim/src/deployment.rs | 249 ++++++++++++++++++++++---- crates/ogar-dir-sim/src/lib.rs | 4 +- 4 files changed, 320 insertions(+), 38 deletions(-) diff --git a/crates/ogar-az/src/mailbox.rs b/crates/ogar-az/src/mailbox.rs index 5e61a5d..356ee20 100644 --- a/crates/ogar-az/src/mailbox.rs +++ b/crates/ogar-az/src/mailbox.rs @@ -2,8 +2,8 @@ //! //! 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 +//! reads ([`Pull`]), the least-privileged permission each one needs +//! ([`Permission`], granted as [`Grant`]), 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 @@ -17,6 +17,11 @@ //! performs HTTP: the caller fetches with its own token and hands the //! response bodies in. //! +//! One more read rides along: [`Pull::UserDrive`] resolves a user's +//! OneDrive ([`user_drive`]), so a consumer that uploads documents (Spear's +//! drive scope) can address the drive from the directory's user id. The +//! upload itself is the consumer's write, not this module's. +//! //! ## `primaryMailboxId` //! //! Graph documents `primaryMailboxId` only as "the unique identifier for the @@ -77,6 +82,17 @@ pub enum Permission { MailboxFolderReadAll, /// `MailboxItem.Read.All`. MailboxItemReadAll, + /// `Files.Read.All`. + FilesReadAll, +} + +/// How a permission is granted. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Grant { + /// To the application itself (no signed-in user). + Application, + /// On behalf of a signed-in user. + Delegated, } impl Permission { @@ -87,6 +103,7 @@ impl Permission { Self::MailboxSettingsRead => "MailboxSettings.Read", Self::MailboxFolderReadAll => "MailboxFolder.Read.All", Self::MailboxItemReadAll => "MailboxItem.Read.All", + Self::FilesReadAll => "Files.Read.All", } } } @@ -116,6 +133,12 @@ pub enum Pull<'a> { /// The folder id as read. folder: &'a str, }, + /// `GET /users/{id}/drive`: the user's OneDrive, whose id addresses + /// uploads to it. + UserDrive { + /// The user. + user: Guid128, + }, } impl Pull<'_> { @@ -131,6 +154,7 @@ impl Pull<'_> { encode(mailbox) ) } + Self::UserDrive { user } => format!("{BASE}/users/{user}/drive"), Self::MailboxItems { mailbox, folder } => format!( "{BASE}/admin/exchange/mailboxes/{}/folders/{}/items", encode(mailbox), @@ -139,15 +163,58 @@ impl Pull<'_> { } } - /// The least-privileged application permission the pull needs. + /// The least-privileged permission the pull needs, under + /// [`Pull::grant`]. pub fn permission(&self) -> Permission { match self { + Self::UserDrive { .. } => Permission::FilesReadAll, Self::MailboxSettings { .. } => Permission::MailboxSettingsRead, Self::ExchangeSettings { .. } => Permission::UserReadAll, Self::MailboxFolders { .. } => Permission::MailboxFolderReadAll, Self::MailboxItems { .. } => Permission::MailboxItemReadAll, } } + + /// How [`Pull::permission`] is granted. Microsoft lists reading a + /// user's drive as delegated only; the mailbox reads take an + /// application permission. + pub fn grant(&self) -> Grant { + match self { + Self::UserDrive { .. } => Grant::Delegated, + _ => Grant::Application, + } + } +} + +/// A user's OneDrive, as `GET /users/{id}/drive` returns it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserDrive { + /// The drive id, which addresses the drive (`/drives/{id}`). + pub id: String, + /// `driveType`: `business` for OneDrive for Business. + pub drive_type: String, +} + +/// Read a `GET /users/{id}/drive` body. The drive is `user`'s only when its +/// `owner.user.id` is `user`; a drive owned by anyone else, or a body +/// without an id, is `None`. +pub fn user_drive(body: &str, user: Guid128) -> Result, AzError> { + let v: Value = serde_json::from_str(body).map_err(|_| AzError::NotAPage)?; + let o = v.as_object().ok_or(AzError::NotAPage)?; + let owner = o + .get("owner") + .and_then(|w| w.get("user")) + .and_then(|u| u.get("id")) + .and_then(Value::as_str) + .and_then(|s| Guid128::parse(s).ok()); + if owner != Some(user) { + return Ok(None); + } + let field = |name| o.get(name).and_then(Value::as_str).map(str::to_string); + Ok(field("id").map(|id| UserDrive { + id, + drive_type: field("driveType").unwrap_or_default(), + })) } /// Percent-encode everything outside RFC 3986's unreserved set. diff --git a/crates/ogar-az/tests/mailbox.rs b/crates/ogar-az/tests/mailbox.rs index 8f54e95..cab5dca 100644 --- a/crates/ogar-az/tests/mailbox.rs +++ b/crates/ogar-az/tests/mailbox.rs @@ -203,3 +203,35 @@ fn accessors_ignore_user_records() { assert_eq!(primary_mailbox_id(user, &pool), None); assert_eq!(mailbox_guid(user), None); } + +// A user's OneDrive: a delegated read, and the drive counts only when the +// user owns it. +#[test] +fn the_user_drive_is_a_delegated_read_owned_by_the_user() { + use ogar_az::mailbox::{Grant, UserDrive, user_drive}; + let user = g(USER); + let pull = Pull::UserDrive { user }; + assert_eq!( + pull.url(), + format!("https://graph.microsoft.com/v1.0/users/{USER}/drive") + ); + assert_eq!(pull.permission().name(), "Files.Read.All"); + assert_eq!(pull.grant(), Grant::Delegated); + assert_eq!(Pull::MailboxSettings { user }.grant(), Grant::Application); + + let body = |owner: &str| { + format!( + r#"{{"id":"b!t18F8ybsHUq1","driveType":"business","owner":{{"user":{{"id":"{owner}","displayName":"x"}}}}}}"# + ) + }; + assert_eq!( + user_drive(&body(USER), user).unwrap(), + Some(UserDrive { + id: "b!t18F8ybsHUq1".into(), + drive_type: "business".into() + }) + ); + assert_eq!(user_drive(&body(TENANT), user).unwrap(), None); + assert_eq!(user_drive(r#"{"id":"b!x"}"#, user).unwrap(), None); + assert_eq!(user_drive("[]", user), Err(AzError::NotAPage)); +} diff --git a/crates/ogar-dir-sim/src/deployment.rs b/crates/ogar-dir-sim/src/deployment.rs index c768d8c..85a0860 100644 --- a/crates/ogar-dir-sim/src/deployment.rs +++ b/crates/ogar-dir-sim/src/deployment.rs @@ -1,51 +1,160 @@ -//! Which Exchange the simulated directory has, and what each source decides. +//! Where mailboxes live, 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. +//! The same directory object reads differently depending on which mail +//! system hosts its mailbox. Three hosts are modelled ([`MailboxHost`]): +//! Exchange Server and Stalwart on-premises, Exchange Online in the cloud. +//! A [`Deployment`] names the on-premises host (if any) and whether +//! Exchange Online is present, and says which observation is authoritative +//! for what. [`Deployment::host_of`] answers, per recipient, where its +//! mailbox is — the question a mail server must answer before it accepts a +//! message as local or relays it. +//! +//! A mailbox's location is its [`MailboxLocation`]: the host, the location +//! type Exchange reports (`Get-MailboxLocation`, [`MailboxLocationType`]), +//! and the mailbox GUID. Exchange identifies a location as +//! `TenantGUID\MailboxGUID`; Microsoft Graph spells the same pair +//! `MBX:{MailboxGUID}@{TenantGUID}` (`primaryMailboxId`). -use crate::exchange::RemoteKind; +use crate::exchange::{MailboxState, Recipient, RemoteKind}; +use ogar_dir_core::Guid128; -/// Where mailboxes live. +/// The mail system that hosts a mailbox. #[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, +pub enum MailboxHost { + /// Exchange Server, on-premises. + ExchangeServer, + /// Stalwart, on-premises, serving recipients from the directory. + Stalwart, + /// Exchange Online. + ExchangeOnline, } -impl ExchangeDeployment { - /// Whether the AD recipient attributes (`msExchRemoteRecipientType`, - /// `msExchRecipientDisplayType`, `msExchRecipientTypeDetails`, - /// `targetAddress`, `msExchMailboxGuid`) are authoritative. +/// Which mail systems are present. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Deployment { + /// The on-premises host, if any: [`MailboxHost::ExchangeServer`] or + /// [`MailboxHost::Stalwart`]. + pub on_premises: Option, + /// Exchange Online is present. + pub online: bool, +} + +impl Deployment { + /// Exchange Server only. + pub const EXCHANGE_SERVER: Self = Self { + on_premises: Some(MailboxHost::ExchangeServer), + online: false, + }; + /// Exchange Online only. + pub const ONLINE: Self = Self { + on_premises: None, + online: true, + }; + /// Exchange Server and Exchange Online, joined by Entra Connect. + pub const EXCHANGE_HYBRID: Self = Self { + on_premises: Some(MailboxHost::ExchangeServer), + online: true, + }; + /// Stalwart only. + pub const STALWART: Self = Self { + on_premises: Some(MailboxHost::Stalwart), + online: false, + }; + /// Stalwart on-premises next to Exchange Online. + pub const STALWART_WITH_ONLINE: Self = Self { + on_premises: Some(MailboxHost::Stalwart), + online: true, + }; + + /// Whether the on-premises recipient attributes + /// (`msExchRemoteRecipientType`, `msExchRecipientDisplayType`, + /// `msExchRecipientTypeDetails`, `targetAddress`, `msExchMailboxGuid`) + /// are authoritative. Without an on-premises host they are not read, + /// even for users synchronized from AD. pub fn reads_on_premises_recipient(self) -> bool { - matches!(self, Self::OnPremises | Self::Hybrid) + self.on_premises.is_some() } /// 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) + self.online } - /// Whether both sides carry an `ExchangeGuid` to compare. + /// Whether both sides carry an `ExchangeGuid` to compare: Exchange + /// Server writes `msExchMailboxGuid`; Stalwart has no `ExchangeGuid`. pub fn compares_exchange_guid(self) -> bool { - self == Self::Hybrid + self.on_premises == Some(MailboxHost::ExchangeServer) && self.online + } + + /// Where `recipient`'s mailbox is hosted, by its on-premises recipient + /// type. An on-premises mailbox is on the on-premises host; a remote + /// mailbox that is not deprovisioned is in Exchange Online. Anything + /// else (not mail-enabled, deprovisioned, a type this vocabulary keeps + /// raw) has no hosted mailbox, and neither has a mailbox whose host the + /// deployment does not include. + /// + /// This reads the recipient type only; whether Exchange Online actually + /// holds the mailbox is the cloud observation's question. + pub fn host_of(self, recipient: &Recipient) -> Option { + match recipient { + Recipient::OnPremisesMailbox { .. } => self.on_premises, + Recipient::RemoteMailbox(m) if m.mailbox() != MailboxState::Deprovisioned => { + self.online.then_some(MailboxHost::ExchangeOnline) + } + _ => None, + } + } +} + +/// `MailboxLocationType`, as `Get-MailboxLocation` reports it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MailboxLocationType { + /// The primary mailbox. + Primary, + /// The main archive. + MainArchive, + /// An auxiliary archive. + AuxArchive, + /// An auxiliary primary mailbox. + AuxPrimary, + /// A component-shared mailbox. + ComponentShared, + /// An aggregated mailbox. + Aggregated, + /// The previous primary mailbox (Exchange Online only). + PreviousPrimary, +} + +impl MailboxLocationType { + /// Decode Exchange's spelling, exactly. Anything else is `None`. + pub fn from_exchange(s: &str) -> Option { + Some(match s { + "Primary" => Self::Primary, + "MainArchive" => Self::MainArchive, + "AuxArchive" => Self::AuxArchive, + "AuxPrimary" => Self::AuxPrimary, + "ComponentShared" => Self::ComponentShared, + "Aggregated" => Self::Aggregated, + "PreviousPrimary" => Self::PreviousPrimary, + _ => return None, + }) } } +/// Where one mailbox is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MailboxLocation { + /// The host. + pub host: MailboxHost, + /// Which of the owner's mailboxes this is. + pub kind: MailboxLocationType, + /// The mailbox GUID, when the host has one (an Exchange mailbox's + /// `ExchangeGuid` for its primary location). `None` = not observed, or + /// a host without one. + pub mailbox_guid: Option, +} + /// `mailboxSettings.userPurpose`: what a cloud mailbox is. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum MailboxPurpose { @@ -96,14 +205,18 @@ impl MailboxPurpose { #[cfg(test)] mod tests { use super::*; + use crate::ValueId; + use crate::exchange::{ArchiveState, RemoteMailbox}; #[test] fn each_deployment_reads_its_own_sources() { - use ExchangeDeployment::*; let table = [ - (OnPremises, true, false, false), - (Online, false, true, false), - (Hybrid, true, true, true), + (Deployment::EXCHANGE_SERVER, true, false, false), + (Deployment::ONLINE, false, true, false), + (Deployment::EXCHANGE_HYBRID, true, true, true), + (Deployment::STALWART, true, false, false), + // Stalwart has no ExchangeGuid to compare. + (Deployment::STALWART_WITH_ONLINE, true, true, false), ]; for (d, onprem, cloud, compare) in table { assert_eq!(d.reads_on_premises_recipient(), onprem, "{d:?}"); @@ -112,6 +225,74 @@ mod tests { } } + // The same recipient lands on different hosts per deployment: an + // on-premises mailbox on Exchange Server or Stalwart, a remote mailbox in + // Exchange Online only where Exchange Online is present. + #[test] + fn host_of_follows_the_recipient_type_and_the_deployment() { + use MailboxHost::*; + let onprem = Recipient::OnPremisesMailbox { + archive: ArchiveState::None, + }; + let remote = Recipient::RemoteMailbox( + RemoteMailbox::new( + RemoteKind::User, + MailboxState::Provisioned, + ArchiveState::None, + Some(ValueId(1)), + ) + .unwrap(), + ); + let gone = Recipient::RemoteMailbox( + RemoteMailbox::new( + RemoteKind::User, + MailboxState::Deprovisioned, + ArchiveState::None, + None, + ) + .unwrap(), + ); + let table = [ + (Deployment::EXCHANGE_SERVER, Some(ExchangeServer), None), + (Deployment::STALWART, Some(Stalwart), None), + (Deployment::ONLINE, None, Some(ExchangeOnline)), + ( + Deployment::EXCHANGE_HYBRID, + Some(ExchangeServer), + Some(ExchangeOnline), + ), + ( + Deployment::STALWART_WITH_ONLINE, + Some(Stalwart), + Some(ExchangeOnline), + ), + ]; + for (d, on, rem) in table { + assert_eq!(d.host_of(&onprem), on, "{d:?}"); + assert_eq!(d.host_of(&remote), rem, "{d:?}"); + assert_eq!(d.host_of(&gone), None, "{d:?}"); + assert_eq!(d.host_of(&Recipient::NotMailEnabled), None, "{d:?}"); + } + } + + #[test] + fn location_types_decode_exactly() { + for (s, t) in [ + ("Primary", MailboxLocationType::Primary), + ("MainArchive", MailboxLocationType::MainArchive), + ("AuxArchive", MailboxLocationType::AuxArchive), + ("AuxPrimary", MailboxLocationType::AuxPrimary), + ("ComponentShared", MailboxLocationType::ComponentShared), + ("Aggregated", MailboxLocationType::Aggregated), + ("PreviousPrimary", MailboxLocationType::PreviousPrimary), + ] { + assert_eq!(MailboxLocationType::from_exchange(s), Some(t), "{s}"); + } + for s in ["primary", "Archive", ""] { + assert_eq!(MailboxLocationType::from_exchange(s), None, "{s}"); + } + } + #[test] fn graph_purposes_decode_exactly_and_map_to_remote_kinds() { let table = [ diff --git a/crates/ogar-dir-sim/src/lib.rs b/crates/ogar-dir-sim/src/lib.rs index d7db584..d38fae4 100644 --- a/crates/ogar-dir-sim/src/lib.rs +++ b/crates/ogar-dir-sim/src/lib.rs @@ -26,7 +26,9 @@ pub mod violation; pub use change::{ Attribute, Change, KeyId, NodeKind, NodeState, Refusal, ValueId, effective_active, normalize, }; -pub use deployment::{ExchangeDeployment, MailboxPurpose}; +pub use deployment::{ + Deployment, MailboxHost, MailboxLocation, MailboxLocationType, MailboxPurpose, +}; pub use exchange::{ AddressTemplate, ArchiveState, ExchangeIdentity, LifecycleRefusal, MailboxState, ROUTING, Recipient, RecipientAttributes, RemoteKind, RemoteMailbox, RemoteMailboxOp, routing_parts, From 019bb7ff2083664894d4a49dcdf0f4fffdc72d34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:58:02 +0000 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20mailbox=20hosts=20and=20locations?= =?UTF-8?q?=20(plan=20V15),=20the=20OneDrive=20read=20(adapters=20=C2=A710?= =?UTF-8?q?)?= 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 --- docs/DIRECTORY-ADAPTERS-POC.md | 6 ++++ docs/DIRECTORY-SIMULATION-POC.md | 48 ++++++++++++++++++++++---------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/DIRECTORY-ADAPTERS-POC.md b/docs/DIRECTORY-ADAPTERS-POC.md index fcfd2b7..7d200f6 100644 --- a/docs/DIRECTORY-ADAPTERS-POC.md +++ b/docs/DIRECTORY-ADAPTERS-POC.md @@ -189,6 +189,12 @@ permission Microsoft documents: | `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` | +| `UserDrive` | `/users/{id}/drive` | `Files.Read.All` (delegated) | + +The mailbox reads take an application permission; Microsoft lists reading +a user's drive as delegated only (`Pull::grant`). `user_drive` accepts a +drive only when its `owner.user.id` is the user, and returns its id, which +Spear's drive scope uses to address uploads. There is no `Mailbox.ReadWrite.All`; the mailbox-content permissions are `MailboxFolder.*` and `MailboxItem.*`, and the read variants suffice for diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index e1d7b0f..a149fdb 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -357,20 +357,38 @@ 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`. +- **V15 — Where mailboxes live (2026-10-10).** `Deployment` names the + on-premises host (`MailboxHost::ExchangeServer` or `Stalwart`, or none) + and whether Exchange Online is present; the constants are + `EXCHANGE_SERVER`, `ONLINE`, `EXCHANGE_HYBRID`, `STALWART` and + `STALWART_WITH_ONLINE`. What each reads as authoritative: + - an on-premises host: the AD recipient attributes decide; + - Exchange Online: the cloud mailbox decides delivery to a cloud-hosted + recipient; without an on-premises host a user is mail-enabled when it + has a cloud mailbox, typed by `MailboxPurpose` (Graph's + `mailboxSettings.userPurpose`), and `msExch*` is not read; + - the `ExchangeGuid` is compared (V12) only for Exchange Server with + Exchange Online: Stalwart has no `ExchangeGuid`. + `Deployment::host_of` says where a recipient's mailbox is: an + on-premises mailbox on the on-premises host, a remote mailbox that is not + deprovisioned in Exchange Online, and nothing for anything else or for a + host the deployment lacks. That is the question a mail server answers + before it accepts a message as local or relays it. + `MailboxLocation { host, kind, mailbox_guid }` carries what + `Get-MailboxLocation` reports; `MailboxLocationType` is its seven values. + Exchange names a location `TenantGUID\MailboxGUID`, the pair Graph + spells `MBX:{MailboxGUID}@{TenantGUID}` (`DIRECTORY-ADAPTERS-POC.md` + §10). `MailboxPurpose` maps user / shared / room / equipment onto + `RemoteKind`; linked and `others` have no remote kind. + **Open:** + - lance-graph does not consume the deployment yet; the simulator still + behaves as `EXCHANGE_HYBRID`. + - Stalwart's DirSim backend answers `Account` for every recipient that + receives, including a remote mailbox whose mailbox is in Exchange + Online. With `STALWART_WITH_ONLINE` it should answer `Account` only + where `host_of` is `Stalwart`, and route the rest to Exchange Online. + - Spear's OneDrive upload: Graph's `GET /users/{id}/drive` resolves the + drive from the directory user (`ogar_az::mailbox::user_drive`); the + upload itself is Spear's write, under a delegated grant. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first. From 53c34bb1645c8ead63df3066631e8e70ffb4dd1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:01:03 +0000 Subject: [PATCH 3/4] docs: OneDrive as a delivery location; unattended uploads address the drive by user id Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- crates/ogar-az/src/mailbox.rs | 9 ++++++--- docs/DIRECTORY-ADAPTERS-POC.md | 7 +++++-- docs/DIRECTORY-SIMULATION-POC.md | 13 ++++++++++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/ogar-az/src/mailbox.rs b/crates/ogar-az/src/mailbox.rs index 356ee20..02c6cb3 100644 --- a/crates/ogar-az/src/mailbox.rs +++ b/crates/ogar-az/src/mailbox.rs @@ -18,9 +18,12 @@ //! response bodies in. //! //! One more read rides along: [`Pull::UserDrive`] resolves a user's -//! OneDrive ([`user_drive`]), so a consumer that uploads documents (Spear's -//! drive scope) can address the drive from the directory's user id. The -//! upload itself is the consumer's write, not this module's. +//! OneDrive ([`user_drive`]) for an interactive (delegated) caller. Unattended +//! delivery into a user's OneDrive (an ERP posting an invoice, a ticket tool +//! filing a report) does not need it: Graph addresses the drive by the user's +//! id (`/users/{id}/drive/items/...`), and uploading there app-only takes +//! `Files.ReadWrite.All`. The upload is the consumer's write, not this +//! module's. //! //! ## `primaryMailboxId` //! diff --git a/docs/DIRECTORY-ADAPTERS-POC.md b/docs/DIRECTORY-ADAPTERS-POC.md index 7d200f6..78248f9 100644 --- a/docs/DIRECTORY-ADAPTERS-POC.md +++ b/docs/DIRECTORY-ADAPTERS-POC.md @@ -193,8 +193,11 @@ permission Microsoft documents: The mailbox reads take an application permission; Microsoft lists reading a user's drive as delegated only (`Pull::grant`). `user_drive` accepts a -drive only when its `owner.user.id` is the user, and returns its id, which -Spear's drive scope uses to address uploads. +drive only when its `owner.user.id` is the user, and returns its id for an +interactive caller. Unattended uploads (an ERP or ticket tool delivering an +invoice or a report) need no drive id: Graph addresses the drive by the +user's id, app-only with `Files.ReadWrite.All` (`Sites.ReadWrite.All` for an +upload session). There is no `Mailbox.ReadWrite.All`; the mailbox-content permissions are `MailboxFolder.*` and `MailboxItem.*`, and the read variants suffice for diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index a149fdb..6ad3804 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -387,8 +387,15 @@ no-population-intermediate rule. receives, including a remote mailbox whose mailbox is in Exchange Online. With `STALWART_WITH_ONLINE` it should answer `Account` only where `host_of` is `Stalwart`, and route the rest to Exchange Online. - - Spear's OneDrive upload: Graph's `GET /users/{id}/drive` resolves the - drive from the directory user (`ogar_az::mailbox::user_drive`); the - upload itself is Spear's write, under a delegated grant. + - OneDrive as a delivery location for ERP and ticket tools (invoices, + reports), executed by Spear. Unattended delivery addresses the drive by + the directory user's Entra id (`/users/{id}/drive/items/{parent}:/{file}:/content`), + app-only with `Files.ReadWrite.All` up to 250 MB; larger files take an + upload session, app-only with `Sites.ReadWrite.All`. The default + conflict behaviour is `fail`, so an invoice is never silently replaced. + `ogar_az::mailbox::user_drive` (`GET /users/{id}/drive`, delegated + only) is for an interactive caller that wants the drive id. The + directory's part is the Entra id of the recipient, from the + correspondence fold. - **V5 — CI.** CI builds `lance-graph-dir-sim` against the OGAR checkout, so it needs this OGAR PR merged first. From bf105636bc95bd1e08c5a9c4440450060f58523f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:10:29 +0000 Subject: [PATCH 4/4] docs: the routing API is consumed by HubSPO-rs and SAP on Quack Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg --- docs/DIRECTORY-SIMULATION-POC.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/DIRECTORY-SIMULATION-POC.md b/docs/DIRECTORY-SIMULATION-POC.md index 6ad3804..0e8eca7 100644 --- a/docs/DIRECTORY-SIMULATION-POC.md +++ b/docs/DIRECTORY-SIMULATION-POC.md @@ -374,6 +374,9 @@ no-population-intermediate rule. deprovisioned in Exchange Online, and nothing for anything else or for a host the deployment lacks. That is the question a mail server answers before it accepts a message as local or relays it. + These are provided and documented as an API only; the routing is used + later by HubSPO-rs and by SAP on Quack (`lance-graph-sap`), which bind + to it in their own repos. `MailboxLocation { host, kind, mailbox_guid }` carries what `Get-MailboxLocation` reports; `MailboxLocationType` is its seven values. Exchange names a location `TenantGUID\MailboxGUID`, the pair Graph