From a66826159ef28e1ac66d49f361c6599d8f9e4789 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:13:50 +0000 Subject: [PATCH 01/13] Initial plan From 18885af5f36e51b9170c0d4b5202036a8c092d07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:20:36 +0000 Subject: [PATCH 02/13] Add ExchangeOnline provider and Mailbox step pack modules Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../IdLE.Provider.ExchangeOnline.psd1 | 25 ++ .../IdLE.Provider.ExchangeOnline.psm1 | 19 + .../Private/New-IdleExchangeOnlineAdapter.ps1 | 242 ++++++++++++ .../Public/New-IdleExchangeOnlineProvider.ps1 | 367 ++++++++++++++++++ src/IdLE.Provider.ExchangeOnline/README.md | 134 +++++++ .../IdLE.Steps.Mailbox.psd1 | 32 ++ .../IdLE.Steps.Mailbox.psm1 | 13 + .../Public/Get-IdleStepMetadataCatalog.ps1 | 45 +++ ...nvoke-IdleStepMailboxOutOfOfficeEnsure.ps1 | 177 +++++++++ .../Public/Invoke-IdleStepMailboxReport.ps1 | 98 +++++ .../Invoke-IdleStepMailboxTypeEnsure.ps1 | 113 ++++++ src/IdLE.Steps.Mailbox/README.md | 181 +++++++++ 12 files changed, 1446 insertions(+) create mode 100644 src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 create mode 100644 src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 create mode 100644 src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 create mode 100644 src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 create mode 100644 src/IdLE.Provider.ExchangeOnline/README.md create mode 100644 src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 create mode 100644 src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 create mode 100644 src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 create mode 100644 src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 create mode 100644 src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxReport.ps1 create mode 100644 src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 create mode 100644 src/IdLE.Steps.Mailbox/README.md diff --git a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 new file mode 100644 index 00000000..8db7a892 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 @@ -0,0 +1,25 @@ +@{ + RootModule = 'IdLE.Provider.ExchangeOnline.psm1' + ModuleVersion = '0.9.0' + GUID = 'e8f9a3b1-4c2d-4a5b-9f7e-3d2c1a9b8e7f' + Author = 'IdLE Contributors' + CompanyName = 'IdLE Project' + Copyright = '(c) 2025 IdLE Contributors. Licensed under Apache License 2.0.' + Description = 'Exchange Online mailbox provider for IdentityLifecycleEngine' + PowerShellVersion = '7.0' + + FunctionsToExport = @('New-IdleExchangeOnlineProvider') + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('IdentityLifecycleEngine', 'IdLE', 'Provider', 'ExchangeOnline', 'Mailbox') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' + IconUri = '' + ReleaseNotes = 'Exchange Online provider for mailbox lifecycle management' + } + } +} diff --git a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 new file mode 100644 index 00000000..fb3cd38e --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 @@ -0,0 +1,19 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +$script:ModuleRoot = $PSScriptRoot + +# Dot-source Public functions +$PublicScripts = Get-ChildItem -Path (Join-Path $script:ModuleRoot 'Public') -Filter '*.ps1' -ErrorAction SilentlyContinue +foreach ($script in ($PublicScripts | Sort-Object Name)) { + . $script.FullName +} + +# Dot-source Private functions +$PrivateScripts = Get-ChildItem -Path (Join-Path $script:ModuleRoot 'Private') -Filter '*.ps1' -ErrorAction SilentlyContinue +foreach ($script in ($PrivateScripts | Sort-Object Name)) { + . $script.FullName +} + +# Export Public functions +Export-ModuleMember -Function $PublicScripts.BaseName diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 new file mode 100644 index 00000000..27240405 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -0,0 +1,242 @@ +function New-IdleExchangeOnlineAdapter { + <# + .SYNOPSIS + Creates an internal adapter that wraps Exchange Online Management cmdlets. + + .DESCRIPTION + This adapter provides a testable boundary between the provider and Exchange Online cmdlets. + Unit tests can inject a fake adapter without requiring a real Exchange Online environment. + + The adapter wraps ExchangeOnlineManagement module cmdlets for maximum compatibility. + + .PARAMETER UseRestApi + (Reserved for future use) Switch to indicate use of Graph API REST calls instead of cmdlets. + #> + [CmdletBinding()] + param( + [Parameter()] + [switch] $UseRestApi + ) + + $adapter = [pscustomobject]@{ + PSTypeName = 'IdLE.ExchangeOnlineAdapter' + UseRestApi = [bool]$UseRestApi + } + + # Helper to safely invoke cmdlets with error handling + $invokeSafely = { + param( + [Parameter(Mandatory)] + [string] $CommandName, + + [Parameter()] + [hashtable] $Parameters = @{} + ) + + try { + $result = & $CommandName @Parameters + return $result + } + catch { + # Build error message without exposing sensitive data + $errorMessage = "Exchange Online command '$CommandName' failed" + if ($_.Exception.Message) { + # Sanitize error message to avoid leaking tokens/secrets + $sanitized = $_.Exception.Message -replace 'Bearer\s+[^\s]+', 'Bearer ' + $sanitized = $sanitized -replace 'token[^\s]*\s*=\s*[^\s,;]+', 'token=' + $errorMessage += " | $sanitized" + } + + $ex = [System.Exception]::new($errorMessage, $_.Exception) + throw $ex + } + } + + $adapter | Add-Member -MemberType ScriptMethod -Name InvokeSafely -Value $invokeSafely -Force + + # GetMailbox: Retrieve mailbox details by identity (UPN or SMTP address) + $adapter | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + try { + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + $mailbox = $this.InvokeSafely('Get-Mailbox', $params) + + if ($null -eq $mailbox) { + return $null + } + + # Normalize output to hashtable + return @{ + Identity = $mailbox.Identity + PrimarySmtpAddress = $mailbox.PrimarySmtpAddress + UserPrincipalName = $mailbox.UserPrincipalName + DisplayName = $mailbox.DisplayName + RecipientType = $mailbox.RecipientType + RecipientTypeDetails = $mailbox.RecipientTypeDetails + Guid = $mailbox.Guid + } + } + catch { + if ($_.Exception.Message -match 'couldn''t be found|not found|does not exist') { + return $null + } + throw + } + } -Force + + # SetMailboxType: Convert mailbox type (User <-> Shared) + $adapter | Add-Member -MemberType ScriptMethod -Name SetMailboxType -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter(Mandatory)] + [ValidateSet('User', 'Shared', 'Room', 'Equipment')] + [string] $Type, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + # Map type to RecipientTypeDetails + switch ($Type) { + 'User' { + $params['Type'] = 'Regular' + } + 'Shared' { + $params['Type'] = 'Shared' + } + 'Room' { + $params['Type'] = 'Room' + } + 'Equipment' { + $params['Type'] = 'Equipment' + } + } + + $this.InvokeSafely('Set-Mailbox', $params) + } -Force + + # GetMailboxAutoReplyConfiguration: Get Out of Office settings + $adapter | Add-Member -MemberType ScriptMethod -Name GetMailboxAutoReplyConfiguration -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + try { + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + $config = $this.InvokeSafely('Get-MailboxAutoReplyConfiguration', $params) + + if ($null -eq $config) { + return $null + } + + # Normalize output to hashtable + return @{ + Identity = $config.Identity + AutoReplyState = $config.AutoReplyState + StartTime = $config.StartTime + EndTime = $config.EndTime + InternalMessage = $config.InternalMessage + ExternalMessage = $config.ExternalMessage + ExternalAudience = $config.ExternalAudience + CreateOOFEvent = $config.CreateOOFEvent + OOFEventSubject = $config.OOFEventSubject + DeclineAllEventsForScheduledOOF = $config.DeclineAllEventsForScheduledOOF + } + } + catch { + if ($_.Exception.Message -match 'couldn''t be found|not found|does not exist') { + return $null + } + throw + } + } -Force + + # SetMailboxAutoReplyConfiguration: Update Out of Office settings + $adapter | Add-Member -MemberType ScriptMethod -Name SetMailboxAutoReplyConfiguration -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $Config, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + # Map config keys to cmdlet parameters + if ($Config.ContainsKey('Mode')) { + $mode = $Config['Mode'] + switch ($mode) { + 'Disabled' { $params['AutoReplyState'] = 'Disabled' } + 'Enabled' { $params['AutoReplyState'] = 'Enabled' } + 'Scheduled' { $params['AutoReplyState'] = 'Scheduled' } + default { throw "Invalid Mode value: $mode. Expected Disabled, Enabled, or Scheduled." } + } + } + + if ($Config.ContainsKey('Start')) { + $params['StartTime'] = $Config['Start'] + } + + if ($Config.ContainsKey('End')) { + $params['EndTime'] = $Config['End'] + } + + if ($Config.ContainsKey('InternalMessage')) { + $params['InternalMessage'] = $Config['InternalMessage'] + } + + if ($Config.ContainsKey('ExternalMessage')) { + $params['ExternalMessage'] = $Config['ExternalMessage'] + } + + if ($Config.ContainsKey('ExternalAudience')) { + $params['ExternalAudience'] = $Config['ExternalAudience'] + } + + $this.InvokeSafely('Set-MailboxAutoReplyConfiguration', $params) + } -Force + + return $adapter +} diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 new file mode 100644 index 00000000..9d6d0a60 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -0,0 +1,367 @@ +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 docs/reference/providers/provider-exchangeonline.md for detailed setup. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Adapter + ) + + if ($null -eq $Adapter) { + # Verify ExchangeOnlineManagement module is available + $module = Get-Module -Name 'ExchangeOnlineManagement' -ListAvailable -ErrorAction SilentlyContinue + if ($null -eq $module) { + throw "ExchangeOnlineManagement module is not installed. Install it with: Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser" + } + + $Adapter = New-IdleExchangeOnlineAdapter + } + + $extractAccessToken = { + param( + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + 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 GetAccessToken() method + if ($AuthSession.PSObject.Methods.Name -contains 'GetAccessToken') { + return $AuthSession.GetAccessToken() + } + + # Object with AccessToken property + if ($AuthSession.PSObject.Properties.Name -contains 'AccessToken') { + return $AuthSession.AccessToken + } + + # 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.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 ($mode -eq 'Scheduled') { + # Compare dates (allow small tolerance for serialization differences) + $startDiff = [Math]::Abs(($currentConfig.Start - [DateTime]$Config['Start']).TotalSeconds) + $endDiff = [Math]::Abs(($currentConfig.End - [DateTime]$Config['End']).TotalSeconds) + if ($startDiff -gt 60 -or $endDiff -gt 60) { + $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 +} diff --git a/src/IdLE.Provider.ExchangeOnline/README.md b/src/IdLE.Provider.ExchangeOnline/README.md new file mode 100644 index 00000000..aeb54db0 --- /dev/null +++ b/src/IdLE.Provider.ExchangeOnline/README.md @@ -0,0 +1,134 @@ +# IdLE.Provider.ExchangeOnline + +Exchange Online mailbox provider for **IdentityLifecycleEngine (IdLE)**. + +## Overview + +This provider integrates IdLE with **Microsoft Exchange Online** for mailbox lifecycle management operations, including: + +- Mailbox reporting (type, configuration, status) +- Mailbox type conversions (User ↔ Shared, Room, Equipment) +- Out of Office (OOF) configuration management + +The provider implements the **mailbox-specific provider contract** used by the `IdLE.Steps.Mailbox` step pack. + +## Prerequisites + +- PowerShell 7.0 or later +- **ExchangeOnlineManagement** PowerShell module: `Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser` +- Exchange Online subscription (Microsoft 365 / Office 365) +- Appropriate permissions: + - **Delegated**: Exchange Administrator or Global Administrator role + - **App-only**: Application permissions with `Exchange.ManageAsApp` (certificate-based, Windows only for MVP) + +## Authentication + +The provider uses the **AuthSessionBroker** pattern for runtime credential selection. + +### Delegated (Interactive) Auth + +```powershell +# Host establishes connection +Connect-ExchangeOnline -UserPrincipalName admin@contoso.com + +# Create provider +$provider = New-IdleExchangeOnlineProvider + +# Use in plan +$plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + ExchangeOnline = $provider +} +``` + +### App-Only (Certificate) Auth (Windows Only) + +```powershell +# Host establishes connection with certificate +Connect-ExchangeOnline ` + -CertificateThumbprint $thumbprint ` + -AppId $appId ` + -Organization $tenantId + +# Create provider +$provider = New-IdleExchangeOnlineProvider + +# Use in plan +$plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + ExchangeOnline = $provider +} +``` + +> **Note**: App-only auth is Windows-only for MVP. Cross-platform support is planned for future releases. + +## Capabilities + +The provider advertises the following capabilities: + +- `IdLE.Mailbox.Read` - Read mailbox details +- `IdLE.Mailbox.Type.Ensure` - Convert mailbox type (User/Shared/Room/Equipment) +- `IdLE.Mailbox.OutOfOffice.Ensure` - Configure Out of Office settings + +## Identity Addressing + +The provider supports: + +- **UserPrincipalName (UPN)** - `john.doe@contoso.com` (preferred) +- **Primary SMTP address** - `john.doe@contoso.com` +- **Mailbox GUID** - `12345678-1234-1234-1234-123456789abc` (most deterministic) + +The canonical identity key for all outputs is the **primary SMTP address**. + +## Provider Contract Methods + +### GetMailbox + +Retrieve mailbox details. + +```powershell +$mailbox = $provider.GetMailbox($identityKey, $authSession) +# Returns: PSCustomObject with PSTypeName = 'IdLE.Mailbox' +``` + +### EnsureMailboxType + +Idempotent mailbox type conversion. + +```powershell +$result = $provider.EnsureMailboxType($identityKey, 'Shared', $authSession) +# Returns: IdLE.ProviderResult with Changed flag +``` + +### GetOutOfOffice + +Retrieve Out of Office configuration. + +```powershell +$oofConfig = $provider.GetOutOfOffice($identityKey, $authSession) +# Returns: PSCustomObject with PSTypeName = 'IdLE.MailboxOutOfOffice' +``` + +### EnsureOutOfOffice + +Idempotent Out of Office configuration. + +```powershell +$config = @{ + Mode = 'Enabled' + InternalMessage = 'I am out of office.' + ExternalMessage = 'I am currently unavailable.' + ExternalAudience = 'All' +} +$result = $provider.EnsureOutOfOffice($identityKey, $config, $authSession) +# Returns: IdLE.ProviderResult with Changed flag +``` + +## See Also + +- [IdLE.Steps.Mailbox](../IdLE.Steps.Mailbox/README.md) - Provider-agnostic mailbox step pack +- [Provider Documentation](../../docs/reference/providers/provider-exchangeonline.md) +- [Capability Documentation](../../docs/advanced/provider-capabilities.md) +- [ExchangeOnlineManagement Module](https://learn.microsoft.com/en-us/powershell/exchange/exchange-online-powershell) + +## License + +Apache License 2.0 - see [LICENSE.md](../../LICENSE.md) diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 new file mode 100644 index 00000000..534f945a --- /dev/null +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 @@ -0,0 +1,32 @@ +@{ + RootModule = 'IdLE.Steps.Mailbox.psm1' + ModuleVersion = '0.9.0' + GUID = 'f7e6d5c4-b3a2-9180-7e6f-5d4c3b2a1908' + Author = 'IdLE Contributors' + CompanyName = 'IdLE Project' + Copyright = '(c) 2025 IdLE Contributors. Licensed under Apache License 2.0.' + Description = 'Provider-agnostic mailbox step pack for IdentityLifecycleEngine' + PowerShellVersion = '7.0' + + RequiredModules = @('..\IdLE.Steps.Common\IdLE.Steps.Common.psd1') + + FunctionsToExport = @( + 'Get-IdleStepMetadataCatalog' + 'Invoke-IdleStepMailboxReport' + 'Invoke-IdleStepMailboxTypeEnsure' + 'Invoke-IdleStepMailboxOutOfOfficeEnsure' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + + PrivateData = @{ + PSData = @{ + Tags = @('IdentityLifecycleEngine', 'IdLE', 'Steps', 'Mailbox', 'ExchangeOnline') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' + IconUri = '' + ReleaseNotes = 'Provider-agnostic mailbox step pack for IdLE' + } + } +} diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 new file mode 100644 index 00000000..1170c701 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 @@ -0,0 +1,13 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +$script:ModuleRoot = $PSScriptRoot + +# Dot-source Public functions +$PublicScripts = Get-ChildItem -Path (Join-Path $script:ModuleRoot 'Public') -Filter '*.ps1' -ErrorAction SilentlyContinue +foreach ($script in ($PublicScripts | Sort-Object Name)) { + . $script.FullName +} + +# Export Public functions +Export-ModuleMember -Function $PublicScripts.BaseName diff --git a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 new file mode 100644 index 00000000..a3fec316 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 @@ -0,0 +1,45 @@ +function Get-IdleStepMetadataCatalog { + <# + .SYNOPSIS + Returns metadata for mailbox step types. + + .DESCRIPTION + This function provides a metadata catalog mapping Step.Type to metadata objects. + Each metadata object contains RequiredCapabilities (array of capability identifiers). + + The metadata is used during plan building to derive required provider capabilities + for each step, removing the need to declare RequiresCapabilities in workflow definitions. + + This catalog declares mailbox-specific step types that work with any provider + implementing the mailbox provider contract. + + .OUTPUTS + Hashtable (case-insensitive) mapping Step.Type (string) to metadata (hashtable). + + .EXAMPLE + $metadata = Get-IdleStepMetadataCatalog + $metadata['IdLE.Step.Mailbox.Report'].RequiredCapabilities + # Returns: @('IdLE.Mailbox.Read') + #> + [CmdletBinding()] + param() + + $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + + # IdLE.Step.Mailbox.Report - read mailbox details + $catalog['IdLE.Step.Mailbox.Report'] = @{ + RequiredCapabilities = @('IdLE.Mailbox.Read') + } + + # IdLE.Step.Mailbox.Type.Ensure - idempotent mailbox type conversion + $catalog['IdLE.Step.Mailbox.Type.Ensure'] = @{ + RequiredCapabilities = @('IdLE.Mailbox.Read', 'IdLE.Mailbox.Type.Ensure') + } + + # IdLE.Step.Mailbox.OutOfOffice.Ensure - idempotent Out of Office configuration + $catalog['IdLE.Step.Mailbox.OutOfOffice.Ensure'] = @{ + RequiredCapabilities = @('IdLE.Mailbox.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') + } + + return $catalog +} diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 new file mode 100644 index 00000000..178cd006 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -0,0 +1,177 @@ +function Invoke-IdleStepMailboxOutOfOfficeEnsure { + <# + .SYNOPSIS + Ensures that a mailbox Out of Office (OOF) configuration matches the desired state. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[]. The provider must implement an EnsureOutOfOffice + method with the signature (IdentityKey, Config, AuthSession) and return an object + that contains a boolean property 'Changed'. + + The step is idempotent by design: it converges OOF configuration to the desired state. + + Out of Office Config shape (data-only hashtable): + - Mode: 'Disabled' | 'Enabled' | 'Scheduled' (required) + - Start: DateTime (required when Mode = 'Scheduled') + - End: DateTime (required when Mode = 'Scheduled') + - InternalMessage: string (optional) + - ExternalMessage: string (optional) + - ExternalAudience: 'None' | 'Known' | 'All' (optional, default provider-specific) + + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method. + - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Admin' }). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + + .EXAMPLE + # In workflow definition (enable OOF): + @{ + Name = 'Enable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'I am out of office.' + ExternalMessage = 'I am currently unavailable.' + ExternalAudience = 'All' + } + } + } + + .EXAMPLE + # In workflow definition (scheduled OOF): + @{ + Name = 'Schedule Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Scheduled' + Start = '2025-02-01T00:00:00Z' + End = '2025-02-15T00:00:00Z' + InternalMessage = 'I am on vacation until February 15.' + ExternalMessage = 'I am currently out of office.' + } + } + } + + .EXAMPLE + # In workflow definition (disable OOF): + @{ + Name = 'Disable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Disabled' + } + } + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "Mailbox.OutOfOffice.Ensure requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'Config')) { + if (-not $with.ContainsKey($key)) { + throw "Mailbox.OutOfOffice.Ensure requires With.$key." + } + } + + $config = $with.Config + if ($null -eq $config -or -not ($config -is [hashtable])) { + throw "Mailbox.OutOfOffice.Ensure requires With.Config to be a hashtable." + } + + # Validate Config shape + if (-not $config.ContainsKey('Mode')) { + throw "Mailbox.OutOfOffice.Ensure requires With.Config.Mode (Disabled, Enabled, or Scheduled)." + } + + $validModes = @('Disabled', 'Enabled', 'Scheduled') + if ($config.Mode -notin $validModes) { + throw "Mailbox.OutOfOffice.Ensure requires With.Config.Mode to be one of: $($validModes -join ', '). Got: $($config.Mode)" + } + + # Validate Scheduled mode requirements + if ($config.Mode -eq 'Scheduled') { + foreach ($key in @('Start', 'End')) { + if (-not $config.ContainsKey($key)) { + throw "Mailbox.OutOfOffice.Ensure with Mode 'Scheduled' requires With.Config.$key." + } + } + } + + # Security: reject ScriptBlocks in Config (data-only constraint) + foreach ($key in $config.Keys) { + if ($config[$key] -is [ScriptBlock]) { + throw "Mailbox.OutOfOffice.Ensure With.Config must not contain ScriptBlocks. Found ScriptBlock in key '$key'." + } + } + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } + + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + # Apply AuthSessionName convention: default to Provider if not specified + if (-not $with.ContainsKey('AuthSessionName')) { + $with['AuthSessionName'] = $providerAlias + } + + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'EnsureOutOfOffice' ` + -MethodArguments @([string]$with.IdentityKey, $config) + + $changed = $false + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $changed = [bool]$result.Changed + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $changed + Error = $null + } +} diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxReport.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxReport.ps1 new file mode 100644 index 00000000..e220b345 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxReport.ps1 @@ -0,0 +1,98 @@ +function Invoke-IdleStepMailboxReport { + <# + .SYNOPSIS + Retrieves mailbox details and returns a structured report. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[]. The provider must implement a GetMailbox + method with the signature (IdentityKey, AuthSession) and return a mailbox object. + + The step is read-only and returns Changed = $false. + + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method. + - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Admin' }). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + + .EXAMPLE + # In workflow definition: + @{ + Name = 'Report user mailbox' + Type = 'IdLE.Step.Mailbox.Report' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + } + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "Mailbox.Report requires 'With' to be a hashtable." + } + + if (-not $with.ContainsKey('IdentityKey')) { + throw "Mailbox.Report requires With.IdentityKey." + } + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } + + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + # Apply AuthSessionName convention: default to Provider if not specified + if (-not $with.ContainsKey('AuthSessionName')) { + $with['AuthSessionName'] = $providerAlias + } + + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'GetMailbox' ` + -MethodArguments @([string]$with.IdentityKey) + + # Store mailbox data in State for downstream steps + $state = @{ + Mailbox = $result + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $false + Error = $null + State = $state + } +} diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 new file mode 100644 index 00000000..b0e77435 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 @@ -0,0 +1,113 @@ +function Invoke-IdleStepMailboxTypeEnsure { + <# + .SYNOPSIS + Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[]. The provider must implement an EnsureMailboxType + method with the signature (IdentityKey, DesiredType, AuthSession) and return an object + that contains a boolean property 'Changed'. + + The step is idempotent by design: it converges state to the desired type. + + Supported mailbox types: + - User (regular user mailbox) + - Shared (shared mailbox for team use) + - Room (room resource mailbox) + - Equipment (equipment resource mailbox) + + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method. + - If With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Admin' }). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + + .EXAMPLE + # In workflow definition (convert to shared mailbox): + @{ + Name = 'Convert to shared mailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + DesiredType = 'Shared' + } + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "Mailbox.Type.Ensure requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'DesiredType')) { + if (-not $with.ContainsKey($key)) { + throw "Mailbox.Type.Ensure requires With.$key." + } + } + + # Validate DesiredType + $validTypes = @('User', 'Shared', 'Room', 'Equipment') + if ($with.DesiredType -notin $validTypes) { + throw "Mailbox.Type.Ensure requires With.DesiredType to be one of: $($validTypes -join ', '). Got: $($with.DesiredType)" + } + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } + + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + # Apply AuthSessionName convention: default to Provider if not specified + if (-not $with.ContainsKey('AuthSessionName')) { + $with['AuthSessionName'] = $providerAlias + } + + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'EnsureMailboxType' ` + -MethodArguments @([string]$with.IdentityKey, [string]$with.DesiredType) + + $changed = $false + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $changed = [bool]$result.Changed + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $changed + Error = $null + } +} diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md new file mode 100644 index 00000000..1623b841 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/README.md @@ -0,0 +1,181 @@ +# IdLE.Steps.Mailbox + +Provider-agnostic mailbox step pack for **IdentityLifecycleEngine (IdLE)**. + +## Overview + +This step pack provides mailbox-focused lifecycle operations that work with any provider +implementing the **mailbox provider contract**. + +The steps are **domain-oriented** (mailbox operations) rather than provider-branded, +ensuring maximum portability across Exchange Online, on-premises Exchange, and future providers. + +## Step Types + +### IdLE.Step.Mailbox.Report + +Read mailbox details and return a structured snapshot. + +```powershell +@{ + Name = 'Report user mailbox' + Type = 'IdLE.Step.Mailbox.Report' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + } +} +``` + +**Returns**: Mailbox object in `State.Mailbox` (read-only, Changed = false) + +--- + +### IdLE.Step.Mailbox.Type.Ensure + +Idempotent mailbox type conversion (User ↔ Shared, Room, Equipment). + +```powershell +@{ + Name = 'Convert to shared mailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + DesiredType = 'Shared' + } +} +``` + +**Supported types**: +- `User` - Regular user mailbox +- `Shared` - Shared mailbox (team mailbox) +- `Room` - Room resource mailbox +- `Equipment` - Equipment resource mailbox + +**Returns**: StepResult with `Changed` flag (true if conversion occurred) + +--- + +### IdLE.Step.Mailbox.OutOfOffice.Ensure + +Idempotent Out of Office (OOF) configuration. + +```powershell +# Enable OOF +@{ + Name = 'Enable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'I am out of office.' + ExternalMessage = 'I am currently unavailable.' + ExternalAudience = 'All' + } + } +} + +# Scheduled OOF +@{ + Name = 'Schedule Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Scheduled' + Start = '2025-02-01T00:00:00Z' + End = '2025-02-15T00:00:00Z' + InternalMessage = 'I am on vacation until February 15.' + } + } +} + +# Disable OOF +@{ + Name = 'Disable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ Mode = 'Disabled' } + } +} +``` + +**Config shape** (data-only hashtable): +- `Mode`: `'Disabled'` | `'Enabled'` | `'Scheduled'` (required) +- `Start`: DateTime (required when Mode = 'Scheduled') +- `End`: DateTime (required when Mode = 'Scheduled') +- `InternalMessage`: string (optional) +- `ExternalMessage`: string (optional) +- `ExternalAudience`: `'None'` | `'Known'` | `'All'` (optional) + +**Returns**: StepResult with `Changed` flag (true if OOF settings were updated) + +--- + +## Required Provider Capabilities + +Each step declares required provider capabilities via metadata catalog: + +| Step Type | Required Capabilities | +|-----------|----------------------| +| `IdLE.Step.Mailbox.Report` | `IdLE.Mailbox.Read` | +| `IdLE.Step.Mailbox.Type.Ensure` | `IdLE.Mailbox.Read`, `IdLE.Mailbox.Type.Ensure` | +| `IdLE.Step.Mailbox.OutOfOffice.Ensure` | `IdLE.Mailbox.Read`, `IdLE.Mailbox.OutOfOffice.Ensure` | + +The IdLE planner automatically validates that the selected provider advertises these capabilities. + +--- + +## Authentication Convention + +**Option B (Convention)**: If `With.AuthSessionName` is not specified, the step defaults it to `With.Provider`. + +Example: +```powershell +With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + # AuthSessionName defaults to 'ExchangeOnline' if omitted +} +``` + +Explicit override: +```powershell +With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + AuthSessionName = 'ExchangeOnline-Tier0' + AuthSessionOptions = @{ Role = 'Tier0' } +} +``` + +--- + +## Provider Contract + +Providers implementing the mailbox contract must expose: + +- `GetMailbox(IdentityKey, AuthSession)` → returns mailbox object +- `EnsureMailboxType(IdentityKey, DesiredType, AuthSession)` → returns result with `Changed` flag +- `GetOutOfOffice(IdentityKey, AuthSession)` → returns OOF config object +- `EnsureOutOfOffice(IdentityKey, Config, AuthSession)` → returns result with `Changed` flag + +Reference implementation: **IdLE.Provider.ExchangeOnline** + +--- + +## See Also + +- [IdLE.Provider.ExchangeOnline](../IdLE.Provider.ExchangeOnline/README.md) - Exchange Online provider +- [Capability Documentation](../../docs/advanced/provider-capabilities.md) +- [Step Reference](../../docs/reference/steps-and-metadata.md) + +## License + +Apache License 2.0 - see [LICENSE.md](../../LICENSE.md) From e410ab6d75e047f7a8c39a0e2318c4369dffed5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:25:51 +0000 Subject: [PATCH 03/13] Add unit tests for ExchangeOnline provider and Mailbox steps Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../IdLE.Steps.Mailbox.psm1 | 6 + .../Private/Invoke-IdleProviderMethod.ps1 | 62 +++ .../Test-IdleProviderMethodParameter.ps1 | 66 ++++ ...IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 | 216 +++++++++++ tests/Invoke-IdleStepMailboxReport.Tests.ps1 | 109 ++++++ ...Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 | 148 +++++++ .../ExchangeOnlineProvider.Tests.ps1 | 367 ++++++++++++++++++ 7 files changed, 974 insertions(+) create mode 100644 src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 create mode 100644 src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 create mode 100644 tests/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 create mode 100644 tests/Invoke-IdleStepMailboxReport.Tests.ps1 create mode 100644 tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 create mode 100644 tests/Providers/ExchangeOnlineProvider.Tests.ps1 diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 index 1170c701..d227a88b 100644 --- a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 @@ -3,6 +3,12 @@ Set-StrictMode -Version Latest $script:ModuleRoot = $PSScriptRoot +# Dot-source Private functions +$PrivateScripts = Get-ChildItem -Path (Join-Path $script:ModuleRoot 'Private') -Filter '*.ps1' -ErrorAction SilentlyContinue +foreach ($script in ($PrivateScripts | Sort-Object Name)) { + . $script.FullName +} + # Dot-source Public functions $PublicScripts = Get-ChildItem -Path (Join-Path $script:ModuleRoot 'Public') -Filter '*.ps1' -ErrorAction SilentlyContinue foreach ($script in ($PublicScripts | Sort-Object Name)) { diff --git a/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 b/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 new file mode 100644 index 00000000..a8d28431 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 @@ -0,0 +1,62 @@ +# Invokes a provider method with optional AuthSession support. +# Handles auth session acquisition, parameter detection, and backwards-compatible fallback. + +function Invoke-IdleProviderMethod { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $With, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ProviderAlias, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MethodName, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $MethodArguments + ) + + # Auth session acquisition (optional, data-only) + $authSession = $null + if ($With.ContainsKey('AuthSessionName')) { + $sessionName = [string]$With.AuthSessionName + $sessionOptions = if ($With.ContainsKey('AuthSessionOptions')) { $With.AuthSessionOptions } else { $null } + + if ($null -ne $sessionOptions -and -not ($sessionOptions -is [hashtable])) { + throw "With.AuthSessionOptions must be a hashtable or null." + } + + $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) + } + + $provider = $Context.Providers[$ProviderAlias] + + # Check if provider method exists + $providerMethod = $provider.PSObject.Methods[$MethodName] + if ($null -eq $providerMethod) { + throw "Provider '$ProviderAlias' does not implement $MethodName method." + } + + # Check if method supports AuthSession parameter + $supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $providerMethod -ParameterName 'AuthSession' + + # Call provider method with appropriate signature + if ($supportsAuthSession -and $null -ne $authSession) { + # Provider supports AuthSession and we have one - pass it + $allArgs = $MethodArguments + $authSession + return $provider.$MethodName.Invoke($allArgs) + } + else { + # Legacy signature (no AuthSession parameter) or no session acquired + return $provider.$MethodName.Invoke($MethodArguments) + } +} diff --git a/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 b/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 new file mode 100644 index 00000000..870217a6 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 @@ -0,0 +1,66 @@ +# Tests whether a provider method supports a given parameter. +# Supports ScriptMethod (AST inspection) and compiled methods (reflection). + +function Test-IdleProviderMethodParameter { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Management.Automation.PSMethodInfo] $ProviderMethod, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ParameterName + ) + + # For ScriptMethod, inspect the AST + if ($ProviderMethod.MemberType -eq 'ScriptMethod') { + $scriptBlock = $ProviderMethod.Script + + # Early exit if required objects are missing + if ($null -eq $scriptBlock) { return $false } + if ($null -eq $scriptBlock.Ast) { return $false } + if ($null -eq $scriptBlock.Ast.ParamBlock) { return $false } + + $params = $scriptBlock.Ast.ParamBlock.Parameters + if ($null -eq $params) { return $false } + + # Check each parameter for a match + foreach ($param in $params) { + if ($null -eq $param.Name) { continue } + if ($null -eq $param.Name.VariablePath) { continue } + + $paramName = $param.Name.VariablePath.UserPath + if ($paramName -eq $ParameterName) { + return $true + } + } + + return $false + } + + # For compiled methods (PSMethod, CodeMethod), use reflection + if ($ProviderMethod.MemberType -in @('Method', 'CodeMethod')) { + try { + # Get the method info via reflection + $methodInfo = $ProviderMethod.OverloadDefinitions + if ($null -ne $methodInfo) { + # Check if any overload contains the parameter name + foreach ($overload in $methodInfo) { + if ($overload -match "\b$ParameterName\b") { + return $true + } + } + } + } + catch { + # If reflection fails, assume parameter is not supported + Write-Verbose "Could not inspect compiled method parameters: $_" + } + return $false + } + + # Unknown method type + return $false +} diff --git a/tests/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 b/tests/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 new file mode 100644 index 00000000..2717a799 --- /dev/null +++ b/tests/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 @@ -0,0 +1,216 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # Import Mailbox step pack + $testsRoot = $PSScriptRoot + $repoRoot = Split-Path -Path $testsRoot -Parent + $mailboxModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Steps.Mailbox\IdLE.Steps.Mailbox.psm1' + if (Test-Path -LiteralPath $mailboxModulePath -PathType Leaf) { + Import-Module $mailboxModulePath -Force + } +} + +Describe 'Invoke-IdleStepMailboxOutOfOfficeEnsure' { + BeforeEach { + # Create mock ExchangeOnline provider + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + Store = @{} + } + + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureOutOfOffice -Value { + param($IdentityKey, $Config, $AuthSession) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Mailbox '$IdentityKey' not found." + } + + $mailbox = $this.Store[$IdentityKey] + + # Simple idempotency check based on Mode + $changed = ($mailbox['OOFMode'] -ne $Config['Mode']) + + if ($changed) { + $mailbox['OOFMode'] = $Config['Mode'] + $mailbox['OOFInternalMessage'] = if ($Config.ContainsKey('InternalMessage')) { $Config['InternalMessage'] } else { '' } + $mailbox['OOFExternalMessage'] = if ($Config.ContainsKey('ExternalMessage')) { $Config['ExternalMessage'] } else { '' } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureOutOfOffice' + IdentityKey = $IdentityKey + Changed = $changed + } + } -Force + + # Add test mailbox + $script:Provider.Store['user@contoso.com'] = @{ + IdentityKey = 'user@contoso.com' + OOFMode = 'Disabled' + OOFInternalMessage = '' + OOFExternalMessage = '' + } + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ ExchangeOnline = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + # Add mock AcquireAuthSession method + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return 'mock-token' + } -Force + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Enable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'I am out of office.' + ExternalMessage = 'Currently unavailable.' + } + } + } + } + + It 'enables Out of Office and reports Changed = true' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + + # Verify OOF was updated + $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Enabled' + $script:Provider.Store['user@contoso.com']['OOFInternalMessage'] | Should -Be 'I am out of office.' + } + + It 'is idempotent when OOF already matches desired state' { + # Set OOF to Enabled first + $script:Provider.Store['user@contoso.com']['OOFMode'] = 'Enabled' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + } + + It 'disables Out of Office' { + # First enable it + $script:Provider.Store['user@contoso.com']['OOFMode'] = 'Enabled' + + $step = $script:StepTemplate + $step.With.Config = @{ Mode = 'Disabled' } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Disabled' + } + + It 'configures scheduled Out of Office' { + $start = [DateTime]::Parse('2025-02-01T00:00:00Z') + $end = [DateTime]::Parse('2025-02-15T00:00:00Z') + + $step = $script:StepTemplate + $step.With.Config = @{ + Mode = 'Scheduled' + Start = $start + End = $end + InternalMessage = 'On vacation' + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store['user@contoso.com']['OOFMode'] | Should -Be 'Scheduled' + } + + It 'throws when Config is missing' { + $step = $script:StepTemplate + $step.With.Remove('Config') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config*" + } + + It 'throws when Config.Mode is missing' { + $step = $script:StepTemplate + $step.With.Config = @{ InternalMessage = 'Test' } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Config.Mode*" + } + + It 'throws when Config.Mode is invalid' { + $step = $script:StepTemplate + $step.With.Config.Mode = 'InvalidMode' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*Mode to be one of: Disabled, Enabled, Scheduled*" + } + + It 'throws when Scheduled mode is missing Start' { + $step = $script:StepTemplate + $step.With.Config = @{ + Mode = 'Scheduled' + End = [DateTime]::Now + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*Mode 'Scheduled' requires With.Config.Start*" + } + + It 'throws when Scheduled mode is missing End' { + $step = $script:StepTemplate + $step.With.Config = @{ + Mode = 'Scheduled' + Start = [DateTime]::Now + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*Mode 'Scheduled' requires With.Config.End*" + } + + It 'rejects ScriptBlocks in Config (security boundary)' { + $step = $script:StepTemplate + $step.With.Config.InternalMessage = { Write-Host "malicious" } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*must not contain ScriptBlocks*" + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'throws when IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxOutOfOfficeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } +} diff --git a/tests/Invoke-IdleStepMailboxReport.Tests.ps1 b/tests/Invoke-IdleStepMailboxReport.Tests.ps1 new file mode 100644 index 00000000..c23b78d6 --- /dev/null +++ b/tests/Invoke-IdleStepMailboxReport.Tests.ps1 @@ -0,0 +1,109 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # Import Mailbox step pack + $testsRoot = $PSScriptRoot + $repoRoot = Split-Path -Path $testsRoot -Parent + $mailboxModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Steps.Mailbox\IdLE.Steps.Mailbox.psm1' + if (Test-Path -LiteralPath $mailboxModulePath -PathType Leaf) { + Import-Module $mailboxModulePath -Force + } +} + +Describe 'Invoke-IdleStepMailboxReport' { + BeforeEach { + # Create mock ExchangeOnline provider + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + Store = @{} + } + + $script:Provider | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { + param($IdentityKey, $AuthSession) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Mailbox '$IdentityKey' not found." + } + + return $this.Store[$IdentityKey] + } -Force + + # Add test mailbox + $script:Provider.Store['user@contoso.com'] = [pscustomobject]@{ + PSTypeName = 'IdLE.Mailbox' + IdentityKey = 'user@contoso.com' + PrimarySmtpAddress = 'user@contoso.com' + UserPrincipalName = 'user@contoso.com' + DisplayName = 'Test User' + Type = 'User' + RecipientType = 'UserMailbox' + RecipientTypeDetails = 'UserMailbox' + Guid = [System.Guid]::NewGuid().ToString() + } + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ ExchangeOnline = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + # Add mock AcquireAuthSession method + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return 'mock-token' + } -Force + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Report mailbox' + Type = 'IdLE.Step.Mailbox.Report' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + } + } + } + + It 'retrieves mailbox and returns data in State' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxReport' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + $result.State | Should -Not -BeNullOrEmpty + $result.State.Mailbox | Should -Not -BeNullOrEmpty + $result.State.Mailbox.IdentityKey | Should -Be 'user@contoso.com' + $result.State.Mailbox.Type | Should -Be 'User' + } + + It 'applies AuthSessionName convention (defaults to Provider)' { + # Remove AuthSessionName to test default behavior + $step = $script:StepTemplate + $step.With.Remove('AuthSessionName') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxReport' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + # AuthSessionName should have been set to 'ExchangeOnline' + $step.With.AuthSessionName | Should -Be 'ExchangeOnline' + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxReport' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'throws when IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxReport' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } +} diff --git a/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 b/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 new file mode 100644 index 00000000..3db2c682 --- /dev/null +++ b/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 @@ -0,0 +1,148 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule + + # Import Mailbox step pack + $testsRoot = $PSScriptRoot + $repoRoot = Split-Path -Path $testsRoot -Parent + $mailboxModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Steps.Mailbox\IdLE.Steps.Mailbox.psm1' + if (Test-Path -LiteralPath $mailboxModulePath -PathType Leaf) { + Import-Module $mailboxModulePath -Force + } +} + +Describe 'Invoke-IdleStepMailboxTypeEnsure' { + BeforeEach { + # Create mock ExchangeOnline provider + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + Store = @{} + } + + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureMailboxType -Value { + param($IdentityKey, $DesiredType, $AuthSession) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + throw "Mailbox '$IdentityKey' not found." + } + + $mailbox = $this.Store[$IdentityKey] + $currentType = $mailbox['Type'] + $changed = ($currentType -ne $DesiredType) + + if ($changed) { + $mailbox['Type'] = $DesiredType + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureMailboxType' + IdentityKey = $IdentityKey + Changed = $changed + Type = $DesiredType + } + } -Force + + # Add test mailbox + $script:Provider.Store['user@contoso.com'] = @{ + IdentityKey = 'user@contoso.com' + Type = 'User' + } + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ ExchangeOnline = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + # Add mock AcquireAuthSession method + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return 'mock-token' + } -Force + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Convert to shared mailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + DesiredType = 'Shared' + } + } + } + + It 'converts mailbox type and reports Changed = true' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + + # Verify mailbox was updated + $script:Provider.Store['user@contoso.com']['Type'] | Should -Be 'Shared' + } + + It 'is idempotent when mailbox already has desired type' { + # Set mailbox to Shared first + $script:Provider.Store['user@contoso.com']['Type'] = 'Shared' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + } + + It 'throws when DesiredType is invalid' { + $step = $script:StepTemplate + $step.With.DesiredType = 'InvalidType' + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $step } | + Should -Throw "*DesiredType to be one of: User, Shared, Room, Equipment*" + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + + It 'throws when IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } + + It 'throws when DesiredType is missing' { + $step = $script:StepTemplate + $step.With.Remove('DesiredType') + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.DesiredType*" + } + + It 'supports all valid mailbox types' { + foreach ($type in @('Shared', 'Room', 'Equipment', 'User')) { + # Always set to a different type first + $startType = if ($type -eq 'User') { 'Shared' } else { 'User' } + $script:Provider.Store['user@contoso.com']['Type'] = $startType + + $step = $script:StepTemplate + $step.With.DesiredType = $type + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Changed | Should -Be $true + $script:Provider.Store['user@contoso.com']['Type'] | Should -Be $type + } + } +} diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 new file mode 100644 index 00000000..bea4f793 --- /dev/null +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -0,0 +1,367 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') + Import-IdleTestModule + + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + + # Import ExchangeOnline provider + $exoModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.ExchangeOnline\IdLE.Provider.ExchangeOnline.psm1' + if (-not (Test-Path -LiteralPath $exoModulePath -PathType Leaf)) { + throw "ExchangeOnline provider module not found at: $exoModulePath" + } + Import-Module $exoModulePath -Force +} + +Describe 'ExchangeOnline provider - Unit tests' { + BeforeAll { + # Create a fake adapter for tests + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.ExchangeOnlineAdapter.Fake' + Store = @{ + Mailboxes = @{} + AutoReply = @{} + } + } + + # GetMailbox: Retrieve mailbox by identity + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { + param($MailboxIdentity, $AccessToken) + + # Try direct key lookup + if ($this.Store.Mailboxes.ContainsKey($MailboxIdentity)) { + return $this.Store.Mailboxes[$MailboxIdentity] + } + + # Search by UPN or SMTP + foreach ($key in $this.Store.Mailboxes.Keys) { + $mailbox = $this.Store.Mailboxes[$key] + if ($mailbox['UserPrincipalName'] -eq $MailboxIdentity -or + $mailbox['PrimarySmtpAddress'] -eq $MailboxIdentity) { + return $mailbox + } + } + + return $null + } -Force + + # SetMailboxType: Convert mailbox type + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name SetMailboxType -Value { + param($MailboxIdentity, $Type, $AccessToken) + + $mailbox = $this.GetMailbox($MailboxIdentity, $AccessToken) + if ($null -eq $mailbox) { + throw "Mailbox '$MailboxIdentity' not found." + } + + # Update RecipientTypeDetails based on Type + $mailbox['RecipientTypeDetails'] = switch ($Type) { + 'User' { 'UserMailbox' } + 'Shared' { 'SharedMailbox' } + 'Room' { 'RoomMailbox' } + 'Equipment' { 'EquipmentMailbox' } + default { 'UserMailbox' } + } + } -Force + + # GetMailboxAutoReplyConfiguration: Get OOF settings + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetMailboxAutoReplyConfiguration -Value { + param($MailboxIdentity, $AccessToken) + + $key = $MailboxIdentity + if (-not $this.Store.AutoReply.ContainsKey($key)) { + # Initialize default OOF config + $this.Store.AutoReply[$key] = @{ + Identity = $MailboxIdentity + AutoReplyState = 'Disabled' + StartTime = $null + EndTime = $null + InternalMessage = '' + ExternalMessage = '' + ExternalAudience = 'All' + } + } + return $this.Store.AutoReply[$key] + } -Force + + # SetMailboxAutoReplyConfiguration: Update OOF settings + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name SetMailboxAutoReplyConfiguration -Value { + param($MailboxIdentity, $Config, $AccessToken) + + $key = $MailboxIdentity + if (-not $this.Store.AutoReply.ContainsKey($key)) { + $this.Store.AutoReply[$key] = @{ + Identity = $MailboxIdentity + AutoReplyState = 'Disabled' + StartTime = $null + EndTime = $null + InternalMessage = '' + ExternalMessage = '' + ExternalAudience = 'All' + } + } + + $current = $this.Store.AutoReply[$key] + + # Map Mode to AutoReplyState (same as real adapter) + if ($Config.ContainsKey('Mode')) { + $mode = $Config['Mode'] + switch ($mode) { + 'Disabled' { $current['AutoReplyState'] = 'Disabled' } + 'Enabled' { $current['AutoReplyState'] = 'Enabled' } + 'Scheduled' { $current['AutoReplyState'] = 'Scheduled' } + } + } + if ($Config.ContainsKey('AutoReplyState')) { + $current['AutoReplyState'] = $Config['AutoReplyState'] + } + if ($Config.ContainsKey('Start')) { + $current['StartTime'] = $Config['Start'] + } + if ($Config.ContainsKey('StartTime')) { + $current['StartTime'] = $Config['StartTime'] + } + if ($Config.ContainsKey('End')) { + $current['EndTime'] = $Config['End'] + } + if ($Config.ContainsKey('EndTime')) { + $current['EndTime'] = $Config['EndTime'] + } + if ($Config.ContainsKey('InternalMessage')) { + $current['InternalMessage'] = $Config['InternalMessage'] + } + if ($Config.ContainsKey('ExternalMessage')) { + $current['ExternalMessage'] = $Config['ExternalMessage'] + } + if ($Config.ContainsKey('ExternalAudience')) { + $current['ExternalAudience'] = $Config['ExternalAudience'] + } + } -Force + + # Helper to create test mailboxes + function Add-TestMailbox { + param( + [string]$PrimarySmtpAddress, + [string]$UserPrincipalName = $PrimarySmtpAddress, + [string]$DisplayName = "User Mailbox", + [string]$RecipientTypeDetails = 'UserMailbox', + [string]$RecipientType = 'UserMailbox' + ) + + $guid = [System.Guid]::NewGuid().ToString() + $mailbox = @{ + Identity = $PrimarySmtpAddress + PrimarySmtpAddress = $PrimarySmtpAddress + UserPrincipalName = $UserPrincipalName + DisplayName = $DisplayName + RecipientType = $RecipientType + RecipientTypeDetails = $RecipientTypeDetails + Guid = $guid + } + + $fakeAdapter.Store.Mailboxes[$PrimarySmtpAddress] = $mailbox + return $mailbox + } + + # Create provider with fake adapter + $provider = New-IdleExchangeOnlineProvider -Adapter $fakeAdapter + } + + Context 'GetCapabilities' { + It 'returns mailbox-specific capabilities' { + $caps = $provider.GetCapabilities() + $caps | Should -Contain 'IdLE.Mailbox.Read' + $caps | Should -Contain 'IdLE.Mailbox.Type.Ensure' + $caps | Should -Contain 'IdLE.Mailbox.OutOfOffice.Ensure' + } + } + + Context 'GetMailbox' { + It 'retrieves mailbox by primary SMTP address' { + Add-TestMailbox -PrimarySmtpAddress 'user1@contoso.com' -DisplayName 'User One' + + $mailbox = $provider.GetMailbox('user1@contoso.com', $null) + + $mailbox | Should -Not -BeNullOrEmpty + $mailbox.PrimarySmtpAddress | Should -Be 'user1@contoso.com' + $mailbox.DisplayName | Should -Be 'User One' + $mailbox.Type | Should -Be 'User' + } + + It 'throws error when mailbox not found' { + { $provider.GetMailbox('nonexistent@contoso.com', $null) } | + Should -Throw "*Mailbox 'nonexistent@contoso.com' not found*" + } + + It 'normalizes mailbox type correctly' { + Add-TestMailbox -PrimarySmtpAddress 'shared1@contoso.com' -RecipientTypeDetails 'SharedMailbox' + + $mailbox = $provider.GetMailbox('shared1@contoso.com', $null) + + $mailbox.Type | Should -Be 'Shared' + $mailbox.RecipientTypeDetails | Should -Be 'SharedMailbox' + } + } + + Context 'EnsureMailboxType' { + It 'converts user mailbox to shared mailbox' { + Add-TestMailbox -PrimarySmtpAddress 'user2@contoso.com' -RecipientTypeDetails 'UserMailbox' + + $result = $provider.EnsureMailboxType('user2@contoso.com', 'Shared', $null) + + $result.Changed | Should -Be $true + $result.Operation | Should -Be 'EnsureMailboxType' + $result.Type | Should -Be 'Shared' + + # Verify mailbox was actually updated + $mailbox = $provider.GetMailbox('user2@contoso.com', $null) + $mailbox.Type | Should -Be 'Shared' + } + + It 'is idempotent when mailbox already has desired type' { + Add-TestMailbox -PrimarySmtpAddress 'shared2@contoso.com' -RecipientTypeDetails 'SharedMailbox' + + $result = $provider.EnsureMailboxType('shared2@contoso.com', 'Shared', $null) + + $result.Changed | Should -Be $false + } + + It 'supports all mailbox types' { + foreach ($type in @('Shared', 'Room', 'Equipment', 'User')) { + $email = "test-$type@contoso.com".ToLower() + # Always start with UserMailbox, except for last iteration testing User type + $startType = if ($type -eq 'User') { 'SharedMailbox' } else { 'UserMailbox' } + Add-TestMailbox -PrimarySmtpAddress $email -RecipientTypeDetails $startType + + $result = $provider.EnsureMailboxType($email, $type, $null) + + $result.Changed | Should -Be $true + $result.Type | Should -Be $type + } + } + } + + Context 'GetOutOfOffice' { + It 'retrieves Out of Office configuration' { + Add-TestMailbox -PrimarySmtpAddress 'user3@contoso.com' + + $config = $provider.GetOutOfOffice('user3@contoso.com', $null) + + $config | Should -Not -BeNullOrEmpty + $config.Mode | Should -Be 'Disabled' + $config.IdentityKey | Should -Be 'user3@contoso.com' + } + + It 'throws error when mailbox not found' { + { $provider.GetOutOfOffice('nonexistent@contoso.com', $null) } | + Should -Throw "*Mailbox 'nonexistent@contoso.com' not found*" + } + } + + Context 'EnsureOutOfOffice' { + It 'enables Out of Office' { + Add-TestMailbox -PrimarySmtpAddress 'user4@contoso.com' + + $config = @{ + Mode = 'Enabled' + InternalMessage = 'I am out of office.' + ExternalMessage = 'Currently unavailable.' + } + + $result = $provider.EnsureOutOfOffice('user4@contoso.com', $config, $null) + + $result.Changed | Should -Be $true + $result.Operation | Should -Be 'EnsureOutOfOffice' + + # Verify OOF was actually updated + $oofConfig = $provider.GetOutOfOffice('user4@contoso.com', $null) + $oofConfig.Mode | Should -Be 'Enabled' + $oofConfig.InternalMessage | Should -Be 'I am out of office.' + } + + It 'is idempotent when OOF already matches desired state' { + Add-TestMailbox -PrimarySmtpAddress 'user5@contoso.com' + + $config = @{ + Mode = 'Enabled' + InternalMessage = 'Out of office' + } + + # Set initial state + $provider.EnsureOutOfOffice('user5@contoso.com', $config, $null) | Out-Null + + # Second call should report no change + $result = $provider.EnsureOutOfOffice('user5@contoso.com', $config, $null) + $result.Changed | Should -Be $false + } + + It 'configures scheduled Out of Office' { + Add-TestMailbox -PrimarySmtpAddress 'user6@contoso.com' + + $start = [DateTime]::Parse('2025-02-01T00:00:00Z') + $end = [DateTime]::Parse('2025-02-15T00:00:00Z') + + $config = @{ + Mode = 'Scheduled' + Start = $start + End = $end + InternalMessage = 'On vacation' + } + + $result = $provider.EnsureOutOfOffice('user6@contoso.com', $config, $null) + + $result.Changed | Should -Be $true + + # Verify OOF was updated + $oofConfig = $provider.GetOutOfOffice('user6@contoso.com', $null) + $oofConfig.Mode | Should -Be 'Scheduled' + } + + It 'disables Out of Office' { + Add-TestMailbox -PrimarySmtpAddress 'user7@contoso.com' + + # First enable it + $enableConfig = @{ Mode = 'Enabled'; InternalMessage = 'Away' } + $provider.EnsureOutOfOffice('user7@contoso.com', $enableConfig, $null) | Out-Null + + # Now disable it + $disableConfig = @{ Mode = 'Disabled' } + $result = $provider.EnsureOutOfOffice('user7@contoso.com', $disableConfig, $null) + + $result.Changed | Should -Be $true + + # Verify OOF is disabled + $oofConfig = $provider.GetOutOfOffice('user7@contoso.com', $null) + $oofConfig.Mode | Should -Be 'Disabled' + } + + It 'throws error when Config is missing Mode' { + Add-TestMailbox -PrimarySmtpAddress 'user8@contoso.com' + + $badConfig = @{ InternalMessage = 'Test' } + + { $provider.EnsureOutOfOffice('user8@contoso.com', $badConfig, $null) } | + Should -Throw -ExpectedMessage "*must contain 'Mode' key*" + } + + It 'throws error when Scheduled mode is missing Start/End' { + Add-TestMailbox -PrimarySmtpAddress 'user9@contoso.com' + + $badConfig = @{ Mode = 'Scheduled' } + + { $provider.EnsureOutOfOffice('user9@contoso.com', $badConfig, $null) } | + Should -Throw -ExpectedMessage "*requires 'Start' and 'End' keys*" + } + + It 'throws error when Mode is invalid' { + Add-TestMailbox -PrimarySmtpAddress 'user10@contoso.com' + + $badConfig = @{ Mode = 'InvalidMode' } + + { $provider.EnsureOutOfOffice('user10@contoso.com', $badConfig, $null) } | + Should -Throw -ExpectedMessage "*must be 'Disabled', 'Enabled', or 'Scheduled'*" + } + } +} From ee8e2d60cab219d6d7d07de55b3679ea513676c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:27:41 +0000 Subject: [PATCH 04/13] Add example workflows, fix ScriptAnalyzer warnings, verify all tests pass Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../live/complete-leaver-entraid-exo.psd1 | 78 +++++++++++++++++++ .../live/exo-leaver-mailbox-offboarding.psd1 | 45 +++++++++++ .../Private/New-IdleExchangeOnlineAdapter.ps1 | 4 + 3 files changed, 127 insertions(+) create mode 100644 examples/workflows/live/complete-leaver-entraid-exo.psd1 create mode 100644 examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 diff --git a/examples/workflows/live/complete-leaver-entraid-exo.psd1 b/examples/workflows/live/complete-leaver-entraid-exo.psd1 new file mode 100644 index 00000000..10bbaba3 --- /dev/null +++ b/examples/workflows/live/complete-leaver-entraid-exo.psd1 @@ -0,0 +1,78 @@ +@{ + Name = 'Complete Leaver - EntraID + ExchangeOnline Offboarding' + LifecycleEvent = 'Leaver' + Description = 'Complete offboarding workflow: disables EntraID account, converts mailbox to shared, and enables Out of Office.' + Steps = @( + @{ + Name = 'ReportMailboxStatus' + Type = 'IdLE.Step.Mailbox.Report' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + } + } + @{ + Name = 'ConvertToSharedMailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + DesiredType = 'Shared' + } + } + @{ + Name = 'EnableOutOfOffice' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + Config = @{ + Mode = 'Enabled' + InternalMessage = '{{Request.Input.DisplayName}} is no longer with the organization. For assistance, please contact {{Request.Input.ManagerEmail}}.' + ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' + ExternalAudience = 'All' + } + } + } + @{ + Name = 'RevokeAllGroupMemberships' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + Provider = 'Identity' + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Desired = @() + } + } + @{ + Name = 'ClearManager' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + Provider = 'Identity' + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + Name = 'Manager' + Value = $null + } + } + @{ + Name = 'DisableEntraIDAccount' + Type = 'IdLE.Step.DisableIdentity' + With = @{ + Provider = 'Identity' + AuthSessionName = 'MicrosoftGraph' + AuthSessionOptions = @{ Role = 'Admin' } + IdentityKey = '{{Request.Input.UserObjectId}}' + } + } + @{ + Name = 'EmitCompletionEvent' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Complete offboarding finished for {{Request.Input.UserPrincipalName}}: Mailbox converted to Shared, OOF enabled, EntraID account disabled.' + } + } + ) +} diff --git a/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 new file mode 100644 index 00000000..080a16b3 --- /dev/null +++ b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 @@ -0,0 +1,45 @@ +@{ + Name = 'ExchangeOnline Leaver - Mailbox Offboarding' + LifecycleEvent = 'Leaver' + Description = 'Converts mailbox to shared, enables Out of Office, and optionally delegates access for offboarding users.' + Steps = @( + @{ + Name = 'ReportMailboxStatus' + Type = 'IdLE.Step.Mailbox.Report' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + } + } + @{ + Name = 'ConvertToSharedMailbox' + Type = 'IdLE.Step.Mailbox.Type.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + DesiredType = 'Shared' + } + } + @{ + Name = 'EnableOutOfOffice' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + Config = @{ + Mode = 'Enabled' + InternalMessage = '{{Request.Input.DisplayName}} is no longer with the organization. For assistance, please contact {{Request.Input.ManagerEmail}}.' + ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' + ExternalAudience = 'All' + } + } + } + @{ + Name = 'EmitCompletionEvent' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Mailbox offboarding completed for {{Request.Input.UserPrincipalName}}.' + } + } + ) +} diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index 27240405..9c56d78e 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -56,6 +56,7 @@ function New-IdleExchangeOnlineAdapter { # GetMailbox: Retrieve mailbox details by identity (UPN or SMTP address) $adapter | Add-Member -MemberType ScriptMethod -Name GetMailbox -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] @@ -99,6 +100,7 @@ function New-IdleExchangeOnlineAdapter { # SetMailboxType: Convert mailbox type (User <-> Shared) $adapter | Add-Member -MemberType ScriptMethod -Name SetMailboxType -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] @@ -139,6 +141,7 @@ function New-IdleExchangeOnlineAdapter { # GetMailboxAutoReplyConfiguration: Get Out of Office settings $adapter | Add-Member -MemberType ScriptMethod -Name GetMailboxAutoReplyConfiguration -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] @@ -185,6 +188,7 @@ function New-IdleExchangeOnlineAdapter { # SetMailboxAutoReplyConfiguration: Update Out of Office settings $adapter | Add-Member -MemberType ScriptMethod -Name SetMailboxAutoReplyConfiguration -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] From 84e923d3ef9e0696c5e2a4a0ee48dd20845c2ea0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:30:27 +0000 Subject: [PATCH 05/13] Address code review feedback: add constants and document code duplication rationale Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 8 ++++++-- .../Public/New-IdleExchangeOnlineProvider.ps1 | 4 +++- .../Private/Invoke-IdleProviderMethod.ps1 | 8 ++++++++ .../Private/Test-IdleProviderMethodParameter.ps1 | 3 +++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index 9c56d78e..de66f599 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -18,6 +18,10 @@ function New-IdleExchangeOnlineAdapter { [switch] $UseRestApi ) + # Regex patterns for sanitizing error messages (redact sensitive data) + $script:BearerTokenPattern = 'Bearer\s+[^\s]+' + $script:TokenAssignmentPattern = 'token[^\s]*\s*=\s*[^\s,;]+' + $adapter = [pscustomobject]@{ PSTypeName = 'IdLE.ExchangeOnlineAdapter' UseRestApi = [bool]$UseRestApi @@ -42,8 +46,8 @@ function New-IdleExchangeOnlineAdapter { $errorMessage = "Exchange Online command '$CommandName' failed" if ($_.Exception.Message) { # Sanitize error message to avoid leaking tokens/secrets - $sanitized = $_.Exception.Message -replace 'Bearer\s+[^\s]+', 'Bearer ' - $sanitized = $sanitized -replace 'token[^\s]*\s*=\s*[^\s,;]+', 'token=' + $sanitized = $_.Exception.Message -replace $script:BearerTokenPattern, 'Bearer ' + $sanitized = $sanitized -replace $script:TokenAssignmentPattern, 'token=' $errorMessage += " | $sanitized" } diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index 9d6d0a60..b73e1629 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -336,9 +336,11 @@ function New-IdleExchangeOnlineProvider { } 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 60 -or $endDiff -gt 60) { + if ($startDiff -gt $dateComparisonToleranceSeconds -or $endDiff -gt $dateComparisonToleranceSeconds) { $changed = $true } } diff --git a/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 b/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 index a8d28431..cc73ef7d 100644 --- a/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 +++ b/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 @@ -1,5 +1,13 @@ # Invokes a provider method with optional AuthSession support. # Handles auth session acquisition, parameter detection, and backwards-compatible fallback. +# +# NOTE: This is a copy of the function from IdLE.Steps.Common/Private/Invoke-IdleProviderMethod.ps1 +# The duplication is necessary because: +# 1. The function is Private in IdLE.Steps.Common and not exported +# 2. RequiredModules does not make Private functions available to dependent modules +# 3. Making it Public would expose implementation details that should remain internal +# This is the same pattern used by IdLE.Steps.DirectorySync which calls this function +# via the Common module's internal scope when RequiredModules is declared. function Invoke-IdleProviderMethod { [CmdletBinding()] diff --git a/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 b/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 index 870217a6..692c5979 100644 --- a/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 +++ b/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 @@ -1,5 +1,8 @@ # Tests whether a provider method supports a given parameter. # Supports ScriptMethod (AST inspection) and compiled methods (reflection). +# +# NOTE: This is a copy of the function from IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1 +# See Invoke-IdleProviderMethod.ps1 in this directory for explanation of why duplication is necessary. function Test-IdleProviderMethodParameter { [CmdletBinding()] From ab1a70bab89b132069d3dfcfb619d0cf57bc599d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:49:42 +0000 Subject: [PATCH 06/13] Address PR feedback: standardize manifest format, simplify READMEs, rename DesiredType to MailboxType Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../live/complete-leaver-entraid-exo.psd1 | 2 +- .../live/exo-leaver-mailbox-offboarding.psd1 | 2 +- .../IdLE.Provider.ExchangeOnline.psd1 | 19 +- src/IdLE.Provider.ExchangeOnline/README.md | 138 ++----------- .../IdLE.Steps.Mailbox.psd1 | 27 ++- .../Invoke-IdleStepMailboxTypeEnsure.ps1 | 18 +- src/IdLE.Steps.Mailbox/README.md | 181 ++---------------- ...Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 | 24 +-- 8 files changed, 79 insertions(+), 332 deletions(-) diff --git a/examples/workflows/live/complete-leaver-entraid-exo.psd1 b/examples/workflows/live/complete-leaver-entraid-exo.psd1 index 10bbaba3..9ba399e3 100644 --- a/examples/workflows/live/complete-leaver-entraid-exo.psd1 +++ b/examples/workflows/live/complete-leaver-entraid-exo.psd1 @@ -17,7 +17,7 @@ With = @{ Provider = 'ExchangeOnline' IdentityKey = '{{Request.Input.UserPrincipalName}}' - DesiredType = 'Shared' + MailboxType = 'Shared' } } @{ diff --git a/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 index 080a16b3..7ec6f5f0 100644 --- a/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 +++ b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 @@ -17,7 +17,7 @@ With = @{ Provider = 'ExchangeOnline' IdentityKey = '{{Request.Input.UserPrincipalName}}' - DesiredType = 'Shared' + MailboxType = 'Shared' } } @{ diff --git a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 index 8db7a892..19b8ef72 100644 --- a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 +++ b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psd1 @@ -1,25 +1,22 @@ @{ RootModule = 'IdLE.Provider.ExchangeOnline.psm1' - ModuleVersion = '0.9.0' + ModuleVersion = '0.9.0' GUID = 'e8f9a3b1-4c2d-4a5b-9f7e-3d2c1a9b8e7f' - Author = 'IdLE Contributors' - CompanyName = 'IdLE Project' - Copyright = '(c) 2025 IdLE Contributors. Licensed under Apache License 2.0.' - Description = 'Exchange Online mailbox provider for IdentityLifecycleEngine' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Exchange Online mailbox provider implementation for IdLE (requires ExchangeOnlineManagement module).' PowerShellVersion = '7.0' - FunctionsToExport = @('New-IdleExchangeOnlineProvider') - CmdletsToExport = @() - VariablesToExport = @() - AliasesToExport = @() + FunctionsToExport = @( + 'New-IdleExchangeOnlineProvider' + ) PrivateData = @{ PSData = @{ Tags = @('IdentityLifecycleEngine', 'IdLE', 'Provider', 'ExchangeOnline', 'Mailbox') LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' - IconUri = '' - ReleaseNotes = 'Exchange Online provider for mailbox lifecycle management' + ContactEmail = '13959569+blindzero@users.noreply.github.com' } } } diff --git a/src/IdLE.Provider.ExchangeOnline/README.md b/src/IdLE.Provider.ExchangeOnline/README.md index aeb54db0..3906e020 100644 --- a/src/IdLE.Provider.ExchangeOnline/README.md +++ b/src/IdLE.Provider.ExchangeOnline/README.md @@ -1,134 +1,38 @@ # IdLE.Provider.ExchangeOnline -Exchange Online mailbox provider for **IdentityLifecycleEngine (IdLE)**. +Exchange Online mailbox provider for IdLE. -## Overview - -This provider integrates IdLE with **Microsoft Exchange Online** for mailbox lifecycle management operations, including: - -- Mailbox reporting (type, configuration, status) -- Mailbox type conversions (User ↔ Shared, Room, Equipment) -- Out of Office (OOF) configuration management - -The provider implements the **mailbox-specific provider contract** used by the `IdLE.Steps.Mailbox` step pack. - -## Prerequisites - -- PowerShell 7.0 or later -- **ExchangeOnlineManagement** PowerShell module: `Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser` -- Exchange Online subscription (Microsoft 365 / Office 365) -- Appropriate permissions: - - **Delegated**: Exchange Administrator or Global Administrator role - - **App-only**: Application permissions with `Exchange.ManageAsApp` (certificate-based, Windows only for MVP) - -## Authentication - -The provider uses the **AuthSessionBroker** pattern for runtime credential selection. - -### Delegated (Interactive) Auth +## Quick Start ```powershell -# Host establishes connection -Connect-ExchangeOnline -UserPrincipalName admin@contoso.com - -# Create provider -$provider = New-IdleExchangeOnlineProvider +# Automatically imported when you import IdLE +Import-Module IdLE -# Use in plan -$plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ - ExchangeOnline = $provider -} -``` - -### App-Only (Certificate) Auth (Windows Only) - -```powershell -# Host establishes connection with certificate -Connect-ExchangeOnline ` - -CertificateThumbprint $thumbprint ` - -AppId $appId ` - -Organization $tenantId +# Host establishes Exchange Online session (delegated or app-only) +Connect-ExchangeOnline -UserPrincipalName admin@contoso.com # Create provider $provider = New-IdleExchangeOnlineProvider -# Use in plan -$plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ +# Use in workflows +$providers = @{ ExchangeOnline = $provider } +$plan = New-IdlePlan -WorkflowPath '.\leaver.psd1' -Request $request -Providers $providers ``` -> **Note**: App-only auth is Windows-only for MVP. Cross-platform support is planned for future releases. - -## Capabilities - -The provider advertises the following capabilities: - -- `IdLE.Mailbox.Read` - Read mailbox details -- `IdLE.Mailbox.Type.Ensure` - Convert mailbox type (User/Shared/Room/Equipment) -- `IdLE.Mailbox.OutOfOffice.Ensure` - Configure Out of Office settings - -## Identity Addressing - -The provider supports: - -- **UserPrincipalName (UPN)** - `john.doe@contoso.com` (preferred) -- **Primary SMTP address** - `john.doe@contoso.com` -- **Mailbox GUID** - `12345678-1234-1234-1234-123456789abc` (most deterministic) - -The canonical identity key for all outputs is the **primary SMTP address**. - -## Provider Contract Methods - -### GetMailbox - -Retrieve mailbox details. - -```powershell -$mailbox = $provider.GetMailbox($identityKey, $authSession) -# Returns: PSCustomObject with PSTypeName = 'IdLE.Mailbox' -``` - -### EnsureMailboxType - -Idempotent mailbox type conversion. - -```powershell -$result = $provider.EnsureMailboxType($identityKey, 'Shared', $authSession) -# Returns: IdLE.ProviderResult with Changed flag -``` - -### GetOutOfOffice - -Retrieve Out of Office configuration. - -```powershell -$oofConfig = $provider.GetOutOfOffice($identityKey, $authSession) -# Returns: PSCustomObject with PSTypeName = 'IdLE.MailboxOutOfOffice' -``` - -### EnsureOutOfOffice - -Idempotent Out of Office configuration. - -```powershell -$config = @{ - Mode = 'Enabled' - InternalMessage = 'I am out of office.' - ExternalMessage = 'I am currently unavailable.' - ExternalAudience = 'All' -} -$result = $provider.EnsureOutOfOffice($identityKey, $config, $authSession) -# Returns: IdLE.ProviderResult with Changed flag -``` - -## See Also +## Prerequisites -- [IdLE.Steps.Mailbox](../IdLE.Steps.Mailbox/README.md) - Provider-agnostic mailbox step pack -- [Provider Documentation](../../docs/reference/providers/provider-exchangeonline.md) -- [Capability Documentation](../../docs/advanced/provider-capabilities.md) -- [ExchangeOnlineManagement Module](https://learn.microsoft.com/en-us/powershell/exchange/exchange-online-powershell) +- PowerShell 7.0+ +- ExchangeOnlineManagement module (`Install-Module ExchangeOnlineManagement`) +- Authenticated Exchange Online session (host-managed) +- **App-only auth**: Windows only (MVP) -## License +## Documentation -Apache License 2.0 - see [LICENSE.md](../../LICENSE.md) +See **[Complete Provider Documentation](../../docs/reference/providers/provider-exchangeonline.md)** for: +- Full usage guide and examples +- Capabilities and mailbox steps +- Authentication patterns (delegated + app-only) +- Required permissions +- Troubleshooting diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 index 534f945a..b2a7a837 100644 --- a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 @@ -1,32 +1,27 @@ @{ RootModule = 'IdLE.Steps.Mailbox.psm1' - ModuleVersion = '0.9.0' + ModuleVersion = '0.9.0' GUID = 'f7e6d5c4-b3a2-9180-7e6f-5d4c3b2a1908' - Author = 'IdLE Contributors' - CompanyName = 'IdLE Project' - Copyright = '(c) 2025 IdLE Contributors. Licensed under Apache License 2.0.' - Description = 'Provider-agnostic mailbox step pack for IdentityLifecycleEngine' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Provider-agnostic mailbox step pack for IdLE.' PowerShellVersion = '7.0' RequiredModules = @('..\IdLE.Steps.Common\IdLE.Steps.Common.psd1') FunctionsToExport = @( - 'Get-IdleStepMetadataCatalog' - 'Invoke-IdleStepMailboxReport' - 'Invoke-IdleStepMailboxTypeEnsure' + 'Get-IdleStepMetadataCatalog', + 'Invoke-IdleStepMailboxReport', + 'Invoke-IdleStepMailboxTypeEnsure', 'Invoke-IdleStepMailboxOutOfOfficeEnsure' ) - CmdletsToExport = @() - VariablesToExport = @() - AliasesToExport = @() PrivateData = @{ PSData = @{ - Tags = @('IdentityLifecycleEngine', 'IdLE', 'Steps', 'Mailbox', 'ExchangeOnline') - LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' - ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' - IconUri = '' - ReleaseNotes = 'Provider-agnostic mailbox step pack for IdLE' + Tags = @('IdentityLifecycleEngine', 'IdLE', 'Steps', 'Mailbox') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' + ContactEmail = '13959569+blindzero@users.noreply.github.com' } } } diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 index b0e77435..2d5146b3 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 @@ -6,7 +6,7 @@ function Invoke-IdleStepMailboxTypeEnsure { .DESCRIPTION This is a provider-agnostic step. The host must supply a provider instance via Context.Providers[]. The provider must implement an EnsureMailboxType - method with the signature (IdentityKey, DesiredType, AuthSession) and return an object + method with the signature (IdentityKey, MailboxType, AuthSession) and return an object that contains a boolean property 'Changed'. The step is idempotent by design: it converges state to the desired type. @@ -39,9 +39,9 @@ function Invoke-IdleStepMailboxTypeEnsure { Name = 'Convert to shared mailbox' Type = 'IdLE.Step.Mailbox.Type.Ensure' With = @{ - Provider = 'ExchangeOnline' - IdentityKey = 'user@contoso.com' - DesiredType = 'Shared' + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + MailboxType = 'Shared' } } #> @@ -61,16 +61,16 @@ function Invoke-IdleStepMailboxTypeEnsure { throw "Mailbox.Type.Ensure requires 'With' to be a hashtable." } - foreach ($key in @('IdentityKey', 'DesiredType')) { + foreach ($key in @('IdentityKey', 'MailboxType')) { if (-not $with.ContainsKey($key)) { throw "Mailbox.Type.Ensure requires With.$key." } } - # Validate DesiredType + # Validate MailboxType $validTypes = @('User', 'Shared', 'Room', 'Equipment') - if ($with.DesiredType -notin $validTypes) { - throw "Mailbox.Type.Ensure requires With.DesiredType to be one of: $($validTypes -join ', '). Got: $($with.DesiredType)" + if ($with.MailboxType -notin $validTypes) { + throw "Mailbox.Type.Ensure requires With.MailboxType to be one of: $($validTypes -join ', '). Got: $($with.MailboxType)" } $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } @@ -95,7 +95,7 @@ function Invoke-IdleStepMailboxTypeEnsure { -With $with ` -ProviderAlias $providerAlias ` -MethodName 'EnsureMailboxType' ` - -MethodArguments @([string]$with.IdentityKey, [string]$with.DesiredType) + -MethodArguments @([string]$with.IdentityKey, [string]$with.MailboxType) $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md index 1623b841..68e22785 100644 --- a/src/IdLE.Steps.Mailbox/README.md +++ b/src/IdLE.Steps.Mailbox/README.md @@ -1,181 +1,32 @@ # IdLE.Steps.Mailbox -Provider-agnostic mailbox step pack for **IdentityLifecycleEngine (IdLE)**. +Provider-agnostic mailbox step pack for IdLE. -## Overview - -This step pack provides mailbox-focused lifecycle operations that work with any provider -implementing the **mailbox provider contract**. - -The steps are **domain-oriented** (mailbox operations) rather than provider-branded, -ensuring maximum portability across Exchange Online, on-premises Exchange, and future providers. - -## Step Types - -### IdLE.Step.Mailbox.Report - -Read mailbox details and return a structured snapshot. - -```powershell -@{ - Name = 'Report user mailbox' - Type = 'IdLE.Step.Mailbox.Report' - With = @{ - Provider = 'ExchangeOnline' - IdentityKey = 'user@contoso.com' - } -} -``` - -**Returns**: Mailbox object in `State.Mailbox` (read-only, Changed = false) - ---- - -### IdLE.Step.Mailbox.Type.Ensure - -Idempotent mailbox type conversion (User ↔ Shared, Room, Equipment). +## Quick Start ```powershell +# Step example: Convert to shared mailbox @{ - Name = 'Convert to shared mailbox' + Name = 'ConvertToSharedMailbox' Type = 'IdLE.Step.Mailbox.Type.Ensure' With = @{ Provider = 'ExchangeOnline' - IdentityKey = 'user@contoso.com' - DesiredType = 'Shared' - } -} -``` - -**Supported types**: -- `User` - Regular user mailbox -- `Shared` - Shared mailbox (team mailbox) -- `Room` - Room resource mailbox -- `Equipment` - Equipment resource mailbox - -**Returns**: StepResult with `Changed` flag (true if conversion occurred) - ---- - -### IdLE.Step.Mailbox.OutOfOffice.Ensure - -Idempotent Out of Office (OOF) configuration. - -```powershell -# Enable OOF -@{ - Name = 'Enable Out of Office' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' - With = @{ - Provider = 'ExchangeOnline' - IdentityKey = 'user@contoso.com' - Config = @{ - Mode = 'Enabled' - InternalMessage = 'I am out of office.' - ExternalMessage = 'I am currently unavailable.' - ExternalAudience = 'All' - } - } -} - -# Scheduled OOF -@{ - Name = 'Schedule Out of Office' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' - With = @{ - Provider = 'ExchangeOnline' - IdentityKey = 'user@contoso.com' - Config = @{ - Mode = 'Scheduled' - Start = '2025-02-01T00:00:00Z' - End = '2025-02-15T00:00:00Z' - InternalMessage = 'I am on vacation until February 15.' - } - } -} - -# Disable OOF -@{ - Name = 'Disable Out of Office' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' - With = @{ - Provider = 'ExchangeOnline' - IdentityKey = 'user@contoso.com' - Config = @{ Mode = 'Disabled' } + IdentityKey = '{{Request.Input.UserPrincipalName}}' + MailboxType = 'Shared' } } ``` -**Config shape** (data-only hashtable): -- `Mode`: `'Disabled'` | `'Enabled'` | `'Scheduled'` (required) -- `Start`: DateTime (required when Mode = 'Scheduled') -- `End`: DateTime (required when Mode = 'Scheduled') -- `InternalMessage`: string (optional) -- `ExternalMessage`: string (optional) -- `ExternalAudience`: `'None'` | `'Known'` | `'All'` (optional) - -**Returns**: StepResult with `Changed` flag (true if OOF settings were updated) - ---- - -## Required Provider Capabilities - -Each step declares required provider capabilities via metadata catalog: - -| Step Type | Required Capabilities | -|-----------|----------------------| -| `IdLE.Step.Mailbox.Report` | `IdLE.Mailbox.Read` | -| `IdLE.Step.Mailbox.Type.Ensure` | `IdLE.Mailbox.Read`, `IdLE.Mailbox.Type.Ensure` | -| `IdLE.Step.Mailbox.OutOfOffice.Ensure` | `IdLE.Mailbox.Read`, `IdLE.Mailbox.OutOfOffice.Ensure` | - -The IdLE planner automatically validates that the selected provider advertises these capabilities. - ---- - -## Authentication Convention - -**Option B (Convention)**: If `With.AuthSessionName` is not specified, the step defaults it to `With.Provider`. - -Example: -```powershell -With = @{ - Provider = 'ExchangeOnline' - IdentityKey = 'user@contoso.com' - # AuthSessionName defaults to 'ExchangeOnline' if omitted -} -``` - -Explicit override: -```powershell -With = @{ - Provider = 'ExchangeOnline' - IdentityKey = 'user@contoso.com' - AuthSessionName = 'ExchangeOnline-Tier0' - AuthSessionOptions = @{ Role = 'Tier0' } -} -``` - ---- - -## Provider Contract - -Providers implementing the mailbox contract must expose: - -- `GetMailbox(IdentityKey, AuthSession)` → returns mailbox object -- `EnsureMailboxType(IdentityKey, DesiredType, AuthSession)` → returns result with `Changed` flag -- `GetOutOfOffice(IdentityKey, AuthSession)` → returns OOF config object -- `EnsureOutOfOffice(IdentityKey, Config, AuthSession)` → returns result with `Changed` flag - -Reference implementation: **IdLE.Provider.ExchangeOnline** - ---- - -## See Also +## Step Types -- [IdLE.Provider.ExchangeOnline](../IdLE.Provider.ExchangeOnline/README.md) - Exchange Online provider -- [Capability Documentation](../../docs/advanced/provider-capabilities.md) -- [Step Reference](../../docs/reference/steps-and-metadata.md) +- **IdLE.Step.Mailbox.Report** - Read mailbox details +- **IdLE.Step.Mailbox.Type.Ensure** - Convert mailbox type (User/Shared/Room/Equipment) +- **IdLE.Step.Mailbox.OutOfOffice.Ensure** - Configure Out of Office settings -## License +## Documentation -Apache License 2.0 - see [LICENSE.md](../../LICENSE.md) +See **[Complete Step Documentation](../../docs/reference/steps/steps-mailbox.md)** for: +- Detailed step usage and parameters +- Provider contract requirements +- Configuration examples +- Best practices diff --git a/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 b/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 index 3db2c682..2535dc95 100644 --- a/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 +++ b/tests/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 @@ -22,7 +22,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { } $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureMailboxType -Value { - param($IdentityKey, $DesiredType, $AuthSession) + param($IdentityKey, $MailboxType, $AuthSession) if (-not $this.Store.ContainsKey($IdentityKey)) { throw "Mailbox '$IdentityKey' not found." @@ -30,10 +30,10 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { $mailbox = $this.Store[$IdentityKey] $currentType = $mailbox['Type'] - $changed = ($currentType -ne $DesiredType) + $changed = ($currentType -ne $MailboxType) if ($changed) { - $mailbox['Type'] = $DesiredType + $mailbox['Type'] = $MailboxType } return [pscustomobject]@{ @@ -41,7 +41,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { Operation = 'EnsureMailboxType' IdentityKey = $IdentityKey Changed = $changed - Type = $DesiredType + Type = $MailboxType } } -Force @@ -70,7 +70,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' - DesiredType = 'Shared' + MailboxType = 'Shared' } } } @@ -97,13 +97,13 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { $result.Changed | Should -Be $false } - It 'throws when DesiredType is invalid' { + It 'throws when MailboxType is invalid' { $step = $script:StepTemplate - $step.With.DesiredType = 'InvalidType' + $step.With.MailboxType = 'InvalidType' $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' { & $handler -Context $script:Context -Step $step } | - Should -Throw "*DesiredType to be one of: User, Shared, Room, Equipment*" + Should -Throw "*MailboxType to be one of: User, Shared, Room, Equipment*" } It 'throws when provider is missing' { @@ -121,12 +121,12 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" } - It 'throws when DesiredType is missing' { + It 'throws when MailboxType is missing' { $step = $script:StepTemplate - $step.With.Remove('DesiredType') + $step.With.Remove('MailboxType') $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' - { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.DesiredType*" + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.MailboxType*" } It 'supports all valid mailbox types' { @@ -136,7 +136,7 @@ Describe 'Invoke-IdleStepMailboxTypeEnsure' { $script:Provider.Store['user@contoso.com']['Type'] = $startType $step = $script:StepTemplate - $step.With.DesiredType = $type + $step.With.MailboxType = $type $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxTypeEnsure' $result = & $handler -Context $script:Context -Step $step From b06ec946930f3e48612a8fa406e025d134ccb7ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 18:58:50 +0000 Subject: [PATCH 07/13] Rename Mailbox.Report to Mailbox.GetInfo consistently across PR Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../live/complete-leaver-entraid-exo.psd1 | 4 ++-- .../live/exo-leaver-mailbox-offboarding.psd1 | 4 ++-- src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 | 2 +- .../Public/Get-IdleStepMetadataCatalog.ps1 | 6 +++--- ...eport.ps1 => Invoke-IdleStepMailboxGetInfo.ps1} | 10 +++++----- src/IdLE.Steps.Mailbox/README.md | 2 +- ...ps1 => Invoke-IdleStepMailboxGetInfo.Tests.ps1} | 14 +++++++------- 7 files changed, 21 insertions(+), 21 deletions(-) rename src/IdLE.Steps.Mailbox/Public/{Invoke-IdleStepMailboxReport.ps1 => Invoke-IdleStepMailboxGetInfo.ps1} (92%) rename tests/{Invoke-IdleStepMailboxReport.Tests.ps1 => Invoke-IdleStepMailboxGetInfo.Tests.ps1} (90%) diff --git a/examples/workflows/live/complete-leaver-entraid-exo.psd1 b/examples/workflows/live/complete-leaver-entraid-exo.psd1 index 9ba399e3..0fa3adf5 100644 --- a/examples/workflows/live/complete-leaver-entraid-exo.psd1 +++ b/examples/workflows/live/complete-leaver-entraid-exo.psd1 @@ -4,8 +4,8 @@ Description = 'Complete offboarding workflow: disables EntraID account, converts mailbox to shared, and enables Out of Office.' Steps = @( @{ - Name = 'ReportMailboxStatus' - Type = 'IdLE.Step.Mailbox.Report' + Name = 'GetMailboxInfo' + Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' IdentityKey = '{{Request.Input.UserPrincipalName}}' diff --git a/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 index 7ec6f5f0..88ef3a60 100644 --- a/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 +++ b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 @@ -4,8 +4,8 @@ Description = 'Converts mailbox to shared, enables Out of Office, and optionally delegates access for offboarding users.' Steps = @( @{ - Name = 'ReportMailboxStatus' - Type = 'IdLE.Step.Mailbox.Report' + Name = 'GetMailboxInfo' + Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' IdentityKey = '{{Request.Input.UserPrincipalName}}' diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 index b2a7a837..38e4cdad 100644 --- a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 @@ -11,7 +11,7 @@ FunctionsToExport = @( 'Get-IdleStepMetadataCatalog', - 'Invoke-IdleStepMailboxReport', + 'Invoke-IdleStepMailboxGetInfo', 'Invoke-IdleStepMailboxTypeEnsure', 'Invoke-IdleStepMailboxOutOfOfficeEnsure' ) diff --git a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 index a3fec316..8d60b21d 100644 --- a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 @@ -18,7 +18,7 @@ function Get-IdleStepMetadataCatalog { .EXAMPLE $metadata = Get-IdleStepMetadataCatalog - $metadata['IdLE.Step.Mailbox.Report'].RequiredCapabilities + $metadata['IdLE.Step.Mailbox.GetInfo'].RequiredCapabilities # Returns: @('IdLE.Mailbox.Read') #> [CmdletBinding()] @@ -26,8 +26,8 @@ function Get-IdleStepMetadataCatalog { $catalog = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) - # IdLE.Step.Mailbox.Report - read mailbox details - $catalog['IdLE.Step.Mailbox.Report'] = @{ + # IdLE.Step.Mailbox.GetInfo - read mailbox details + $catalog['IdLE.Step.Mailbox.GetInfo'] = @{ RequiredCapabilities = @('IdLE.Mailbox.Read') } diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxReport.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 similarity index 92% rename from src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxReport.ps1 rename to src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 index e220b345..52c06b59 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxReport.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 @@ -1,4 +1,4 @@ -function Invoke-IdleStepMailboxReport { +function Invoke-IdleStepMailboxGetInfo { <# .SYNOPSIS Retrieves mailbox details and returns a structured report. @@ -29,8 +29,8 @@ function Invoke-IdleStepMailboxReport { .EXAMPLE # In workflow definition: @{ - Name = 'Report user mailbox' - Type = 'IdLE.Step.Mailbox.Report' + Name = 'Get mailbox info' + Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' @@ -50,11 +50,11 @@ function Invoke-IdleStepMailboxReport { $with = $Step.With if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "Mailbox.Report requires 'With' to be a hashtable." + throw "Mailbox.GetInfo requires 'With' to be a hashtable." } if (-not $with.ContainsKey('IdentityKey')) { - throw "Mailbox.Report requires With.IdentityKey." + throw "Mailbox.GetInfo requires With.IdentityKey." } $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'ExchangeOnline' } diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md index 68e22785..11ed3990 100644 --- a/src/IdLE.Steps.Mailbox/README.md +++ b/src/IdLE.Steps.Mailbox/README.md @@ -19,7 +19,7 @@ Provider-agnostic mailbox step pack for IdLE. ## Step Types -- **IdLE.Step.Mailbox.Report** - Read mailbox details +- **IdLE.Step.Mailbox.GetInfo** - Read mailbox details - **IdLE.Step.Mailbox.Type.Ensure** - Convert mailbox type (User/Shared/Room/Equipment) - **IdLE.Step.Mailbox.OutOfOffice.Ensure** - Configure Out of Office settings diff --git a/tests/Invoke-IdleStepMailboxReport.Tests.ps1 b/tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 similarity index 90% rename from tests/Invoke-IdleStepMailboxReport.Tests.ps1 rename to tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 index c23b78d6..54b7bf64 100644 --- a/tests/Invoke-IdleStepMailboxReport.Tests.ps1 +++ b/tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 @@ -13,7 +13,7 @@ BeforeAll { } } -Describe 'Invoke-IdleStepMailboxReport' { +Describe 'Invoke-IdleStepMailboxGetInfo' { BeforeEach { # Create mock ExchangeOnline provider $script:Provider = [pscustomobject]@{ @@ -58,8 +58,8 @@ Describe 'Invoke-IdleStepMailboxReport' { } -Force $script:StepTemplate = [pscustomobject]@{ - Name = 'Report mailbox' - Type = 'IdLE.Step.Mailbox.Report' + Name = 'Get mailbox info' + Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' @@ -68,7 +68,7 @@ Describe 'Invoke-IdleStepMailboxReport' { } It 'retrieves mailbox and returns data in State' { - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxReport' + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' $result = & $handler -Context $script:Context -Step $script:StepTemplate $result.Status | Should -Be 'Completed' @@ -84,7 +84,7 @@ Describe 'Invoke-IdleStepMailboxReport' { $step = $script:StepTemplate $step.With.Remove('AuthSessionName') - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxReport' + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' $result = & $handler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' @@ -95,7 +95,7 @@ Describe 'Invoke-IdleStepMailboxReport' { It 'throws when provider is missing' { $script:Context.Providers.Clear() - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxReport' + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * } @@ -103,7 +103,7 @@ Describe 'Invoke-IdleStepMailboxReport' { $step = $script:StepTemplate $step.With.Remove('IdentityKey') - $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxReport' + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxGetInfo' { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" } } From 2f379d44e4b1bf6dd3dad0843dada4fc95343a83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:00:11 +0000 Subject: [PATCH 08/13] Add documentation for OOF message formatting and template substitution Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/usage/workflows.md | 110 ++++++++++++++++++ ...nvoke-IdleStepMailboxOutOfOfficeEnsure.ps1 | 21 ++++ 2 files changed, 131 insertions(+) diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index df02f4a6..d9febd8a 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -114,6 +114,116 @@ Prefer explicit reference fields over implicit parsing: This makes configurations safe and statically validatable. +### Template Substitution in Workflow Configs + +IdLE supports **template substitution** using `{{...}}` syntax in string values within workflow configurations. This allows dynamic values from the lifecycle request to be inserted during plan building. + +**Syntax:** +```powershell +'{{Request.Input.PropertyName}}' +``` + +**Example - Simple substitution:** +```powershell +@{ + Name = 'Create user account' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + Provider = 'Identity' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + DisplayName = '{{Request.Input.DisplayName}}' + } +} +``` + +**Example - Out of Office message with substitution:** +```powershell +@{ + Name = 'Enable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + Config = @{ + Mode = 'Enabled' + InternalMessage = '{{Request.Input.DisplayName}} is no longer with the organization. For assistance, please contact {{Request.Input.ManagerEmail}}.' + ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' + } + } +} +``` + +**How Request.Input is populated:** + +The host creates the lifecycle request and provides the Input hashtable: + +```powershell +$request = New-IdleLifecycleRequest ` + -LifecycleEvent 'Leaver' ` + -IdentityKeys @{ UserPrincipalName = 'john.doe@contoso.com' } ` + -Input @{ + UserPrincipalName = 'john.doe@contoso.com' + DisplayName = 'John Doe' + ManagerEmail = 'jane.smith@contoso.com' + Department = 'Engineering' + } +``` + +Template substitution happens during `New-IdlePlan`, replacing `{{...}}` with actual values from the request. + +**Limitations for formatted messages:** + +- **Single-line only**: Multi-line strings with line breaks are not supported in workflow configs due to the data-only constraint +- **No script expressions**: Cannot use `@"..."@` or script blocks for string formatting +- **Simple substitution only**: Only `{{Path.To.Value}}` syntax is supported, no formatting options + +**For complex message formatting:** + +If you need multi-line messages or complex formatting: + +1. **External templates**: Store message templates in separate files and reference them by name, with the host loading and formatting them before creating the request +2. **Pre-formatted in Input**: Format the complete message in the host code and pass it as a single Input parameter +3. **Step-level formatting**: Create a custom step that handles message formatting logic + +**Example - Pre-formatted message approach:** +```powershell +# Host code +$oofMessage = @" +$displayName is no longer with the organization. + +For assistance, please contact: +- Manager: $managerEmail +- HR: hr@contoso.com +- IT Support: it@contoso.com +"@ + +$request = New-IdleLifecycleRequest ` + -LifecycleEvent 'Leaver' ` + -Input @{ + UserPrincipalName = 'john.doe@contoso.com' + OOFInternalMessage = $oofMessage + } +``` + +```powershell +# Workflow +@{ + Name = 'Enable Out of Office' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + Config = @{ + Mode = 'Enabled' + InternalMessage = '{{Request.Input.OOFInternalMessage}}' + ExternalMessage = '{{Request.Input.OOFExternalMessage}}' + } + } +} +``` + +This approach keeps workflows data-only while allowing rich message formatting in the host code. + ## Related - [Steps](steps.md) diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 index 178cd006..98401d27 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -52,6 +52,27 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { } } + .EXAMPLE + # In workflow definition (with template substitution for dynamic values): + @{ + Name = 'Enable Out of Office for Leaver' + Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = '{{Request.Input.UserPrincipalName}}' + Config = @{ + Mode = 'Enabled' + InternalMessage = '{{Request.Input.DisplayName}} is no longer with the organization. For assistance, please contact {{Request.Input.ManagerEmail}}.' + ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' + ExternalAudience = 'All' + } + } + } + # Note: Template substitution ({{...}}) happens during plan building. + # Request.Input parameters are provided by the host when creating the lifecycle request. + # Multi-line messages with line breaks are not currently supported in workflow configs + # due to the data-only constraint. For complex formatting, consider external templates. + .EXAMPLE # In workflow definition (scheduled OOF): @{ From 1187f595b65a4721e4377cf6eff7fb5ec8f8d792 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:29:31 +0000 Subject: [PATCH 09/13] Fix Export-ModuleMember to use explicit function names for deterministic behavior Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../IdLE.Provider.ExchangeOnline.psm1 | 6 ++++-- src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 index fb3cd38e..2a8fb111 100644 --- a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 +++ b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 @@ -15,5 +15,7 @@ foreach ($script in ($PrivateScripts | Sort-Object Name)) { . $script.FullName } -# Export Public functions -Export-ModuleMember -Function $PublicScripts.BaseName +# Export Public functions - explicit list for deterministic behavior +Export-ModuleMember -Function @( + 'New-IdleExchangeOnlineProvider' +) diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 index d227a88b..22523d23 100644 --- a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 @@ -15,5 +15,10 @@ foreach ($script in ($PublicScripts | Sort-Object Name)) { . $script.FullName } -# Export Public functions -Export-ModuleMember -Function $PublicScripts.BaseName +# Export Public functions - explicit list for deterministic behavior +Export-ModuleMember -Function @( + 'Get-IdleStepMetadataCatalog', + 'Invoke-IdleStepMailboxGetInfo', + 'Invoke-IdleStepMailboxTypeEnsure', + 'Invoke-IdleStepMailboxOutOfOfficeEnsure' +) From da84bceef06f55ea8bdca3441fb827a5064dc01c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:03:15 +0000 Subject: [PATCH 10/13] Fix idempotency: include ExternalAudience in OOF change detection Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Public/New-IdleExchangeOnlineProvider.ps1 | 3 ++ .../ExchangeOnlineProvider.Tests.ps1 | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index b73e1629..f3e2589d 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -334,6 +334,9 @@ function New-IdleExchangeOnlineProvider { 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 diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index bea4f793..b03aedf8 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -363,5 +363,34 @@ Describe 'ExchangeOnline provider - Unit tests' { { $provider.EnsureOutOfOffice('user10@contoso.com', $badConfig, $null) } | Should -Throw -ExpectedMessage "*must be 'Disabled', 'Enabled', or 'Scheduled'*" } + + It 'detects changes to ExternalAudience and updates accordingly' { + Add-TestMailbox -PrimarySmtpAddress 'user11@contoso.com' + + # Set initial OOF with ExternalAudience = 'Known' + $initialConfig = @{ + Mode = 'Enabled' + InternalMessage = 'Out of office' + ExternalMessage = 'Currently unavailable' + ExternalAudience = 'Known' + } + $provider.EnsureOutOfOffice('user11@contoso.com', $initialConfig, $null) | Out-Null + + # Change only ExternalAudience to 'All', keep messages the same + $updatedConfig = @{ + Mode = 'Enabled' + InternalMessage = 'Out of office' + ExternalMessage = 'Currently unavailable' + ExternalAudience = 'All' + } + $result = $provider.EnsureOutOfOffice('user11@contoso.com', $updatedConfig, $null) + + # Should detect the change and update + $result.Changed | Should -Be $true + + # Verify ExternalAudience was actually updated + $oofConfig = $provider.GetOutOfOffice('user11@contoso.com', $null) + $oofConfig.ExternalAudience | Should -Be 'All' + } } } From 7061a94f435aef761d65aa0d7da6b7440e943086 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:17:17 +0000 Subject: [PATCH 11/13] Address code review feedback: fix module patterns, remove template substitution docs, fix plan mutation Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/usage/workflows.md | 108 +----------------- .../live/complete-leaver-entraid-exo.psd1 | 16 +-- .../live/exo-leaver-mailbox-offboarding.psd1 | 10 +- .../IdLE.Provider.ExchangeOnline.psm1 | 26 +++-- src/IdLE.Provider.ExchangeOnline/README.md | 6 +- .../IdLE.Steps.Mailbox.psm1 | 26 +++-- .../Public/Invoke-IdleStepMailboxGetInfo.ps1 | 11 +- ...nvoke-IdleStepMailboxOutOfOfficeEnsure.ps1 | 21 ++-- .../Invoke-IdleStepMailboxTypeEnsure.ps1 | 11 +- src/IdLE.Steps.Mailbox/README.md | 4 +- tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 | 5 +- 11 files changed, 81 insertions(+), 163 deletions(-) diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index d9febd8a..6cbd6627 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -114,113 +114,9 @@ Prefer explicit reference fields over implicit parsing: This makes configurations safe and statically validatable. -### Template Substitution in Workflow Configs +## Advanced Workflow Patterns -IdLE supports **template substitution** using `{{...}}` syntax in string values within workflow configurations. This allows dynamic values from the lifecycle request to be inserted during plan building. - -**Syntax:** -```powershell -'{{Request.Input.PropertyName}}' -``` - -**Example - Simple substitution:** -```powershell -@{ - Name = 'Create user account' - Type = 'IdLE.Step.CreateIdentity' - With = @{ - Provider = 'Identity' - IdentityKey = '{{Request.Input.UserPrincipalName}}' - DisplayName = '{{Request.Input.DisplayName}}' - } -} -``` - -**Example - Out of Office message with substitution:** -```powershell -@{ - Name = 'Enable Out of Office' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' - With = @{ - Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' - Config = @{ - Mode = 'Enabled' - InternalMessage = '{{Request.Input.DisplayName}} is no longer with the organization. For assistance, please contact {{Request.Input.ManagerEmail}}.' - ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' - } - } -} -``` - -**How Request.Input is populated:** - -The host creates the lifecycle request and provides the Input hashtable: - -```powershell -$request = New-IdleLifecycleRequest ` - -LifecycleEvent 'Leaver' ` - -IdentityKeys @{ UserPrincipalName = 'john.doe@contoso.com' } ` - -Input @{ - UserPrincipalName = 'john.doe@contoso.com' - DisplayName = 'John Doe' - ManagerEmail = 'jane.smith@contoso.com' - Department = 'Engineering' - } -``` - -Template substitution happens during `New-IdlePlan`, replacing `{{...}}` with actual values from the request. - -**Limitations for formatted messages:** - -- **Single-line only**: Multi-line strings with line breaks are not supported in workflow configs due to the data-only constraint -- **No script expressions**: Cannot use `@"..."@` or script blocks for string formatting -- **Simple substitution only**: Only `{{Path.To.Value}}` syntax is supported, no formatting options - -**For complex message formatting:** - -If you need multi-line messages or complex formatting: - -1. **External templates**: Store message templates in separate files and reference them by name, with the host loading and formatting them before creating the request -2. **Pre-formatted in Input**: Format the complete message in the host code and pass it as a single Input parameter -3. **Step-level formatting**: Create a custom step that handles message formatting logic - -**Example - Pre-formatted message approach:** -```powershell -# Host code -$oofMessage = @" -$displayName is no longer with the organization. - -For assistance, please contact: -- Manager: $managerEmail -- HR: hr@contoso.com -- IT Support: it@contoso.com -"@ - -$request = New-IdleLifecycleRequest ` - -LifecycleEvent 'Leaver' ` - -Input @{ - UserPrincipalName = 'john.doe@contoso.com' - OOFInternalMessage = $oofMessage - } -``` - -```powershell -# Workflow -@{ - Name = 'Enable Out of Office' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' - With = @{ - Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' - Config = @{ - Mode = 'Enabled' - InternalMessage = '{{Request.Input.OOFInternalMessage}}' - ExternalMessage = '{{Request.Input.OOFExternalMessage}}' - } - } -} -``` +(Content for advanced patterns will be added in future updates) This approach keeps workflows data-only while allowing rich message formatting in the host code. diff --git a/examples/workflows/live/complete-leaver-entraid-exo.psd1 b/examples/workflows/live/complete-leaver-entraid-exo.psd1 index 0fa3adf5..0e0fa2d4 100644 --- a/examples/workflows/live/complete-leaver-entraid-exo.psd1 +++ b/examples/workflows/live/complete-leaver-entraid-exo.psd1 @@ -8,7 +8,7 @@ Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } } } @{ @@ -16,7 +16,7 @@ Type = 'IdLE.Step.Mailbox.Type.Ensure' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } MailboxType = 'Shared' } } @@ -25,10 +25,10 @@ Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } Config = @{ Mode = 'Enabled' - InternalMessage = '{{Request.Input.DisplayName}} is no longer with the organization. For assistance, please contact {{Request.Input.ManagerEmail}}.' + InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' ExternalAudience = 'All' } @@ -41,7 +41,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserObjectId}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } Desired = @() } } @@ -52,7 +52,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserObjectId}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } Name = 'Manager' Value = $null } @@ -64,14 +64,14 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserObjectId}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } } } @{ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'Complete offboarding finished for {{Request.Input.UserPrincipalName}}: Mailbox converted to Shared, OOF enabled, EntraID account disabled.' + Message = 'Complete offboarding finished: Mailbox converted to Shared, OOF enabled, EntraID account disabled.' } } ) diff --git a/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 index 88ef3a60..713de5fc 100644 --- a/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 +++ b/examples/workflows/live/exo-leaver-mailbox-offboarding.psd1 @@ -8,7 +8,7 @@ Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } } } @{ @@ -16,7 +16,7 @@ Type = 'IdLE.Step.Mailbox.Type.Ensure' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } MailboxType = 'Shared' } } @@ -25,10 +25,10 @@ Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } Config = @{ Mode = 'Enabled' - InternalMessage = '{{Request.Input.DisplayName}} is no longer with the organization. For assistance, please contact {{Request.Input.ManagerEmail}}.' + InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' ExternalAudience = 'All' } @@ -38,7 +38,7 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'Mailbox offboarding completed for {{Request.Input.UserPrincipalName}}.' + Message = 'Mailbox offboarding completed.' } } ) diff --git a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 index 2a8fb111..2e0d4945 100644 --- a/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 +++ b/src/IdLE.Provider.ExchangeOnline/IdLE.Provider.ExchangeOnline.psm1 @@ -1,18 +1,26 @@ #requires -Version 7.0 Set-StrictMode -Version Latest -$script:ModuleRoot = $PSScriptRoot +$PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath 'Private' +if (Test-Path -Path $PrivatePath) { -# Dot-source Public functions -$PublicScripts = Get-ChildItem -Path (Join-Path $script:ModuleRoot 'Public') -Filter '*.ps1' -ErrorAction SilentlyContinue -foreach ($script in ($PublicScripts | Sort-Object Name)) { - . $script.FullName + # Materialize first to avoid enumeration issues during import. + $privateScripts = @(Get-ChildItem -Path $PrivatePath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $privateScripts) { + . $script.FullName + } } -# Dot-source Private functions -$PrivateScripts = Get-ChildItem -Path (Join-Path $script:ModuleRoot 'Private') -Filter '*.ps1' -ErrorAction SilentlyContinue -foreach ($script in ($PrivateScripts | Sort-Object Name)) { - . $script.FullName +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + + # Materialize first to avoid enumeration issues during import. + $publicScripts = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $publicScripts) { + . $script.FullName + } } # Export Public functions - explicit list for deterministic behavior diff --git a/src/IdLE.Provider.ExchangeOnline/README.md b/src/IdLE.Provider.ExchangeOnline/README.md index 3906e020..51b1d485 100644 --- a/src/IdLE.Provider.ExchangeOnline/README.md +++ b/src/IdLE.Provider.ExchangeOnline/README.md @@ -5,8 +5,8 @@ Exchange Online mailbox provider for IdLE. ## Quick Start ```powershell -# Automatically imported when you import IdLE -Import-Module IdLE +# Import the provider +Import-Module IdLE.Provider.ExchangeOnline # Host establishes Exchange Online session (delegated or app-only) Connect-ExchangeOnline -UserPrincipalName admin@contoso.com @@ -30,7 +30,7 @@ $plan = New-IdlePlan -WorkflowPath '.\leaver.psd1' -Request $request -Providers ## Documentation -See **[Complete Provider Documentation](../../docs/reference/providers/provider-exchangeonline.md)** for: +See the main IdLE documentation for: - Full usage guide and examples - Capabilities and mailbox steps - Authentication patterns (delegated + app-only) diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 index 22523d23..ba8e5247 100644 --- a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 @@ -1,18 +1,26 @@ #requires -Version 7.0 Set-StrictMode -Version Latest -$script:ModuleRoot = $PSScriptRoot +$PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath 'Private' +if (Test-Path -Path $PrivatePath) { -# Dot-source Private functions -$PrivateScripts = Get-ChildItem -Path (Join-Path $script:ModuleRoot 'Private') -Filter '*.ps1' -ErrorAction SilentlyContinue -foreach ($script in ($PrivateScripts | Sort-Object Name)) { - . $script.FullName + # Materialize first to avoid enumeration issues during import. + $privateScripts = @(Get-ChildItem -Path $PrivatePath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $privateScripts) { + . $script.FullName + } } -# Dot-source Public functions -$PublicScripts = Get-ChildItem -Path (Join-Path $script:ModuleRoot 'Public') -Filter '*.ps1' -ErrorAction SilentlyContinue -foreach ($script in ($PublicScripts | Sort-Object Name)) { - . $script.FullName +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + + # Materialize first to avoid enumeration issues during import. + $publicScripts = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $publicScripts) { + . $script.FullName + } } # Export Public functions - explicit list for deterministic behavior diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 index 52c06b59..cd6ff218 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxGetInfo.ps1 @@ -69,17 +69,20 @@ function Invoke-IdleStepMailboxGetInfo { throw "Provider '$providerAlias' was not supplied by the host." } + # Create execution-local copy of With to avoid mutating the plan + $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with } + # Apply AuthSessionName convention: default to Provider if not specified - if (-not $with.ContainsKey('AuthSessionName')) { - $with['AuthSessionName'] = $providerAlias + if (-not $effectiveWith.ContainsKey('AuthSessionName')) { + $effectiveWith['AuthSessionName'] = $providerAlias } $result = Invoke-IdleProviderMethod ` -Context $Context ` - -With $with ` + -With $effectiveWith ` -ProviderAlias $providerAlias ` -MethodName 'GetMailbox' ` - -MethodArguments @([string]$with.IdentityKey) + -MethodArguments @([string]$effectiveWith.IdentityKey) # Store mailbox data in State for downstream steps $state = @{ diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 index 98401d27..5f371877 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -53,25 +53,21 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { } .EXAMPLE - # In workflow definition (with template substitution for dynamic values): + # In workflow definition (with ValueFrom for dynamic values): @{ Name = 'Enable Out of Office for Leaver' Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } Config = @{ Mode = 'Enabled' - InternalMessage = '{{Request.Input.DisplayName}} is no longer with the organization. For assistance, please contact {{Request.Input.ManagerEmail}}.' + InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' ExternalAudience = 'All' } } } - # Note: Template substitution ({{...}}) happens during plan building. - # Request.Input parameters are provided by the host when creating the lifecycle request. - # Multi-line messages with line breaks are not currently supported in workflow configs - # due to the data-only constraint. For complex formatting, consider external templates. .EXAMPLE # In workflow definition (scheduled OOF): @@ -170,17 +166,20 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { throw "Provider '$providerAlias' was not supplied by the host." } + # Create execution-local copy of With to avoid mutating the plan + $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with } + # Apply AuthSessionName convention: default to Provider if not specified - if (-not $with.ContainsKey('AuthSessionName')) { - $with['AuthSessionName'] = $providerAlias + if (-not $effectiveWith.ContainsKey('AuthSessionName')) { + $effectiveWith['AuthSessionName'] = $providerAlias } $result = Invoke-IdleProviderMethod ` -Context $Context ` - -With $with ` + -With $effectiveWith ` -ProviderAlias $providerAlias ` -MethodName 'EnsureOutOfOffice' ` - -MethodArguments @([string]$with.IdentityKey, $config) + -MethodArguments @([string]$effectiveWith.IdentityKey, $config) $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 index 2d5146b3..6878dda0 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 @@ -85,17 +85,20 @@ function Invoke-IdleStepMailboxTypeEnsure { throw "Provider '$providerAlias' was not supplied by the host." } + # Create execution-local copy of With to avoid mutating the plan + $effectiveWith = if ($with -is [hashtable]) { $with.Clone() } else { @{} + $with } + # Apply AuthSessionName convention: default to Provider if not specified - if (-not $with.ContainsKey('AuthSessionName')) { - $with['AuthSessionName'] = $providerAlias + if (-not $effectiveWith.ContainsKey('AuthSessionName')) { + $effectiveWith['AuthSessionName'] = $providerAlias } $result = Invoke-IdleProviderMethod ` -Context $Context ` - -With $with ` + -With $effectiveWith ` -ProviderAlias $providerAlias ` -MethodName 'EnsureMailboxType' ` - -MethodArguments @([string]$with.IdentityKey, [string]$with.MailboxType) + -MethodArguments @([string]$effectiveWith.IdentityKey, [string]$effectiveWith.MailboxType) $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md index 11ed3990..5a583fd8 100644 --- a/src/IdLE.Steps.Mailbox/README.md +++ b/src/IdLE.Steps.Mailbox/README.md @@ -11,7 +11,7 @@ Provider-agnostic mailbox step pack for IdLE. Type = 'IdLE.Step.Mailbox.Type.Ensure' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } MailboxType = 'Shared' } } @@ -25,7 +25,7 @@ Provider-agnostic mailbox step pack for IdLE. ## Documentation -See **[Complete Step Documentation](../../docs/reference/steps/steps-mailbox.md)** for: +See the main IdLE documentation for: - Detailed step usage and parameters - Provider contract requirements - Configuration examples diff --git a/tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 b/tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 index 54b7bf64..b38e7955 100644 --- a/tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 +++ b/tests/Invoke-IdleStepMailboxGetInfo.Tests.ps1 @@ -88,8 +88,9 @@ Describe 'Invoke-IdleStepMailboxGetInfo' { $result = & $handler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' - # AuthSessionName should have been set to 'ExchangeOnline' - $step.With.AuthSessionName | Should -Be 'ExchangeOnline' + # Step should complete successfully using default AuthSessionName + # (Plan object should remain unmodified - AuthSessionName still absent) + $step.With.ContainsKey('AuthSessionName') | Should -Be $false } It 'throws when provider is missing' { From dfd679e96a00d3c4d1115e661f32ae66a0324516 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:43:16 +0000 Subject: [PATCH 12/13] Remove duplicate Private helper functions, reuse IdLE.Steps.Common helpers instead Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../IdLE.Steps.Mailbox.psm1 | 17 ++--- .../Private/Invoke-IdleProviderMethod.ps1 | 70 ------------------- .../Test-IdleProviderMethodParameter.ps1 | 69 ------------------ 3 files changed, 9 insertions(+), 147 deletions(-) delete mode 100644 src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 delete mode 100644 src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 index ba8e5247..f878ea29 100644 --- a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 @@ -1,14 +1,15 @@ #requires -Version 7.0 Set-StrictMode -Version Latest -$PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath 'Private' -if (Test-Path -Path $PrivatePath) { - - # Materialize first to avoid enumeration issues during import. - $privateScripts = @(Get-ChildItem -Path $PrivatePath -Filter '*.ps1' -File | Sort-Object -Property FullName) - - foreach ($script in $privateScripts) { - . $script.FullName +# Import private helper functions from IdLE.Steps.Common +$commonModule = Get-Module -Name 'IdLE.Steps.Common' +if ($null -ne $commonModule) { + $commonPrivatePath = Join-Path -Path $commonModule.ModuleBase -ChildPath 'Private' + if (Test-Path -Path $commonPrivatePath) { + $privateScripts = @(Get-ChildItem -Path $commonPrivatePath -Filter '*.ps1' -File | Sort-Object -Property FullName) + foreach ($script in $privateScripts) { + . $script.FullName + } } } diff --git a/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 b/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 deleted file mode 100644 index cc73ef7d..00000000 --- a/src/IdLE.Steps.Mailbox/Private/Invoke-IdleProviderMethod.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -# Invokes a provider method with optional AuthSession support. -# Handles auth session acquisition, parameter detection, and backwards-compatible fallback. -# -# NOTE: This is a copy of the function from IdLE.Steps.Common/Private/Invoke-IdleProviderMethod.ps1 -# The duplication is necessary because: -# 1. The function is Private in IdLE.Steps.Common and not exported -# 2. RequiredModules does not make Private functions available to dependent modules -# 3. Making it Public would expose implementation details that should remain internal -# This is the same pattern used by IdLE.Steps.DirectorySync which calls this function -# via the Common module's internal scope when RequiredModules is declared. - -function Invoke-IdleProviderMethod { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Context, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [hashtable] $With, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $ProviderAlias, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $MethodName, - - [Parameter(Mandatory)] - [AllowEmptyCollection()] - [object[]] $MethodArguments - ) - - # Auth session acquisition (optional, data-only) - $authSession = $null - if ($With.ContainsKey('AuthSessionName')) { - $sessionName = [string]$With.AuthSessionName - $sessionOptions = if ($With.ContainsKey('AuthSessionOptions')) { $With.AuthSessionOptions } else { $null } - - if ($null -ne $sessionOptions -and -not ($sessionOptions -is [hashtable])) { - throw "With.AuthSessionOptions must be a hashtable or null." - } - - $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) - } - - $provider = $Context.Providers[$ProviderAlias] - - # Check if provider method exists - $providerMethod = $provider.PSObject.Methods[$MethodName] - if ($null -eq $providerMethod) { - throw "Provider '$ProviderAlias' does not implement $MethodName method." - } - - # Check if method supports AuthSession parameter - $supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $providerMethod -ParameterName 'AuthSession' - - # Call provider method with appropriate signature - if ($supportsAuthSession -and $null -ne $authSession) { - # Provider supports AuthSession and we have one - pass it - $allArgs = $MethodArguments + $authSession - return $provider.$MethodName.Invoke($allArgs) - } - else { - # Legacy signature (no AuthSession parameter) or no session acquired - return $provider.$MethodName.Invoke($MethodArguments) - } -} diff --git a/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 b/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 deleted file mode 100644 index 692c5979..00000000 --- a/src/IdLE.Steps.Mailbox/Private/Test-IdleProviderMethodParameter.ps1 +++ /dev/null @@ -1,69 +0,0 @@ -# Tests whether a provider method supports a given parameter. -# Supports ScriptMethod (AST inspection) and compiled methods (reflection). -# -# NOTE: This is a copy of the function from IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1 -# See Invoke-IdleProviderMethod.ps1 in this directory for explanation of why duplication is necessary. - -function Test-IdleProviderMethodParameter { - [CmdletBinding()] - [OutputType([bool])] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [System.Management.Automation.PSMethodInfo] $ProviderMethod, - - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $ParameterName - ) - - # For ScriptMethod, inspect the AST - if ($ProviderMethod.MemberType -eq 'ScriptMethod') { - $scriptBlock = $ProviderMethod.Script - - # Early exit if required objects are missing - if ($null -eq $scriptBlock) { return $false } - if ($null -eq $scriptBlock.Ast) { return $false } - if ($null -eq $scriptBlock.Ast.ParamBlock) { return $false } - - $params = $scriptBlock.Ast.ParamBlock.Parameters - if ($null -eq $params) { return $false } - - # Check each parameter for a match - foreach ($param in $params) { - if ($null -eq $param.Name) { continue } - if ($null -eq $param.Name.VariablePath) { continue } - - $paramName = $param.Name.VariablePath.UserPath - if ($paramName -eq $ParameterName) { - return $true - } - } - - return $false - } - - # For compiled methods (PSMethod, CodeMethod), use reflection - if ($ProviderMethod.MemberType -in @('Method', 'CodeMethod')) { - try { - # Get the method info via reflection - $methodInfo = $ProviderMethod.OverloadDefinitions - if ($null -ne $methodInfo) { - # Check if any overload contains the parameter name - foreach ($overload in $methodInfo) { - if ($overload -match "\b$ParameterName\b") { - return $true - } - } - } - } - catch { - # If reflection fails, assume parameter is not supported - Write-Verbose "Could not inspect compiled method parameters: $_" - } - return $false - } - - # Unknown method type - return $false -} From 5f0621a705b3b0ad2ff3d9157d81e9e82f5a1636 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:19:11 +0000 Subject: [PATCH 13/13] Address code review feedback: use local variables for regex patterns, fix doc link, use ToLowerInvariant Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 10 +++++----- .../Public/New-IdleExchangeOnlineProvider.ps1 | 2 +- tests/Providers/ExchangeOnlineProvider.Tests.ps1 | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index de66f599..4973f0e9 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -18,9 +18,9 @@ function New-IdleExchangeOnlineAdapter { [switch] $UseRestApi ) - # Regex patterns for sanitizing error messages (redact sensitive data) - $script:BearerTokenPattern = 'Bearer\s+[^\s]+' - $script:TokenAssignmentPattern = 'token[^\s]*\s*=\s*[^\s,;]+' + # Regex patterns for sanitizing error messages (captured by scriptblock closure) + $bearerTokenPattern = 'Bearer\s+[^\s]+' + $tokenAssignmentPattern = 'token[^\s]*\s*=\s*[^\s,;]+' $adapter = [pscustomobject]@{ PSTypeName = 'IdLE.ExchangeOnlineAdapter' @@ -46,8 +46,8 @@ function New-IdleExchangeOnlineAdapter { $errorMessage = "Exchange Online command '$CommandName' failed" if ($_.Exception.Message) { # Sanitize error message to avoid leaking tokens/secrets - $sanitized = $_.Exception.Message -replace $script:BearerTokenPattern, 'Bearer ' - $sanitized = $sanitized -replace $script:TokenAssignmentPattern, 'token=' + $sanitized = $_.Exception.Message -replace $bearerTokenPattern, 'Bearer ' + $sanitized = $sanitized -replace $tokenAssignmentPattern, 'token=' $errorMessage += " | $sanitized" } diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index f3e2589d..e949bb59 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -66,7 +66,7 @@ function New-IdleExchangeOnlineProvider { - Exchange Administrator or Global Administrator role (delegated) - Required role: Mail Recipients (manage mailboxes) - See docs/reference/providers/provider-exchangeonline.md for detailed setup. + See the IdLE provider documentation for detailed setup. #> [CmdletBinding()] param( diff --git a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index b03aedf8..a3986e3a 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -230,7 +230,7 @@ Describe 'ExchangeOnline provider - Unit tests' { It 'supports all mailbox types' { foreach ($type in @('Shared', 'Room', 'Equipment', 'User')) { - $email = "test-$type@contoso.com".ToLower() + $email = "test-$type@contoso.com".ToLowerInvariant() # Always start with UserMailbox, except for last iteration testing User type $startType = if ($type -eq 'User') { 'SharedMailbox' } else { 'UserMailbox' } Add-TestMailbox -PrimarySmtpAddress $email -RecipientTypeDetails $startType