SoliDeoGloria.tech

Technology for the Glory of God

Grant Admin Consent for an Azure AD Application With Terraform

  • 5 minutes

One challenge we often run into when provisioning Azure AD applications with Terraform is a need to grant admin consent for API permissions. Sadly there is not a native resource within Terraform to make this happen, however with some creative use of provisioners (yes, I feel bad about it too) we can ensure that admin consent is granted for our applications.

To start with, we deploy our Azure AD application as normal. As part of the configuration, we also assign the required API permissions.

resource "azuread_application" "api_permissions" {
  display_name            = "API Permission Demonstration"
  sign_in_audience        = "AzureADMyOrg"
  identifier_uris         = ["https://SoliDeoGloria.tech"]

  required_resource_access {
    resource_app_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph

    resource_access {
      id   = data.azuread_service_principal.msgraph.app_role_ids["Application.ReadWrite.All"]
      type = "Role"
    }
    resource_access {
      id   = data.azuread_service_principal.msgraph.app_role_ids["Group.ReadWrite.All"]
      type = "Role"
    }
    resource_access {
      id   = data.azuread_service_principal.msgraph.app_role_ids["User.Read.All"]
      type = "Role"
    }
  }
}

resource "azuread_service_principal" "api_permissions" {
  client_id = azuread_application.api_permissions.client_id
}

# Use data resources to get the UUIDs so we can address them by name
data "azuread_application_published_app_ids" "well_known" {}

data "azuread_service_principal" "msgraph" {
  client_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph
}

If you run the above Terraform code, and then find your application in Azure AD, you will see that it helpfully reminds you that administrative consent is required for these permissions.

Admin consent required

So how do we get over this last hurdle?

Update: So, it turns out that Terraform does have a resource for granting consent. My thanks to Jonas Gschwend for reaching out and letting me know that I had completely missed the azuread_app_role_assignment resource. And to my shame, it’s not like this is a new resource. It first arrived in the v2.4.0 provider, more than 2 years ago. So I’ve updated the solution to use this resource. The original solution is left below that for humility posterity.

Enter the azuread_app_role_assignment resource. It allows us to specify which role assignments should be approved for a specific service principal. In addition to being a native resource, it also allows us to only approve the permissions we have allocated. With the orginal script option, if someone managed to add a permission to the app before we ran the approval, that permission would also be granted.

Aside: The azuread_app_role_assignment resource requires a service principal (Enterprise Application) to be created, while the original solution could operate on the application registration. In most situations that won’t matter as we normally create the service principal in additon to the registration. But we need to account for this and turn off login and display to users if we don’t need the SP.

However nothing is quite a straightforward as it seems. The azuread_app_role_assignment requires us to pass the object_id of the service principal to which we are granting access. However this is not used in the required_resource_access block on the application, so we don’t have direct access to it. To do this we need to get the details of all the service principals available to us.

data "azuread_service_principals" "all" {
  return_all = true
}

We can then use this data resource in a for_each block

# Because both required_resource_access and resource_access are blocks, they are internally
# represented as lists of obejcts. Therefore, we need to build a new list which flattens
# these nested representations into a single list of objects.
# To make life difficult, we need the object_id and not the client_id of the service principal :(
resource "azuread_app_role_assignment" "api_permissions" {
  for_each = { for v in flatten([
    for rra in azuread_application.api_permissions.required_resource_access : [
      for ra in rra.resource_access : {
        resource_object_id = one([
          # Loop through all the service principals and find the object_id of the one
          # that matches the client_id of the resource_app_id from the azuread_application.
          for sp in data.azuread_service_principals.all.service_principals :
          sp.object_id
          if sp.client_id == rra.resource_app_id
        ])
        app_role_id = ra.id
      }
    ]
  ]) : join("|", [v.resource_object_id, v.app_role_id]) => v }

  principal_object_id = azuread_service_principal.api_permissions.object_id
  resource_object_id  = each.value.resource_object_id
  app_role_id         = each.value.app_role_id
}

And just like that, we have approvals of the API permissions for the application!

Original solution:

While Terraform doesn’t provide a resource to grant admin consent, the Azure CLI does. The az ad app permission admin-consent command will grant admin consent for all assigned permissions, if you have the correct permissions.1

Enter the Terraform provisioner block. A local-exec provisioner allows us to run a command on the local machine after creating a resource. Perfect! we might say. There is one catch however. The provisioner command only runs once, after the resource is created. While that is fine for 99% of requirements, in some situations we may want to be able to run the command after a change to the resource — in our case, we want it to also run if we change the permissions assigned to the application.

So enter the Terraform null_resource. One of the features of the null_resouce is the ability to define a map which will trigger the recreation of the resource (and therefore the running of the provisioner) if the values change.

resource "null_resource" "aad_admin_consent" {
  triggers = merge(
    [for app in azuread_application.api_permissions.required_resource_access :
      { for role in app.resource_access :
        join("_", [app.resource_app_id, role.id]) => role.type
      }
    ]...
  )

  provisioner "local-exec" {
    command = "sleep 30 && az ad app permission admin-consent --id ${azuread_application.api_permissions.application_id}"
  }
}

For the triggers, we loop through the list of required_resource_access blocks created in the azuread_application (the outer for loop), and then loop through the actual permissions granted for each app (the inner for loop). We then build a map of values, where the key is the combination of the application ID and the role ID, and the value is the type of permission granted.

What this means is that if we add or remove a permission from the application, this map will change, which will then trigger the null_resource to be recreated. On recreation, the admin consent CLI command will run (after a 30 second pause to allow Azure AD to catch up to the creation of the application if required) and the permissions will be consented.

Simples.


  1. Either Privileged Role Administrator or Global Administrator is required. ↩︎