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
68 changes: 68 additions & 0 deletions crates/ogar-az/examples/mailbox_ingest.rs
Original file line number Diff line number Diff line change
@@ -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 <user-id>
//! curl -s -H "Authorization: Bearer $TOKEN" "<mailboxSettings url>" > settings.json
//! curl -s -H "Authorization: Bearer $TOKEN" "<settings/exchange url>" > exchange.json
//! cargo run -q -p ogar-az --example mailbox_ingest -- <user-id> <tenant-id> 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<String> = 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 <user-id> | <user-id> <tenant-id> <settings.json> <exchange.json>"
);
std::process::exit(2);
}
}
}
7 changes: 6 additions & 1 deletion crates/ogar-az/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
263 changes: 263 additions & 0 deletions crates/ogar-az/src/mailbox.rs
Original file line number Diff line number Diff line change
@@ -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<Guid128> {
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<DirRecord, AzError> {
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<Option<String>, 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<Guid128> {
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()
}
10 changes: 10 additions & 0 deletions crates/ogar-az/tests/fixtures/mailbox_settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading