Public/PrivilegedAccess/Get-EntraOpsPrivilegedEAMEntraId.ps1

<#
.SYNOPSIS
    Get a list in schema of EntraOps with all privileged principals in Entra ID and assigned roles and classifications.

.DESCRIPTION
    Get a list in schema of EntraOps with all privileged principals in Entra ID and assigned roles and classifications.
    Supports parallel processing (PowerShell 7+) using Microsoft Graph SDK's process-level authentication context.

.PARAMETER TenantId
    Tenant ID of the Microsoft Entra ID tenant. Default is the current tenant ID.

.PARAMETER FolderClassification
    Folder path to the classification definition files. Default is "./Classification".

.PARAMETER SampleMode
    Use sample data for testing or offline mode. Default is $False. Default sample data is stored in "./Samples"

.PARAMETER GlobalExclusion
    Use global exclusion list for classification. Default is $true. Global exclusion list is stored in "./Classification/Global.json".

.PARAMETER EnableParallelProcessing
    Enable parallel processing for object detail resolution. Default is $true.
    Requires PowerShell 7+ and Microsoft Graph SDK authentication (not UseInvokeRestMethodOnly mode).
    Leverages MgGraph's process-level authentication context - no re-authentication needed in parallel threads.

.PARAMETER ParallelThrottleLimit
    Maximum number of parallel threads. Default is 10. Adjust based on API rate limits and system resources.

.PARAMETER IncludeJustification
    Include the Justification property (documenting a manual classification overwrite) on all Classification
    entries of the returned objects. Default is $false, so the property is not present in the output at all.

.PARAMETER IncludeObjectDetails
    Include descriptive object details in warning output. Defaults to ConsoleOutput.IncludeObjectDetails from
    EntraOpsConfig.json. Object IDs are always shown.

.EXAMPLE
    Get privileged principals with automatic parallel processing (PowerShell 7+)
    Get-EntraOpsPrivilegedEamEntraId

.EXAMPLE
    Disable parallel processing for sequential execution
    Get-EntraOpsPrivilegedEamEntraId -EnableParallelProcessing $false

.EXAMPLE
    Use parallel processing with reduced throttle limit to avoid API throttling
    Get-EntraOpsPrivilegedEamEntraId -ParallelThrottleLimit 5
#>


function Get-EntraOpsPrivilegedEamEntraId {

    [cmdletbinding()]
    param (
        [Parameter(Mandatory = $false)]
        [System.String]$TenantId = (Get-EntraOpsAzContextValue -Property TenantId)
        ,
        [Parameter(Mandatory = $false)]
        [System.String]$FolderClassification = "$DefaultFolderClassification"
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$SampleMode = $False
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$GlobalExclusion = $true
        ,
        [Parameter(Mandatory = $false)]
        [switch]$IncludeActivatedPIMAssignments
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$EnableParallelProcessing = $true
        ,
        [Parameter(Mandatory = $false)]
        [System.Int32]$ParallelThrottleLimit = 20
        ,
        [Parameter(Mandatory = $false)]
        [switch]$IncludeJustification
        ,
        [Parameter(Mandatory = $false)]
        [System.Boolean]$IncludeObjectDetails = [bool]$Global:EntraOpsIncludeObjectDetails
    )

    # Configuration for batch processing
    $BatchSize = 100  # Number of objects to process before showing progress
    $WarningMessages = New-Object -TypeName "System.Collections.Generic.List[psobject]"

    #region Stage 1: Fetch Entra ID Role Assignments
    $Stage1Start = Get-Date
    Write-Host ""
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " Stage 1/6: Fetching Entra ID Role Assignments" -ForegroundColor Cyan
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host "Retrieving active and eligible role assignments and definitions from Microsoft Graph..." -ForegroundColor Gray

    #region Check if classification file custom and/or template file exists, choose custom template for tenant if available
    $ClassificationFileName = "Classification_AadResources.json"
    $AadClassificationFilePath = Resolve-EntraOpsClassificationPath -ClassificationFileName $ClassificationFileName -FolderClassification $FolderClassification
    #endregion

    #region Get all role assignments and global exclusions
    Write-Progress -Activity "Stage 1/6: Fetching Role Assignments" -Status "Loading role assignments and global exclusions..." -PercentComplete 10

    if ($SampleMode -eq $True) {
        $SampleAssignmentsFile = "$EntraOpsBaseFolder/Samples/AadRoleManagementAssignments.json"
        if (-not (Test-Path -LiteralPath $SampleAssignmentsFile)) {
            throw "Sample file $SampleAssignmentsFile not found — SampleMode requires sample data files under Samples/"
        }
        $AadRbacAssignments = get-content -Path $SampleAssignmentsFile | ConvertFrom-Json -Depth 10
    } else {
        $EntraIdRolesParams = @{
            TenantId        = $TenantId
            WarningMessages = $WarningMessages
        }
        if ($IncludeActivatedPIMAssignments) {
            $EntraIdRolesParams['IncludeActivatedPIMAssignments'] = $true
        }
        $AadRbacAssignments = Get-EntraOpsPrivilegedEntraIdRoles @EntraIdRolesParams
    }

    $GlobalExclusionList = Import-EntraOpsGlobalExclusions -Enabled $GlobalExclusion

    $Stage1Duration = ((Get-Date) - $Stage1Start).TotalSeconds
    Write-Host "✓ Stage 1 completed in $([Math]::Round($Stage1Duration, 2)) seconds ($($AadRbacAssignments.Count) role assignments retrieved)" -ForegroundColor Green
    Write-Progress -Activity "Stage 1/6: Fetching Role Assignments" -Completed
    #endregion

    #region Classification of assignments by JSON
    #region Stage 2: Load and Validate Classification Rules
    $Stage2Start = Get-Date
    Write-Host ""
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " Stage 2/6: Loading Classification Rules" -ForegroundColor Cyan
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host "Loading role action classifications from JSON file and preparing rule set..." -ForegroundColor Gray
    Write-Progress -Activity "Stage 2/6: Loading Classification Rules" -Status "Reading classification file..." -PercentComplete 15

    $AadRbacClassifications = foreach ($AadRbacAssignment in $AadRbacAssignments) {

        [PSCustomObject]@{
            'RoleAssignmentId'                      = $AadRbacAssignment.RoleAssignmentId
            'RoleAssignmentScopeId'                 = $AadRbacAssignment.RoleAssignmentScopeId
            'RoleAssignmentScopeName'               = $AadRbacAssignment.RoleAssignmentScopeName
            'RoleAssignmentType'                    = $AadRbacAssignment.RoleAssignmentType
            'RoleAssignmentSubType'                 = $AadRbacAssignment.RoleAssignmentSubType
            'PIMManagedRole'                        = $AadRbacAssignment.RoleAssignmentPIMRelated
            'PIMAssignmentType'                     = $AadRbacAssignment.RoleAssignmentPIMAssignmentType
            'RoleDefinitionName'                    = $AadRbacAssignment.RoleName
            'RoleDefinitionId'                      = $AadRbacAssignment.RoleId
            'RoleType'                              = $AadRbacAssignment.RoleType
            'RoleIsPrivileged'                      = if ($null -eq $AadRbacAssignment.IsPrivileged) { $false } else { $AadRbacAssignment.IsPrivileged }
            'Classification'                        = $null  # Will be set during classification processing
            'ObjectId'                              = $AadRbacAssignment.ObjectId
            'ObjectType'                            = $AadRbacAssignment.ObjectType
            'ObjectTenantId'                        = $AadRbacAssignment.ObjectTenantId
            'TransitiveByObjectId'                  = $AadRbacAssignment.TransitiveByObjectId
            'TransitiveByObjectDisplayName'         = $AadRbacAssignment.TransitiveByObjectDisplayName
            'TransitiveByNestingObjectIds'          = $AadRbacAssignment.TransitiveByNestingObjectIds
            'TransitiveByNestingObjectDisplayNames' = $AadRbacAssignment.TransitiveByNestingObjectDisplayNames
        }
    }

    Write-Host "Checking if RBAC role action and scope is defined in JSON classification..."
    $AadResourcesByClassificationJSON = Expand-EntraOpsPrivilegedEAMJsonFile -FilePath "$($AadClassificationFilePath)" | select-object EAMTierLevelName, EAMTierLevelTagValue, Category, Service, RoleAssignmentScopeName, ExcludedRoleAssignmentScopeName, RoleDefinitionActions, ExcludedRoleDefinitionActions

    # Load classification overwrites (down-/upgrade of entire role definitions) from tenant-specific folder or Templates fallback.
    # Role action overwrites are already baked into the classification file by Update-EntraOpsClassificationControlPlaneScope.
    $ClassificationOverwrites = Import-EntraOpsClassificationOverwrites -RbacSystem "EntraID" -FolderClassification $FolderClassification

    # Build scope pattern lookup hashtables for O(1) lookups
    $ScopePatternLookup = @{}
    $ExactScopeLookup = @{}
    foreach ($ClassificationItem in $AadResourcesByClassificationJSON) {
        $ScopeName = $ClassificationItem.RoleAssignmentScopeName
        if ($null -ne $ScopeName) {
            # Check if scope contains wildcards
            if ($ScopeName -match '[*?\[\]]') {
                # Pattern-based scope (needs -like matching)
                if (-not $ScopePatternLookup.ContainsKey($ScopeName)) {
                    $ScopePatternLookup[$ScopeName] = [System.Collections.Generic.List[object]]::new()
                }
                $ScopePatternLookup[$ScopeName].Add($ClassificationItem)
            } else {
                # Exact match scope (faster hashtable lookup)
                if (-not $ExactScopeLookup.ContainsKey($ScopeName)) {
                    $ExactScopeLookup[$ScopeName] = [System.Collections.Generic.List[object]]::new()
                }
                $ExactScopeLookup[$ScopeName].Add($ClassificationItem)
            }
        }
    }

    # Get all role actions for Entra ID roles, role actions are defined tenant wide
    # Recommendation: Implement Persistent Disk Caching for role definitions (matching module cache pattern)
    $PersistentCachePath = $__EntraOpsSession.PersistentCachePath
    if (-not (Test-Path $PersistentCachePath)) {
        New-Item -ItemType Directory -Path $PersistentCachePath -Force | Out-Null
    }

    $RoleDefCacheKey = "EntraOps_RoleDefinitions_$($TenantId)"
    $RoleDefCacheFileName = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($RoleDefCacheKey)) + ".json"
    $RoleDefCacheFile = Join-Path $PersistentCachePath $RoleDefCacheFileName
    $RoleDefCacheValid = $false
    $RoleDefCacheTTL = $__EntraOpsSession.StaticDataCacheTTL  # Use configurable TTL for static data

    if (Test-Path $RoleDefCacheFile) {
        try {
            $RoleDefCachedObject = Get-Content $RoleDefCacheFile -Raw | ConvertFrom-Json
            $CurrentTime = [DateTime]::UtcNow
            $ExpiryTime = [DateTime]::Parse($RoleDefCachedObject.ExpiryTime)

            if ($CurrentTime -lt $ExpiryTime) {
                $RoleDefCacheValid = $true
                $TimeRemaining = ($ExpiryTime - $CurrentTime).TotalSeconds
                Write-Verbose "Using persistent disk cache for role definitions: $RoleDefCacheFileName (expires in $([Math]::Round($TimeRemaining, 0))s)"
            } else {
                Write-Verbose "Role definitions cache expired, fetching fresh data"
            }
        } catch {
            Write-Verbose "Failed to read role definitions cache metadata: $_"
        }
    }

    if ($SampleMode -eq $True) {
        $SampleRoleDefinitionsFile = "$EntraOpsBaseFolder/Samples/AadRoleManagementRoleDefinitions.json"
        if (-not (Test-Path -LiteralPath $SampleRoleDefinitionsFile)) {
            throw "Sample file $SampleRoleDefinitionsFile not found — SampleMode requires sample data files under Samples/"
        }
        $AllAadRoleActions = get-content -Path $SampleRoleDefinitionsFile | ConvertFrom-Json -Depth 10
    } elseif ($RoleDefCacheValid) {
        $RoleDefCachedObject = Get-Content $RoleDefCacheFile -Raw | ConvertFrom-Json
        $AllAadRoleActions = $RoleDefCachedObject.Data
    } else {
        # Recommendation: Optimize Payload (Minimal Select)
        $AllAadRoleActions = (Invoke-EntraOpsMsGraphQuery -Method Get -Uri "https://graph.microsoft.com/beta/roleManagement/directory/roleDefinitions?`$select=id,templateId,displayName,rolePermissions" -OutputType PSObject)

        if ($AllAadRoleActions.Count -gt 0) {
            try {
                $CurrentTime = [DateTime]::UtcNow
                $ExpiryTime = $CurrentTime.AddSeconds($RoleDefCacheTTL)

                $RoleDefPersistentCacheObject = @{
                    CacheKey   = $RoleDefCacheKey
                    CachedTime = $CurrentTime.ToString("o")
                    ExpiryTime = $ExpiryTime.ToString("o")
                    TTLSeconds = $RoleDefCacheTTL
                    Data       = $AllAadRoleActions
                }

                $RoleDefPersistentCacheObject | ConvertTo-Json -Depth 10 -Compress | Out-File -FilePath $RoleDefCacheFile -Force
                Write-Verbose "Persisted role definitions cache: $RoleDefCacheFileName (TTL: $($RoleDefCacheTTL)s)"
            } catch {
                Write-Verbose "Failed to persist role definitions cache to disk: $_"
            }
        }
    }

    # Optimization: Create lookup hashtable for role actions
    $RoleActionsLookup = Build-EntraOpsRoleActionsLookup -RoleDefinitions $AllAadRoleActions
    #endregion

    #region Apply classification for all role definitions
    $AadRbacClassification = foreach ($CurrentAadRbacClassification in $AadRbacClassifications) {
        $CurrentRoleDefinitionName = $CurrentAadRbacClassification.RoleDefinitionName
        $AadRoleScope = $CurrentAadRbacClassification.RoleAssignmentScopeId

        # Get role actions for role definition by Id (display names are not unique/stable), name as fallback
        $AadRoleActions = $RoleActionsLookup["$($CurrentAadRbacClassification.RoleDefinitionId)"]
        if ($null -eq $AadRoleActions) { $AadRoleActions = $RoleActionsLookup["$($CurrentRoleDefinitionName)"] }

        # Check if RBAC scope is listed in JSON using optimized hashtable lookups
        $MatchedClassificationByScope = [System.Collections.Generic.List[object]]::new()

        if ($null -ne $AadRoleScope) {
            # First, try exact match lookup (fastest - O(1))
            if ($ExactScopeLookup.ContainsKey($AadRoleScope)) {
                foreach ($Classification in $ExactScopeLookup[$AadRoleScope]) {
                    # Check exclusions
                    $ScopeExcluded = $false
                    if ($null -ne $Classification.ExcludedRoleAssignmentScopeName) {
                        foreach ($ExcludedScope in $Classification.ExcludedRoleAssignmentScopeName) {
                            # One-directional match (assignment scope -like exclusion pattern),
                            # aligned with the Azure scope-exclusion pattern in Get-EntraOpsPrivilegedEAMAzure.ps1
                            if ($AadRoleScope -like $ExcludedScope) {
                                $ScopeExcluded = $true
                                break
                            }
                        }
                    }
                    if (-not $ScopeExcluded) {
                        $MatchedClassificationByScope.Add($Classification)
                    }
                }
            }

            # Then check pattern-based scopes (requires -like matching)
            foreach ($Pattern in $ScopePatternLookup.Keys) {
                if ($AadRoleScope -like $Pattern) {
                    foreach ($Classification in $ScopePatternLookup[$Pattern]) {
                        # Check exclusions
                        $ScopeExcluded = $false
                        if ($null -ne $Classification.ExcludedRoleAssignmentScopeName) {
                            foreach ($ExcludedScope in $Classification.ExcludedRoleAssignmentScopeName) {
                                # One-directional match (assignment scope -like exclusion pattern),
                                # aligned with the Azure scope-exclusion pattern in Get-EntraOpsPrivilegedEAMAzure.ps1
                                if ($AadRoleScope -like $ExcludedScope) {
                                    $ScopeExcluded = $true
                                    break
                                }
                            }
                        }
                        if (-not $ScopeExcluded) {
                            $MatchedClassificationByScope.Add($Classification)
                        }
                    }
                }
            }
        }

        # Check if role action and scope exists in JSON definition using optimized lookup.
        # Matchers are precomputed once per classification entry (loop-invariant): a case-insensitive
        # HashSet lookup plus an inline wildcard fallback replaces two Test-EntraOpsClassificationActionMatch
        # invocations per action x classification pair in this hottest Stage-2 loop.
        $AadRoleActionsInJsonDefinition = [System.Collections.Generic.List[object]]::new()
        if ($null -ne $AadRoleActions -and $null -ne $AadRoleActions.rolePermissions) {
            $ClassificationMatchers = @(foreach ($MatchedClassification in $MatchedClassificationByScope) {
                    [pscustomobject]@{
                        Classification = $MatchedClassification
                        Matcher        = Build-EntraOpsClassificationActionMatcher -RoleDefinitionActions $MatchedClassification.RoleDefinitionActions -ExcludedRoleDefinitionActions $MatchedClassification.ExcludedRoleDefinitionActions
                    }
                })
            # Owner-scoped permissions are resource-local capabilities, not tenant-wide role actions.
            # Filtering before the optimized matcher prevents roles such as Application Developer from
            # inheriting Control Plane classification for applications owned by the assignee only.
            $ClassifiableRoleActions = @(Get-EntraOpsClassifiableRoleActions -RolePermissions $AadRoleActions.rolePermissions)
            foreach ($Action in $ClassifiableRoleActions) {
                if ([string]::IsNullOrEmpty($Action)) { continue }
                foreach ($MatcherEntry in $ClassificationMatchers) {
                    $Matcher = $MatcherEntry.Matcher
                    $AllowedMatch = $Matcher.AllowedExact.Contains($Action)
                    if (-not $AllowedMatch) {
                        foreach ($Pattern in $Matcher.AllowedWildcards) { if ($Action -like $Pattern) { $AllowedMatch = $true; break } }
                    }
                    if (-not $AllowedMatch) { continue }
                    $ExcludedMatch = $Matcher.ExcludedExact.Contains($Action)
                    if (-not $ExcludedMatch) {
                        foreach ($Pattern in $Matcher.ExcludedWildcards) { if ($Action -like $Pattern) { $ExcludedMatch = $true; break } }
                    }
                    if ($ExcludedMatch) { continue }
                    $MatchedClassification = $MatcherEntry.Classification
                    $AadRoleActionsInJsonDefinition.Add([PSCustomObject]@{
                            EAMTierLevelTagValue = $MatchedClassification.EAMTierLevelTagValue
                            EAMTierLevelName     = $MatchedClassification.EAMTierLevelName
                            Service              = $MatchedClassification.Service
                            MatchedAction        = $Action
                        })
                }
            }
        }

        $CurrentAadRbacClassification.Classification = [System.Collections.Generic.List[object]]::new()

        $UniqueClassifications = @{}
        if ($AadRoleActionsInJsonDefinition.Count -gt 0) {
            # Use hashtable to track unique combinations and their matched actions
            foreach ($Item in $AadRoleActionsInJsonDefinition) {
                $key = "$($Item.EAMTierLevelTagValue)|$($Item.EAMTierLevelName)|$($Item.Service)"
                if (-not $UniqueClassifications.ContainsKey($key)) {
                    $UniqueClassifications[$key] = [PSCustomObject]@{
                        'EAMTierLevelTagValue' = $Item.EAMTierLevelTagValue
                        'EAMTierLevelName'     = $Item.EAMTierLevelName
                        'Service'              = $Item.Service
                        'MatchedActions'       = [System.Collections.Generic.List[string]]::new()
                    }
                }
                if (-not [string]::IsNullOrEmpty($Item.MatchedAction) -and -not $UniqueClassifications[$key].MatchedActions.Contains($Item.MatchedAction)) {
                    $UniqueClassifications[$key].MatchedActions.Add($Item.MatchedAction) | Out-Null
                }
            }

            # Sort and add to classification
            $SortedClassifications = $UniqueClassifications.Values | Sort-Object EAMTierLevelTagValue, Service
            foreach ($Item in $SortedClassifications) {
                [array]$MatchedActionsArray = @($Item.MatchedActions)
                $ClassifiedRoleAction = [PSCustomObject]@{
                    'AdminTierLevel'             = $Item.EAMTierLevelTagValue
                    'AdminTierLevelName'         = $Item.EAMTierLevelName
                    'Service'                    = $Item.Service
                    'MatchedActions'             = if ($MatchedActionsArray.Count -gt 0) { , @($MatchedActionsArray) } else { $null }
                    'ScopedObjects'              = $null
                    'TaggedBy'                   = "JSONwithAction"
                    'TaggedByObjectIds'          = $null
                    'TaggedByObjectDisplayNames' = $null
                    'TaggedByRoleSystem'         = "EntraID"
                }
                $CurrentAadRbacClassification.Classification.Add($ClassifiedRoleAction) | Out-Null
            }
        }

        $CurrentAadRbacClassification
    }

    # Apply role definition overwrites (down-/upgrade by RoleDefinitionId or RoleDefinitionName) via the
    # shared engine (replaces the previous inline implementation; also prints the applied-overwrite summary)
    $AadRbacClassification = @(Invoke-EntraOpsClassificationRoleOverwrite -RbacClassifications @($AadRbacClassification) -RoleDefinitionOverwrites $ClassificationOverwrites.RoleDefinitionOverwrites -RoleSystem "EntraID")

    # Fail loud when Microsoft flags a role as privileged but no classification rule matched any of its
    # actions - such a role would otherwise silently appear as unclassified in every downstream report.
    $UnclassifiedPrivilegedRoles = @($AadRbacClassification | Where-Object {
            $_.RoleIsPrivileged -eq $true -and @($_.Classification).Count -eq 0
        } | Select-Object -ExpandProperty RoleDefinitionName -Unique | Sort-Object)
    if ($UnclassifiedPrivilegedRoles.Count -gt 0) {
        $WarningMessages.Add([PSCustomObject]@{
                Type    = "UnclassifiedPrivilegedRole"
                Message = "$($UnclassifiedPrivilegedRoles.Count) role definition(s) flagged as privileged by Microsoft have no matching classification rule and are reported as unclassified: $($UnclassifiedPrivilegedRoles -join ', '). Add the missing role actions to Classification_AadResources.json or pin the role in Classification_RoleDefinitionOverwrites.json."
            })
    }

    $Stage2Duration = ((Get-Date) - $Stage2Start).TotalSeconds
    Write-Host "✓ Stage 2 completed in $([Math]::Round($Stage2Duration, 2)) seconds ($($AadRbacClassification.Count) assignments classified)" -ForegroundColor Green
    Write-Progress -Activity "Stage 2/6: Loading Classification Rules" -Completed
    #endregion

    #region Apply classification on all principals
    #region Stage 3: Classify Role Assignments
    $Stage3Start = Get-Date
    Write-Host ""
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " Stage 3/6: Classifying Role Assignments" -ForegroundColor Cyan
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host "Matching role assignments against classification rules and applying tier levels..." -ForegroundColor Gray

    # Optimization: Group assignments by ObjectId to avoid O(N^2) filtering in loop
    $RbacAssignmentsByObject = $AadRbacClassification | Group-Object ObjectId -AsHashTable -AsString

    # Optimization: Collect all unique ObjectIds and batch resolve details
    $UniqueObjects = $AadRbacClassification | Select-Object -Unique ObjectId, ObjectType, ObjectTenantId | Where-Object { $null -ne $_.ObjectId }
    $UniqueObjectIds = @($UniqueObjects.ObjectId)

    $Stage3Duration = ((Get-Date) - $Stage3Start).TotalSeconds
    Write-Host "✓ Stage 3 completed in $([Math]::Round($Stage3Duration, 2)) seconds ($($UniqueObjects.Count) unique principals identified)" -ForegroundColor Green
    #endregion

    #region Stage 4: Pre-Fetch Principal Details
    $Stage4Start = Get-Date
    Write-Host ""
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " Stage 4/6: Pre-Fetching Principal Details" -ForegroundColor Cyan
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host "Batch loading user, group, and service principal details to populate cache ($($UniqueObjectIds.Count) objects)..." -ForegroundColor Gray

    #region Pre-fetch objects by type for massive performance improvement
    # Pre-fetching all objects in type-specific batches reduces API calls from N to ~3-5
    # This eliminates API throttling and improves performance by 80-90%
    $ObjectsByType = $UniqueObjects | Group-Object ObjectType
    $PreFetchStats = @{
        TotalObjects    = $UniqueObjectIds.Count
        PreFetchedCount = 0
        FailedCount     = 0
    }

    # store pre-fetched objects in hashtable for O(1) lookup during resolution
    $PreFetchedObjectLookup = @{}

    $TypeIndex = 0
    $TotalTypes = $ObjectsByType.Count
    foreach ($TypeGroup in $ObjectsByType) {
        $ObjectType = $TypeGroup.Name
        $ObjectIds = @($TypeGroup.Group.ObjectId)
        $TypeIndex++

        # Determine endpoint and select fields based on type
        # UPGRADE: Switched to /beta/ to retrieve 'isManagementRestricted' and other advanced properties
        # This allows Stage 5 to skip the initial API call entirely
        $EndpointInfo = switch ($ObjectType) {
            'user' {
                @{
                    Endpoint = '/beta/users/getByIds'
                    Select   = 'id,displayName,userPrincipalName,mail,accountEnabled,userType,onPremisesSyncEnabled,assignedLicenses,isManagementRestricted,isAssignableToRole,passwordPolicies'
                }
            }
            'group' {
                @{
                    Endpoint = '/beta/groups/getByIds'
                    Select   = 'id,displayName,mailEnabled,securityEnabled,groupTypes,onPremisesSyncEnabled,membershipRule,isManagementRestricted,isAssignableToRole'
                }
            }
            'servicePrincipal' {
                @{
                    Endpoint = '/beta/servicePrincipals/getByIds'
                    Select   = 'id,displayName,appId,servicePrincipalType,accountEnabled,appOwnerOrganizationId,isManagementRestricted,isAssignableToRole'
                }
            }
            default { $null }
        }

        if ($EndpointInfo) {
            Write-Progress -Activity "Stage 4/6: Pre-Fetching Principal Details" -Status "Loading $ObjectType objects ($TypeIndex of $TotalTypes types, $($ObjectIds.Count) objects)..." -PercentComplete (($TypeIndex / $TotalTypes) * 100)
            Write-Verbose "Pre-fetching $($ObjectIds.Count) $ObjectType objects..."

            try {
                # Batch in chunks of 1000 (API limit for getByIds)
                for ($i = 0; $i -lt $ObjectIds.Count; $i += 1000) {
                    $Batch = $ObjectIds[$i..([Math]::Min($i + 999, $ObjectIds.Count - 1))]
                    $BatchNumber = [Math]::Floor($i / 1000) + 1
                    $TotalBatches = [Math]::Ceiling($ObjectIds.Count / 1000)

                    Write-Progress -Activity "Stage 4/6: Pre-Fetching Principal Details" -Status "Loading $ObjectType objects - Batch $BatchNumber of $TotalBatches ($($Batch.Count) objects)..." -PercentComplete (($TypeIndex / $TotalTypes) * 100)

                    $Body = @{ ids = $Batch } | ConvertTo-Json

                    # Use $select to minimize payload size
                    $Uri = "$($EndpointInfo.Endpoint)?`$select=$($EndpointInfo.Select)"
                    $PreFetchedObjects = Invoke-EntraOpsMsGraphQuery -Method POST -Uri $Uri -Body $Body -OutputType PSObject

                    if ($PreFetchedObjects) {
                        $PreFetchedObjects | ForEach-Object {
                            if ($_.id) { $PreFetchedObjectLookup[$_.id] = $_ }
                        }
                        $PreFetchStats.PreFetchedCount += $PreFetchedObjects.Count
                        Write-Verbose "Pre-fetched batch of $($PreFetchedObjects.Count) $ObjectType objects (batch $BatchNumber of $TotalBatches)"
                    }

                    # Brief delay between batches to avoid throttling
                    if ($i + 1000 -lt $ObjectIds.Count) {
                        Start-Sleep -Milliseconds 200
                    }
                }
            } catch {
                $WarningMessages.Add([PSCustomObject]@{Type = "Stage5-PreFetch"; Message = "Pre-fetch failed for $ObjectType objects: $($_.Exception.Message)" })
                $PreFetchStats.FailedCount += $ObjectIds.Count
            }
        } else {
            Write-Verbose "Skipping pre-fetch for unknown object type: $ObjectType"
        }
    }

    $Stage4Duration = ((Get-Date) - $Stage4Start).TotalSeconds
    Write-Host "✓ Stage 4 completed in $([Math]::Round($Stage4Duration, 2)) seconds ($($PreFetchStats.PreFetchedCount) objects cached, $($PreFetchStats.FailedCount) failed)" -ForegroundColor Green
    Write-Progress -Activity "Stage 4/6: Pre-Fetching Principal Details" -Completed
    #endregion

    $ObjectDetailsCache = @{}

    #region Stage 5: Resolve Object Details (Parallel or Sequential)
    $Stage5Start = Get-Date
    Write-Host ""
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " Stage 5/6: Resolving Object Details" -ForegroundColor Cyan
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host "Enriching principals with detailed attributes, group memberships, and ownership data..." -ForegroundColor Gray

    # Determine if parallel processing is viable (PowerShell 7+ guaranteed by module prerequisite)
    # Requirements: dataset >= 20 objects plus a viable auth mechanism for the runspaces: either the
    # Graph SDK's process-wide context (SDK mode), or - in REST-only mode, where Connect-EntraOps
    # deliberately skips Connect-MgGraph for non-interactive auth - a pre-warmed token in the shared
    # session's MsGraphTokenCache (pre-warmed in the main thread because Get-AzAccessToken inside
    # fresh runspaces is not guaranteed to see the Az context).
    $HasSufficientObjects = $UniqueObjectIds.Count -ge 20
    $IsRestOnlyModeStage5 = [bool]$__EntraOpsSession['UseInvokeRestMethodOnly']
    $IsUsingMgGraphSDK = $false
    if (-not $IsRestOnlyModeStage5) {
        try {
            $IsUsingMgGraphSDK = $null -ne (Get-MgContext -ErrorAction Stop)
        } catch {
            Write-Verbose "Microsoft Graph SDK context not available - parallel object resolution requires it: $($_.Exception.Message)"
        }
    }
    $RestOnlyParallelReady = $false
    if ($IsRestOnlyModeStage5 -and $EnableParallelProcessing) {
        $RestOnlyParallelReady = Initialize-EntraOpsRestOnlyParallelToken
    }
    $HasParallelAuth = $IsUsingMgGraphSDK -or $RestOnlyParallelReady
    $UseParallel = $EnableParallelProcessing -and $HasSufficientObjects -and $HasParallelAuth

    # Share the module-wide synchronized caches with parallel runspaces.
    if ($UseParallel) {
        $SyncGraphCache = $__EntraOpsSession.GraphCache
        $SyncCacheMetadata = $__EntraOpsSession.CacheMetadata
        $SyncPreFetchedObjectLookup = [System.Collections.Hashtable]::Synchronized(@{})

        # Share pre-fetched objects
        foreach ($key in $PreFetchedObjectLookup.Keys) {
            $SyncPreFetchedObjectLookup[$key] = $PreFetchedObjectLookup[$key]
        }

        Write-Verbose "Initialized synchronized cache for parallel processing: $($SyncGraphCache.Count) cached entries"
    }

    if ($UseParallel) {
        # Optimize throttle limit based on dataset size (Balanced Mode)
        # With pre-fetch optimization, most API calls hit cache, allowing higher thread counts
        # Adaptive throttling will adjust based on actual API response
        $OptimalThrottleLimit = switch ($UniqueObjectIds.Count) {
            { $_ -lt 50 } { 10; break }
            { $_ -lt 100 } { 20; break }
            { $_ -lt 500 } { 30; break }
            { $_ -lt 1000 } { 40; break }
            default { 50 }  # Balanced cap with pre-fetch optimization
        }

        # Use user-specified limit if lower (respects user's throttling preference)
        $EffectiveThrottleLimit = [Math]::Min($ParallelThrottleLimit, $OptimalThrottleLimit)

        Write-Host "Using parallel processing with $EffectiveThrottleLimit threads for $($UniqueObjectIds.Count) objects..." -ForegroundColor Yellow
        if ($EffectiveThrottleLimit -ne $ParallelThrottleLimit) {
            Write-Verbose "Throttle limit adjusted from $ParallelThrottleLimit to $EffectiveThrottleLimit for optimal performance"
        }

        # SDK mode: verify MgGraph connection exists (REST-only mode was validated by the token pre-warm)
        $MgGraphModulePath = $null
        if ($IsUsingMgGraphSDK) {
            $MgContext = Get-MgContext
            if ($null -eq $MgContext) {
                $WarningMessages.Add([PSCustomObject]@{Type = "Stage5-Parallel"; Message = "Microsoft Graph is not connected. Falling back to sequential processing." })
                $UseParallel = $false
            } else {
                Write-Verbose "MgGraph Context: TenantId=$($MgContext.TenantId), Scopes=$($MgContext.Scopes -join ', ')"

                # Import module in current scope to ensure it's available
                Import-Module Microsoft.Graph.Authentication -ErrorAction SilentlyContinue
                $MgGraphModulePath = (Get-Module Microsoft.Graph.Authentication).Path
            }
        }

        if ($UseParallel) {
            # Prepare module paths for parallel runspaces
            $EntraOpsModulePath = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent

            # Capture global variables and synchronized caches for parallel runspaces
            $LocalEntraOpsConfig = $Global:EntraOpsConfig
            $LocalEntraOpsBaseFolder = $Global:EntraOpsBaseFolder
            $LocalTenantIdContext = $Global:TenantIdContext
            $SharedEntraOpsSession = $__EntraOpsSession
            $SharedPreFetchedLookup = $SyncPreFetchedObjectLookup

            # Create thread-safe progress counter for parallel processing
            $ProgressCounter = [System.Collections.Concurrent.ConcurrentBag[int]]::new()
            $TotalObjects = $UniqueObjects.Count

            try {
                # Execute parallel processing with shared cache
                # Key insight: Synchronized hashtables allow thread-safe cache sharing!
                # All threads read/write to same cache = massive performance improvement
                $ParallelResults = $UniqueObjects | ForEach-Object -ThrottleLimit $EffectiveThrottleLimit -Parallel {
                    $obj = $_
                    $ObjectId = $obj.ObjectId
                    $LocalTenantId = $using:TenantId
                    $LocalEntraOpsPath = $using:EntraOpsModulePath
                    $LocalMgGraphPath = $using:MgGraphModulePath
                    $LocalVerbosePref = $using:VerbosePreference
                    $LocalIsRestOnly = $using:IsRestOnlyModeStage5

                    # Set verbose preference in parallel runspace
                    $VerbosePreference = $LocalVerbosePref

                    try {
                        if (-not $LocalIsRestOnly) {
                            # Import Microsoft.Graph.Authentication in parallel runspace
                            # Load Graph authentication commands when absent from the runspace.
                            if (-not (Get-Module -Name Microsoft.Graph.Authentication)) {
                                Import-Module $LocalMgGraphPath -ErrorAction Stop
                            }

                            # Verify MgGraph context is accessible (it should be - process-scoped!)
                            $ThreadMgContext = Get-MgContext
                            if ($null -eq $ThreadMgContext) {
                                throw "MgGraph context not available in parallel runspace"
                            }
                        }

                        # CRITICAL: Set cache BEFORE importing module
                        # Module functions will look for $__EntraOpsSession in global scope if not found in module scope
                        $global:__EntraOpsSession = $using:SharedEntraOpsSession

                        Write-Verbose "[Thread $([System.Threading.Thread]::CurrentThread.ManagedThreadId)] Pre-import: Global cache set with $($global:__EntraOpsSession.GraphCache.Count) entries"

                        # Import EntraOps module to get all functions - Optimized to avoid reload on reused runspaces
                        $EntraOpsModuleManifest = Join-Path $LocalEntraOpsPath "EntraOps.psd1"
                        if (Test-Path $EntraOpsModuleManifest) {
                            $env:ENTRAOPS_NOWELCOME = $true
                            if (-not (Get-Module -Name EntraOps)) {
                                Import-Module $EntraOpsModuleManifest -ErrorAction Stop -WarningAction SilentlyContinue
                            }
                        } else {
                            throw "EntraOps module manifest not found at $EntraOpsModuleManifest"
                        }

                        # Inject the shared session INTO THE MODULE SCOPE: module functions resolve
                        # $__EntraOpsSession from their own script scope (created fresh on import), so the
                        # global assignment above never reaches them. Required for REST-only auth
                        # (pre-warmed MsGraphTokenCache + UseInvokeRestMethodOnly flag) and makes the
                        # shared GraphCache visible to runspaces in both modes.
                        $EntraOpsModuleInstance = Get-Module -Name EntraOps
                        & $EntraOpsModuleInstance { param($s) Set-Variable -Name __EntraOpsSession -Value $s -Scope Script } $global:__EntraOpsSession

                        Write-Verbose "[Thread $([System.Threading.Thread]::CurrentThread.ManagedThreadId)] Post-import: Cache has $($global:__EntraOpsSession.GraphCache.Count) entries"

                        # Restore global variables in parallel runspace. UseInvokeRestMethodOnly mirrors the
                        # session mode: SDK mode authenticates via the process-wide MgGraph context, REST-only
                        # mode via the shared session's pre-warmed token cache
                        $global:UseInvokeRestMethodOnly = $LocalIsRestOnly
                        $global:EntraOpsConfig = $using:LocalEntraOpsConfig
                        $global:EntraOpsBaseFolder = $using:LocalEntraOpsBaseFolder
                        # Cache-key tenant scoping in REST-only mode falls back to this global (no MgContext)
                        $global:TenantIdContext = $using:LocalTenantIdContext

                        # Retrieve pre-fetched object if available
                        $SharedLookup = $using:SharedPreFetchedLookup
                        $PreFetchedObj = if ($SharedLookup.ContainsKey($ObjectId)) { $SharedLookup[$ObjectId] } else { $null }

                        # Get object details using MgGraph SDK - authentication already in place!
                        # Optimized: Passes pre-fetched InputObject to avoid redundant API calls
                        # Flag foreign principals (from tenant governance) so expected 404s are treated as informational.
                        $IsForeign = (-not [string]::IsNullOrEmpty($obj.ObjectTenantId) -and $obj.ObjectTenantId -ne $LocalTenantId)
                        $ObjectDetails = Get-EntraOpsPrivilegedEntraObject -AadObjectId $ObjectId -TenantId $LocalTenantId -InputObject $PreFetchedObj -IsForeignPrincipal:$IsForeign

                        # Update progress (thread-safe)
                        $LocalCounter = $using:ProgressCounter
                        $LocalCounter.Add(1)
                        $Completed = $LocalCounter.Count
                        $LocalTotal = $using:TotalObjects

                        # Progress reporting every 5% or every 10 items (whichever is more frequent)
                        if (($Completed % [Math]::Max(10, [Math]::Floor($LocalTotal / 20))) -eq 0 -or $Completed -eq $LocalTotal) {
                            $PercentComplete = [Math]::Round(($Completed / $LocalTotal) * 100, 1)
                            Write-Progress -Id 1 -Activity "Parallel Object Resolution" -Status "Processed $Completed of $LocalTotal objects ($PercentComplete%)" -PercentComplete $PercentComplete
                        }

                        [PSCustomObject]@{
                            ObjectId = $ObjectId
                            Details  = $ObjectDetails
                            Success  = $true
                            ThreadId = [System.Threading.Thread]::CurrentThread.ManagedThreadId
                        }
                    } catch {
                        # Update progress even on failure
                        $LocalCounter = $using:ProgressCounter
                        $LocalCounter.Add(1)

                        [PSCustomObject]@{
                            ObjectId = $ObjectId
                            Details  = $null
                            Success  = $false
                            Error    = $_.Exception.Message
                            ThreadId = [System.Threading.Thread]::CurrentThread.ManagedThreadId
                        }
                    }
                }

                Write-Progress -Id 1 -Activity "Parallel Object Resolution" -Completed

                # Process parallel results and build cache
                $SuccessCount = 0
                $FailureCount = 0
                $UsedThreads = @()

                foreach ($Result in $ParallelResults) {
                    if ($Result.Success) {
                        $ObjectDetailsCache[$Result.ObjectId] = $Result.Details
                        $SuccessCount++
                        if ($Result.ThreadId -notin $UsedThreads) { $UsedThreads += $Result.ThreadId }
                    } else {
                        $WarningMessages.Add([PSCustomObject]@{Type = "Stage5-ObjectDetails"; Message = "Failed to get details for object $($Result.ObjectId): $($Result.Error)"; Target = $Result.ObjectId })
                        $ObjectDetailsCache[$Result.ObjectId] = $null
                        $FailureCount++
                    }
                }

                Write-Host "Parallel processing completed: $SuccessCount successful, $FailureCount failed (used $($UsedThreads.Count) threads)"

                if ($ParallelResults.Count -ne $TotalObjects) {
                    $WarningMessages.Add([PSCustomObject]@{Type = "Stage5-Parallel"; Message = "Parallel processing returns mismatch count of objects. Expected: $TotalObjects, Actual: $($ParallelResults.Count)" })
                    Write-Warning "Parallel processing returns mismatch count of objects. Expected: $TotalObjects, Actual: $($ParallelResults.Count)"
                }

                Write-Verbose "Shared cache now has $($SyncGraphCache.Count) entries after parallel processing"

            } catch {
                $WarningMessages.Add([PSCustomObject]@{Type = "Stage5-ParallelFallback"; Message = "Parallel processing failed: $($_.Exception.Message). Falling back to sequential processing." })
                $UseParallel = $false
            }
        }

        if ($UseParallel) {
            $Stage5Duration = ((Get-Date) - $Stage5Start).TotalSeconds
            Write-Host "✓ Stage 5 completed in $([Math]::Round($Stage5Duration, 2)) seconds (parallel: $SuccessCount successful, $FailureCount failed, $($UsedThreads.Count) threads)" -ForegroundColor Green
        }
    }

    # Sequential processing (fallback or when parallel is not viable)
    if (-not $UseParallel) {
        # Provide informative reason for sequential processing
        if ($EnableParallelProcessing) {
            $Reasons = @()
            if (-not $HasSufficientObjects) { $Reasons += "dataset too small (<20 objects)" }
            if (-not $HasParallelAuth) {
                $Reasons += if ($IsRestOnlyModeStage5) { "Graph token pre-warm for REST-only parallel processing failed" } else { "Microsoft Graph SDK context not available" }
            }
            if ($Reasons.Count -gt 0) {
                Write-Host "Using sequential processing: $($Reasons -join ', ')" -ForegroundColor Yellow
            }
        } else {
            Write-Host "Using sequential processing (parallel disabled)..." -ForegroundColor Yellow
        }

        # Batch resolution with progress reporting
        for ($i = 0; $i -lt $UniqueObjectIds.Count; $i++) {
            $ObjectId = $UniqueObjectIds[$i]

            # Update progress more frequently for better UX (every 10 items or 5%, whichever is less frequent)
            $ProgressInterval = [Math]::Max(10, [Math]::Floor($UniqueObjectIds.Count / 20))
            if (($i % $ProgressInterval) -eq 0 -or $i -eq ($UniqueObjectIds.Count - 1)) {
                $PercentComplete = [math]::Round(($i / $UniqueObjectIds.Count) * 100, 0)
                Write-Progress -Activity "Stage 5/6: Resolving Object Details" -Status "Processing object $($i + 1) of $($UniqueObjectIds.Count)" -PercentComplete $PercentComplete
                if ($VerbosePreference -ne 'SilentlyContinue') {
                    Write-Verbose "Processing object $($i + 1) of $($UniqueObjectIds.Count)..."
                }
            }

            try {
                # Use pre-fetched object if available
                $PreFetchedObj = if ($PreFetchedObjectLookup.ContainsKey($ObjectId)) { $PreFetchedObjectLookup[$ObjectId] } else { $null }

                # Flag foreign principals (from tenant governance) so expected 404s are treated as informational.
                $ObjMeta = $UniqueObjects[$i]
                $IsForeign = (-not [string]::IsNullOrEmpty($ObjMeta.ObjectTenantId) -and $ObjMeta.ObjectTenantId -ne $TenantId)
                $ObjectDetailsCache[$ObjectId] = Get-EntraOpsPrivilegedEntraObject -AadObjectId $ObjectId -TenantId $TenantId -InputObject $PreFetchedObj -IsForeignPrincipal:$IsForeign
            } catch {
                $WarningMessages.Add([PSCustomObject]@{Type = "Stage5-Sequential"; Message = "Failed to get details for object $($ObjectId): $_"; Target = $ObjectId })
                $ObjectDetailsCache[$ObjectId] = $null
            }
        }
        Write-Progress -Activity "Stage 5/6: Resolving Object Details" -Completed

        $Stage5Duration = ((Get-Date) - $Stage5Start).TotalSeconds
        Write-Host "✓ Stage 5 completed in $([Math]::Round($Stage5Duration, 2)) seconds (sequential: $($ObjectDetailsCache.Count) objects processed)" -ForegroundColor Green
    }
    #endregion

    #region Stage 5b: Cross-Tenant Group Expansion and Object Resolution
    # Detect cross-tenant objects from classification data
    $CrossTenantGroupEntries = @($AadRbacClassification | Where-Object {
            $_.ObjectType -eq 'group' -and
            -not [string]::IsNullOrEmpty($_.ObjectTenantId) -and
            $_.ObjectTenantId -ne $TenantId
        } | Select-Object -Unique ObjectId, ObjectType, ObjectTenantId)

    $CrossTenantNonGroupObjects = @($AadRbacClassification | Where-Object {
            $_.ObjectType -ne 'group' -and
            -not [string]::IsNullOrEmpty($_.ObjectTenantId) -and
            $_.ObjectTenantId -ne $TenantId
        } | Select-Object -Unique ObjectId, ObjectType, ObjectTenantId)

    $HasCrossTenantWork = ($CrossTenantGroupEntries.Count -gt 0 -or $CrossTenantNonGroupObjects.Count -gt 0) -and
    -not [string]::IsNullOrEmpty($Global:ManagingTenantIdContext)

    if ($HasCrossTenantWork) {
        $Stage5bStart = Get-Date
        Write-Host ""
        Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
        Write-Host " Stage 5b: Cross-Tenant Group Expansion and Object Resolution" -ForegroundColor Cyan
        Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
        Write-Host "Managing tenant: '$Global:ManagingTenantIdContext' — $($CrossTenantGroupEntries.Count) group(s) to expand, $($CrossTenantNonGroupObjects.Count) direct cross-tenant object(s)" -ForegroundColor Gray

        $AuthType = $__EntraOpsSession.AuthenticationType
        $IsInteractiveAuth = $AuthType -in @('UserInteractive', 'DeviceAuthentication')
        $IsRestOnlyMode = [bool]$__EntraOpsSession['UseInvokeRestMethodOnly']

        # Scopes for the managing tenant connection.
        $ManagingTenantScopes = @(
            "Application.Read.All",
            "Directory.Read.All",
            "Group.Read.All",
            "GroupMember.Read.All",
            "User.Read.All"
        )

        # Scopes for the interactive home-tenant restore via Connect-MgGraph -TenantId.
        # Must be a subset of what Connect-EntraOps requested so MSAL returns the cached
        # full-scope home tenant token without prompting.
        $HomeTenantScopes = @(
            "AdministrativeUnit.Read.All",
            "Application.Read.All",
            "CustomSecAttributeAssignment.Read.All",
            "Directory.Read.All",
            "Group.Read.All",
            "GroupMember.Read.All",
            "PrivilegedAccess.Read.AzureADGroup",
            "PrivilegedEligibilitySchedule.Read.AzureADGroup",
            "RemoteTenantGroups.Read.All",
            "RoleManagement.Read.All",
            "User.Read.All"
        )

        # Home token needed only for non-interactive restore (Connect-MgGraph -AccessToken).
        # For interactive, home restore uses Connect-MgGraph -TenantId (MSAL cache).
        $HomeToken = $null
        $Stage5bSkip = $false
        if (-not $IsRestOnlyMode -and -not $IsInteractiveAuth) {
            try {
                $HomeToken = (Get-AzAccessToken -ResourceTypeName "MSGraph" -TenantId $Global:TenantIdContext -AsSecureString -ErrorAction Stop).Token
            } catch {
                Write-Warning "Could not acquire home tenant token for context restore: $($_.Exception.Message). Stage 5b skipped."
                $Stage5bSkip = $true
            }
        }

        if (-not $Stage5bSkip) {
            try {
                #region Connect to managing tenant (single connection for all cross-tenant work)
                if ($IsRestOnlyMode) {
                    # Invoke-EntraOpsMsGraphQuery uses this session override to acquire and cache
                    # a managing-tenant token without requiring the Microsoft Graph SDK.
                    $__EntraOpsSession['CurrentGraphTenantId'] = $Global:ManagingTenantIdContext
                } elseif ($IsInteractiveAuth) {
                    Write-Host "Connecting to managing tenant '$Global:ManagingTenantIdContext' (interactive sign-in required)..." -ForegroundColor Cyan
                    Connect-MgGraph -TenantId $Global:ManagingTenantIdContext -Scopes $ManagingTenantScopes -NoWelcome -ErrorAction Stop
                } else {
                    $ManagingToken = (Get-AzAccessToken -ResourceTypeName "MSGraph" -TenantId $Global:ManagingTenantIdContext -AsSecureString -ErrorAction Stop).Token
                    Connect-MgGraph -AccessToken $ManagingToken -NoWelcome -ErrorAction Stop
                }
                Write-Host "Connected to managing tenant '$Global:ManagingTenantIdContext'." -ForegroundColor Cyan
                #endregion

                #region Phase 1: Expand cross-tenant groups into transitive classification entries
                $NewTransitiveClassifications = [System.Collections.Generic.List[object]]::new()

                if ($CrossTenantGroupEntries.Count -gt 0) {
                    Write-Host "Expanding $($CrossTenantGroupEntries.Count) cross-tenant group(s)..." -ForegroundColor Gray

                    foreach ($GroupEntry in $CrossTenantGroupEntries) {
                        $GroupId = $GroupEntry.ObjectId
                        $GroupTenantId = $GroupEntry.ObjectTenantId

                        # A Graph context switch is only possible for the configured managing tenant
                        # (pre-authenticated by Connect-EntraOps). Groups from any other foreign tenant
                        # (e.g. additional Tenant Governance governing tenants) cannot be expanded here -
                        # their display name was already resolved in Stage 5 via the home tenant's
                        # remoteTenantGroups (remote tenant root group) fallback.
                        if ($GroupTenantId -ne $Global:ManagingTenantIdContext) {
                            Write-Verbose "Skipping transitive expansion of group $GroupId - tenant $GroupTenantId is not the configured managing tenant."
                            $WarningMessages.Add([PSCustomObject]@{
                                    Type    = "CrossTenant-ExpansionSkipped"
                                    Message = "Skipped transitive expansion of group $GroupId in tenant $GroupTenantId - tenant is not the configured managing tenant ('$Global:ManagingTenantIdContext'), display name resolves via remote tenant root group only."
                                    Target  = $GroupId
                                })
                            continue
                        }

                        try {
                            $TransitiveMembers = Get-EntraOpsPrivilegedTransitiveGroupMember `
                                -GroupObjectId $GroupId `
                                -TenantId $GroupTenantId `
                                -WarningMessages $WarningMessages

                            # All classification entries for this group (one per role assignment)
                            $GroupClassEntries = @($AadRbacClassification | Where-Object { $_.ObjectId -eq $GroupId })

                            if ($TransitiveMembers.Count -eq 0) {
                                Write-Verbose "Empty cross-tenant group $GroupId — no transitive entries created"
                                $WarningMessages.Add([PSCustomObject]@{
                                        Type    = "Empty Group"
                                        Message = "Empty cross-tenant group $GroupId in tenant $GroupTenantId — no transitive assignments created"
                                        Target  = $GroupId
                                    })
                            }

                            foreach ($TransitiveMember in $TransitiveMembers) {
                                $MemberObjectType = $TransitiveMember.'@odata.type'.Replace("#microsoft.graph.", "").ToLower()
                                $MemberObjectId = $TransitiveMember.id

                                foreach ($GroupClassEntry in $GroupClassEntries) {
                                    $TransitiveEntry = [PSCustomObject]@{
                                        RoleAssignmentId                      = $GroupClassEntry.RoleAssignmentId
                                        RoleAssignmentScopeId                 = $GroupClassEntry.RoleAssignmentScopeId
                                        RoleAssignmentScopeName               = $GroupClassEntry.RoleAssignmentScopeName
                                        RoleAssignmentType                    = "Transitive"
                                        RoleAssignmentSubType                 = $TransitiveMember.RoleAssignmentSubType
                                        PIMManagedRole                        = $GroupClassEntry.PIMManagedRole
                                        PIMAssignmentType                     = $GroupClassEntry.PIMAssignmentType
                                        RoleDefinitionName                    = $GroupClassEntry.RoleDefinitionName
                                        RoleDefinitionId                      = $GroupClassEntry.RoleDefinitionId
                                        RoleType                              = $GroupClassEntry.RoleType
                                        RoleIsPrivileged                      = $GroupClassEntry.RoleIsPrivileged
                                        Classification                        = $GroupClassEntry.Classification
                                        ObjectId                              = $MemberObjectId
                                        ObjectTenantId                        = $GroupTenantId
                                        ObjectType                            = $MemberObjectType
                                        TransitiveByObjectId                  = $GroupId
                                        TransitiveByObjectDisplayName         = $null  # Populated after object resolution below
                                        TransitiveByNestingObjectIds          = $TransitiveMember.NestingObjectIds
                                        TransitiveByNestingObjectDisplayNames = $TransitiveMember.NestingObjectDisplayNames
                                    }
                                    $NewTransitiveClassifications.Add($TransitiveEntry) | Out-Null
                                }
                            }
                        } catch {
                            Write-Warning "Failed to expand cross-tenant group $GroupId in tenant $GroupTenantId : $($_.Exception.Message)"
                            $WarningMessages.Add([PSCustomObject]@{
                                    Type    = "CrossTenant-TransitiveExpansion"
                                    Message = "Failed to expand group $GroupId in tenant $GroupTenantId : $($_.Exception.Message)"
                                    Target  = $GroupId
                                })
                        }
                    }

                    if ($NewTransitiveClassifications.Count -gt 0) {
                        Write-Host "Cross-tenant group expansion: $($NewTransitiveClassifications.Count) transitive classification entries created." -ForegroundColor Cyan

                        # Merge into $AadRbacClassification and rebuild Stage 3 lookups
                        $AadRbacClassification = @($AadRbacClassification) + @($NewTransitiveClassifications)
                        $RbacAssignmentsByObject = $AadRbacClassification | Group-Object ObjectId -AsHashTable -AsString

                        # Add newly discovered unique objects (transitive members) to $UniqueObjects
                        $ExistingObjectIds = @($UniqueObjects.ObjectId)
                        $NewUniqueObjects = @($NewTransitiveClassifications |
                            Select-Object -Unique ObjectId, ObjectType, ObjectTenantId |
                            Where-Object { $null -ne $_.ObjectId -and $ExistingObjectIds -notcontains $_.ObjectId })
                        if ($NewUniqueObjects.Count -gt 0) {
                            $UniqueObjects = @($UniqueObjects) + $NewUniqueObjects
                        }
                    }
                }
                #endregion

                #region Phase 2: Resolve all cross-tenant unique objects in managing tenant context
                # Restricted to objects that actually live in the configured managing tenant.
                # Objects from OTHER remote tenants (e.g. additional TG relationships) can never
                # be found there - re-resolving them would only produce an 'Identity not found'
                # placeholder that clobbers the Stage 5 home-tenant remoteTenantGroups resolution.
                $AllCrossTenantUniqueObjects = @($UniqueObjects | Where-Object {
                        $_.ObjectTenantId -eq $Global:ManagingTenantIdContext
                    })

                if ($AllCrossTenantUniqueObjects.Count -gt 0) {
                    Write-Host "Resolving $($AllCrossTenantUniqueObjects.Count) cross-tenant object(s) against managing tenant..." -ForegroundColor Gray

                    # REST-only mode is parallel-capable here: CurrentGraphTenantId was switched
                    # to the managing tenant above, so the pre-warm inside the resolution helper
                    # targets the managing-tenant token cache key that runspaces will read.
                    $ManagingTenantCache = Invoke-EntraOpsParallelObjectResolution `
                        -UniqueObjects $AllCrossTenantUniqueObjects `
                        -TenantId $Global:ManagingTenantIdContext `
                        -EnableParallelProcessing $EnableParallelProcessing `
                        -ParallelThrottleLimit $ParallelThrottleLimit

                    $MergedCount = 0
                    foreach ($ObjectId in $ManagingTenantCache.Keys) {
                        $ResolvedDetails = $ManagingTenantCache[$ObjectId]
                        if ($null -eq $ResolvedDetails) { continue }
                        # Never overwrite a successful home-tenant resolution (e.g. remote tenant
                        # root group with display name) with a not-found 'unknown' placeholder.
                        $ExistingDetails = $ObjectDetailsCache[$ObjectId]
                        if ($ResolvedDetails.ObjectType -eq 'unknown' -and
                            $null -ne $ExistingDetails -and $ExistingDetails.ObjectType -ne 'unknown') {
                            Write-Verbose "Keeping home-tenant resolution for $ObjectId (managing tenant returned 'unknown')."
                            continue
                        }
                        $ObjectDetailsCache[$ObjectId] = $ResolvedDetails
                        $MergedCount++
                    }
                    Write-Host "Cross-tenant object resolution complete: $MergedCount of $($AllCrossTenantUniqueObjects.Count) object(s) resolved." -ForegroundColor Cyan
                }
                #endregion

                #region Post-process: populate TransitiveByObjectDisplayName from resolved cache
                foreach ($Entry in $NewTransitiveClassifications) {
                    if ($null -ne $Entry.TransitiveByObjectId -and $ObjectDetailsCache.ContainsKey($Entry.TransitiveByObjectId)) {
                        $Entry.TransitiveByObjectDisplayName = $ObjectDetailsCache[$Entry.TransitiveByObjectId].ObjectDisplayName
                    }
                }
                #endregion

            } catch {
                Write-Warning "Stage 5b cross-tenant processing failed: $($_.Exception.Message)"
                $WarningMessages.Add([PSCustomObject]@{
                        Type    = "Stage5b-CrossTenant"
                        Message = "Cross-tenant processing failed: $($_.Exception.Message)"
                    })
            } finally {
                # Always restore home-tenant MgGraph context regardless of success or failure.
                # Interactive: Connect-MgGraph -TenantId hits the MSAL cache (full-scope token from
                # Connect-EntraOps) — no prompt, no scope loss.
                # Non-interactive: Connect-MgGraph -AccessToken uses the Az app token which carries
                # full application permissions.
                try {
                    if ($IsRestOnlyMode) {
                        $__EntraOpsSession.Remove('CurrentGraphTenantId')
                    } elseif ($IsInteractiveAuth) {
                        Connect-MgGraph -TenantId $Global:TenantIdContext -Scopes $HomeTenantScopes -NoWelcome -ErrorAction Stop
                    } elseif ($null -ne $HomeToken) {
                        Connect-MgGraph -AccessToken $HomeToken -NoWelcome -ErrorAction Stop
                    }
                    Write-Verbose "Restored Microsoft Graph context to home tenant '$Global:TenantIdContext'."
                } catch {
                    Write-Warning "Failed to restore home tenant Microsoft Graph context: $($_.Exception.Message)"
                }
            }

            $Stage5bDuration = ((Get-Date) - $Stage5bStart).TotalSeconds
            Write-Host "✓ Stage 5b completed in $([Math]::Round($Stage5bDuration, 2)) seconds" -ForegroundColor Green
        }
    } else {
        Write-Verbose "Stage 5b: No cross-tenant groups or objects found — skipping."
    }
    #endregion
    #endregion

    #region Stage 6: Apply Global Exclusions and Finalize
    $Stage6Start = Get-Date
    Write-Host ""
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " Stage 6/6: Finalizing Results" -ForegroundColor Cyan
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host "Building final object collection, applying exclusions, and sorting results..." -ForegroundColor Gray
    Write-Progress -Activity "Stage 6/6: Finalizing Results" -Status "Processing classifications and building final dataset..." -PercentComplete 50

    # Aggregate classifications and build output objects
    $AadRbacClassifiedObjects = Invoke-EntraOpsEAMClassificationAggregation `
        -UniqueObjects $UniqueObjects `
        -ObjectDetailsCache $ObjectDetailsCache `
        -RbacClassificationsByObject $RbacAssignmentsByObject `
        -RoleSystem "EntraID" `
        -EnableParallelProcessing $EnableParallelProcessing `
        -ParallelThrottleLimit $ParallelThrottleLimit `
        -WarningMessages $WarningMessages

    Write-Progress -Activity "Stage 6/6: Finalizing Results" -Status "Applying global exclusions and sorting..." -PercentComplete 90
    $EamEntraId = $AadRbacClassifiedObjects | Where-Object { $GlobalExclusionList -notcontains $_.ObjectId }

    $Stage6Duration = ((Get-Date) - $Stage6Start).TotalSeconds
    $TotalDuration = ((Get-Date) - $Stage1Start).TotalSeconds

    Write-Progress -Activity "Stage 6/6: Finalizing Results" -Completed
    Write-Host "✓ Stage 6 completed in $([Math]::Round($Stage6Duration, 2)) seconds ($($EamEntraId.Count) privileged objects after exclusions)" -ForegroundColor Green

    Show-EntraOpsWarningSummary -WarningMessages $WarningMessages -IncludeObjectDetails $IncludeObjectDetails

    Write-Host ""
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Green
    Write-Host " ✓ All Stages Completed Successfully" -ForegroundColor Green
    Write-Host "═══════════════════════════════════════════════════════════════════════════════" -ForegroundColor Green
    Write-Host "Total execution time: $([Math]::Round($TotalDuration, 2)) seconds" -ForegroundColor Gray
    Write-Host "Cache statistics: $($__EntraOpsSession.GraphCache.Count) cached entries, $($__EntraOpsSession.CacheMetadata.Count) metadata entries" -ForegroundColor Gray
    Write-Host "Final result: $($EamEntraId.Count) privileged objects ready for export" -ForegroundColor Gray
    #endregion

    $EamEntraId | Where-Object { $null -ne $_.ObjectType -and $null -ne $_.ObjectId } | Set-EntraOpsEAMClassificationJustification -IncludeJustification:$IncludeJustification | Sort-Object ObjectAdminTierLevel, ObjectDisplayName
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDmf9yOYJ7dmbwr
# /IF2Cr3gR1ZfilUrwDlIt6daz/boSqCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# AEkoya49fu0nMA0GCSqGSIb3DQEBDAUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK
# ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2Vy
# dGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5l
# dHdvcmsgQ0EwHhcNMjEwNTMxMDY0MzA2WhcNMjkwOTE3MDY0MzA2WjCBgDELMAkG
# A1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAl
# BgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMb
# Q2VydHVtIFRydXN0ZWQgTmV0d29yayBDQSAyMIICIjANBgkqhkiG9w0BAQEFAAOC
# Ag8AMIICCgKCAgEAvfl4+ObVgAxknYYblmRnPyI6HnUBfe/7XGeMycxca6mR5rlC
# 5SBLm9qbe7mZXdmbgEvXhEArJ9PoujC7Pgkap0mV7ytAJMKXx6fumyXvqAoAl4Va
# qp3cKcniNQfrcE1K1sGzVrihQTib0fsxf4/gX+GxPw+OFklg1waNGPmqJhCrKtPQ
# 0WeNG0a+RzDVLnLRxWPa52N5RH5LYySJhi40PylMUosqp8DikSiJucBb+R3Z5yet
# /5oCl8HGUJKbAiy9qbk0WQq/hEr/3/6zn+vZnuCYI+yma3cWKtvMrTscpIfcRnNe
# GWJoRVfkkIJCu0LW8GHgwaM9ZqNd9BjuiMmNF0UpmTJ1AjHuKSbIawLmtWJFfzcV
# WiNoidQ+3k4nsPBADLxNF8tNorMe0AZa3faTz1d1mfX6hhpneLO/lv403L3nUlbl
# s+V1e9dBkQXcXWnjlQ1DufyDljmVe2yAWk8TcsbXfSl6RLpSpCrVQUYJIP4ioLZb
# MI28iQzV13D4h1L92u+sUS4Hs07+0AnacO+Y+lbmbdu1V0vc5SwlFcieLnhO+Nqc
# noYsylfzGuXIkosagpZ6w7xQEmnYDlpGizrrJvojybawgb5CAKT41v4wLsfSRvbl
# jnX98sy50IdbzAYQYLuDNbdeZ95H7JlI8aShFf6tjGKOOVVPORa5sWOd/7cCAwEA
# AaOCAT4wggE6MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLahVDkCw6A/joq8
# +tT4HKbROg79MB8GA1UdIwQYMBaAFAh2zcsH/yT2xc3tu5C84oQ3RnX3MA4GA1Ud
# DwEB/wQEAwIBBjAvBgNVHR8EKDAmMCSgIqAghh5odHRwOi8vY3JsLmNlcnR1bS5w
# bC9jdG5jYS5jcmwwawYIKwYBBQUHAQEEXzBdMCgGCCsGAQUFBzABhhxodHRwOi8v
# c3ViY2Eub2NzcC1jZXJ0dW0uY29tMDEGCCsGAQUFBzAChiVodHRwOi8vcmVwb3Np
# dG9yeS5jZXJ0dW0ucGwvY3RuY2EuY2VyMDkGA1UdIAQyMDAwLgYEVR0gADAmMCQG
# CCsGAQUFBwIBFhhodHRwOi8vd3d3LmNlcnR1bS5wbC9DUFMwDQYJKoZIhvcNAQEM
# BQADggEBAFHCoVgWIhCL/IYx1MIy01z4S6Ivaj5N+KsIHu3V6PrnCA3st8YeDrJ1
# BXqxC/rXdGoABh+kzqrya33YEcARCNQOTWHFOqj6seHjmOriY/1B9ZN9DbxdkjuR
# mmW60F9MvkyNaAMQFtXx0ASKhTP5N+dbLiZpQjy6zbzUeulNndrnQ/tjUoCFBMQl
# lVXwfqefAcVbKPjgzoZwpic7Ofs4LphTZSJ1Ldf23SIikZbr3WjtP6MZl9M7JYjs
# NhI9qX7OAo0FmpKnJ25FspxihjcNpDOO16hO0EoXQ0zF8ads0h5YbBRRfopUofbv
# n3l6XYGaFpAP4bvxSgD5+d2+7arszgowggZrMIIEU6ADAgECAhBX5gfP8LabvnxC
# XoItEBa/MA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhB
# c3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBDb2RlIFNp
# Z25pbmcgMjAyMSBDQTAeFw0yNjEwMDYwODI4MjNaFw0yNzEwMDYwODI4MjJaMIGQ
# MQswCQYDVQQGEwJERTEdMBsGA1UECAwUUmhpbmVsYW5kLVBhbGF0aW5hdGUxEjAQ
# BgNVBAcMCUxhaG5zdGVpbjEeMBwGA1UECgwVT3BlbiBTb3VyY2UgRGV2ZWxvcGVy
# MS4wLAYDVQQDDCVPcGVuIFNvdXJjZSBEZXZlbG9wZXIgVGhvbWFzIE5hdW5oZWlt
# MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAphp2ljSmVsRB6gvkEq7A
# DUMENpKZiDFoAfF1ktD1nCwaVyI+bJ0ROjHwAn4iJgS+vlBDPOUIw2GLHkiqUhOg
# HSwzg6gj45GNmt6zxsKP0zPlhMB/fb0zmz7/F7e3yfKCXLfhbHuk0Es9gJ+8Wmet
# 9StVwwSE/K0MpW06GKA9nPWyE4N/ADwigDIj9rsTKJR4cve9w8G1b3zohpUc/qMr
# onab+HVw5r9b3sOBnnaXC6zvrX4fovRz1ztwfWjRjILLoFG6mW/TuK5F2ICD3Rhl
# 9R2rJWWWhzTDMsQlzvX+/SaM74q3OFpIVNdhvtmWU9pKUBRAyYMijcDqKEFGSzrc
# 8JFmGMpP5TuroWF+N21d3px2eARvW1mV0DCfFAH+Og20SH/gVW6/ltQQEkH/z/Zt
# 49ARyJrgESLOQtlMCIYSoKISdkk4ml4VUS1E/PcbzXEYj3a7m6DOxcd3WuY03UT9
# cnk3p0LMN6LeLYAZgVY+HRloZlH+kW2SkOHNuWgtXvZhAgMBAAGjggF4MIIBdDAM
# BgNVHRMBAf8EAjAAMD0GA1UdHwQ2MDQwMqAwoC6GLGh0dHA6Ly9jY3NjYTIwMjEu
# Y3JsLmNlcnR1bS5wbC9jY3NjYTIwMjEuY3JsMHMGCCsGAQUFBwEBBGcwZTAsBggr
# BgEFBQcwAYYgaHR0cDovL2Njc2NhMjAyMS5vY3NwLWNlcnR1bS5jb20wNQYIKwYB
# BQUHMAKGKWh0dHA6Ly9yZXBvc2l0b3J5LmNlcnR1bS5wbC9jY3NjYTIwMjEuY2Vy
# MB8GA1UdIwQYMBaAFN10XUwA23ufoHTKsW73PMAywHDNMB0GA1UdDgQWBBTBvGyH
# MXj1dMbVnxuof9oP4PY7cDBLBgNVHSAERDBCMAgGBmeBDAEEATA2BgsqhGgBhvZ3
# AgUBBDAnMCUGCCsGAQUFBwIBFhlodHRwczovL3d3dy5jZXJ0dW0ucGwvQ1BTMBMG
# A1UdJQQMMAoGCCsGAQUFBwMDMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsF
# AAOCAgEAkC165c7/r6y3VeEEpNXas9bQs6ZpKPdJffHkf9xoPi4kWvAIWGc4ocG1
# DJLb18RXiDYShDmT5sb/6I08FeipKi2DNJkuOq6yOTJ3S7+hNFIZCyd6i7WKO8ea
# P5I+FAbyJP4+5coZoMg45bUa9JoocaJCm+5n7WiCfN7/EQpClW5Trm4+ZMbp7EMN
# F1uLJgGCMnN0SsoUarp7PHlqupMxMC4Uhhe+lAP2ytcSIU81L97/qjrADbLWqICk
# 9il5iGM+aZndguq/cmfH9rJj4GRZHWnG6WwnzYD9+KgYlRI6em+/9dq+IRiXe2Bs
# thUuIaFW7U2gWw8tBTEYm1UBLHdJx+L1gjJCLs5KT/wy5wU32wZxNDqPUQejUC1x
# GvU3cKqmLgRq+yNf3gFVfzqIoggyM3U9wQvnk7nBtCkpjXS2hdx4yxzcmrlWsDHr
# z6+6/jeyQySzbYUlFmKnc4n9Mw2soHOajEua5poFARVRS5KgUBuiiQ21YFNYzWBo
# qGuECaaAI/C5rGxapMJ3dAOBnHw6NLqBet+ZXBJEccX5y89MLlOQu+jDmJP8HieR
# qDLlwfhVZnw4RY8Rus43NZBwhNlVuOUXqocHKmJhKNlekda3qsJj93eQBQOxKJcc
# w5O08q9jukpH2+yzFAj+7ll33DuNUgWptogc4Awtbx3glzcEvJQwggaCMIIEaqAD
# AgECAhAo8HfBHDa9/l90MkdwJy4DMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYT
# AlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMT
# G0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQTAeFw0yNjAzMTEwNzM0NTRaFw0z
# NjAyMjcwNzM0NTRaMFAxCzAJBgNVBAYTAlBMMSEwHwYDVQQKDBhBc3NlY28gRGF0
# YSBTeXN0ZW1zIFMuQS4xHjAcBgNVBAMMFUNlcnR1bSBUaW1lc3RhbXAgMjAyNjCC
# AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALi9zfBw+xI12hi2F9uNQBNO
# STkTIi13fuh3DiWybI5392bBfJw6tO7zDs7UqP/rejVf4xFTU3XNOPsuOFkP5SPF
# snWwn3VJL+NhNhsuujvYsQtaQnvNo1wk46reTgws1L8OFEdKaxgyqT4zOQx81mLw
# NZSqXNU9Rrb9oAJWJF4Mydz+C1ebu0D0+vgM3tqAQ8TU203fqgKCPqIdHloaFqH9
# eyR+8tnpmy2hnnN59ZOtmsuHeRwf5iTOhlhf0d/qqquLOSIGPgGVL1Hj5XBUox2O
# E7tQRr+l/xxNbL6OuwBO4Aa2EbuxYg37cpXv5i2/lTL4NASB/duf9W4fjA3Ro+GP
# +bC54n2OYiM3Cq8z92ejx8F5HWO2e4/75eC95B9tMw/eq7109GDyW2+2ut5Bn0o8
# MXJTERkyystWbqcnUkomA97qfiDCwvpWuOMdfTOksXe9S+xDxOJaCbiyTivgPulx
# cT+tfQTRXBr5UwtPvc+INnafOEilzVHA11tv+pHcylryxBc08cUihf/ttqkZS0bm
# x3VxQay8uohaTp0//ECA9vYGW51SKdJMJhXvKyIdBLy8WYNchTt8yRITv9Adxg9J
# qGujh+TnP1bW3aQjFtlEl7SzgNeI8OVA/CJAWWUuRT3Au9ToOpHy/flrrknhN4he
# Ew7/6ILMv4HSsj0YZ4otAgMBAAGjggFQMIIBTDB1BggrBgEFBQcBAQRpMGcwOwYI
# KwYBBQUHMAKGL2h0dHA6Ly9zdWJjYS5yZXBvc2l0b3J5LmNlcnR1bS5wbC9jdHNj
# YTIwMjEuY2VyMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u
# Y29tMB8GA1UdIwQYMBaAFL5UAi+/QGxzQ86sCSVOnkNEGu7gMAwGA1UdEwEB/wQC
# MAAwOQYDVR0fBDIwMDAuoCygKoYoaHR0cDovL3N1YmNhLmNybC5jZXJ0dW0ucGwv
# Y3RzY2EyMDIxLmNybDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAOBgNVHQ8BAf8E
# BAMCB4AwIgYDVR0gBBswGTAIBgZngQwBBAIwDQYLKoRoAYb2dwIFAQswHQYDVR0O
# BBYEFCM5aiirmhKnpKfyH/lFmr+N/oIDMA0GCSqGSIb3DQEBDAUAA4ICAQBm/pOb
# rAveTqYFPCSthxNVwBHTW4TfuBQY2KGCdMlH6ALjVPYf2XARNGMTXScR0JQ2/c2L
# xLpoHHhRwxFf3d933DhFS165rYj6iPSQKm41rc9AMpTCjMdmai1aK9o870pOzTzL
# RhWFVIxm+5Qf+t4V5z14tPoUsAWdfRKD1Zzvzfe5AHlE9rxOZcGROiULxs3Edf7Z
# J8RZI0jzD9juj2oEX2XS7BVKuNCK1W0h1mjapv028yqo8UtZh5/1Ib48Yyp88BDY
# Shh5PSGUUuS5tYXVfcduIPBe3PdNTYRYdT5VNxkwLgjMzIcr/6SH2fejFJDjnCxj
# vXLuarErJg/IdI+hXdGIBDVOGv1zrkF4ktoQvFZIp5DIAYh0GwyUhc87qqn5UaYT
# j2+iyiBDSNh7aGCrkuAnqg7Qh7frhX+QhMGeJ9SpLpnG76qN85hLyjvYR6gRYiLT
# j8G4fxjqV400/buPw0rBo9yJZyq/8cGdCOLPOYf9qpefTcrZKeO9pg+UJHx8UABb
# aTqizRxm8sUSEycccCCFhIit3AI/3wGuSOADeiA676NcQ7+dsP04YvzvdN7pTZNy
# g672WpBDJdg0eItHl4yomvNeIOWh1qs4QqOW6TI8Ocsi4uSDTLorxLUDnBTwKQzz
# pxd1YUctEgRh32R0J9FkFkeEruJU94RljFr1uDCCBrkwggShoAMCAQICEQCZo4AK
# JlU7ZavcboSms+o5MA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAG
# A1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVt
# IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3Rl
# ZCBOZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjE4WhcNMzYwNTE4MDUzMjE4WjBW
# MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEu
# MSQwIgYDVQQDExtDZXJ0dW0gQ29kZSBTaWduaW5nIDIwMjEgQ0EwggIiMA0GCSqG
# SIb3DQEBAQUAA4ICDwAwggIKAoICAQCdI88EMCM7wUYs5zNzPmNdenW6vlxNur3r
# Lfi+5OZ+U3iZIB+AspO+CC/bj+taJUbMbFP1gQBJUzDUCPx7BNLgid1TyztVLn52
# NKgxxu8gpyTr6EjWyGzKU/gnIu+bHAse1LCitX3CaOE13rbuHbtrxF2tPU8f253Q
# gX6eO8yTbGps1Mg+yda3DcTsOYOhSYNCJiL+5wnjZ9weoGRtvFgMHtJg6i671OPX
# IciiHO4Lwo2p9xh/tnj+JmCQEn5QU0NxzrOiRna4kjFaA9ZcwSaG7WAxeC/xoZSx
# F1oK1UPZtKVt+yrsGKqWONoK6f5EmBOAVEK2y4ATDSkb34UD7JA32f+Rm0wsr5aj
# zftDhA5mBipVZDjHpwzv8bTKzCDUSUuUmPo1govD0RwFcTtMXcfJtm1i+P2UNXad
# PyYVKRxKQATHN3imsfBiNRdN5kiVVeqP55piqgxOkyt+HkwIA4gbmSc3hD8ke66t
# 9MjlcNg73rZZlrLHsAIV/nJ0mmgSjBI/TthoGJDydekOQ2tQD2Dup/+sKQptalDl
# ui59SerVSJg8gAeV7N/ia4mrGoiez+SqV3olVfxyLFt3o/OQOnBmjhKUANoKLYlK
# mUpKEFI0PfoT8Q1W/y6s9LTI6ekbi0igEbFUIBE8KDUGfIwnisEkBw5KcBZ3XwnH
# mfznwlKo8QIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
# 3XRdTADbe5+gdMqxbvc8wDLAcM0wHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61Pgc
# ptE6Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDAGA1Ud
# HwQpMCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYI
# KwYBBQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0
# dW0uY29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwv
# Y3RuY2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0
# cDovL3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQB1iFgP5Y9Q
# KJpTnxDsQ/z0O23JmoZifZdEOEmQvo/79PQg9nLF/GJe6ZiUBEyDBHMtFRK0mXj3
# Qv3gL0sYXe+PPMfwmreJHvgFGWQ7XwnfMh2YIpBrkvJnjwh8gIlNlUl4KENTK5DL
# qsYPEtRQCw7R6p4s2EtWyDDr/M58iY2UBEqfUU/ujR9NuPyKk0bEcEi62JGxauFY
# zZ/yld13fHaZskIoq2XazjaD0pQkcQiIueL0HKiohS6XgZuUtCKA7S6CHttZEsOb
# QJ1j2s0urIDdqF7xaXFVaTHKtAuMfwi0jXtF3JJphrJfc+FFILgCbX/uYBPBlbBI
# P4Ht4xxk2GmfzMn7oxPITpigQFJFWuzTMUUgdRHTxaTSKRJ/6Uh7ki/pFjf9sUAS
# WgxT69QF9Ki4JF5nBIujxZ2sOU9e1HSCJwOfK07t5nnzbs1LbHuAIGJsRJiQ6HX/
# DW1XFOlXY1rc9HufFhWU+7Uk+hFkJsfzqBz3pRO+5aI6u5abI4Qws4YaeJH7H7M8
# X/YNoaArZbV4Ql+jarKsE0+8XvC4DJB+IVcvC9Ydqahi09mjQse4fxfef0L7E3hh
# o2O3bLDM6v60rIRUCi2fJT2/IRU5ohgyTch4GuYWefSBsp5NPJh4QRTP9DC3gc5Q
# EKtbrTY0Ka87Web7/zScvLmvQBm8JDFpDjCCBrkwggShoAMCAQICEQDn/2nHOzXO
# S5Em2HR8aKWHMA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UE
# ChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl
# cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBO
# ZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjA3WhcNMzYwNTE4MDUzMjA3WjBWMQsw
# CQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQw
# IgYDVQQDExtDZXJ0dW0gVGltZXN0YW1waW5nIDIwMjEgQ0EwggIiMA0GCSqGSIb3
# DQEBAQUAA4ICDwAwggIKAoICAQDpEh8ENe25XXrFppVBvoplf0530W0lddNmjtv4
# YSh/f7eDQKFaIqc7tHj7ox+u8vIsJZlroakUeMS3i3T8aJRC+eQs4FF0GqvkM6+W
# ZO8kmzZfxmZaBYmMLs8FktgFYCzywmXeQ1fEExflee2OpbHVk665eXRHjH7MYZIz
# Nnjl2m8Hy8ulB9mR8wL/W0v0pjKNT6G0sfrx1kk+3OGosFUb7yWNnVkWKU4qSxLv
# 16kJ6oVJ4BSbZ4xMak6JLeB8szrK9vwGDpvGDnKCUMYL3NuviwH1x4gZG0JAXU3x
# 2pOAz91JWKJSAmRy/l0s0l5bEYKolg+DMqVhlOANd8Yh5mkQWaMEvBRE/kAGzIqg
# WhwzN2OsKIVtO8mf5sPWSrvyplSABAYa13rMYnzwfg08nljZHghquCJYCa/xHK9a
# cev9UD7Y+usr15d7mrszzxhF1JOr1Mpup2chNSBlyOObhlSO16rwrffVrg/SzaKf
# SndS5swRhr8bnDqNJY9TNyEYvBYpgF95K7p0g4LguR4A++Z1nFIHWVY5v0fNVZmg
# zxD9uVo/gta3onGOQj3JCxgYx0KrCXu4yc9QiVwTFLWbNdHFSjBCt5/8Q9pLuRhV
# ocdCunhcHudMS1CGQ/Rn0+7P+fzMgWdRKfEOh/hjLrnQ8BdJiYrZNxvIOhM2aa3z
# EDHNwwIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUvlQC
# L79AbHNDzqwJJU6eQ0Qa7uAwHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61PgcptE6
# Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMDAGA1UdHwQp
# MCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYIKwYB
# BQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u
# Y29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwvY3Ru
# Y2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0cDov
# L3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQC4k1l3yUwV/ZQH
# CKCneqAs8EGTnwEUJLdDpokN/dMhKjK0rR5qX8nIIHzxpQR3TAw2IRw1Uxsr2Pli
# G3bCFqSdQTUbfaTq6V3vBzEebDru9QFjqlKnxCF2h1jhLNFFplbPJiW+JSnJTh1f
# KEqEdKdxgl9rVTvlxfEJ7exOn25MGbd/wGPwuSmMxRJVO0wnqgS7kmoJjNF9zqee
# hFSDDP8ZVkWg4EZ2tIS0M3uZmByRr+1Lkwjjt8AtW83mVnZTyTsOb+FNfwJY7DS4
# FmWhkRbgcHRetreoTirPOr/ozyDKhT8MTSTf6Lttg6s6T/u08mDWw6HK04ZRDfQ9
# sb77QV8mKgO44WGP31vXnVKoWVJpFBjPvjL8/Zck/5wXX2iqjOaLStFOR/IQki+E
# hn4zlcgVm22ZVCBPF+l8nAwUUShCtKuSU7GmZLKCmmxQMkSiWILTm8EtVD6AxnJh
# oq8EnhjEEyUoflkeRF2WhFiVQOmWTwZRr44IxWGkNJC6tTorW5rl2Zl+2e9JLPYf
# 3pStAPMDoPKIjVXd6NW2+fZrNUBeDo2eOa5Fn7Brs/HLQff5Xgris5MeUbdVgDrF
# 8uxO6cLPvZPo63j62SsNg55pTWk9fUIF9iPoRbb4QurjoY/woI1RAOKtYtTic6aA
# Jq3u83RIPpGXBSJKwx4KJAOZnCDCtTGCBqIwggaeAgEBMGowVjELMAkGA1UEBhMC
# UEwxITAfBgNVBAoTGEFzc2VjbyBEYXRhIFN5c3RlbXMgUy5BLjEkMCIGA1UEAxMb
# Q2VydHVtIENvZGUgU2lnbmluZyAyMDIxIENBAhBX5gfP8LabvnxCXoItEBa/MA0G
# CWCGSAFlAwQCAQUAoIGEMBgGCisGAQQBgjcCAQwxCjAIoAKAAKECgAAwGQYJKoZI
# hvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcC
# ARUwLwYJKoZIhvcNAQkEMSIEIE/6GAg5Zu5OPJ7kLLixRAK2AkxjG/2CCpf19+ao
# 84MNMA0GCSqGSIb3DQEBAQUABIIBgBbopMYZCMZhOMLHcC3o/sohNVhxha2WMy0b
# waIwkD3GjsUlLhpzlW1c0Ivf/ZlyQl44Sp8lmWMTnJhOHfj46cgxdEDWzonm6sfZ
# uIVhjbjCdUGzvs0OckrBRGCvC/Om6nmmFuuPvcuk8bwPHvTyOtBvSQljZ9GYLLx/
# OpT12UWsdp1rAgwiGWcTRf82xko3p/l1qeQjc+LHt0XIgI60zF76qnvEE2L/deLp
# N0av40pQa6mJZ7nEVfWBZsy8OUSu3hOiNpJaLs0Sa7Gzf809P4SLmm3wEvOuzAgw
# h/ukOtqLGAp0CoAHvxmJ0tDa7yXkGSW/z2xyOB5wWWNIYcGx12mUtnI1P6ZAnZG9
# B8+pmvJUw+jWRbBxIkgXD0f2UA8IX1oL4iZE7E6vJHN1FQKQZagR/2GA2Imp9jfd
# PTOMoL1VXTS3Lp4iHaghIfTj5DFPgiX3JWoaXPny03W/AYnKheXzjrUMB6CAUstQ
# MewXKiKueuMrumYYcsDrhQCvGRKMl6GCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQxMThaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDBF03ve5Ufl+s+CzGQGPq7OLQ+LMxrKIm5Y
# LjJTjqUVJMO2onbWFC7S/09j4mI60McwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIATy5xhCyITiDahB0zgyCmv5n454ldKgbUx9nbif/DvLoX0tIX
# 2VvDVmz3UIElCOfZKKr6pYnU2MpqIeSMdaeq3NpM5ow3CFBMTZJFMk9Zd/AQF95X
# GXLDOVN6V/6sBwgMh4b52wmpi3Jl5JIzgLlAEnmngdTae8DMZfIZuCvKGBIiit11
# U9M1/T7y2Awr/FXzSzoYXyMq29tOlLMf1XAigzcI9GqM2lwZg6n0mgxd9wLkVKjA
# im6E/C+f9Oqo8YcD4hJMmqWY4Zji++HLv/G60PhNJ/Ot45/IdI3lNUFA8Xx2innF
# UL7UgjFeFmmlPPfAocvh+QmPO++4jAeHmGGiE9Jy47oshFPuOHoQskW+t6zm4FmO
# qAFNZ3q2B5sSMcejT9VYhCSZXuZvDd6Ow6CIfrW0QRTWRfbUJ9oF/TlM+BQprjGL
# FYSXOLsUJTE68Dxl3AiFIPLQwaqvhsBIrG3qCyjZANGITmxhwUXXeIE3pWa9KXDf
# XE8eJ29djMK3QStMu7w7EvCfnYZPihvyGj0qt6bP6KWPbpdqD/FeSSNaMipkL1oc
# BoYcHLMzpEKf688UXiDO/pV/wG38mgpLXfHHCXH1sZJ8JLNjxbqy1aZ/XAn6U8++
# f7pRnyhzzpHPcmtEODfG19gTDR0mXKGdgPts0Mso7mtwZdSG1trhicoQQIQ=
# SIG # End signature block