Functions/Grant-AzDoProjectPermission.ps1

Function Grant-AzDoProjectPermission {
<#
    .SYNOPSIS
        Adds a user (or group) to a project-level Azure DevOps group (eg. Contributors, Project Administrators) or team.
 
    .DESCRIPTION
        This helper will try a few heuristics to find the project-scoped target group/team and the user principal
        then create a Graph membership to grant the membership (which effectively gives the permissions of that group).
 
    .PARAMETER Project
        The project name. Defaults to the script-scoped $Project if present.
 
    .PARAMETER User
        The user to add. Can be email, display name or part thereof.
 
    .PARAMETER Group
        The project-level group or team to add the user to. Common values: "Contributors", "Project Administrators", "Readers".
 
    .PARAMETER WhatIf
        If set, the function will only show the actions it would take without performing the membership change.
 
    .EXAMPLE
        Grant-AzDoProjectPermission -Project "MyProject" -User "alice@contoso.com" -Group "Contributors"
 
    .NOTES
        Author: Lars Panzerbjørn
        Creation Date: 2024.12.05
#>

    [CmdletBinding(PositionalBinding=$False)]
    param(
        [Parameter()]
        [Alias('TeamName')]
        [string]$Project = $Script:Project,

        [Parameter(Mandatory)]
        [string]$User,

        [Parameter(Mandatory)]
        [string]$Group,

        [switch]$WhatIf
    )

    BEGIN{
        Write-Verbose "Beginning $($MyInvocation.Mycommand)"

        IF(-not $Header) {
            THROW "Authorization header not available. Run Get-AzDoAccessToken first."
        }

        IF(-not $BaseUri) {
            THROW "BaseUri not found. Run Get-AzDoAccessToken first."
        }

        # Derive organization from BaseUri (assumes format https://dev.azure.com/{org}/ )
        $Org = $null
        IF($BaseUri -match 'https?://dev.azure.com/([^/]+)/?') { $Org = $Matches[1] }
        ELSEIF($BaseUri -match 'https?://([^/.]+)\.visualstudio\.com/?') { $Org = $Matches[1] }
        ELSE { Write-Verbose "Could not reliably parse organization from BaseUri: $BaseUri" }

        Write-Verbose "Organization determined as: $Org"
    }

    PROCESS{
        Write-Verbose "Processing $($MyInvocation.Mycommand)"

        # Get project details (to obtain id)
        $projUri = "$BaseUri$Project/_apis/projects/$Project`?api-version=7.0"
        Write-Verbose "Getting project details from $projUri"
        TRY{
            $proj = Invoke-RestMethod -Uri $projUri -Method GET -Headers $Header -ErrorAction Stop
        }
        CATCH{
            THROW "Failed to get project $Project - $_"
        }
        $projectId = $proj.id
        Write-Verbose "ProjectId: $projectId"

        # Attempt 1: list graph groups and try to match common project group names.
        $groupsUri = "https://vssps.dev.azure.com/$Org/_apis/graph/groups?api-version=7.0-preview.1"
        Write-Verbose "Retrieving graph groups from $groupsUri"
        TRY{
            $allGroups = Invoke-RestMethod -Uri $groupsUri -Method GET -Headers $Header -ErrorAction Stop
        }
        CATCH{
            THROW "Failed retrieving graph groups - $_"
        }

        # Heuristics to find group descriptor: try exact project-prefixed name, then simple name, then contains.
        $candidates = @()
        $projPrefixed = "$Project $Group"
        FOREACH ($g in $allGroups.value) {
            $candidates += [pscustomobject]@{
                displayName = $g.displayName
                descriptor  = $g.descriptor
                origin      = $g.origin
                subjectKind = $g.subjectKind
            }
        }

        $match = $candidates | Where-Object { $_.displayName -eq $projPrefixed } | Select-Object -First 1
        IF(-not $match) { $match = $candidates | Where-Object { $_.displayName -eq $Group } | Select-Object -First 1 }
        IF(-not $match) { $match = $candidates | Where-Object { $_.displayName -match [regex]::Escape($Group) } | Select-Object -First 1 }

        IF($match) {
            Write-Verbose "Found group: $($match.displayName) (descriptor: $($match.descriptor))"
            $groupDescriptor = $match.descriptor
        }
        ELSE {
            Write-Verbose "No graph group matched by heuristics. Will also try project teams."
            # fallback: try project teams API (teams are also containers for membership in many scenarios)
            $teamsUri = "$BaseUri$Project/_apis/teams?api-version=7.0"
            Write-Verbose "Getting project teams: $teamsUri"
            TRY{
                $teams = Invoke-RestMethod -Uri $teamsUri -Method GET -Headers $Header -ErrorAction Stop
            }
            CATCH{
                $teams = $null
            }
            $teamMatch = $null
            IF($teams) {
                $teamMatch = $teams.value | Where-Object { $_.name -eq $Group -or $_.name -match [regex]::Escape($Group) } | Select-Object -First 1
            }
            IF($teamMatch) {
                Write-Verbose "Found team: $($teamMatch.name) (id: $($teamMatch.id))"
                # Need to resolve team to graph descriptor by querying graph groups where originId equals team id
                $groupByOrigin = $candidates | Where-Object { $_.displayName -eq $teamMatch.name -or $_.displayName -match [regex]::Escape($teamMatch.name) } | Select-Object -First 1
                IF($groupByOrigin) {
                    $groupDescriptor = $groupByOrigin.descriptor
                    $match = $groupByOrigin
                }
            }
        }

        IF(-not $groupDescriptor) {
            THROW "Unable to find group or team descriptor for group '$Group' in project '$Project'. Ensure you have rights to read graph groups."
        }

        # Find the user principal (graph user descriptor)
        Write-Verbose "Searching for user principal matching '$User'"
        $usersUri = "https://vssps.dev.azure.com/$Org/_apis/graph/users?api-version=7.0-preview.1"
        TRY{
            $allUsers = Invoke-RestMethod -Uri $usersUri -Method GET -Headers $Header -ErrorAction Stop
        }
        CATCH{
            THROW "Failed retrieving graph users - $_"
        }
        $userMatch = $allUsers.value | Where-Object {
            ($_.principalName -and ($_.principalName -ieq $User -or $_.principalName -like "*$User*")) -or
            ($_.mailAddress -and ($_.mailAddress -ieq $User -or $_.mailAddress -like "*$User*")) -or
            ($_.displayName -and ($_.displayName -like "*$User*"))
        } | Select-Object -First 1

        IF(-not $userMatch) {
            THROW "Unable to find a graph user matching '$User'. Ensure the user exists in the organization."
        }
        Write-Verbose "Found user: $($userMatch.displayName) (descriptor: $($userMatch.descriptor))"
        $userDescriptor = $userMatch.descriptor

        # Build membership URL and perform the membership create (PUT)
        $membershipUri = "https://vssps.dev.azure.com/$Org/_apis/graph/memberships/$userDescriptor/$groupDescriptor`?api-version=7.0-preview.1"
        Write-Verbose "Membership URI: $membershipUri"

        IF($WhatIf) {
            Write-Output "WhatIf: would create membership: user '$($userMatch.displayName)' -> group '$Group' (descriptor: $groupDescriptor)"
            RETURN
        }

        Write-Verbose "Creating membership..."
        TRY{
            $res = Invoke-RestMethod -Uri $membershipUri -Method PUT -Headers $Header -ErrorAction Stop -ContentType 'application/json' -Body $null
            $MatchedGroupName = IF($match) { $match.displayName } ELSE { $Group }
            Write-Output "User '$($userMatch.displayName)' added to group '$MatchedGroupName'."
            RETURN $res
        }
        CATCH{
            THROW "Failed to add membership - $_"
        }
    }

    END{
        Write-Verbose "Ending $($MyInvocation.Mycommand)"
    }
}