All Articles

Assign Azure roles dynamically through Azure DevOps

You’ve probably came to this question many times, what are the three fundamental principles that need to adhere to strict compliancy frameworks? Yes, you’ve probably already guessed it. Security, traceability and audibility. Azure DevOps services comes with these principles in mind, but how can you make it visible that Azure roles are assigned against your Azure environment?

In this blog post, you will be going into a deep dive on setting up an Azure pipeline that adheres to these fundamental principles. You will build templates so you can re-use them for your teams, and later you can secure them by placing a Approval check to use those templates for assignments in Azure.

There is a lot that is going to be covered, so this blog will be in series.

Prerequisites

Before you start with this in depth blog post, there are some pre-requisites needed if you want to follow this tutorial:

  • A Azure DevOps account
  • A code editor like Visual Studio (VSCode)
  • The Az Powershell module
  • The VSTeam Powershell module
  • An Azure Subscription to play in

Preparing the environment

Assuming that your quite familiar with having multiple environments, like test, quality assurance and production, this will be the starting point. To prepare your environment, you can run the below script which will create three resource groups, service principals, and service connections.

  1. Open Windows Powershell
  2. Change the values pattoken, subscriptionId, projectName and azdoAccount with your respective environment

When you create the Personal Access Token, make sure that it can manage the Service Connections

# Variables
$PAT = '<patToken>'
$SubscriptionId = '<subscriptionId'
$ProjectName = '<projectName>'
$AzDoAccount = '<azdoAccount>'

# Connect to Azure
Connect-AzAccount -Subscription $SubscriptionId
$Context = Get-AzContext

# Connect VSTeam module
Set-VSTeamAccount -Account $AzDoAccount -PersonalAccessToken $PAT

# Create the resources in Azure and Azure DevOps
$Environments = @('test', 'qa', 'prod')
foreach ($Environment in $Environments) {
    $ResourceGroup = New-AzResourceGroup -Name "rg-data-$Environment" -Location westeurope
    $ServicePrincipal = New-AzADServicePrincipal -DisplayName sp-azdo-connection-$Environment 
    New-AzRoleAssignment -ApplicationId $ServicePrincipal.AppId -RoleDefinitionName "Owner" -Scope $ResourceGroup.ResourceId
    Add-VSTeamAzureRMServiceEndpoint -subscriptionName $Context.Subscription.Name `
        -subscriptionId $Context.Subscription.Id `
        -subscriptionTenantId $Context.Tenant.Id `
        -servicePrincipalId $ServicePrincipal.AppId `
        -servicePrincipalKey $ServicePrincipal.PasswordCredentials.SecretText `
        -endpointName azdo-sp-$Environment `
        -ProjectName $ProjectName
}
  1. Run the above script when the values have been propagated

You will see later in this tutorial how this all fits into it’s place. For now, have a quick look which resources have been created.

Creating re-usable templates

In this section, you’ll be going to setup the templates that can be used amongst pipelines that will be created by you or your team members. Inside these templates, the role assignment will be configured, you’ll primarily focus on the New-AzRoleAssignment cmdlet, and Remove-AzRoleAssignment cmdlet. Let’s get started by creating the structure. Assuming that you already have a repository checked out, in this tutorial it is called Templates.

  1. In your favorite editor, create the following folder structure

templates-folder-structure

  1. Inside the task folder, create a file called azure-rbac-powershell-task.yml
  2. In the task YAML file, copy the following content which is an Azure Powershell script to assign roles based on the parameters that you later going to define. You see that the AzureSubscription and Scope are dynamically build up based on the environment that is going to be passed
parameters: 
- name: environmentName
  type: string
- name: roleAssigner
  type: string
  values:
  - New 
  - Remove
- name: objectId
  type: string 
- name: scope 
  type: string 
- name: roleDefinitionName
  type: string 
- name: azureSubscription
  type: string

steps: 
  - task: AzurePowershell@5
    displayName: 'Azure role assigner'
    inputs:
      azureSubscription: "${{ parameters.azureSubscription }}-${{ parameters.environmentName }}"
      azurePowerShellVersion: 'LatestVersion'
      ScriptType: 'InlineScript'
      Inline: |
        $Params = @{
          ObjectId = "${{ parameters.objectId }}"
          Scope = "${{ parameters.Scope }}-${{ parameters.environmentName }}"
          RoleDefinitionName = "${{ parameters.roleDefinitionName }}"
        }

        $RoleAssignment = Get-AzRoleAssignment @Params -ErrorAction SilentlyContinue 
        if (-not $RoleAssignment) {
          if ("${{ parameters.roleAssigner }}" -ne 'New') {
            Write-Output "Removing role assignment..."
            try {
              $RoleAssignment = Remove-AzRoleAssignment @Params
              return $RoleAssignment
            } catch {
              Write-Host "##vso[task.logissue type=error]$($_.Exception.Message)"
            }
          }
          Write-Output "Creating new role assignment..."
          try {
            $RoleAssignment = New-AzRoleAssignment @Params
            return $RoleAssignment
          } catch {
            Write-Host "##vso[task.logissue type=error]$($_.Exception.Message)"
          }
        } else {
          Write-Host "##vso[task.logissue type=warning]That role assignment already exist, to re-add please remove the role assignment first"
        }
  1. Inside the job folder, create a file called azure-rbac-powershell-job.yml
  2. In the job YAML file, copy the following content to reference the task
parameters: 
- name: environmentName
  type: string
- name: roleAssigner
  type: string
  values:
  - New 
  - Remove
- name: objectId
  type: string 
- name: scope 
  type: string 
- name: roleDefinitionName
  type: string 
- name: azureSubscription
  type: string

jobs:
  - job: Assigner_${{ parameters.environmentName }}
    displayName: 'Azure role assigner - ${{ parameters.environmentName }}'
    steps: 
      - template: ../task/azure-rbac-powershell-task.yml
        parameters: 
          environmentName: ${{ parameters.environmentName }}
          roleAssigner: ${{ parameters.roleAssigner }}
          objectId: ${{ parameters.objectId }}
          scope: ${{ parameters.scope }}
          roleDefinitionName: ${{ parameters.roleDefinitionName }}
          azureSubscription: ${{ parameters.azureSubscription }}
  1. Now you also need a stage, so inside the stage folder, create a file called azure-rbac-powershell-stage.yml and copy the following content in
parameters: 
- name: environmentName
  type: string
- name: roleAssigner
  type: string
  values:
  - New 
  - Remove
- name: objectId
  type: string 
- name: scope 
  type: string 
- name: roleDefinitionName
  type: string 
- name: azureSubscription
  type: string

stages: 
  - stage: Assigner_${{ parameters.environmentName }}
    displayName: 'Azure role assigner - ${{ parameters.environmentName }}'
    jobs: 
      - template: ../job/azure-rbac-powershell-job.yml
        parameters: 
          environmentName: ${{ parameters.environmentName }}
          roleAssigner: ${{ parameters.roleAssigner }}
          objectId: ${{ parameters.objectId }}
          scope: ${{ parameters.scope }}
          roleDefinitionName: ${{ parameters.roleDefinitionName }}
          azureSubscription: ${{ parameters.azureSubscription }}

Oef, that was suddenly a lot of code, so let’s summaries what is going on here.

  • azure-rbac-powershell-task.yml: has 6 parameters to execute a Azure Powershell script that uses 3 values, the objectId, roleDefinitionName and the scope

    • environementName: you can fill in the environment that was populated during the preparation of the environment, like test, qa or prod
    • roleAssigner: parameter to check wether you want to create a new role assignment or remove
    • objectId: the Object ID that you can find for example of a user or service principal object-id
    • scope: the scope of a resource, in the below example it is the resource group that was created scope
    • roleDefinitionName: the role that you can give to the Object ID, Contributor as example
    • azureSubscription: the service connection to target to without the environment, during the creation of the environment, the prefix would be rg-data
  • azure-rbac-powershell-job.yml: has the same parameters as the tasked, and specifies the jobs to make up the stage. It targets the azure-rbac-powershell-task.yml as template for re-usability purpose
  • azure-rbac-powershell-stage.yml: the same as the job template, and is the collection of jobs, in this case it targets the azure-rbac-powershell-job.yml

Now there is one more thing to do in the templates, and that is adding a template stage which wraps all the environments and configuration in one. It nearly looks like you are doing some Inception here…

Template within templates

  1. Still in your code editor, add one more fill under the stage folder called azure-rbac-powershell-template-stage.yml
  2. Inside this file, add the following content
parameters:
- name: environments
  type: object
  default:
  - name: test 
  - name: qa
  - name: prod
- name: taskConfiguration
  type: object
  default:
  - roleAssigner: 'New'
    objectId: ''
    scope: ''
    roleDefinitionName: ''
    azureSubscription: ''

stages: 
  - ${{ each environment in parameters.environments }}:
    - ${{ each configuration in parameters.taskConfiguration }}:
      - template: azure-rbac-powershell-stage.yml
        parameters:
          environmentName: ${{ environment.name }}
          roleAssigner: ${{ configuration.roleAssigner }}
          objectId: ${{ configuration.objectId }}
          scope: ${{ configuration.scope }}
          roleDefinitionName: ${{ configuration.roleDefinitionName }}
          azureSubscription: ${{ configuration.azureSubscription }}

You can see that the parameters are now different that needs to be given.

  • environments: here you can specify which environments you want to target
  • taskConfiguration: any YAML structure can be specified, and is passed a long in a for each loop to the azure-rbac-powershell-stage.yml

You have now successfully created the required templates! Let’s see it in action. Before moving to the next section, make sure that you commit all the code to your repository, in this example it will be the Azure Repos.

Extending templates

As already stated in the introduction, security is quite important in companies that have to follow strict compliancy rules. Using the extends syntax in YAML, gives us the ability to securely enforcing that a pipeline extends from a particular template. You are already probably getting the picture a bit with all the hard work you did in previous sections. In this section, you are going to setup the extends from the template in the templates repository.

  1. Create a new repository in your Version Control System, in this example it will be Azure Repos and is called az-role-assigner
  2. Create the folder cicd which will store the azure-pipelines.yml file

azure-role-assigner-repository

  1. Now create a resources that targets the templates repository as identifier
resources:
  repositories:
    - repository: templates
      type: git
      name: templates
      ref: master
  1. Add the extends that references the azure-rbac-powershell-template-stage.yml template
extends:
  template: /templates/az/rbac/stage/azure-rbac-powershell-template-stage.yml@templates
  1. Under the template, add the following parameter to start targeting the test environment
extends:
  template: /templates/az/rbac/stage/azure-rbac-powershell-template-stage.yml@templates
  parameters:
      environments:
        - name: test
  1. Go to the Azure Portal to find the objectId, scope and definition that you want to target. In this example, a fake user was added to Azure Active Directory and given the Contributor permissions. Make sure that the objectId and subscriptionId is filled in with your values
extends:
  template: /templates/az/rbac/stage/azure-rbac-powershell-template-stage.yml@templates
  parameters:
    environments:
      - name: test
    taskConfiguration: 
      - roleAssigner: 'New'
        objectId: '<objectId>'
        scope: '/subscriptions/<subscriptionId>/resourceGroups/rg-data'
        roleDefinitionName: Contributor
        azureSubscription: azdo-sp
  1. Run the pipeline and see the results

azure-pipeline-run

  1. Go to the Azure Portal and see the results on the rg-data-test Resource Group

azure-portal-results

That was awesome! With just a few simple lines, you are re-using the templates, and dynamically building up the stage. If you want, you can also add an additional line in the environments parameter to target qa!

But how can you make sure that the pipelines adhere now to these templates? Let’s move by adding a Required template as check.

Adding required template

  1. In your Azure DevOps project, go to Project settings
  2. Go to Service connections
  3. Find your Service connections that were populated when you prepared for the environment and select azdo-sp-test

service-connections

  1. Click on one of the Service connections and click the three dots on the right and select Approvals and checks

service-connections-1

  1. Click See all and select Required template

required-template-1

  1. Click the plus symbol and add the following values depending on your project

required-template

  1. Save the results
  2. Back on your pipeline, try out a run, which will fail

failed-run

Cool, you at least know that the check is working accordingly, but why is this the case? When adding the resources, a name was specified that does not match the one that was specified in the Required template. So let’s update the name and see if it works.

  1. Open the azure-pipelines.yml and instead of name: templates, update it with <projectname>/templates, in this case the project is sandbox
resources:
  repositories:
    - repository: templates
      type: git
      name: sandbox/templates
      ref: master
  1. Run the pipeline once more, and it should succeed now

success-run

The basis is now setup, and you can adjust the values inside the pipeline to target different objects, scopes and definitions.

Conclusion

It was some advanced stuff you went through, but if you managed this far, you have successfully created templates in your repository that can be shared amongst yourself and team members. You can now dynamically assign Azure role assignments through a pipeline which is secured by using templates.

In the next part, we’ll be covering how you can add dynamic cmdlets to run through the pipeline, as we’ve focussed now mainly on the New-AzRoleAssignment and Remove-AzRoleAssignment cmdlet with only three parameters. Even that both cmdlets have more parameters, there are also a lot more cmdlets that can set certain access, say for example the Set-AzKeyVaultAccessPolicy cmdlet. We also haven’t fully covered the traceability through Azure DevOps, so stay tuned for next part!

While the resources that where created during this tutorial don’t cost money depending if you’ve added additional resources to it, it is always good to cleanup after a tutorial. You can run the below script to remove all the resources.

# Variables
$PAT = '<patToken>'
$SubscriptionId = '<subscriptionId'
$ProjectName = '<projectName>'
$AzDoAccount = '<azdoAccount>'

# Connect to Azure
Connect-AzAccount -Subscription $SubscriptionId
$Context = Get-AzContext

# Connect VSTeam module
Set-VSTeamAccount -Account $AzDoAccount -PersonalAccessToken $PAT

# Create the resources in Azure and Azure DevOps
$Environments = @('test', 'qa', 'prod')
foreach ($Environment in $Environments) {
    $ResourceGroup = Remove-AzResourceGroup -Name "rg-data-$Environment" -Location westeurope
    Get-AzADServicePrincipal -DisplayName sp-azdo-connection-$Environment | Remove-AzADServicePrincipal
    Get-VSTeamServiceEndpoint -ProjectName $ProjectName | Where-Object {$_.Name -like "*azdo-sp*"} | Remove-VSTeamServiceEndpoint
}