Private/Resolve-InforcerGraphEnrichment.ps1

function Resolve-InforcerGraphEnrichment {
    <#
    .SYNOPSIS
        Connects to Microsoft Graph and resolves group names, assignment filters, scope tags, and compliance rules for a tenant.
    .DESCRIPTION
        Extracts unique group ObjectIDs from raw policy data, connects to Graph targeting the tenant's
        Azure AD, and resolves IDs to display names. Also fetches compliance policy detection rules
        (rulesContent) that the Inforcer API does not return. Returns a hashtable with GroupNameMap,
        FilterMap, ScopeTagMap, and ComplianceRulesMap ready for ConvertTo-InforcerDocModel.

        Uses Connect-InforcerGraph to obtain a Microsoft Graph context for the target tenant and
        required scopes. This may reuse an existing matching Graph context rather than always
        performing a fresh sign-in.

        Shared by Export-InforcerTenantDocumentation and Get-InforcerComparisonData to avoid duplicating
        Graph enrichment logic.
    .PARAMETER DocData
        Hashtable from Get-InforcerDocData containing Tenant and Policies.
    .PARAMETER Label
        Display label for progress messages (e.g., 'Source', 'Destination').
    .OUTPUTS
        Hashtable with keys: GroupNameMap, FilterMap, ScopeTagMap, ComplianceRulesMap. Values are $null if Graph connection fails.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$DocData,

        [Parameter()]
        [string]$Label = ''
    )

    $prefix = if ($Label) { " [$Label] " } else { ' ' }

    # Extract Azure AD tenant GUID from the Inforcer tenant data so Graph targets the correct tenant
    $msTenantId = $null
    if ($DocData.Tenant -and $DocData.Tenant.PSObject.Properties['msTenantId']) {
        $msTenantId = $DocData.Tenant.msTenantId
    }

    $graphConnectParams = @{ RequiredScopes = @('Directory.Read.All', 'DeviceManagementConfiguration.Read.All') }
    if ($msTenantId) { $graphConnectParams['TenantId'] = $msTenantId }
    $graphCtx = Connect-InforcerGraph @graphConnectParams

    if (-not $graphCtx) {
        Write-Warning "${prefix}Microsoft Graph connection failed. Falling back to raw ObjectIDs."
        return @{ GroupNameMap = $null; FilterMap = $null; ScopeTagMap = $null; ComplianceRulesMap = $null; ComplianceScriptLinkMap = $null }
    }

    Write-Host "${prefix}Graph connected as: $($graphCtx.Account)" -ForegroundColor Green

    # Validate Graph is connected to the correct tenant
    if ($msTenantId -and $graphCtx.TenantId -and $graphCtx.TenantId -ne $msTenantId) {
        $tenantName = if ($DocData.Tenant.tenantFriendlyName) { $DocData.Tenant.tenantFriendlyName } else { $msTenantId }
        Write-Warning "${prefix}Graph signed into tenant $($graphCtx.TenantId) but target tenant is '$tenantName' ($msTenantId). Group names may not resolve correctly."
    }

    # Collect all unique group ObjectIDs from raw policy data
    $objectIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($policy in @($DocData.Policies)) {
        $rawAssign = $policy.policyData.assignments
        if ($null -eq $rawAssign) { $rawAssign = $policy.assignments }
        if ($null -eq $rawAssign) { continue }
        foreach ($a in @($rawAssign)) {
            $t = $a.target; if ($null -eq $t) { $t = $a }
            if ($t.groupId -and $t.groupId -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
                [void]$objectIds.Add($t.groupId)
            }
        }
    }

    $groupNameMap = @{}
    if ($objectIds.Count -gt 0) {
        Write-Host "${prefix}Resolving $($objectIds.Count) unique group/object IDs..." -ForegroundColor Gray
        $resolved = 0
        # Use batch endpoint to resolve all IDs in one call (max 1000 per request)
        $idList = @($objectIds)
        try {
            $body = @{ ids = $idList; types = @('group','user','device','servicePrincipal') } | ConvertTo-Json -Depth 10 -Compress
            $response = Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/v1.0/directoryObjects/getByIds' `
                -Method POST -Body $body -ContentType 'application/json' -OutputType PSObject -ErrorAction Stop
            if ($response -and $response.value) {
                foreach ($obj in $response.value) {
                    if ($obj.id -and $obj.displayName) {
                        $groupNameMap[$obj.id] = $obj.displayName
                        $resolved++
                    } elseif ($obj.id) {
                        $groupNameMap[$obj.id] = $obj.id
                    }
                }
            }
        } catch {
            Write-Warning "${prefix}Batch resolve failed: $($_.Exception.Message). Falling back to individual lookups."
            foreach ($oid in $idList) {
                $obj = Invoke-InforcerGraphRequest -Uri "https://graph.microsoft.com/v1.0/directoryObjects/$oid" -SingleObject
                if ($obj -and $obj.displayName) {
                    $groupNameMap[$oid] = $obj.displayName
                    $resolved++
                } else {
                    $groupNameMap[$oid] = $oid
                }
            }
        }
        # Fill any IDs not returned by the batch (deleted objects, etc.)
        foreach ($oid in $idList) {
            if (-not $groupNameMap.ContainsKey($oid)) { $groupNameMap[$oid] = $oid }
        }
        Write-Host "${prefix}Resolved $resolved of $($objectIds.Count) group names" -ForegroundColor Gray
    }

    # Fetch assignment filters from Intune
    Write-Host "${prefix}Fetching assignment filters..." -ForegroundColor Gray
    $rawFilters = Invoke-InforcerGraphRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters'
    $filterMap = @{}
    if ($rawFilters) {
        foreach ($f in $rawFilters) { $filterMap[$f.id] = $f }
        Write-Host "${prefix}Loaded $($filterMap.Count) assignment filters" -ForegroundColor Gray
    }

    # Fetch scope tags from Intune
    Write-Host "${prefix}Fetching scope tags..." -ForegroundColor Gray
    $rawScopeTags = Invoke-InforcerGraphRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/roleScopeTags'
    $scopeTagMap = @{}
    if ($rawScopeTags) {
        foreach ($st in $rawScopeTags) { $scopeTagMap[$st.id.ToString()] = $st.displayName }
        Write-Host "${prefix}Loaded $($scopeTagMap.Count) scope tags" -ForegroundColor Gray
    }

    # Fetch compliance rules (rulesContent) via Graph $expand — supplements Inforcer API gap
    Write-Host "${prefix}Fetching compliance rules..." -ForegroundColor Gray
    $complianceRulesMap = @{}
    $complianceScriptLinkMap = @{}
    try {
        # Fetch compliance policy IDs from raw DocData (matching IntuneLens approach:
        # the list endpoint doesn't return deviceCompliancePolicyScript, so we must
        # GET each policy individually to retrieve the custom compliance rules)
        $compPolicyIds = @($DocData.Policies | Where-Object {
            $_.policyData -and $_.policyData.id -and
            $_.policyData.'@odata.type' -match 'CompliancePolicy'
        } | ForEach-Object { @{ Id = $_.policyData.id; Name = $_.displayName } })
        Write-Host "${prefix}Checking $($compPolicyIds.Count) compliance policies for custom rules..." -ForegroundColor Gray
        foreach ($cpInfo in $compPolicyIds) {
            try {
                $cpFull = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies('$($cpInfo.Id)')" `
                    -Method GET -OutputType PSObject -ErrorAction Stop
                if ($cpFull.deviceCompliancePolicyScript -and $cpFull.deviceCompliancePolicyScript.rulesContent) {
                    $rulesB64 = $cpFull.deviceCompliancePolicyScript.rulesContent
                    try {
                        $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($rulesB64))
                        $complianceRulesMap[$cpInfo.Id] = $decoded
                        Write-Host "${prefix} Compliance rules found: $($cpInfo.Name)" -ForegroundColor Green
                    } catch {
                        $complianceRulesMap[$cpInfo.Id] = $rulesB64
                    }
                }
                # Capture linked discovery script ID for compliance-to-script linking
                $scriptId = $cpFull.deviceCompliancePolicyScript.deviceComplianceScriptId
                if ($scriptId) {
                    $complianceScriptLinkMap[$cpInfo.Id] = $scriptId
                }
            } catch {
                Write-Verbose "${prefix} Failed to fetch: $($cpInfo.Name) — $($_.Exception.Message)"
            }
        }
        Write-Host "${prefix}Found compliance rules for $($complianceRulesMap.Count) of $($compPolicyIds.Count) policies" -ForegroundColor Gray
    } catch {
        Write-Warning "${prefix}Failed to fetch compliance rules: $($_.Exception.Message)"
    }

    @{
        GroupNameMap       = $groupNameMap
        FilterMap          = $filterMap
        ScopeTagMap        = $scopeTagMap
        ComplianceRulesMap = $complianceRulesMap
        ComplianceScriptLinkMap = $complianceScriptLinkMap
    }
}