Modules/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1
|
function New-IdleExchangeOnlineProvider { <# .SYNOPSIS Creates an Exchange Online mailbox provider for IdLE. .DESCRIPTION This provider integrates with Exchange Online for mailbox lifecycle management operations. It supports mailbox reporting, type conversions, and Out of Office configuration management. The provider implements the mailbox-specific provider contract used by IdLE.Steps.Mailbox. Identity addressing: - UserPrincipalName (UPN) - preferred - Primary SMTP address (email) - Mailbox GUID (for deterministic operations) The canonical identity key for all outputs is the primary SMTP address. Authentication: Provider methods accept an optional AuthSession parameter for runtime credential selection via the AuthSessionBroker. The provider supports multiple auth session formats: - String access token (for future Graph API integration) - Object with AccessToken property - Object with GetAccessToken() method - PSCredential (for certificate-based auth) By default, mailbox steps should use: - With.AuthSessionName = 'ExchangeOnline' - With.AuthSessionOptions = @{ Role = 'Admin' } (or other routing keys) Prerequisites: - ExchangeOnlineManagement PowerShell module must be installed - For app-only (certificate) auth: Windows platform required (MVP limitation) - Authenticated session must be established before using provider methods .PARAMETER Adapter Internal parameter for dependency injection during testing. Allows unit tests to inject a fake adapter without requiring a real Exchange Online environment. .EXAMPLE # Basic usage with delegated auth # Host establishes connection first Connect-ExchangeOnline -UserPrincipalName admin@contoso.com $provider = New-IdleExchangeOnlineProvider $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ ExchangeOnline = $provider } .EXAMPLE # Certificate-based app-only auth (Windows only) # Host establishes connection first Connect-ExchangeOnline -CertificateThumbprint $thumbprint -AppId $appId -Organization $tenantId $provider = New-IdleExchangeOnlineProvider $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ ExchangeOnline = $provider } .OUTPUTS PSCustomObject with IdLE mailbox provider contract methods .NOTES Requires Exchange Online Management module and appropriate permissions: - Exchange.ManageAsApp (app-only) - Exchange Administrator or Global Administrator role (delegated) - Required role: Mail Recipients (manage mailboxes) See the IdLE provider documentation for detailed setup. #> [CmdletBinding()] param( [Parameter()] [AllowNull()] [object] $Adapter ) # Check prerequisites and emit warnings if required components are missing $prereqs = Test-IdleExchangeOnlinePrerequisites if (-not $prereqs.IsHealthy) { foreach ($missing in $prereqs.MissingRequired) { Write-Warning "ExchangeOnline provider prerequisite check: Required component '$missing' is not available." } foreach ($note in $prereqs.Notes) { Write-Warning "ExchangeOnline provider prerequisite check: $note" } } if ($null -eq $Adapter) { $Adapter = New-IdleExchangeOnlineAdapter } $extractAccessToken = { param( [Parameter()] [AllowNull()] [object] $AuthSession ) # Validate prerequisites only when using the real (default) adapter # Skip validation if a fake adapter is injected for tests # Check TypeNames collection (PSTypeName in hashtable adds to TypeNames, not as a property) $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.ExchangeOnlineAdapter') if ($isRealAdapter) { $prereqCheck = Test-IdleExchangeOnlinePrerequisites if (-not $prereqCheck.IsHealthy) { $missingList = $prereqCheck.MissingRequired -join ', ' $errorMsg = "ExchangeOnline provider operation cannot proceed. Required prerequisite(s) missing: $missingList" if ($prereqCheck.Notes.Count -gt 0) { $errorMsg += "`n" + ($prereqCheck.Notes -join "`n") } throw $errorMsg } } if ($null -eq $AuthSession) { # For tests/development, allow null but commands will use existing session return $null } # String token (for future Graph API integration) if ($AuthSession -is [string]) { return $AuthSession } # Object with AccessToken property $hasAccessTokenProperty = $null -ne ($AuthSession.PSObject.Properties | Where-Object { $_.Name -eq 'AccessToken' }) if ($hasAccessTokenProperty) { return $AuthSession.AccessToken } # Object with GetAccessToken() method $hasGetAccessTokenMethod = $null -ne ($AuthSession.PSObject.Methods | Where-Object { $_.Name -eq 'GetAccessToken' }) if ($hasGetAccessTokenMethod) { return $AuthSession.GetAccessToken() } # PSCredential (for certificate-based auth) if ($AuthSession -is [PSCredential]) { # Certificate thumbprint might be in password field return $AuthSession.GetNetworkCredential().Password } # Default: allow null for existing session-based commands return $null } $normalizeMailboxType = { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $RecipientTypeDetails ) # Map Exchange RecipientTypeDetails to simplified types switch -Regex ($RecipientTypeDetails) { '^UserMailbox$|^LinkedMailbox$|^RemoteUserMailbox$' { return 'User' } '^SharedMailbox$|^RemoteSharedMailbox$' { return 'Shared' } '^RoomMailbox$|^RemoteRoomMailbox$' { return 'Room' } '^EquipmentMailbox$|^RemoteEquipmentMailbox$' { return 'Equipment' } default { return $RecipientTypeDetails } } } $provider = [pscustomobject]@{ PSTypeName = 'IdLE.Provider.ExchangeOnlineProvider' Name = 'ExchangeOnlineProvider' Adapter = $Adapter } $provider | Add-Member -MemberType ScriptMethod -Name ExtractAccessToken -Value $extractAccessToken -Force $provider | Add-Member -MemberType ScriptMethod -Name NormalizeMailboxType -Value $normalizeMailboxType -Force $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { $caps = @( 'IdLE.Mailbox.Info.Read' 'IdLE.Mailbox.Type.Ensure' 'IdLE.Mailbox.OutOfOffice.Ensure' ) return $caps } -Force # GetMailbox: Retrieve mailbox details $provider | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter()] [AllowNull()] [object] $AuthSession ) $accessToken = $this.ExtractAccessToken($AuthSession) $mailbox = $this.Adapter.GetMailbox($IdentityKey, $accessToken) if ($null -eq $mailbox) { throw "Mailbox '$IdentityKey' not found." } # Normalize mailbox type $normalizedType = $this.NormalizeMailboxType($mailbox['RecipientTypeDetails']) # Return structured mailbox data return [pscustomobject]@{ PSTypeName = 'IdLE.Mailbox' IdentityKey = [string]$mailbox['PrimarySmtpAddress'] PrimarySmtpAddress = [string]$mailbox['PrimarySmtpAddress'] UserPrincipalName = [string]$mailbox['UserPrincipalName'] DisplayName = [string]$mailbox['DisplayName'] Type = $normalizedType RecipientType = [string]$mailbox['RecipientType'] RecipientTypeDetails = [string]$mailbox['RecipientTypeDetails'] Guid = [string]$mailbox['Guid'] } } -Force # EnsureMailboxType: Idempotent mailbox type conversion $provider | Add-Member -MemberType ScriptMethod -Name EnsureMailboxType -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter(Mandatory)] [ValidateSet('User', 'Shared', 'Room', 'Equipment')] [string] $DesiredType, [Parameter()] [AllowNull()] [object] $AuthSession ) $accessToken = $this.ExtractAccessToken($AuthSession) # Get current mailbox state $mailbox = $this.GetMailbox($IdentityKey, $AuthSession) $currentType = $mailbox.Type # Check idempotency if ($currentType -eq $DesiredType) { return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'EnsureMailboxType' IdentityKey = $mailbox.PrimarySmtpAddress Changed = $false Type = $DesiredType } } # Perform conversion $this.Adapter.SetMailboxType($mailbox.PrimarySmtpAddress, $DesiredType, $accessToken) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'EnsureMailboxType' IdentityKey = $mailbox.PrimarySmtpAddress Changed = $true Type = $DesiredType } } -Force # GetOutOfOffice: Retrieve Out of Office configuration $provider | Add-Member -MemberType ScriptMethod -Name GetOutOfOffice -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter()] [AllowNull()] [object] $AuthSession ) $accessToken = $this.ExtractAccessToken($AuthSession) # Verify mailbox exists first $mailbox = $this.GetMailbox($IdentityKey, $AuthSession) $config = $this.Adapter.GetMailboxAutoReplyConfiguration($mailbox.PrimarySmtpAddress, $accessToken) if ($null -eq $config) { throw "Out of Office configuration for mailbox '$IdentityKey' not found." } # Map AutoReplyState to simplified Mode $mode = switch ($config['AutoReplyState']) { 'Disabled' { 'Disabled' } 'Enabled' { 'Enabled' } 'Scheduled' { 'Scheduled' } default { 'Disabled' } } return [pscustomobject]@{ PSTypeName = 'IdLE.MailboxOutOfOffice' IdentityKey = $mailbox.PrimarySmtpAddress Mode = $mode Start = $config['StartTime'] End = $config['EndTime'] InternalMessage = $config['InternalMessage'] ExternalMessage = $config['ExternalMessage'] ExternalAudience = $config['ExternalAudience'] } } -Force # EnsureOutOfOffice: Idempotent Out of Office configuration $provider | Add-Member -MemberType ScriptMethod -Name EnsureOutOfOffice -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $Config, [Parameter()] [AllowNull()] [object] $AuthSession ) $accessToken = $this.ExtractAccessToken($AuthSession) # Validate Config shape if (-not $Config.ContainsKey('Mode')) { throw "OutOfOffice Config must contain 'Mode' key (Disabled, Enabled, or Scheduled)." } $mode = $Config['Mode'] if ($mode -notin @('Disabled', 'Enabled', 'Scheduled')) { throw "OutOfOffice Config Mode must be 'Disabled', 'Enabled', or 'Scheduled'. Got: $mode" } if ($mode -eq 'Scheduled') { if (-not $Config.ContainsKey('Start') -or -not $Config.ContainsKey('End')) { throw "OutOfOffice Config Mode 'Scheduled' requires 'Start' and 'End' keys." } } # Verify mailbox exists first $mailbox = $this.GetMailbox($IdentityKey, $AuthSession) # Get current config for idempotency check $currentConfig = $this.GetOutOfOffice($mailbox.PrimarySmtpAddress, $AuthSession) # Simple idempotency check: if mode matches and messages match, skip update $changed = $false if ($currentConfig.Mode -ne $mode) { $changed = $true } elseif ($Config.ContainsKey('InternalMessage') -and $currentConfig.InternalMessage -ne $Config['InternalMessage']) { $changed = $true } elseif ($Config.ContainsKey('ExternalMessage') -and $currentConfig.ExternalMessage -ne $Config['ExternalMessage']) { $changed = $true } elseif ($Config.ContainsKey('ExternalAudience') -and $currentConfig.ExternalAudience -ne $Config['ExternalAudience']) { $changed = $true } elseif ($mode -eq 'Scheduled') { # Compare dates (allow small tolerance for serialization differences) # Tolerance: 60 seconds to account for rounding during serialization/deserialization $dateComparisonToleranceSeconds = 60 $startDiff = [Math]::Abs(($currentConfig.Start - [DateTime]$Config['Start']).TotalSeconds) $endDiff = [Math]::Abs(($currentConfig.End - [DateTime]$Config['End']).TotalSeconds) if ($startDiff -gt $dateComparisonToleranceSeconds -or $endDiff -gt $dateComparisonToleranceSeconds) { $changed = $true } } if (-not $changed) { return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'EnsureOutOfOffice' IdentityKey = $mailbox.PrimarySmtpAddress Changed = $false } } # Perform update $this.Adapter.SetMailboxAutoReplyConfiguration($mailbox.PrimarySmtpAddress, $Config, $accessToken) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'EnsureOutOfOffice' IdentityKey = $mailbox.PrimarySmtpAddress Changed = $true } } -Force return $provider } |