private/export/Resolve-ZtRoleAssignmentPrincipal.ps1

function Resolve-ZtRoleAssignmentPrincipal {
    [CmdletBinding()]
    param (
        [object[]]
        $Assignments,

        [Parameter(Mandatory = $true)]
        [hashtable]
        $Cache,

        [switch]
        $ReconcileOnly
    )

    if (-not $Assignments) {
        return
    }

    if ($ReconcileOnly) {
        foreach ($assignment in $Assignments) {
            $principalId = if ($assignment.principal.id) { $assignment.principal.id } else { $assignment.principalId }
            if (-not $principalId -or -not $Cache.ContainsKey($principalId)) {
                continue
            }

            $resolvedPrincipal = @{
                '@odata.type' = $Cache[$principalId]['@odata.type']
                id = $Cache[$principalId].id
                displayName = $Cache[$principalId].displayName
                userPrincipalName = $Cache[$principalId].userPrincipalName
                uniqueName = $Cache[$principalId].uniqueName
            }
            if ($assignment -is [System.Collections.IDictionary]) {
                $assignment['principal'] = $resolvedPrincipal
            }
            else {
                $assignment.principal = [pscustomobject]$resolvedPrincipal
            }
        }
        return
    }

    $unresolved = @{}
    $completedLookups = @{}
    foreach ($assignment in $Assignments) {
        $principal = $assignment.principal
        $principalId = if ($principal.id) { $principal.id } else { $assignment.principalId }
        if (-not $principalId) {
            continue
        }

        $hasNewPrincipalData = $false
        if (-not $Cache.ContainsKey($principalId)) {
            $Cache[$principalId] = @{
                '@odata.type' = $principal.'@odata.type'
                id = $principalId
                displayName = $principal.displayName
                userPrincipalName = $principal.userPrincipalName
                uniqueName = $principal.uniqueName
                '__ztLookupAttempted' = $false
            }
            $hasNewPrincipalData = $true
        }
        else {
            $cachedType = $Cache[$principalId]['@odata.type']
            $incomingType = $principal.'@odata.type'
            $isDerivedType =
                ($cachedType -eq '#microsoft.graph.user' -and $incomingType -eq '#microsoft.graph.agentUser') -or
                ($cachedType -eq '#microsoft.graph.servicePrincipal' -and $incomingType -in @('#microsoft.graph.agentIdentity', '#microsoft.graph.agentIdentityBlueprintPrincipal'))
            if ($incomingType -and (-not $cachedType -or $isDerivedType)) {
                $Cache[$principalId]['@odata.type'] = $incomingType
                $hasNewPrincipalData = $true
            }

            foreach ($propertyName in 'displayName', 'userPrincipalName', 'uniqueName') {
                if (-not $Cache[$principalId][$propertyName] -and $principal.$propertyName) {
                    $Cache[$principalId][$propertyName] = $principal.$propertyName
                    $hasNewPrincipalData = $true
                }
            }
        }

        $lookupAttempted = $Cache[$principalId]['__ztLookupAttempted']
        $isIncomplete = -not $Cache[$principalId]['@odata.type'] -or -not $Cache[$principalId].displayName
        if ($isIncomplete -and (-not $lookupAttempted -or $hasNewPrincipalData)) {
            $unresolved[$principalId] = $Cache[$principalId]['@odata.type']
        }
        elseif (-not $isIncomplete) {
            [void]$unresolved.Remove($principalId)
        }
    }

    $missingTypeIds = @($unresolved.Keys | Where-Object { -not $unresolved[$_] })
    for ($index = 0; $index -lt $missingTypeIds.Count; $index += 1000) {
        $lastIndex = [Math]::Min($index + 999, $missingTypeIds.Count - 1)
        $ids = @($missingTypeIds[$index..$lastIndex])
        $body = @{
            ids = $ids
            types = @('user', 'group', 'servicePrincipal')
        } | ConvertTo-Json -Depth 3 -Compress

        try {
            $typeResults = @(Invoke-ZtGraphRequest -RelativeUri 'directoryObjects/getByIds' -Method POST -Body $body -ApiVersion beta -OutputType Hashtable -DisableCache -ErrorAction Stop)
            foreach ($result in $typeResults) {
                if ($result.id -and $unresolved.ContainsKey($result.id)) {
                    $unresolved[$result.id] = $result.'@odata.type'
                    $Cache[$result.id]['@odata.type'] = $result.'@odata.type'
                }
            }
            foreach ($id in $ids) {
                if (-not $unresolved[$id]) {
                    $completedLookups[$id] = $true
                }
            }
        }
        catch {
            Write-PSFMessage -Level Warning -Message 'Failed to resolve role principal types.' -ErrorRecord $_ -Tag Graph, Export
        }
    }

    $typeDefinitions = @(
        @{
            ODataType = '#microsoft.graph.user'
            ODataTypes = @('#microsoft.graph.user', '#microsoft.graph.agentUser')
            RelativeUri = 'users'
            Select = @('id', 'displayName', 'userPrincipalName')
        },
        @{
            ODataType = '#microsoft.graph.group'
            ODataTypes = @('#microsoft.graph.group')
            RelativeUri = 'groups'
            Select = @('id', 'displayName', 'uniqueName')
        },
        @{
            ODataType = '#microsoft.graph.servicePrincipal'
            ODataTypes = @(
                '#microsoft.graph.servicePrincipal'
                '#microsoft.graph.agentIdentity'
                '#microsoft.graph.agentIdentityBlueprintPrincipal'
            )
            RelativeUri = 'servicePrincipals'
            Select = @('id', 'displayName')
        }
    )

    foreach ($typeDefinition in $typeDefinitions) {
        $principalIds = @($unresolved.Keys | Where-Object { $unresolved[$_] -in $typeDefinition.ODataTypes })
        if (-not $principalIds) {
            continue
        }

        try {
            $select = $typeDefinition.Select -join ','
            $path = "$($typeDefinition.RelativeUri)/{0}?`$select=$select"
            $lookupResults = @(Invoke-ZtGraphBatchRequest -Path $path -ArgumentList $principalIds -Matched -ApiVersion beta -OutputType Hashtable -ErrorAction SilentlyContinue)
            foreach ($lookupResult in $lookupResults) {
                $principalId = [string]$lookupResult.Argument
                if (-not $principalId -or -not $Cache.ContainsKey($principalId)) {
                    continue
                }

                if (-not $lookupResult.Success) {
                    if ($lookupResult.Status -eq 404) {
                        $completedLookups[$principalId] = $true
                    }
                    continue
                }

                $principal = @($lookupResult.Result | Where-Object { $_.id -eq $principalId } | Select-Object -First 1)
                if (-not $principal) {
                    continue
                }
                $principal = $principal[0]
                $completedLookups[$principalId] = $true

                $cachedType = $Cache[$principalId]['@odata.type']
                $resolvedType = $principal.'@odata.type'
                if (-not $resolvedType -or ($resolvedType -eq $typeDefinition.ODataType -and $cachedType -ne $typeDefinition.ODataType)) {
                    $resolvedType = $cachedType
                }
                if (-not $resolvedType) {
                    $resolvedType = $typeDefinition.ODataType
                }

                $Cache[$principalId] = @{
                    '@odata.type' = $resolvedType
                    id = $principalId
                    displayName = if ($principal.displayName) { $principal.displayName } else { $Cache[$principalId].displayName }
                    userPrincipalName = if ($principal.userPrincipalName) { $principal.userPrincipalName } else { $Cache[$principalId].userPrincipalName }
                    uniqueName = if ($principal.uniqueName) { $principal.uniqueName } else { $Cache[$principalId].uniqueName }
                    '__ztLookupAttempted' = $Cache[$principalId]['__ztLookupAttempted']
                }
            }
        }
        catch {
            Write-PSFMessage -Level Warning -Message 'Failed to enrich {0} role principals.' -StringValues $typeDefinition.ODataType -ErrorRecord $_ -Tag Graph, Export
        }
    }

    $supportedTypes = @($typeDefinitions | ForEach-Object { $_.ODataTypes })
    foreach ($principalId in $unresolved.Keys) {
        if ($unresolved[$principalId] -and $unresolved[$principalId] -notin $supportedTypes) {
            $completedLookups[$principalId] = $true
        }
    }
    foreach ($principalId in $completedLookups.Keys) {
        $Cache[$principalId]['__ztLookupAttempted'] = $true
    }

    $unenrichedPrincipalIds = @($unresolved.Keys | Where-Object { -not $Cache[$_]['@odata.type'] -or -not $Cache[$_].displayName } | Sort-Object)
    if ($unenrichedPrincipalIds) {
        Write-PSFMessage -Level Warning -Message '{0} role principals could not be enriched. Their identifiers and known types were preserved in the export.' -StringValues $unenrichedPrincipalIds.Count -Tag Graph, Export
    }

    foreach ($assignment in $Assignments) {
        $principalId = if ($assignment.principal.id) { $assignment.principal.id } else { $assignment.principalId }
        if (-not $principalId -or -not $Cache.ContainsKey($principalId)) {
            continue
        }

        $resolvedPrincipal = @{
            '@odata.type' = $Cache[$principalId]['@odata.type']
            id = $Cache[$principalId].id
            displayName = $Cache[$principalId].displayName
            userPrincipalName = $Cache[$principalId].userPrincipalName
            uniqueName = $Cache[$principalId].uniqueName
        }
        if ($assignment -is [System.Collections.IDictionary]) {
            $assignment['principal'] = $resolvedPrincipal
        }
        else {
            $assignment.principal = [pscustomobject]$resolvedPrincipal
        }
    }
}