Providers/Authentik/Private/Invoke-AuthentikRequest.ps1
|
function Invoke-AuthentikRequest { <# .SYNOPSIS Sends one request to the Authentik API, with paging, retry and a readable error .DESCRIPTION The single path every Authentik call takes. Authentik's API is a Django REST Framework application under /api/v3, which fixes three things this function has to know: - Listings are paginated by page number. A page carries its results under 'results' and a 'pagination' object whose 'next' is the next page number, or 0 on the last page. With -Paginate this follows that number until it runs out and returns the flattened results; without it the page is returned as the server sent it. - Errors are JSON. A generic failure carries 'detail'; a validation failure carries one array of messages per field, or 'non_field_errors'. Get-AuthentikErrorDetail turns either into one line, and that line is what the thrown exception says. - Rate limiting answers 429 with a Retry-After header in seconds. That is honoured, capped, and otherwise backed off exponentially, as is anything 5xx. The response is decoded from its raw bytes as UTF-8 rather than trusting the charset Windows PowerShell infers, because the seed data carries accented names on purpose and Latin-1 decoding turns every one of them into mojibake. .PARAMETER Method HTTP method. PATCH is included because Authentik updates are partial by convention. .PARAMETER Path The path under /api/v3, with a leading slash and Authentik's trailing slash, for example '/core/users/'. .PARAMETER Body A hashtable or object to send as JSON, or a string sent as-is. .PARAMETER Query Query string parameters. Null and empty values are dropped rather than sent. .PARAMETER Paginate Follow the page numbers and return every result across all pages. .PARAMETER Connection A connection to use instead of the active one. Tests pass one; the service account bootstrap passes the one it is in the middle of proving. .PARAMETER MaxRetry Attempts before a 429 or 5xx is reported as a failure. .OUTPUTS System.Object. The deserialised page, or with -Paginate an array of every result. .EXAMPLE PS> Invoke-AuthentikRequest -Method GET -Path '/core/users/' -Query @{ path = 'zz-test' } -Paginate DESCRIPTION: Lists every user under the seed path across all pages OUTPUT: An array of user objects USE CASE: Ownership discovery before teardown .EXAMPLE PS> Invoke-AuthentikRequest -Method POST -Path '/core/groups/' -Body @{ name = 'ZZ-TEST-All Staff' } DESCRIPTION: Creates a group OUTPUT: The group as Authentik stored it, including its pk USE CASE: Every create in the seed .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory = $true)] [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] [string]$Method, [Parameter(Mandatory = $true)] [ValidatePattern('^/')] [string]$Path, [Parameter()] [object]$Body, [Parameter()] [hashtable]$Query, [Parameter()] [switch]$Paginate, [Parameter()] [hashtable]$Connection, [Parameter()] [ValidateRange(1, 10)] [int]$MaxRetry = 5 ) if (-not $Connection) { $Connection = Get-AuthentikConnection } # Windows PowerShell defaults to TLS 1.0. Only ever add to the enabled set: clearing it # would change behaviour for everything else in the session. if ($PSVersionTable.PSEdition -eq 'Desktop') { $tls12 = [System.Net.SecurityProtocolType]::Tls12 if (([System.Net.ServicePointManager]::SecurityProtocol -band $tls12) -ne $tls12) { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor $tls12 } } $baseUri = '{0}/api/v3{1}' -f $Connection.BaseUrl.TrimEnd('/'), $Path # Page numbers are a query parameter, so the query is rebuilt per page rather than a # next-URL being followed. page_size is raised from the default 20 to keep the number of # round trips proportionate to a few hundred objects. $queryPairs = @{} if ($Query) { foreach ($key in $Query.Keys) { if ($null -eq $Query[$key] -or '' -eq $Query[$key]) { continue } $queryPairs[$key] = [string]$Query[$key] } } if ($Paginate -and -not $queryPairs.ContainsKey('page_size')) { $queryPairs['page_size'] = '100' } $headers = @{ 'Accept' = 'application/json' } if (-not [string]::IsNullOrWhiteSpace($Connection.AuthorizationHeader)) { $headers['Authorization'] = $Connection.AuthorizationHeader } $bodyBytes = $null if ($null -ne $Body) { $bodyText = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 -Compress } $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($bodyText) } $previousProgress = $ProgressPreference $ProgressPreference = 'SilentlyContinue' $collected = [System.Collections.Generic.List[object]]::new() $result = $null try { $page = 1 while ($true) { if ($Paginate) { $queryPairs['page'] = [string]$page } $uri = $baseUri if ($queryPairs.Count -gt 0) { $pairs = foreach ($key in ($queryPairs.Keys | Sort-Object)) { '{0}={1}' -f [uri]::EscapeDataString($key), [uri]::EscapeDataString($queryPairs[$key]) } $uri = '{0}?{1}' -f $baseUri, ($pairs -join '&') } $attempt = 0 $response = $null while ($true) { $attempt++ try { $requestArgs = @{ Uri = $uri Method = $Method Headers = $headers UseBasicParsing = $true ErrorAction = 'Stop' } if ($null -ne $bodyBytes) { $requestArgs.Body = $bodyBytes $requestArgs.ContentType = 'application/json; charset=UTF-8' } Write-Verbose "Authentik $Method $uri (attempt $attempt)" $response = Invoke-WebRequest @requestArgs break } catch { $statusCode = 0 if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) { try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { $statusCode = 0 } } # A throttle or a server error is retried for every verb. A transport # failure with no status at all - a dropped connection, a tunnel hiccup - # is retried only for the verbs that are safe to repeat: a GET reads the # same thing twice, a DELETE that already happened answers 404, but a POST # repeated after a lost response creates a second object. $retryable = ($statusCode -eq 429 -or $statusCode -ge 500) -or ($statusCode -eq 0 -and $Method -in 'GET', 'DELETE') if (-not $retryable -or $attempt -ge $MaxRetry) { # Built into a variable first. A concatenation written inline inside # New-Object's argument list binds as one array argument, and the # exception is then constructed with the message alone and no # InnerException. $statusText = if ($statusCode) { " with HTTP $statusCode" } else { '' } $message = "Authentik $Method $Path failed${statusText}: " + (Get-AuthentikErrorDetail -ErrorRecord $_) throw (New-Object System.Exception($message, $_.Exception)) } $waitSeconds = [Math]::Min(60, [Math]::Pow(2, $attempt)) try { $retryAfter = $_.Exception.Response.Headers['Retry-After'] if ($retryAfter) { $fromHeader = [int](@($retryAfter)[0]) if ($fromHeader -gt 0) { $waitSeconds = [Math]::Min(60, $fromHeader) } } } catch { Write-Verbose 'No usable Retry-After header; backing off instead.' } $reason = if ($statusCode) { "returned HTTP $statusCode" } else { "could not be reached ($($_.Exception.Message))" } Write-Warning ("Authentik $reason for $Method $Path. " + "Retrying in $waitSeconds second(s) (attempt $attempt of $MaxRetry).") Start-Sleep -Seconds $waitSeconds } } $content = $null if ($response.RawContentStream -and $response.RawContentStream.Length -gt 0) { $content = [System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) } elseif ($response.Content -is [byte[]]) { $content = [System.Text.Encoding]::UTF8.GetString($response.Content) } else { $content = [string]$response.Content } $parsed = $null if (-not [string]::IsNullOrWhiteSpace($content)) { $parsed = $content | ConvertFrom-Json } if (-not $Paginate) { $result = $parsed break } # A listing always has 'results'. Anything else under -Paginate is a caller error # worth failing on, not silently returning one object as a page of one. if (-not $parsed -or -not ($parsed.PSObject.Properties.Name -contains 'results')) { throw "Authentik $Method $Path did not return a paginated listing, so -Paginate does not apply." } $collected.AddRange(@($parsed.results)) $nextPage = 0 if ($parsed.pagination -and $parsed.pagination.next) { $nextPage = [int]$parsed.pagination.next } # The self-comparison is the loop guard: a server that hands back the current page # as the next one would otherwise never finish. if ($nextPage -le $page) { break } $page = $nextPage } } finally { $ProgressPreference = $previousProgress } # Returned bare, deliberately. An empty array unrolls to nothing on the pipeline, and # every caller wraps the call in @(), which turns nothing into an empty array. Wrapping # it here with the comma operator instead hands @(call) a single item - the empty array # itself - which counts as one, and the bootstrap then refused to create an account that # did not exist. if ($Paginate) { return $collected.ToArray() } return $result } |