From d3b53d875efb4101a574ac2117523afd6b5c4a9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:38:18 +0000 Subject: [PATCH 1/4] Initial plan From ac19609d8e5f0dc4231c9c928522a38be68fea9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:47:33 +0000 Subject: [PATCH 2/4] feat: Add IdLE.Step.Mailbox.EnsurePermissions step and ExchangeOnline provider support Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleExchangeOnlineAdapter.ps1 | 307 +++++++++++++++ .../Public/New-IdleExchangeOnlineProvider.ps1 | 115 ++++++ .../IdLE.Steps.Mailbox.psd1 | 3 +- .../IdLE.Steps.Mailbox.psm1 | 3 +- .../Public/Get-IdleStepMetadataCatalog.ps1 | 5 + ...nvoke-IdleStepMailboxPermissionsEnsure.ps1 | 182 +++++++++ .../ExchangeOnlineProvider.Tests.ps1 | 267 ++++++++++++- ...IdleStepMailboxPermissionsEnsure.Tests.ps1 | 357 ++++++++++++++++++ 8 files changed, 1235 insertions(+), 4 deletions(-) create mode 100644 src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 create mode 100644 tests/Steps/Invoke-IdleStepMailboxPermissionsEnsure.Tests.ps1 diff --git a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 index 7013d6c2..71d2b124 100644 --- a/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Private/New-IdleExchangeOnlineAdapter.ps1 @@ -258,5 +258,312 @@ function New-IdleExchangeOnlineAdapter { $this.InvokeSafely('Set-MailboxAutoReplyConfiguration', $params) } -Force + # GetMailboxPermissions: Get FullAccess permissions for a mailbox + $adapter | Add-Member -MemberType ScriptMethod -Name GetMailboxPermissions -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + # AccessToken is reserved for future Graph API integration + $null = $AccessToken + + try { + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + $permissions = $this.InvokeSafely('Get-MailboxPermission', $params) + + if ($null -eq $permissions) { + return @() + } + + # Normalize output: filter out NT AUTHORITY, SELF, and SID-only entries. + # - NT AUTHORITY\*: built-in system accounts (e.g. NT AUTHORITY\SELF from inheritance) + # - *\SELF: owner self-permission added automatically by Exchange + # - S-1-*: unresolved SIDs that should not be managed as named delegates + $result = @() + foreach ($perm in $permissions) { + $user = [string]$perm.User + if ($user -match '^NT AUTHORITY\\|\\SELF$|^S-1-') { + continue + } + foreach ($right in $perm.AccessRights) { + $result += @{ + MailboxIdentity = $MailboxIdentity + User = $user + AccessRight = [string]$right + IsInherited = [bool]$perm.IsInherited + } + } + } + return $result + } + catch { + if ($_.Exception.Message -match 'couldn''t be found|not found|does not exist') { + return @() + } + throw + } + } -Force + + # AddMailboxPermission: Grant FullAccess to a mailbox + $adapter | Add-Member -MemberType ScriptMethod -Name AddMailboxPermission -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $User, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + # AccessToken is reserved for future Graph API integration + $null = $AccessToken + + $params = @{ + Identity = $MailboxIdentity + User = $User + AccessRights = 'FullAccess' + ErrorAction = 'Stop' + } + + $this.InvokeSafely('Add-MailboxPermission', $params) + } -Force + + # RemoveMailboxPermission: Revoke FullAccess from a mailbox + $adapter | Add-Member -MemberType ScriptMethod -Name RemoveMailboxPermission -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $User, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + # AccessToken is reserved for future Graph API integration + $null = $AccessToken + + $params = @{ + Identity = $MailboxIdentity + User = $User + AccessRights = 'FullAccess' + Confirm = $false + ErrorAction = 'Stop' + } + + $this.InvokeSafely('Remove-MailboxPermission', $params) + } -Force + + # GetRecipientPermissions: Get SendAs permissions for a mailbox + $adapter | Add-Member -MemberType ScriptMethod -Name GetRecipientPermissions -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + # AccessToken is reserved for future Graph API integration + $null = $AccessToken + + try { + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + $permissions = $this.InvokeSafely('Get-RecipientPermission', $params) + + if ($null -eq $permissions) { + return @() + } + + # Normalize output: filter out NT AUTHORITY entries. + # Get-RecipientPermission only returns NT AUTHORITY\SELF for built-in system entries; + # unlike Get-MailboxPermission it does not return unresolved SIDs or \SELF owner entries. + $result = @() + foreach ($perm in $permissions) { + $trustee = [string]$perm.Trustee + if ($trustee -match '^NT AUTHORITY\\') { + continue + } + $result += @{ + MailboxIdentity = $MailboxIdentity + Trustee = $trustee + AccessControlType = [string]$perm.AccessControlType + AccessRight = [string]($perm.AccessRights -join ',') + IsInherited = [bool]$perm.IsInherited + } + } + return $result + } + catch { + if ($_.Exception.Message -match 'couldn''t be found|not found|does not exist') { + return @() + } + throw + } + } -Force + + # AddRecipientPermission: Grant SendAs to a mailbox + $adapter | Add-Member -MemberType ScriptMethod -Name AddRecipientPermission -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Trustee, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + # AccessToken is reserved for future Graph API integration + $null = $AccessToken + + $params = @{ + Identity = $MailboxIdentity + Trustee = $Trustee + AccessRights = 'SendAs' + Confirm = $false + ErrorAction = 'Stop' + } + + $this.InvokeSafely('Add-RecipientPermission', $params) + } -Force + + # RemoveRecipientPermission: Revoke SendAs from a mailbox + $adapter | Add-Member -MemberType ScriptMethod -Name RemoveRecipientPermission -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Trustee, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + # AccessToken is reserved for future Graph API integration + $null = $AccessToken + + $params = @{ + Identity = $MailboxIdentity + Trustee = $Trustee + AccessRights = 'SendAs' + Confirm = $false + ErrorAction = 'Stop' + } + + $this.InvokeSafely('Remove-RecipientPermission', $params) + } -Force + + # GetMailboxSendOnBehalf: Get the GrantSendOnBehalfTo list for a mailbox + $adapter | Add-Member -MemberType ScriptMethod -Name GetMailboxSendOnBehalf -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + # AccessToken is reserved for future Graph API integration + $null = $AccessToken + + try { + $params = @{ + Identity = $MailboxIdentity + ErrorAction = 'Stop' + } + + $mailbox = $this.InvokeSafely('Get-Mailbox', $params) + + if ($null -eq $mailbox) { + return @() + } + + # GrantSendOnBehalfTo returns a MultiValuedProperty - normalize to string array + $result = @() + foreach ($entry in $mailbox.GrantSendOnBehalfTo) { + $result += [string]$entry + } + return $result + } + catch { + if ($_.Exception.Message -match 'couldn''t be found|not found|does not exist') { + return @() + } + throw + } + } -Force + + # SetMailboxSendOnBehalf: Set the GrantSendOnBehalfTo list for a mailbox + $adapter | Add-Member -MemberType ScriptMethod -Name SetMailboxSendOnBehalf -Value { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'AccessToken', Justification = 'Reserved for future Graph API integration')] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MailboxIdentity, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]] $Delegates, + + [Parameter()] + [AllowNull()] + [string] $AccessToken + ) + + # AccessToken is reserved for future Graph API integration + $null = $AccessToken + + $params = @{ + Identity = $MailboxIdentity + GrantSendOnBehalfTo = $Delegates + ErrorAction = 'Stop' + } + + $this.InvokeSafely('Set-Mailbox', $params) + } -Force + return $adapter } diff --git a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 index 875707fd..ba3bf011 100644 --- a/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 +++ b/src/IdLE.Provider.ExchangeOnline/Public/New-IdleExchangeOnlineProvider.ps1 @@ -177,6 +177,7 @@ function New-IdleExchangeOnlineProvider { 'IdLE.Mailbox.Info.Read' 'IdLE.Mailbox.Type.Ensure' 'IdLE.Mailbox.OutOfOffice.Ensure' + 'IdLE.Mailbox.Permissions.Ensure' ) return $caps @@ -413,5 +414,119 @@ function New-IdleExchangeOnlineProvider { } } -Force + # EnsureMailboxPermissions: Idempotent mailbox delegate permissions convergence + $provider | Add-Member -MemberType ScriptMethod -Name EnsureMailboxPermissions -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object[]] $Permissions, + + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + $accessToken = $this.ExtractAccessToken($AuthSession) + + # Verify mailbox exists first + $mailbox = $this.GetMailbox($IdentityKey, $AuthSession) + $mailboxSmtp = $mailbox.PrimarySmtpAddress + + $changed = $false + + # --- FullAccess --- + $desiredFullAccess = @($Permissions | Where-Object { $_.Right -eq 'FullAccess' }) + if ($desiredFullAccess.Count -gt 0) { + $currentPerms = $this.Adapter.GetMailboxPermissions($mailboxSmtp, $accessToken) + + # Normalize current delegates (case-insensitive) + $currentFullAccessUsers = @($currentPerms | + Where-Object { $_.AccessRight -eq 'FullAccess' -and -not $_.IsInherited } | + ForEach-Object { $_.User.ToLowerInvariant() }) + + foreach ($entry in $desiredFullAccess) { + $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentFullAccessUsers -contains $userLower + + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + $this.Adapter.AddMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + $this.Adapter.RemoveMailboxPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } + } + } + + # --- SendAs --- + $desiredSendAs = @($Permissions | Where-Object { $_.Right -eq 'SendAs' }) + if ($desiredSendAs.Count -gt 0) { + $currentRecipientPerms = $this.Adapter.GetRecipientPermissions($mailboxSmtp, $accessToken) + + $currentSendAsTrustees = @($currentRecipientPerms | + Where-Object { $_.AccessRight -match 'SendAs' -and -not $_.IsInherited } | + ForEach-Object { $_.Trustee.ToLowerInvariant() }) + + foreach ($entry in $desiredSendAs) { + $trusteeLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentSendAsTrustees -contains $trusteeLower + + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + $this.Adapter.AddRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + $this.Adapter.RemoveRecipientPermission($mailboxSmtp, [string]$entry.AssignedUser, $accessToken) + $changed = $true + } + } + } + + # --- SendOnBehalf --- + $desiredSendOnBehalf = @($Permissions | Where-Object { $_.Right -eq 'SendOnBehalf' }) + if ($desiredSendOnBehalf.Count -gt 0) { + $currentDelegates = $this.Adapter.GetMailboxSendOnBehalf($mailboxSmtp, $accessToken) + $currentDelegatesLower = @($currentDelegates | ForEach-Object { $_.ToLowerInvariant() }) + + # Compute desired final list based on Present/Absent entries + $updatedDelegates = [System.Collections.Generic.List[string]]::new() + foreach ($d in $currentDelegates) { $updatedDelegates.Add($d) } + + $sobChanged = $false + foreach ($entry in $desiredSendOnBehalf) { + $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() + $isPresent = $currentDelegatesLower -contains $userLower + + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + $updatedDelegates.Add([string]$entry.AssignedUser) + $sobChanged = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + # Remove case-insensitively + $toRemove = $updatedDelegates | Where-Object { $_.ToLowerInvariant() -eq $userLower } + foreach ($r in @($toRemove)) { $updatedDelegates.Remove($r) | Out-Null } + $sobChanged = $true + } + } + + if ($sobChanged) { + $this.Adapter.SetMailboxSendOnBehalf($mailboxSmtp, [string[]]$updatedDelegates, $accessToken) + $changed = $true + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureMailboxPermissions' + IdentityKey = $mailboxSmtp + Changed = $changed + } + } -Force + return $provider } diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 index 0240601e..2ab37eac 100644 --- a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 @@ -14,7 +14,8 @@ 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepMailboxGetInfo', 'Invoke-IdleStepMailboxTypeEnsure', - 'Invoke-IdleStepMailboxOutOfOfficeEnsure' + 'Invoke-IdleStepMailboxOutOfOfficeEnsure', + 'Invoke-IdleStepMailboxPermissionsEnsure' ) PrivateData = @{ diff --git a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 index f878ea29..7f0eb329 100644 --- a/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 +++ b/src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psm1 @@ -29,5 +29,6 @@ Export-ModuleMember -Function @( 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepMailboxGetInfo', 'Invoke-IdleStepMailboxTypeEnsure', - 'Invoke-IdleStepMailboxOutOfOfficeEnsure' + 'Invoke-IdleStepMailboxOutOfOfficeEnsure', + 'Invoke-IdleStepMailboxPermissionsEnsure' ) diff --git a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 index c779e5ca..39b8d425 100644 --- a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 @@ -41,5 +41,10 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') } + # IdLE.Step.Mailbox.EnsurePermissions - idempotent mailbox delegate permissions + $catalog['IdLE.Step.Mailbox.EnsurePermissions'] = @{ + RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.Permissions.Ensure') + } + return $catalog } diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 new file mode 100644 index 00000000..44f04c38 --- /dev/null +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxPermissionsEnsure.ps1 @@ -0,0 +1,182 @@ +function Invoke-IdleStepMailboxPermissionsEnsure { + <# + .SYNOPSIS + Ensures that mailbox delegate permissions match 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 EnsureMailboxPermissions + method with the signature (IdentityKey, Permissions, AuthSession) and return an object + that contains a boolean property 'Changed'. + + The step is idempotent by design: it converges mailbox delegate permissions to the desired + state by computing the delta between current and desired permissions and applying only the + necessary changes. + + Supported rights (v1): + - FullAccess + - SendAs + - SendOnBehalf + + Permissions array shape (data-only): + Each entry must be a hashtable with: + - AssignedUser: string (required) - UPN or SMTP address of the delegate + - Right: 'FullAccess' | 'SendAs' | 'SendOnBehalf' (required) + - Ensure: 'Present' | 'Absent' (required) + + 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 (grant FullAccess and SendAs): + @{ + Name = 'Set Shared Mailbox Permissions' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + @{ AssignedUser = 'user2@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } + ) + } + } + + .EXAMPLE + # In workflow definition (revoke access): + @{ + Name = 'Revoke Mailbox Access' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'leaver@contoso.com'; Right = 'FullAccess'; Ensure = 'Absent' } + @{ AssignedUser = 'leaver@contoso.com'; Right = 'SendOnBehalf'; Ensure = 'Absent' } + ) + } + } + + .EXAMPLE + # With dynamic identity from request: + @{ + Name = 'Grant Team Mailbox Access' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'team@contoso.com' + Permissions = @( + @{ AssignedUser = @{ ValueFrom = 'Request.Intent.UserPrincipalName' }; Right = 'FullAccess'; Ensure = 'Present' } + ) + } + } + #> + [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.Permissions.Ensure requires 'With' to be a hashtable." + } + + foreach ($key in @('IdentityKey', 'Permissions')) { + if (-not $with.ContainsKey($key)) { + throw "Mailbox.Permissions.Ensure requires With.$key." + } + } + + $permissions = $with.Permissions + if ($null -eq $permissions) { + throw "Mailbox.Permissions.Ensure requires With.Permissions to be an array." + } + + # Accept single hashtable or array of hashtables + if ($permissions -is [hashtable]) { + $permissions = @($permissions) + } + + $validRights = @('FullAccess', 'SendAs', 'SendOnBehalf') + $validEnsure = @('Present', 'Absent') + + foreach ($entry in $permissions) { + if ($null -eq $entry -or -not ($entry -is [hashtable])) { + throw "Mailbox.Permissions.Ensure: each Permissions entry must be a hashtable." + } + foreach ($key in @('AssignedUser', 'Right', 'Ensure')) { + if (-not $entry.ContainsKey($key)) { + throw "Mailbox.Permissions.Ensure: each Permissions entry requires '$key'." + } + } + if ($entry.Right -notin $validRights) { + throw "Mailbox.Permissions.Ensure: Right must be one of: $($validRights -join ', '). Got: $($entry.Right)" + } + if ($entry.Ensure -notin $validEnsure) { + throw "Mailbox.Permissions.Ensure: Ensure must be one of: $($validEnsure -join ', '). Got: $($entry.Ensure)" + } + } + + # Security: reject ScriptBlocks in Permissions (data-only constraint) + Assert-IdleNoScriptBlock -InputObject $with.Permissions -Path 'With.Permissions' + + $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." + } + + # Create execution-local copy of With to avoid mutating the plan + $effectiveWith = $with.Clone() + + # Apply AuthSessionName convention: default to Provider if not specified + if (-not $effectiveWith.ContainsKey('AuthSessionName')) { + $effectiveWith['AuthSessionName'] = $providerAlias + } + + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $effectiveWith ` + -ProviderAlias $providerAlias ` + -MethodName 'EnsureMailboxPermissions' ` + -MethodArguments @([string]$effectiveWith.IdentityKey, $permissions) + + $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/tests/Providers/ExchangeOnlineProvider.Tests.ps1 b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 index a033492c..e18c28ee 100644 --- a/tests/Providers/ExchangeOnlineProvider.Tests.ps1 +++ b/tests/Providers/ExchangeOnlineProvider.Tests.ps1 @@ -28,8 +28,11 @@ Describe 'ExchangeOnline provider - Unit tests' { $fakeAdapter = [pscustomobject]@{ PSTypeName = 'IdLE.ExchangeOnlineAdapter.Fake' Store = @{ - Mailboxes = @{} - AutoReply = @{} + Mailboxes = @{} + AutoReply = @{} + FullAccess = @{} # mailboxSmtp -> @{ userLower -> $true } + SendAs = @{} # mailboxSmtp -> @{ trusteeLower -> $true } + SendOnBehalf = @{} # mailboxSmtp -> [List[string]] } } @@ -172,6 +175,110 @@ Describe 'ExchangeOnline provider - Unit tests' { return $mailbox } + # GetMailboxPermissions: return FullAccess entries for a mailbox + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetMailboxPermissions -Value { + param($MailboxIdentity, $AccessToken) + + $key = $MailboxIdentity.ToLowerInvariant() + if (-not $this.Store.FullAccess.ContainsKey($key)) { + return @() + } + $result = @() + foreach ($user in $this.Store.FullAccess[$key].Keys) { + $result += @{ + MailboxIdentity = $MailboxIdentity + User = $user + AccessRight = 'FullAccess' + IsInherited = $false + } + } + return $result + } -Force + + # AddMailboxPermission: grant FullAccess + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name AddMailboxPermission -Value { + param($MailboxIdentity, $User, $AccessToken) + + $key = $MailboxIdentity.ToLowerInvariant() + if (-not $this.Store.FullAccess.ContainsKey($key)) { + $this.Store.FullAccess[$key] = @{} + } + $this.Store.FullAccess[$key][$User.ToLowerInvariant()] = $true + } -Force + + # RemoveMailboxPermission: revoke FullAccess + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveMailboxPermission -Value { + param($MailboxIdentity, $User, $AccessToken) + + $key = $MailboxIdentity.ToLowerInvariant() + if ($this.Store.FullAccess.ContainsKey($key)) { + $this.Store.FullAccess[$key].Remove($User.ToLowerInvariant()) + } + } -Force + + # GetRecipientPermissions: return SendAs entries for a mailbox + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetRecipientPermissions -Value { + param($MailboxIdentity, $AccessToken) + + $key = $MailboxIdentity.ToLowerInvariant() + if (-not $this.Store.SendAs.ContainsKey($key)) { + return @() + } + $result = @() + foreach ($trustee in $this.Store.SendAs[$key].Keys) { + $result += @{ + MailboxIdentity = $MailboxIdentity + Trustee = $trustee + AccessControlType = 'Allow' + AccessRight = 'SendAs' + IsInherited = $false + } + } + return $result + } -Force + + # AddRecipientPermission: grant SendAs + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name AddRecipientPermission -Value { + param($MailboxIdentity, $Trustee, $AccessToken) + + $key = $MailboxIdentity.ToLowerInvariant() + if (-not $this.Store.SendAs.ContainsKey($key)) { + $this.Store.SendAs[$key] = @{} + } + $this.Store.SendAs[$key][$Trustee.ToLowerInvariant()] = $true + } -Force + + # RemoveRecipientPermission: revoke SendAs + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name RemoveRecipientPermission -Value { + param($MailboxIdentity, $Trustee, $AccessToken) + + $key = $MailboxIdentity.ToLowerInvariant() + if ($this.Store.SendAs.ContainsKey($key)) { + $this.Store.SendAs[$key].Remove($Trustee.ToLowerInvariant()) + } + } -Force + + # GetMailboxSendOnBehalf: return SendOnBehalf delegate list + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetMailboxSendOnBehalf -Value { + param($MailboxIdentity, $AccessToken) + + $key = $MailboxIdentity.ToLowerInvariant() + if (-not $this.Store.SendOnBehalf.ContainsKey($key)) { + return @() + } + return @($this.Store.SendOnBehalf[$key]) + } -Force + + # SetMailboxSendOnBehalf: replace SendOnBehalf delegate list + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name SetMailboxSendOnBehalf -Value { + param($MailboxIdentity, $Delegates, $AccessToken) + + $key = $MailboxIdentity.ToLowerInvariant() + $list = [System.Collections.Generic.List[string]]::new() + foreach ($d in $Delegates) { $list.Add($d) } + $this.Store.SendOnBehalf[$key] = $list + } -Force + # Create provider with fake adapter $provider = New-IdleExchangeOnlineProvider -Adapter $fakeAdapter } @@ -182,6 +289,7 @@ Describe 'ExchangeOnline provider - Unit tests' { $caps | Should -Contain 'IdLE.Mailbox.Info.Read' $caps | Should -Contain 'IdLE.Mailbox.Type.Ensure' $caps | Should -Contain 'IdLE.Mailbox.OutOfOffice.Ensure' + $caps | Should -Contain 'IdLE.Mailbox.Permissions.Ensure' } } @@ -514,4 +622,159 @@ Describe 'ExchangeOnline provider - Unit tests' { $normalized | Should -Be '

This is important and contact us.

' } } + + Context 'EnsureMailboxPermissions' { + It 'grants FullAccess and reports Changed = true' { + Add-TestMailbox -PrimarySmtpAddress 'perm1@contoso.com' + + $permissions = @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + ) + + $result = $provider.EnsureMailboxPermissions('perm1@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $true + $result.Operation | Should -Be 'EnsureMailboxPermissions' + + # Verify the permission was stored + $fakeAdapter.Store.FullAccess['perm1@contoso.com']['delegate1@contoso.com'] | Should -Be $true + } + + It 'is idempotent when FullAccess already present' { + Add-TestMailbox -PrimarySmtpAddress 'perm2@contoso.com' + $fakeAdapter.Store.FullAccess['perm2@contoso.com'] = @{ 'delegate1@contoso.com' = $true } + + $permissions = @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + ) + + $result = $provider.EnsureMailboxPermissions('perm2@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $false + } + + It 'revokes FullAccess when Ensure = Absent' { + Add-TestMailbox -PrimarySmtpAddress 'perm3@contoso.com' + $fakeAdapter.Store.FullAccess['perm3@contoso.com'] = @{ 'delegate1@contoso.com' = $true } + + $permissions = @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'FullAccess'; Ensure = 'Absent' } + ) + + $result = $provider.EnsureMailboxPermissions('perm3@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $true + $fakeAdapter.Store.FullAccess['perm3@contoso.com'].ContainsKey('delegate1@contoso.com') | Should -Be $false + } + + It 'grants SendAs and reports Changed = true' { + Add-TestMailbox -PrimarySmtpAddress 'perm4@contoso.com' + + $permissions = @( + @{ AssignedUser = 'delegate2@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } + ) + + $result = $provider.EnsureMailboxPermissions('perm4@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $true + $fakeAdapter.Store.SendAs['perm4@contoso.com']['delegate2@contoso.com'] | Should -Be $true + } + + It 'is idempotent when SendAs already present' { + Add-TestMailbox -PrimarySmtpAddress 'perm5@contoso.com' + $fakeAdapter.Store.SendAs['perm5@contoso.com'] = @{ 'delegate2@contoso.com' = $true } + + $permissions = @( + @{ AssignedUser = 'delegate2@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } + ) + + $result = $provider.EnsureMailboxPermissions('perm5@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $false + } + + It 'revokes SendAs when Ensure = Absent' { + Add-TestMailbox -PrimarySmtpAddress 'perm6@contoso.com' + $fakeAdapter.Store.SendAs['perm6@contoso.com'] = @{ 'delegate2@contoso.com' = $true } + + $permissions = @( + @{ AssignedUser = 'delegate2@contoso.com'; Right = 'SendAs'; Ensure = 'Absent' } + ) + + $result = $provider.EnsureMailboxPermissions('perm6@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $true + $fakeAdapter.Store.SendAs['perm6@contoso.com'].ContainsKey('delegate2@contoso.com') | Should -Be $false + } + + It 'grants SendOnBehalf and reports Changed = true' { + Add-TestMailbox -PrimarySmtpAddress 'perm7@contoso.com' + + $permissions = @( + @{ AssignedUser = 'delegate3@contoso.com'; Right = 'SendOnBehalf'; Ensure = 'Present' } + ) + + $result = $provider.EnsureMailboxPermissions('perm7@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $true + $fakeAdapter.Store.SendOnBehalf['perm7@contoso.com'] | Should -Contain 'delegate3@contoso.com' + } + + It 'is idempotent when SendOnBehalf already present' { + Add-TestMailbox -PrimarySmtpAddress 'perm8@contoso.com' + $list = [System.Collections.Generic.List[string]]::new() + $list.Add('delegate3@contoso.com') + $fakeAdapter.Store.SendOnBehalf['perm8@contoso.com'] = $list + + $permissions = @( + @{ AssignedUser = 'delegate3@contoso.com'; Right = 'SendOnBehalf'; Ensure = 'Present' } + ) + + $result = $provider.EnsureMailboxPermissions('perm8@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $false + } + + It 'revokes SendOnBehalf when Ensure = Absent' { + Add-TestMailbox -PrimarySmtpAddress 'perm9@contoso.com' + $list = [System.Collections.Generic.List[string]]::new() + $list.Add('delegate3@contoso.com') + $fakeAdapter.Store.SendOnBehalf['perm9@contoso.com'] = $list + + $permissions = @( + @{ AssignedUser = 'delegate3@contoso.com'; Right = 'SendOnBehalf'; Ensure = 'Absent' } + ) + + $result = $provider.EnsureMailboxPermissions('perm9@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $true + $fakeAdapter.Store.SendOnBehalf['perm9@contoso.com'] | Should -Not -Contain 'delegate3@contoso.com' + } + + It 'handles mixed rights in a single call' { + Add-TestMailbox -PrimarySmtpAddress 'perm10@contoso.com' + + $permissions = @( + @{ AssignedUser = 'userA@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + @{ AssignedUser = 'userB@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } + @{ AssignedUser = 'userC@contoso.com'; Right = 'SendOnBehalf'; Ensure = 'Present' } + ) + + $result = $provider.EnsureMailboxPermissions('perm10@contoso.com', $permissions, $null) + + $result.Changed | Should -Be $true + $fakeAdapter.Store.FullAccess['perm10@contoso.com']['usera@contoso.com'] | Should -Be $true + $fakeAdapter.Store.SendAs['perm10@contoso.com']['userb@contoso.com'] | Should -Be $true + $fakeAdapter.Store.SendOnBehalf['perm10@contoso.com'] | Should -Contain 'userC@contoso.com' + } + + It 'throws error when mailbox not found' { + $permissions = @( + @{ AssignedUser = 'delegate1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + ) + + { $provider.EnsureMailboxPermissions('nonexistent@contoso.com', $permissions, $null) } | + Should -Throw "*Mailbox 'nonexistent@contoso.com' not found*" + } + } } diff --git a/tests/Steps/Invoke-IdleStepMailboxPermissionsEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxPermissionsEnsure.Tests.ps1 new file mode 100644 index 00000000..cdb76d89 --- /dev/null +++ b/tests/Steps/Invoke-IdleStepMailboxPermissionsEnsure.Tests.ps1 @@ -0,0 +1,357 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + Import-IdleTestMailboxModule + + # 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-IdleStepMailboxPermissionsEnsure' { + BeforeEach { + # Create mock ExchangeOnline provider with in-memory permission store + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + Store = @{ + FullAccess = @{} # mailboxSmtp -> @{ userLower -> $true } + SendAs = @{} # mailboxSmtp -> @{ trusteeLower -> $true } + SendOnBehalf = @{} # mailboxSmtp -> [List[string]] + } + } + + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureMailboxPermissions -Value { + param($IdentityKey, $Permissions, $AuthSession) + + $smtpKey = $IdentityKey.ToLowerInvariant() + + # Ensure store keys exist + if (-not $this.Store.FullAccess.ContainsKey($smtpKey)) { $this.Store.FullAccess[$smtpKey] = @{} } + if (-not $this.Store.SendAs.ContainsKey($smtpKey)) { $this.Store.SendAs[$smtpKey] = @{} } + if (-not $this.Store.SendOnBehalf.ContainsKey($smtpKey)) { + $this.Store.SendOnBehalf[$smtpKey] = [System.Collections.Generic.List[string]]::new() + } + + $changed = $false + + foreach ($entry in $Permissions) { + $userLower = ([string]$entry.AssignedUser).ToLowerInvariant() + + switch ($entry.Right) { + 'FullAccess' { + $isPresent = $this.Store.FullAccess[$smtpKey].ContainsKey($userLower) + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + $this.Store.FullAccess[$smtpKey][$userLower] = $true + $changed = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + $this.Store.FullAccess[$smtpKey].Remove($userLower) + $changed = $true + } + } + 'SendAs' { + $isPresent = $this.Store.SendAs[$smtpKey].ContainsKey($userLower) + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + $this.Store.SendAs[$smtpKey][$userLower] = $true + $changed = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + $this.Store.SendAs[$smtpKey].Remove($userLower) + $changed = $true + } + } + 'SendOnBehalf' { + $list = $this.Store.SendOnBehalf[$smtpKey] + $isPresent = $list | Where-Object { $_.ToLowerInvariant() -eq $userLower } + if ($entry.Ensure -eq 'Present' -and -not $isPresent) { + $list.Add([string]$entry.AssignedUser) + $changed = $true + } + elseif ($entry.Ensure -eq 'Absent' -and $isPresent) { + $toRemove = @($list | Where-Object { $_.ToLowerInvariant() -eq $userLower }) + foreach ($r in $toRemove) { $list.Remove($r) | Out-Null } + $changed = $true + } + } + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureMailboxPermissions' + IdentityKey = $IdentityKey + Changed = $changed + } + } -Force + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ ExchangeOnline = $script:Provider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return 'mock-token' + } -Force + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Ensure Mailbox Permissions' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + ) + } + } + } + + Context 'Behavior' { + It 'grants FullAccess and reports Changed = true' { + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + + $script:Provider.Store.FullAccess['shared@contoso.com']['user1@contoso.com'] | Should -Be $true + } + + It 'is idempotent when FullAccess already present' { + # Pre-populate the store + $script:Provider.Store.FullAccess['shared@contoso.com'] = @{ 'user1@contoso.com' = $true } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + } + + It 'revokes FullAccess when Ensure = Absent' { + $script:Provider.Store.FullAccess['shared@contoso.com'] = @{ 'user1@contoso.com' = $true } + + $step = [pscustomobject]@{ + Name = 'Revoke FullAccess' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Absent' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store.FullAccess['shared@contoso.com'].ContainsKey('user1@contoso.com') | Should -Be $false + } + + It 'is idempotent when Absent is already absent' { + $step = [pscustomobject]@{ + Name = 'Revoke SendAs (already absent)' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user2@contoso.com'; Right = 'SendAs'; Ensure = 'Absent' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $false + } + + It 'grants SendAs and reports Changed = true' { + $step = [pscustomobject]@{ + Name = 'Grant SendAs' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user2@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store.SendAs['shared@contoso.com']['user2@contoso.com'] | Should -Be $true + } + + It 'grants SendOnBehalf and reports Changed = true' { + $step = [pscustomobject]@{ + Name = 'Grant SendOnBehalf' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user3@contoso.com'; Right = 'SendOnBehalf'; Ensure = 'Present' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store.SendOnBehalf['shared@contoso.com'] | Should -Contain 'user3@contoso.com' + } + + It 'handles multiple permission entries in a single step' { + $step = [pscustomobject]@{ + Name = 'Multi-permission step' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + @{ AssignedUser = 'user2@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } + @{ AssignedUser = 'user3@contoso.com'; Right = 'SendOnBehalf'; Ensure = 'Present' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -Be $true + $script:Provider.Store.FullAccess['shared@contoso.com']['user1@contoso.com'] | Should -Be $true + $script:Provider.Store.SendAs['shared@contoso.com']['user2@contoso.com'] | Should -Be $true + $script:Provider.Store.SendOnBehalf['shared@contoso.com'] | Should -Contain 'user3@contoso.com' + } + } + + Context 'Validation' { + It 'throws when Permissions is missing' { + $step = [pscustomobject]@{ + Name = 'Missing Permissions' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.Permissions*" + } + + It 'throws when IdentityKey is missing' { + $step = [pscustomobject]@{ + Name = 'Missing IdentityKey' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + Permissions = @( + @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires With.IdentityKey*" + } + + It 'throws when a Permissions entry is missing AssignedUser' { + $step = [pscustomobject]@{ + Name = 'Bad entry' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ Right = 'FullAccess'; Ensure = 'Present' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*requires 'AssignedUser'*" + } + + It 'throws when a Permissions entry has an invalid Right' { + $step = [pscustomobject]@{ + Name = 'Invalid right' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user1@contoso.com'; Right = 'CalendarDelegate'; Ensure = 'Present' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*Right must be one of*" + } + + It 'throws when a Permissions entry has an invalid Ensure value' { + $step = [pscustomobject]@{ + Name = 'Invalid ensure' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Maybe' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*Ensure must be one of*" + } + + It 'rejects ScriptBlocks in Permissions (security boundary)' { + $step = [pscustomobject]@{ + Name = 'ScriptBlock injection' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = { 'injected' }; Right = 'FullAccess'; Ensure = 'Present' } + ) + } + } + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + { & $handler -Context $script:Context -Step $step } | Should -Throw "*ScriptBlocks are not allowed*" + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Mailbox\Invoke-IdleStepMailboxPermissionsEnsure' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * + } + } +} From 58417382159aca2791a19663382b6d89bdad5cdf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:04:29 +0000 Subject: [PATCH 3/4] fix: add step registry entry, regenerate docs, update provider and README for PowerShell 7+ compat Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../providers/provider-exchangeonline.md | 44 ++++++++-- docs/reference/steps.md | 1 + .../steps/step-mailbox-ensure-permissions.md | 80 +++++++++++++++++++ .../Private/Get-IdleStepRegistry.ps1 | 7 ++ src/IdLE.Steps.Mailbox/README.md | 1 + 5 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 docs/reference/steps/step-mailbox-ensure-permissions.md diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index fa2b9b83..0fb58b0a 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -11,8 +11,8 @@ import ExoLeaverMailboxOffboarding from '@site/../examples/workflows/templates/e ## Summary - **Module:** `IdLE.Provider.ExchangeOnline` -- **What it’s for:** Exchange Online mailbox configuration (type conversion, Out of Office, mailbox info) -- **Targets:** Exchange Online via `ExchangeOnlineManagement` cmdlets +- **What it’s for:** Exchange Online mailbox configuration (type conversion, Out of Office, delegate permissions, mailbox info) +- **Targets:** Exchange Online via `ExchangeOnlineManagement` v3+ cmdlets (PowerShell 7+ compatible) - **Identity keys:** UPN (recommended), SMTP address, mailbox identifiers (provider-specific) ## When to use @@ -23,6 +23,7 @@ Use this provider when your workflows need to manage **mailbox settings** in Exc - apply a safe baseline at onboarding (verify mailbox + ensure expected type) - convert mailbox type (e.g. user → shared for leavers) - set Out of Office messages (internal/external) and audience +- converge delegate permissions (FullAccess, SendAs, SendOnBehalf) Non-goals: @@ -33,9 +34,13 @@ Non-goals: ### Requirements -- `ExchangeOnlineManagement` module available on the execution host +- `ExchangeOnlineManagement` v3.0+ module available on the execution host (supports PowerShell 7+) - A host/runtime that establishes an Exchange Online session (delegated or app-only) -- Permissions for the mailbox operations you intend to run (conversion, OOO, etc.) +- Permissions for the mailbox operations you intend to run (conversion, OOO, permissions, etc.) + +> **PowerShell 7+ compatibility:** `ExchangeOnlineManagement` v3.0.0 and later support PowerShell 7+ on +> Windows, macOS, and Linux via REST-based cmdlets. The “Windows only” limitation in earlier versions +> applied to certificate-based app-only auth; the module itself runs cross-platform from v3.0 onwards. ### Install (PowerShell Gallery) @@ -75,9 +80,33 @@ Mailbox steps typically reference that session via: Common step types using this provider include: -- `IdLE.Step.Mailbox.GetInfo` -- `IdLE.Step.Mailbox.EnsureType` -- `IdLE.Step.Mailbox.EnsureOutOfOffice` +| Step Type | Capability Required | Description | +| --- | --- | --- | +| `IdLE.Step.Mailbox.GetInfo` | `IdLE.Mailbox.Info.Read` | Read mailbox details | +| `IdLE.Step.Mailbox.EnsureType` | `IdLE.Mailbox.Type.Ensure` | Convert mailbox type | +| `IdLE.Step.Mailbox.EnsureOutOfOffice` | `IdLE.Mailbox.OutOfOffice.Ensure` | Configure Out of Office | +| `IdLE.Step.Mailbox.EnsurePermissions` | `IdLE.Mailbox.Permissions.Ensure` | Converge delegate permissions | + +### Delegate permissions example + +```powershell +@{ + Name = 'Set Shared Mailbox Permissions' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'shared@contoso.com' + Permissions = @( + @{ AssignedUser = 'user1@contoso.com'; Right = 'FullAccess'; Ensure = 'Present' } + @{ AssignedUser = 'user2@contoso.com'; Right = 'SendAs'; Ensure = 'Present' } + @{ AssignedUser = 'leaver@contoso.com'; Right = 'FullAccess'; Ensure = 'Absent' } + ) + } +} +``` + +Supported rights: `FullAccess`, `SendAs`, `SendOnBehalf`. +Each entry requires `AssignedUser` (UPN/SMTP), `Right`, and `Ensure` (`Present` or `Absent`). ## Configuration @@ -97,6 +126,7 @@ To keep provider documentation focused and consistent, this page embeds only the - **Not connected**: ensure the host establishes an Exchange Online session before IdLE runs. - **Access denied**: the session identity must have permission to change mailbox settings. - **OOO formatting issues**: use `MessageFormat = 'Html'` and validate HTML in a test mailbox first. +- **Permission changes not applied**: ensure the session identity has the *Mail Recipients* management role (or Exchange Administrator) required for `Add/Remove-MailboxPermission` and `Add/Remove-RecipientPermission`. ## Scenarios (link-only) diff --git a/docs/reference/steps.md b/docs/reference/steps.md index 3f56767e..688c34b5 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -13,6 +13,7 @@ | [IdLE.Step.EnsureAttributes](steps/step-ensure-attributes.md) | ``IdLE.Steps.Common`` | Ensures that multiple identity attributes match their desired values. | | [IdLE.Step.EnsureEntitlement](steps/step-ensure-entitlement.md) | ``IdLE.Steps.Common`` | Ensures that an entitlement assignment is present or absent for an identity. | | [IdLE.Step.Mailbox.EnsureOutOfOffice](steps/step-mailbox-ensure-out-of-office.md) | ``IdLE.Steps.Mailbox`` | Ensures that a mailbox Out of Office (OOF) configuration matches the desired state. | +| [IdLE.Step.Mailbox.EnsurePermissions](steps/step-mailbox-ensure-permissions.md) | ``IdLE.Steps.Mailbox`` | Ensures that mailbox delegate permissions match the desired state. | | [IdLE.Step.Mailbox.EnsureType](steps/step-mailbox-ensure-type.md) | ``IdLE.Steps.Mailbox`` | Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). | | [IdLE.Step.Mailbox.GetInfo](steps/step-mailbox-get-info.md) | ``IdLE.Steps.Mailbox`` | Retrieves mailbox details and returns a structured report. | | [IdLE.Step.MoveIdentity](steps/step-move-identity.md) | ``IdLE.Steps.Common`` | Moves an identity to a different container/OU in the target system. | diff --git a/docs/reference/steps/step-mailbox-ensure-permissions.md b/docs/reference/steps/step-mailbox-ensure-permissions.md new file mode 100644 index 00000000..a118e8ab --- /dev/null +++ b/docs/reference/steps/step-mailbox-ensure-permissions.md @@ -0,0 +1,80 @@ +# IdLE.Step.Mailbox.EnsurePermissions + +> Generated file. Do not edit by hand. +> Source: tools/Generate-IdleStepReference.ps1 + +## Summary + +- **Step Type**: `IdLE.Step.Mailbox.EnsurePermissions` +- **Module**: `IdLE.Steps.Mailbox` +- **Implementation**: `Invoke-IdleStepMailboxPermissionsEnsure` +- **Idempotent**: `Yes` + +## Synopsis + +Ensures that mailbox delegate permissions match the desired state. + +## Description + +The host must supply a provider instance via +Context.Providers[<ProviderAlias>]. The provider must implement an EnsureMailboxPermissions +method with the signature (IdentityKey, Permissions, AuthSession) and return an object +that contains a boolean property 'Changed'. + +The step is idempotent by design: it converges mailbox delegate permissions to the desired +state by computing the delta between current and desired permissions and applying only the +necessary changes. + +Supported rights (v1): + +- FullAccess + +- SendAs + +- SendOnBehalf + +Permissions array shape (data-only): +Each entry must be a hashtable with: + +- AssignedUser: string (required) - UPN or SMTP address of the delegate + +- Right: 'FullAccess' | 'SendAs' | 'SendOnBehalf' (required) + +- Ensure: 'Present' | 'Absent' (required) + +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' \}). + +## Inputs (With.*) + +The following keys are required in the step's ``With`` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `IdentityKey` | Yes | Unique identifier for the identity | +| `Permissions` | Yes | See step description for details | + +## Example + +```powershell +@{ + Name = 'IdLE.Step.Mailbox.EnsurePermissions Example' + Type = 'IdLE.Step.Mailbox.EnsurePermissions' + With = @{ + IdentityKey = 'user.name' + Permissions = '' + } +} +``` + +## See Also + +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities +- [Providers](../providers.md) - Available provider implementations diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 9e7952fc..74d7ed73 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -199,5 +199,12 @@ function Get-IdleStepRegistry { } } + if (-not $registry.ContainsKey('IdLE.Step.Mailbox.EnsurePermissions')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepMailboxPermissionsEnsure' -ModuleName 'IdLE.Steps.Mailbox' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.Mailbox.EnsurePermissions'] = $handler + } + } + return $registry } diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md index 77f3807b..8776c4ce 100644 --- a/src/IdLE.Steps.Mailbox/README.md +++ b/src/IdLE.Steps.Mailbox/README.md @@ -22,6 +22,7 @@ Provider-agnostic mailbox step pack for IdLE. - **IdLE.Step.Mailbox.GetInfo** - Read mailbox details - **IdLE.Step.Mailbox.EnsureType** - Convert mailbox type (User/Shared/Room/Equipment) - **IdLE.Step.Mailbox.EnsureOutOfOffice** - Configure Out of Office settings +- **IdLE.Step.Mailbox.EnsurePermissions** - Converge delegate permissions (FullAccess, SendAs, SendOnBehalf) ## Documentation From eaee7ed79b376b9248eb60442e0a496e42c538d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:22:33 +0000 Subject: [PATCH 4/4] docs: add step-mailbox-ensure-permissions to sidebar navigation Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- website/sidebars.js | 1 + 1 file changed, 1 insertion(+) diff --git a/website/sidebars.js b/website/sidebars.js index f861a6fa..962285dd 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -106,6 +106,7 @@ const sidebars = { 'reference/steps/step-mailbox-get-info', 'reference/steps/step-mailbox-ensure-type', 'reference/steps/step-mailbox-ensure-out-of-office', + 'reference/steps/step-mailbox-ensure-permissions', ] }, 'reference/providers',