gitazure-devopsazure-repos

How to deny force push to main only, but still allow direct (non-force) push in Azure DevOps git repos


Question

Is there a way to deny the force push permission by default, only on the main branch, for any existing and new repos within a project, while still allowing it for all other branches?

Background

My team uses Azure DevOps to host our various git repos. Our typical workflow is to create feature branches from main, make changes, then commit them to main via pull requests. We want to have the freedom to force push to and delete feature branches we create.

In some cases, we find it useful to make direct commits to main instead. By convention, this is limited to minor changes with little impact, like fixing a typo in a readme. These needs are served by simple contribute permissions to allow a normal, non-force push.

Currently, we inherit force push permissions to all our repos from our group at the project level. This works well, except it also gives us force push permissions to main.

We'd like to deny force push permissions to main in all repos, current and future, by default to prevent anyone from accidentally erasing history from main.

What I've found so far

I believe we could achieve this for all current repos by setting the permission to deny explicitly on the main branch in each repo. However, we have a lot of repos and create new ones frequently. Having to set this for each new repo is impractical.

The general approach to this seems to be to make main a "protected" branch by enabling one or more policies. Doing so forces any changes to main to be made via pull requests, though, which would prevent us from making normal, non-force push changes directly to main.


Solution

  • Currently, there is no direct way, by default for each repo, to deny force push to main only, but still allow direct (non-force) push in Azure DevOps git repos.

    You can consider using the Azure DevOps REST API to deny force push to all the main branch in your organization. The REST API commands we will use:

    1. Identities - Read Identities: to get the group descriptor
    2. Projects - List: to get all the projects of the given organization
    3. Repositories - List: to get all repositories for the current project
    4. Access Control Entries - Set Access Control Entries: set the permission

    Here is the sample PowerShell script. It will set deny force push for all main branches for the Project Collection Valid Users group. The Personal access token should have Code (Read), Identity (Read), Project and Team (Read) and Security (Manage) scopes.

    scope

    # Define organization, PAT
    $organization = ""
    $pat = ""
    
    
    # Create header with PAT
    $headers = @{
        Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$($pat)"))
    }
    
    
    # Get the descriptor of the Project Collection Valid Users group. You can change the group as you need.
    $validUsersuri = "https://vssps.dev.azure.com/$organization/_apis/identities?searchFilter=General&filterValue=[$organization]\Project Collection Valid Users&queryMembership=None&api-version=7.1-preview.1"
    $validUserresponse = Invoke-RestMethod -Uri $validUsersuri -Method Get -Headers $Headers
    $descriptor = $validUserresponse.value.descriptor
    
    
    #  Get all projects
    $projectsUrl = "https://dev.azure.com/$organization/_apis/projects?api-version=7.1-preview.1"
    $projectsResponse = Invoke-RestMethod -Uri $projectsUrl -Headers $headers -Method "GET"
    
    if ($projectsResponse) {
        foreach ($project in $projectsResponse.value) {
            #Write-Host "project: $($project.name)"
    
    
            # Get all repositories for the current project
            $reposUrl = "https://dev.azure.com/$organization/$($project.name)/_apis/git/repositories?api-version=7.1-preview.1"
            $reposResponse = Invoke-RestMethod -Uri $reposUrl -Headers $headers -Method "GET"
            
            if ($reposResponse) {
                foreach ($repo in $reposResponse.value) {
                    #Write-Host "repo: $($repo.name)"
    
                    # Set the Force push permission for [$organization]\Project Collection Valid Users to deny in this repo. 
                    # The sequence 6d00610069006e00 represents the Unicode (UTF-16) encoding of the word “main”.
                    $token ="repoV2/$($project.id)/$($repo.id)/refs/heads/6d00610069006e00/"
                    # "Force push namespaceId": "2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87"
                    $permissionuri = "https://dev.azure.com/$organization/_apis/accesscontrolentries/2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87?api-version=7.1-preview.1"
                    $body = @"
                    {
                        "token": "$token",
                        "merge": true,
                        "accessControlEntries": [
                            {
                                "descriptor": "$descriptor",
                                "allow": 0,
                                "deny": 8,
                                "extendedInfo": {
                                    "effectiveAllow": 0,
                                    "effectiveDeny": 8,
                                    "inheritedAllow": 0,
                                    "inheritedDeny": 8
                                }
                            }
                        ]
                    }
    "@
                    # Send the POST request to set the permisson
                    $permissionresponse =   Invoke-RestMethod -Uri $permissionuri -Method Post -Headers $Headers -Body $body -ContentType "application/json"
                    if ($permissionResponse) {
                        Write-Host "$($project.name) > $($repo.name) > main :   Force push permission is set to deny"
                    }else 
                    {
                        Write-Host "  Failed to set permisson for $($project.name) > $($repo.name) > main branch "
                    }
    
                }
            }
            else {
                Write-Host "    Failed to retrieve repository information for $($project.name)."
            }
        }
    }
    else {
        Write-Host "Failed to retrieve project information."
    }
    
    

    Test result:

    result1 result2

    You can set a pipeline to run the above script with a Scheduled trigger to make sure the future new repos will have the same setting.