Private/Resolve-SPMEntraGroup.ps1

function Resolve-SPMEntraGroup {
    <#
    .SYNOPSIS
        Resolves the members of an Entra ID group transitively via Microsoft Graph.
    .DESCRIPTION
        Uses Invoke-PnPGraphMethod (same token as the PnP connection, no extra Graph module).
        Nested groups are resolved recursively while keeping the nesting visible in the
        resulting tree. Every group is resolved only once per scan ($GroupCache), cycles
        are detected via $Visited.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$GroupId,

        [Parameter(Mandatory)]
        [hashtable]$GroupCache,

        [ValidateSet('members', 'owners')]
        [string]$Relation = 'members',

        [System.Collections.Generic.HashSet[string]]$Visited
    )

    $cacheKey = "$GroupId|$Relation"
    if ($GroupCache.ContainsKey($cacheKey)) {
        return $GroupCache[$cacheKey]
    }
    if (-not $Visited) {
        $Visited = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    }
    if (-not $Visited.Add($GroupId)) {
        return , @([pscustomobject]@{ name = '(cycle - group already resolved further up)'; type = 'Note' })
    }

    $members = [System.Collections.Generic.List[object]]::new()
    try {
        $url = "v1.0/groups/$GroupId/$Relation`?`$select=id,displayName,userPrincipalName&`$top=999"
        while ($url) {
            $response = Invoke-SPMWithRetry { Invoke-PnPGraphMethod -Url $url }
            foreach ($m in @($response.value)) {
                switch ($m.'@odata.type') {
                    '#microsoft.graph.group' {
                        $members.Add([pscustomobject]@{
                                name    = $m.displayName
                                type    = 'EntraGroup'
                                members = @(Resolve-SPMEntraGroup -GroupId $m.id -GroupCache $GroupCache -Visited $Visited)
                            })
                    }
                    '#microsoft.graph.user' {
                        $members.Add([pscustomobject]@{
                                name = $m.displayName
                                type = 'User'
                                upn  = $m.userPrincipalName
                            })
                    }
                    default {
                        $label = if ($m.displayName) { $m.displayName } else { [string]$m.id }
                        $members.Add([pscustomobject]@{ name = $label; type = 'Other' })
                    }
                }
            }
            $url = $response.'@odata.nextLink'
        }
    }
    catch {
        Write-Warning "Could not resolve $Relation of Entra group ${GroupId}: $($_.Exception.Message)"
        $members.Add([pscustomobject]@{ name = '(resolution failed - is the Graph permission Directory.Read.All granted?)'; type = 'Note' })
    }

    $result = $members.ToArray()
    $GroupCache[$cacheKey] = $result
    [void]$Visited.Remove($GroupId)
    return $result
}