Public/Get-TntExchangeCalendarPermissionReport.ps1

function Get-TntExchangeCalendarPermissionReport {
    <#
    .SYNOPSIS
        Retrieves calendar folder permissions for all users in Microsoft 365 tenant.
 
    .DESCRIPTION
        This function retrieves and analyzes calendar folder permissions for all users in a Microsoft 365 tenant.
        It identifies who has access to each user's calendar and what level of permissions they have been granted.
 
        Calendar folders are discovered dynamically via Get-EXOMailboxFolderStatistics -FolderScope Calendar,
        which returns the actual calendar folder regardless of the mailbox locale. This avoids hardcoding
        folder names and supports any language without additional configuration.
 
        PERFORMANCE WARNING: This function processes calendar permissions for each user individually using
        Exchange Online PowerShell. For large tenants (>500 users), expect processing times of 10-30 minutes.
        Use -Verbose to monitor progress.
 
    .PARAMETER TenantId
        The Azure AD Tenant ID to connect to.
 
    .PARAMETER ClientId
        The Application (Client) ID of the app registration created for calendar permission reporting.
 
    .PARAMETER ClientSecret
        The client secret for the app registration. Use this for automated scenarios.
 
    .PARAMETER CertificateThumbprint
        The thumbprint of the certificate to use for authentication instead of client secret.
 
    .PARAMETER OutputPath
        The directory path where reports will be saved. Defaults to current directory.
 
    .PARAMETER OutputFormat
        The output format for the report. Valid values are CSV, JSON, or All.
 
    .PARAMETER ExportToFile
        Switch to export the report to a file in addition to returning the object.
 
    .PARAMETER IncludeSystemAccounts
        Switch to include system and service accounts in the results. By default, these are excluded.
 
    .PARAMETER IncludeDefaultPermissions
        Switch to include Default and Anonymous calendar permissions. By default, these are excluded.
 
    .EXAMPLE
        Get-TntExchangeCalendarPermissionReport -TenantId "12345678-1234-1234-1234-123456789012" -ClientId "87654321-4321-4321-4321-210987654321" -ClientSecret $secret
 
        Retrieves calendar permissions for all users.
 
    .EXAMPLE
        Get-TntExchangeCalendarPermissionReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret -ExportToFile -OutputFormat CSV -Verbose
 
        Retrieves all calendar permissions, exports to CSV, and shows verbose progress.
 
    .NOTES
        Author: Tom de Leeuw
        Website: https://systom.dev
        Module: TenantReports
 
        Required Permissions:
        - User.Read.All (Application)
        - Directory.Read.All (Application)
 
        Additional Requirements:
        - Exchange Online View-Only Recipients role (or higher) to query folder permissions.
 
        Performance Considerations:
        - Large tenants may take significant time; use -Verbose to monitor progress.
 
    .LINK
        https://systom.dev
    #>


    [CmdletBinding(DefaultParameterSetName = 'ClientSecret')]
    [OutputType([System.Management.Automation.PSObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
        [Alias('ApplicationId')]
        [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')]
        [string]$ClientId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Alias('ApplicationSecret')]
        [ValidateNotNullOrEmpty()]
        [SecureString]$ClientSecret,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [ValidateNotNullOrEmpty()]
        [string]$CertificateThumbprint,

        [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')]
        [switch]$Interactive,

        [Parameter()]
        [switch]$IncludeSystemAccounts,

        [Parameter()]
        [switch]$IncludeDefaultPermissions
    )

    begin {
        Write-Information 'STARTED : Calendar permissions analysis... (This may take a while)' -InformationAction Continue
    }

    process {
        try {
            # Establish or verify Microsoft Graph connection (needed for tenant domain resolution)
            $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters
            $ConnectionInfo = Connect-TntGraphSession @ConnectionParams

            # Initialize results collection
            $CalendarPermissions = [System.Collections.Generic.List[PSObject]]::new()

            # Connect to Exchange Online (required - throw on failure)
            try {
                if ($PSCmdlet.ParameterSetName -eq 'ClientSecret') {
                    $TokenParams = @{
                        TenantId     = $TenantId
                        ClientId     = $ClientId
                        ClientSecret = $ClientSecret
                        Scope        = 'Exchange'
                    }
                    $ExchangeToken = Get-GraphToken @TokenParams
                    Connect-ExchangeOnline -Organization $TenantId -AccessToken $ExchangeToken.AccessToken -ShowBanner:$false -ErrorAction Stop
                } else {
                    # Certificate auth requires domain name, not GUID
                    $TenantDomain = $null
                    try {
                        $Org = Get-MgOrganization -Property VerifiedDomains | Select-Object -First 1
                        if ($Org.VerifiedDomains) {
                            $TenantDomain = ($Org.VerifiedDomains.Where({ $_.IsInitial }) | Select-Object -First 1 -ExpandProperty Name)
                            if (-not $TenantDomain) {
                                $TenantDomain = ($Org.VerifiedDomains.Where({ $_.IsDefault }) | Select-Object -First 1 -ExpandProperty Name)
                            }
                        }
                    } catch {
                        Write-Verbose "Could not resolve tenant domain: $($_.Exception.Message)"
                    }

                    if (-not $TenantDomain) {
                        $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                                [System.Exception]::new('Could not resolve tenant domain name. Certificate authentication requires a domain name for Exchange Online, not a tenant GUID.'),
                                'ExchangeTenantDomainResolutionError',
                                [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                                $TenantId
                            ))
                    }

                    Connect-ExchangeOnline -AppId $ClientId -CertificateThumbprint $CertificateThumbprint -Organization $TenantDomain -ShowBanner:$false -ErrorAction Stop
                }
                $ExchangeConnected = $true
                Write-Verbose 'Successfully connected to Exchange Online.'
            } catch {
                $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                        [System.Exception]::new("Exchange Online connection required: $($_.Exception.Message)"),
                        'ExchangeConnectionError',
                        [System.Management.Automation.ErrorCategory]::ConnectionError,
                        $null
                    ))
            }

            # Retrieve mailboxes via Exchange Online (better coverage than Graph users)
            Write-Verbose 'Retrieving mailboxes via Exchange Online...'

            $TargetMailboxes = (Get-EXOMailbox -ResultSize Unlimited -Properties RecipientTypeDetails, PrimarySmtpAddress, DisplayName, UserPrincipalName).Where({
                $_.RecipientTypeDetails -in @('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox')
            })

            if (-not $IncludeSystemAccounts) {
                $TargetMailboxes = $TargetMailboxes.Where({
                    $_.Name -notlike 'HealthMailbox*' -and
                    $_.Name -notlike 'SystemMailbox*' -and
                    $_.Name -notlike 'DiscoverySearchMailbox*' -and
                    $_.Name -notlike 'Migration.*' -and
                    $_.Name -notlike 'FederatedEmail.*'
                })
            }

            # Use parallel processing for calendar permissions
            Write-Verbose "Processing $($TargetMailboxes.Count) mailboxes in parallel for calendar folder permissions..."

            $AllCalendarPermissions = $TargetMailboxes | ForEach-Object -Parallel {
                $UserCalendarPermissions = [System.Collections.Generic.List[PSObject]]::new()
                $UPN = $_.UserPrincipalName

                try {
                    # Discover the actual calendar folder name via folder statistics (locale-independent)
                    $CalendarFolder = Get-EXOMailboxFolderStatistics -Identity $UPN -FolderScope Calendar -ErrorAction Stop |
                        Where-Object { $_.FolderType -eq 'Calendar' } |
                        Select-Object -First 1

                    if (-not $CalendarFolder) { return $UserCalendarPermissions }

                    $FolderName = $CalendarFolder.Name
                    $FolderPath = "$($UPN):\$($FolderName)"
                    $FolderPerms = Get-EXOMailboxFolderPermission -Identity $FolderPath -ErrorAction Stop

                    if ($FolderPerms) {
                        # Filter out Default and Anonymous entries unless explicitly included
                        if (-not $using:IncludeDefaultPermissions) {
                            $FolderPerms = @($FolderPerms.Where({
                                $_.User.DisplayName -notin @('Default', 'Anonymous') -and
                                $_.User.DisplayName -ne $_.FolderName -and
                                $_.AccessRights -ne 'None'
                            }))
                        }

                        foreach ($Perm in $FolderPerms) {
                            $UserCalendarPermissions.Add([PSCustomObject]@{
                                    UserPrincipalName          = $UPN
                                    DisplayName                = $_.DisplayName
                                    FolderName                 = $FolderName
                                    FolderPath                 = $FolderPath
                                    GrantedToUser              = $Perm.User.DisplayName
                                    GrantedToUserPrincipalName = $Perm.User.UserPrincipalName
                                    AccessRights               = $Perm.AccessRights -join ', '
                                    SharingPermissionFlags     = $Perm.SharingPermissionFlags
                                })
                        }
                    }
                } catch {
                    # Skip mailboxes where calendar folder cannot be retrieved
                }

                return $UserCalendarPermissions
            } -ThrottleLimit 20

            # Process the calendar permissions into the standard format
            foreach ($CalPerm in $AllCalendarPermissions) {
                $CalendarPermissions.Add([PSCustomObject]@{
                        Mailbox                = $CalPerm.UserPrincipalName
                        MailboxDisplayName     = $CalPerm.DisplayName
                        CalendarName           = $CalPerm.FolderName
                        FolderPath             = $CalPerm.FolderPath
                        GrantedTo              = $CalPerm.GrantedToUserPrincipalName
                        GrantedToName          = $CalPerm.GrantedToUser
                        AccessRights           = $CalPerm.AccessRights
                        SharingPermissionFlags = $CalPerm.SharingPermissionFlags
                    })
            }

            $Summary = [PSCustomObject]@{
                TotalPermissions         = $CalendarPermissions.Count
                UniqueGrantees           = ($CalendarPermissions | Select-Object -ExpandProperty GrantedTo -Unique).Count
                UsersWithSharedCalendars = ($CalendarPermissions | Select-Object -ExpandProperty Mailbox -Unique).Count
            }

            Write-Information "FINISHED : Calendar permissions analysis - $($Summary.TotalPermissions) permissions found" -InformationAction Continue
            
            [PSCustomObject][Ordered]@{
                Summary             = $Summary
                CalendarPermissions = $CalendarPermissions
            }
        } catch {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.Exception]::new("Get-TntExchangeCalendarPermissionReport failed: $($_.Exception.Message)", $_.Exception),
                'GetTntExchangeCalendarPermissionReportError',
                [System.Management.Automation.ErrorCategory]::OperationStopped,
                $TenantId
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        } finally {
            try {
                # Only disconnect if we established the connection
                Disconnect-TntGraphSession -ConnectionState $ConnectionInfo | Out-Null

                if ($ExchangeConnected) {
                    Disconnect-ExchangeOnline -Confirm:$false -ErrorAction Stop | Out-Null
                }
            } catch {
                Write-Verbose "Could not disconnect from services: $($_.Exception.Message)"
            }
        }
    }
}