Private/Invoke-InforcerApiRequest.ps1
|
function Invoke-InforcerApiRequest { <# .SYNOPSIS Sends a request to the Inforcer REST API (Private helper). .DESCRIPTION Works for both login types. API-key sessions call {BaseUrl}/beta{path}; client-credentials sessions call {ApiHost}/v1{path} with the v1 path renames (see Get-InforcerBaseUrl). Endpoints are version-neutral. beta responses: unwraps response.data, then (unless -PreserveStructure) the first array property. v1 responses: a list response unwraps to its single collection array (metadata such as continuationToken is ignored); a single object is never unwrapped. -ObjectType maps v1 property names to the module's canonical names before any JSON conversion. A non-empty failedPolicyTypes is surfaced as a warning. Recognised error envelopes: * problem+json: { type, title, status, detail, instance, errors{} } -> "title: detail (field: message)" * App-layer: { success, message, errors[], errorCode } * APIM gateway: { statusCode, message } ErrorId is ApiRequestFailed_<status>. On v1, 429 is retried up to three times honouring Retry-After (seconds or HTTP date, capped at 300 s) and a 401 renews the token once and retries. The x-correlation-id response header is logged to the verbose stream and included in error messages when present. .PARAMETER Endpoint Version-neutral API path (e.g. /tenants/{id}/policies). Leading slash optional. .PARAMETER Method HTTP method. Default: GET. .PARAMETER Body Optional JSON body for POST/PUT/PATCH. .PARAMETER OutputType PowerShellObject (return PSObjects) or JsonObject (return JSON string). Default: PowerShellObject. .PARAMETER PreserveStructure beta: unwraps .data but keeps its inner structure (e.g. items + continuationToken). v1: returns the response object as-is (collection mapped), without unwrapping. .PARAMETER PreserveFullResponse Returns the full parsed response without unwrapping (v1: collection mapped via -ObjectType). .PARAMETER ObjectType v1 only: maps the response to canonical property names via ConvertFrom-InforcerV1Response. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Endpoint, [Parameter(Mandatory = $false)] [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] [string]$Method = 'GET', [Parameter(Mandatory = $false)] [string]$Body, [Parameter(Mandatory = $false)] [ValidateSet('PowerShellObject', 'JsonObject')] [string]$OutputType = 'PowerShellObject', [Parameter(Mandatory = $false)] [switch]$PreserveStructure, [Parameter(Mandatory = $false)] [switch]$PreserveFullResponse, [Parameter(Mandatory = $false)] [ValidateSet('Tenant', 'Policy', 'Baseline', 'AlignmentScore', 'AlignmentDetails', 'User', 'Group', 'GroupMember', 'Role', 'SecureScore', 'Assessment', 'AssessmentRun', 'ReportType', 'ReportRun', 'ReportOutput', 'AuditEvent', 'AuditEventType')] [string]$ObjectType ) if (-not (Test-InforcerSession)) { Write-Error -Message 'Not connected. Run Connect-Inforcer first.' ` -ErrorId 'NotConnected' ` -Category ConnectionError return } $session = $script:InforcerSession $isV1 = $session.ApiVersion -eq 'v1' $uri = Get-InforcerBaseUrl -Endpoint $Endpoint -Session $session Write-Verbose "Making $Method request to: $uri" $request = @{ Uri = $uri; Method = $Method } if (-not [string]::IsNullOrWhiteSpace($Body)) { $request['Body'] = $Body } $retries429 = 0 $renewToken = $false $tokenRenewed = $false while ($true) { $headers = Get-InforcerAuthHeader -Session $session -ForceRefresh:$renewToken if (-not $headers) { return } $renewToken = $false $headers['Accept'] = 'application/json' $headers['Content-Type'] = 'application/json' $attempt = $request.Clone() $attempt['Headers'] = $headers try { $response = Invoke-InforcerHttpRequest -Request $attempt } catch { Clear-InforcerRequestCredential -ErrorRecord $_ $detail = Protect-InforcerApiKeyInText -Text $_.Exception.Message -Session $session Write-Error -Message "Inforcer API request failed: $detail" -ErrorId 'ApiRequestFailed' -Category ConnectionError return } $statusCode = $response.StatusCode $correlationId = Get-InforcerHeaderValue -Headers $response.Headers -Name 'x-correlation-id' if ($correlationId) { Write-Verbose "x-correlation-id: $correlationId" } if ($statusCode -lt 400) { $rawResponse = $response.Body break } if ($statusCode -eq 429 -and $isV1 -and $retries429 -lt 3) { $retries429++ $delay = Get-InforcerRetryDelay -Value (Get-InforcerHeaderValue -Headers $response.Headers -Name 'Retry-After') -Attempt $retries429 Write-Verbose "Rate limited (HTTP 429). Retrying in $delay second(s) (attempt $retries429 of 3)." Start-Sleep -Seconds $delay continue } if ($statusCode -eq 401 -and $isV1 -and -not $tokenRenewed) { Write-Verbose 'HTTP 401: renewing the access token and retrying once.' $tokenRenewed = $true $renewToken = $true continue } $detail = "Response status code does not indicate success: $statusCode ($([System.Net.HttpStatusCode]$statusCode))." $json = $response.Body if ($json -is [string]) { if (-not [string]::IsNullOrWhiteSpace($json)) { $detail = $json } try { $json = $json | ConvertFrom-Json -Depth 100 -ErrorAction Stop } catch { $json = $null } } if ($json -is [System.Management.Automation.PSCustomObject]) { if ($response.Body -isnot [string]) { $detail = $json | ConvertTo-Json -Depth 100 -Compress } $apiMessage = $null $errorCode = $null $traceId = $null $props = $json.PSObject.Properties $isAppEnvelope = $null -ne $props['errorCode'] -or $null -ne $props['success'] if (-not $isAppEnvelope -and $null -ne $props['title'] -and ($null -ne $props['status'] -or $null -ne $props['type'])) { # problem+json (RFC 9457): v1 errors, and beta framework errors such as 415. $title = ($props['title'].Value -as [string]) $problemDetail = ($props['detail'].Value -as [string]) $apiMessage = if ($title -and $problemDetail -and $problemDetail -ne $title) { "${title}: $problemDetail" } elseif ($problemDetail) { $problemDetail } else { $title } $fieldErrors = Format-InforcerProblemError -Errors $props['errors'].Value if ($fieldErrors) { $apiMessage = "$apiMessage ($fieldErrors)" } $traceId = ($props['traceId'].Value -as [string]) } elseif ($isAppEnvelope -or $null -ne $props['errors']) { # App-layer envelope { success, message, errors[], errorCode } $errorCode = ($props['errorCode'].Value -as [string]) $apiMessage = ($props['message'].Value -as [string]) $errorsProp = $props['errors'] if ($errorsProp -and $errorsProp.Value) { $errorsJoined = Format-InforcerErrorDetail -Errors $errorsProp.Value if (-not [string]::IsNullOrWhiteSpace($errorsJoined)) { if ([string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage = $errorsJoined } elseif ($apiMessage -notlike "*$errorsJoined*") { $apiMessage = "$apiMessage — $errorsJoined" } } } } elseif ($null -ne $props['statusCode'] -and $null -ne $props['message']) { # APIM gateway { statusCode, message } $apiMessage = ($props['message'].Value -as [string]) } $detail = switch ($true) { ($statusCode -eq 429 -or ($apiMessage -and $apiMessage -match 'quota|rate.?limit|throttl')) { if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { "API rate limit: $apiMessage" } else { 'API rate limit exceeded. Please wait and try again.' } } ($errorCode -match '^forbidden$') { if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } else { "You don't have permission to access this tenant or resource." } } ($errorCode -match 'notfound|not_found') { if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } else { 'Tenant or resource not found.' } } default { if (-not [string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage } elseif ($props['error']) { $json.error -as [string] } else { $detail } } } if (-not [string]::IsNullOrWhiteSpace($traceId)) { $detail = "$detail (traceId: $traceId)" } } $detail = Protect-InforcerApiKeyInText -Text $detail -Session $session $msg = "Inforcer API request failed (HTTP $statusCode): $detail" if ($correlationId) { $msg = "$msg [correlation-id: $correlationId]" } Write-Error -Message $msg -ErrorId "ApiRequestFailed_$statusCode" -Category ConnectionError return } if ($null -eq $rawResponse -or ($rawResponse -is [string] -and [string]::IsNullOrWhiteSpace($rawResponse))) { Write-Error -Message 'API returned an empty response.' -ErrorId 'EmptyResponse' -Category InvalidData return } # Invoke-RestMethod auto-parses JSON. A string means a non-JSON Content-Type that may still hold JSON. if ($rawResponse -is [string]) { try { $rawResponse = $rawResponse | ConvertFrom-Json -Depth 100 -ErrorAction Stop } catch { $preview = $rawResponse if ($preview.Length -gt 200) { $preview = $preview.Substring(0, 200) + '...' } Write-Error -Message "API returned non-JSON response. Base URL may be incorrect. Response starts with: $(Protect-InforcerApiKeyInText -Text $preview -Session $session)" ` -ErrorId 'NonJsonResponse' -Category InvalidData return } } if ($rawResponse -isnot [PSObject] -and $rawResponse -isnot [array]) { $preview = $rawResponse.ToString() if ($preview.Length -gt 200) { $preview = $preview.Substring(0, 200) + '...' } Write-Error -Message "API returned non-JSON response. Base URL may be incorrect. Response starts with: $(Protect-InforcerApiKeyInText -Text $preview -Session $session)" ` -ErrorId 'NonJsonResponse' -Category InvalidData return } if ($isV1) { $collectionName = Get-InforcerV1CollectionName -Response $rawResponse $failedProp = $rawResponse.PSObject.Properties['failedPolicyTypes'] if ($failedProp -and $failedProp.Value -is [System.Management.Automation.PSCustomObject]) { $failed = @($failedProp.Value.PSObject.Properties | ForEach-Object { if ([string]::IsNullOrWhiteSpace([string]$_.Value)) { $_.Name } else { "$($_.Name) ($($_.Value))" } }) if ($failed.Count -gt 0) { Write-Warning "inforcer could not retrieve $($failed.Count) policy type(s); their policies are missing from this result: $($failed -join '; ')" } } # Plain assignments only: an if-expression would unroll a one-item or empty array. if ($collectionName) { $data = @(@($rawResponse.$collectionName).Where({ $null -ne $_ })) if ($ObjectType) { $data = @(ConvertFrom-InforcerV1Response -InputObject $data -ObjectType $ObjectType) } } else { $data = $rawResponse if ($ObjectType) { $data = ConvertFrom-InforcerV1Response -InputObject $data -ObjectType $ObjectType } } $result = $data if ($collectionName -and ($PreserveFullResponse -or $PreserveStructure)) { $full = [ordered]@{} foreach ($p in $rawResponse.PSObject.Properties) { $full[$p.Name] = $p.Value } $full[$collectionName] = $data $result = [PSCustomObject]$full } if ($PreserveFullResponse) { return $result } if ($OutputType -eq 'JsonObject') { return (ConvertTo-Json -InputObject $result -Depth 100) } return $result } # beta: API error payload (success: false) — single, clean message; no stack or raw JSON $successValue = $rawResponse.PSObject.Properties['success'].Value if ($null -ne $rawResponse.PSObject.Properties['success'] -and $successValue -eq $false) { $apiMessage = ($rawResponse.PSObject.Properties['message'].Value -as [string]) if ([string]::IsNullOrWhiteSpace($apiMessage)) { $apiMessage = 'Request failed.' } $apiMessage = $apiMessage.Trim() $errorCode = ($rawResponse.PSObject.Properties['errorCode'].Value -as [string]) if ([string]::IsNullOrWhiteSpace($errorCode)) { $errorCode = '' } $friendlyMessage = switch -Regex ($errorCode) { '^forbidden$' { "You don't have permission to access this tenant or resource." } 'notfound|not_found' { "Tenant or resource not found." } default { $apiMessage } } if ($rawResponse.errors -is [array] -and $rawResponse.errors.Count -gt 0) { $extra = ($rawResponse.errors | ForEach-Object { $_.ToString() }) -join '; ' if (-not [string]::IsNullOrWhiteSpace($extra)) { $friendlyMessage += " $extra" } } Write-Error -Message $friendlyMessage -ErrorId 'ApiError' -Category InvalidOperation return } if ($PreserveFullResponse) { return $rawResponse } $data = $rawResponse $dataProp = $rawResponse.PSObject.Properties['data'] if ($dataProp -and $null -ne $dataProp.Value) { $data = $dataProp.Value } if ($OutputType -eq 'JsonObject') { $json = $data | ConvertTo-Json -Depth 100 return $json } if ($PreserveStructure) { return $data } # If data is a single object with one array property (e.g. value, items, policies), unwrap to that array if ($data -isnot [array] -and $data -is [PSObject]) { $arrayProp = $data.PSObject.Properties | Where-Object { $_.Value -is [array] } | Select-Object -First 1 if ($arrayProp) { $data = $arrayProp.Value } } $data } function Invoke-InforcerHttpRequest { # Invoke-RestMethod that returns HTTP errors instead of throwing them, so a caller's -ErrorVariable # only ever holds the module's own error record. Network failures still throw. # Redirects are never followed: the request carries the API key, token or client secret. param([Parameter(Mandatory = $true)][hashtable]$Request) $statusCode = $null $responseHeaders = $null # -ErrorAction Ignore: a 3xx also raises a non-terminating 'maximum redirection' record (holding the request headers); network failures still throw. $body = Invoke-RestMethod @Request -MaximumRedirection 0 -SkipHttpErrorCheck -StatusCodeVariable statusCode -ResponseHeadersVariable responseHeaders -Verbose:$false -Debug:$false -ErrorAction Ignore if ($statusCode -ge 300 -and $statusCode -lt 400) { throw (Get-InforcerRedirectMessage -StatusCode $statusCode -Headers $responseHeaders) } [PSCustomObject]@{ StatusCode = if ($statusCode) { [int]$statusCode } else { 200 } Body = $body Headers = $responseHeaders } } function Get-InforcerRedirectMessage { param([int]$StatusCode, $Headers) $location = Get-InforcerHeaderValue -Headers $Headers -Name 'Location' $target = if ($location) { " to '$location'" } else { '' } "The API answered with a redirect (HTTP $StatusCode)$target. The module does not follow redirects, so credentials are never sent to another address. Check -BaseUrl or -Region." } function Format-InforcerProblemError { # problem+json errors{}: { "<field>": ["message", ...] } -> "field: message; field: message" param($Errors) if ($Errors -isnot [System.Management.Automation.PSCustomObject]) { return Format-InforcerErrorDetail -Errors $Errors } $parts = foreach ($p in $Errors.PSObject.Properties) { $messages = @($p.Value | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join ', ' if ($messages) { "$($p.Name): $messages" } else { $p.Name } } if ($parts) { @($parts) -join '; ' } } function Get-InforcerRetryDelay { # Retry-After is either delay-seconds or an HTTP date; falls back to exponential backoff. Capped at 300s. param([string]$Value, [int]$Attempt = 1) $seconds = 0 if (-not [string]::IsNullOrWhiteSpace($Value)) { $date = [datetimeoffset]::MinValue if ([int]::TryParse($Value.Trim(), [ref]$seconds)) { $seconds = [Math]::Max(0, $seconds) } elseif ([datetimeoffset]::TryParse($Value.Trim(), [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$date)) { $seconds = [int][Math]::Ceiling(($date - [datetimeoffset]::UtcNow).TotalSeconds) $seconds = [Math]::Max(0, $seconds) } else { $seconds = [int][Math]::Pow(2, $Attempt) } } else { $seconds = [int][Math]::Pow(2, $Attempt) } [Math]::Min(300, [Math]::Max(1, $seconds)) } |