public/Get-MsecAzureDevOpsUser.ps1

function Get-MsecAzureDevOpsUser {
    <#
    .SYNOPSIS
        Every user in an Azure DevOps organization and the groups they belong to - one row per
        membership, for an access review.

    .DESCRIPTION
        Answers "who can reach this organization, and through what". ADO permissions are almost
        always granted through group membership, so a user list without groups says nothing
        about what anyone can do.

        PAGINATED, WHICH THE OBVIOUS IMPLEMENTATION IS NOT. The ADO graph APIs return one page
        and put the cursor in the X-MS-ContinuationToken RESPONSE HEADER. Reading only
        $response.value returns the first page with no error and no sign that more existed - in
        an access review the users that go missing look exactly like users who do not exist.

        GROUP NAMES ARE RESOLVED FROM ONE FETCH, not one call per membership. The direct
        translation of "for each user, for each membership, get the group" is a call per
        membership - on a few hundred users that is thousands of round trips for a few dozen
        distinct groups.

        A USER IN NO GROUP STILL GETS A ROW, with Group '(none)'. Emitting nothing for them
        would drop the account from the review entirely, and an account nobody granted anything
        to is worth seeing rather than losing.

        ORIGIN SEPARATES ENTRA-BACKED ACCOUNTS FROM LOCAL ONES. An 'aad' user is governed by
        Conditional Access, MFA and the joiner/leaver process; a 'vsts' user is an account that
        exists only inside Azure DevOps and survives everything that happens in Entra.

    .PARAMETER Organization
        Azure DevOps organization name: the path segment after dev.azure.com/, e.g. 'contoso'.

    .PARAMETER SubjectType
        Restrict to particular subject SUBTYPES: 'aad' (Entra-backed), 'msa' (Microsoft
        account), 'svc' (service identity), 'imp' (imported). Omitted by default, which returns
        every kind - including service identities, so a service principal quietly holding
        Project Collection Administrators shows up without asking for it.

        These are the codes the API uses. Passing anything else - 'user', 'group' - is not
        rejected: it matches no subtype and returns an EMPTY LIST, which reads as an
        organization with nobody in it.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec -TenantId <guid> -ClientId <guid>
        Get-MsecAzureDevOpsUser -Organization 'contoso' | Format-Table DisplayName, PrincipalName, Group

    .EXAMPLE
        # The membership that matters most.
        Get-MsecAzureDevOpsUser -Organization 'contoso' |
            Where-Object Group -match 'Project Collection Administrators'

    .EXAMPLE
        # Accounts that exist only in Azure DevOps - no Conditional Access, no leaver process.
        Get-MsecAzureDevOpsUser -Organization 'contoso' | Where-Object Origin -ne 'aad' |
            Select-Object DisplayName, PrincipalName -Unique

    .EXAMPLE
        # Service identities only.
        Get-MsecAzureDevOpsUser -Organization 'contoso' -SubjectType svc

    .OUTPUTS
        PSCustomObject per (user, group), PSTypeName 'MsecAzureDevOpsUser'.

    .NOTES
        Needs Connect-Msec, and the msec app's service principal must 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.

        One call per user is unavoidable - memberships are only addressable per subject - so
        this is O(users) round trips and takes a while on a large organization.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string] $Organization,

        # ValidateSet, because a wrong value here does not fail - it silently filters everything
        # out. 'user' and 'group' look like the obvious values and are not.
        [ValidateSet('aad', 'msa', 'svc', 'imp')]
        [string[]] $SubjectType
    )

    Assert-MsecSession

    # Groups first, once, as a lookup. See the note in the help about why this is not done per
    # membership.
    $groups = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization -Path '_apis/graph/groups' -All)
    $groupByDescriptor = @{}
    foreach ($group in $groups) {
        if ($group.descriptor) { $groupByDescriptor[[string] $group.descriptor] = $group }
    }
    Write-Verbose "Resolved $($groupByDescriptor.Count) group(s) in '$Organization'."

    # NO subjectTypes FILTER BY DEFAULT. The parameter takes subtype codes - aad, msa, svc, imp -
    # not subject kinds, and /graph/users already returns only users. Sending 'user' matches no
    # subtype at all and comes back empty, which is indistinguishable from an organization the
    # app cannot read.
    $path = '_apis/graph/users'
    if ($SubjectType) { $path += "?subjectTypes=$($SubjectType -join ',')" }
    $subjects = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization -Path $path -All)

    if (-not $subjects.Count) {
        Write-Warning "No subjects returned for '$Organization'. That is not the same as an empty organization - it usually means the app can authenticate but cannot read the identity graph. Treat this as unread, not as clean."
        return
    }

    foreach ($subject in $subjects) {
        $descriptor = [string] $subject.descriptor
        if (-not $descriptor) { continue }

        # direction=up walks from the subject to the containers it belongs to. Memberships are
        # only addressable per subject, so this call cannot be batched.
        $memberships = @()
        $unreadable = $false
        try {
            # $($descriptor), NOT $descriptor. '?' is a legal character in a PowerShell variable
            # name, so "$descriptor?direction=up" parses as the variable 'descriptor?direction'
            # - undefined, therefore empty - and the URL silently becomes
            # '_apis/graph/memberships/=up'. It fails as a 404 per user, which reads like a
            # missing identity rather than a mangled request.
            $memberships = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization `
                                 -Path "_apis/graph/memberships/$($descriptor)?direction=up")
        }
        catch {
            # Named, not dropped: a user whose memberships could not be read is not a user with
            # no access, and silently omitting them understates the review.
            Write-Warning "Could not read group memberships for '$($subject.principalName)': $($_.Exception.Message)"
            $unreadable = $true
        }

        $rows = foreach ($membership in $memberships) {
            $container = [string] $membership.containerDescriptor
            $group = $groupByDescriptor[$container]

            [PSCustomObject]@{
                PSTypeName     = 'MsecAzureDevOpsUser'
                Organization   = $Organization
                DisplayName    = $subject.displayName
                PrincipalName  = $subject.principalName
                # 'aad' is Entra-backed; 'vsts' exists only inside Azure DevOps.
                Origin         = $subject.origin
                SubjectKind    = $subject.subjectKind
                # A container this module could not resolve is reported by its descriptor rather
                # than as a blank - the membership is real either way.
                Group          = if ($group) { $group.displayName } else { "(unresolved: $container)" }
                GroupPrincipal = if ($group) { $group.principalName } else { $null }
                Descriptor     = $descriptor
            }
        }
        $rows = @($rows)

        if ($rows.Count) {
            $rows
        }
        else {
            [PSCustomObject]@{
                PSTypeName     = 'MsecAzureDevOpsUser'
                Organization   = $Organization
                DisplayName    = $subject.displayName
                PrincipalName  = $subject.principalName
                Origin         = $subject.origin
                SubjectKind    = $subject.subjectKind
                # '(none)' and '(unreadable)' are different answers and must not collapse: one
                # is an account with no group, the other is an account we failed to ask about.
                Group          = if ($unreadable) { '(unreadable)' } else { '(none)' }
                GroupPrincipal = $null
                Descriptor     = $descriptor
            }
        }
    }
}