Azure DevOps Pipeline with Workload Identity federation
Tags: azure, devops, security
Published: 2023-10-10 11:22:41

If you are using Azure DevOps pipelines with **Service Connections**, you may have noticed a mysterious **_blue dot_** next to your service connection name

![blue dot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pkw8m4d4421kb95yxplc.png)

There's also a notification on top of the Service connection page:

![Convert your existing Azure Resource Manager service connections which use secrets to authenticate to leverage Workload identity federation instead, for improved security and simplified maintenance.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3qi971hx68xk2xwm0aaf.png)

which opens a [Create an Azure Resource Manager service connection using workload identity federation](https://learn.microsoft.com/en-us/azure/devops/pipelines/library/connect-to-azure?view=azure-devops#create-an-azure-resource-manager-service-connection-using-workload-identity-federation) documentation. 

## Why is it important

Azure Resource Manager **Service Connections** that use **Service Principal** are using Service Principal **_Id_** and either key (**_a secret_**) or **_certificate_** to authenticate. 
**_Managed Identity_** authentication, on the other hand, is for self hosted agents.

I never understood why can't we use Managed Identity for Microsoft hosted agents, since Azure DevOps runs on Azure anyway. 🤔 It seems that someone at Microsoft was of the same opinion. 

So here we are, it seems we can finally (mind it's still in preview) get rid of these secrets and certificates =)

## New Azure Service Connection

I went ahead and created a new service connection using **_Workload Identity federation (automatic)_**

![New service connection](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rybie0x7f1duf7kpsv2i.png)

Details page looks very familiar, there's a link to **Manage service connection roles** and to **Manage Service Principal**

![Details page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tyl69ct0nm4q13mh9gqr.png)

### Manage Service Principal

Let's see what happens here... **Manage Service Principal** redirects me to Azure Portal. It's an **App Registration** page

![App Registration page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fcubsa0auc06jr2dsw9z.png)

After clicking on **Managed application in local directory** link, I'm redirected to the **Enterprise Application** page

![Enterprise Application page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mcr9qercdq598t6o4v1l.png)

## Grant API Permissions

Perfect, granting access to the identity used by the pipeline is therefore exactly the same, as to any other service using Managed Identity. 

Let's say that my pipeline will create some items in a SPO list. I'm using PnP.PowerShell, so both: Graph and SharePoint API permissions are required. Obviously, I'm scoping access using `Sites.Selected`.

What you need to note down here, is the information displayed on the **Enterprise Application** page. The **Application Id** is the same in both cases, but you need the correct **Object Id**.

```powershell
$appId ="3fxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx6d"
$objId = "bdxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx34"
```

### Grant API Permissions 

Use MsGraph PowerShell to grant `Sites.Selected` API permissions

```powershell
$tenantID = "{tenant-id}"
$objId = "bdxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx34" #from Enterprise Application page
$appId = "3fxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx6d"

Connect-MgGraph -TenantId $tenantID -Scopes "AppRoleAssignment.ReadWrite.All", "Application.Read.All"
# Get Service Principal
$sp = Get-MgServicePrincipal -ServicePrincipalId  $objId

### STEP 1: GRANT API PERMISSIONS TO MANAGED IDENTITY

#Retrieve the Azure AD Service Principal instance for the Microsoft Graph (00000003-0000-0000-c000-000000000000) or SharePoint Online (00000003-0000-0ff1-ce00-000000000000).
$servicePrincipal_Graph = Get-MgServicePrincipal -Filter "DisplayName eq 'Microsoft Graph'"
$servicePrincipal_SPO = Get-MgServicePrincipal -Filter "DisplayName eq 'Office 365 SharePoint Online'"

#Get AppRole Id for Sites.Selected
$appRole_GraphId = ($servicePrincipal_Graph.AppRoles | Where-Object { $_.AllowedMemberTypes -eq "Application" -and $_.Value -eq "Sites.Selected" }).Id
$appRole_SPOId = ($servicePrincipal_SPO.AppRoles | Where-Object { $_.AllowedMemberTypes -eq "Application" -and $_.Value -eq "Sites.Selected" }).Id

# Grant API Permissions
$graphParams = @{
    principalId = $sp.Id
    resourceId  = $servicePrincipal_Graph.Id
    appRoleId   = $appRole_GraphId
}
$spoParams=@{
    principalId = $sp.Id
    resourceId  = $servicePrincipal_SPO.Id
    appRoleId   = $appRole_SPOId
}
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -BodyParameter $graphParams
New-MgServicePrincipalAppRoleAssignment  -ServicePrincipalId $sp.Id -BodyParameter $spoParams

#Quick check if everything went well
Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id
```

Let's double check. Both, the App Registration page and the Enterprise App page show that API permissions have been granted:

![API permissions on App Registrations pageon](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0lv4xq6ne9pit4hz45tr.png)


![API permissions on Enterprise Application page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e19t89zr8g16io5lmci2.png)

### Grant permissions to a SPO site

Next, grant permissions to the SPO site. This script is the continuation of the example above and is using `$sp` object to retrieve the Service Principal Display Name

```powershell
### STEP 2: GRANT SPO ACCESS TO MANAGED IDENTITY
$tenantName = "{tenant-name}.sharepoint.com"

$spoSiteId = $tenantName + ":/sites/" + $siteName + ":"
$appRole = "write"
$application = @{
    id          = $appId
    displayName = $sp.DisplayName
}

Connect-MgGraph -Scope Sites.FullControl.All
New-MgSitePermission -SiteId $spoSiteId -Roles $appRole -GrantedToIdentities @{ Application = $application }

```

## The pipeline

So far everything looks really familiar. Let's look into the pipeline now. 

I want to connect to the SP site using PnP.PowerShell. This time however, I won't be using service principal id or certificate. I want to use the Managed Identity from the pipeline's context.

This requires an additional step. First, I need to retrieve the token from the Azure Context, and then I'm using the token to connect to SPO. 
```powershell
$azAccessToken = Get-AzAccessToken -ResourceUrl $url
$conn = Connect-PnPOnline -Url "$url/sites/$(siteName)" -AccessToken $azAccessToken.Token -ReturnConnection
$web = Get-PnPWeb -Connection $conn
```

This example pipeline has some extra try/catch. You wouldn't do it productively, but it will help you to see where any potential errors occur =)

```yaml
variables:
- name: tenantName
  value: "contoso"
- name: siteName
  value: "siteName"

steps:
- task: AzurePowerShell@5
  name: ConnectPnpOnline
  inputs:
    azureSubscription: DEV_Connection
    azurePowerShellVersion: latestVersion
    ScriptType: InlineScript
    Inline: |
      $url = "https://$(tenantName).sharepoint.com"
      Write-Host "##[debug]Connecting to $url/sites/$(siteName)"

      Write-Host "##[group]Install/Import  PS modules"
      Install-Module PnP.PowerShell -Scope "CurrentUser" -Verbose -AllowClobber -Force
      Write-Host "##[endgroup]"

      try {
        $azAccessToken = Get-AzAccessToken -ResourceUrl $url
        $conn = Connect-PnPOnline -Url "$url/sites/$(siteName)" -AccessToken $azAccessToken.Token -ReturnConnection
        Write-Host "##[debug]Get-PnPConnection"
        Write-Host $conn.Url
      }
      catch {
          Write-Host "##[error] 1 (Connect-PnPOnline -AccessToken): $($_.Exception.Message)"
      }

      try {
        Write-Host "##[debug]Get-PnPWeb"
        $web = Get-PnPWeb -Connection $conn
        Write-Host  $web.Title
      }
      catch {
          Write-Host "##[error] 2 (Get-PnPWeb): $($_.Exception.Message)"
      }
  displayName: A couple of tests

```

The script above grants **Managed Identity** `write` permissions, so let's try to create an item. Make sure you have a list named "Test"

```yaml
variables:
- name: tenantName
  value: "contoso"
- name: siteName
  value: "siteName"

steps:
- task: AzurePowerShell@5
  name: DeploySPFx
  inputs:
    azureSubscription: DEV_Connection
    azurePowerShellVersion: latestVersion
    ScriptType: InlineScript
    Inline: |
      # Write-Host "##[group]Install/Import  PS modules"
      # Install-Module PnP.PowerShell -Scope "CurrentUser" -Verbose -AllowClobber -Force
      # Write-Host "##[endgroup]"

      $url = "https://$(tenantName).sharepoint.com"

      try {
        $azAccessToken = Get-AzAccessToken -ResourceUrl $url
        $conn = Connect-PnPOnline -Url "$url/sites/$(siteName)" -AccessToken $azAccessToken.Token -ReturnConnection
        Add-PnPListItem -List "test" -Values @{"Title"="$(Build.BuildId)"} -Connection $conn
      }
      catch {
          Write-Host "##[error]$($_.Exception.Message)"
      }
  displayName: Add item
```

It works! =)

![Item created](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k8hctylwhykzhy4tshe2.png)

So cool! 🤩 Now we can get rid of the secrets and certificates 

### Why not `Connect-PnPOnline -ManagedIdentity`?

Currently, the `Connect-PnPOnline -ManagedIdentity` doesn't seem to support the **_Workload Identity federation_**. Would be great if this changed, so we don't have to retrieve the token separately

### How do I know which service principal is really used?

You may use the following script to retrieve the object ID of the service principal:

```powershell
$x = (Get-AzContext).Account.Id
$y = Get-AzADServicePrincipal -ApplicationId $x
Write-Host "##[debug] $($y.Id)"
```

Execute it from the `AzurePowerShell@5` task with `azureSubscription` set to the name of the service connection using the **_Workload Identity federation_**