Exchange Online PowerShell script Report only

Get-EXOMailboxPermissionsReport.ps1

Audits delegated mailbox access: FullAccess, SendAs and SendOnBehalf grants across Exchange Online mailboxes.

Overview#

For every mailbox in scope the script collects explicit (non-inherited) FullAccess grants with Get-EXOMailboxPermission, SendAs grants with Get-EXORecipientPermission and SendOnBehalf delegates from the mailbox's GrantSendOnBehalfTo property. Each trustee is resolved once through a cached Get-EXORecipient lookup so the report shows its primary SMTP address and recipient type; trustees that remain on the ACL as bare SIDs (deleted accounts) are flagged as Orphaned. One row per grant is written to CSV; the console summary lists the mailboxes with the most delegates and the number of orphaned grants. The script is read-only.

Safety: Report only — makes no changes to your tenant. Run Get-Help .\Get-EXOMailboxPermissionsReport.ps1 -Full for the complete help text.

Parameters#

ParameterWhat it does
-IdentityOne or more mailbox identities (UPN, primary SMTP address, alias or GUID). When omitted, every mailbox is audited.
-SharedOnlyLimit the audit to SharedMailbox, RoomMailbox and EquipmentMailbox recipients, where delegation is the norm.
-OutputPathPath of the CSV report. Defaults to .\Reports\EXOMailboxPermissions_yyyyMMdd-HHmm.csv.
-PassThruAlso emit the report objects to the pipeline.

Examples#

PowerShell
PS> .\Get-EXOMailboxPermissionsReport.ps1

Audits every mailbox and writes .\Reports\EXOMailboxPermissions_<timestamp>.csv.

PowerShell
PS> .\Get-EXOMailboxPermissionsReport.ps1 -SharedOnly -Verbose

Audits shared, room and equipment mailboxes only, showing each lookup as it happens.

PowerShell
PS> .\Get-EXOMailboxPermissionsReport.ps1 -Identity ceo@contoso.com -PassThru | Where-Object { $_.TrusteeType -eq 'Orphaned' }

Lists grants on one mailbox whose trustee no longer exists in the directory.

Permissions, modules and notes#

Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, ExchangeOnlineManagement 3.x Permissions : View-Only Recipients for the report; Exchange Administrator / Recipient Management to act on the findings Notes : Two REST calls per mailbox (Get-EXOMailboxPermission and Get-EXORecipientPermission) - expect roughly 1-2 seconds per mailbox. AutoMapping is not exposed by the EXO cmdlets and is therefore not reported. TrusteeType 'Orphaned' means the ACL holds a SID (S-1-5-21-...) for a deleted account; clean it up with Remove-MailboxPermission -Identity <mailbox> -User <SID> -AccessRights FullAccess. 'Unknown' means the trustee could not be resolved as a recipient, for example a security group without an email address.

Full source#

PowerShell · Get-EXOMailboxPermissionsReport.ps1
<#
.SYNOPSIS
    Audits delegated mailbox access: FullAccess, SendAs and SendOnBehalf grants across Exchange Online mailboxes.
.DESCRIPTION
    For every mailbox in scope the script collects explicit (non-inherited) FullAccess grants with
    Get-EXOMailboxPermission, SendAs grants with Get-EXORecipientPermission and SendOnBehalf delegates from the
    mailbox's GrantSendOnBehalfTo property. Each trustee is resolved once through a cached Get-EXORecipient
    lookup so the report shows its primary SMTP address and recipient type; trustees that remain on the ACL as
    bare SIDs (deleted accounts) are flagged as Orphaned.
    One row per grant is written to CSV; the console summary lists the mailboxes with the most delegates and the
    number of orphaned grants. The script is read-only.
.PARAMETER Identity
    One or more mailbox identities (UPN, primary SMTP address, alias or GUID). When omitted, every mailbox is audited.
.PARAMETER SharedOnly
    Limit the audit to SharedMailbox, RoomMailbox and EquipmentMailbox recipients, where delegation is the norm.
.PARAMETER OutputPath
    Path of the CSV report. Defaults to .\Reports\EXOMailboxPermissions_yyyyMMdd-HHmm.csv.
.PARAMETER PassThru
    Also emit the report objects to the pipeline.
.EXAMPLE
    PS> .\Get-EXOMailboxPermissionsReport.ps1
    Audits every mailbox and writes .\Reports\EXOMailboxPermissions_<timestamp>.csv.
.EXAMPLE
    PS> .\Get-EXOMailboxPermissionsReport.ps1 -SharedOnly -Verbose
    Audits shared, room and equipment mailboxes only, showing each lookup as it happens.
.EXAMPLE
    PS> .\Get-EXOMailboxPermissionsReport.ps1 -Identity ceo@contoso.com -PassThru | Where-Object { $_.TrusteeType -eq 'Orphaned' }
    Lists grants on one mailbox whose trustee no longer exists in the directory.
.NOTES
    Author      : Omer Eltayeb
    Blog        : https://www.oeltayeb.com
    GitHub      : https://github.com/omer-eltayeb
    Version     : 1.0.0
    Requires    : PowerShell 5.1 or 7.x, ExchangeOnlineManagement 3.x
    Permissions : View-Only Recipients for the report; Exchange Administrator / Recipient Management to act on the findings
    Notes       : Two REST calls per mailbox (Get-EXOMailboxPermission and Get-EXORecipientPermission) - expect roughly
                  1-2 seconds per mailbox. AutoMapping is not exposed by the EXO cmdlets and is therefore not reported.
                  TrusteeType 'Orphaned' means the ACL holds a SID (S-1-5-21-...) for a deleted account; clean it up with
                  Remove-MailboxPermission -Identity <mailbox> -User <SID> -AccessRights FullAccess. 'Unknown' means the
                  trustee could not be resolved as a recipient, for example a security group without an email address.
.LINK
    https://learn.microsoft.com/powershell/module/exchange/get-exomailboxpermission
.LINK
    https://learn.microsoft.com/powershell/module/exchange/get-exorecipientpermission
#>
#Requires -Version 5.1
#Requires -Modules ExchangeOnlineManagement

[CmdletBinding()]
param(
    [Parameter()]
    [string[]]$Identity,

    [Parameter()]
    [switch]$SharedOnly,

    [Parameter()]
    [string]$OutputPath,

    [Parameter()]
    [switch]$PassThru
)

$ErrorActionPreference = 'Stop'

#region Helpers
function Connect-ExchangeIfNeeded {
    <# Connects to Exchange Online (or Security & Compliance PowerShell) only when no live session exists. #>
    [CmdletBinding()]
    param(
        [Parameter()]
        [switch]$Compliance
    )
    $connections = @(Get-ConnectionInformation -ErrorAction SilentlyContinue)
    if ($Compliance) {
        $active = @($connections | Where-Object { $_.ConnectionUri -like '*compliance*' -and $_.State -eq 'Connected' })
        if ($active.Count -eq 0) {
            Write-Verbose 'Connecting to Security & Compliance PowerShell.'
            Connect-IPPSSession -ErrorAction Stop
        }
    }
    else {
        $active = @($connections | Where-Object { $_.ConnectionUri -notlike '*compliance*' -and $_.State -eq 'Connected' })
        if ($active.Count -eq 0) {
            Write-Verbose 'Connecting to Exchange Online.'
            Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
        }
    }
}

function Resolve-Trustee {
    <# Resolves a trustee (UPN, name, canonical name or SID) to its primary SMTP address and RecipientTypeDetails.
       Results are cached because the same delegates usually appear on many mailboxes. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$TrusteeIdentity
    )
    if (-not $script:TrusteeCache.ContainsKey($TrusteeIdentity)) {
        $resolved = [PSCustomObject]@{ Trustee = $TrusteeIdentity; TrusteeType = 'Unknown' }
        if ($TrusteeIdentity -match '^S-1-5-') {
            # Deleted accounts stay on the ACL as a bare SID.
            $resolved.TrusteeType = 'Orphaned'
        }
        else {
            try {
                $recipient = Get-EXORecipient -Identity $TrusteeIdentity -Properties PrimarySmtpAddress, RecipientTypeDetails -ErrorAction Stop | Select-Object -First 1
                if ($null -ne $recipient) {
                    if (-not [string]::IsNullOrWhiteSpace($recipient.PrimarySmtpAddress)) { $resolved.Trustee = [string]$recipient.PrimarySmtpAddress }
                    $resolved.TrusteeType = [string]$recipient.RecipientTypeDetails
                }
            }
            catch {
                Write-Verbose "Trustee '$TrusteeIdentity' could not be resolved: $($_.Exception.Message)"
            }
        }
        $script:TrusteeCache[$TrusteeIdentity] = $resolved
    }
    return $script:TrusteeCache[$TrusteeIdentity]
}
#endregion Helpers

#region Main
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    $reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
    $OutputPath = Join-Path -Path $reportFolder -ChildPath ('EXOMailboxPermissions_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
    New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}

try {
    Connect-ExchangeIfNeeded
}
catch {
    throw "Unable to connect to Exchange Online: $($_.Exception.Message)"
}

$script:TrusteeCache = @{}
$mailboxProperties = @('DisplayName', 'UserPrincipalName', 'PrimarySmtpAddress', 'RecipientTypeDetails', 'GrantSendOnBehalfTo')
$mailboxes = New-Object -TypeName System.Collections.Generic.List[object]
if ($PSBoundParameters.ContainsKey('Identity')) {
    foreach ($id in $Identity) {
        try {
            $mailboxes.Add((Get-EXOMailbox -Identity $id -Properties $mailboxProperties -ErrorAction Stop))
        }
        catch {
            Write-Warning "Mailbox '$id' was not found or is not accessible: $($_.Exception.Message)"
        }
    }
}
else {
    $getParams = @{ ResultSize = 'Unlimited'; Properties = $mailboxProperties; ErrorAction = 'Stop' }
    if ($SharedOnly) { $getParams['RecipientTypeDetails'] = @('SharedMailbox', 'RoomMailbox', 'EquipmentMailbox') }
    try {
        foreach ($mailbox in (Get-EXOMailbox @getParams)) { $mailboxes.Add($mailbox) }
    }
    catch {
        throw "Failed to retrieve mailboxes: $($_.Exception.Message)"
    }
}
Write-Verbose "Auditing $($mailboxes.Count) mailbox(es)."

$results = New-Object -TypeName System.Collections.Generic.List[object]
$index = 0
foreach ($mailbox in $mailboxes) {
    $index++
    $upn = $mailbox.UserPrincipalName
    Write-Progress -Activity 'Collecting mailbox permissions' -Status "$index of $($mailboxes.Count) - $upn ($($results.Count) grants so far)" -PercentComplete (($index / $mailboxes.Count) * 100)

    # Gather raw grants first, then resolve trustees once per grant.
    $grants = New-Object -TypeName System.Collections.Generic.List[object]
    try {
        $fullAccess = Get-EXOMailboxPermission -Identity $upn -ErrorAction Stop |
            Where-Object { $_.IsInherited -ne $true -and $_.User -notlike 'NT AUTHORITY\SELF' -and (@($_.AccessRights) -join ',') -match 'FullAccess' }
        foreach ($entry in @($fullAccess)) {
            $grants.Add(@{ Type = 'FullAccess'; Trustee = [string]$entry.User; Rights = (@($entry.AccessRights) -join ', '); Deny = [bool]$entry.Deny })
        }
    }
    catch {
        Write-Warning "Could not read mailbox permissions for '$upn': $($_.Exception.Message)"
    }

    try {
        $sendAs = Get-EXORecipientPermission -Identity $upn -ErrorAction Stop |
            Where-Object { (@($_.AccessRights) -join ',') -match 'SendAs' -and $_.Trustee -notlike 'NT AUTHORITY\SELF' }
        foreach ($entry in @($sendAs)) {
            $grants.Add(@{ Type = 'SendAs'; Trustee = [string]$entry.Trustee; Rights = 'SendAs'; Deny = ([string]$entry.AccessControlType -eq 'Deny') })
        }
    }
    catch {
        Write-Warning "Could not read SendAs permissions for '$upn': $($_.Exception.Message)"
    }

    foreach ($entry in @($mailbox.GrantSendOnBehalfTo)) {
        if ([string]::IsNullOrWhiteSpace([string]$entry)) { continue }
        $grants.Add(@{ Type = 'SendOnBehalf'; Trustee = [string]$entry; Rights = 'SendOnBehalf'; Deny = $false })
    }

    foreach ($grant in $grants) {
        $trustee = Resolve-Trustee -TrusteeIdentity $grant.Trustee
        $results.Add([PSCustomObject]@{
                Mailbox         = $mailbox.DisplayName
                MailboxUPN      = $upn
                MailboxType     = [string]$mailbox.RecipientTypeDetails
                PermissionType  = $grant.Type
                Trustee         = $trustee.Trustee
                TrusteeIdentity = $grant.Trustee
                TrusteeType     = $trustee.TrusteeType
                AccessRights    = $grant.Rights
                Deny            = $grant.Deny
            })
    }
}
Write-Progress -Activity 'Collecting mailbox permissions' -Completed

if ($results.Count -gt 0) {
    $results | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
else {
    Write-Verbose 'No delegated permissions were found; no CSV written.'
}

$orphaned = @($results | Where-Object { $_.TrusteeType -eq 'Orphaned' })
$topMailboxes = @($results | Group-Object -Property MailboxUPN | Sort-Object -Property Count -Descending | Select-Object -First 5)

Write-Host ''
Write-Host 'Mailbox permissions summary' -ForegroundColor Cyan
Write-Host ('  Mailboxes audited   : {0}' -f $mailboxes.Count)
Write-Host ('  Grants found        : {0}' -f $results.Count)
foreach ($group in ($results | Group-Object -Property PermissionType | Sort-Object -Property Name)) {
    Write-Host ('    {0,-14}: {1}' -f $group.Name, $group.Count)
}
Write-Host ('  Orphaned trustees   : {0}' -f $orphaned.Count) -ForegroundColor $(if ($orphaned.Count -gt 0) { 'Yellow' } else { 'Green' })
if ($topMailboxes.Count -gt 0) {
    Write-Host '  Most delegated mailboxes:'
    foreach ($group in $topMailboxes) {
        Write-Host ('    {0,3} grant(s)  {1}' -f $group.Count, $group.Name)
    }
}
if ($results.Count -gt 0) {
    Write-Host ('  Report              : {0}' -f $OutputPath)
}

if ($PassThru) {
    $results
}
#endregion Main

Scripts are provided as-is under the MIT licence. Review the permissions a script requests, test in a non-production tenant, and use -WhatIf before letting any script change anything.

Found a bug or have an improvement? Open an issue on GitHub or email me.