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
        with support for multi-language calendar folder names. It identifies who has access to each user's calendar
        and what level of permissions they have been granted.

        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.

        MULTI-LANGUAGE SUPPORT:
        Automatically detects calendar folders in multiple languages including:
        - English (Calendar)
        - Dutch (Agenda)
        - French (Calendrier)
        - German (Kalender)
        - Spanish/Italian (Calendario)
        - Portuguese (Calendario)

    .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.

    .OUTPUTS
        ITC.Reports.CalendarPermissions
        Returns comprehensive calendar permissions analysis.

    .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(
        # Tenant ID of the Microsoft 365 tenant.
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        # Application (client) ID of the registered app.
        [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,

        # Client secret credential when using secret-based authentication.
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Alias('ApplicationSecret')]
        [ValidateNotNullOrEmpty()]
        [SecureString]$ClientSecret,

        # Certificate thumbprint for certificate-based authentication.
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [ValidateNotNullOrEmpty()]
        [string]$CertificateThumbprint,

        # Use interactive authentication (no app registration required).
        [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')]
        [switch]$Interactive,

        # Switch to include system and service accounts in the results.
        [Parameter()]
        [switch]$IncludeSystemAccounts,

        # Switch to include Default and Anonymous permissions in the results.
        [Parameter()]
        [switch]$IncludeDefaultPermissions
    )

    begin {
        # Multi-language calendar folder names
        $CalendarFolderNames = @(
            'Calendar',      # English
            'Agenda',        # Dutch
            'Calendrier',    # French
            'Kalender',      # German
            'Calendario',    # Spanish/Italian
            [string]::Concat('Calend', [char]0x00E1, 'rio')     # Portuguese
        )

        Write-Information 'Starting calendar permissions analysis... (This may take several minutes for large tenants)' -InformationAction Continue
    }

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

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

            # Resolve tenant domain for Exchange connection
            try {
                $org = Get-MgOrganization -Property VerifiedDomains | Select-Object -First 1
                if ($org.VerifiedDomains) {
                    $TenantDomain = ($org.VerifiedDomains | Where-Object { $_.IsInitial }) | Select-Object -First 1 -ExpandProperty Name
                    if (-not $TenantDomain) {
                        $TenantDomain = ($org.VerifiedDomains | Where-Object { $_.IsDefault }) | Select-Object -First 1 -ExpandProperty Name
                    }
                }

                if ($TenantDomain) {
                    Write-Verbose "Resolved tenant domain for Exchange Online: $TenantDomain"
                }
            } catch {
                Write-Verbose "Could not resolve tenant domain, will use TenantId: $($_.Exception.Message)"
            }

            # Connect to Exchange Online (required - throw on failure)
            try {
                $ExchangeOrgTarget = if ($TenantDomain) { $TenantDomain } else { $TenantId }

                if ($PSCmdlet.ParameterSetName -eq 'ClientSecret') {
                    $TokenParams = @{
                        TenantId     = $TenantId
                        ClientId     = $ClientId
                        ClientSecret = $ClientSecret
                        Scope        = 'Exchange'
                    }
                    $ExchangeToken = Get-GraphToken @TokenParams

                    Connect-ExchangeOnline -Organization $ExchangeOrgTarget -AccessToken $ExchangeToken.AccessToken -ShowBanner:$false -ErrorAction Stop
                } else {
                    Connect-ExchangeOnline -AppId $ClientId -CertificateThumbprint $CertificateThumbprint -Organization $ExchangeOrgTarget -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-Object {
                $_.RecipientTypeDetails -in @('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox')
            }

            if (-not $IncludeSystemAccounts) {
                $TargetMailboxes = $TargetMailboxes | Where-Object {
                    $_.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()

                foreach ($FolderName in $using:CalendarFolderNames) {
                    try {
                        $FolderPath = "$($_.UserPrincipalName):\$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-Object {
                                    $_.User.DisplayName -notin @('Default', 'Anonymous') -and
                                    $_.User.DisplayName -ne $_.FolderName -and
                                    $_.AccessRights -ne 'None'
                                }
                            }

                            foreach ($Perm in $FolderPerms) {
                                $UserCalendarPermissions.Add([PSCustomObject]@{
                                        UserPrincipalName          = $_.UserPrincipalName
                                        DisplayName                = $_.DisplayName
                                        FolderName                 = $FolderName
                                        FolderPath                 = $FolderPath
                                        GrantedToUser              = $Perm.User.DisplayName
                                        GrantedToUserPrincipalName = $Perm.User.UserPrincipalName
                                        AccessRights               = $Perm.AccessRights -join ', '
                                        SharingPermissionFlags     = $Perm.SharingPermissionFlags
                                    })
                            }

                            # Break after finding the first valid calendar folder
                            if ($FolderPerms.Count -gt 0) {
                                break
                            }
                        }
                    } catch {
                        # Silent handling for each folder attempt
                        continue
                    }
                }

                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
                    })
            }

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

            # Create comprehensive report object
            $Report = [PSCustomObject][Ordered]@{
                Summary             = $Summary
                CalendarPermissions = $CalendarPermissions
            }

            Write-Information "Calendar permissions analysis completed - $($Summary.TotalPermissions) permissions found" -InformationAction Continue

            $Report
        } 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 {
            # Cleanup connections
            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)"
            }
        }
    }
}