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 |