Public/Core/Invoke-EntraOpsAzQuery.ps1

<#
.SYNOPSIS
    Executing optimized queries against the Azure Resource Manager (ARM) REST API.

.DESCRIPTION
    Wrapper to call the Azure Resource Manager API (https://management.azure.com) with automatic
    pagination (nextLink), adaptive throttling/retry handling and result caching. The design borrows
    proven performance patterns from AzAPICall (https://github.com/JulianHayward/AzAPICall):

    - Throttling: honors the Retry-After header, reacts to the x-ms-ratelimit-remaining-* headers to
      proactively back off before ARM starts returning 429, and retries the transient status codes
      (429/500/502/503/504) as well as ARM error codes that indicate a retryable condition
      (e.g. ExpiredAuthenticationToken, ServerTimeout, GatewayTimeout, ResourceRequestsThrottled).
    - Paging: follows nextLink (and @odata.nextLink) until the full result set is collected.
    - Batching: bundles many requests into the ARM $batch endpoint (max 20 processed concurrently),
      which is what makes tenant-wide collection (all subscriptions and all management groups) fast.

    Pass a single relative/absolute Uri for one resource/list call, or pass an array of relative URIs
    (or request hashtables) to -BatchRequest to fan out many calls through the ARM batch endpoint. The
    batch path is the recommended way to collect RBAC role assignments across the entire tenant.

.PARAMETER Method
    HTTP Method to be used for the request. Default is GET.

.PARAMETER Uri
    Relative ARM Uri (starting with /subscriptions, /providers, /tenants, ...) or an absolute
    https://management.azure.com/... Uri. If -ApiVersion is supplied and the Uri has no api-version
    query parameter, it is appended automatically.

.PARAMETER BatchRequest
    One or more requests to send through the ARM $batch endpoint. Each item can be either a relative
    Uri string or a hashtable @{ Method = 'GET'; Uri = '/subscriptions/.../...?api-version=2022-04-01' }.
    Requests are chunked into -BatchSize (default 20) and each batched response is paged independently.

.PARAMETER Body
    Body of the request (objects are converted to JSON automatically, strings are sent as-is).

.PARAMETER ApiVersion
    Default api-version appended to URIs that do not already specify one. Default is 2022-04-01
    (the GA Microsoft.Authorization version used for role assignments).

.PARAMETER BatchSize
    Number of requests bundled into a single ARM $batch call. ARM processes a maximum of 20 requests
    concurrently per batch, which is the default and recommended value.

.PARAMETER OutputType
    Type of output to be returned. Default is PSObject. Other options are HashTable and Json.

.PARAMETER TenantId
    Optional tenant id to acquire the ARM access token for (cross-tenant scenarios). Defaults to the
    tenant of the current Az PowerShell context.

.PARAMETER DisableCache
    Disable the module-internal cache mechanism for the request.

.PARAMETER UseInvokeRestMethodOnly
    Accepted for consistency with the other Invoke-EntraOps*Query cmdlets. This wrapper is natively
    implemented with Invoke-RestMethod (token via Get-AzAccessToken, no Az.Resources/Invoke-AzRestMethod
    dependency), so batching, paging and retry behavior are identical with or without this switch.

.PARAMETER MaxRetries
    Maximum number of retry attempts for throttling (429) or transient errors. Default is 5.

.PARAMETER InitialRetryDelay
    Initial delay in seconds before the first retry. Default is 2 seconds. Adaptive backoff combined
    with the Retry-After header is used for subsequent attempts.

.EXAMPLE
    # List all role assignments of a single subscription (paged automatically)
    Invoke-EntraOpsAzQuery -Uri "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleAssignments"

.EXAMPLE
    # Get all role assignments tenant-wide (every subscription AND every management group) in batches.
    # Discover scopes first, then fan out through the ARM $batch endpoint for maximum throughput.
    $Subscriptions = (Invoke-EntraOpsAzQuery -Uri "/subscriptions" -ApiVersion "2020-01-01").subscriptionId
    $MgmtGroups = (Invoke-EntraOpsAzQuery -Uri "/providers/Microsoft.Management/managementGroups" -ApiVersion "2020-05-01").name

    $RbacRequests = @()
    $RbacRequests += $Subscriptions | ForEach-Object { "/subscriptions/$_/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01" }
    $RbacRequests += $MgmtGroups | ForEach-Object { "/providers/Microsoft.Management/managementGroups/$_/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01" }

    $AllRoleAssignments = Invoke-EntraOpsAzQuery -BatchRequest $RbacRequests

.EXAMPLE
    # Filter role assignments to those at the exact scope (atScope) for a subscription
    Invoke-EntraOpsAzQuery -Uri "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleAssignments?`$filter=atScope()"
#>


function Invoke-EntraOpsAzQuery {
    [CmdletBinding(DefaultParameterSetName = 'Single')]
    param (
        [Parameter(Mandatory = $false, ParameterSetName = 'Single')]
        [string]$Method = 'GET',

        [Parameter(Mandatory = $true, ParameterSetName = 'Single')]
        [string]$Uri,

        [Parameter(Mandatory = $true, ParameterSetName = 'Batch')]
        [object[]]$BatchRequest,

        [Parameter(Mandatory = $false, ParameterSetName = 'Single')]
        [object]$Body,

        [Parameter(Mandatory = $false)]
        [string]$ApiVersion = '2022-04-01',

        [Parameter(Mandatory = $false, ParameterSetName = 'Batch')]
        [ValidateRange(1, 20)]
        [int]$BatchSize = 20,

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

        [Parameter(Mandatory = $false)]
        [string]$TenantId,

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

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

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

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

    $ArmBaseUri = "https://management.azure.com"

    # This wrapper is natively implemented with Invoke-RestMethod, so the switch is a no-op
    # and only exists so all Invoke-EntraOps*Query cmdlets share the same parameter surface
    if ($UseInvokeRestMethodOnly) {
        Write-Verbose "UseInvokeRestMethodOnly requested - Invoke-EntraOpsAzQuery always uses Invoke-RestMethod."
    }

    #region Helper - Acquire (and cache) the ARM bearer token
    function Get-EntraOpsArmAccessToken {
        param ([string]$ForTenantId)

        $TokenKey = if ([string]::IsNullOrEmpty($ForTenantId)) { "default" } else { $ForTenantId }
        $Now = [DateTime]::UtcNow

        $Cached = $__EntraOpsSession.ArmTokenCache[$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 = @{
            ResourceUrl     = "$ArmBaseUri/"
            AsSecureString  = $true
            ErrorAction     = 'Stop'
        }
        if (-not [string]::IsNullOrEmpty($ForTenantId)) { $TokenParams.TenantId = $ForTenantId }

        $TokenResponse = Get-AzAccessToken @TokenParams
        $PlainToken = $TokenResponse.Token | ConvertFrom-SecureString -AsPlainText
        $Expiry = if ($TokenResponse.ExpiresOn) { $TokenResponse.ExpiresOn.UtcDateTime } else { $Now.AddMinutes(30) }

        $__EntraOpsSession.ArmTokenCache[$TokenKey] = @{ Token = $PlainToken; Expiry = $Expiry }
        return $PlainToken
    }
    #endregion

    #region Helper - Tenant discriminator for response cache keys
    # Many ARM URIs are tenant agnostic ("/subscriptions", "/providers/Microsoft.Management/managementGroups",
    # "/providers/Microsoft.Authorization/roleAssignments?$filter=atScope()"), so a URI-only cache key lets a
    # response collected for tenant A be served to tenant B in a multi-tenant or worker-reuse run.
    # The tenant is derived from the "tid" claim of the ARM bearer token actually used for the request, which
    # is authoritative - unlike ambient context, it cannot silently fall back to the same value in both tenants.
    function Get-EntraOpsArmCacheTenantId {
        param ([string]$ForTenantId)

        if (-not [string]::IsNullOrEmpty($ForTenantId)) { return $ForTenantId }

        try {
            $Token = Get-EntraOpsArmAccessToken -ForTenantId $ForTenantId
            $Payload = ($Token -split '\.')[1]
            if ([string]::IsNullOrEmpty($Payload)) { return 'unknown-tenant' }
            $Payload = $Payload.Replace('-', '+').Replace('_', '/')
            $Payload = $Payload.PadRight([int][Math]::Ceiling($Payload.Length / 4) * 4, '=')
            $Claims = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Payload)) | ConvertFrom-Json
            if (-not [string]::IsNullOrEmpty($Claims.tid)) { return $Claims.tid }
        } catch {
            Write-Verbose "Unable to determine ARM tenant context for cache scoping: $_"
        }
        # Deliberately NOT "default": an unresolvable tenant must not share a cache namespace with a resolved one.
        return 'unknown-tenant'
    }
    #endregion

    #region Helper - Normalize a relative/absolute ARM Uri and ensure api-version
    function Resolve-EntraOpsArmUri {
        param ([string]$RawUri)

        if ($RawUri -like "$ArmBaseUri/*") {
            $Resolved = $RawUri
        } elseif ($RawUri -like "/*") {
            $Resolved = "$ArmBaseUri$RawUri"
        } else {
            throw "Invalid Azure Resource Manager URI: $($RawUri)! Use a relative path (e.g. /subscriptions/...) or an absolute $ArmBaseUri/... URI."
        }

        if ($Resolved -notmatch "[?&]api-version=") {
            $Separator = if ($Resolved.Contains('?')) { '&' } else { '?' }
            $Resolved = "$Resolved$($Separator)api-version=$ApiVersion"
        }
        return $Resolved
    }
    #endregion

    #region Helper - Proactively back off based on the remaining rate-limit budget
    function Invoke-EntraOpsArmRateLimitBackoff {
        param ($ResponseHeaders)

        if ($null -eq $ResponseHeaders) { return }

        # ARM exposes several x-ms-ratelimit-remaining-* counters; back off when the lowest gets close to 0
        $RemainingValues = foreach ($HeaderName in $ResponseHeaders.Keys) {
            if ($HeaderName -like 'x-ms-ratelimit-remaining-*') {
                $RawValue = $ResponseHeaders[$HeaderName]
                if ($RawValue -is [array]) { $RawValue = $RawValue[0] }
                $Parsed = 0
                if ([int]::TryParse("$RawValue", [ref]$Parsed)) { $Parsed }
            }
        }

        if ($RemainingValues) {
            $Lowest = ($RemainingValues | Measure-Object -Minimum).Minimum
            if ($Lowest -le 10) {
                Write-Verbose "ARM rate-limit budget low (remaining: $Lowest). Backing off 5s to avoid throttling."
                Start-Sleep -Seconds 5
            } elseif ($Lowest -le 50) {
                Write-Verbose "ARM rate-limit budget getting low (remaining: $Lowest). Backing off 1s."
                Start-Sleep -Seconds 1
            }
        }
    }
    #endregion

    #region Helper - Execute a single ARM request with adaptive retry/throttle handling
    # Returns a hashtable: @{ Success = [bool]; Data = <parsed object>; ErrorCode = <string>; StatusCode = <int> }
    function Invoke-EntraOpsArmRequest {
        param (
            [string]$RequestMethod,
            [string]$RequestUri,
            [object]$RequestBody
        )

        # Retryable ARM/HTTP signals adopted from AzAPICall's rule set
        $RetryableStatusCodes = @(429, 500, 502, 503, 504)
        $RetryableErrorCodes = @(
            'ExpiredAuthenticationToken', 'InvalidAuthenticationToken', 'Authentication_ExpiredToken', 'TokenExpired',
            'GatewayTimeout', 'BadGateway', 'InvalidGatewayHost', 'ServerTimeout', 'ServiceUnavailable',
            'RequestTimeout', 'InternalServerError', 'UnknownError', 'MultipleErrorsOccurred',
            'ResourceRequestsThrottled', 'RateLimiting', 'TooManyRequests', 'OperationNotAllowed'
        )

        $BodyJson = $null
        if ($null -ne $RequestBody) {
            $BodyJson = if ($RequestBody -is [string]) { $RequestBody } else { $RequestBody | ConvertTo-Json -Depth 20 -Compress }
        }

        $RetryCount = 0
        while ($RetryCount -le $MaxRetries) {
            $ResponseHeaders = $null

            try {
                # Acquire/refresh token on every attempt (cached) so an expired-token retry gets a fresh one.
                # Kept inside the try so a transient credential/OIDC failure (e.g. ClientAssertionCredential)
                # is retried like any other transient error instead of crashing the whole run.
                $HeaderParams = @{ Authorization = "Bearer $(Get-EntraOpsArmAccessToken -ForTenantId $TenantId)" }

                $InvokeParams = @{
                    Headers                 = $HeaderParams
                    Uri                     = $RequestUri
                    Method                  = $RequestMethod
                    ContentType             = "application/json"
                    ResponseHeadersVariable = 'ResponseHeaders'
                    ErrorAction             = 'Stop'
                }
                if ($null -ne $BodyJson) { $InvokeParams.Body = $BodyJson }

                $RequestResult = Invoke-RestMethod @InvokeParams

                # Honor the remaining rate-limit budget before the caller fires the next request
                Invoke-EntraOpsArmRateLimitBackoff -ResponseHeaders $ResponseHeaders

                return @{ Success = $true; Data = $RequestResult; ErrorCode = $null; StatusCode = 200 }

            } catch {
                $StatusCode = $null
                if ($_.Exception.Response) { $StatusCode = $_.Exception.Response.StatusCode.value__ }

                # Extract the ARM error code from the response body (best effort)
                $ErrorCode = $null
                if ($_.ErrorDetails.Message) {
                    try { $ErrorCode = ($_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop).error.code } catch { }
                }

                $IsNetworkError = ($null -eq $StatusCode -and $_.Exception.Message -match 'An error occurred while sending the request|operation has timed out|Unable to connect|Connection reset')

                # Token acquisition/credential failures (e.g. a transient OIDC/federated-credential exchange
                # hiccup surfaced by Get-AzAccessToken as "ClientAssertionCredential authentication failed")
                # have no HTTP response at all, so they must be detected from the exception message.
                $IsAuthError = ($null -eq $StatusCode -and $_.Exception.Message -match 'ClientAssertionCredential authentication failed|ManagedIdentityCredential authentication failed|Failed to acquire token|AADSTS')

                # Non-retryable, expected conditions: report and skip without burning retries
                if ($ErrorCode -in @('ResponseTooLarge')) {
                    Write-Warning "ARM returned '$ErrorCode' for $RequestUri. Result skipped - narrow the scope or use the ARM `$batch endpoint per sub-scope."
                    return @{ Success = $false; Data = $null; ErrorCode = $ErrorCode; StatusCode = $StatusCode }
                }

                $IsRetryable = ($StatusCode -in $RetryableStatusCodes) -or ($ErrorCode -in $RetryableErrorCodes) -or $IsNetworkError -or $IsAuthError

                if ($IsRetryable -and $RetryCount -lt $MaxRetries) {
                    $RetryCount++

                    # An expired or unusable token will be re-acquired on the next loop; drop it from cache now
                    if ($IsAuthError -or $ErrorCode -in @('ExpiredAuthenticationToken', 'InvalidAuthenticationToken', 'Authentication_ExpiredToken', 'TokenExpired')) {
                        $TokenKey = if ([string]::IsNullOrEmpty($TenantId)) { "default" } else { $TenantId }
                        $__EntraOpsSession.ArmTokenCache.Remove($TokenKey)
                    }

                    # Prefer the Retry-After header, otherwise adaptive exponential backoff capped at 60s
                    $RetryAfter = $null
                    try {
                        if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers.Contains('Retry-After')) {
                            $RetryAfter = [int]($_.Exception.Response.Headers.GetValues('Retry-After') | Select-Object -First 1)
                        }
                    } catch { }

                    if ($null -ne $RetryAfter -and $RetryAfter -gt 0) {
                        $RetryDelay = $RetryAfter
                    } else {
                        $RetryDelay = [Math]::Min($InitialRetryDelay * [Math]::Pow(2, $RetryCount - 1), 60)
                    }

                    # Add jitter (±20%) to prevent a thundering herd of synchronized retries
                    $Jitter = $RetryDelay * 0.2 * (Get-Random -Minimum -1.0 -Maximum 1.0)
                    $RetryDelay = [Math]::Max(1, $RetryDelay + $Jitter)

                    Add-EntraOpsRetryStatistic -Name TotalRetries
                    Add-EntraOpsRetryStatistic -Name ThrottledRequests
                    Write-Verbose "ARM request throttled/transient (HTTP $StatusCode / $ErrorCode). Retry $RetryCount/$MaxRetries in $([Math]::Round($RetryDelay, 1))s for: $RequestUri"

                    Start-Sleep -Seconds $RetryDelay
                } else {
                    if ($RetryCount -ge $MaxRetries) {
                        Add-EntraOpsRetryStatistic -Name FailedRequests
                        $__EntraOpsSession.RetryStatistics.FailedRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $RequestUri; StatusCode = $StatusCode; ErrorMessage = "$($_.Exception.Message)$(if ($ErrorCode) { " ($ErrorCode)" })" })
                        Write-Error "ARM request to $RequestUri failed after $MaxRetries retry attempts. Error: $($_.Exception.Message)"
                    } else {
                        Add-EntraOpsRetryStatistic -Name NonRetryableRequests
                        $__EntraOpsSession.RetryStatistics.NonRetryableRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $RequestUri; StatusCode = $StatusCode; ErrorMessage = "$($_.Exception.Message)$(if ($ErrorCode) { " ($ErrorCode)" })" })
                        Write-Warning "ARM request to $RequestUri failed (non-retryable HTTP $StatusCode / $ErrorCode). Error: $($_.Exception.Message)"
                    }
                    return @{ Success = $false; Data = $null; ErrorCode = $ErrorCode; StatusCode = $StatusCode }
                }
            }
        }

        Add-EntraOpsRetryStatistic -Name FailedRequests
        $__EntraOpsSession.RetryStatistics.FailedRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $RequestUri; StatusCode = 429; ErrorMessage = "Persistent throttling after $MaxRetries retry attempts" })
        Write-Error "ARM request to $RequestUri failed after $MaxRetries retry attempts due to persistent throttling."
        return @{ Success = $false; Data = $null; ErrorCode = 'MaxRetriesExceeded'; StatusCode = 429 }
    }
    #endregion

    #region Helper - Follow nextLink pagination for an ARM list payload
    function Get-EntraOpsArmPagedResult {
        param ([object]$FirstPage)

        $Collected = New-Object System.Collections.Generic.List[Object]

        $Page = $FirstPage
        while ($true) {
            if ($null -ne $Page.value) {
                $Collected.AddRange(@($Page.value))
            } elseif ($null -ne $Page) {
                $Collected.Add($Page)
            }

            $NextLink = $Page.nextLink
            if ([string]::IsNullOrEmpty($NextLink)) { $NextLink = $Page.'@odata.nextLink' }
            if ([string]::IsNullOrEmpty($NextLink)) { break }

            $NextResult = Invoke-EntraOpsArmRequest -RequestMethod 'GET' -RequestUri $NextLink -RequestBody $null
            if (-not $NextResult.Success) {
                # Returning what was collected so far makes a truncated result indistinguishable from a
                # complete one: the run reports success and the missing privileged principals look like
                # legitimate remediation in the committed diff. Fail loudly instead - the empty/failed-result
                # guard in Save-EntraOpsEAMRbacSystemJson then preserves the previous good export.
                throw "ARM pagination failed after $($Collected.Count) item(s) while following '$NextLink'. Refusing to return a partial result set."
            }
            $Page = $NextResult.Data
        }

        return $Collected
    }
    #endregion

    $Result = New-Object System.Collections.Generic.List[Object]

    if ($PSCmdlet.ParameterSetName -eq 'Batch') {
        #region Batch mode - fan out through the ARM $batch endpoint (max 20 concurrent per call)
        $BatchEndpoint = "$ArmBaseUri/batch?api-version=2020-06-01"

        # Normalize each request to @{ httpMethod; url } with a resolved Uri + api-version
        $NormalizedRequests = foreach ($Request in $BatchRequest) {
            if ($Request -is [hashtable]) {
                @{ httpMethod = ($Request.Method ?? 'GET'); url = (Resolve-EntraOpsArmUri -RawUri $Request.Uri) }
            } else {
                @{ httpMethod = 'GET'; url = (Resolve-EntraOpsArmUri -RawUri "$Request") }
            }
        }

        $TotalRequests = @($NormalizedRequests).Count
        Write-Verbose "Dispatching $TotalRequests ARM request(s) through the `$batch endpoint in chunks of $BatchSize."

        # Retain failed batch items so incomplete responses cannot be exported as complete snapshots.
        $FailedBatchItems = [System.Collections.Generic.List[string]]::new()

        for ($Offset = 0; $Offset -lt $TotalRequests; $Offset += $BatchSize) {
            $Chunk = @($NormalizedRequests)[$Offset..([Math]::Min($Offset + $BatchSize - 1, $TotalRequests - 1))]

            # ARM requires a unique name per request to correlate responses
            $ChunkRequests = @()
            for ($i = 0; $i -lt $Chunk.Count; $i++) {
                $ChunkRequests += @{ httpMethod = $Chunk[$i].httpMethod; name = "$($Offset + $i)"; url = $Chunk[$i].url }
            }

            $BatchBody = @{ requests = $ChunkRequests }
            $BatchResult = Invoke-EntraOpsArmRequest -RequestMethod 'POST' -RequestUri $BatchEndpoint -RequestBody $BatchBody
            if (-not $BatchResult.Success) {
                # Retry the whole chunk once (Invoke-EntraOpsArmRequest already applies its own
                # per-request retry/backoff internally). Silently continuing here would drop up to
                # $BatchSize scopes from the result while the run still reports success.
                Write-Warning "ARM `$batch chunk (offset $Offset, $($ChunkRequests.Count) request(s)) failed - retrying chunk once."
                $BatchResult = Invoke-EntraOpsArmRequest -RequestMethod 'POST' -RequestUri $BatchEndpoint -RequestBody $BatchBody
                if (-not $BatchResult.Success) {
                    $FailedScopes = @($Chunk | ForEach-Object { $_.url }) -join ', '
                    throw "ARM `$batch chunk (offset $Offset) failed after retry. Refusing to return a truncated result set. Failed scope request(s): $FailedScopes"
                }
            }

            foreach ($Response in $BatchResult.Data.responses) {
                $ItemStatus = $Response.httpStatusCode

                if ($ItemStatus -ge 200 -and $ItemStatus -lt 300) {
                    # Page each successful response independently (nextLink follows go direct, not via batch)
                    $Result.AddRange(@(Get-EntraOpsArmPagedResult -FirstPage $Response.content))
                } elseif ($ItemStatus -in @(429, 500, 502, 503, 504)) {
                    # Per-item throttling/transient error: retry that single request on its own
                    $RetryReq = @($NormalizedRequests)[[int]$Response.name]
                    Write-Verbose "Batch item '$($Response.name)' returned HTTP $ItemStatus, retrying individually: $($RetryReq.url)"
                    $ItemResult = Invoke-EntraOpsArmRequest -RequestMethod $RetryReq.httpMethod -RequestUri $RetryReq.url -RequestBody $null
                    if ($ItemResult.Success) {
                        $Result.AddRange(@(Get-EntraOpsArmPagedResult -FirstPage $ItemResult.Data))
                    } else {
                        $FailedBatchItems.Add("$($RetryReq.url) (HTTP $ItemStatus, individual retry failed)")
                    }
                } else {
                    $ItemCode = $Response.content.error.code
                    $ItemUrl = (@($NormalizedRequests)[[int]$Response.name]).url
                    Add-EntraOpsRetryStatistic -Name NonRetryableRequests
                    $__EntraOpsSession.RetryStatistics.NonRetryableRequestDetails.Add([pscustomobject]@{ Timestamp = (Get-Date); Uri = $ItemUrl; StatusCode = $ItemStatus; ErrorMessage = "Batch item failed ($ItemCode)" })
                    Write-Warning "Batch item '$($Response.name)' failed (HTTP $ItemStatus / $ItemCode): $ItemUrl"
                    $FailedBatchItems.Add("$ItemUrl (HTTP $ItemStatus / $ItemCode)")
                }
            }
        }

        if ($FailedBatchItems.Count -gt 0) {
            throw "$($FailedBatchItems.Count) ARM `$batch item(s) failed. Refusing to return a truncated result set. Failed request(s): $($FailedBatchItems -join '; ')"
        }
        #endregion
    } else {
        #region Single mode - one resource/list call with caching + pagination
        $ResolvedUri = Resolve-EntraOpsArmUri -RawUri $Uri
        $IsCacheable = ($Method -eq 'GET' -and -not $DisableCache)
        # Tenant-scoped cache key (matches the "<tenant>#<uri>" convention of Invoke-EntraOpsMsGraphQuery).
        # CacheMetadata keeps the raw Uri as a field so verbose/telemetry output stays readable.
        $CacheKey = if ($IsCacheable) { "$(Get-EntraOpsArmCacheTenantId -ForTenantId $TenantId)#$ResolvedUri" } else { $ResolvedUri }

        $QueryResult = $null
        if ($IsCacheable -and $__EntraOpsSession.GraphCache.ContainsKey($CacheKey)) {
            if ($__EntraOpsSession.CacheMetadata.ContainsKey($CacheKey)) {
                $CacheEntry = $__EntraOpsSession.CacheMetadata[$CacheKey]
                if ([DateTime]::UtcNow -lt $CacheEntry.ExpiryTime) {
                    Write-Verbose "Using valid ARM cache: $ResolvedUri"
                    $QueryResult = $__EntraOpsSession.GraphCache[$CacheKey]
                } else {
                    $__EntraOpsSession.GraphCache.Remove($CacheKey)
                    $__EntraOpsSession.CacheMetadata.Remove($CacheKey)
                }
            } else {
                $QueryResult = $__EntraOpsSession.GraphCache[$CacheKey]
            }
        }

        if ($null -eq $QueryResult) {
            $FirstResult = Invoke-EntraOpsArmRequest -RequestMethod $Method -RequestUri $ResolvedUri -RequestBody $Body
            if (-not $FirstResult.Success) { return $null }

            $QueryResult = Get-EntraOpsArmPagedResult -FirstPage $FirstResult.Data

            # Cache GET results with TTL metadata (reuses the shared session cache + TTL settings)
            if ($IsCacheable) {
                $CurrentTime = [DateTime]::UtcNow
                $CacheMetadataEntry = @{
                    Uri         = $ResolvedUri
                    CachedTime  = $CurrentTime
                    ExpiryTime  = $CurrentTime.AddSeconds($__EntraOpsSession.DefaultCacheTTL)
                    TTLSeconds  = $__EntraOpsSession.DefaultCacheTTL
                    ResultCount = $QueryResult.Count
                }
                $__EntraOpsSession.GraphCache[$CacheKey] = $QueryResult
                $__EntraOpsSession.CacheMetadata[$CacheKey] = $CacheMetadataEntry
                Write-Verbose "Cached ARM result for $ResolvedUri (TTL: $($__EntraOpsSession.DefaultCacheTTL)s, Count: $($QueryResult.Count))"
            }
        }

        $Result.AddRange(@($QueryResult))
        #endregion
    }

    switch ($OutputType) {
        Json     { return ($Result | ConvertTo-Json -Depth 20) }
        # Case-insensitive conversion so property access with different casing (e.g. .Id vs "id")
        # behaves like the rest of the module (ConvertFrom-Json -AsHashtable is case-sensitive)
        HashTable { return @($Result | ForEach-Object { ConvertTo-EntraOpsCaseInsensitiveHashtable -InputObject $_ }) }
        default  { return $Result }
    }
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBHVHGFXfteZZex
# ZfLIq6ECMra+YqLW7UAOE8P2IiEa86CCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# 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
# ARUwLwYJKoZIhvcNAQkEMSIEINtryHdKOJ4m/4VC9iq5xkHhpN44s+x3u6ZjLuPg
# pk9IMA0GCSqGSIb3DQEBAQUABIIBgD6sqW/VAYJCPPEqZw5EiN8Z+Du2Axmlhejh
# 5yE33Ej4JXGP6l8TSG7Su30fPanTInmP2TfpB4VgrYmDX/WhOflPcqwcpGDTyfDC
# 42cP+GZRNudd5Xf+fvuGMEDXO9ynCAs8ea5b0TNMBEK/HbzYWVcWmAFs2hEXo1T2
# tgHZiNjg1AYXu5dzTQ9Tqe0x2Gmkr28645ONpCPequsE5+6+vgMBeeUhj66ZweZO
# guC0y18v2HUSUYMcFSCph064AWmLyvobySMU2fkr6zW1uKArTbAZvpNC2cR/fWSM
# 3NyIwWCGGtitqyCjJeBQErgwULJnEkW5NTgz+Ct7eivJ05ghh0j8vO7W15m2ZU9q
# Fb16GwYn6wTOHH68prHLuyTdGETsIR+xWrJmcV63F8W07zvf+Z1lPaH6w8VfOHT5
# Ff8dmlMD2SEwEIFmV3nDSTIrwmxPPgIPhTzgDs9ATrwD4A09BSVmwXnyQslqLqVL
# jOKzhk8aWHOaK50ifN7DURQnCKfPs6GCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQwNDhaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDATdxJgB5XMEZ1r1hasPUKjJoBX55uoH0pN
# QvJO3g50+Ppx/7LuAc6OU1Td2H/AN+UwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAIPCwZmtHXMv3xWHm+6WX/YDeOEsmIhKweCXx2WVRCJmzLjNO
# DzcHpvnDcE0SYW2dgFa3QHyPgittW/DmelHL253e+6j4eZbrAj1Ne2RD5W8X3r1S
# mj24XBDdwoQHKxE1cWeSTf+wP1TPuwYJl0WUU517lwDVmFEV5HXmAG2JnRxDTStX
# dfHHi5hD2RdIZrKbSRtLXTjkShng6EIeSXTVmPYUGy+7/CilKGnV0C/iRhmzsT8A
# 8fMif3zsyTIHT6d0DQwie/aPliOjCujRV7+ddLBN8nceIV3BeZvtHv11cIWocLKr
# QVogA4FApN/N+efx6m/w09EZO/oiiF9BEjMnKgRa7pFVIIWSFs55SKMgPZkXq0xj
# 4Tv56rWrcNwb5tGpkbn9V3JZSJpmXQvMeT9otntjt9uIKVEVtS6pO3R7RKp1DVGU
# lnz08O2Axv4lAwrJmA/3fxSKlHdVGpevugUUABPv+wEb+5g2DQzmM4gKz15oVvob
# SwXoyJsnRHaVaUvItqeEK76QpWkksap/CNyngtIXZMStAWuaAfQ1YIllcM6yrKLD
# mUYpowOFKs1XvhEn5AHDW8nGOQ5+mM3tE3g4TJDWn0D2HzX8DCb0Un8trvL+qEvG
# C2Z73TXvFw+YQZRZUkGvD8JQ8v4pKAgCgGyCR5aU/uE6RN8j7C5UToefuNc=
# SIG # End signature block