Public/Core/Invoke-EntraOpsMsGraphQuery.ps1

<#
.SYNOPSIS
    Executing Query on Microsoft Graph API.
 
.DESCRIPTION
    Wrapper to call Microsoft Graph API with pagination support to fetch all data and set default values.
 
.PARAMETER Method
    HTTP Method to be used for the request. Default is GET.
 
.PARAMETER Uri
    URI of the Microsoft Graph API to be called. Format of the URI should be /beta/ or /v1.0/ followed by the endpoint.
 
.PARAMETER Body
    Body of the request to be sent to the Microsoft Graph API.
 
.PARAMETER ConsistencyLevel
    Consistency level to be used for the request.
 
.PARAMETER OutputType
    Type of output to be returned. Default is HashTable.
    Other options are PSObject, HttpResponseMessage, and JSON.
 
.PARAMETER DisableCache
    Disable module-internal cache mechanism for the request.
 
.PARAMETER UseInvokeRestMethodOnly
    Use Invoke-RestMethod instead of Invoke-MgGraphRequest (Microsoft Graph SDK) for the request.
    The access token is taken from the module-private session store (set by Connect-EntraOps
    -MsGraphAccessToken) or acquired via Get-AzAccessToken from the current Az PowerShell context.
    Output, pagination, batching ($batch), caching and retry behavior are identical to the SDK path.
    If not set explicitly, the module-wide setting from Connect-EntraOps -UseInvokeRestMethodOnly applies.
 
.PARAMETER MaxRetries
    Maximum number of retry attempts for rate limiting (429) or transient errors (503, 504). Default is 5.
 
.PARAMETER InitialRetryDelay
    Initial delay in seconds before first retry. Default is 2 seconds. Uses adaptive backoff with Retry-After header.
 
.PARAMETER SuppressNotFoundWarning
    Downgrade the non-retryable-error warning to Write-Verbose specifically for HTTP 404 (NotFound)
    responses. Use for "probe for absence" lookups where a 404 is an expected, common outcome rather
    than a genuine failure.
 
.PARAMETER SuppressBadRequestWarning
    Downgrade the non-retryable-error warning to Write-Verbose specifically for HTTP 400 (BadRequest)
    responses. Use for capability probes where a 400 is an expected outcome (e.g. PIM for Groups
    eligibilitySchedules queries against groups that are not PIM-capable) rather than a genuine failure.
 
.PARAMETER SuppressForbiddenWarning
    Downgrade the non-retryable-error warning to Write-Verbose specifically for HTTP 403 (Forbidden)
    responses. Use together with -ThrowOnFailure at call sites that catch the error and emit their
    own, more actionable warning (e.g. naming the missing Graph permission), so the same failure is
    not reported twice.
 
.EXAMPLE
    Get list of all transitive role assignments for a principal in Microsoft Entra ID by principalId and using ConsistencyLevel.
    Invoke-EntraOpsMsGraphQuery -Uri "/beta/roleManagement/directory/transitiveRoleAssignments?`$count=true&`$filter=principalId eq '$Principal'" -ConsistencyLevel "eventual"
 
.EXAMPLE
    Get list of all role definitions in Microsoft Entra ID.
    Invoke-EntraOpsMsGraphQuery -Uri "/beta/roleManagement/directory/roleDefinitions"
 
.EXAMPLE
    Query with custom retry settings for high-traffic scenarios.
    Invoke-EntraOpsMsGraphQuery -Uri "/beta/users" -MaxRetries 7 -InitialRetryDelay 10
#>


function Invoke-EntraOpsMsGraphQuery {
    [CmdletBinding()]
    param (
        [parameter(Mandatory = $false)]
        [string]$Method = 'GET',

        [parameter(Mandatory = $true)]
        [string]$Uri,

        [parameter(Mandatory = $false)]
        [string]$Body,

        [parameter(Mandatory = $false)]
        [string]$ConsistencyLevel,

        [parameter(Mandatory = $false)]
        [ValidateSet("HashTable", "PSObject", "HttpResponseMessage", "Json")]
        [string]$OutputType = "HashTable",

        [Parameter(Mandatory = $false)]
        [switch]$DisableCache,

        [Parameter(Mandatory = $false)]
        [switch]$UseInvokeRestMethodOnly,

        [Parameter(Mandatory = $false)]
        [int]$MaxRetries = 5,

        [Parameter(Mandatory = $false)]
        [int]$InitialRetryDelay = 2,

        # Throw a terminating error instead of writing a non-terminating error and returning $null.
        # Use from callers whose output must never be built from a failed/incomplete result set - a
        # returned $null is indistinguishable from "no results", which silently narrows classification.
        [switch]$ThrowOnFailure,

        # Downgrade the non-retryable-error warning to Write-Verbose specifically for HTTP 404
        # (NotFound) responses. Use at "probe for absence" call sites where a 404 is an expected,
        # common outcome (e.g. checking whether an object has zero administrativeUnit memberships)
        # rather than a genuine failure - avoids alarming warning spam for normal states. Has no
        # effect on any other status code, and does not affect -ThrowOnFailure.
        [switch]$SuppressNotFoundWarning,

        # Same as SuppressNotFoundWarning but for HTTP 400 (BadRequest) - for capability probes
        # where Graph answers 400 for a normal state (e.g. eligibilitySchedules on a group that
        # is not PIM-capable).
        [switch]$SuppressBadRequestWarning,

        # Same as SuppressNotFoundWarning but for HTTP 403 (Forbidden) - for call sites that pair
        # it with -ThrowOnFailure and surface their own permission-specific warning instead.
        [switch]$SuppressForbiddenWarning,

        # Fetch only the first page and do not follow @odata.nextLink. Use for intentional
        # "sample the first N items" queries (e.g. $top probes) where full pagination is unwanted.
        [switch]$FirstPageOnly
    )

    # Fall back to the module-wide setting when not set explicitly: session store from
    # Connect-EntraOps -UseInvokeRestMethodOnly is authoritative, a user-set global variable
    # is honored as legacy fallback
    if (-not $PSBoundParameters.ContainsKey('UseInvokeRestMethodOnly')) {
        if ($__EntraOpsSession.ContainsKey('UseInvokeRestMethodOnly')) {
            $UseInvokeRestMethodOnly = [bool]$__EntraOpsSession['UseInvokeRestMethodOnly']
        } else {
            $UseInvokeRestMethodOnly = [bool]$Global:UseInvokeRestMethodOnly
        }
    }

    #region Helper - Acquire (and cache) a Microsoft Graph bearer token for the Invoke-RestMethod path
    function Get-EntraOpsMsGraphAccessToken {
        $Now = [DateTime]::UtcNow

        # Cross-tenant work (e.g. managing tenant group expansion/object resolution) sets
        # CurrentGraphTenantId in the session - the REST path must then use a token for THAT
        # tenant, mirroring what Connect-MgGraph context switching does for the SDK path
        $TargetTenantId = $__EntraOpsSession['CurrentGraphTenantId']
        $TokenKey = if ([string]::IsNullOrEmpty($TargetTenantId)) { 'default' } else { $TargetTenantId }

        if ($TokenKey -eq 'default') {
            # Prefer a token explicitly provided to Connect-EntraOps -MsGraphAccessToken (module-private
            # session store), e.g. workload identity scenarios where the Az context cannot mint new Graph
            # tokens itself. Falls through to Get-AzAccessToken once the provided token has expired.
            # Only valid for the home/target tenant - never for cross-tenant requests.
            $Provided = $__EntraOpsSession.MsGraphTokenCache['provided']
            if ($null -ne $Provided -and $Provided.Expiry -gt $Now) {
                return $Provided.Token
            }

            # Legacy fallback: global variable set by callers before the module-private store existed
            if (-not [string]::IsNullOrEmpty($Global:MsGraphAccessToken)) {
                # Deprecated - warn once per module load, keep working for backward compatibility
                if (-not $Script:MsGraphGlobalTokenFallbackWarned) {
                    Write-Warning "Reading the Microsoft Graph access token from `$Global:MsGraphAccessToken is deprecated and will be removed in a future release. Pass the token via Connect-EntraOps -MsGraphAccessToken instead."
                    $Script:MsGraphGlobalTokenFallbackWarned = $true
                }
                return $Global:MsGraphAccessToken
            }
        }

        $Cached = $__EntraOpsSession.MsGraphTokenCache[$TokenKey]
        # Re-use a cached token while it is valid for at least another 5 minutes
        if ($null -ne $Cached -and $Cached.Expiry -gt $Now.AddMinutes(5)) {
            return $Cached.Token
        }

        $TokenParams = @{ ResourceTypeName = 'MSGraph'; AsSecureString = $true; ErrorAction = 'Stop' }
        if ($TokenKey -ne 'default') { $TokenParams.TenantId = $TargetTenantId }
        $TokenResponse = Get-AzAccessToken @TokenParams
        $PlainToken = $TokenResponse.Token | ConvertFrom-SecureString -AsPlainText
        $Expiry = if ($TokenResponse.ExpiresOn) { $TokenResponse.ExpiresOn.UtcDateTime } else { $Now.AddMinutes(30) }
        $__EntraOpsSession.MsGraphTokenCache[$TokenKey] = @{ Token = $PlainToken; Expiry = $Expiry }
        return $PlainToken
    }
    #endregion

    $HeaderParams = @{}

    # Check if the Uri is valid.
    if ($Uri -like "/beta/*" -or $Uri -like "/v1.0/*") {
        $Uri = "https://graph.microsoft.com$Uri"
    } elseif ($Uri -like "https://graph.microsoft.com/*") {
    } else {
        throw "Invalid Graph URI: $($Uri)!"
    }

    # Add ConsistencyLevel if provided in parameter
    if ($null -ne $ConsistencyLevel) {
        $HeaderParams.Add('ConsistencyLevel', "$ConsistencyLevel")
    }

    # Check cache property for the Uri
    $isBatch = $Uri.EndsWith('$batch')
    $isMethodGet = $Method -eq 'GET'
    $isCacheablePost = ($Method -eq 'POST' -and ($Uri -like "*/getByIds*" -or $Uri -like "*/validateProperties"))

    # Cache keys must be scoped per tenant: $__EntraOpsSession is a single module-scope
    # hashtable shared across the whole process, so without a tenant discriminator, a
    # Graph URI (e.g. role definitions) queried for Tenant A would be served back for
    # Tenant B if both are processed in the same session/runspace (sequential loops,
    # or worker-process/runspace reuse in Azure Functions). Prefer the actual
    # authenticated Graph context's TenantId (reflects the token actually used for the
    # call); fall back to the Connect-EntraOps global context if unavailable.
    # A cross-tenant override set by managing-tenant code paths takes precedence: in REST-only
    # mode Get-MgContext does not reflect the tenant actually queried by Invoke-RestMethod
    $CacheTenantId = $__EntraOpsSession['CurrentGraphTenantId']
    if ([string]::IsNullOrEmpty($CacheTenantId)) {
        try {
            $CacheTenantId = (Get-MgContext -ErrorAction Stop).TenantId
        } catch {
            Write-Verbose "Unable to determine current Graph tenant context for cache scoping: $_"
        }
    }
    if ([string]::IsNullOrEmpty($CacheTenantId)) {
        $CacheTenantId = $Global:TenantIdContext
    }
    if ([string]::IsNullOrEmpty($CacheTenantId)) {
        $CacheTenantId = "default"
    }

    if ($isCacheablePost -and $null -ne $Body) {
        # Create hash of body to ensure unique cache key for POST requests
        $BodyBytes = [System.Text.Encoding]::UTF8.GetBytes($Body)
        $Sha256 = [System.Security.Cryptography.SHA256]::Create()
        try {
            $BodyHash = [BitConverter]::ToString($Sha256.ComputeHash($BodyBytes)).Replace("-", "")
        } finally {
            $Sha256.Dispose()
        }
        $cacheKey = "$CacheTenantId#$Uri#$BodyHash"
    } else {
        $cacheKey = "$CacheTenantId#$Uri"
    }

    try {
        $isInCache = $__EntraOpsSession.GraphCache.ContainsKey($cacheKey)
    } catch {
        Write-Verbose "Cache is empty"
    }
    
    # Determine if this is static reference data (longer TTL)
    $IsStaticData = $Uri -match "roleDefinitions|directoryRoleTemplates|permissionGrants|appRoles|publishedPermissionScopes"
    $CacheTTLSeconds = if ($IsStaticData) { $__EntraOpsSession.StaticDataCacheTTL } else { $__EntraOpsSession.DefaultCacheTTL }

    # Check if Cache can be used and data is available in cache
    # Enhanced logic with TTL support for improved cache management
    $CacheIsValid = $false
    if (!$DisableCache -and !$isBatch -and $isInCache -and ($isMethodGet -or $isCacheablePost)) {
        # Check if cache entry has expired
        if ($__EntraOpsSession.CacheMetadata.ContainsKey($cacheKey)) {
            $CacheEntry = $__EntraOpsSession.CacheMetadata[$cacheKey]
            $CurrentTime = [DateTime]::UtcNow
            
            if ($CurrentTime -lt $CacheEntry.ExpiryTime) {
                $CacheIsValid = $true
                $TimeRemaining = ($CacheEntry.ExpiryTime - $CurrentTime).TotalSeconds
                Write-Verbose ("Using valid graph cache: $($cacheKey) (expires in $([Math]::Round($TimeRemaining, 0))s)")
                $QueryResult = $__EntraOpsSession.GraphCache[$cacheKey]
            } else {
                Write-Verbose ("Cache expired for: $($cacheKey), fetching fresh data")
                $__EntraOpsSession.GraphCache.Remove($cacheKey)
                $__EntraOpsSession.CacheMetadata.Remove($cacheKey)
                $isInCache = $false
            }
        } else {
            # Legacy cache entry without metadata, use it but stamp metadata so it expires after the regular TTL
            Write-Verbose ("Using legacy graph cache (no TTL): $($cacheKey), stamping TTL metadata ($($CacheTTLSeconds)s)")
            $CacheIsValid = $true
            $QueryResult = $__EntraOpsSession.GraphCache[$cacheKey]
            $LegacyCachedTime = [DateTime]::UtcNow
            $__EntraOpsSession.CacheMetadata[$cacheKey] = @{
                Uri          = $Uri
                CachedTime   = $LegacyCachedTime
                ExpiryTime   = $LegacyCachedTime.AddSeconds($CacheTTLSeconds)
                TTLSeconds   = $CacheTTLSeconds
                IsStaticData = $IsStaticData
                ResultCount  = if ($QueryResult -is [System.Collections.ICollection]) { $QueryResult.Count } else { 1 }
            }
        }
    }

    if (!$QueryResult) {
        # Create empty arrays to store the results
        $QueryRequest = @()
        $QueryResult = New-Object System.Collections.Generic.List[Object]

        if ($UseInvokeRestMethodOnly) {
            Write-Verbose -Message "Using Invoke-RestMethod Cmdlet"
            try {
                $HeaderParams['Authorization'] = "Bearer $(Get-EntraOpsMsGraphAccessToken)"
            } catch {
                throw "UseInvokeRestMethodOnly is enabled but no Microsoft Graph access token could be acquired. Provide -MsGraphAccessToken to Connect-EntraOps or authenticate with Az PowerShell (Connect-AzAccount). Error: $($_.Exception.Message)"
            }

            $RetryCount = 0
            $Success = $false
            
            while (-not $Success -and $RetryCount -le $MaxRetries) {
                try {
                    # A retry restarts from the first page, so anything collected by the failed attempt must be
                    # discarded first. Without this, a mid-pagination failure retried at this level re-appends
                    # the pages already collected and silently duplicates results.
                    $QueryResult.Clear()

                    # Run the initial query to Graph API
                    if ($Method -eq 'GET') {
                        $QueryRequest = Invoke-RestMethod -Headers $HeaderParams -Uri $Uri -Method $Method -ContentType "application/json" -ResponseHeadersVariable 'ResponseMessage'
                    } else {
                        $QueryRequest = Invoke-RestMethod -Headers $HeaderParams -Uri $Uri -Method $Method -ContentType "application/json" -Body $Body -ResponseHeadersVariable 'ResponseMessage'
                    }

                    # Add the initial query result to the result array
                    if ($null -ne $QueryRequest.value) {
                        $QueryResult.AddRange(@($QueryRequest.value))
                    } else {
                        $QueryResult.Add($QueryRequest)
                    }

                    # Run another query to fetch data until there are no pages left
                    if (-not $FirstPageOnly) {
                        while ($QueryRequest.'@odata.nextLink') {
                            # Pagination retry logic
                            $PageRetryCount = 0
                            $PageSuccess = $false
                            
                            while (-not $PageSuccess -and $PageRetryCount -le 3) {
                                try {
                                    $QueryRequest = Invoke-RestMethod -Headers $HeaderParams -Uri $QueryRequest.'@odata.nextLink' -Method $Method -ContentType "application/json" -ResponseHeadersVariable 'ResponseMessage'
                                    $QueryResult.AddRange(@($QueryRequest.value))
                                    $PageSuccess = $true
                                } catch {
                                    $PageStatusCode = $_.Exception.Response.StatusCode.value__
                                    
                                    if ($PageStatusCode -in @(429, 503, 504) -and $PageRetryCount -lt 3) {
                                        $PageRetryCount++
                                        
                                        # Try to extract Retry-After from pagination response
                                        $PageRetryAfter = $null
                                        try {
                                            if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) {
                                                $PageRetryAfter = [int]$_.Exception.Response.Headers['Retry-After']
                                            }
                                        } catch { }
                                        
                                        if ($null -ne $PageRetryAfter -and $PageRetryAfter -gt 0) {
                                            $PageDelay = $PageRetryAfter
                                        } else {
                                            $PageDelay = $InitialRetryDelay * [Math]::Pow(2, $PageRetryCount - 1)
                                            $PageDelay = [Math]::Min($PageDelay, 30)
                                        }
                                        
                                        # Add jitter for pagination
                                        $PageJitter = $PageDelay * 0.2 * (Get-Random -Minimum -1.0 -Maximum 1.0)
                                        $PageDelay = [Math]::Max(1, $PageDelay + $PageJitter)
                                        
                                        # Track pagination retry (silent)
                                        Add-EntraOpsRetryStatistic -Name TotalRetries
                                        Add-EntraOpsRetryStatistic -Name ThrottledRequests
                                        
                                        Write-Verbose "Pagination hit rate limit (HTTP $PageStatusCode). Retry $PageRetryCount/3 in $([Math]::Round($PageDelay, 1))s"
                                        Start-Sleep -Seconds $PageDelay
                                    } else {
                                        throw
                                    }
                                }
                            }
                            
                            if (-not $PageSuccess) {
                                throw "Failed to retrieve paginated results after $PageRetryCount retries"
                            }
                        }
                    }

                    switch ($OutputType) {
                        # Case-insensitive conversion for parity with Invoke-MgGraphRequest hashtables
                        # (ConvertFrom-Json -AsHashtable would create case-SENSITIVE keys and break
                        # consumers accessing properties with different casing, e.g. .Id vs "id")
                        HashTable { $QueryResult = @($QueryResult | ForEach-Object { ConvertTo-EntraOpsCaseInsensitiveHashtable -InputObject $_ }) }
                        JSON { $QueryResult = $QueryResult | ConvertTo-Json -Depth 10 }
                        PSObject { $QueryResult = $QueryResult }
                        HttpResponseMessage { $QueryResult = $ResponseMessage }
                    }
                    
                    $Success = $true
                    $QueryResult
                    
                } catch {
                    $StatusCode = $_.Exception.Response.StatusCode.value__
                    $IsNetworkError = $false

                    # Detect network/connection errors that should be retried
                    if ($null -eq $StatusCode -and $_.Exception.Message -match 'An error occurred while sending the request|The operation has timed out|Unable to connect|Connection reset') {
                        $IsNetworkError = $true
                    }

                    # An expired token (401) is retryable when the token came from Get-AzAccessToken
                    # (session cache can be refreshed); a statically provided token (Connect-EntraOps
                    # -MsGraphAccessToken or legacy global variable) cannot be renewed. Static tokens
                    # only ever serve default-tenant requests - cross-tenant tokens always refresh.
                    $RefreshTenantId = $__EntraOpsSession['CurrentGraphTenantId']
                    $RefreshTokenKey = if ([string]::IsNullOrEmpty($RefreshTenantId)) { 'default' } else { $RefreshTenantId }
                    $ProvidedToken = $__EntraOpsSession.MsGraphTokenCache['provided']
                    $HasStaticToken = ($RefreshTokenKey -eq 'default') -and (($null -ne $ProvidedToken -and $ProvidedToken.Expiry -gt [DateTime]::UtcNow) -or (-not [string]::IsNullOrEmpty($Global:MsGraphAccessToken)))
                    $IsTokenRefreshable = ($StatusCode -eq 401 -and -not $HasStaticToken)

                    # Retry logic for rate limiting, transient errors, and network errors
                    if (($StatusCode -in @(429, 503, 504) -or $IsNetworkError -or $IsTokenRefreshable) -and $RetryCount -lt $MaxRetries) {
                        $RetryCount++

                        if ($IsTokenRefreshable) {
                            $__EntraOpsSession.MsGraphTokenCache.Remove($RefreshTokenKey)
                            try {
                                $HeaderParams['Authorization'] = "Bearer $(Get-EntraOpsMsGraphAccessToken)"
                                Write-Verbose "Graph access token expired (HTTP 401), acquired a fresh token for retry"
                            } catch {
                                Write-Verbose "Failed to refresh Graph access token: $($_.Exception.Message)"
                            }
                        }

                        # Try to extract Retry-After header from response
                        $RetryAfter = $null
                        try {
                            if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) {
                                $RetryAfter = [int]$_.Exception.Response.Headers['Retry-After']
                                Write-Verbose "Graph API provided Retry-After: ${RetryAfter}s"
                            }
                        } catch {
                            Write-Verbose "Could not extract Retry-After header: $_"
                        }
                        
                        # Use Retry-After if available, otherwise adaptive exponential backoff
                        if ($null -ne $RetryAfter -and $RetryAfter -gt 0) {
                            $RetryDelay = $RetryAfter
                        } else {
                            $RetryDelay = $InitialRetryDelay * [Math]::Pow(2, $RetryCount - 1)
                            $RetryDelay = [Math]::Min($RetryDelay, 60)
                        }
                        
                        # Add jitter (±20%) to prevent thundering herd
                        $Jitter = $RetryDelay * 0.2 * (Get-Random -Minimum -1.0 -Maximum 1.0)
                        $RetryDelay = [Math]::Max(1, $RetryDelay + $Jitter)
                        
                        # Track retry statistics (silent - no warning spam)
                        Add-EntraOpsRetryStatistic -Name TotalRetries
                        if ($IsNetworkError) {
                            Add-EntraOpsRetryStatistic -Name ThrottledRequests
                            Write-Verbose "Network error. Retry $RetryCount/$MaxRetries in $([Math]::Round($RetryDelay, 1))s for: $Uri"
                        } else {
                            Add-EntraOpsRetryStatistic -Name ThrottledRequests
                            Write-Verbose "Graph API throttled (HTTP $StatusCode). Retry $RetryCount/$MaxRetries in $([Math]::Round($RetryDelay, 1))s for: $Uri"
                        }
                        Write-Verbose "Error details: $($_.Exception.Message)"
                        
                        Start-Sleep -Seconds $RetryDelay
                    } else {
                        if ($RetryCount -ge $MaxRetries) {
                            Add-EntraOpsRetryStatistic -Name FailedRequests
                            $__EntraOpsSession.RetryStatistics.FailedRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $Uri; StatusCode = $StatusCode; ErrorMessage = "$($_.Exception.Message) (after $MaxRetries retry attempts)" })
                            # Intended non-terminating (followed by return $null below); -ErrorAction Continue keeps
                            # it non-terminating despite the module-wide $ErrorActionPreference = "Stop"
                            Write-Error "Failed to execute $Uri after $MaxRetries retry attempts. Error: $($_.Exception.Message)" -ErrorAction Continue
                        } else {
                            Add-EntraOpsRetryStatistic -Name NonRetryableRequests
                            $__EntraOpsSession.RetryStatistics.NonRetryableRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $Uri; StatusCode = $StatusCode; ErrorMessage = $_.Exception.Message })
                            if (($SuppressNotFoundWarning -and $StatusCode -eq 404) -or ($SuppressBadRequestWarning -and $StatusCode -eq 400) -or ($SuppressForbiddenWarning -and $StatusCode -eq 403)) {
                                Write-Verbose "Failed to execute $Uri (expected $StatusCode, warning suppressed). Error: $($_.Exception.Message)"
                            } else {
                                Write-Warning "Failed to execute $Uri (non-retryable error). Error: $($_.Exception.Message)"
                            }
                        }
                        if ($ThrowOnFailure) {
                            $GraphException = [System.InvalidOperationException]::new("Microsoft Graph query '$Uri' failed: $($_.Exception.Message)", $_.Exception)
                            $GraphException.Data['StatusCode'] = $StatusCode
                            $GraphException.Data['Uri'] = $Uri
                            throw $GraphException
                        }
                        return $null
                    }
                }
            }

            if (-not $Success) {
                Add-EntraOpsRetryStatistic -Name FailedRequests
                $__EntraOpsSession.RetryStatistics.FailedRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $Uri; StatusCode = $null; ErrorMessage = "Persistent rate limiting" })
                if ($ThrowOnFailure) { throw "Microsoft Graph query '$Uri' failed after $MaxRetries retry attempts due to persistent rate limiting." }
                # Intended non-terminating (followed by return $null); -ErrorAction Continue keeps it
                # non-terminating despite the module-wide $ErrorActionPreference = "Stop"
                Write-Error "Failed to execute $Uri after $MaxRetries retry attempts due to persistent rate limiting." -ErrorAction Continue
                return $null
            }
        } else {
            Write-Verbose -Message "Using Invoke-MgGraphRequest Cmdlet"

            $RetryCount = 0
            $Success = $false
            
            while (-not $Success -and $RetryCount -le $MaxRetries) {
                try {
                    # A retry restarts from the first page, so anything collected by the failed attempt must be
                    # discarded first. Without this, a mid-pagination failure retried at this level re-appends
                    # the pages already collected and silently duplicates results.
                    $QueryResult.Clear()

                    # Run the initial query to Graph API
                    if ($Method -eq 'GET') {
                        $QueryRequest = Invoke-MgGraphRequest -Headers $HeaderParams -Uri $Uri -Method $Method -ContentType "application/json" -OutputType $OutputType
                    } else {
                        $QueryRequest = Invoke-MgGraphRequest -Headers $HeaderParams -Uri $Uri -Method $Method -ContentType "application/json" -Body $Body -OutputType $OutputType
                    }

                    # Add the initial query result to the result array
                    if ($null -ne $QueryRequest.value) {
                        $QueryResult.AddRange(@($QueryRequest.value))
                    } else {
                        $QueryResult.Add($QueryRequest)
                    }

                    # Run another query to fetch data until there are no pages left
                    if (-not $FirstPageOnly) {
                        while ($QueryRequest.'@odata.nextLink') {
                            # Pagination can also hit rate limits, wrap in retry logic
                            $PageRetryCount = 0
                            $PageSuccess = $false
                            
                            while (-not $PageSuccess -and $PageRetryCount -le 3) {
                                try {
                                    $QueryRequest = Invoke-MgGraphRequest -Headers $HeaderParams -Uri $QueryRequest.'@odata.nextLink' -Method $Method -ContentType "application/json" -OutputType $OutputType
                                    $QueryResult.AddRange(@($QueryRequest.value))
                                    $PageSuccess = $true
                                } catch {
                                    $PageStatusCode = $null
                                    if ($_.Exception.Response) {
                                        $PageStatusCode = $_.Exception.Response.StatusCode.value__
                                    } elseif ($_.Exception.Message -match 'TooManyRequests|429') {
                                        $PageStatusCode = 429
                                    }
                                    
                                    if ($PageStatusCode -in @(429, 503, 504) -and $PageRetryCount -lt 3) {
                                        $PageRetryCount++
                                        
                                        # Try to extract Retry-After from pagination response
                                        $PageRetryAfter = $null
                                        try {
                                            if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) {
                                                $PageRetryAfter = [int]$_.Exception.Response.Headers['Retry-After']
                                            }
                                        } catch { }
                                        
                                        if ($null -ne $PageRetryAfter -and $PageRetryAfter -gt 0) {
                                            $PageDelay = $PageRetryAfter
                                        } else {
                                            $PageDelay = $InitialRetryDelay * [Math]::Pow(2, $PageRetryCount - 1)
                                            $PageDelay = [Math]::Min($PageDelay, 30)
                                        }
                                        
                                        # Add jitter for pagination
                                        $PageJitter = $PageDelay * 0.2 * (Get-Random -Minimum -1.0 -Maximum 1.0)
                                        $PageDelay = [Math]::Max(1, $PageDelay + $PageJitter)
                                        
                                        # Track pagination retry (silent)
                                        Add-EntraOpsRetryStatistic -Name TotalRetries
                                        Add-EntraOpsRetryStatistic -Name ThrottledRequests
                                        
                                        Write-Verbose "Pagination hit rate limit (HTTP $PageStatusCode). Retry $PageRetryCount/3 in $([Math]::Round($PageDelay, 1))s"
                                        Start-Sleep -Seconds $PageDelay
                                    } else {
                                        throw
                                    }
                                }
                            }
                            
                            if (-not $PageSuccess) {
                                throw "Failed to retrieve paginated results after $PageRetryCount retries"
                            }
                        }
                    }
                    
                    $Success = $true
                    $QueryResult
                    
                } catch {
                    # Extract status code from exception
                    $StatusCode = $null
                    $IsNetworkError = $false
                    
                    if ($_.Exception.Response) {
                        $StatusCode = $_.Exception.Response.StatusCode.value__
                    } elseif ($_.Exception.Message -match 'TooManyRequests|429') {
                        $StatusCode = 429
                    } elseif ($_.Exception.Message -match 'ServiceUnavailable|503') {
                        $StatusCode = 503
                    } elseif ($_.Exception.Message -match 'GatewayTimeout|504') {
                        $StatusCode = 504
                    } elseif ($_.Exception.Message -match 'An error occurred while sending the request|The operation has timed out|Unable to connect|Connection reset') {
                        $IsNetworkError = $true
                    }
                    
                    # Retry logic for rate limiting, transient errors, and network errors
                    if (($StatusCode -in @(429, 503, 504) -or $IsNetworkError) -and $RetryCount -lt $MaxRetries) {
                        $RetryCount++
                        
                        # Try to extract Retry-After header from response
                        $RetryAfter = $null
                        try {
                            if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) {
                                $RetryAfter = [int]$_.Exception.Response.Headers['Retry-After']
                                Write-Verbose "Graph API provided Retry-After: ${RetryAfter}s"
                            }
                        } catch {
                            Write-Verbose "Could not extract Retry-After header: $_"
                        }
                        
                        # Use Retry-After if available, otherwise adaptive exponential backoff
                        if ($null -ne $RetryAfter -and $RetryAfter -gt 0) {
                            $RetryDelay = $RetryAfter
                        } else {
                            # Adaptive backoff: 2s, 4s, 8s, 16s, 32s
                            $RetryDelay = $InitialRetryDelay * [Math]::Pow(2, $RetryCount - 1)
                            # Cap at 60 seconds (Graph API rarely needs more)
                            $RetryDelay = [Math]::Min($RetryDelay, 60)
                        }
                        
                        # Add jitter (±20%) to prevent thundering herd
                        $Jitter = $RetryDelay * 0.2 * (Get-Random -Minimum -1.0 -Maximum 1.0)
                        $RetryDelay = [Math]::Max(1, $RetryDelay + $Jitter)
                        
                        # Track retry statistics (silent - no warning spam)
                        Add-EntraOpsRetryStatistic -Name TotalRetries
                        if ($IsNetworkError) {
                            Add-EntraOpsRetryStatistic -Name ThrottledRequests
                            Write-Verbose "Network error. Retry $RetryCount/$MaxRetries in $([Math]::Round($RetryDelay, 1))s for: $Uri"
                        } else {
                            Add-EntraOpsRetryStatistic -Name ThrottledRequests
                            Write-Verbose "Graph API throttled (HTTP $StatusCode). Retry $RetryCount/$MaxRetries in $([Math]::Round($RetryDelay, 1))s for: $Uri"
                        }
                        Write-Verbose "Error details: $($_.Exception.Message)"
                        
                        Start-Sleep -Seconds $RetryDelay
                    } else {
                        # Non-retryable error or max retries exceeded
                        if ($RetryCount -ge $MaxRetries) {
                            Add-EntraOpsRetryStatistic -Name FailedRequests
                            $__EntraOpsSession.RetryStatistics.FailedRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $Uri; StatusCode = $StatusCode; ErrorMessage = "$($_.Exception.Message) (after $MaxRetries retry attempts)" })
                            # Intended non-terminating (followed by return $null below); -ErrorAction Continue keeps
                            # it non-terminating despite the module-wide $ErrorActionPreference = "Stop"
                            Write-Error "Failed to execute $Uri after $MaxRetries retry attempts. Error: $($_.Exception.Message)" -ErrorAction Continue
                        } else {
                            Add-EntraOpsRetryStatistic -Name NonRetryableRequests
                            $__EntraOpsSession.RetryStatistics.NonRetryableRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $Uri; StatusCode = $StatusCode; ErrorMessage = $_.Exception.Message })
                            if (($SuppressNotFoundWarning -and $StatusCode -eq 404) -or ($SuppressBadRequestWarning -and $StatusCode -eq 400) -or ($SuppressForbiddenWarning -and $StatusCode -eq 403)) {
                                Write-Verbose "Failed to execute $Uri (expected $StatusCode, warning suppressed). Error: $($_.Exception.Message)"
                            } else {
                                Write-Warning "Failed to execute $Uri (non-retryable error). Error: $($_.Exception.Message)"
                            }
                        }
                        if ($ThrowOnFailure) {
                            $GraphException = [System.InvalidOperationException]::new("Microsoft Graph query '$Uri' failed: $($_.Exception.Message)", $_.Exception)
                            $GraphException.Data['StatusCode'] = $StatusCode
                            $GraphException.Data['Uri'] = $Uri
                            throw $GraphException
                        }

                        # Return empty result instead of throwing to allow function to continue
                        return $null
                    }
                }
            }
            
            if (-not $Success) {
                Add-EntraOpsRetryStatistic -Name FailedRequests
                $__EntraOpsSession.RetryStatistics.FailedRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $Uri; StatusCode = $null; ErrorMessage = "Persistent rate limiting" })
                if ($ThrowOnFailure) { throw "Microsoft Graph query '$Uri' failed after $MaxRetries retry attempts due to persistent rate limiting." }
                # Intended non-terminating (followed by return $null); -ErrorAction Continue keeps it
                # non-terminating despite the module-wide $ErrorActionPreference = "Stop"
                Write-Error "Failed to execute $Uri after $MaxRetries retry attempts due to persistent rate limiting." -ErrorAction Continue
                return $null
            }
        }
        # Updating cache with TTL metadata
        if ($QueryResult -and !$isBatch -and ($isMethodGet -or $isCacheablePost)) {
            $CurrentTime = [DateTime]::UtcNow
            $ExpiryTime = $CurrentTime.AddSeconds($CacheTTLSeconds)
            
            # Assignment is idempotent when parallel requests populate the same key.
            $__EntraOpsSession.GraphCache[$cacheKey] = $QueryResult
            
            # Update or add cache metadata with TTL
            $CacheMetadataEntry = @{
                Uri          = $Uri
                CachedTime   = $CurrentTime
                ExpiryTime   = $ExpiryTime
                TTLSeconds   = $CacheTTLSeconds
                IsStaticData = $IsStaticData
                ResultCount  = if ($QueryResult -is [System.Collections.ICollection]) { $QueryResult.Count } else { 1 }
            }
            
            $__EntraOpsSession.CacheMetadata[$cacheKey] = $CacheMetadataEntry
            
            Write-Verbose "Cached result for $($cacheKey) (TTL: $($CacheTTLSeconds)s, Count: $($CacheMetadataEntry.ResultCount))"
            
            # Optionally persist static data to disk for cross-session caching
            if ($IsStaticData -and (Test-Path $__EntraOpsSession.PersistentCachePath)) {
                try {
                    $CacheFileName = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cacheKey)) + ".json"
                    $CacheFilePath = Join-Path $__EntraOpsSession.PersistentCachePath $CacheFileName
                    
                    $PersistentCacheObject = @{
                        Uri        = $Uri
                        CachedTime = $CurrentTime.ToString("o")
                        ExpiryTime = $ExpiryTime.ToString("o")
                        Data       = $QueryResult
                    }
                    
                    $PersistentCacheObject | ConvertTo-Json -Depth 10 -Compress | Out-File -FilePath $CacheFilePath -Force
                    Write-Verbose "Persisted static data cache to: $CacheFileName"
                } catch {
                    Write-Verbose "Failed to persist cache to disk: $_"
                }
            }
        }
    } else {
        $QueryResult
    }
}
# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCB2e3rzahbVO5sv
# O1wkskRbKAr2OObWBglK0PdTCzrcl6CCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# 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
# ARUwLwYJKoZIhvcNAQkEMSIEIJc1+S2Ut6Mv3cPKFALX+WepG5WzspeV28Z0M4Rg
# xMsvMA0GCSqGSIb3DQEBAQUABIIBgAtjFdb/2pyDASgIkV/oLdM7Asw8OpHdtdKg
# MDupFwYu3Q3RrDm9V5ArpL8EssjrPyk/YPSuXo6W9ppuVAhTUmSspoXz4tAH+pPX
# MCxfl8CNG3vkhLLPEfckH77Zqg09hZGOO45NcBbeKOq3ES97xccaQjazMsl5e/IR
# cE74ZZHhnAPuOoO6GOTO9/zJzJV4KnCwMrS4b716qXlTJmt7DJPTfU939egm/ihl
# 8XfUhRs3o4ctmZtKPIHo/T76B3QSQClk+A8FyoMlNH1azeNjESmy4+6cP3NoYNKN
# U0Rw3xf6iVgDKoaFl5hZzFJSMN8fovIIv0AQAJBzK759EgI6BwGWf5418Qs4eCQN
# YnsR0dSSO2tTKtvVkZa+/41MneaQWzTB+B/iuiSGJRRlI+0zqS/Kn2l7yVyMoG7L
# FInkfsrgOjHF/4yHL/q6Pf8yBJ/zcxRschSBchzRRzRJM0rwaIT2QxaZdTyBsmr3
# Am6glMvdPccDtgfUP3BtXuDYPnwnuKGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQwNTBaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDByvayYaeFD5DSeHkixLVcDptaMBRanTt09
# u/D2WfvUr6yISMkHwm+RMKFeSoa2sXcwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAtjpJTi+/X1J9VhojNc9syvNd53iOMynfnACbIlE55Bh65GsX
# lrJZ7JP6JhcSNQFsitfqiZfvcZ4mr46AulIHkKqGGtWggVaU9rOQVjGQ3dTCu1i/
# A40E736Lr2pmuEQNRepI5z7dEYkzvvPTTOAstgv/vgOiml8N/cq3Weucr0gZvafd
# RC15uK1xjw0ABhuu961YwMW3Hn1M3lOWKfvPompjIXcvN2E8nP9Rc2J6ub8cQCVU
# c1Bu3RUuLyrcUv5SI4ni2dWPNUv2uHi2Rfzm9j5WjJts0GFEaY7XEI6Lo8BuUI+k
# w8fYlHQsq68T/L82icDGxeTFOVcfxVfB0zsIU+iE3EBElQdmJVfnxww/CejB3hpK
# asKmjBlcpEetOnf7QYVZD6NsTiupCYsUdj8WNokZdOvkpSMbftE6ZH5cTzJ+TDax
# 1lL/rVWOPcxB4VcVKUmRMSNN+jtK2w6LHGVjDlp0yul1liif2UNBHa5uTfw5qmMv
# bNNYePjsVtqaErrNu+HrmZJj0W91uZ5liZYiyqQpbXSGvguuCWst5bMOmQNsAz/W
# ajeFzILmrM/lhHJR+k2uXEoeR/rGUT2wOKiNrapY3brETSHxAabEpm1mHlOSOcua
# xFD1See15Q9TjsRkOWqfoI6wRtuzkH2Hx3icjm+W7yu3R5q3Ok3AK2MG2/U=
# SIG # End signature block