Private/Expand-ResolvedEntraObject.ps1

function Expand-ResolvedEntraObject {
    <#
    .SYNOPSIS
        Recursively walks an already-resolved Entra object down to its leaf principals.
 
    .DESCRIPTION
        Internal recursive core for Expand-EntraPrincipalChain - kept as its own function (one
        function per file, matching this module's convention) rather than nested inline, since it
        recurses on the already-resolved object (looked up once via Resolve-EntraObject and cached
        by ObjectId) rather than re-resolving each nested member by name.
 
        Every leaf carries a MembershipState: 'Active' if every hop from the top-level login down
        to that leaf is a real, currently-active membership, or 'Eligible' if any hop along the way
        is only a PIM-for-Groups eligible assignment nobody has activated - meaning the leaf doesn't
        actually have this access right now, but could get it on demand. IsEligible tracks whether
        the current path has already crossed such a hop; once true, it stays true for every
        descendant, since access below an unactivated gate isn't real either.
    #>

    param(
        [Parameter(Mandatory)] [PSCustomObject]$Obj,
        [Parameter(Mandatory)] [hashtable]$Cache,
        [string[]]$ChainSoFar = @(),
        [string[]]$VisitedIds = @(),
        [bool]$IsEligible = $false
    )

    $membershipState = if ($IsEligible) { 'Eligible' } else { 'Active' }

    if ($Obj.Type -ne 'Group') {
        return [PSCustomObject]@{
            GroupChain            = ($ChainSoFar -join ' > ')
            ResolvedPrincipal     = $Obj.DisplayName
            ResolvedPrincipalType = $Obj.Type
            MembershipState       = $membershipState
        }
    }

    if ($Obj.Id -and ($Obj.Id -in $VisitedIds)) {
        Write-Warning "Circular group membership detected at '$($Obj.DisplayName)' (chain: $(($ChainSoFar + $Obj.DisplayName) -join ' > ')). Stopping expansion."
        return [PSCustomObject]@{
            GroupChain            = ($ChainSoFar -join ' > ')
            ResolvedPrincipal     = $Obj.DisplayName
            ResolvedPrincipalType = 'Group (circular - not expanded)'
            MembershipState       = $membershipState
        }
    }

    $newChain   = $ChainSoFar + $Obj.DisplayName
    $newVisited = $VisitedIds + $Obj.Id
    $results    = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($memberId in $Obj.Members) {
        $memberObj = Resolve-EntraObject -ObjectId $memberId -Cache $Cache
        $results.AddRange([PSCustomObject[]]@(Expand-ResolvedEntraObject -Obj $memberObj -Cache $Cache -ChainSoFar $newChain -VisitedIds $newVisited -IsEligible $IsEligible))
    }

    # PIM-for-Groups eligible members nobody has activated yet - invisible to Get-AzADGroupMember,
    # so without this branch an eligible-but-unactivated group reports as empty even though its
    # members could elevate themselves into real access at any time (see Get-EntraGroupPimEligibleMembers).
    foreach ($memberId in $Obj.EligibleMembers) {
        $memberObj = Resolve-EntraObject -ObjectId $memberId -Cache $Cache
        $results.AddRange([PSCustomObject[]]@(Expand-ResolvedEntraObject -Obj $memberObj -Cache $Cache -ChainSoFar $newChain -VisitedIds $newVisited -IsEligible $true))
    }

    if ($results.Count -eq 0) {
        $results.Add([PSCustomObject]@{
            GroupChain            = ($newChain -join ' > ')
            ResolvedPrincipal     = '(empty group)'
            ResolvedPrincipalType = 'None'
            MembershipState       = $membershipState
        })
    }

    return $results
}