Now some of my colleagues have been making the case that we Azure Terraformers should exclusively be using the AzAPI provider in lieu of our dear old friend the “AzureRM”. So I decided to take them up on this challenge.

Now as you know, I am no stranger to the AzAPI. I have used it for quite a while. I use it when I run into a gap or whitespace in the AzureRM provider — and in this role, the AzAPI serves me well.

Tonight, I had to quickly onboard some colleagues to an internal Hackathon and needed to pump out some Entra ID user invitations, groups and role assignments. So I thought to myself “Gee, this is an easy enough problem. Why not give the AzAPI provide a real shot”.

So I did.

The first thing you run into with the AzAPI provider is that you have essentially one resource. Yes, that’s right, one resource to rule them all. One resource to find them, one resource to bring them all, and in JSON define them.

Three resources for the developers under Azure’s sky, Seven for the architects in their towers of ivory, Nine for the Infrastructure guys, always standing by, One for the AzAPI on its JSON throne In the land of ARM templates where complexities lie. One resource to rule them all, one to unify, One resource to bring them all and with JSON define them, In the land of ARM templates where complexities lie.”

Here is that resource:

 resource "azapi_resource" "example" {
   type      = "Microsoft.ContainerRegistry/registries@2020-11-01-preview"
   name      = "registry1"
   parent_id = azurerm_resource_group.example.id
   location = azurerm_resource_group.example.location

   body = {
     sku = {
       name = "Standard"
     }
     properties = {
       adminUserEnabled = true
     }
   }

 }

You’ll notice the type that defines what schema is expected in the body. Previously we had to use a jsonencode function to convert from HCL into a JSON string but that has been (thankfully) replaced.

The problem is, how do you find out how to use a specific type? Unlike on the documentation for the AzureRM provider where there is a unique type (usuually) for every resource (sometimes two) with documentation that goes along with it.

Well I started out going to Google (or Bing) and typing this:

terraform azure role assignment azapi terraform

Now, Google (or Bing) this:

terraform azure role assignment bicep

While I’m also happy to say that the second results are far superior in almost every way (I still love you Derek). Besides the slew of masterful YouTube videos from my channel, Azure Terraformer, you see, right there, at the top of the search results? This is exactly the link we need. Microsoft.Authorization/roleAssignments — Bicep, ARM template & Terraform AzAPI reference | Microsoft Learn

That’s because of this big, beautiful tab control:

I can’t help but feel for Terraform in that ridiculous red and green Christmas sweater, totally ready to get down with the festive spirit but strangely out-of-place and feeling like the third wheel.

But here it is. In all it’s glory, the code we have been searching for. The code we had to trick the Google, I mean, Bing Gods into thinking we were good little boys and girls looking for some Bicep code in order to get access to.

resource "azapi_resource" "symbolicname" {
  type = "Microsoft.Authorization/roleAssignments@2022-04-01"
  name = "string"
  parent_id = "string"
  body = jsonencode({
    properties = {
      condition = "string"
      conditionVersion = "string"
      delegatedManagedIdentityResourceId = "string"
      description = "string"
      principalId = "string"
      principalType = "string"
      roleDefinitionId = "string"
    }
  })
}

But is this Terraform code?

Does it look like Terraform code to you?

Well, technically its not “code” its “schema”. Let’s fix it up and make it real…

Ok, where to begin? Let’s start with the required attributes, shall we?

Name. That should be easy, right?

string (required)
Character limit: 36
Valid characters:
Must be a globally unique identifier (GUID).
Resource name must be unique across tenant.

That in the world. Character limit 36? Valid characters: Must be a GUID? Hold on a second, I’ll be right back…

OK, yeah, I guess it’s a “ME” thing. So the name is just a GUID.

resource "azapi_resource" "symbolicname" {
  type = "Microsoft.Authorization/roleAssignments@2022-04-01"
  name = "string"
  parent_id = "string"
  body = jsonencode({
    properties = {
      condition = "string"
      conditionVersion = "string"
      delegatedManagedIdentityResourceId = "string"
      description = "string"
      principalId = "string"
      principalType = "string"
      roleDefinitionId = "string"
    }
  })
}

Name. Check.

Now, what about this Parent ID business?

The ID of the resource to apply this extension resource to.

I guess they mean the “Azure Resource ID”. I want to create a role assignment to a subscription so I guess I can do that super easily in Azure AzAPI right?

Well I guess not. But subscription Resource IDs are easy.

“/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092”

Here we go:

resource "azapi_resource" "symbolicname" {
  type      = "Microsoft.Authorization/roleAssignments@2022-04-01"
  name      = random_uuid.role_assignment_name.result
  parent_id = "/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092"
  body = jsonencode({
    properties = {
      condition                          = "string"
      conditionVersion                   = "string"
      delegatedManagedIdentityResourceId = "string"
      description                        = "string"
      principalId                        = "string"
      principalType                      = "string"
      roleDefinitionId                   = "string"
    }
  })
}

OK, now let’s figure out the properties block.

I think I can skip condition and conditionVersion. It also seems like I don’t need delegatedManagedIdentityResourceId — whatever the hell that is! Description, pfff, do you think I actually comment my code? Yeah right! So that leaves principalId and principalType and roleDefinitionId . Principal Type should be easy. Let’s start with that. I wanna do an Entra ID Group. Let’s check what the documentation has to say: The principal type of the assigned principal ID. AWESOME. Oh — ho! But I forget to horizontally scroll! Check this out:

  • “Device”
  • “ForeignGroup”
  • “Group”
  • “ServicePrincipal”
  • “User”

Checkmate.

resource "azapi_resource" "symbolicname" {
  type      = "Microsoft.Authorization/roleAssignments@2022-04-01"
  name      = random_uuid.role_assignment_name.result
  parent_id = "/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092"
  body = jsonencode({
    properties = {
      principalId                        = "string"
      principalType                      = "Group"
      roleDefinitionId                   = "string"
    }
  })
}

Let’s start with roleDefinitionId. Oh, where to begin? I know the Role Definition Name I want. It’s called “Contributor”. But what is the bloody Role Definition ID? Well, luckily there is a handy-dandy little Azure CLI command I can use to look that up.

az role definition list --query "[?roleName=='Contributor']" -o json

Which produces this result:

[
  {
    "assignableScopes": [
      "/"
    ],
    "description": "Grants full access to manage all resources, but does not allow you to assign roles in Azure RBAC, manage assignments in Azure Blueprints, or share image galleries.",
    "id": "/subscriptions/52942f45-54fd-4fd9-b730-03d518fedf35/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c",
    "name": "b24988ac-6180-42a0-ab88-20f7382dd24c",
    "permissions": [
      {
        "actions": [
          "*"
        ],
        "dataActions": [],
        "notActions": [
          "Microsoft.Authorization/*/Delete",
          "Microsoft.Authorization/*/Write",
          "Microsoft.Authorization/elevateAccess/Action",
          "Microsoft.Blueprint/blueprintAssignments/write",
          "Microsoft.Blueprint/blueprintAssignments/delete",
          "Microsoft.Compute/galleries/share/action",
          "Microsoft.Purview/consents/write",
          "Microsoft.Purview/consents/delete",
          "Microsoft.Resources/deploymentStacks/manageDenySetting/action"
        ],
        "notDataActions": []
      }
    ],
    "roleName": "Contributor",
    "roleType": "BuiltInRole",
    "type": "Microsoft.Authorization/roleDefinitions"
  }
]

Oh, boy is that juicy!!! Now I know the role Definition ID is “b24988ac-6180–42a0-ab88–20f7382dd24c”. So obvious! I should’ve known that! Wait but that’s actually just the Role Definition Name. This is the Role Definition ID:

/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c

Eureka!

resource "azapi_resource" "symbolicname" {
  type      = "Microsoft.Authorization/roleAssignments@2022-04-01"
  name      = random_uuid.role_assignment_name.result
  parent_id = "/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092"
  body = jsonencode({
    properties = {
      principalId                        = "string"
      principalType                      = "Group"
      roleDefinitionId                   = "/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
    }
  })
}

Now all I need to do is figure out that pesky Principal ID. Well that’s simple too! It’s just the Object ID from the Entra Group, which is obviously “3a6049b7–57df-4f33–9b81-bc03fabe9c63”.

resource "azapi_resource" "symbolicname" {
  type      = "Microsoft.Authorization/roleAssignments@2022-04-01"
  name      = random_uuid.role_assignment_name.result
  parent_id = "/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092"
  body = jsonencode({
    properties = {
      principalId                        = "3a6049b7–57df-4f33–9b81-bc03fabe9c63"
      principalType                      = "Group"
      roleDefinitionId                   = "/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
    }
  })
}

Wait, what happened.

│ Error: Invalid body
│
│ with azapi_resource.symbolicname,
│ on teams.tf line 20, in resource “azapi_resource” “symbolicname”:
│ 20: resource “azapi_resource” “symbolicname” {
│
│ The argument “body” is invalid: unmarshaling failed: value:
│ “{\”properties\”:{\”principalId\”:\”3a6049b7–57df-4f33–9b81-bc03fabe9c63\”,\”principalType\”:\”Group\”,\”roleDefinitionId\”:\”/subscriptions/2105a554-d70c-4db7–99c0–4db27e966092/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180–42a0-ab88–20f7382dd24c\”}}”,
│ err: json: cannot unmarshal string into Go value of type map[string]interface {}
╵
╷
│ Error: Invalid Type
│
│ with azapi_resource.symbolicname,
│ on teams.tf line 24, in resource “azapi_resource” “symbolicname”:
│ 24: body = jsonencode({
│ 25: properties = {
│ 26: principalId = “3a6049b7–57df-4f33–9b81-bc03fabe9c63”
│ 27: principalType = “Group”
│ 28: roleDefinitionId = “/subscriptions/2105a554-d70c-4db7–99c0–4db27e966092/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180–42a0-ab88–20f7382dd24c”
│ 29: }
│ 30: })
│
│ The value must not be a string

Ah. I guess I don’t actually need to use jsonencode.

resource "azapi_resource" "symbolicname" {
  type      = "Microsoft.Authorization/roleAssignments@2022-04-01"
  name      = random_uuid.role_assignment_name.result
  parent_id = "/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092"
  body = {
    properties = {
      principalId      = "3a6049b7-57df-4f33-9b81-bc03fabe9c63"
      principalType    = "Group"
      roleDefinitionId = "/subscriptions/2105a554-d70c-4db7-99c0-4db27e966092/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
    }
  }
}

Man. That was sooo easy. I don’t know why anybody would want to use azurerm. That old dinosaur!!! Just for kicks, let’s go back and revisit our friend in that silly Christmas sweater.

data "azurerm_subscription" "primary" {
  subscription_id = "2105a554-d70c-4db7-99c0-4db27e966092"
}

data "azuread_group" "cool_group" {
  display_name     = "My Cool Group"
}

resource "azurerm_role_assignment" "symbolicname" {
  scope                = data.azurerm_subscription.primary.id
  role_definition_name = "Contributor"
  principal_id         = data.azuread_group.cool_group.object_id
}

All neatly wrapped in a bow, eh?