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 = '(Zyklus - Gruppe bereits weiter oben aufgeloest)'; 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 "Mitglieder der Entra-Gruppe $GroupId konnten nicht aufgeloest werden: $($_.Exception.Message)"
        $members.Add([pscustomobject]@{ name = '(Aufloesung fehlgeschlagen - Graph-Berechtigung Directory.Read.All vorhanden?)'; type = 'Note' })
    }

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