private/Invoke-MsecAzureDevOpsRequest.ps1

function Invoke-MsecAzureDevOpsRequest {
    <#
        One place for the three things every Azure DevOps call in this module has to get right:
        the token, the continuation header, and what a 401 actually means.

        PAGINATION IS THE REASON THIS EXISTS. The ADO graph APIs return a page and put the
        cursor in an X-MS-CONTINUATION-TOKEN RESPONSE HEADER - not in the body, where every
        other API in this module keeps it. Code that reads only $response.value gets the first
        page, no error, and no indication there was more. In an access review that is the worst
        possible failure: the users who are missing look exactly like users who do not exist.

        401/403 here almost never means the Entra token is wrong. It means the msec app is not
        a member of the ADO organization, which is granted INSIDE Azure DevOps and cannot be
        fixed by New-MsecApp, so the message says so rather than pointing at API permissions.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Organization,

        # Everything after the organization, e.g. '_apis/graph/users'. May carry its own query
        # string - 'memberships/{d}?direction=up' - and the api-version is appended correctly
        # either way.
        [Parameter(Mandatory)]
        [string] $Path,

        # The graph/identity APIs live on vssps, the rest on dev.azure.com. NOT named $Host:
        # that is an automatic variable, and assigning it silently breaks Write-Host.
        [string] $HostName = 'vssps.dev.azure.com',

        [string] $ApiVersion = '7.1-preview.1',

        # Follow the continuation header to the end. Off by default so a single-object call
        # (one group, one user) does not pay for a loop it cannot use.
        [switch] $All
    )

    # ADO is a separate Entra resource - 499b84ac-1321-427f-aa17-267ca6975798 is Microsoft's
    # well-known Azure DevOps app ID. Get-MsecAccessToken appends /.default itself, so pass the
    # bare resource identifier (NOT '.../.default' - that produces a malformed
    # '.../default/.default' scope and Entra 400s).
    try {
        $token = Get-MsecAccessToken -Resource '499b84ac-1321-427f-aa17-267ca6975798'
    }
    catch {
        throw "Could not acquire an Entra token for Azure DevOps. This is a token-request failure (Entra-side), NOT an ADO membership failure. Check the msec app's certificate is still valid and that Connect-Msec succeeded. Original error: $($_.Exception.Message)"
    }

    $headers = @{ Authorization = "Bearer $token" }
    $separator = if ($Path -match '\?') { '&' } else { '?' }
    $results = [System.Collections.Generic.List[object]]::new()
    $continuation = $null
    $page = 0

    do {
        $uri = "https://$HostName/$Organization/$Path$separator" + "api-version=$ApiVersion"
        if ($continuation) { $uri += "&continuationToken=$([uri]::EscapeDataString($continuation))" }

        # Invoke-WebRequest, not Invoke-RestMethod, because the continuation cursor arrives in a
        # HEADER. Invoke-RestMethod only surfaces headers through -ResponseHeadersVariable, an
        # out-variable side effect - which cannot be produced by a mock, so the paging loop
        # would be untestable and this is the one part that must not be got wrong.
        try {
            $web = Invoke-WebRequest -Method GET -Uri $uri -Headers $headers -ErrorAction Stop
            $response = if ($web.Content) { $web.Content | ConvertFrom-Json } else { $null }
            $responseHeaders = $web.Headers
        }
        catch {
            $detail = $_.Exception.Message
            if ($detail -match '401|403|Unauthorized|Forbidden') {
                throw "Unauthorized calling '$Path' in organization '$Organization'. The msec app's service principal needs to be a member of the ADO organization (Organization Settings > Users > Add) with at least Reader. That is granted inside Azure DevOps, NOT through Entra API permissions, so New-MsecApp cannot do it. Original error: $detail"
            }
            throw "Azure DevOps request failed for '$Path' in '$Organization': $detail"
        }

        if ($null -ne $response -and $response.PSObject.Properties.Name -contains 'value') {
            $results.AddRange(@($response.value))
        }
        elseif ($null -ne $response) {
            $results.Add($response)
        }

        $continuation = $null
        if ($responseHeaders -and $responseHeaders['X-MS-ContinuationToken']) {
            $continuation = @($responseHeaders['X-MS-ContinuationToken'])[0]
        }
        $page++
    } while ($All -and $continuation)

    if ($page -gt 1) { Write-Verbose "Read $page page(s) from '$Path' in '$Organization'." }

    # A guard against a server that keeps handing back the same cursor: without -All we stop
    # after one page anyway, and the caller sees a short answer rather than a hung shell.
    if (-not $All -and $continuation) {
        Write-Verbose "'$Path' has more pages; call with -All to read them."
    }

    # NOT ', $results.ToArray()'. The unary comma emits the array as a SINGLE object, so a
    # caller writing @(Invoke-MsecAzureDevOpsRequest ...) - which every caller does - gets a
    # one-element collection holding an array, and an EMPTY result counts as one. Downstream
    # that turns "no users" into one nameless user and skips the empty-response warning
    # entirely. Emitting the elements is what callers expect.
    $results.ToArray()
}