Let’s talk about YAML pipelines in Azure DevOps. YAML pipelines are great! Using YAML pipelines in Azure DevOps allows deployment pipelines to be expressed as code. Expressing pipelines as code simplifies the use of version control to support build & release pipelines. It also allows DevOps professionals to create libraries of pipeline code that can be reused to accelerate new tasks. All of this is great, but it is also important to ensure that pipelines are secure.

One approach to securing a pipeline is to set a clear separation of responsibilities between those who write the code and those who manage the resources that deploy and execute the code. In some industries this may be more than a best practice; it may be a requirement.

Today we’re going to walk through the implementation of a pattern that can be leveraged to build a secure environment that encourages collaboration and cross-training. We’ll use a small project to provide context and direct our implementation.

Core Concepts

There are a few core concepts that we’ll be working with to implement our pattern. These Azure DevOps features will allow us to secure resources when using YAML templates.

Approvals and Checks

A service connection is what allows Azure DevOps (ADO) to securely connect to external resources such as the Azure Resource Manager, Kubernetes, etc. It is possible to add all sorts of conditions around the use of a service connection. These are known as Approvals & Checks. We will be using Approvals and Required Templates as part of our project.

Note: It is possible to use Approvals & Checks with other resources such as Environments.

Extended Templates

An “extends” template has at least two components. The first component is a “root” template. This is typically the YAML pipeline that triggers the build/deploy. The second component is the “extends” template. The “extends” template augments the behavior of the “root” template by defining the entire flow or modifying the flow specified by the “root”.

Shared Connections

The last feature our project will leverage is the ability to share service connections between projects. This allows us to define and manage all service connections in one place. Service connections can be created in the devops-core repository and then shared with the demo repository. The permissions and requirements of the service connection are enforced even when the connection is shared.

The Project

We are going to create a new DevOps environment that uses secured YAML pipelines for both build and deploy operations. We also have our first customer: a development team that would like to deploy a shiny new Azure Container Registry! We need to ensure the following:

  1. The development team may create a deployment template
  2. The development team may manage the pipeline triggers
  3. The development team must obtain approvals for deployments
  4. The development team must use a predefined build & release pipeline.

At the end of the project the development team will be able to deploy a container registry using a secured devops implementation.

The templates used in this project can be found on github.

Architecture Overview

Our “separation of concerns” pattern uses a secure project that is owned and maintained by the DevOps team. We’ll name this project devops-core. The devops-core project will be home to service connections, pipeline templates and the IaC needed to maintain and run the pipeline infrastructure. We’ll go into each of these later as we build them out and secure them.

Additional projects are created for other development teams. Members of development teams may view the contents of the devops-core project (minus secret things) but are unable to make changes to devops-core resources without obtaining approvals from the DevOps team.

site-image
Fig 1: Architecture Overview

Implementation

We’ll start the implementation by creating and configuring our devops-core project and all of its dependencies. After that we’ll build out a demo project for our development team. We will then create a little bicep template and a YAML pipeline to deploy the project.

This guide makes the following assumptions:

  1. You’ve already got an Azure Subscription.
  2. You’ve already got an Azure DevOps Organization.

Organization Setup

Let’s create two groups: devops-core and demo.

  1. Sign into Azure DevOps (ADO)
  2. Navigate to ‘Organization’ settings in the lower left corner.
  3. Under ‘Security’ choose ‘Permissions’.
  4. Create two groups: devops-core and demo

site-image
New ADO Permission Groups

Project: devops-core

Create the Project

Now that we have the two groups setup we can create the devops-core project. This step is pretty easy - just navigate back to the organization page and click the “+ New Project” button in the upper-right.

site-image
New ADO Project

Group Permissions

Head back to the Organization settings and update the permissions of those two groups created previously.

  1. Add the devops-core group as a member to [devops-core]/Project Administrators
  2. Add the demo group as a member to [devops-core]/Readers

Create a Service Connection

The next thing we’ll do is create a service connection. The process is pretty well documented in the “Manage Service Connections” page found in the ADO docs.

New Service Connection

For the purpose of this project we’ll create a service connection named devopscore-arm-dev. The devopscore-arm-dev connection will be an ARM connection that has contributor access to a dev subscription.

Note: In practice it is a good idea to develop a strategy for limiting the scope of a service connection.

Create Pipeline Repo

We will create one repository named pipeline. This repository will contain pipeline templates that will be extended by root templates in other repositories.

pipeline folder layout

.
├── entry-point
│   └── bicep-deployment.yml
└── README.md

The entry-point folder contains our “extends” templates. These are the templates that will be called by other pipeline templates. Our service connections will require that an approved “extends” template is used.

The bicep-deployment.yml is the “extends” template that will be used by our project. There is quite a lot that can be done with extended templates but for our purpose we will keep it simple and use the extended template to define the flow for building and deploying a bicep template. Any “root” templates that extend the bicep-deployment.yml will supply parameters and hand over control the the “extends” template.

Go ahead and create the directory structure noted above. The contents of bicep-deployment.yml are available on github. We’ll talk through some of the YAML next.

Excerpt: bicep-deployment.yml

parameters:
- name: bicepPath
  type: string
- name: targetRg
  type: string

variables:
...
- name: armServiceConn
  ${{ if eq(variables['System.TeamProject'], 'devops-core') }}:
    value: devopscore-arm-dev
  ${{ if ne(variables['System.TeamProject'], 'devops-core') }}:
    value: devopscore-arm-dev-$(System.TeamProject)
- name: rgLocation
  value: centralus

stages:
...


Let’s take a closer look at a few lines of this YAML pipeline.

parameters

parameters:
- name: bicepPath
  type: string
- name: targetRg
  type: string

These parameters will be supplied by the root template. They’ll be used within the pipeline to generate a resource group and also determine the path of the bicep template.

Service Connection

variables:
...
- name: armServiceConn
  ${{ if eq(variables['System.TeamProject'], 'devops-core') }}:
    value: devopscore-arm-dev
  ${{ if ne(variables['System.TeamProject'], 'devops-core') }}:
    value: devopscore-arm-dev-$(System.TeamProject)

The name that a pipeline uses to access a service connection depends on the execution context of the original or “root” pipeline. If the context of the executing pipeline is the same as the shared connection then the project name is appended to the service connection name. If the context of the executing pipeline is the same as the service connection, ie. devops-core, then the name of the service connection is unchanged. The conditional insertion allows us to render the correct connection name when the pipeline runs.

Add Checks & Approvals

The service connection created previously has the ability to manage resources in Azure. These next steps will place restrictions on the use of the service connection. We will create two restrictions:

  1. An “approval” check that will require that a member of the DevOps team provide an approval at deploy time.
  2. A “required template” check that will require the “root” pipeline template extend our “bicep-deployment.yml” template.

NOTE Approvers may be anyone within the organization.

Approval

  1. Navigate to the service connection and select “Approvals and Checks” from the vertical ellipse menu.
  2. From the next screen select the “Add Check” button.
  3. Choose the option for “Approvals”
  4. Add the ‘devops-core’ group to the list of approvers.
Approvals

Required Template

  1. Navigate to the service connection and select “Approvals and Checks” from the vertical ellipse menu.
  2. From the next screen select the “Add Check” button.
  3. Choose the option for “Required Template”.
  4. Supply the repository information.
Required Template

Project: demo

Now we need a project for our development team. Let’s call this project demo and create it in the same organization as our devops-core project. The demo project will contain the Bicep templates that the development team will use to deploy the Azure Container Registry.

Group Permissions

Head back to the Organization settings and update the permissions for the devops-core and demo groups.

  1. Add the devops-core group as a member to [demo]/Project Administrators
  2. Add the demo group as a member to [demo]/Contributors

Share the Service Connection

Before we can create a pipeline that uses the ARM service connection that was created in the devops-core project we need to share that service connection with the infra project. This is done in the “project settings” of the “devops-core” project. Once there, locate and select the service connection that we want to share (devopscore-arm-dev). From the vertical ellipse chose “Security”. At the bottom of the page you’ll find “Project Permissions”.

Project Permissions

This will allow pipelines in the demo project to use the devopscore-arm-dev service connection.

Create an Infra Repo

Now that we have setup some global permissions for the project we need to add a repository for our developers to store their IaC. Let’s call the repository infra and create the initial directory structure shown below.

infra folder layout

.
└─── container-registry
     ├─── parameters
     │    ├─── dev.parameters.json
     |    └─── main.parameters.json
     ├─── azure-pipelines.yml
     └─── main.bicep

The main.bicep file will contain a basic Bicep template that can be used to deploy a container registry. The parameter files are used to supply environment overrides (dev) and defaults (main). The azure-pipelines.yml is our “root” template.

The azure-devops.yml is our “root” pipeline template. This template will be extended by referencing our “extends” template (bicep-deploy.yml).

azure-pipelines.yml

resources:
  repositories:
  - repository: core
    type: git
    name: devops-core/pipeline
    ref: main

extends:
  template: entry-point/bicep-deployment.yml@core
  parameters:
    bicepPath: container-registry
    targetRg: demo

The azure-pipelines.yml is pretty simple. This is because it is only responsible for triggering builds and calling the extended template.

The “resources” section allows us to reference the devops-core/pipeline repository. We are able to reference the “extends” template by using the “extends” attribute and adding template: entry-point/bicep-deployment.yml@core.

bicep-pipeline.yml

azure-pipelines.yml

parameters:
- name: bicepPath
  type: string
- name: targetRg
  type: string
extends:
  template: entry-point/bicep-deployment.yml@core
  parameters:
    bicepPath: container-registry
    targetRg: demo

To see how the two templates interact compare the “parameters” section of the bicep-pipeline.yml to the “extends” section of the azure-pipelines.yml. The “template” attribute indicates the “extends” template that our “root” template will implement. The parameters defined in the bicep-pipeline.yml are supplied by the azure-pipelines.yml document. This allows the development team to supply some configuration details.

Pipeline

As we’ve seen so far, the actual pipeline definition is created by two different files: a “root” template and an “extends” template. Let’s instantiate our pipeline.

  1. Go to the demo project and navigate to “Pipelines”
  2. Select ‘New Pipeline’
  3. Choose ‘Azure Repos Git’ as the location of the code
  4. Select the infra repository
  5. Finally, choose ‘Existing Azure Pipelines YAML file’ and then supply the location of the azure-pipelines.yml file.
  6. Optionally, rename the pipeline. This will allow us to reuse the same repository for multiple infra projects and pipelines.

Pipeline Execution

Let’s try executing the pipeline. On the first execution you’ll be prompted to provide the pipeline with permissions to access external resources such as the service connection. This will only occur the first time that each new resource is accessed by the pipeline.

There’s a lot of information to explore on the summary page.

Pipeline Execution
  1. The “Sources” section shows two repositories. These include the repository that contains the “root” template as well as the repository that contains the “extends” template.

    Pipeline: Sources
  2. The ‘Dev’ stage has passed one check but it is waiting on another. Clicking on the “Review” button or clicking on the link in the stage tile will show the approval and check details. The pipeline has passed the “Required Template” check but it is still waiting on an approval. Clicking “approve” will allow the pipeline to proceed to completion.



Additional deployment stages, such as “test” and “production” can be created by adding additional service connections and updating the templates to include additional deployment stages that use the new connections. The connections can be secured in the same manner as the dev connection and different approvers can be specified for each environment.

Summary

That’s pretty much it. In a nut-shell we can secure our pipelines by drawing clear boundaries around responsibilities: develop and deploy. This separation can be achieved in Azure DevOps by creating a project for the devops team and requiring that all release pipelines use curated “extends” templates that are developed/approved by the responsible teams. Maintaining service connections in one place makes the connections easier to manage and easier to secure.