public/Get-MsecAzureDevOpsOrganization.ps1
|
function Get-MsecAzureDevOpsOrganization { <# .SYNOPSIS Every Azure DevOps organization connected to the Entra tenant, with its owner. .DESCRIPTION Anyone in the tenant can create an Azure DevOps organization, and by default nothing announces it. The result is organizations nobody is reviewing: created for a trial or a side project, owned by one person, holding repositories and service connections that no governance process knows about. Measured on a live tenant: 28 organizations, most of them named after individuals. THIS IS THE COMMAND THAT TELLS YOU WHAT TO POINT THE OTHERS AT. Every other Get-MsecAzureDevOps* command takes -Organization, and the answer is only as complete as the list of organizations you thought to check. THE ENDPOINT IS INTERNAL. There is no documented REST API for enumerating a tenant's organizations; this is the route behind the Azure DevOps organization list in the Entra admin portal, and it returns CSV rather than JSON. Microsoft can change or remove it without notice. If this starts returning nothing, that is the first thing to suspect - which is why an empty result warns rather than reporting a tenant with no organizations. THE OWNER IS THE ACCOUNTABLE PERSON, not necessarily an administrator. It is whoever created the organization or had ownership transferred to them, and it is the single most useful column here: an organization whose owner has left the company is one nobody can administer. .PARAMETER TenantId The Entra tenant to enumerate. Defaults to the tenant of the current msec session, which is almost always what you want. .EXAMPLE Connect-Msec -KeyVaultName kv-msec Get-MsecAzureDevOpsOrganization | Sort-Object Owner .EXAMPLE # Organizations named after a person - usually personal, usually unreviewed. Get-MsecAzureDevOpsOrganization | Where-Object { $_.Organization -notmatch '^(contoso|prod|shared)' } .EXAMPLE # Feed the whole estate through another command. Get-MsecAzureDevOpsOrganization | ForEach-Object { Get-MsecAzureDevOpsOrganizationPolicy -Organization $_.Organization } .OUTPUTS PSCustomObject per organization, PSTypeName 'MsecAzureDevOpsOrganization'. .NOTES Needs Connect-Msec. The app needs no membership in the organizations it lists - this is a tenant-level query - but it does need to be able to acquire an Azure DevOps token, which Connect-Msec handles. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [string] $TenantId ) Assert-MsecSession if (-not $TenantId) { $TenantId = $script:MsecSession.TenantId } if (-not $TenantId) { throw 'No tenant id in the session and none given. Pass -TenantId.' } 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). Check the msec app's certificate is still valid and that Connect-Msec succeeded. Original error: $($_.Exception.Message)" } # Not Invoke-MsecAzureDevOpsRequest: that builds https://{host}/{organization}/... and appends # an api-version, and this route is tenant-scoped with neither. $uri = "https://aex.dev.azure.com/_apis/EnterpriseCatalog/Organizations?tenantId=$TenantId" try { $response = Invoke-WebRequest -Uri $uri -Headers @{ Authorization = "Bearer $token" } -ErrorAction Stop } catch { $detail = $_.Exception.Message if ($detail -match '401|403|Unauthorized|Forbidden') { throw "Forbidden listing organizations in tenant '$TenantId'. This is a tenant-level query and needs an identity the tenant recognises; being a member of one organization is not the same thing. Original error: $detail" } throw "Could not list organizations in tenant '$TenantId': $detail" } # CSV, not JSON - see the help. $rows = @($response.Content | ConvertFrom-Csv) if (-not $rows.Count) { Write-Warning "No organizations returned for tenant '$TenantId'. This route is internal to the Azure DevOps admin portal and may have changed shape - treat this as UNREAD, not as a tenant with no organizations." return } foreach ($row in $rows) { [PSCustomObject]@{ PSTypeName = 'MsecAzureDevOpsOrganization' TenantId = $TenantId Organization = $row.'Organization Name' # Whoever created it or had ownership transferred to them. An organization whose # owner has left is one nobody can administer. Owner = $row.Owner Url = $row.Url Id = $row.'Organization Id' } } } |