public/Get-MsecAzureDevOpsEnvironment.ps1

function Get-MsecAzureDevOpsEnvironment {
    <#
    .SYNOPSIS
        Pipeline environments across an organization, the checks guarding them, and who approves
        - one row per environment.

    .DESCRIPTION
        An environment is what a pipeline deploys TO, and the checks on it are the last thing
        between a pipeline run and production. An environment with no approval check is a
        deployment target nobody signs off: the pipeline reaches it unattended, whenever it runs.

        NO CHECKS IS THE FINDING, and it is easy to miss because it looks like nothing. An
        environment that has never had a check configured returns an empty list, which is the
        same shape as one whose checks could not be read - so those two are reported differently:
        CheckCount 0 means none are configured, $null means the read failed.

        AN APPROVAL WITH NO APPROVERS APPROVES NOTHING USEFUL. Approvers are resolved to names
        where the API gives them, and a check configured against a group is reported as that
        group - who is IN the group is a separate question, answerable with
        Get-MsecAzureDevOpsUser.

        OPEN TO ALL PIPELINES applies here as it does to service connections and variable groups:
        any pipeline in the project may deploy to the environment with no further authorization.
        Combined with no approval check, that is a production target reachable by a pipeline
        somebody writes this afternoon.

    .PARAMETER Organization
        Azure DevOps organization name: the path segment after dev.azure.com/.

    .PARAMETER Project
        Restrict to one project. All projects by default.

    .PARAMETER Unchecked
        Only environments with no checks configured at all.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec
        Get-MsecAzureDevOpsEnvironment -Organization 'contoso' -Unchecked

    .EXAMPLE
        # Reachable by any pipeline, with nobody approving.
        Get-MsecAzureDevOpsEnvironment -Organization 'contoso' |
            Where-Object { $_.OpenToAllPipelines -and -not $_.HasApproval }

    .OUTPUTS
        PSCustomObject per environment, PSTypeName 'MsecAzureDevOpsEnvironment'.

    .NOTES
        Needs Connect-Msec and organization membership. One call per project to list
        environments, then two per environment - the checks and the pipeline authorization.
    #>

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

        [string] $Project,

        [switch] $Unchecked
    )

    Assert-MsecSession

    $projects = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization `
                      -HostName 'dev.azure.com' -ApiVersion '7.1' -Path '_apis/projects' -All)
    if ($Project) {
        $projects = @($projects | Where-Object { $_.name -eq $Project })
        if (-not $projects.Count) { throw "No project named '$Project' in '$Organization'." }
    }
    if (-not $projects.Count) {
        Write-Warning "No projects returned for '$Organization'. That is 'nothing was read', not 'no projects'."
        return
    }

    $unreadable = [System.Collections.Generic.List[string]]::new()

    foreach ($proj in $projects) {
        $environments = @()
        try {
            $environments = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization `
                                  -HostName 'dev.azure.com' -ApiVersion '7.1-preview.1' `
                                  -Path "$($proj.id)/_apis/distributedtask/environments")
        }
        catch {
            $unreadable.Add($proj.name)
            Write-Verbose "Could not read environments in '$($proj.name)': $($_.Exception.Message)"
            continue
        }

        foreach ($environment in $environments) {
            $checks = $null
            try {
                $checks = @(Invoke-MsecAzureDevOpsRequest -Organization $Organization `
                                -HostName 'dev.azure.com' -ApiVersion '7.1-preview.1' `
                                -Path "$($proj.id)/_apis/pipelines/checks/configurations?resourceType=environment&resourceId=$($environment.id)&`$expand=settings")
            }
            catch { Write-Verbose "Could not read checks for environment '$($environment.name)': $($_.Exception.Message)" }

            $approvals = @($checks | Where-Object { $_.type.name -eq 'Approval' })
            # The API nests approvers under settings; a check configured against a group reports
            # the group, which is the honest answer - who is in it is a separate question.
            $approvers = @($approvals | ForEach-Object { $_.settings.approvers.displayName } | Where-Object { $_ } | Sort-Object -Unique)

            $open = $null; $authorized = $null; $openedBy = $null; $openedOn = $null
            try {
                $perms = Invoke-MsecAzureDevOpsRequest -Organization $Organization `
                             -HostName 'dev.azure.com' -ApiVersion '7.1-preview.1' `
                             -Path "$($proj.id)/_apis/pipelines/pipelinePermissions/environment/$($environment.id)"
                $open       = [bool] $perms.allPipelines.authorized
                $authorized = @($perms.pipelines).Count
                $openedBy   = $perms.allPipelines.authorizedBy.displayName
                $openedOn   = $perms.allPipelines.authorizedOn
            }
            catch { Write-Verbose "Could not read pipeline permissions for environment '$($environment.name)': $($_.Exception.Message)" }

            $row = [PSCustomObject]@{
                PSTypeName    = 'MsecAzureDevOpsEnvironment'
                Organization  = $Organization
                Project       = $proj.name
                Environment   = $environment.name
                # 0 means no checks are configured. $null means the read failed - a deployment
                # target nobody looked at must not read as one nobody guards.
                CheckCount    = if ($null -eq $checks) { $null } else { $checks.Count }
                Checks        = if ($null -eq $checks) { $null } else { (@($checks | ForEach-Object { $_.type.name }) | Sort-Object -Unique) -join ', ' }
                HasApproval   = if ($null -eq $checks) { $null } else { $approvals.Count -gt 0 }
                # An approval with nobody named on it is a gate that cannot be satisfied by
                # anyone in particular.
                ApproverCount = if ($null -eq $checks) { $null } else { $approvers.Count }
                Approvers     = if ($null -eq $checks) { $null } else { $approvers -join '; ' }

                OpenToAllPipelines      = $open
                AuthorizedPipelineCount = $authorized
                OpenedBy                = $openedBy
                OpenedOn                = $openedOn

                LastModifiedBy = $environment.lastModifiedBy.displayName
                LastModifiedOn = $environment.lastModifiedOn
                CreatedBy      = $environment.createdBy.displayName
                Id             = $environment.id
            }

            # $null is not evidence of being unchecked.
            if ($Unchecked -and $row.CheckCount -ne 0) { continue }
            $row
        }
    }

    if ($unreadable.Count) {
        $shown = ($unreadable | Select-Object -First 5) -join ', '
        $more  = if ($unreadable.Count -gt 5) { " and $($unreadable.Count - 5) more" } else { '' }
        Write-Warning "$($unreadable.Count) project(s) refused their environments: $shown$more. Those are NOT in this output - treat them as unread, not as projects without deployment targets."
    }
}