Modules/Private/Extraction/Start-AZTIDevOpsExtraction.ps1
|
<# .Synopsis Extract Azure DevOps organizations, projects, pipelines, service connections, repositories, and agent pools via the Azure DevOps REST API. .DESCRIPTION Mirrors the Entra extraction model: every item is normalized into a synthetic resource (e.g. 'devops/pipelines') and appended to the main resource array, so the ordinary inventory modules can filter and report on it exactly like an ARM resource. Each normalized resource has: - id : Stable identifier, prefixed with the organization - name : Display name - type : Synthetic TYPE string (e.g. 'devops/projects') - tenantId : The tenant ID - properties : Nested PSObject containing the full original payload Authentication resolves in this order: 1. -Pat, when supplied — HTTP basic with the PAT as the password. 2. The current Azure sign-in — an Entra access token for the Azure DevOps resource ID (499b84ac-1321-427f-aa17-267ca6975798). Option 2 is the default and is why no PAT is required for the common case: the identity already signed in for the ARM inventory is normally the same identity that can read Azure DevOps. A PAT is only needed when the two differ, or for an organization not backed by the signed-in tenant. Organizations are discovered from the signed-in profile when -Organization is not supplied. Discovery needs a user identity; a service principal must name its organizations explicitly because the accounts endpoint has no profile to enumerate. .PARAMETER TenantID The Entra tenant identifier, stamped onto each normalized resource. .PARAMETER Organization One or more Azure DevOps organization names (the 'contoso' in dev.azure.com/contoso). When omitted, organizations are discovered from the signed-in profile. .PARAMETER Pat Personal access token. Needs, at minimum, read scopes for Project and Team, Build, Release, Code, Service Connections, and Agent Pools. .OUTPUTS [PSCustomObject] with property DevOpsResources (array of normalized objects). .LINK https://github.com/thisismydemo/azure-scout .COMPONENT This PowerShell Module is part of Azure Scout (AZSC) .NOTES Work item: AB#327 Azure Scout is read-only. Every call here is a GET. #> function Start-AZSCDevOpsExtraction { [CmdletBinding()] param( [string]$TenantID, [string[]]$Organization, [string]$Pat ) Write-Host 'Starting Azure DevOps Extraction: ' -NoNewline Write-Host '5 Resource Types' -ForegroundColor Cyan $allDevOpsResources = [System.Collections.Generic.List[object]]::new() # ── Authentication header ──────────────────────────────────────────────── $headers = $null if (-not [string]::IsNullOrWhiteSpace($Pat)) { $pair = ':' + $Pat $encoded = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)) $headers = @{ Authorization = 'Basic ' + $encoded } Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Azure DevOps auth: personal access token.') } else { try { # 499b84ac-1321-427f-aa17-267ca6975798 is the fixed first-party application ID # for Azure DevOps. Requesting a token for it reuses the existing Azure sign-in. $token = Get-AzAccessToken -ResourceUrl '499b84ac-1321-427f-aa17-267ca6975798' -ErrorAction Stop $rawToken = if ($token.Token -is [System.Security.SecureString]) { # Az 14+ returns a SecureString by default. [System.Net.NetworkCredential]::new('', $token.Token).Password } else { $token.Token } $headers = @{ Authorization = 'Bearer ' + $rawToken } Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Azure DevOps auth: Entra access token from the current sign-in.') } catch { Write-Warning ('Could not acquire an Azure DevOps token from the current sign-in: ' + $_.Exception.Message + ' Supply -DevOpsPat to authenticate with a personal access token instead. Skipping Azure DevOps extraction.') return [PSCustomObject]@{ DevOpsResources = @() } } } # ── Helper: a single GET, returning $null rather than throwing ─────────── function Invoke-DevOpsRequest { param([string]$Uri) try { Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Azure DevOps GET: ' + $Uri) return Invoke-RestMethod -Uri $Uri -Headers $headers -Method Get -ErrorAction Stop } catch { $status = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } # 401/403 on one endpoint is routine — a PAT or identity may hold Project # read but not Service Connection read. Skip that slice, keep the rest. if ($status -in 401, 403) { Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Access denied (' + $status + '): ' + $Uri) } elseif ($status -eq 404) { Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Not found: ' + $Uri) } else { Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Azure DevOps request failed: ' + $_.Exception.Message) } return $null } } # ── Helper: normalize into the standard resource shape ─────────────────── function Add-NormalizedDevOpsResource { param( [object[]]$Items, [string]$SyntheticType, [string]$OrgName, [string]$NameProperty = 'name' ) foreach ($item in $Items) { if ($null -eq $item) { continue } $name = $null if ($item.PSObject.Properties.Name -contains $NameProperty) { $name = $item.$NameProperty } if ([string]::IsNullOrWhiteSpace($name)) { $name = 'unnamed' } $itemId = if ($item.PSObject.Properties.Name -contains 'id') { $item.id } else { [guid]::NewGuid().ToString() } $allDevOpsResources.Add([PSCustomObject]@{ id = $OrgName + '/' + $itemId name = $name type = $SyntheticType tenantId = $TenantID organization = $OrgName properties = $item }) } } # ── Resolve the organizations to scan ──────────────────────────────────── $orgs = @() if ($Organization) { $orgs = @($Organization | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Using ' + $orgs.Count + ' explicitly supplied organization(s).') } else { $profileMe = Invoke-DevOpsRequest -Uri 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.0' if ($profileMe -and $profileMe.PSObject.Properties.Name -contains 'id') { $accounts = Invoke-DevOpsRequest -Uri ('https://app.vssps.visualstudio.com/_apis/accounts?memberId=' + $profileMe.id + '&api-version=7.0') if ($accounts -and $accounts.PSObject.Properties.Name -contains 'value') { $orgs = @($accounts.value | ForEach-Object { $_.accountName }) } } if (-not $orgs -or $orgs.Count -eq 0) { Write-Warning 'No Azure DevOps organizations could be discovered from the signed-in identity. Service principals cannot enumerate organizations — pass -DevOpsOrganization to name them explicitly. Skipping Azure DevOps extraction.' return [PSCustomObject]@{ DevOpsResources = @() } } Write-Debug ((Get-Date -Format 'yyyy-MM-dd_HH_mm_ss') + ' - Discovered ' + $orgs.Count + ' organization(s).') } # ── Collect ────────────────────────────────────────────────────────────── foreach ($org in $orgs) { Write-Host (' Organization: ' + $org) -ForegroundColor DarkGray $projects = Invoke-DevOpsRequest -Uri ('https://dev.azure.com/' + $org + '/_apis/projects?$top=1000&api-version=7.0') if (-not $projects -or $projects.PSObject.Properties.Name -notcontains 'value') { Write-Host (' [SKIP] No project access in ' + $org) -ForegroundColor DarkGray continue } $projectList = @($projects.value) Add-NormalizedDevOpsResource -Items $projectList -SyntheticType 'devops/projects' -OrgName $org Write-Host (' [OK] Projects: ' + $projectList.Count) -ForegroundColor DarkGray $pipelineCount = 0 $endpointCount = 0 $repoCount = 0 foreach ($project in $projectList) { $projectName = [uri]::EscapeDataString($project.name) $pipelines = Invoke-DevOpsRequest -Uri ('https://dev.azure.com/' + $org + '/' + $projectName + '/_apis/pipelines?$top=1000&api-version=7.0') if ($pipelines -and $pipelines.PSObject.Properties.Name -contains 'value') { $enriched = @($pipelines.value | ForEach-Object { $_ | Add-Member -NotePropertyName 'projectName' -NotePropertyValue $project.name -Force -PassThru }) Add-NormalizedDevOpsResource -Items $enriched -SyntheticType 'devops/pipelines' -OrgName $org $pipelineCount += @($enriched).Count } $endpoints = Invoke-DevOpsRequest -Uri ('https://dev.azure.com/' + $org + '/' + $projectName + '/_apis/serviceendpoint/endpoints?api-version=7.0') if ($endpoints -and $endpoints.PSObject.Properties.Name -contains 'value') { $enriched = @($endpoints.value | ForEach-Object { $_ | Add-Member -NotePropertyName 'projectName' -NotePropertyValue $project.name -Force -PassThru }) Add-NormalizedDevOpsResource -Items $enriched -SyntheticType 'devops/serviceconnections' -OrgName $org $endpointCount += @($enriched).Count } $repos = Invoke-DevOpsRequest -Uri ('https://dev.azure.com/' + $org + '/' + $projectName + '/_apis/git/repositories?api-version=7.0') if ($repos -and $repos.PSObject.Properties.Name -contains 'value') { $enriched = @($repos.value | ForEach-Object { $_ | Add-Member -NotePropertyName 'projectName' -NotePropertyValue $project.name -Force -PassThru }) Add-NormalizedDevOpsResource -Items $enriched -SyntheticType 'devops/repositories' -OrgName $org $repoCount += @($enriched).Count } } Write-Host (' [OK] Pipelines: ' + $pipelineCount) -ForegroundColor DarkGray Write-Host (' [OK] Service Connections: ' + $endpointCount) -ForegroundColor DarkGray Write-Host (' [OK] Repositories: ' + $repoCount) -ForegroundColor DarkGray # Agent pools are organization-scoped, not project-scoped. $pools = Invoke-DevOpsRequest -Uri ('https://dev.azure.com/' + $org + '/_apis/distributedtask/pools?api-version=7.0') if ($pools -and $pools.PSObject.Properties.Name -contains 'value') { $poolList = @($pools.value) Add-NormalizedDevOpsResource -Items $poolList -SyntheticType 'devops/agentpools' -OrgName $org Write-Host (' [OK] Agent Pools: ' + $poolList.Count) -ForegroundColor DarkGray } } Write-Host ('Azure DevOps Extraction Complete: ' + $allDevOpsResources.Count + ' total resources') -ForegroundColor Cyan return [PSCustomObject]@{ DevOpsResources = $allDevOpsResources.ToArray() } } |