Public/Get-MsecAdoServiceConnection.ps1

function Get-MsecAdoServiceConnection {
    <#
    .SYNOPSIS
        Lists every service connection (service endpoint) in an Azure DevOps
        organization, projected to flat PowerShell rows with the full Graph
        object preserved in Raw.

    .DESCRIPTION
        Calls the Azure DevOps REST API:

            GET https://dev.azure.com/{org}/{project}/_apis/serviceendpoint/endpoints
                ?api-version=7.1-preview.4

        Service connections are project-scoped in ADO, but commonly *shared*
        across projects. This function walks all projects in the org by default
        and de-duplicates by endpoint Id, so each connection appears as one row
        even if it's exposed to multiple projects. The 'Projects' column lists
        every project the connection is currently shared to.

        See the examples for the audit-relevant questions the ADO portal makes painful.

    .EXAMPLE
        # All service connections, sorted by what they connect to.
        Get-MsecAdoServiceConnection -Organization 'contoso' |
            Sort-Object Type | Format-Table Name, Type, AuthScheme, IsShared

    .EXAMPLE
        # Connections to Azure subscriptions specifically: find forgotten ones, and audit
        # the auth scheme - Service Principal vs Managed Identity vs Federated Workload
        # Identity. A long-lived secret here is a standing key to a subscription.
        Get-MsecAdoServiceConnection -Organization 'contoso' |
            Where-Object Type -eq 'azurerm' |
            Select-Object Name, AuthScheme, @{ n = 'SubId'; e = { $_.Raw.data.subscriptionId } },
                          CreatedByName, Projects

    .EXAMPLE
        # Highly shared connections - broad blast radius if one is compromised, because
        # any pipeline in any of those projects can use it.
        Get-MsecAdoServiceConnection -Organization 'contoso' |
            Where-Object { $_.Projects.Count -gt 3 } |
            Sort-Object { $_.Projects.Count } -Descending

    .PARAMETER Organization
        Azure DevOps organization name. The bit before .visualstudio.com in
        the legacy URL, or the path segment after dev.azure.com/ in the modern
        URL. E.g. 'contoso' for https://dev.azure.com/contoso.

    .PARAMETER Project
        Restrict to one project. When omitted, walks every project in the org
        (so the result is the org-wide unique list).

    .NOTES
        The msec app's service principal must be added as a member of the ADO
        organization with at least "Reader" permissions at the project-collection
        level (or project-scoped reader on every project you want to query).
        This is configured INSIDE Azure DevOps (Organization Settings > Users),
        NOT via Entra API permissions - so it's NOT something New-MsecApp can
        provision. A clearer error is raised on the typical 401/403.

        Each row is a [PSCustomObject] with PSTypeName 'MsecAdoServiceConnection'.
        Default Format-Table view: Name, Type, AuthScheme, IsShared - registered
        in Msec.psm1. Raw and other columns remain accessible via property
        access or Format-List.
    #>

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

        [Parameter()]
        [string] $Project
    )

    Assert-MsecSession

    # 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" }

    # Resolve projects to walk.
    $projects = if ($Project) {
        @($Project)
    }
    else {
        $projUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1"
        try {
            $resp = Invoke-RestMethod -Method GET -Uri $projUri -Headers $headers -ErrorAction Stop
        }
        catch {
            if ($_.Exception.Message -match '401|Unauthorized|403|Forbidden') {
                throw "Unauthorized listing projects in '$Organization'. The msec app's service principal needs to be added as a member of the ADO organization (Organization Settings > Users > Add) and granted at least Reader access. Original error: $($_.Exception.Message)"
            }
            throw
        }
        Write-Verbose "Listed $($resp.count) projects in '$Organization'"
        @($resp.value.name)
    }

    # Walk projects, dedupe by endpoint Id. Shared service connections appear in
    # multiple projects' /endpoints responses; the first sighting wins, and we
    # collect every project the endpoint is exposed to from its
    # serviceEndpointProjectReferences array.
    $seen = @{}
    $totalEndpointsSeen = 0
    foreach ($p in $projects) {
        $epUri = "https://dev.azure.com/$Organization/$p/_apis/serviceendpoint/endpoints?api-version=7.1-preview.4"
        try {
            $resp = Invoke-RestMethod -Method GET -Uri $epUri -Headers $headers -ErrorAction Stop
        }
        catch {
            # One unauthorised project shouldn't kill the org-wide walk.
            Write-Warning "Could not list service endpoints for project '$p': $($_.Exception.Message)"
            continue
        }
        $endpointCount = @($resp.value).Count
        $totalEndpointsSeen += $endpointCount
        Write-Verbose "Project '$p': $endpointCount service endpoint(s) visible"

        foreach ($e in $resp.value) {
            if ($seen.ContainsKey($e.id)) { continue }
            $seen[$e.id] = $true

            # Project names where this endpoint is exposed - useful to spot
            # widely-shared connections (broad blast radius).
            $projectNames = @($e.serviceEndpointProjectReferences.projectReference.name)

            [PSCustomObject]@{
                PSTypeName    = 'MsecAdoServiceConnection'
                Id            = $e.id
                Name          = $e.name
                Type          = $e.type
                Url           = $e.url
                Description   = $e.description
                IsShared      = [bool]$e.isShared
                IsReady       = [bool]$e.isReady
                AuthScheme    = $e.authorization.scheme
                CreatedByName = $e.createdBy.displayName
                Projects      = $projectNames
                Raw           = $e
            }
        }
    }

    Write-Verbose "Walked $($projects.Count) project(s); $totalEndpointsSeen total endpoint reference(s) (incl. duplicates from shared connections); $($seen.Count) unique service connection(s) returned"
    if ($projects.Count -gt 0 -and $seen.Count -eq 0) {
        Write-Warning "Walked $($projects.Count) project(s) but found zero service connections. This almost always means the msec SP has 'Project Reader' access (so it can SEE projects) but doesn't have read access to service connections themselves - they have their own permission gate. Either add the SP to each project's 'Endpoint Administrators' group, or grant Reader role at Project Settings > Pipelines > Service connections > Security."
    }
}