All Articles

Assign Azure roles dynamically through Azure DevOps - part 2

In this blog, you are going to build upon the basis from the first part of this series. The only pre-requisites that you need for this tutorial, is that you’ve followed along with the first part. So when you are ready, let’s dive right into it.

Adding custom task

In the first part of the tutorial, you’ve mainly focussed on the New-AzRoleAssignment and Remove-AzRoleAssignment cmdlet. Now you are going to look by adding an optional task to run with the Invoke-Expression cmdlet. While the Invoke-Expression can happily run any code that you pass, it can be used maliciously as it construct itself during runtime. For this, you are going to look how to reduce the security risk while running the pipeline. Nevertheless, let’s get going by adding the task to the templates repository.

  1. Open your templates repository in your code editor
  2. Under the task folder, add the following file azure-rbac-powershell-custom-task.yml

azure-rbac-powershell-custom-task

  1. Add the following code which builds up of four parameters
parameters: 
- name: environmentName
  type: string
- name: command 
  type: string 
- name: parameters
  type: string
- name: azureSubscription
  type: string


steps: 
  - task: AzurePowershell@5
    displayName: 'Azure role assigner - custom'
    inputs: 
      azureSubscription: "${{ parameters.azureSubscription }}-${{ parameters.environmentName }}"
      azurePowerShellVersion: 'LatestVersion'
      ScriptType: 'InlineScript'
      Inline: |
        $Command = "${{ parameters.command }}"
        $Params = "${{ parameters.parameters }}"
        Write-Output "Running command: $Command $Params"
        try {
          Invoke-Expression "$Command $Params" -ErrorAction Stop
        } catch {
          Throw "An error occurred while executing: $($_.Exception.Message)"
        }

Let’s go through it a bit what the task does:

  • environmentName: just like in the first part, you can fill in a parameter to target the environment accordingly
  • command: the command that you want to run, let’s say you want to run the New-AzTag cmdlet
  • parameters: the parameters to pass to the command, for example to the New-AzTag you can add the -Name and -Value parameters
  • azureSubscription: the service connection to target

Now you still want to have this flexibility to either run the normal task, or the custom task. For that, you have to make some modifications to the other templates.

  1. Open up the azure-rbac-powershell-job.yml file
  2. Change the parameters to add the newly introduced values to target the task parameters and an additional runCustom parameter that is going to be used to evaluate the tasks to run
parameters: 
- name: environmentName
  type: string
- name: roleAssigner
  type: string
  default: New
- name: objectId
  type: string
- name: scope 
  type: string 
- name: roleDefinitionName
  type: string 
- name: azureSubscription
  type: string
- name: runCustom 
  type: boolean
  default: false
- name: command
  type: string
- name: parameters
  type: string
  1. Just after the steps, add a expression to check the runCustom variable which by default is false
jobs:
  - job: Assigner_${{ parameters.environmentName }}
    displayName: 'Azure role assigner - ${{ parameters.environmentName }}'
    steps: 
    - ${{ if eq(parameters.runCustom, false)}}:
      - 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. After the normal task, add the custom task with the expression that evaluates to true if specified in the parameter
- ${{ if eq(parameters.runCustom, true)}}:
  - template: ../task/azure-rbac-powershell-custom-task.yml
    parameters: 
      environmentName: ${{ parameters.environmentName }}
      command: ${{ parameters.command }}
      parameters: ${{ parameters.parameters }}
      azureSubscription: ${{ parameters.azureSubscription }}

Already sweet, the job is adjusted now accordingly. The stage job parameters needs to be updated also to pass on the default parameter values.

  1. Open the azure-rbac-powershell-stage.yml file
  2. Copy and paste the parameters from the job section and save it

stage-job-parameters

  1. Add the three new introduced parameters just underneath the azureSubscription
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 }}
          runCustom: ${{ parameters.runCustom }}
          command: ${{ parameters.command }}
          parameters: ${{ parameters.parameters }}

Lastly, within the template that is being used as extends, the required parameters are also needed.

  1. Open the azure-rbac-powershell-template-stage.yml file
  2. Adjust the parameters with the following code
parameters:
- name: environments
  type: object
  default:
  - name: test
  - name: qa
  - name: prod
  values: 
    - test 
    - qa
    - prod
- name: taskConfiguration
  type: object
- name: runCustom 
  type: boolean

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 }}
          runCustom: ${{ parameters.runCustom }}
          command: ${{ configuration.command }}
          parameters: ${{ configuration.parameters }}

When you are finished with updating the files, commit back the changes to your Version Control System.

Building in traceability

In the first part, it was already stated that the traceability wasn’t fully covered yet. In this section, you are going to have a look into how this can be achieved through Azure Boards, linking up the changes to a work item, and eventually see it in the Azure Pipelines.

  1. Open the Azure Boards and create a task

create-work-item

When you are searching for a New Work Item and task is not listed, it might mean that you are using a different Process template

  1. Add a title for the new task, in this example it is Add new Azure tag
  2. From the task, click on the three dots and select New branch

create-branch

  1. Name the branch, in this example it is add-az-tag and select the az-role-assigner repository to branch off from

create-branch-from-item

  1. Go to the Azure Repos -> Select the az-role-assigner and switch to the branch

select-branch-repos

  1. Modify the azure-pipelines.yml to create a new Azure tag on the resource group
resources:
  repositories:
    - repository: templates
      type: git
      name: sandbox/templates
      ref: master

extends:
  template: /templates/az/rbac/stage/azure-rbac-powershell-template-stage.yml@templates
  parameters:
    environments: 
      - name: test
    taskConfiguration: 
      - roleAssigner: ''
        objectId: ''
        scope: ''
        roleDefinitionName: ''
        azureSubscription: azdo-sp
        command: New-AzTag
        parameters: '-ResourceId /subscriptions/<subscriptionId>/resourceGroups/rg-data-test -Tag @{tag = ''test''}'
    runCustom: true

Make sure that you modify the subscriptionId with your subscription ID

  1. Commit the changes and add the work item as link

work-item-link

  1. The pipeline will now be trigger and you noticed that the work item is linked

work-item-link-pipeline

From this part, you can link everything back to your commit back to the work item that was developed back in the days. Some great traceability for auditing purposes!

During the time of writing, the deployment status reporting is not implemented for YAML pipelines

You’ll also see that the new tag is added depending on the -ResourceId you gave.

added-tag

Adding intervention

Lastly, as already stated in the beginning, using the Invoke-Expression cmdlet can have malicious intents or it can be misused by granting more permissions then needed which definitely is not desired in a high secure environment. There are always a lot of possibilities available to build security around your pipeline, and in this tutorial you’ll be using the ManualValidation@0 task as a pre-step before executing the custom task. Keep in mind, users with the ‘Queue builds’ permissions do have access to Resume or Reject the run. It still keeps a record who has either resumed or rejected the run.

  1. Open the Project Settings, click Permissions and create a New Group

create-group

  1. Name the group, give it a description and add at least one user

create-group-2

  1. Back in your editor, make sure that you are in the templates repository
  2. In the task folder, create a new file called manual-intervention-task.yml

manual-intervention-task

  1. Add the following content into the file
parameters: 
  - name: command 
    type: string 
  - name: groupName
    type: string
    default: 'Security group'

steps: 
  - task: ManualValidation@0
    displayName: 'Manual validation'
    inputs:
      instructions: 'Do you want to execute: ${{ parameters.command }}'
      emailRecipients: |
        ${{ parameters.groupName }}
  1. In the job folder, create a file called manual-intervention-job.yml as the manual validation task can only run on agentless-jobs
  2. Inside this file, add the following content
jobs: 
  - job: ManualValidation
    pool: server 
    steps: 
      - template: ../task/manual-intervention-task.yml
        parameters: 
          groupName: ${{ parameters.groupName }}
          command: ${{ parameters.command }}
  1. Go to the azure-rbac-powershell-job.yml and just under the jobs, add the following content to conditionally check wether it is a custom or normal run
jobs:
  - ${{ if eq(parameters.runCustom, true)}}:
    - job: ManualValidation
      displayName: 'Manual validation'
      pool: server 
      steps:
        - template: ../task/manual-intervention-task.yml
          parameters: 
            groupName: ${{ parameters.groupName }}
            command: ${{ parameters.command }}
  1. Make sure the parameter groupName is added
- name: groupName
  type: string
  1. Lastly in this file, make sure that the dependsOn is turned on for the ManualValidation job, as you want to make sure that job has finished first
- job: Assigner_${{ parameters.environmentName }}
    displayName: 'Azure role assigner - ${{ parameters.environmentName }}'
    dependsOn:
    - ${{ if eq(parameters.runCustom, true)}}:
      - ManualValidation

Make sure that the groupName parameter is added to both the stages file also.

groupname-stage groupname-stage-template

Now make sure that you commit back the changes. When the templates are updated, you can run a custom run, and see what the result is.

manual-validation

Series conclusion

Phew! That was the end of the this series. You now should have a fully auditable, traceable and secure pipeline running for your Azure role assignments, but also custom assignments. What will you do next? Are you going to add conditions on the higher environments with pull request validations? Will you add a custom list which cmdlets can run when you switch on the runCustom parameter? There are a lot more possibilities to explore!