Compare commits
6 Commits
master
...
feat/micro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0f87fa3d | ||
|
|
97b9739320 | ||
|
|
d600c775c2 | ||
|
|
c7f341885b | ||
|
|
71b16357f3 | ||
|
|
2dbe4e4812 |
@ -11,12 +11,12 @@ pub use schema::{
|
||||
ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig,
|
||||
GoogleTtsConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
|
||||
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig,
|
||||
McpServerConfig, McpTransport, MemoryConfig, ModelRouteConfig, MultimodalConfig,
|
||||
NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig, OtpConfig, OtpMethod,
|
||||
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QdrantConfig,
|
||||
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
|
||||
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig,
|
||||
SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
|
||||
MultimodalConfig, NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig,
|
||||
OtpConfig, OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope,
|
||||
QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
|
||||
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
|
||||
SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||
StorageProviderSection, StreamMode, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
|
||||
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
||||
};
|
||||
|
||||
@ -188,6 +188,10 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub composio: ComposioConfig,
|
||||
|
||||
/// Microsoft 365 Graph API integration (`[microsoft365]`).
|
||||
#[serde(default)]
|
||||
pub microsoft365: Microsoft365Config,
|
||||
|
||||
/// Secrets encryption configuration (`[secrets]`).
|
||||
#[serde(default)]
|
||||
pub secrets: SecretsConfig,
|
||||
@ -1282,6 +1286,78 @@ impl Default for ComposioConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Microsoft 365 (Graph API integration) ───────────────────────
|
||||
|
||||
/// Microsoft 365 integration via Microsoft Graph API (`[microsoft365]` section).
|
||||
///
|
||||
/// Provides access to Outlook mail, Teams messages, Calendar events,
|
||||
/// OneDrive files, and SharePoint search.
|
||||
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Microsoft365Config {
|
||||
/// Enable Microsoft 365 integration
|
||||
#[serde(default, alias = "enable")]
|
||||
pub enabled: bool,
|
||||
/// Azure AD tenant ID
|
||||
#[serde(default)]
|
||||
pub tenant_id: Option<String>,
|
||||
/// Azure AD application (client) ID
|
||||
#[serde(default)]
|
||||
pub client_id: Option<String>,
|
||||
/// Azure AD client secret (stored encrypted when secrets.encrypt = true)
|
||||
#[serde(default)]
|
||||
pub client_secret: Option<String>,
|
||||
/// Authentication flow: "client_credentials" or "device_code"
|
||||
#[serde(default = "default_ms365_auth_flow")]
|
||||
pub auth_flow: String,
|
||||
/// OAuth scopes to request
|
||||
#[serde(default = "default_ms365_scopes")]
|
||||
pub scopes: Vec<String>,
|
||||
/// Encrypt the token cache file on disk
|
||||
#[serde(default = "default_true")]
|
||||
pub token_cache_encrypted: bool,
|
||||
/// User principal name or "me" (for delegated flows)
|
||||
#[serde(default)]
|
||||
pub user_id: Option<String>,
|
||||
}
|
||||
|
||||
fn default_ms365_auth_flow() -> String {
|
||||
"client_credentials".to_string()
|
||||
}
|
||||
|
||||
fn default_ms365_scopes() -> Vec<String> {
|
||||
vec!["https://graph.microsoft.com/.default".to_string()]
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Microsoft365Config {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Microsoft365Config")
|
||||
.field("enabled", &self.enabled)
|
||||
.field("tenant_id", &self.tenant_id)
|
||||
.field("client_id", &self.client_id)
|
||||
.field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
|
||||
.field("auth_flow", &self.auth_flow)
|
||||
.field("scopes", &self.scopes)
|
||||
.field("token_cache_encrypted", &self.token_cache_encrypted)
|
||||
.field("user_id", &self.user_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Microsoft365Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
tenant_id: None,
|
||||
client_id: None,
|
||||
client_secret: None,
|
||||
auth_flow: default_ms365_auth_flow(),
|
||||
scopes: default_ms365_scopes(),
|
||||
token_cache_encrypted: true,
|
||||
user_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Secrets (encrypted credential store) ────────────────────────
|
||||
|
||||
/// Secrets encryption configuration (`[secrets]` section).
|
||||
@ -4186,6 +4262,7 @@ impl Default for Config {
|
||||
tunnel: TunnelConfig::default(),
|
||||
gateway: GatewayConfig::default(),
|
||||
composio: ComposioConfig::default(),
|
||||
microsoft365: Microsoft365Config::default(),
|
||||
secrets: SecretsConfig::default(),
|
||||
browser: BrowserConfig::default(),
|
||||
http_request: HttpRequestConfig::default(),
|
||||
@ -4679,6 +4756,11 @@ impl Config {
|
||||
&mut config.composio.api_key,
|
||||
"config.composio.api_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.microsoft365.client_secret,
|
||||
"config.microsoft365.client_secret",
|
||||
)?;
|
||||
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
@ -5227,6 +5309,63 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Microsoft 365
|
||||
if self.microsoft365.enabled {
|
||||
let tenant = self
|
||||
.microsoft365
|
||||
.tenant_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
if tenant.is_none() {
|
||||
anyhow::bail!(
|
||||
"microsoft365.tenant_id must not be empty when microsoft365 is enabled"
|
||||
);
|
||||
}
|
||||
// Validate tenant_id format to prevent URL injection in OAuth
|
||||
// endpoints. Azure AD tenant IDs are either UUIDs or verified
|
||||
// domain names (alphanumeric, hyphens, dots).
|
||||
if let Some(tid) = tenant {
|
||||
if !tid
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '.')
|
||||
{
|
||||
anyhow::bail!(
|
||||
"microsoft365.tenant_id contains invalid characters; \
|
||||
expected UUID or domain format (alphanumeric, hyphens, dots)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let client = self
|
||||
.microsoft365
|
||||
.client_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
if client.is_none() {
|
||||
anyhow::bail!(
|
||||
"microsoft365.client_id must not be empty when microsoft365 is enabled"
|
||||
);
|
||||
}
|
||||
let flow = self.microsoft365.auth_flow.trim();
|
||||
if flow != "client_credentials" && flow != "device_code" {
|
||||
anyhow::bail!(
|
||||
"microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
|
||||
);
|
||||
}
|
||||
if flow == "client_credentials"
|
||||
&& self
|
||||
.microsoft365
|
||||
.client_secret
|
||||
.as_deref()
|
||||
.map_or(true, |s| s.trim().is_empty())
|
||||
{
|
||||
anyhow::bail!(
|
||||
"microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// MCP
|
||||
if self.mcp.enabled {
|
||||
validate_mcp_config(&self.mcp)?;
|
||||
@ -5606,6 +5745,11 @@ impl Config {
|
||||
&mut config_to_save.composio.api_key,
|
||||
"config.composio.api_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config_to_save.microsoft365.client_secret,
|
||||
"config.microsoft365.client_secret",
|
||||
)?;
|
||||
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
@ -5950,6 +6094,7 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
async fn sync_directory(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@ -5975,6 +6120,7 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
#[allow(unused_imports)]
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::{Mutex, MutexGuard};
|
||||
use tokio::test;
|
||||
@ -6292,6 +6438,7 @@ default_temperature = 0.7
|
||||
tunnel: TunnelConfig::default(),
|
||||
gateway: GatewayConfig::default(),
|
||||
composio: ComposioConfig::default(),
|
||||
microsoft365: Microsoft365Config::default(),
|
||||
secrets: SecretsConfig::default(),
|
||||
browser: BrowserConfig::default(),
|
||||
http_request: HttpRequestConfig::default(),
|
||||
@ -6583,6 +6730,7 @@ tool_dispatcher = "xml"
|
||||
tunnel: TunnelConfig::default(),
|
||||
gateway: GatewayConfig::default(),
|
||||
composio: ComposioConfig::default(),
|
||||
microsoft365: Microsoft365Config::default(),
|
||||
secrets: SecretsConfig::default(),
|
||||
browser: BrowserConfig::default(),
|
||||
http_request: HttpRequestConfig::default(),
|
||||
|
||||
@ -159,6 +159,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
||||
tunnel: tunnel_config,
|
||||
gateway: crate::config::GatewayConfig::default(),
|
||||
composio: composio_config,
|
||||
microsoft365: crate::config::Microsoft365Config::default(),
|
||||
secrets: secrets_config,
|
||||
browser: BrowserConfig::default(),
|
||||
http_request: crate::config::HttpRequestConfig::default(),
|
||||
@ -516,6 +517,7 @@ async fn run_quick_setup_with_home(
|
||||
tunnel: crate::config::TunnelConfig::default(),
|
||||
gateway: crate::config::GatewayConfig::default(),
|
||||
composio: ComposioConfig::default(),
|
||||
microsoft365: crate::config::Microsoft365Config::default(),
|
||||
secrets: SecretsConfig::default(),
|
||||
browser: BrowserConfig::default(),
|
||||
http_request: crate::config::HttpRequestConfig::default(),
|
||||
|
||||
431
src/tools/microsoft365/auth.rs
Normal file
431
src/tools/microsoft365/auth.rs
Normal file
@ -0,0 +1,431 @@
|
||||
use anyhow::Context;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Cached OAuth2 token state persisted to disk between runs.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct CachedTokenState {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
/// Unix timestamp (seconds) when the access token expires.
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CachedTokenState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CachedTokenState")
|
||||
.field("access_token", &"***")
|
||||
.field("refresh_token", &self.refresh_token.as_ref().map(|_| "***"))
|
||||
.field("expires_at", &self.expires_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl CachedTokenState {
|
||||
/// Returns `true` when the token is expired or will expire within 60 seconds.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
self.expires_at <= now + 60
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe token cache with disk persistence.
|
||||
pub struct TokenCache {
|
||||
inner: RwLock<Option<CachedTokenState>>,
|
||||
/// Serialises the slow acquire/refresh path so only one caller performs the
|
||||
/// network round-trip while others wait and then read the updated cache.
|
||||
acquire_lock: Mutex<()>,
|
||||
config: super::types::Microsoft365ResolvedConfig,
|
||||
cache_path: PathBuf,
|
||||
}
|
||||
|
||||
impl TokenCache {
|
||||
pub fn new(
|
||||
config: super::types::Microsoft365ResolvedConfig,
|
||||
zeroclaw_dir: &std::path::Path,
|
||||
) -> anyhow::Result<Self> {
|
||||
if config.token_cache_encrypted {
|
||||
anyhow::bail!(
|
||||
"microsoft365: token_cache_encrypted is enabled but encryption is not yet \
|
||||
implemented; refusing to store tokens in plaintext. Set token_cache_encrypted \
|
||||
to false or wait for encryption support."
|
||||
);
|
||||
}
|
||||
|
||||
// Scope cache file to (tenant_id, client_id, auth_flow) so config
|
||||
// changes never reuse tokens from a different account/flow.
|
||||
// Use a deterministic fingerprint (not DefaultHasher which is unstable
|
||||
// across Rust versions and would orphan cache files on toolchain updates).
|
||||
let fingerprint = format!(
|
||||
"{}_{}_{}", config.tenant_id, config.client_id, config.auth_flow
|
||||
).replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "_");
|
||||
|
||||
let cache_path = zeroclaw_dir.join(format!("ms365_token_cache_{fingerprint}.json"));
|
||||
let cached = Self::load_from_disk(&cache_path);
|
||||
Ok(Self {
|
||||
inner: RwLock::new(cached),
|
||||
acquire_lock: Mutex::new(()),
|
||||
config,
|
||||
cache_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a valid access token, refreshing or re-authenticating as needed.
|
||||
pub async fn get_token(&self, client: &reqwest::Client) -> anyhow::Result<String> {
|
||||
// Fast path: cached and not expired.
|
||||
{
|
||||
let guard = self.inner.read();
|
||||
if let Some(ref state) = *guard {
|
||||
if !state.is_expired() {
|
||||
return Ok(state.access_token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: serialise through a mutex so only one caller performs the
|
||||
// network round-trip while concurrent callers wait and re-check.
|
||||
let _lock = self.acquire_lock.lock().await;
|
||||
|
||||
// Re-check after acquiring the lock — another caller may have refreshed
|
||||
// while we were waiting.
|
||||
{
|
||||
let guard = self.inner.read();
|
||||
if let Some(ref state) = *guard {
|
||||
if !state.is_expired() {
|
||||
return Ok(state.access_token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let new_state = self.acquire_token(client).await?;
|
||||
let token = new_state.access_token.clone();
|
||||
self.persist_to_disk(&new_state);
|
||||
*self.inner.write() = Some(new_state);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
async fn acquire_token(&self, client: &reqwest::Client) -> anyhow::Result<CachedTokenState> {
|
||||
// Try refresh first if we have a refresh token and the flow supports it.
|
||||
// Client credentials flow does not issue refresh tokens, so skip the
|
||||
// attempt entirely to avoid a wasted round-trip.
|
||||
if self.config.auth_flow.as_str() != "client_credentials" {
|
||||
// Clone the token out so the RwLock guard is dropped before the await.
|
||||
let refresh_token_copy = {
|
||||
let guard = self.inner.read();
|
||||
guard.as_ref().and_then(|state| state.refresh_token.clone())
|
||||
};
|
||||
if let Some(refresh_tok) = refresh_token_copy {
|
||||
match self.refresh_token(client, &refresh_tok).await {
|
||||
Ok(new_state) => return Ok(new_state),
|
||||
Err(e) => {
|
||||
tracing::debug!("ms365: refresh token failed, re-authenticating: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.config.auth_flow.as_str() {
|
||||
"client_credentials" => self.client_credentials_flow(client).await,
|
||||
"device_code" => self.device_code_flow(client).await,
|
||||
other => anyhow::bail!("Unsupported auth flow: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn client_credentials_flow(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
) -> anyhow::Result<CachedTokenState> {
|
||||
let client_secret = self
|
||||
.config
|
||||
.client_secret
|
||||
.as_deref()
|
||||
.context("client_credentials flow requires client_secret")?;
|
||||
|
||||
let token_url = format!(
|
||||
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
|
||||
self.config.tenant_id
|
||||
);
|
||||
|
||||
let scope = self.config.scopes.join(" ");
|
||||
|
||||
let resp = client
|
||||
.post(&token_url)
|
||||
.form(&[
|
||||
("grant_type", "client_credentials"),
|
||||
("client_id", &self.config.client_id),
|
||||
("client_secret", client_secret),
|
||||
("scope", &scope),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: failed to request client_credentials token")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::debug!("ms365: client_credentials raw OAuth error: {body}");
|
||||
anyhow::bail!("ms365: client_credentials token request failed ({status})");
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.context("ms365: failed to parse token response")?;
|
||||
|
||||
Ok(CachedTokenState {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token,
|
||||
expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in,
|
||||
})
|
||||
}
|
||||
|
||||
async fn device_code_flow(&self, client: &reqwest::Client) -> anyhow::Result<CachedTokenState> {
|
||||
let device_code_url = format!(
|
||||
"https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
|
||||
self.config.tenant_id
|
||||
);
|
||||
let scope = self.config.scopes.join(" ");
|
||||
|
||||
let resp = client
|
||||
.post(&device_code_url)
|
||||
.form(&[
|
||||
("client_id", self.config.client_id.as_str()),
|
||||
("scope", &scope),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: failed to request device code")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::debug!("ms365: device_code initiation raw error: {body}");
|
||||
anyhow::bail!("ms365: device code request failed ({status})");
|
||||
}
|
||||
|
||||
let device_resp: DeviceCodeResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.context("ms365: failed to parse device code response")?;
|
||||
|
||||
// Log only a generic prompt; the full device_resp.message may contain
|
||||
// sensitive verification URIs or codes that should not appear in logs.
|
||||
tracing::info!(
|
||||
"ms365: device code auth required — follow the instructions shown to the user"
|
||||
);
|
||||
// Print the user-facing message to stderr so the operator can act on it
|
||||
// without it being captured in structured log sinks.
|
||||
eprintln!("ms365: {}", device_resp.message);
|
||||
|
||||
let token_url = format!(
|
||||
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
|
||||
self.config.tenant_id
|
||||
);
|
||||
|
||||
let interval = device_resp.interval.max(5);
|
||||
let max_polls = u32::try_from(
|
||||
(device_resp.expires_in / i64::try_from(interval).unwrap_or(i64::MAX)).max(1),
|
||||
)
|
||||
.unwrap_or(u32::MAX);
|
||||
|
||||
for _ in 0..max_polls {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
|
||||
|
||||
let poll_resp = client
|
||||
.post(&token_url)
|
||||
.form(&[
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
("client_id", self.config.client_id.as_str()),
|
||||
("device_code", &device_resp.device_code),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: failed to poll device code token")?;
|
||||
|
||||
if poll_resp.status().is_success() {
|
||||
let token_resp: TokenResponse = poll_resp
|
||||
.json()
|
||||
.await
|
||||
.context("ms365: failed to parse token response")?;
|
||||
return Ok(CachedTokenState {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token,
|
||||
expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in,
|
||||
});
|
||||
}
|
||||
|
||||
let body = poll_resp.text().await.unwrap_or_default();
|
||||
if body.contains("authorization_pending") {
|
||||
continue;
|
||||
}
|
||||
if body.contains("slow_down") {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
tracing::debug!("ms365: device code polling raw error: {body}");
|
||||
anyhow::bail!("ms365: device code polling failed");
|
||||
}
|
||||
|
||||
anyhow::bail!("ms365: device code flow timed out waiting for user authorization")
|
||||
}
|
||||
|
||||
async fn refresh_token(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
refresh_token: &str,
|
||||
) -> anyhow::Result<CachedTokenState> {
|
||||
let token_url = format!(
|
||||
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
|
||||
self.config.tenant_id
|
||||
);
|
||||
|
||||
let mut params = vec![
|
||||
("grant_type", "refresh_token"),
|
||||
("client_id", self.config.client_id.as_str()),
|
||||
("refresh_token", refresh_token),
|
||||
];
|
||||
|
||||
let secret_ref;
|
||||
if let Some(ref secret) = self.config.client_secret {
|
||||
secret_ref = secret.as_str();
|
||||
params.push(("client_secret", secret_ref));
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(&token_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: failed to refresh token")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::debug!("ms365: token refresh raw error: {body}");
|
||||
anyhow::bail!("ms365: token refresh failed ({status})");
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.context("ms365: failed to parse refresh token response")?;
|
||||
|
||||
Ok(CachedTokenState {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp
|
||||
.refresh_token
|
||||
.or_else(|| Some(refresh_token.to_string())),
|
||||
expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_from_disk(path: &std::path::Path) -> Option<CachedTokenState> {
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
fn persist_to_disk(&self, state: &CachedTokenState) {
|
||||
if let Ok(json) = serde_json::to_string_pretty(state) {
|
||||
if let Err(e) = Self::write_restricted(&self.cache_path, &json) {
|
||||
tracing::warn!("ms365: failed to persist token cache: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write file with restricted permissions (0o600 on Unix) to prevent
|
||||
/// other local users from reading cached OAuth tokens.
|
||||
fn write_restricted(path: &std::path::Path, content: &str) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
file.write_all(content.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(path, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
refresh_token: Option<String>,
|
||||
#[serde(default = "default_expires_in")]
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
fn default_expires_in() -> i64 {
|
||||
3600
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeviceCodeResponse {
|
||||
device_code: String,
|
||||
message: String,
|
||||
#[serde(default = "default_device_interval")]
|
||||
interval: u64,
|
||||
#[serde(default = "default_device_expires_in")]
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
fn default_device_interval() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
fn default_device_expires_in() -> i64 {
|
||||
900
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_is_expired_when_past_deadline() {
|
||||
let state = CachedTokenState {
|
||||
access_token: "test".into(),
|
||||
refresh_token: None,
|
||||
expires_at: chrono::Utc::now().timestamp() - 10,
|
||||
};
|
||||
assert!(state.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_is_expired_within_buffer() {
|
||||
let state = CachedTokenState {
|
||||
access_token: "test".into(),
|
||||
refresh_token: None,
|
||||
expires_at: chrono::Utc::now().timestamp() + 30,
|
||||
};
|
||||
assert!(state.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_is_valid_when_far_from_expiry() {
|
||||
let state = CachedTokenState {
|
||||
access_token: "test".into(),
|
||||
refresh_token: None,
|
||||
expires_at: chrono::Utc::now().timestamp() + 3600,
|
||||
};
|
||||
assert!(!state.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_disk_returns_none_for_missing_file() {
|
||||
let path = std::path::Path::new("/nonexistent/ms365_token_cache.json");
|
||||
assert!(TokenCache::load_from_disk(path).is_none());
|
||||
}
|
||||
}
|
||||
516
src/tools/microsoft365/graph_client.rs
Normal file
516
src/tools/microsoft365/graph_client.rs
Normal file
@ -0,0 +1,516 @@
|
||||
use anyhow::Context;
|
||||
|
||||
const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
|
||||
|
||||
/// Build the user path segment: `/me` or `/users/{user_id}`.
|
||||
/// The user_id is percent-encoded to prevent path-traversal attacks.
|
||||
fn user_path(user_id: &str) -> String {
|
||||
if user_id == "me" {
|
||||
"/me".to_string()
|
||||
} else {
|
||||
format!("/users/{}", urlencoding::encode(user_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode a single path segment to prevent path-traversal attacks.
|
||||
fn encode_path_segment(segment: &str) -> String {
|
||||
urlencoding::encode(segment).into_owned()
|
||||
}
|
||||
|
||||
/// List mail messages for a user.
|
||||
pub async fn mail_list(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
folder: Option<&str>,
|
||||
top: u32,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let base = user_path(user_id);
|
||||
let path = match folder {
|
||||
Some(f) => format!(
|
||||
"{GRAPH_BASE}{base}/mailFolders/{}/messages",
|
||||
encode_path_segment(f)
|
||||
),
|
||||
None => format!("{GRAPH_BASE}{base}/messages"),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&path)
|
||||
.bearer_auth(token)
|
||||
.query(&[("$top", top.to_string())])
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: mail_list request failed")?;
|
||||
|
||||
handle_json_response(resp, "mail_list").await
|
||||
}
|
||||
|
||||
/// Send a mail message.
|
||||
pub async fn mail_send(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
to: &[String],
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let base = user_path(user_id);
|
||||
let url = format!("{GRAPH_BASE}{base}/sendMail");
|
||||
|
||||
let to_recipients: Vec<serde_json::Value> = to
|
||||
.iter()
|
||||
.map(|addr| {
|
||||
serde_json::json!({
|
||||
"emailAddress": { "address": addr }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"message": {
|
||||
"subject": subject,
|
||||
"body": {
|
||||
"contentType": "Text",
|
||||
"content": body
|
||||
},
|
||||
"toRecipients": to_recipients
|
||||
}
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: mail_send request failed")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||
tracing::debug!("ms365: mail_send raw error body: {body}");
|
||||
anyhow::bail!("ms365: mail_send failed ({status}, code={code})");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List messages in a Teams channel.
|
||||
pub async fn teams_message_list(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
team_id: &str,
|
||||
channel_id: &str,
|
||||
top: u32,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let url = format!(
|
||||
"{GRAPH_BASE}/teams/{}/channels/{}/messages",
|
||||
encode_path_segment(team_id),
|
||||
encode_path_segment(channel_id)
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(token)
|
||||
.query(&[("$top", top.to_string())])
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: teams_message_list request failed")?;
|
||||
|
||||
handle_json_response(resp, "teams_message_list").await
|
||||
}
|
||||
|
||||
/// Send a message to a Teams channel.
|
||||
pub async fn teams_message_send(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
team_id: &str,
|
||||
channel_id: &str,
|
||||
body: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let url = format!(
|
||||
"{GRAPH_BASE}/teams/{}/channels/{}/messages",
|
||||
encode_path_segment(team_id),
|
||||
encode_path_segment(channel_id)
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"body": {
|
||||
"content": body
|
||||
}
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: teams_message_send request failed")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||
tracing::debug!("ms365: teams_message_send raw error body: {body}");
|
||||
anyhow::bail!("ms365: teams_message_send failed ({status}, code={code})");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List calendar events in a date range.
|
||||
pub async fn calendar_events_list(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
start: &str,
|
||||
end: &str,
|
||||
top: u32,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let base = user_path(user_id);
|
||||
let url = format!("{GRAPH_BASE}{base}/calendarView");
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(token)
|
||||
.query(&[
|
||||
("startDateTime", start.to_string()),
|
||||
("endDateTime", end.to_string()),
|
||||
("$top", top.to_string()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: calendar_events_list request failed")?;
|
||||
|
||||
handle_json_response(resp, "calendar_events_list").await
|
||||
}
|
||||
|
||||
/// Create a calendar event.
|
||||
pub async fn calendar_event_create(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
subject: &str,
|
||||
start: &str,
|
||||
end: &str,
|
||||
attendees: &[String],
|
||||
body_text: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
let base = user_path(user_id);
|
||||
let url = format!("{GRAPH_BASE}{base}/events");
|
||||
|
||||
let attendee_list: Vec<serde_json::Value> = attendees
|
||||
.iter()
|
||||
.map(|email| {
|
||||
serde_json::json!({
|
||||
"emailAddress": { "address": email },
|
||||
"type": "required"
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"subject": subject,
|
||||
"start": {
|
||||
"dateTime": start,
|
||||
"timeZone": "UTC"
|
||||
},
|
||||
"end": {
|
||||
"dateTime": end,
|
||||
"timeZone": "UTC"
|
||||
},
|
||||
"attendees": attendee_list
|
||||
});
|
||||
|
||||
if let Some(text) = body_text {
|
||||
payload["body"] = serde_json::json!({
|
||||
"contentType": "Text",
|
||||
"content": text
|
||||
});
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: calendar_event_create request failed")?;
|
||||
|
||||
let value = handle_json_response(resp, "calendar_event_create").await?;
|
||||
let event_id = value["id"].as_str().unwrap_or("unknown").to_string();
|
||||
Ok(event_id)
|
||||
}
|
||||
|
||||
/// Delete a calendar event by ID.
|
||||
pub async fn calendar_event_delete(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
event_id: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let base = user_path(user_id);
|
||||
let url = format!(
|
||||
"{GRAPH_BASE}{base}/events/{}",
|
||||
encode_path_segment(event_id)
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.delete(&url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: calendar_event_delete request failed")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||
tracing::debug!("ms365: calendar_event_delete raw error body: {body}");
|
||||
anyhow::bail!("ms365: calendar_event_delete failed ({status}, code={code})");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List children of a OneDrive folder.
|
||||
pub async fn onedrive_list(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
path: Option<&str>,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let base = user_path(user_id);
|
||||
let url = match path {
|
||||
Some(p) if !p.is_empty() => {
|
||||
// Encode each path component individually, preserving '/' separators.
|
||||
// Graph API expects literal '/' in the root:/{path}: syntax.
|
||||
let encoded: String = p
|
||||
.split('/')
|
||||
.map(|seg| urlencoding::encode(seg).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children")
|
||||
}
|
||||
_ => format!("{GRAPH_BASE}{base}/drive/root/children"),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: onedrive_list request failed")?;
|
||||
|
||||
handle_json_response(resp, "onedrive_list").await
|
||||
}
|
||||
|
||||
/// Download a OneDrive item by ID, with a maximum size guard.
|
||||
pub async fn onedrive_download(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
item_id: &str,
|
||||
max_size: usize,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let base = user_path(user_id);
|
||||
let url = format!(
|
||||
"{GRAPH_BASE}{base}/drive/items/{}/content",
|
||||
encode_path_segment(item_id)
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: onedrive_download request failed")?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||
tracing::debug!("ms365: onedrive_download raw error body: {body}");
|
||||
anyhow::bail!("ms365: onedrive_download failed ({status}, code={code})");
|
||||
}
|
||||
|
||||
// Check Content-Length header first to reject oversized files before
|
||||
// buffering the entire response body into memory.
|
||||
if let Some(content_length) = resp.content_length() {
|
||||
if usize::try_from(content_length).unwrap_or(usize::MAX) > max_size {
|
||||
anyhow::bail!(
|
||||
"ms365: file Content-Length exceeds max_size ({content_length} > {max_size})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.context("ms365: failed to read download body")?;
|
||||
if bytes.len() > max_size {
|
||||
anyhow::bail!(
|
||||
"ms365: downloaded file exceeds max_size ({} > {max_size})",
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// Search SharePoint for documents matching a query.
|
||||
pub async fn sharepoint_search(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
query: &str,
|
||||
top: u32,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let url = format!("{GRAPH_BASE}/search/query");
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"requests": [{
|
||||
"entityTypes": ["driveItem", "listItem", "site"],
|
||||
"query": {
|
||||
"queryString": query
|
||||
},
|
||||
"from": 0,
|
||||
"size": top
|
||||
}]
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.bearer_auth(token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.context("ms365: sharepoint_search request failed")?;
|
||||
|
||||
handle_json_response(resp, "sharepoint_search").await
|
||||
}
|
||||
|
||||
/// Extract a short, safe error code from a Graph API JSON error body.
|
||||
/// Returns `None` when the body is not a recognised Graph error envelope.
|
||||
fn extract_graph_error_code(body: &str) -> Option<String> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
let code = parsed
|
||||
.get("error")
|
||||
.and_then(|e| e.get("code"))
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string());
|
||||
code
|
||||
}
|
||||
|
||||
/// Parse a JSON response body, returning an error on non-success status.
|
||||
/// Raw Graph API error bodies are not propagated; only the HTTP status and a
|
||||
/// short error code (when available) are surfaced to avoid leaking internal
|
||||
/// API details.
|
||||
async fn handle_json_response(
|
||||
resp: reqwest::Response,
|
||||
operation: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
|
||||
tracing::debug!("ms365: {operation} raw error body: {body}");
|
||||
anyhow::bail!("ms365: {operation} failed ({status}, code={code})");
|
||||
}
|
||||
|
||||
resp.json()
|
||||
.await
|
||||
.with_context(|| format!("ms365: failed to parse {operation} response"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_path_me() {
|
||||
assert_eq!(user_path("me"), "/me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_path_specific_user() {
|
||||
assert_eq!(user_path("user@contoso.com"), "/users/user%40contoso.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mail_list_url_no_folder() {
|
||||
let base = user_path("me");
|
||||
let url = format!("{GRAPH_BASE}{base}/messages");
|
||||
assert_eq!(url, "https://graph.microsoft.com/v1.0/me/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mail_list_url_with_folder() {
|
||||
let base = user_path("me");
|
||||
let folder = "inbox";
|
||||
let url = format!(
|
||||
"{GRAPH_BASE}{base}/mailFolders/{}/messages",
|
||||
encode_path_segment(folder)
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_view_url() {
|
||||
let base = user_path("user@example.com");
|
||||
let url = format!("{GRAPH_BASE}{base}/calendarView");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://graph.microsoft.com/v1.0/users/user%40example.com/calendarView"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teams_message_url() {
|
||||
let url = format!(
|
||||
"{GRAPH_BASE}/teams/{}/channels/{}/messages",
|
||||
encode_path_segment("team-123"),
|
||||
encode_path_segment("channel-456")
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://graph.microsoft.com/v1.0/teams/team-123/channels/channel-456/messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onedrive_root_url() {
|
||||
let base = user_path("me");
|
||||
let url = format!("{GRAPH_BASE}{base}/drive/root/children");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://graph.microsoft.com/v1.0/me/drive/root/children"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onedrive_path_url() {
|
||||
let base = user_path("me");
|
||||
let path = "Documents/Reports";
|
||||
let encoded: String = path
|
||||
.split('/')
|
||||
.map(|seg| urlencoding::encode(seg).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
let url = format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://graph.microsoft.com/v1.0/me/drive/root:/Documents/Reports:/children"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharepoint_search_url() {
|
||||
let url = format!("{GRAPH_BASE}/search/query");
|
||||
assert_eq!(url, "https://graph.microsoft.com/v1.0/search/query");
|
||||
}
|
||||
}
|
||||
567
src/tools/microsoft365/mod.rs
Normal file
567
src/tools/microsoft365/mod.rs
Normal file
@ -0,0 +1,567 @@
|
||||
//! Microsoft 365 integration tool — Graph API access for Mail, Teams, Calendar,
|
||||
//! OneDrive, and SharePoint via a single action-dispatched tool surface.
|
||||
//!
|
||||
//! Auth is handled through direct HTTP calls to the Microsoft identity platform
|
||||
//! (client credentials or device code flow) with token caching.
|
||||
|
||||
pub mod auth;
|
||||
pub mod graph_client;
|
||||
pub mod types;
|
||||
|
||||
use crate::security::policy::ToolOperation;
|
||||
use crate::security::SecurityPolicy;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Maximum download size for OneDrive files (10 MB).
|
||||
const MAX_ONEDRIVE_DOWNLOAD_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// Default number of items to return in list operations.
|
||||
const DEFAULT_TOP: u32 = 25;
|
||||
|
||||
pub struct Microsoft365Tool {
|
||||
config: types::Microsoft365ResolvedConfig,
|
||||
security: Arc<SecurityPolicy>,
|
||||
token_cache: Arc<auth::TokenCache>,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Microsoft365Tool {
|
||||
pub fn new(
|
||||
config: types::Microsoft365ResolvedConfig,
|
||||
security: Arc<SecurityPolicy>,
|
||||
zeroclaw_dir: &std::path::Path,
|
||||
) -> anyhow::Result<Self> {
|
||||
let http_client =
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("tool.microsoft365", 60, 10);
|
||||
let token_cache = Arc::new(auth::TokenCache::new(config.clone(), zeroclaw_dir)?);
|
||||
Ok(Self {
|
||||
config,
|
||||
security,
|
||||
token_cache,
|
||||
http_client,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_token(&self) -> anyhow::Result<String> {
|
||||
self.token_cache.get_token(&self.http_client).await
|
||||
}
|
||||
|
||||
fn user_id(&self) -> &str {
|
||||
&self.config.user_id
|
||||
}
|
||||
|
||||
async fn dispatch(&self, action: &str, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
match action {
|
||||
"mail_list" => self.handle_mail_list(args).await,
|
||||
"mail_send" => self.handle_mail_send(args).await,
|
||||
"teams_message_list" => self.handle_teams_message_list(args).await,
|
||||
"teams_message_send" => self.handle_teams_message_send(args).await,
|
||||
"calendar_events_list" => self.handle_calendar_events_list(args).await,
|
||||
"calendar_event_create" => self.handle_calendar_event_create(args).await,
|
||||
"calendar_event_delete" => self.handle_calendar_event_delete(args).await,
|
||||
"onedrive_list" => self.handle_onedrive_list(args).await,
|
||||
"onedrive_download" => self.handle_onedrive_download(args).await,
|
||||
"sharepoint_search" => self.handle_sharepoint_search(args).await,
|
||||
_ => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Unknown action: {action}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read actions ────────────────────────────────────────────────
|
||||
|
||||
async fn handle_mail_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Read, "microsoft365.mail_list")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let folder = args["folder"].as_str();
|
||||
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
|
||||
.unwrap_or(DEFAULT_TOP);
|
||||
|
||||
let result =
|
||||
graph_client::mail_list(&self.http_client, &token, self.user_id(), folder, top).await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&result)?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_teams_message_list(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Read, "microsoft365.teams_message_list")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let team_id = args["team_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("team_id is required"))?;
|
||||
let channel_id = args["channel_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("channel_id is required"))?;
|
||||
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
|
||||
.unwrap_or(DEFAULT_TOP);
|
||||
|
||||
let result =
|
||||
graph_client::teams_message_list(&self.http_client, &token, team_id, channel_id, top)
|
||||
.await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&result)?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_calendar_events_list(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Read, "microsoft365.calendar_events_list")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let start = args["start"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("start datetime is required"))?;
|
||||
let end = args["end"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("end datetime is required"))?;
|
||||
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
|
||||
.unwrap_or(DEFAULT_TOP);
|
||||
|
||||
let result = graph_client::calendar_events_list(
|
||||
&self.http_client,
|
||||
&token,
|
||||
self.user_id(),
|
||||
start,
|
||||
end,
|
||||
top,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&result)?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_onedrive_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_list")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let path = args["path"].as_str();
|
||||
|
||||
let result =
|
||||
graph_client::onedrive_list(&self.http_client, &token, self.user_id(), path).await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&result)?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_onedrive_download(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_download")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let item_id = args["item_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("item_id is required"))?;
|
||||
let max_size = args["max_size"]
|
||||
.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.unwrap_or(MAX_ONEDRIVE_DOWNLOAD_SIZE)
|
||||
.min(MAX_ONEDRIVE_DOWNLOAD_SIZE);
|
||||
|
||||
let bytes = graph_client::onedrive_download(
|
||||
&self.http_client,
|
||||
&token,
|
||||
self.user_id(),
|
||||
item_id,
|
||||
max_size,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Return base64-encoded for binary safety.
|
||||
use base64::Engine;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!(
|
||||
"Downloaded {} bytes (base64 encoded):\n{encoded}",
|
||||
bytes.len()
|
||||
),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_sharepoint_search(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Read, "microsoft365.sharepoint_search")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let query = args["query"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("query is required"))?;
|
||||
let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
|
||||
.unwrap_or(DEFAULT_TOP);
|
||||
|
||||
let result = graph_client::sharepoint_search(&self.http_client, &token, query, top).await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&result)?,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Write actions ───────────────────────────────────────────────
|
||||
|
||||
async fn handle_mail_send(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Act, "microsoft365.mail_send")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let to: Vec<String> = args["to"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("to must be an array of email addresses"))?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect();
|
||||
|
||||
if to.is_empty() {
|
||||
anyhow::bail!("to must contain at least one email address");
|
||||
}
|
||||
|
||||
let subject = args["subject"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("subject is required"))?;
|
||||
let body = args["body"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("body is required"))?;
|
||||
|
||||
graph_client::mail_send(
|
||||
&self.http_client,
|
||||
&token,
|
||||
self.user_id(),
|
||||
&to,
|
||||
subject,
|
||||
body,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Email sent to: {}", to.join(", ")),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_teams_message_send(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Act, "microsoft365.teams_message_send")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let team_id = args["team_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("team_id is required"))?;
|
||||
let channel_id = args["channel_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("channel_id is required"))?;
|
||||
let body = args["body"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("body is required"))?;
|
||||
|
||||
graph_client::teams_message_send(&self.http_client, &token, team_id, channel_id, body)
|
||||
.await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: "Teams message sent".to_string(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_calendar_event_create(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_create")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let subject = args["subject"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("subject is required"))?;
|
||||
let start = args["start"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("start datetime is required"))?;
|
||||
let end = args["end"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("end datetime is required"))?;
|
||||
let attendees: Vec<String> = args["attendees"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let body_text = args["body"].as_str();
|
||||
|
||||
let event_id = graph_client::calendar_event_create(
|
||||
&self.http_client,
|
||||
&token,
|
||||
self.user_id(),
|
||||
subject,
|
||||
start,
|
||||
end,
|
||||
&attendees,
|
||||
body_text,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Calendar event created (id: {event_id})"),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_calendar_event_delete(
|
||||
&self,
|
||||
args: &serde_json::Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
self.security
|
||||
.enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_delete")
|
||||
.map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
let token = self.get_token().await?;
|
||||
let event_id = args["event_id"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("event_id is required"))?;
|
||||
|
||||
graph_client::calendar_event_delete(&self.http_client, &token, self.user_id(), event_id)
|
||||
.await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Calendar event {event_id} deleted"),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for Microsoft365Tool {
|
||||
fn name(&self) -> &str {
|
||||
"microsoft365"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Microsoft 365 integration: manage Outlook mail, Teams messages, Calendar events, \
|
||||
OneDrive files, and SharePoint search via Microsoft Graph API"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"mail_list",
|
||||
"mail_send",
|
||||
"teams_message_list",
|
||||
"teams_message_send",
|
||||
"calendar_events_list",
|
||||
"calendar_event_create",
|
||||
"calendar_event_delete",
|
||||
"onedrive_list",
|
||||
"onedrive_download",
|
||||
"sharepoint_search"
|
||||
],
|
||||
"description": "The Microsoft 365 action to perform"
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"description": "Mail folder ID (for mail_list, e.g. 'inbox', 'sentitems')"
|
||||
},
|
||||
"to": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Recipient email addresses (for mail_send)"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Email subject or calendar event subject"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Message body text"
|
||||
},
|
||||
"team_id": {
|
||||
"type": "string",
|
||||
"description": "Teams team ID (for teams_message_list/send)"
|
||||
},
|
||||
"channel_id": {
|
||||
"type": "string",
|
||||
"description": "Teams channel ID (for teams_message_list/send)"
|
||||
},
|
||||
"start": {
|
||||
"type": "string",
|
||||
"description": "Start datetime in ISO 8601 format (for calendar actions)"
|
||||
},
|
||||
"end": {
|
||||
"type": "string",
|
||||
"description": "End datetime in ISO 8601 format (for calendar actions)"
|
||||
},
|
||||
"attendees": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Attendee email addresses (for calendar_event_create)"
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "Calendar event ID (for calendar_event_delete)"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "OneDrive folder path (for onedrive_list)"
|
||||
},
|
||||
"item_id": {
|
||||
"type": "string",
|
||||
"description": "OneDrive item ID (for onedrive_download)"
|
||||
},
|
||||
"max_size": {
|
||||
"type": "integer",
|
||||
"description": "Maximum download size in bytes (for onedrive_download, default 10MB)"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query (for sharepoint_search)"
|
||||
},
|
||||
"top": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of items to return (default 25)"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let action = match args["action"].as_str() {
|
||||
Some(a) => a.to_string(),
|
||||
None => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("'action' parameter is required".to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
match self.dispatch(&action, &args).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("microsoft365.{action} failed: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tool_name_is_microsoft365() {
|
||||
// Verify the schema is valid JSON with the expected structure.
|
||||
let schema_str = r#"{"type":"object","required":["action"]}"#;
|
||||
let _: serde_json::Value = serde_json::from_str(schema_str).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_schema_has_action_enum() {
|
||||
let schema = json!({
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"mail_list",
|
||||
"mail_send",
|
||||
"teams_message_list",
|
||||
"teams_message_send",
|
||||
"calendar_events_list",
|
||||
"calendar_event_create",
|
||||
"calendar_event_delete",
|
||||
"onedrive_list",
|
||||
"onedrive_download",
|
||||
"sharepoint_search"
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
|
||||
assert_eq!(actions.len(), 10);
|
||||
assert!(actions.contains(&json!("mail_list")));
|
||||
assert!(actions.contains(&json!("sharepoint_search")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_dispatch_table_is_exhaustive() {
|
||||
let valid_actions = [
|
||||
"mail_list",
|
||||
"mail_send",
|
||||
"teams_message_list",
|
||||
"teams_message_send",
|
||||
"calendar_events_list",
|
||||
"calendar_event_create",
|
||||
"calendar_event_delete",
|
||||
"onedrive_list",
|
||||
"onedrive_download",
|
||||
"sharepoint_search",
|
||||
];
|
||||
assert_eq!(valid_actions.len(), 10);
|
||||
assert!(!valid_actions.contains(&"invalid_action"));
|
||||
}
|
||||
}
|
||||
55
src/tools/microsoft365/types.rs
Normal file
55
src/tools/microsoft365/types.rs
Normal file
@ -0,0 +1,55 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Resolved Microsoft 365 configuration with all secrets decrypted and defaults applied.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Microsoft365ResolvedConfig {
|
||||
pub tenant_id: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub auth_flow: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub token_cache_encrypted: bool,
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Microsoft365ResolvedConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Microsoft365ResolvedConfig")
|
||||
.field("tenant_id", &self.tenant_id)
|
||||
.field("client_id", &self.client_id)
|
||||
.field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
|
||||
.field("auth_flow", &self.auth_flow)
|
||||
.field("scopes", &self.scopes)
|
||||
.field("token_cache_encrypted", &self.token_cache_encrypted)
|
||||
.field("user_id", &self.user_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolved_config_serialization_roundtrip() {
|
||||
let config = Microsoft365ResolvedConfig {
|
||||
tenant_id: "test-tenant".into(),
|
||||
client_id: "test-client".into(),
|
||||
client_secret: Some("secret".into()),
|
||||
auth_flow: "client_credentials".into(),
|
||||
scopes: vec!["https://graph.microsoft.com/.default".into()],
|
||||
token_cache_encrypted: false,
|
||||
user_id: "me".into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let parsed: Microsoft365ResolvedConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.tenant_id, "test-tenant");
|
||||
assert_eq!(parsed.client_id, "test-client");
|
||||
assert_eq!(parsed.client_secret.as_deref(), Some("secret"));
|
||||
assert_eq!(parsed.auth_flow, "client_credentials");
|
||||
assert_eq!(parsed.scopes.len(), 1);
|
||||
assert_eq!(parsed.user_id, "me");
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,7 @@ pub mod mcp_transport;
|
||||
pub mod memory_forget;
|
||||
pub mod memory_recall;
|
||||
pub mod memory_store;
|
||||
pub mod microsoft365;
|
||||
pub mod model_routing_config;
|
||||
pub mod node_tool;
|
||||
pub mod pdf_read;
|
||||
@ -92,6 +93,7 @@ pub use mcp_tool::McpToolWrapper;
|
||||
pub use memory_forget::MemoryForgetTool;
|
||||
pub use memory_recall::MemoryRecallTool;
|
||||
pub use memory_store::MemoryStoreTool;
|
||||
pub use microsoft365::Microsoft365Tool;
|
||||
pub use model_routing_config::ModelRoutingConfigTool;
|
||||
#[allow(unused_imports)]
|
||||
pub use node_tool::NodeTool;
|
||||
@ -356,6 +358,61 @@ pub fn all_tools_with_runtime(
|
||||
}
|
||||
}
|
||||
|
||||
// Microsoft 365 Graph API integration
|
||||
if root_config.microsoft365.enabled {
|
||||
let ms_cfg = &root_config.microsoft365;
|
||||
let tenant_id = ms_cfg
|
||||
.tenant_id
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let client_id = ms_cfg
|
||||
.client_id
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if !tenant_id.is_empty() && !client_id.is_empty() {
|
||||
// Fail fast: client_credentials flow requires a client_secret at registration time.
|
||||
if ms_cfg.auth_flow.trim() == "client_credentials"
|
||||
&& ms_cfg
|
||||
.client_secret
|
||||
.as_deref()
|
||||
.map_or(true, |s| s.trim().is_empty())
|
||||
{
|
||||
tracing::error!(
|
||||
"microsoft365: client_credentials auth_flow requires a non-empty client_secret"
|
||||
);
|
||||
return (boxed_registry_from_arcs(tool_arcs), None);
|
||||
}
|
||||
|
||||
let resolved = microsoft365::types::Microsoft365ResolvedConfig {
|
||||
tenant_id,
|
||||
client_id,
|
||||
client_secret: ms_cfg.client_secret.clone(),
|
||||
auth_flow: ms_cfg.auth_flow.clone(),
|
||||
scopes: ms_cfg.scopes.clone(),
|
||||
token_cache_encrypted: ms_cfg.token_cache_encrypted,
|
||||
user_id: ms_cfg.user_id.as_deref().unwrap_or("me").to_string(),
|
||||
};
|
||||
// Store token cache in the config directory (next to config.toml),
|
||||
// not the workspace directory, to keep bearer tokens out of the
|
||||
// project tree.
|
||||
let cache_dir = root_config.config_path.parent().unwrap_or(workspace_dir);
|
||||
match Microsoft365Tool::new(resolved, security.clone(), cache_dir) {
|
||||
Ok(tool) => tool_arcs.push(Arc::new(tool)),
|
||||
Err(e) => {
|
||||
tracing::error!("microsoft365: failed to initialize tool: {e}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"microsoft365: skipped registration because tenant_id or client_id is empty"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add delegation tool when agents are configured
|
||||
let delegate_handle: Option<DelegateParentToolsHandle> = if agents.is_empty() {
|
||||
None
|
||||
|
||||
Loading…
Reference in New Issue
Block a user