public/Get-MsecAzureDevOpsServiceConnection.ps1

function Get-MsecAzureDevOpsServiceConnection {
    <#
    .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-MsecAzureDevOpsServiceConnection -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-MsecAzureDevOpsServiceConnection -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-MsecAzureDevOpsServiceConnection -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 'MsecAzureDevOpsServiceConnection'.
        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,

        # Who can use or administer each connection, and which pipelines may reference it.
        # OPT-IN because it costs two extra calls per connection - 243 connections on the
        # organization this was built against, so nearly 500 round trips.
        #
        # Without it the security columns are $null, which reads as "not collected" rather than
        # "nobody has access" - the same distinction this command draws everywhere else.
        [Parameter()]
        [switch] $IncludeSecurity
    )

    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
    # Projects that answered 200 with nothing in them. Tracked because that answer is
    # ambiguous - see the warning at the end.
    $emptyProjects = [System.Collections.Generic.List[string]]::new()
    # Counted so -IncludeSecurity cannot look like a no-op when the reads are refused.
    $securityFailures = 0
    $securityTried    = 0
    foreach ($p in $projects) {
        # Encoded: project names routinely contain spaces - 17 of 36 did on the organization
        # this was built against - and passing one raw to -Project built a malformed URL.
        $epUri = "https://dev.azure.com/$Organization/$([uri]::EscapeDataString($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
        if ($endpointCount -eq 0) { $emptyProjects.Add($p) }
        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)

            $security = [pscustomobject]@{
                Administrators = $null; AdministratorCount = $null; UserCount = $null
                ReaderCount = $null; OpenToAllPipelines = $null; AuthorizedPipelineCount = $null
                OpenedBy = $null; OpenedOn = $null
            }
            if ($IncludeSecurity) {
                $securityTried++
                # The endpoint's own project, which is where both of these are addressed from.
                $ownerProjectId = @($e.serviceEndpointProjectReferences.projectReference.id)[0]

                try {
                    $roles = @((Invoke-RestMethod -Method GET -Headers $headers -Uri (
                        "https://dev.azure.com/$Organization/_apis/securityroles/scopes/distributedtask.serviceendpointrole" +
                        "/roleassignments/resources/$($ownerProjectId)_$($e.id)?api-version=7.1-preview.1")).value)

                    $byRole = { param($n) @($roles | Where-Object { $_.role.name -eq $n }) }
                    $admins = & $byRole 'Administrator'
                    $security.Administrators     = ($admins | ForEach-Object { $_.identity.displayName } | Sort-Object -Unique) -join '; '
                    $security.AdministratorCount = $admins.Count
                    $security.UserCount          = @(& $byRole 'User').Count
                    $security.ReaderCount        = @(& $byRole 'Reader').Count
                }
                catch {
                    # Left $null. A connection whose roles could not be read must not report as
                    # having no administrators. Counted, and reported once at the end - a silent
                    # $null makes the switch look like it does nothing.
                    $securityFailures++
                    Write-Verbose "Could not read roles for '$($e.name)': $($_.Exception.Message)"
                }

                try {
                    $perms = (Invoke-RestMethod -Method GET -Headers $headers -Uri (
                        "https://dev.azure.com/$Organization/$ownerProjectId/_apis/pipelines/pipelinePermissions/endpoint/$($e.id)?api-version=7.1-preview.1"))
                    # The field is absent unless the setting is on, so absence is false - but a
                    # failed CALL stays $null above, which is a different thing.
                    $security.OpenToAllPipelines  = [bool] $perms.allPipelines.authorized
                    $security.AuthorizedPipelineCount = @($perms.pipelines).Count
                    # Who opened it, and when - the decision is usually old.
                    $security.OpenedBy = $perms.allPipelines.authorizedBy.displayName
                    $security.OpenedOn = $perms.allPipelines.authorizedOn
                }
                catch {
                    Write-Verbose "Could not read pipeline permissions for '$($e.name)': $($_.Exception.Message)"
                }
            }

            [PSCustomObject]@{
                PSTypeName    = 'MsecAzureDevOpsServiceConnection'
                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

                # $null unless -IncludeSecurity: not collected, not "none".
                Administrators          = $security.Administrators
                AdministratorCount      = $security.AdministratorCount
                UserCount               = $security.UserCount
                ReaderCount             = $security.ReaderCount
                # TRUE IS THE PERMISSIVE STATE: "Grant access permission to all pipelines" is on,
                # so any pipeline in the project may authenticate through this connection with no
                # further approval. FALSE means pipelines are authorised individually, and those
                # appear in AuthorizedPipelineCount. The API omits the field entirely when the
                # setting is off, so absence is reported as false - but a failed CALL stays $null.
                OpenToAllPipelines  = $security.OpenToAllPipelines
                AuthorizedPipelineCount = $security.AuthorizedPipelineCount
                OpenedBy                = $security.OpenedBy
                OpenedOn                = $security.OpenedOn

                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"
    # WHAT THIS NEEDS IS THE 'User' ROLE, established against a live organization after four
    # wrong answers. Service connections use ROLE ASSIGNMENTS
    # (distributedtask.serviceendpointrole), not the ServiceEndpoints security namespace - an
    # allow on that namespace is accepted, stored, reported back, and confers nothing. And
    # within the role model, 'Reader' is not enough: an identity holding Reader on every
    # connection in a project, inherited and effective, still gets an empty list. 'User' is what
    # this API checks.
    #
    # GRANT IT ONCE FOR THE ORGANIZATION, on the namespace side. The two permission systems are
    # connected: an allow at the ServiceEndpoints root token 'endpoints' surfaces as an inherited
    # ROLE on every project and connection.
    #
    # ViewEndpoint (bit 16) -> inherited Reader -> still an empty list
    # Use (bit 1) -> inherited User -> what this API checks
    #
    # Grant-MsecAzureDevOpsPermission -Organization <org> -Identity <group> `
    # -Namespace ServiceEndpoints -Permission Use -Scope Organization
    #
    # Verified: one write took an organization from 70 connections in 1 project to 243 across 14.
    # Per-project role assignments (-RoleName User -Scope Project) do the same thing one project
    # at a time and are not needed.
    #
    # 'User' means "may authenticate through this connection", which reads alarming for a
    # read-only module. In practice the app would also need to author and run a pipeline to
    # exploit it, which needs Contribute on a repository and build permissions it does not have.
    # Worth knowing rather than waving away.
    #
    # A PROJECT WITH NO VISIBLE ENDPOINTS RETURNS 200 AND AN EMPTY LIST - the same answer as a
    # project that genuinely has none. Service connections are permissioned per connection, so an
    # identity can see some and not others within one project, and the response never says what
    # it withheld. Measured on a live organization: an app in [Project]\Readers on 36 projects
    # saw 70 connections in 1 project where a person saw 155 across 14.
    #
    # The shortfall is still reported rather than assumed away, because a project can withhold
    # connections for reasons this command cannot see - but the usual cause is the missing
    # 'User' role above.
    if ($IncludeSecurity -and $securityFailures) {
        $which = if ($securityFailures -eq $securityTried) { 'every connection' } else { "$securityFailures of $securityTried connection(s)" }
        Write-Warning "-IncludeSecurity could not read role assignments for $which, so Administrators, AdministratorCount, UserCount and ReaderCount are `$null rather than counts. Reading a connection's roles needs more than listing it - grant the group 'Administer' is NOT required, but the reader must be able to see the role assignments on the endpoint scope."
    }

    if ($emptyProjects.Count) {
        $shown = ($emptyProjects | Select-Object -First 5) -join ', '
        $more  = if ($emptyProjects.Count -gt 5) { " and $($emptyProjects.Count - 5) more" } else { '' }
        Write-Warning "$($emptyProjects.Count) of $($projects.Count) project(s) returned no service connections: $shown$more. That is NOT proof they have none - this API answers 200 with an empty list for connections the caller cannot see. Compare against what a person sees in the portal before treating this as the full picture."
    }
}