Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 73 additions & 3 deletions crates/ogar-az/src/mailbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +17,14 @@
//! 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`]) 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`
//!
//! Graph documents `primaryMailboxId` only as "the unique identifier for the
Expand Down Expand Up @@ -77,6 +85,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 {
Expand All @@ -87,6 +106,7 @@ impl Permission {
Self::MailboxSettingsRead => "MailboxSettings.Read",
Self::MailboxFolderReadAll => "MailboxFolder.Read.All",
Self::MailboxItemReadAll => "MailboxItem.Read.All",
Self::FilesReadAll => "Files.Read.All",
}
}
}
Expand Down Expand Up @@ -116,6 +136,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<'_> {
Expand All @@ -131,6 +157,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),
Expand All @@ -139,15 +166,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<Option<UserDrive>, 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.
Expand Down
32 changes: 32 additions & 0 deletions crates/ogar-az/tests/mailbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Loading
Loading