Functions/PermissionEvidence.ps1
|
function Get-AzDevOpsPermissionEvidence { <# .SYNOPSIS Collect an ACL ancestry and identity graph for an effective-permission assessment. .DESCRIPTION AdministrativeBypass is a reviewed descriptor-to-mask map of platform bypass privileges, not an allow list. Its provenance is required before collection can be complete. Unknown namespaces, incomplete identity responses, and API failures remain unavailable. #> [CmdletBinding()] param( [Parameter(Mandatory)][guid]$NamespaceId, [Parameter(Mandatory)][string]$Token, [System.Collections.IDictionary]$AdministrativeBypass, [string]$AdministrativeEvidenceReference, [string[]]$AdditionalDescriptors = @() ) if ($null -eq $script:connection) { throw 'Not connected to Azure DevOps. Run Connect-AzDevOps first.' } $evidence = [ordered]@{ SchemaVersion = 1; Status = 'Partial'; NamespaceId = $NamespaceId.ToString(); Token = $Token ScopeComplete = $false; IdentitiesComplete = $false; AdministratorsComplete = $false AdministrativeEvidenceReference = $AdministrativeEvidenceReference CollectedAt = [DateTime]::UtcNow.ToString('o'); Acls = @(); Identities = @(); ReasonCode = 'CollectionIncomplete' } if ($null -eq $AdministrativeBypass -or -not $AdministrativeEvidenceReference) { $evidence.ReasonCode = 'AdministrativeEvidenceUnavailable' return [pscustomobject]$evidence } $organization = $script:connection.Organization $header = $script:connection.GetHeader() try { $response = Invoke-RestMethod -Uri "https://dev.azure.com/$organization/_apis/securitynamespaces/$($NamespaceId)?api-version=7.1" -Headers $header -Method Get -ErrorAction Stop $namespaces = @($response.value | Where-Object namespaceId -EQ $NamespaceId.ToString()) if ($namespaces.Count -ne 1) { throw 'NamespaceUnavailable' } $namespace = $namespaces[0] if ($namespace.useTokenTranslator -or $null -eq $namespace.structureValue) { throw 'UnsupportedNamespace' } $tokens = @($Token) if ($namespace.structureValue -eq 1) { if ($namespace.elementLength -ne -1 -or [string]::IsNullOrEmpty($namespace.separatorValue) -or $namespace.separatorValue -eq "`0") { throw 'UnsupportedNamespace' } $separator = [string]$namespace.separatorValue $tokens = @('') $parts = $Token.Split(@($separator), [System.StringSplitOptions]::None) for ($index = 0; $index -lt $parts.Count; $index++) { $tokens += ($parts[0..$index] -join $separator) } } elseif ($namespace.structureValue -ne 0) { throw 'UnsupportedNamespace' } $pending = [System.Collections.Generic.Queue[string]]::new() foreach ($descriptor in @($AdministrativeBypass.Keys) + $AdditionalDescriptors) { $pending.Enqueue([string]$descriptor) } foreach ($scope in $tokens) { $encoded = [Uri]::EscapeDataString($scope) $response = Invoke-RestMethod -Uri "https://dev.azure.com/$organization/_apis/accesscontrollists/$($NamespaceId)?token=$encoded&includeExtendedInfo=true&recurse=false&api-version=7.1" -Headers $header -Method Get -ErrorAction Stop if ($null -eq $response.value -or $null -eq $response.count -or $response.count -ne @($response.value).Count) { throw 'AclResponseIncomplete' } $matches = @($response.value | Where-Object token -EQ $scope) if ($matches.Count -gt 1) { throw 'AclResponseAmbiguous' } $acl = if ($matches.Count -eq 1) { $matches[0] } else { # A successful empty ACL query is an unset scope, inheriting its parent. [pscustomobject]@{ token = $scope; inheritPermissions = $true; acesDictionary = [pscustomobject]@{} } } if ($null -eq $acl.acesDictionary -or $null -eq $acl.inheritPermissions) { throw 'AclResponseIncomplete' } $evidence.Acls += $acl foreach ($ace in $acl.acesDictionary.psobject.Properties.Value) { $pending.Enqueue([string]$ace.descriptor) } } $evidence.ScopeComplete = $true $seen = @{} while ($pending.Count -gt 0) { $descriptor = $pending.Dequeue() if ($seen.ContainsKey($descriptor)) { continue } $seen[$descriptor] = $true $encoded = [Uri]::EscapeDataString($descriptor) $response = Invoke-RestMethod -Uri "https://vssps.dev.azure.com/$organization/_apis/identities?descriptors=$encoded&queryMembership=Direct&api-version=7.1" -Headers $header -Method Get -ErrorAction Stop $identities = @($response.value | Where-Object descriptor -EQ $descriptor) if ($identities.Count -ne 1 -or $null -eq $identities[0].isContainer) { throw 'IdentityResponseIncomplete' } $identity = $identities[0] if ($identity.isContainer -and $null -eq $identity.members) { throw 'MembershipUnavailable' } $members = @() foreach ($member in $identity.members) { $memberDescriptor = if ($member -is [string]) { $member } elseif ($member.identityType -and $member.identifier) { "$($member.identityType);$($member.identifier)" } else { throw 'MembershipUnavailable' } $members += $memberDescriptor $pending.Enqueue($memberDescriptor) } $evidence.Identities += [pscustomobject]@{ descriptor = $descriptor; isContainer = $identity.isContainer; members = $members bypassMask = [long]$AdministrativeBypass[$descriptor] } } $evidence.IdentitiesComplete = $true $evidence.AdministratorsComplete = $true $evidence.Status = 'Complete' $evidence.ReasonCode = 'Collected' } catch { # Never persist API exception messages: they can contain organization or identity data. $evidence.ReasonCode = 'PermissionCollectionUnavailable' Set-AzDevOpsCollectionStatus -Status Partial -ReasonCode MissingRequiredData } return [pscustomobject]$evidence } Export-ModuleMember -Function Get-AzDevOpsPermissionEvidence |