Public/Reporting.ps1

<#
.SYNOPSIS
    Get Microsoft 365 audit records using the Graph API.
.DESCRIPTION
    This function queries the Microsoft 365 audit log using a long-running Graph API query.
    It allows filtering by date and operation, and returns the results as custom objects.
.PARAMETER StartDate
    The start date for the audit log search. Defaults to 180 days ago.
.PARAMETER EndDate
    The end date for the audit log search. Defaults to today.
.PARAMETER Operations
    An array of operations to search for. Defaults to 'FileModified' and 'FileUploaded'.
.PARAMETER NoGridView
    If specified, the function will return the results as an array of objects instead of displaying them in a grid view.
.EXAMPLE
    Get-O365MgAuditRecord -Operations "FileAccessed", "FileModified" -StartDate (Get-Date).AddDays(-30)
.EXAMPLE
    Get-O365MgAuditRecord -NoGridView | Export-Csv -Path "C:\temp\AuditRecords.csv" -NoTypeInformation
.NOTES
    You must be connected to the Microsoft Graph with the 'AuditLogsQuery.Read.All' scope before running this function.
    Use Connect-MgGraph -Scopes "AuditLogsQuery.Read.All" to connect.
#>

function Get-O365MgAuditRecord {
    [CmdletBinding()]
    param(
        [datetime]$StartDate = (Get-Date).AddDays(-180),
        [datetime]$EndDate = (Get-Date),
        [string[]]$Operations = @("FileModified", "FileUploaded"),
        [switch]$NoGridView
    )

    Write-Verbose "Starting audit log query."

    $null = Set-MgRequestContext -MaxRetry 10 -RetryDelay 15
    $AuditQueryName = ("Audit Job created at {0}" -f (Get-Date))
    $AuditQueryStart = (Get-Date $StartDate -format s)
    $AuditQueryEnd = (Get-Date $EndDate -format s)

    $AuditQueryParameters = @{
        "displayName"       = $AuditQueryName
        "OperationFilters"  = $Operations
        "filterStartDateTime" = $AuditQueryStart
        "filterEndDateTime"   = $AuditQueryEnd
    }

    # Submit the audit query
    Write-Verbose "Submitting audit query: $AuditQueryName"
    $AuditJob = New-MgBetaSecurityAuditLogQuery -BodyParameter $AuditQueryParameters

    # Check the audit query status every 20 seconds until it completes
    [int]$i = 1
    [int]$SleepSeconds = 20
    $SearchFinished = $false; [int]$SecondsElapsed = 20
    Start-Sleep -Seconds 30
    $AuditQueryStatus = Get-MgBetaSecurityAuditLogQuery -AuditLogQueryId $AuditJob.Id
    while ($SearchFinished -eq $false) {
        $i++
        Write-Verbose ("Waiting for audit search to complete. Check {0} after {1} seconds. Current state {2}" -f $i, $SecondsElapsed, $AuditQueryStatus.status)
        if ($AuditQueryStatus.status -eq 'succeeded') {
            $SearchFinished = $true
        }
        else {
            Start-Sleep -Seconds $SleepSeconds
            $SecondsElapsed = $SecondsElapsed + $SleepSeconds
            $AuditQueryStatus = Get-MgBetaSecurityAuditLogQuery -AuditLogQueryId $AuditJob.Id
        }
    }

    # Fetch the audit records returned by the query
    Write-Verbose "Fetching audit records for query: $AuditQueryName"
    [array]$AuditRecords = Get-MgBetaSecurityAuditLogQueryRecord -AuditLogQueryId $AuditJob.Id -All -PageSize 999

    $Report = [System.Collections.Generic.List[Object]]::new()
    foreach ($Record in $AuditRecords) {
        $ReportLine = [PSCustomObject]@{ 
            Service   = $Record.Service
            Timestamp = $Record.CreatedDateTime
            UPN       = $Record.userPrincipalName
            Operation = $record.operation
        }
        $Report.Add($ReportLine)
    }

    $SortedReport = [array]$Report | Sort-Object { $_.Timestamp -as [datetime] }

    if ($NoGridView) {
        $SortedReport
    }
    else {
        $SortedReport | Out-GridView -Title ("Audit Records fetched by query {0}" -f $AuditQueryName)
    }
}

<#
.SYNOPSIS
    Get Copilot interaction data from a user's mailbox.
.DESCRIPTION
    This function fetches Copilot interaction data from the TeamsMessagesData folder in a user's mailbox.
    It can be used to analyze how a user is interacting with Copilot.
.PARAMETER UserId
    The UPN or Id of the user to search. Defaults to the current user.
.PARAMETER StartDate
    The start date for the search. Defaults to 365 days ago.
.PARAMETER EndDate
    The end date for the search. Defaults to today.
.PARAMETER NoGridView
    If specified, the function will return the results as an array of objects instead of displaying them in a grid view.
.PARAMETER ShowSummary
    If specified, a summary of interactions by Copilot app will be displayed in a grid view.
.EXAMPLE
    Get-O365CopilotInteraction -UserId 'alan.dignard@office365itpros.com'
.EXAMPLE
    Get-O365CopilotInteraction -NoGridView | Export-Csv -Path "C:\temp\CopilotInteractions.csv" -NoTypeInformation
.NOTES
    You must be connected to Exchange Online and the Microsoft Graph with the 'User.Read' and 'Mail.Read' scopes before running this function.
#>

function Get-O365CopilotInteraction {
    [CmdletBinding()]
    param(
        [string]$UserId = (Get-MgContext).Account,
        [datetime]$StartDate = (Get-Date).AddDays(-365),
        [datetime]$EndDate = (Get-Date),
        [switch]$NoGridView,
        [switch]$ShowSummary
    )

    $User = Get-MgUser -UserId $UserId
    Write-Verbose "Fetching Copilot interactions for user: $($User.DisplayName)"

    [array]$Folders = Get-ExoMailboxFolderStatistics -Identity $User.Id -FolderScope NonIPMRoot | Select-Object Name, FolderId
    $TeamsMessagesData = $Folders | Where-Object {$_.Name -eq "TeamsMessagesData"}
    If ($TeamsMessagesData) {
        $FolderId = $TeamsMessagesData.FolderId
    } Else {
        Write-Warning "TeamsMessagesData folder not found for user: $($User.DisplayName)"
        return
    }

    $RestId = Convert-StoreIdToRestId -StoreId $FolderId -UserId $User.Id
    Write-Verbose ("The RestId for the TeamsMessagesData folder is {0}" -f $RestId)

    $CP0 = "Microsoft 365 Chat"
    $CP1 = "Copilot in Word"
    $CP2 = "Copilot in Outlook"
    $CP3 = "Copilot in PowerPoint"
    $CP4 = "Copilot in Excel"
    $CP5 = "Copilot in Teams"
    $CP6 = "Copilot in Stream"
    $CP7 = "Copilot in OneNote"
    $CP8 = "Copilot in Loop"
    $CP9 = "Copilot in SharePoint"
    $CP10 = "Microsoft Copilot"

    [string]$StartDateStr = Get-Date $StartDate -Format "yyyy-MM-ddTHH:mm:ssZ"
    [string]$EndDateStr = Get-Date $EndDate -Format "yyyy-MM-ddTHH:mm:ssZ"

    $null = Set-MgRequestContext -MaxRetry 10 -RetryDelay 15
    Write-Verbose "Fetching messages sent by Copilot from the TeamsMessagesData folder"
    # Find messages sent by Copilot
    [array]$Items = Get-MgUserMailFolderMessage -UserId $User.Id -MailFolderId 'TeamsMessagesData' -All -PageSize 500  `
        -Filter "(ReceivedDateTime ge $StartDateStr and ReceivedDateTime le $EndDateStr) `
        and (sender/emailAddress/name eq '$CP0' or sender/emailAddress/name eq '$CP1' or sender/emailAddress/name eq '$CP2' `
        or sender/emailAddress/name eq '$CP3' or sender/emailAddress/name eq '$CP4' or sender/emailAddress/name eq '$CP5' `
        or sender/emailAddress/name eq '$CP6' or sender/emailAddress/name eq '$CP7' or sender/emailAddress/name eq '$CP8'
        or sender/emailAddress/name eq '$CP9' or sender/emailAddress/name eq '$CP10')"
 -Property Sender, SentDateTime, BodyPreview, ToRecipients

    Write-Verbose "Finding messages received by Copilot..."
    # Now try and find messages received by Copilot
    [array]$ItemsReceived = Get-MgUserMailFolderMessage -UserId $User.Id -MailFolderId 'TeamsMessagesData' `
        -All -PageSize 500 -Property Sender, SentDateTime, BodyPreview, ToRecipients `
        -Filter "(ReceivedDateTime ge $StartDateStr and ReceivedDateTime le $EndDateStr) `
        AND (singleValueExtendedProperties/any(ep:ep/id eq 'String 0x0E04' and contains(ep/value,'Copilot in')))"


    # And because we have some prompts received by "Microsoft 365 Chat", we need to find them too
    [array]$ItemsChat = Get-MgUserMailFolderMessage -UserId $User.Id -MailFolderId 'TeamsMessagesData' `
        -All -PageSize 500 -Property Sender, SentDateTime, BodyPreview, ToRecipients `
        -Filter "(ReceivedDateTime ge $StartDateStr and ReceivedDateTime le $EndDateStr) `
        AND (singleValueExtendedProperties/any(ep:ep/id eq 'String 0x0E04' and ep/value eq 'Microsoft 365 Chat'))"


    if ($ItemsReceived) {
        $Items = $Items + $ItemsReceived
    }
    if ($ItemsChat) {
        $Items = $Items + $ItemsChat
    }

    Write-Verbose ("Found {0} messages sent and received by Copilot in the TeamsMessagesData folder" -f $Items.Count)

    $Report = [System.Collections.Generic.List[Object]]::new()
    ForEach ($Item in $Items) {

        $ReportLine = [PSCustomObject][Ordered]@{ 
            Sender  = $Item.Sender.emailaddress.Name
            To      = $Item.Torecipients.emailaddress.name -join ","
            Sent    = $Item.SentDateTime
            Body    = $Item.BodyPreview
        }
        $Report.Add($ReportLine)
    }

    $SortedReport = [array]$Report | Sort-Object { $_.Sent -as [datetime] }

    if ($NoGridView) {
        $SortedReport
    }
    else {
        $SortedReport | Out-GridView -Title "Copilot Interactions"
    }

    if ($ShowSummary) {
        $ReportCopilot = $SortedReport | Where-Object {$_.Sender -ne $User.displayName}
        $ReportCopilot | Group-Object -Property Sender | Select-Object Name, Count | Sort-Object Count -Descending | Out-GridView -Title "Copilot Interactions by App"
    }
}

<#
.SYNOPSIS
    Fetches service messages from the Microsoft Graph.
.DESCRIPTION
    This function fetches service messages (Message Center posts) from the Microsoft Graph and provides a report and analysis of the data.
.PARAMETER ExportPath
    If specified, the function will export the results to a CSV file at the given path.
.PARAMETER NoGridView
    If specified, the function will not display the results in a grid view.
.PARAMETER ShowAnalysis
    If specified, a detailed analysis of the messages will be displayed.
.EXAMPLE
    Get-O365ServiceMessage -ExportPath "C:\temp\ServiceMessages.csv" -ShowAnalysis
.NOTES
    You must be connected to the Microsoft Graph with the 'ServiceMessage.Read.All' scope before running this function.
#>

function Get-O365ServiceMessage {
    [CmdletBinding()]
    param(
        [string]$ExportPath,
        [switch]$NoGridView,
        [switch]$ShowAnalysis
    )

    Write-Verbose "Fetching Microsoft 365 Message Center Notifications..."
    [array]$MCPosts = Get-MgServiceAnnouncementMessage -Sort 'LastmodifiedDateTime desc' -All

    Write-Verbose "Generating a report..."
    $Report = [System.Collections.Generic.List[Object]]::new()
    ForEach ($M in $MCPosts) {
        [array]$Services = $M.Services
        if ([string]::IsNullOrEmpty($M.ActionRequiredByDateTime)) {
            $ActionRequiredDate = $null
        }
        else {
            $ActionRequiredDate = Get-Date($M.ActionRequiredByDateTime) -format "dd-MMM-yyyy"
        }
        $Age = New-TimeSpan($M.LastModifiedDateTime)
        $AgeSinceStart = New-TimeSpan($M.StartDateTime)
        $Body = $M | Select-Object -ExpandProperty Body
        $HTML = New-Object -Com "HTMLFile"
        $HTML.write([ref]$body.content)
        $MCPostText = $HTML.body.innerText

        $ReportLine  = [PSCustomObject] @{ 
            MessageId            = $M.Id
            Title                = $M.Title
            Workloads            = ($Services -join ",")
            Category             = $M.category
            'Start Time'         = Get-Date($M.StartDateTime) -format "dd-MMM-yyyy HH:mm"
            'End Time'           = Get-Date($M.EndDateTime) -format "dd-MMM-yyyy HH:mm"
            'Last Update'        = Get-Date($M.LastModifiedDateTime) -format "dd-MMM-yyyy HH:mm"
            'Action Required by' = $ActionRequiredDate
            MessageText          = $MCPostText
            Age                  = ("{0} days {1} hours" -f $Age.Days.ToString(), $Age.Hours.ToString())
            IsRead               = $M.ViewPoint.IsRead
            IsDismissed          = $M.ViewPoint.IsDismissed
            MinutesSinceUpdate   = $Age.TotalMinutes
            MinutesSinceStart    = $AgeSinceStart.TotalMinutes
        }
        $Report.Add($ReportLine)
    }

    $SortedReport = $Report | Sort-Object {$_.'Last Update' -as [DateTime]} -Descending

    if (-not $NoGridView) {
        $SortedReport | Select-Object MessageId, Title, Category, 'Last Update', 'Action Required By', Age | Out-GridView
    }

    if ($ExportPath) {
        try {
            $SortedReport | Export-CSV -NoTypeInformation $ExportPath
            Write-Verbose "Report file saved to $ExportPath"
        }
        catch {
            Write-Warning "Failed to save report file to $ExportPath. Error: $_"
        }
    }

    if ($ShowAnalysis) {
        # Figure out how many MC posts are for each workload
        $WorkloadCounts = $SortedReport | Group-Object -Property Workloads | Select-Object @{Name="Workload"; Expression={$_.Name}}, Count
        
        $DelayedPosts = $SortedReport | Where-Object {$_.Title -like "*(Updated)*"}
        $DelayedWorkloadCounts = $DelayedPosts | Group-Object -Property Workloads | Select-Object @{Name="Workload"; Expression={$_.Name}}, Count

        $Analysis = foreach($Workload in $WorkloadCounts) {
            $DelayedCount = ($DelayedWorkloadCounts | Where-Object {$_.Workload -eq $Workload.Workload}).Count
            $PercentDelayed = if($Workload.Count -gt 0) {($DelayedCount / $Workload.Count).ToString('P')} else {'N/A'}
            [PSCustomObject]@{ 
                Workload = $Workload.Workload
                TotalPosts = $Workload.Count
                DelayedPosts = $DelayedCount
                PercentDelayed = $PercentDelayed
            }
        }
        
        Write-Output $Analysis
    }

    return $SortedReport
}

<#
.SYNOPSIS
    Searches the admin audit log.
.DESCRIPTION
    This function searches the admin audit log for specific operations.
.PARAMETER Operations
    An array of operations to search for.
.PARAMETER StartDate
    The start date for the audit log search.
.PARAMETER EndDate
    The end date for the audit log search.
.PARAMETER UserIds
    An array of user IDs to filter the search by.
.EXAMPLE
    Get-O365AdminAuditLog -Operations 'New-Mailbox' -StartDate (Get-Date).AddDays(-7)
.NOTES
    You must be connected to Exchange Online before running this function.
#>

function Get-O365AdminAuditLog {
    [CmdletBinding()]
    param(
        [string[]]$Operations,
        [datetime]$StartDate = (Get-Date).AddDays(-90),
        [datetime]$EndDate = (Get-Date).AddDays(1),
        [string[]]$UserIds
    )

    Write-Verbose "Searching the admin audit log..."
    $AuditLog = Search-UnifiedAuditLog -StartDate $StartDate -EndDate $EndDate -Operations $Operations -UserIds $UserIds -ResultSize 5000
    
    $Report = [System.Collections.Generic.List[Object]]::new()
    foreach ($Record in $AuditLog) {
        $AuditData = ConvertFrom-Json $Record.AuditData
        $ReportLine = [PSCustomObject]@{ 
            TimeStamp = $Record.CreationDate
            User = $Record.UserIds
            Operation = $Record.Operations
            Result = $AuditData.ResultStatus
            Object = $AuditData.ObjectId
            Details = $AuditData
        }
        $Report.Add($ReportLine)
    }

    return $Report
}
<#
.SYNOPSIS
    Generates a report of mailbox permissions.
.DESCRIPTION
    This function generates a detailed report of all mailbox permissions (FullAccess, SendAs, SendOnBehalf) for all mailboxes in the tenant.
.EXAMPLE
    Get-O365MailboxPermissionsReport
.NOTES
    You must be connected to Exchange Online before running this function.
#>

function Get-O365MailboxPermissionsReport {
    [CmdletBinding()]
    param()

    Write-Verbose "Generating mailbox permissions report..."

    $Mailboxes = Get-Mailbox -ResultSize Unlimited
    $Report = [System.Collections.Generic.List[Object]]::new()

    foreach ($Mailbox in $Mailboxes) {
        Write-Verbose "Checking mailbox: $($Mailbox.DisplayName)"

        # Full Access
        $FullAccess = Get-MailboxPermission -Identity $Mailbox.Identity | Where-Object { $_.AccessRights -eq 'FullAccess' -and -not $_.IsInherited }
        foreach ($Permission in $FullAccess) {
            $ReportLine = [PSCustomObject]@{ 
                Mailbox = $Mailbox.DisplayName
                User = $Permission.User
                Permission = 'FullAccess'
                IsExternal = $Permission.User -like '*#EXT#*'
            }
            $Report.Add($ReportLine)
        }

        # Send As
        $SendAs = Get-RecipientPermission -Identity $Mailbox.Identity | Where-Object { $_.Trustee -ne $Mailbox.DisplayName }
        foreach ($Permission in $SendAs) {
            $ReportLine = [PSCustomObject]@{ 
                Mailbox = $Mailbox.DisplayName
                User = $Permission.Trustee
                Permission = 'SendAs'
                IsExternal = $Permission.Trustee -like '*#EXT#*'
            }
            $Report.Add($ReportLine)
        }

        # Send on Behalf
        if ($Mailbox.GrantSendOnBehalfTo) {
            foreach ($Trustee in $Mailbox.GrantSendOnBehalfTo) {
                $ReportLine = [PSCustomObject]@{ 
                    Mailbox = $Mailbox.DisplayName
                    User = $Trustee.Name
                    Permission = 'SendOnBehalf'
                    IsExternal = $false # Cannot be external
                }
                $Report.Add($ReportLine)
            }
        }
    }

    return $Report
}