Public/Invoke-ModernMailboxPermReport.ps1
|
function Invoke-ModernMailboxPermReport { <# .SYNOPSIS Generates a user-centric mailbox permission report. .DESCRIPTION Reads mailbox permissions (Full Access, Send As, Send on Behalf) from Exchange Online and pivots them so you can see which user has access to which mailboxes. Supports optional export to CSV or JSON. .PARAMETER OutPath Optional path for exporting the report. Provide a folder to create MailboxPermissionReport.csv or a specific file path ending in .csv or .json. .PARAMETER PermissionType Filters which permission types to include. Defaults to all. .PARAMETER IncludeInherited Include inherited Full Access entries (Default permissions). Off by default. .PARAMETER Raw Return one row per permission assignment instead of the per-user summary. .EXAMPLE Invoke-ModernMailboxPermReport .EXAMPLE Invoke-ModernMailboxPermReport -OutPath C:\Reports\MailboxPermissionReport.html .EXAMPLE Invoke-ModernMailboxPermReport -OutPath C:\Reports .EXAMPLE Invoke-ModernMailboxPermReport -AddHtmlOutput -OutPath C:\Reports .EXAMPLE Invoke-ModernMailboxPermReport -PermissionType FullAccess,SendAs -OutPath C:\Reports\perm.json .EXAMPLE Invoke-ModernMailboxPermReport -PermissionType FullAccess,SendAs -Raw -OutPath C:\Reports\perm.json .LINK https://exchangepermissions.alweys.ch/modernmailtools/Invoke-ModernMailboxPermReport #> [CmdletBinding()] param( [Parameter(Mandatory = $false, HelpMessage = 'Optional export path (folder, .csv, or .json).')] [string]$OutPath, [ValidateSet('FullAccess', 'SendAs', 'SendOnBehalf')] [string[]]$PermissionType = @('FullAccess', 'SendAs', 'SendOnBehalf'), [Parameter(Mandatory = $false, HelpMessage = 'Include inherited permissions')] [switch]$IncludeInherited, [Parameter(Mandatory = $false, HelpMessage = 'Return detailed rows instead of summary')] [switch]$Raw, [Parameter(Mandatory = $false, HelpMessage = 'Emit an HTML report (MailboxPermissionReport.html)')] [switch]$AddHtmlOutput, [Parameter(Mandatory = $false, HelpMessage = 'Disable telemetry')] [switch]$DisableTelemetry ) if ($DisableTelemetry -or $Script:DisableTelemetry) { $Script:DisableTelemetry = $true } else { Write-Telemetry -EventName "InvokeModernMailboxPermReport" } # Ensure shared session cache exists to keep compatibility with other functions. if (-not (Get-Variable -Name __MMTSession -Scope Script -ErrorAction SilentlyContinue)) { $script:__MMTSession = @{ CloudUserCache = @() CloudMailboxCache = @() CountCache = @{} TableCache = @{} } New-Variable -Name __MMTSession -Value $script:__MMTSession -Scope Script -Force | Out-Null } # Exchange Online session $session = Get-ConnectionInformation | Where-Object { $_.state -eq 'Connected' -and $_.ModulePrefix -eq 'EO' } if (-not $session) { $session = Connect-MMExchangeOnline } # Build a user index to resolve trustees to UPN/display name quickly. $userIndex = @{} $cloudUsers = Get-MMCloudUser foreach ($user in $cloudUsers) { foreach ($key in @($user.UserPrincipalName, $user.PrimarySmtpAddress, $user.Alias, $user.ExternalDirectoryObjectId)) { if ($null -ne $key -and -not [string]::IsNullOrWhiteSpace($key)) { $userIndex[$key.ToString().ToLower()] = $user } } } function Resolve-PermissionPrincipal { param( [string]$RawPrincipal ) if ([string]::IsNullOrWhiteSpace($RawPrincipal)) { return $null } $normalized = $RawPrincipal.ToLower() if ($userIndex.ContainsKey($normalized)) { return $userIndex[$normalized] } if ($normalized.StartsWith('smtp:')) { $trimmed = $normalized.Substring(5) if ($userIndex.ContainsKey($trimmed)) { return $userIndex[$trimmed] } } return $null } function Test-MMSkipPrincipal { param( [string]$Principal, [string]$MailboxSmtp ) if ([string]::IsNullOrWhiteSpace($Principal)) { return $true } $p = $Principal.ToLower() $mail = if ($MailboxSmtp) { $MailboxSmtp.ToLower() } else { '' } if ($mail -and $p -eq $mail) { return $true } if ($p -match '^nt authority\\' -or $p -match '^s-1-' -or $p -eq 'anonymous logon' -or $p -eq 'default') { return $true } if ($p -match '^exchange (admins|servers|trusted subsystem)') { return $true } if ($p -match '^managed availability' -or $p -match '^healthmailbox') { return $true } if ($p -match '^federatedemail' -or $p -match '^discovery') { return $true } if ($p -match '^systemmailbox') { return $true } return $false } $mailboxes = Get-EOMailbox -ResultSize unlimited $permissionRows = @() foreach ($mbx in $mailboxes) { $mbxSmtp = $mbx.PrimarySmtpAddress if ($PermissionType -contains 'FullAccess') { try { $fa = Get-EOMailboxPermission -Identity $mbx.Identity -ErrorAction Stop | Where-Object { $_.AccessRights -contains 'FullAccess' -and -not $_.Deny -and ($IncludeInherited -or -not $_.IsInherited) } foreach ($entry in $fa) { $principal = $entry.User.ToString() if (Test-MMSkipPrincipal -Principal $principal -MailboxSmtp $mbxSmtp) { continue } $permissionRows += [pscustomobject]@{ Mailbox = $mbxSmtp MailboxDisplayName = $mbx.DisplayName PermissionType = 'FullAccess' Trustee = $principal IsInherited = [bool]$entry.IsInherited } } } catch { Write-Verbose "FullAccess query failed for $($mbxSmtp): $($_.Exception.Message)" } } if ($PermissionType -contains 'SendAs') { try { $sa = Get-EORecipientPermission -Identity $mbx.Identity -ErrorAction Stop | Where-Object { $_.AccessRights -contains 'SendAs' } foreach ($entry in $sa) { $principal = $entry.Trustee.ToString() if (Test-MMSkipPrincipal -Principal $principal -MailboxSmtp $mbxSmtp) { continue } $permissionRows += [pscustomobject]@{ Mailbox = $mbxSmtp MailboxDisplayName = $mbx.DisplayName PermissionType = 'SendAs' Trustee = $principal IsInherited = $false } } } catch { Write-Verbose "SendAs query failed for $($mbxSmtp): $($_.Exception.Message)" } } if ($PermissionType -contains 'SendOnBehalf') { $sobEntries = $mbx.GrantSendOnBehalfTo if ($sobEntries) { foreach ($entry in $sobEntries) { $principal = if ($entry.PrimarySmtpAddress) { $entry.PrimarySmtpAddress } else { $entry.Name } if (Test-MMSkipPrincipal -Principal $principal -MailboxSmtp $mbxSmtp) { continue } $permissionRows += [pscustomobject]@{ Mailbox = $mbxSmtp MailboxDisplayName = $mbx.DisplayName PermissionType = 'SendOnBehalf' Trustee = $principal IsInherited = $false } } } } } $expandedRows = foreach ($row in $permissionRows) { $resolved = Resolve-PermissionPrincipal -RawPrincipal $row.Trustee [pscustomobject]@{ UserPrincipalName = if ($resolved) { $resolved.UserPrincipalName } else { $row.Trustee } UserDisplayName = if ($resolved) { $resolved.DisplayName } else { $row.Trustee } RecipientTypeDetails = if ($resolved) { $resolved.RecipientTypeDetails } else { $null } Mailbox = $row.Mailbox MailboxDisplayName = $row.MailboxDisplayName PermissionType = $row.PermissionType IsInherited = $row.IsInherited } } if ($Raw) { $output = $expandedRows | Sort-Object UserPrincipalName, Mailbox, PermissionType } else { $output = $expandedRows | Group-Object UserPrincipalName | ForEach-Object { $first = $_.Group | Select-Object -First 1 [pscustomobject]@{ UserPrincipalName = $_.Name UserDisplayName = $first.UserDisplayName RecipientTypeDetails = $first.RecipientTypeDetails MailboxCount = ($_.Group | Select-Object -ExpandProperty Mailbox -Unique).Count Permissions = ($_.Group | Sort-Object Mailbox, PermissionType | ForEach-Object { "$($_.Mailbox) [$($_.PermissionType)]" }) -join '; ' } } | Sort-Object UserPrincipalName } if ($OutPath) { $targetPath = $OutPath if (-not [System.IO.Path]::HasExtension($targetPath)) { $targetPath = Join-Path -Path $OutPath -ChildPath 'MailboxPermissionReport.csv' } $dir = Split-Path -Path $targetPath -Parent if (-not (Test-Path -Path $dir -PathType Container)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } if ($targetPath.ToLower().EndsWith('.json')) { $output | ConvertTo-Json -Depth 6 | Set-Content -Path $targetPath -Encoding UTF8 Write-Verbose "Report exported to $targetPath" } elseif ($targetPath.ToLower().EndsWith('.html') -or $AddHtmlOutput) { $htmlPath = if ($targetPath.ToLower().EndsWith('.html')) { $targetPath } else { Join-Path -Path (Split-Path $targetPath -Parent) -ChildPath 'MailboxPermissionReport.html' } $htmlTitle = 'Mailbox Permission Report' $htmlDate = Get-Date $tenantId = if ($session.TenantId) { $session.TenantId } else { 'N/A' } $generatedBy = if ($session.UserPrincipalName) { $session.UserPrincipalName } else { 'N/A' } $exoConnected = [bool]$session $rowsForHtml = if ($Raw) { $output } else { $expandedRows } $assignmentCount = $rowsForHtml.Count $uniqueUsers = ($rowsForHtml | Select-Object -ExpandProperty UserPrincipalName -Unique).Count $uniqueMailboxes = ($rowsForHtml | Select-Object -ExpandProperty Mailbox -Unique).Count $permStats = $rowsForHtml | Group-Object PermissionType | Sort-Object Name $chartLabels = $permStats | ForEach-Object { "'$($_.Name)'" } $chartData = $permStats | ForEach-Object { $_.Count } $chartLabelsString = "[" + ($chartLabels -join ',') + "]" $chartDataString = "[" + ($chartData -join ',') + "]" $summaryRows = if (-not $Raw) { $output | Select-Object UserPrincipalName, UserDisplayName, RecipientTypeDetails, MailboxCount, Permissions } else { @() } $assignRows = $rowsForHtml | Select-Object UserPrincipalName, UserDisplayName, RecipientTypeDetails, Mailbox, MailboxDisplayName, PermissionType, IsInherited $page = @" <!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>$htmlTitle</title> <script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> </head> <body class="bg-gray-100 text-gray-800"> <div class="container mx-auto p-6"> <div class="bg-orange-500 text-white rounded-lg shadow-md p-6 mb-6"> <div class="flex flex-col gap-2"> <h1 class="text-3xl font-bold">$htmlTitle</h1> <p class="text-lg">Tenant ID: $tenantId</p> <p class="text-lg">Generated by: $generatedBy</p> <p class="text-sm opacity-90">Generated on: $htmlDate</p> <div class="mt-3 flex flex-wrap gap-2 text-xs font-semibold"> <a href="https://www.alweys.ch" class="bg-blue-500 hover:bg-blue-700 text-white px-3 py-1 rounded shadow-sm">Visit Alweys Website (Blog)</a> <a href="https://exchangepermissions.alweys.ch/modernmailtools/Invoke-ModernMailboxPermReport" class="bg-blue-500 hover:bg-blue-700 text-white px-3 py-1 rounded shadow-sm">Documentation for Cmdlet (Help)</a> <a href="https://www.linkedin.com/in/stefanwey" class="bg-blue-500 hover:bg-blue-700 text-white px-3 py-1 rounded shadow-sm">Connect on LinkedIn (Stefan Wey)</a> <a href="#" class="bg-gray-500 hover:bg-gray-700 text-white px-3 py-1 rounded shadow-sm">Exchange Online: $exoConnected</a> </div> </div> </div> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> <div class="bg-white rounded-lg shadow p-4"> <div class="text-sm text-gray-500">Total assignments</div> <div class="text-2xl font-semibold text-orange-500">$assignmentCount</div> </div> <div class="bg-white rounded-lg shadow p-4"> <div class="text-sm text-gray-500">Unique users</div> <div class="text-2xl font-semibold text-orange-500">$uniqueUsers</div> </div> <div class="bg-white rounded-lg shadow p-4"> <div class="text-sm text-gray-500">Target mailboxes</div> <div class="text-2xl font-semibold text-orange-500">$uniqueMailboxes</div> </div> </div> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="bg-white rounded-lg shadow p-4 max-w-lg"> <h2 class="text-xl font-semibold mb-3">Assignments by permission</h2> <canvas id="permChart" class="w-72 h-72 mx-auto"></canvas> </div> <div class="bg-white rounded-lg shadow p-4 overflow-x-auto"> <h2 class="text-xl font-semibold mb-3">Permission breakdown</h2> <table class="min-w-full table-auto text-sm border-collapse"> <thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200"> <tr> <th class="px-3 py-2 text-left">Permission</th> <th class="px-3 py-2 text-left">Assignments</th> </tr> </thead> <tbody> "@ foreach ($stat in $permStats) { $page += @" <tr class="odd:bg-white even:bg-gray-50 hover:bg-amber-50 border-b border-gray-100"> <td class="px-3 py-2 whitespace-nowrap">$($stat.Name)</td> <td class="px-3 py-2 whitespace-nowrap">$($stat.Count)</td> </tr> "@ } $page += @" </tbody> </table> </div> </div> "@ if ($summaryRows.Count -gt 0) { $page += @" <div class="bg-white rounded-lg shadow p-4 overflow-x-auto mb-6"> <h2 class="text-xl font-semibold mb-3">User summary</h2> <table class="min-w-full table-auto text-sm border-collapse"> <thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200"> <tr> <th class="px-3 py-2 text-left">User</th> <th class="px-3 py-2 text-left">Display Name</th> <th class="px-3 py-2 text-left">Type</th> <th class="px-3 py-2 text-left">Mailboxes</th> <th class="px-3 py-2 text-left">Permissions</th> </tr> </thead> <tbody class="divide-y divide-gray-100"> "@ foreach ($row in $summaryRows) { $page += @" <tr class="odd:bg-white even:bg-gray-50 hover:bg-amber-50 border-b border-gray-100"> <td class="px-3 py-2 whitespace-nowrap">$($row.UserPrincipalName)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.UserDisplayName)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.RecipientTypeDetails)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.MailboxCount)</td> <td class="px-3 py-2">$([System.Web.HttpUtility]::HtmlEncode($row.Permissions))</td> </tr> "@ } $page += @" </tbody> </table> </div> "@ } $page += @" <div class="bg-white rounded-lg shadow p-4 overflow-x-auto"> <h2 class="text-xl font-semibold mb-3">Detailed assignments</h2> <table class="min-w-full table-auto text-sm border-collapse"> <thead class="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200"> <tr> <th class="px-3 py-2 text-left">User</th> <th class="px-3 py-2 text-left">Display Name</th> <th class="px-3 py-2 text-left">Type</th> <th class="px-3 py-2 text-left">Mailbox</th> <th class="px-3 py-2 text-left">Mailbox Name</th> <th class="px-3 py-2 text-left">Permission</th> <th class="px-3 py-2 text-left">Inherited</th> </tr> </thead> <tbody> "@ foreach ($row in $assignRows) { $page += @" <tr class="odd:bg-white even:bg-gray-50 hover:bg-amber-50 border-b border-gray-100"> <td class="px-3 py-2 whitespace-nowrap">$($row.UserPrincipalName)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.UserDisplayName)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.RecipientTypeDetails)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.Mailbox)</td> <td class="px-3 py-2">$($row.MailboxDisplayName)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.PermissionType)</td> <td class="px-3 py-2 whitespace-nowrap">$($row.IsInherited)</td> </tr> "@ } $page += @" </tbody> </table> </div> </div> <script> const permLabels = $chartLabelsString; const permData = $chartDataString; const ctx = document.getElementById('permChart'); if (ctx && permLabels.length > 0) { new Chart(ctx, { type: 'doughnut', data: { labels: permLabels, datasets: [{ data: permData, backgroundColor: ['#EF4444','#3B82F6','#10B981','#F59E0B','#8B5CF6','#EC4899'], borderWidth: 1 }] }, options: { plugins: { legend: { position: 'bottom' } } } }); } </script> </body> </html> "@ $page | Set-Content -Path $htmlPath -Encoding UTF8 Write-Verbose "Report exported to $htmlPath" } else { $output | Export-Csv -Path $targetPath -NoTypeInformation -Encoding UTF8 Write-Verbose "Report exported to $targetPath" } } return $output } |