Do you need to increase the speed of your scripts that reads the members of many groups? Look no further, this is how to do it properly with the $batch endpoint.
The following cmdlet, Get-GroupMembersMapUsingBatch, takes as pipeline input the objectid of a set of groups and returns a dictionary with the group objectid as key and the member as values.
<#
.DESCRIPTION
This function is used to get all members or owners of a group, using batch requests to the Microsoft Graph API for high performance.
.SYNOPSIS
Gets all members or owners of a group, using batch requests to the Microsoft Graph API for high performance.
.PARAMETER Id
The ID of the group to get members or owners for.
.PARAMETER PageSize
The page size to use for each request. Default is 999.
.PARAMETER BatchSize
The batch size to use for each request. Default is 20.
.PARAMETER Select
The select statement to use for the request. Default is "id".
.PARAMETER Type
Defines whether to get the members, owners or transitiveMembers. Default is "members".
.PARAMETER ReturnFullUserObjects
If this switch is present, the full user objects will be returned instead of just the IDs.
.EXAMPLE
"f30059f8-f973-428f-b1d6-4fdd20ae5f81",
"12345678-1234-1234-1234-123456789012" | Get-EntraIDGroupSyncMembersMapUsingBatch
.EXAMPLE
"f30059f8-f973-428f-b1d6-4fdd20ae5f81",
"12345678-1234-1234-1234-123456789012" | Get-EntraIDGroupSyncMembersMapUsingBatch -Type "owners" -ReturnFullUserObjects
#>
function New-EntraIDGroupSyncMembersMapUsingBatch {
[CmdletBinding()]
Param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[String] $Id,
[Parameter(Mandatory = $false)]
[ValidateRange(10, 999)]
[Int] $PageSize = 999,
[Parameter(Mandatory = $false)]
[ValidateRange(1, 20)]
[Int] $BatchSize = 20,
[Parameter(Mandatory = $false)]
[String] $Select = "id",
[Parameter(Mandatory = $false)]
[ValidateSet("members", "owners", "transitiveMembers")]
[String] $Type = "members",
[Parameter(Mandatory = $false)]
[Switch] $ReturnFullUserObjects
)
Begin {
$groupMembers = @{}
$batch = @{Requests = New-Object System.Collections.ArrayList }
}
Process {
# Add the group to the map as a list (which will be populated with members)
if (!$groupMembers.ContainsKey($Id)) { $groupMembers[$Id] = New-object System.Collections.ArrayList }
# Add the get members request to the batch
$batch.Requests.Add(@{
method = "GET"
url = ("groups/{0}/{3}?`$top={2}&`$select={1}" -f $Id, $Select, $PageSize, $Type)
id = "{0}" -f $Id
}) | Out-Null
# If the batch is full (of $BatchSize requests), Go into a loop in order to potentially run multiple batches (needed only when we have all 20 requests in a batch all return nextlink values)
while ($batch.Requests.Count -eq $BatchSize) {
# Send the batch to the graph API
$batchresponse = Invoke-MgGraphRequest -Method Post -Uri "https://graph.microsoft.com/v1.0/`$batch" -Body (ConvertTo-Json -InputObject $batch -Depth 10) -ContentType "application/json"
# Start working on a new batch
$batch = @{Requests = New-Object System.Collections.ArrayList }
# Process the responses from the batch
$batchresponse.responses | ForEach-Object {
$response = $_ # Useful for debugging: $response = $batchresponse.responses | get-random -count 1
# If the response status is not 200, throw an error, as we do not really have a reliable way to continue
if ($response.status -ne 200) {
throw "Critical error - A request failed with status $($response.status)"
}
# Process the returned members
if ($response.body.value) {
$response.body.value |
ForEach-Object {
Write-Debug "Found $($Type -replace "s$") $($_.id) in group $($response.id)"
$groupMembers[$response.id].Add(($ReturnFullUserObjects.IsPresent ? $_ : $_.id)) | Out-Null
}
}
# If there are another page, add it to the batch
if ($response.body.'@odata.nextlink') {
Write-Verbose "Adding nextlink for $($response.id)"
$batch.Requests.Add(@{
method = "GET"
url = ($response.body.'@odata.nextlink' -replace "https://graph.microsoft.com/v1.0/" -replace "https://graph.microsoft.com/beta/")
id = "{0}" -f $response.id
}) | Out-Null
}
}
}
}
End {
# Now we need to process the potentially last batch
# Go into a loop in order to potentially run multiple batches (needed only when we have all 20 requests in a batch all return nextlink values)
while ($batch.Requests.Count -gt 0) {
# Send the batch to the graph API
$batchresponse = Invoke-MgGraphRequest -Method Post -Uri "https://graph.microsoft.com/v1.0/`$batch" -Body (ConvertTo-Json -InputObject $batch -Depth 10) -ContentType "application/json"
# Start working on a new batch
$batch = @{Requests = New-Object System.Collections.ArrayList }
# Process the responses from the batch
$batchresponse.responses | ForEach-Object {
$response = $_ # Useful for debugging: $response = $batchresponse.responses | get-random -count 1
# If the response status is not 200, throw an error, as we do not really have a reliable way to continue
if ($response.status -ne 200) {
throw "Critical error - A request failed with status $($response.status)"
}
# Process the returned members
if ($response.body.value) {
$response.body.value |
ForEach-Object {
Write-Debug "Found $($Type -replace "s$") $($_.id) in group $($response.id)"
$groupMembers[$response.id].Add(($ReturnFullUserObjects.IsPresent ? $_ : $_.id)) | Out-Null
}
}
# If there are another page, add it to the batch
if ($response.body.'@odata.nextlink') {
$batch.Requests.Add(@{
method = "GET"
url = ($response.body.'@odata.nextlink' -replace "https://graph.microsoft.com/v1.0/" -replace "https://graph.microsoft.com/beta/")
id = "{0}" -f $response.id
}) | Out-Null
}
}
}
$groupMembers
}
}
Usage:
$groups = Get-MgGroup -All
$members = $groups.id | New-EntraIDGroupSyncMembersMapUsingBatch -Verbose
$groups | ForEach-Object {
Write-Host "==== $($_.DisplayName) ===="
$members[$_.id] | ForEach-Object {
Write-Host " - $($_)"
}
}