Grant Admin Consent to Azure AD Apps in Azure Pipelines

In a previous blog post, I described a series of scripts leveraging various Azure CLI commands.  My end goal is a multi-stage, YAML based Azure Pipeline to build and deploy an Azure FunctionApp that provisions Office 365 resources including Teams, SharePoint sites, and other resources using the Microsoft Graph. I want to provision all the infrastructure needed for the application into development and production environments (storage, app insights, service plan, etc.). The infrastructure includes Azure AD application registrations for a FunctionApp and for a custom connector for Flow (see the tutorial using $batch MS Graph calls for a similar connector).   I thought converting the entire script to an Infrastructure as Code deployment stage in Azure Pipelines would be a great learning experience. It was, but for different reasons than I expected. This post details the issue I ran into when attempting to create an Azure AD application registrations and grant consent to that application using the Azure CLI DevOps Task.

From my previous post, I had all the Azure CLI commands to create and configure the Azure AD application registrations, retrieve the clientId, assign the roles and resources (like the MS Graph and SharePoint permissions), and then grant the Admin consent from the script.

The entire deployment process works great from the create-devenv.sh script run locally to build the application. The bits that created the Azure AD app registration follows below.

# Create a client secret for the app registration
clientSecret=$(openssl rand -base64 44)

# Register an App Principal to be used for Azure Function App Authentication 
az ad app create --display-name "FunctionApp" \
                --password $clientSecret \
                --identifier-uris "https://functiopnapp.azurewebsites.net" \
                --reply-urls "https://functionapp.azurewebsites.net/.auth/login/aad/callback" \
                --required-resource-accesses "$FUNC_REQUIRED_RESOURCES" \
                
# Retrieve the clientId for the Azure Function App
funcAppClientId=$(az ad app list --output json | jq -r --arg appname "functionapp" '.[]| select(.displayName==$appname) |.appId')

# Create app roles for the application 
az ad app update --id $funcAppClientId --app-roles "$FUNC_APP_ROLES" 

# Grant consent the Azure AD Func app 
az ad app permission admin-consent --id $funcAppClientId 

When the script runs locally the entire infrastructure is deployed to Azure. consistently as infrastructure as code. Azure AD apps are registered for use from a Function App and a local development environment. But the goal is to do this from a pipeline.

Azure Pipelines Service Connections

If you are creating an Azure Pipeline that provisions resources in Azure, like a Function App, you'll need an Azure Resource Manager service connection. Service connections enable exactly that, connections to common services that enable actions like creating, configuring, or removing aspects of a given service.  Azure Resource Manager connections are just one connection type. There are many other connection types like Github, Docker, npm and others) as well.  

When configuring tasks in a pipeline that is for a resource like a .NET Core Function App you are prompted for an Azure Subscription that will be used for the resources you need to deploy.  Service connections can be created in several  ways.

Create a Service Connection with a Pipeline

One way, as the image below shows, is by using the New pipeline  button in the Azure Pipelines UI, selecting your source repository, selecting a pipeline type, and the Azure Subscription to use. If you select a resource type that requires a service connection, like a Function App, you a presented a bit of a wizard to specify a resource to manage.  But we don't have a resource, we want to deploy them using infrastructure as code.

Create a Service Connection Automatically from Project Settings

A second option is using Azure DevOps Project Settings.  After opening a DevOps projects settings, the image below shows (1) clicking Service Connections, (2) click New service connection, and (3) completing the Add an Azure Resource Manager service connection screen.

When you complete the form and click OK, the following actions are completed for you:

  1. An Azure AD application is created (registered) on behalf of the current user
  2. The application is assigned the Contributor role for the Azure Subscription.
  3. An Azure Resource Manager service connection is created using this new Azure AD application registration's details
  4. The service connection can now be used when configuring Pipeline tasks.

That third step creates an Azure AD app registration with a name similar to {user name}-{project name}-{subscription Id} by default. For example if I create a service connection for a project named DevOpsBlog, I would see an app registration in the format peteskelly-DevOpsBlog-9379263f-c48c-4aba-a676-xxxxxxxxxxxx.

wizard-appreg

Create a Service Connection Manually

You can also choose to create your own Azure AD application registration and then configure your service connection to use that application manually. The image below shows an example of using an existing Azure AD application (clientID) and password (secret) created manually.  Finally, if you want this connection available to all pipelines in the project, check the checkbox at the bottom of the page.

Azure Resource Service Connection Dialog

Configure Tasks with the Service Connection

Once you have a service connection configured you can then reference that connection in a pipeline task's azureSubscription property. The snippet below shows using the our connection in configuring the Azure CLI task.

  - task: AzureCLI@2
         displayName: 'Azure CLI - Create AAD app for FunctionApp' 
         inputs:
            azureSubscription: 'SasquatchCoding Pay-As-You-Go ...'
            scriptType: 'pscore'
            scriptLocation: 'scriptPath'
            scriptPath: '$(System.ArtifactsDirectory)/drop/deploy/powershell/deploy-aadfunctionapp.ps1'      
            arguments: '... (arguments removed)'
            errorActionPreference: 'stop'
            

The snippet above is an Azure CLI task which will use our service connection as the azureSubscription and executes a PowerShell core script that was copied to the /drop/powershell/ folder as part of the build output.

Convert an Existing Azure CLI Command

Once the Pipeline and service connection are created, you can convert the script to be callable from a DevOps task.  I started converting the scripts and extending an existing azure-pipelines.yml file with a second stage, a new job, and enabling the script to receive parameters from the Azure CLI task.  Sounded simple enough.  The code below shows the converted script copied as part of the build stage of the pipeline.

Param(
  [parameter(Mandatory=$true)][string]$ResourceGroup,     
  [parameter(Mandatory=$true)][string]$FunctionApp,
  [parameter(Mandatory=$true)][string]$AADResourcesConfigPath,
  [parameter(Mandatory=$true)][string]$AADRolesConfigPath,
  [parameter(Mandatory=$true)][string]$AdminAccount,
  [parameter(Mandatory=$true)][string]$AdminPassword
)

# Display the bound parameters for debugging the pipeline output 
foreach ($key in $MyInvocation.BoundParameters.keys)
{
  Write-Host "Parameter: $($key) -> $($MyInvocation.BoundParameters[$key])"
}

# Create a function app in the resource group, using the storage account and in the created plan 
Write-Output  "Begin registering App Principal for Function App $FunctionApp."

Write-Output  "Reading required resources file $AADResourcesConfigPath..."
$aadRequiredResources = Get-Content -Path "$AADResourcesConfigPath" | Out-String 

Write-Output  "Reading required roles file $AADRolesConfigPath..."
$aadRequiredRoles = Get-Content -Path "$AADRolesConfigPath" | Out-String 


#Generate base64 string to use as clientSecret
$clientSecret = $(openssl rand -base64 44)
Write-Host  "Generated app secret $clientSecret..."

Write-Output  "Creating AAD App for $FunctionApp..."
az ad app create --display-name "$FunctionApp" `
                 --password "$clientSecret" `
                 --identifier-uris "https://$FunctionApp.azurewebsites.net" `
                 --reply-urls "https://$FunctionApp.azurewebsites.net/.auth/login/aad/callback" `
                 --required-resource-accesses "$aadRequiredResources"

Write-Output  "AAD App registration created."

# Retrieve the clientId for the Azure Function App post creation
$funcAppClientId = $(az ad app list --output json | jq -r --arg appname "$FunctionApp" '.[]| select(.displayName==$appname) |.appId')
Write-Output  "Retreived  AAD App clientId $funcAppClientId..."

# Create app roles for the application 
Write-Output "Adding roles for App Principal for Function App..."
az ad app update --id $funcAppClientId --app-roles "$aadRequiredRoles"

Write-Output  "Finished registering App Principal for Function App $FunctionApp."
Write-Output ""
#endregion #####################################################################

Cool, now we're in business right? Nope. But the service connection was granted the Contributor role to the subscription - this should work, right? Wrong!

Azure RBAC vs Azure AD Roles

In the end Azure RBAC and Azure AD Roles do not overlap!  As this article states:

Azure AD administrator roles span Azure AD and Microsoft Office 365, ...however, by default, the Global Administrator doesn't have access to Azure resources.

Although the application has a access to the resources in the Azure subscription, the application is restricted in Azure AD and must be granted explicit permissions.

If you run the pipeline now and call the Azure CLI task you get the following: "ERROR: Directory permission is needed for the current user to register the application". The application id doesn't have the privileges to create the Azure AD app registration. Even assigning the Owner role in the subscription to the application will not enable the Azure CLI task to register an application.

Granting Required Active Directory API Permissions

At least the error message is somewhat specific and obvious. Granting the application permissions to read and create directory objects in Azure AD seems to do the trick.

For our purposes, we need our application to have the Application.ReadWrite.All and Directory.ReadWrite.All (you might want to restrict to Directory.Read.All) from the Azure Active Directory Graph as shown below.

Adding the permissions, and granting admin consent, enables the application to create an app registration for the Function App when called from the Azure CLI task in the pipeline.  Finally we're in business, right? Well, almost.  

The steps above enable the script called from the Azure CLI pipeline task to complete and the Function App app registration is created, but the application API permissions require admin consent to be used. The Pipeline task will actually complete, but consent will not be granted for use.

The only remaining issue is granting consent to the Function Apps client permissions. The original script made a final call to the az ad permissions admin-consent --id [app_id]. If we are running the script locally as an Azure AD Global Admin, then this works, fine. But in the DevOps pipeline we are logged in as the service principal of the service connection and will fail.

Even if you assign the service connections app registration the Global Administrator Azure AD role (please don't do this!), the attempt to use the Azure CLI to grant admin consent will fail with the following: "AADSTS50058: A silent sign-in request was sent but no user is signed in."  

One way to provide admin consent is to use an actual user account with Global Admin permissions. But how can we ensure these credentials are not leaked? I noticed the following in the task logs.

arspnlogin

The Azure CLI task logs in as the service princial using az login with a service principal, secret, and tenant information. Logging in as the admin user (global admin) in the script using az login should do the trick!

One More Azure CLI Task

Adding one more script to be called in the pipeline brings it all together.  The following script is then called as another pipeline task.  I found if I call this inline in the same script it may fail due to timing issues, but calling as a separate task seems to be a viable workaround.  

Param(
  [parameter(Mandatory=$true)][string]$FunctionApp,
  [parameter(Mandatory=$true)][string]$AdminAccount,
  [parameter(Mandatory=$true)][string]$AdminPassword
)

# Display the bound parameters for debugging the pipeline output 
foreach ($key in $MyInvocation.BoundParameters.keys)
{
  Write-Host "Parameter: $($key) -> $($MyInvocation.BoundParameters[$key])"
}

# Retrieve the clientId for the Azure Function App post creation
$funcAppClientId = $(az ad app list --output json | jq -r --arg appname "$FunctionApp" '.[]| select(.displayName==$appname) |.appId')
Write-Output  "Retreived  AAD App clientId $funcAppClientId..."

Write-Output "Logging in as $AdminAccount to perform admin consent..."
# Login as the service account (a user) and grant consent 
az login -u $AdminAccount -p $AdminPassword --allow-no-subscriptions

# Grant consent the Azure AD Func app 
Write-Output "Granting consent for scopes to Azure AD Function App..."
az ad app permission admin-consent --id $funcAppClientId  

Write-Output  "Finished registering App Principal for Function App $FunctionApp."
Write-Output ""

Lastly, another Azure CLI task is needed to call this script (This is the same as above, with only the script name changed).

Now, some might say this is risky, and I agree, but granting admin consent in a DevOps pipeline might be needed in some situations.  However, to mitigate the risk you can create a Library variable group to secure the secrets.  If you truly need this and want to to use Azure KeyVault, you can even connect that variable group to Azure KeyVault to store and managed the administrator password.  

Whew!

I thought this would be a quick exercise of converting a script and creating a couple tasks in a YAML file...  Well, file this one under #nothingsevereasy.   If anyone has an alternative, or knows of a recommended way of granting consent during a DevOps deployment please leave me a comment. Very interested to know if others have had similar issues and other options. Or if there is simply a better way, please let me know.  

HTH - This might be more of a bread crumb post for me, but perhaps someone else can get some use out of it.  

Tweet Post Update Email

My name is Pete Skelly. I write this blog. I am the VP of Technology at ThreeWill, LLC in Alpharetta, GA.

Tags:
azure cli functions powershell provisioning devops sharepoint teams graph
comments powered by Disqus