Private/Read-AACEntraGroup.ps1

function Read-AACEntraGroup {
    <#
    .SYNOPSIS
        Reads Entra ID groups and their members from Microsoft Graph - the
        groups asked for, then their members and the members of every
        nested group, a level at a time, several requests at once.
    .DESCRIPTION
        Groups (GET /v1.0/groups, following @odata.nextLink):
          -GroupName one exact displayName filter per name
          -GroupNameStartsWith startswith(displayName, ...)
          neither every group
        Members (GET /v1.0/groups/{id}/members, $top=999): the groups found,
        then the groups nested in them, and so on - each group read once,
        however many groups it is nested in - up to 20 levels.
 
        Up to 8 requests run at once (Invoke-AACHttpBatch); throttling (429)
        waits as long as Graph's Retry-After asks. The token comes from the
        Connect-AAC sign-in (Get-AACAccessToken -Resource
        https://graph.microsoft.com) - no second sign-in.
 
        Returns @{ Groups; Members (group ID -> members); MemberErrors
        (group ID -> why they couldn't be read); Missing (names with no
        group) }. Progress goes to the Invoke-AACProgress display.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [string[]] $GroupName = @(),

        [string] $GroupNameStartsWith
    )

    $graph = 'https://graph.microsoft.com/v1.0'
    $groupSelect = 'id,displayName,description,groupTypes,securityEnabled,mailEnabled,isAssignableToRole,onPremisesSyncEnabled,membershipRule,mail'
    $memberSelect = 'id,displayName,userPrincipalName,mail,userType,accountEnabled,jobTitle,department'
    # An OData string literal: single quotes doubled, then URL-escaped.
    $literal = { param([string] $Text) [System.Uri]::EscapeDataString("'" + ($Text -replace "'", "''") + "'") }

    # --- The groups ---------------------------------------------------------------------------------
    $filters = [ordered]@{}
    foreach ($name in @($GroupName | Where-Object { $_ } | Select-Object -Unique)) { $filters["name:$name"] = "displayName eq $(& $literal $name)" }
    if ($GroupNameStartsWith) { $filters['prefix'] = "startswith(displayName,$(& $literal $GroupNameStartsWith))" }
    if (-not $filters.Count) { $filters['all'] = '' }
    $found = @{}
    foreach ($key in $filters.Keys) { $found[$key] = [System.Collections.Generic.List[object]]::new() }
    $requests = @(foreach ($key in $filters.Keys) {
            @{ Key = $key; Body = $null; Uri = "$graph/groups?`$select=$groupSelect&`$top=999$(if ($filters[$key]) { "&`$filter=$($filters[$key])" })" }
        })
    $pages = {
        param($Key, [string] $Content)
        $page = ConvertFrom-Json -InputObject $Content -AsHashtable -Depth 20
        foreach ($item in @($page['value'])) { if ($null -ne $item) { $found[$Key].Add($item) } }
        if ($page['@odata.nextLink']) { @{ Uri = [string]$page['@odata.nextLink'] } }
    }
    Update-AACProgress -Id 'groups' -Indeterminate -Description $(if ($filters.Contains('all')) { 'Finding every group in Microsoft Graph' } else { 'Finding the groups in Microsoft Graph' })
    $failures = Invoke-AACHttpBatch -Request $requests -OnResponse $pages -Resource 'https://graph.microsoft.com' -ThrottleLimit 8
    foreach ($key in $failures.Keys) {
        if ($failures[$key]) {
            throw "Microsoft Graph refused to list the groups: $($failures[$key].TrimEnd('.')). Your account needs to be allowed to read groups in Entra ID (members can by default; guests, or tenants that restrict it, can't). With an App Registration of your own in Connect-AAC -ClientId, give it Microsoft Graph's delegated Group.Read.All and User.Read.All permissions."
        }
    }
    $groups = @{}
    foreach ($key in $filters.Keys) { foreach ($g in $found[$key]) { $groups[[string]$g['id']] = $g } }
    $missing = @(foreach ($name in @($GroupName | Where-Object { $_ } | Select-Object -Unique)) { if (-not $found["name:$name"].Count) { $name } })
    Update-AACProgress -Id 'groups' -Complete -Description ('Found {0:N0} group(s){1}' -f $groups.Count, $(if ($missing.Count) { "; $($missing.Count) name(s) not found" }))

    # --- Their members, a level of nesting at a time -------------------------------------------------
    $members = @{}
    $memberErrors = @{}
    $read = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $next = @($groups.Keys)
    $level = 0
    $calls = 0
    Update-AACProgress -Id 'members' -Total ([Math]::Max(1, $next.Count)) -Description ('Reading the members of {0:N0} group(s)' -f $next.Count)
    while ($next.Count -and $level -lt 20) {
        $level++
        $batch = @($next | Where-Object { $read.Add($_) })
        if (-not $batch.Count) { break }
        foreach ($id in $batch) { $members[$id] = [System.Collections.Generic.List[object]]::new() }
        $memberPages = {
            param($Key, [string] $Content)
            $page = ConvertFrom-Json -InputObject $Content -AsHashtable -Depth 20
            foreach ($item in @($page['value'])) { if ($null -ne $item) { $members[$Key].Add($item) } }
            if ($page['@odata.nextLink']) { @{ Uri = [string]$page['@odata.nextLink'] } }
        }
        $before = $calls
        $failures = Invoke-AACHttpBatch -Request @($batch | ForEach-Object { @{ Key = $_; Body = $null; Uri = "$graph/groups/$_/members?`$select=$memberSelect&`$top=999" } }) -OnResponse $memberPages -Resource 'https://graph.microsoft.com' -ThrottleLimit 8 -OnDone {
            param($Key, $Failure, $Done, $Total)
            Update-AACProgress -Id 'members' -Total ($before + $Total) -Increment 1 -Description ('Reading the members of {0:N0} group(s){1}' -f ($before + $Done), $(if ($level -gt 1) { " (nesting level $level)" }))
        }
        $calls += $batch.Count
        foreach ($key in $failures.Keys) { if ($failures[$key]) { $memberErrors[$key] = $failures[$key] } }
        # The groups nested in these, not read yet.
        $next = @($batch | ForEach-Object { $members[$_] } | Where-Object { [string]$_['@odata.type'] -eq '#microsoft.graph.group' } | ForEach-Object { [string]$_['id'] } | Where-Object { -not $read.Contains($_) } | Select-Object -Unique)
    }
    $memberCount = 0
    foreach ($list in $members.Values) { $memberCount += $list.Count }
    Update-AACProgress -Id 'members' -Complete -Description ('Read {0:N0} membership(s) of {1:N0} group(s), {2:N0} of them nested{3}' -f $memberCount, $read.Count, ($read.Count - $groups.Count), $(if ($memberErrors.Count) { "; $($memberErrors.Count) could not be read" }))

    $plain = @{}
    foreach ($key in $members.Keys) { $plain[$key] = $members[$key].ToArray() }
    @{
        Groups       = @($groups.Values)
        Members      = $plain
        MemberErrors = $memberErrors
        Missing      = $missing
    }
}