Integrations

Purview eDiscovery Reporting

Pulling a Complete List of Held Mailboxes from Purview

2025-09-10
Merge Reports

It's Wednesday, which means Microsoft is making another change to the Purview portal for eDiscovery. To their credit, they provided a means of creating classic cases in the new portal for quite a while, but no more. All new cases are created in the new Premium experience. There are some positives here; specifically, I like that when a data source is selected, it automatically identifies the corresponding OneDrive library. In the classic experience, the OneDrive URL had to be entered manually, and as I recall, there wasn't a way to validate the URL before placing it on hold. But the purpose of this post isn't about the UI, it's about API access to the data.

We've been successfully generating reports with the Security and Compliance PowerShell module throughout several iterations of the eDiscovery front-end. However, we noticed that recently created cases were missing from our reports. Fair enough, we'd had a few good years with minimal maintenance, so it was time to update our code.

As with most resources in the M365/Azure world, the latest eDiscovery data is exposed via the Graph API. Looking through the docs, it appeared that the route I needed to query was eDiscoveryCases in the security module. In the permissions list, I noted that Application permissions are supported. We need this because these scripts run unattended.

Required Permissions I created an app registration in Azure, granted it eDsicovery.Read.All permissions, and generated a client secret to start testing. The first thing I needed was to obtain a token:

$tenantId = "<tenant_id>"
$appId = "<app_id>"
$clientSecret = "<client_secret">

function Get-GraphToken(){
    $tokenEndpoint = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token"
    $body = @{
        client_id     = $appId
        scope         = "https://graph.microsoft.com/.default"
        client_secret = $clientSecret
        grant_type    = "client_credentials"
    }
    $response = Invoke-RestMethod -Method Post `
                                  -Uri $tokenEndpoint `
                                  -Body $body `
                                  -ContentType "application/x-www-form-urlencoded"
    if($response.access_token){
        return $response.access_token
    }
    else{
        return $false
    }
}

$token = Get-GraphToken

With a token in hand, I could test out a query against the ediscoveryCases endpoint:

    $uri = "https://graph.microsoft.com/v1.0/security/cases/ediscoveryCases"
    $headers = @{
        Authorization = "Bearer $token"
        "Content-Type" = "application/json"
    }
    $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
    $response.value

The request was successful, but I got no results despite there being plenty of cases in Purview. This was almost worse than an error. At least an error would inform me of where to go next.

API Response

I hopped over to the Graph Explorer to confirm I was querying the correct route. The count was lower than expected, but at least it was returning data.

Graph Explorer

Since it worked under my account in the Graph explorer, this pointed to a permissions issue. I didn't find much help searching the web, and most of ChatGPT's suggestions were based on legacy access. At least one post indicated that in order for anyone to access the new premium cases, they would need to be explicitly added to the case. Unfortunately, you can't add App service principals to eDiscovery cases. I did add the principal to the eDiscovery Manager role, but it still returned an empty response.

I opened a ticket with Microsoft back in July. The technician repeatedly postponed meetings, then indicated they needed more time to research the issue, and eventually left the company. It's still unresolved.

In the meantime, I still needed a way to pull these reports manually. Since it worked under my account context, that just means I need to be the one to authenticate. I noted earlier that the counts were low in the Graph explorer. This is because the Graph routes only query the new premium experience cases. To get all the data, we'd need to query both the updated Graph routes and the legacy security and compliance PowerShell cmdlets. Note that the script below connects to both after specifying the org and tenant IDs.

$orgId = "<orgname>.onmicrosoft.com"
$tenantId = "<tenant>"

Connect-IPPSSession
Connect-MgGraph -scopes "User.Read.All", "ediscovery.Read.All" 

$pex_cases = Get-MgSecurityCaseEdiscoveryCase -All | Where-Object { $_.status -eq "Active"}
$legacy_cases = Get-ComplianceCase | Where-Object { $_.status -eq "Active"}

$objArr = @()

# Process legacy cases
foreach($case in $legacy_cases){
    $scope = @()
    if($policy = Get-CaseHoldPolicy -Case $case.Identity -DistributionDetail -IncludeBindings){
        foreach($mailbox in $policy.ExchangeLocation){
            $scope += @{
                "displayName" = $mailbox.DisplayName
                "email" = $mailbox.Name
            }
        }

        $rule = Get-CaseHoldRule -Policy $policy.Id
        if($rule.Disabled -eq $false){
            $hold_enabled = $true
        }
        else{
            $hold_enabled = $false
        } 

        $objArr += [pscustomobject][ordered]@{
            "id" = $case.name;
            "Case Name"=$case.name;
            "Description"=$case.Description;
            "Case Scope"=$scope;
            "Owner"=$case.LastModifiedBy;
            "Date Modified"=$case.LastModifiedDateTime;
            "Date Created" = $case.CreatedDateTime
            "Enabled"=$hold_enabled
        }    
    }
    else{
        Write-Host "No policy found for $($case.Name)"
    }
}

# Process the premium experience cases
foreach($case in $pex_cases){
    $scope = @()
    if($policy = Get-CaseHoldPolicy -Case $case.Id -DistributionDetail -IncludeBindings){
        foreach($mailbox in $policy.ExchangeLocation){
            $scope += @{
                "displayName" = $mailbox.DisplayName
                "email" = $mailbox.Name
            }
        }

        $rule = Get-CaseHoldRule -Policy $policy.Id
        if($rule.Disabled -eq $false){
            $hold_enabled = $true
        }
        else{
            $hold_enabled = $false
        } 

        $objArr += [pscustomobject][ordered]@{
            "id" = $case.DisplayName;
            "Case Name"=$case.DisplayName;
            "Description"=$case.Description;
            "Case Scope"=$scope;
            "Owner"=$case.LastModifiedBy.user.DisplayName;
            "Date Modified"=$case.LastModifiedDateTime;
            "Date Created" = $case.CreatedDateTime
            "Enabled"=$hold_enabled
        }    
    }
    else{
        Write-Host "No policy found for $($case.DisplayName)"
    }
}

foreach($obj in $objArr){
    $in_scope_dn = ($obj.'case scope' | sort-object displayname).displayname -join "; " 
    $in_scope_mail  = ($obj.'case scope' | sort-object email).email -join "; " 
    $obj | Add-Member -NotePropertyName "Hold Members" -NotePropertyValue $in_scope_dn
    $obj | Add-Member -NotePropertyName "Hold Members Mail" -NotePropertyValue $in_scope_mail
}
$objArr | Select-Object "Case Name","Hold Members", "Hold Members Mail", "Date Created","Description","Enabled","Owner" | Sort-Object "Date Created" -Descending | Export-CSV "Preservation_Hold_Report.csv" -NoTypeInformation

This produces a report with a single line item for each active case (legacy and premium) that includes lists of user display names and email addresses that are currently on hold.