public/Get-MsecAzureDevOpsOrganizationPolicy.ps1
|
function Get-MsecAzureDevOpsOrganizationPolicy { <# .SYNOPSIS The organization-wide Azure DevOps policies from Organization Settings > Policies - third-party OAuth access, SSH, PAT creation, guest access, public projects, audit logging - as one row per policy. .DESCRIPTION These are the ORGANIZATION's ceiling, the same role the SharePoint tenant settings and the Teams Global policy play: a well-governed project inside an organization that allows third-party OAuth apps and unrestricted PAT creation is still exposed, and reviewing projects or pipelines one at a time never surfaces it. THERE IS NO REST API FOR THIS, and that is worth knowing before you rely on it. _apis/organizationpolicy/policies does not exist - it 404s on every api-version and on both hosts. The only source is the data provider behind the portal's own settings page: GET https://dev.azure.com/{org}/_settings/organizationPolicy?__rt=fps&__ver=2 That is an INTERNAL route. It needs no extra permission beyond organization membership, it returns the same data the page renders, and Microsoft can change or remove it without notice or a version bump. If this command starts returning nothing, that is the first thing to suspect. THE PORTAL SHOWS SOME TOGGLES INVERTED. Four policies are named for what they forbid - Policy.DisallowOAuthAuthentication and friends - so the page renders the opposite of the stored value: DisallowOAuthAuthentication = True appears as "Third-party application access via OAuth: Off". Value is reported RAW, as the API gives it, and IsInverted says when the page disagrees. Reading the raw value together with the policy name is unambiguous; reading it against the page's label is not. IsExplicit MATTERS AS MUCH AS THE VALUE. A policy nobody ever set reports its default, and the provider says so separately. A default that happens to be safe today is not a decision anyone made, and nothing stops it changing. .PARAMETER Organization Azure DevOps organization name: the path segment after dev.azure.com/, e.g. 'contoso'. .EXAMPLE Connect-Msec -KeyVaultName kv-msec Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso' | Format-Table Category, Setting, Value, IsExplicit .EXAMPLE # Everything still sitting on its default, i.e. never decided by anyone. Get-MsecAzureDevOpsOrganizationPolicy -Organization 'contoso' | Where-Object { -not $_.IsExplicit } .OUTPUTS PSCustomObject per policy, PSTypeName 'MsecAzureDevOpsOrganizationPolicy'. .NOTES Needs Connect-Msec, and the msec app's service principal must be a member of the ADO organization (Organization Settings > Users > Add) with Basic access. That is granted INSIDE Azure DevOps, not through Entra API permissions, so New-MsecApp cannot do it. Verified against a live organization: 13 policies in 4 groups. A POLICY THAT IS ON IS A SETTING, NOT A CAPABILITY. These rows report what the organization has configured; they do not report what Azure DevOps will actually let anyone do. The two can disagree, and 'Allow public projects' is the case where they did: measured on a live organization it read Value True and IsExplicit True - somebody had deliberately turned it on - while the product refused to create a public project at all, offering GitHub instead. The reason is that PUBLIC PROJECTS ARE RETIRED: Microsoft removed the ability to create one or to make a private project public, and existing public projects convert to private during 2027. The toggle still renders, still stores a value and still reports IsExplicit - and means nothing. A vestigial setting is a worse failure than a wrong one, because it reads as a live permission in both directions. So an enabled policy here is the right place to START a question, not the answer to it. Reading 'Allow public projects: True' as "this organization can publish its code" was wrong on the one organization it was tested against - the only way to know is to try it, or to check what the projects actually are (Get-MsecAzureDevOpsRepository reports the repositories; project visibility is on the project). The reverse error is not possible in the same way: a policy that is OFF really does mean the capability is unavailable. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, Position = 0)] [string] $Organization ) Assert-MsecSession # The provider groups the policies itself - applicationConnection, security, user, privacy - # so there is no category table here to drift out of date. Only the casing is ours. $categoryNames = @{ applicationConnection = 'Application connection' security = 'Security' user = 'User' privacy = 'Privacy' } # Not Invoke-MsecAzureDevOpsRequest: that appends an api-version, and this internal route # takes __rt/__ver instead and 404s with one attached. 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)" } $uri = "https://dev.azure.com/$Organization/_settings/organizationPolicy?__rt=fps&__ver=2" try { $response = Invoke-WebRequest -Uri $uri -Headers @{ Authorization = "Bearer $token" } -ErrorAction Stop } catch { $detail = $_.Exception.Message if ($detail -match '401|403|Unauthorized|Forbidden') { throw "Unauthorized reading organization policies in '$Organization'. The msec app's service principal needs to be a member of the ADO organization (Organization Settings > Users > Add) with Basic access - Stakeholder is not enough. This is granted inside Azure DevOps, not through Entra, so New-MsecApp cannot do it. Original error: $detail" } throw "Could not read organization policies in '$Organization': $detail" } $provider = ($response.Content | ConvertFrom-Json).fps.dataProviders.data.'ms.vss-admin-web.organization-policies-data-provider' if (-not $provider -or -not $provider.policies) { # Named rather than returned empty: this is the internal route changing shape, which is # exactly the risk the help warns about - and an empty list would read as an # organization with no policies rather than a source that stopped working. Write-Warning "The organization-policies data provider returned nothing for '$Organization'. This route is internal to the portal and may have changed shape - treat this as UNREAD, not as an organization with no policies set." return } # The four policies named for what they forbid, which the page therefore renders inverted. $inverted = @($provider.invertedPolicies) foreach ($group in $provider.policies.PSObject.Properties) { foreach ($entry in $group.Value) { $name = [string] $entry.policy.name if (-not $name) { continue } [PSCustomObject]@{ PSTypeName = 'MsecAzureDevOpsOrganizationPolicy' Category = if ($categoryNames.ContainsKey($group.Name)) { $categoryNames[$group.Name] } else { $group.Name } # The portal's own label. Far more use in a review than the raw name, which is # kept alongside it for filtering and for scripts. Setting = $entry.description # effectiveValue is what is in force including anything inherited; value is only # what this organization set. Value = $entry.policy.effectiveValue # ABSENCE MEANS SET. The provider omits isValueUndefined entirely for a policy # someone configured, and emits it as true for one still on its default - so # there is no third "unknown" state to preserve here, and treating the missing # property as unknown reported the three explicitly-set policies as blank. IsExplicit = -not [bool] $entry.policy.isValueUndefined # True where the settings page shows the OPPOSITE of Value, because the policy is # named for what it forbids. IsInverted = $inverted -contains $name # The 'Policy.' prefix is on every one of them, so it distinguishes nothing. Policy = $name -replace '^Policy\.', '' Organization = $Organization } } } } |