public/Grant-MsecAzureDevOpsPermission.ps1

function Grant-MsecAzureDevOpsPermission {
    <#
    .SYNOPSIS
        Grants an Azure DevOps permission to an identity or group, at organization or project
        scope. Runs as YOU - one-time setup, not something the app can do for itself.

    .DESCRIPTION
        Some things msec needs cannot be granted through Entra. New-MsecApp handles API
        permissions and directory roles; Azure DevOps keeps its own permission system, and an
        app that is a member of the organization still reads nothing until permissions are set
        INSIDE Azure DevOps. This is the other half.

        IT RUNS AS YOU, NOT AS THE APP, and that is not a detail. The app is usually the
        GRANTEE, and an identity that could grant itself permissions would make the whole
        exercise circular. Managing permissions in Azure DevOps needs Project Collection
        Administrator or equivalent, which a person has and the app should not.

        NO PERSONAL ACCESS TOKEN. An earlier version of this took a PAT. It does not need one:
        the security namespace, access control list and identity APIs all accept an ordinary
        Entra token for the Azure DevOps resource, which was verified against all three before
        the PAT was removed. A PAT is a long-lived credential, and asking people to create one
        for a setup task is worse than using the sign-in they already have.

        AZURE DEVOPS HAS TWO PERMISSION SYSTEMS AND THEY ARE NOT INTERCHANGEABLE.

          -Permission classic security namespaces, granted as ACL bits on a hierarchical token.
                       Repositories and Advanced Security live here.
          -RoleName role assignments (Reader / User / Administrator) on a resource scope.
                       Pipeline resources - service connections, agent pools, variable groups,
                       secure files - live here, and have no organization root.

        Picking the wrong one fails SILENTLY: an allow on the ServiceEndpoints namespace is
        accepted, stored, reported back by the ACL API, and confers nothing at all. Verified the
        hard way.

        NOTHING IS HARDCODED. The namespace id and the permission bit are resolved by NAME at
        run time from the security namespace metadata, so a renumbered bit fails loudly instead
        of silently granting a different permission. The bit for 'view alerts' happens to be
        65536 today; that is a fact about one organization on one date, not something to rely on.

        THE ROOT TOKEN IS NAMESPACE-SPECIFIC AND HAS NO TRAILING SLASH. Git Repositories is
        'repoV2'; 'repoV2/' returns 400 "The request is invalid" for the same body. That one
        character is the difference between granting once for the whole organization and
        granting once per project, and it cost an afternoon to find. Namespaces this command has
        not been proven against are refused rather than guessed at.

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

    .PARAMETER Identity
        Who to grant to - a group or an app, by display name. A GROUP is usually right: the
        permission is then granted once and membership becomes the control.

    .PARAMETER Permission
        Permission names as the namespace defines them, e.g. ViewAdvSecAlerts. Run with
        -ListPermissions to see them.

    .PARAMETER RoleName
        Role assignment instead of a namespace ACL - Reader, User or Administrator. Use this for
        pipeline resources; see the note above on the two systems.

    .PARAMETER RoleScope
        The roles scope. distributedtask.serviceendpointrole is service connections.

    .PARAMETER Namespace
        Security namespace name, for -Permission. Case-sensitive.

    .PARAMETER Scope
        Organization or Project.

    .PARAMETER Project
        Limit to one project by name.

    .PARAMETER ListPermissions
        Emit the permission names and bits in the namespace, and stop. Reads only.

    .PARAMETER ListRoles
        Emit the role assignments that exist on the scope, and stop. Reads only. Use it when a
        grant reports "already" and nothing changed - it shows what is actually there rather
        than what a match against one identity implies.

    .PARAMETER Revoke
        Take the permission away instead of granting it. Working out which permission an API
        actually checks tends to leave grants behind that turned out not to enable anything,
        and those should not just be left in place.

    .EXAMPLE
        Connect-AzAccount
        Grant-MsecAzureDevOpsPermission -Organization contoso -ListPermissions

    .EXAMPLE
        Grant-MsecAzureDevOpsPermission -Organization contoso -Identity 'Security Reporting Readers' `
            -Permission ViewAdvSecAlerts -Scope Organization -WhatIf

        Shows what would change. Drop -WhatIf to write it.

    .EXAMPLE
        # Service connections use ROLES, not namespace bits.
        Grant-MsecAzureDevOpsPermission -Organization contoso -Identity 'Security Reporting Readers' `
            -RoleName Reader -Scope Project

    .OUTPUTS
        One PSCustomObject per target describing what was found and what was done.

    .NOTES
        Needs an Az sign-in (Connect-AzAccount) as someone who can manage Azure DevOps
        permissions - Project Collection Administrator or equivalent. It does NOT need
        Connect-Msec: this grants the app its access, so it runs before the app has any.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [string] $Organization,

        [string] $Identity,

        [string[]] $Permission,

        # THE OTHER MECHANISM - see the help. Service connections use ROLES; an allow on the
        # ServiceEndpoints namespace is accepted, stored, and confers nothing.
        [string] $RoleName,

        [string] $RoleScope = 'distributedtask.serviceendpointrole',

        [string] $Namespace = 'Git Repositories',

        [ValidateSet('Organization', 'Project')]
        [string] $Scope = 'Organization',

        [string] $Project,

        [switch] $ListPermissions,

        [switch] $ListRoles,

        [switch] $Revoke
    )

    # Azure DevOps' own well-known application id. Get-AzAccessToken wants the resource, and the
    # ADO APIs accept the resulting bearer token directly - no PAT, no Basic auth.
    $adoResource = '499b84ac-1321-427f-aa17-267ca6975798'

    if (-not (Get-AzContext -ErrorAction SilentlyContinue)) {
        throw ('No Azure context. Run Connect-AzAccount first - this command grants permissions AS YOU, ' +
               'not as the msec app, because the app is usually the grantee and must not be able to grant ' +
               'itself access.')
    }

    try { $tokenInfo = Get-AzAccessToken -ResourceUrl $adoResource -ErrorAction Stop }
    catch { throw "Could not get an Azure DevOps token for the signed-in user: $($_.Exception.Message)" }

    $token = if ($tokenInfo.Token -is [securestring]) {
        $tokenInfo.Token | ConvertFrom-SecureString -AsPlainText
    }
    else { [string] $tokenInfo.Token }

    $headers = @{ Authorization = "Bearer $token" }

    # Root tokens per namespace. See the help: no trailing slash, and unproven namespaces are
    # refused rather than guessed at.
    $tokenGrammar = @{
        'Git Repositories' = @{ Root = @('repoV2');                   Project = { param($id) "repoV2/$id" } }
        'ServiceEndpoints' = @{ Root = @('endpoints');                Project = { param($id) "endpoints/$id" } }
        # Agent pools. Root spelling unverified - the candidates are tried in order.
        'DistributedTask'  = @{ Root = @('AgentPools', 'agentpools'); Project = { param($id) "AgentPools/$id" } }
    }

    # ---- namespace, resolved by name ----------------------------------------------------------
    $namespaces = @((Invoke-RestMethod -Uri "https://dev.azure.com/$Organization/_apis/securitynamespaces?api-version=7.1" -Headers $headers).value)
    $ns = @($namespaces | Where-Object { $_.name -eq $Namespace })
    if ($ns.Count -ne 1) { throw "Security namespace '$Namespace' matched $($ns.Count). Names are case-sensitive here." }
    $ns = $ns[0]

    if ($ListPermissions) {
        Write-Verbose "namespace $($ns.name) ($($ns.namespaceId)), $(if ($ns.structureValue -eq 1) { 'hierarchical' } else { 'flat' })"
        foreach ($action in ($ns.actions | Sort-Object bit)) {
            [PSCustomObject]@{
                PSTypeName  = 'MsecAzureDevOpsPermissionName'
                Namespace   = [string] $ns.name
                Name        = [string] $action.name
                Bit         = $action.bit
                DisplayName = [string] $action.displayName
            }
        }
        return
    }

    if ($ListRoles) {
        $projects = @((Invoke-RestMethod -Uri "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1" -Headers $headers).value)
        if ($Project) { $projects = @($projects | Where-Object { $_.name -eq $Project }) }
        foreach ($proj in $projects) {
            $assignments = @()
            try {
                $assignments = @((Invoke-RestMethod -Headers $headers -Uri ("https://dev.azure.com/$Organization/_apis/securityroles/scopes/$RoleScope" +
                                    "/roleassignments/resources/$($proj.id)?api-version=7.1-preview.1")).value)
            }
            catch { Write-Warning "$($proj.name): could not read role assignments - $($_.Exception.Message)" }

            foreach ($a in $assignments) {
                [PSCustomObject]@{
                    PSTypeName = 'MsecAzureDevOpsRoleAssignment'
                    Project    = [string] $proj.name
                    Scope      = $RoleScope
                    Resource   = '(project)'
                    Identity   = [string] $a.identity.displayName
                    Role       = [string] $a.role.name
                    Access     = [string] $a.access
                }
            }

            # Service connections are permissioned per CONNECTION as well as per project, and a
            # connection whose inheritance is off ignores whatever the project scope says. The
            # resource id for one connection is {projectId}_{endpointId}.
            if ($Project) {
                $endpoints = @()
                try {
                    $endpoints = @((Invoke-RestMethod -Headers $headers -Uri ("https://dev.azure.com/$Organization/$([uri]::EscapeDataString($proj.name))" +
                                     "/_apis/serviceendpoint/endpoints?api-version=7.1-preview.4")).value)
                }
                catch { Write-Warning "$($proj.name): could not list service connections - $($_.Exception.Message)" }

                foreach ($ep in $endpoints) {
                    try {
                        $epRoles = @((Invoke-RestMethod -Headers $headers -Uri ("https://dev.azure.com/$Organization/_apis/securityroles/scopes/$RoleScope" +
                                       "/roleassignments/resources/$($proj.id)_$($ep.id)?api-version=7.1-preview.1")).value)
                        foreach ($a in $epRoles) {
                            [PSCustomObject]@{
                                PSTypeName = 'MsecAzureDevOpsRoleAssignment'
                                Project    = [string] $proj.name
                                Scope      = $RoleScope
                                Resource   = [string] $ep.name
                                Identity   = [string] $a.identity.displayName
                                Role       = [string] $a.role.name
                                Access     = [string] $a.access
                            }
                        }
                    }
                    catch { Write-Warning "$($proj.name)/$($ep.name): [$($_.Exception.Response.StatusCode.value__)]" }
                }
            }
        }
        return
    }

    if (-not $Identity) { throw 'Identity is required unless -ListPermissions or -ListRoles is given.' }
    if ($Permission -and $RoleName) { throw 'Use -Permission (namespace ACL) or -RoleName (role assignment), not both.' }
    if (-not $Permission -and -not $RoleName) { throw 'One of -Permission or -RoleName is required.' }

    # ---- permission bits, resolved by name ----------------------------------------------------
    # @($null) is an array containing one $null, not an empty array - so an unguarded loop here
    # ran once with an empty name and failed a -RoleName call with "'' is not a permission".
    $mask = 0
    foreach ($name in @($Permission | Where-Object { $_ })) {
        $action = @($ns.actions | Where-Object { $_.name -eq $name })
        if ($action.Count -ne 1) {
            throw "'$name' is not a permission in '$Namespace'. Run with -ListPermissions to see the names."
        }
        $mask = $mask -bor $action[0].bit
        Write-Verbose "permission $($action[0].name) = $($action[0].bit) ($($action[0].displayName))"
    }

    # ---- grantee, resolved by name ------------------------------------------------------------
    $found = @((Invoke-RestMethod -Uri ("https://vssps.dev.azure.com/$Organization/_apis/identities?searchFilter=General" +
                "&filterValue=$([uri]::EscapeDataString($Identity))&api-version=7.1") -Headers $headers).value)
    if (-not $found.Count) { throw "No identity matching '$Identity' in '$Organization'. Create the group, or add the app under Organization Settings > Users, first." }
    if ($found.Count -gt 1) {
        $found | ForEach-Object { Write-Warning " matched: $($_.providerDisplayName)" }
        throw "'$Identity' matched $($found.Count) identities. Use the exact display name."
    }
    $descriptor = $found[0].descriptor
    $identityId = $found[0].id
    Write-Verbose "grantee $($found[0].providerDisplayName)"

    # ---- targets ------------------------------------------------------------------------------
    $grammar = $null
    if (-not $RoleName) {
        $grammar = $tokenGrammar[$Namespace]
        if (-not $grammar) { throw "No token grammar known for namespace '$Namespace'. Add one only after proving it against a live organization." }
    }

    if ($RoleName -and $Scope -eq 'Organization') {
        # Candidate resource ids for the collection-level role scope. Role assignments DO inherit
        # from a scope above the project - a project-scope read showed access=inherited, which has
        # to come from somewhere - but the resource id for that scope is not documented anywhere
        # I could find, so the candidates are tried in order and the one that takes is reported.
        $collectionId = $null
        try { $collectionId = ((Invoke-RestMethod -Uri "https://dev.azure.com/$Organization/_apis/connectionData?api-version=7.1-preview.1" -Headers $headers).instanceId) } catch { }
        $targets = @([pscustomobject]@{ Name = '(collection root)'; Tokens = @($collectionId, $Organization | Where-Object { $_ }) })
    }
    elseif ($RoleName) {
        $projects = @((Invoke-RestMethod -Uri "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1" -Headers $headers).value)
        if ($Project) {
            $projects = @($projects | Where-Object { $_.name -eq $Project })
            if (-not $projects.Count) { throw "No project named '$Project' in '$Organization'." }
        }
        $targets = @($projects | ForEach-Object { [pscustomobject]@{ Name = $_.name; Tokens = @($_.id) } })
    }
    elseif ($Scope -eq 'Organization') {
        $targets = @([pscustomobject]@{ Name = '(organization root)'; Tokens = @($grammar.Root) })
    }
    else {
        $projects = @((Invoke-RestMethod -Uri "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1" -Headers $headers).value)
        if ($Project) {
            $projects = @($projects | Where-Object { $_.name -eq $Project })
            if (-not $projects.Count) { throw "No project named '$Project' in '$Organization'." }
        }
        $targets = @($projects | ForEach-Object { [pscustomobject]@{ Name = $_.name; Tokens = @((& $grammar.Project $_.id)) } })
    }

    Write-Verbose "scope $Scope, $($targets.Count) target(s)"

    $verb = if ($Revoke) { 'Revoke' } else { 'Grant' }
    $what = if ($RoleName) { "role '$RoleName'" } else { "permission(s) $($Permission -join ', ')" }

    # ---- apply --------------------------------------------------------------------------------
    foreach ($target in $targets) {
        $already = $false
        $result  = $null
        $detail  = $null

        if ($RoleName) {
            try {
                $existing = @((Invoke-RestMethod -Headers $headers -Uri ("https://dev.azure.com/$Organization/_apis/securityroles/scopes/$RoleScope" +
                                "/roleassignments/resources/$($target.Tokens[0])?api-version=7.1-preview.1")).value)
                $already = [bool] @($existing | Where-Object { $_.identity.id -eq $identityId -and $_.role.name -eq $RoleName }).Count
            }
            catch { Write-Warning "$($target.Name): could not read current role assignments - $($_.Exception.Message)" }

            if ($already) { $result = 'AlreadyAssigned' }
            elseif (-not $PSCmdlet.ShouldProcess("$($target.Name) in $Organization", "$verb $what to '$Identity'")) { $result = 'Skipped' }
            else {
                $assigned = $false
                # EVERY candidate's failure, not just the last. Reporting only the last hid the
                # real error behind a malformed URL from a later candidate.
                $failures = [System.Collections.Generic.List[string]]::new()
                foreach ($resource in $target.Tokens) {
                    try {
                        $body = ConvertTo-Json -Depth 4 -InputObject @(@{ roleName = $RoleName; userId = $identityId })
                        Invoke-RestMethod -Method PUT -ContentType 'application/json' -Body $body -Headers $headers `
                            -Uri "https://dev.azure.com/$Organization/_apis/securityroles/scopes/$RoleScope/roleassignments/resources/$resource`?api-version=7.1-preview.1" | Out-Null
                        $result = 'Assigned'; $detail = "resource '$resource'"; $assigned = $true
                        break
                    }
                    catch {
                        $msg = ''
                        try { $msg = ($_.ErrorDetails.Message | ConvertFrom-Json).message } catch { $msg = $_.Exception.Message }
                        $failures.Add(("resource '{0}' [{1}] {2}" -f $resource, $_.Exception.Response.StatusCode.value__, $msg))
                    }
                }
                if (-not $assigned) {
                    $result = 'Failed'; $detail = ($failures -join ' | ')
                    Write-Warning "$($target.Name): no candidate resource accepted the assignment. $detail"
                }
            }
        }
        else {
            try {
                $acl = (Invoke-RestMethod -Headers $headers -Uri ("https://dev.azure.com/$Organization/_apis/accesscontrollists/$($ns.namespaceId)" +
                        "?token=$([uri]::EscapeDataString($target.Tokens[0]))&descriptors=$([uri]::EscapeDataString($descriptor))&api-version=7.1")).value
                foreach ($entry in @($acl)) {
                    foreach ($ace in @($entry.acesDictionary.PSObject.Properties)) {
                        if (($ace.Value.allow -band $mask) -eq $mask) { $already = $true }
                    }
                }
            }
            catch { Write-Warning "$($target.Name): could not read current ACL - $($_.Exception.Message)" }

            if ($Revoke -and -not $already) { $result = 'NotGranted' }
            elseif (-not $Revoke -and $already) { $result = 'AlreadyAllowed' }
            elseif (-not $PSCmdlet.ShouldProcess("$($target.Name) in $Organization", "$verb $what to '$Identity'")) { $result = 'Skipped' }
            elseif ($Revoke) {
                $removed = $false
                foreach ($candidate in $target.Tokens) {
                    try {
                        # DELETE removes this DESCRIPTOR's entry at this token. Other identities
                        # keep theirs, which is why this is not a rewrite of the ACL.
                        Invoke-RestMethod -Method DELETE -Headers $headers -Uri (
                            "https://dev.azure.com/$Organization/_apis/accesscontrolentries/$($ns.namespaceId)" +
                            "?token=$([uri]::EscapeDataString($candidate))&descriptors=$([uri]::EscapeDataString($descriptor))&api-version=7.1") | Out-Null
                        $result = 'Revoked'; $detail = "token '$candidate'"; $removed = $true
                        break
                    }
                    catch { }
                }
                if (-not $removed) { $result = 'Failed'; Write-Warning "$($target.Name): could not revoke at any candidate token." }
            }
            else {
                $wrote = $false
                $lastError = $null
                foreach ($candidate in $target.Tokens) {
                    try {
                        # merge = true so other permissions on this token are preserved, not replaced.
                        $body = @{
                            token                = $candidate
                            merge                = $true
                            accessControlEntries = @(@{ descriptor = $descriptor; allow = $mask; deny = 0 })
                        } | ConvertTo-Json -Depth 5

                        Invoke-RestMethod -Method POST -ContentType 'application/json' -Body $body -Headers $headers `
                            -Uri "https://dev.azure.com/$Organization/_apis/accesscontrolentries/$($ns.namespaceId)?api-version=7.1" | Out-Null

                        $result = 'Allowed'; $detail = "token '$candidate'"; $wrote = $true
                        break
                    }
                    catch { $lastError = $_ }
                }
                if (-not $wrote) {
                    # Named, not swallowed: a target left ungranted is data that stays missing,
                    # and a silent skip would look like success. The BODY carries the real
                    # complaint on a 400.
                    $body = ''
                    try { $body = $lastError.ErrorDetails.Message } catch { }
                    $result = 'Failed'
                    $detail = "$($lastError.Exception.Message) $($body.Substring(0, [Math]::Min(300, $body.Length)))".Trim()
                    Write-Warning "$($target.Name): FAILED - $detail"
                }
            }
        }

        [PSCustomObject]@{
            PSTypeName   = 'MsecAzureDevOpsGrant'
            Organization = $Organization
            Target       = [string] $target.Name
            Identity     = [string] $found[0].providerDisplayName
            Granted      = $(if ($RoleName) { $RoleName } else { ($Permission -join ', ') })
            Mechanism    = $(if ($RoleName) { 'RoleAssignment' } else { 'NamespaceAcl' })
            Result       = $result
            Detail       = $detail
        }
    }
}