In Infrastructure-as-Code, especially within the Azure ecosystem, there’s often a tension between expressive precision and human readability. Azure’s native tools like Bicep and even AzAPI offer tightly coupled abstractions over Azure Resource Manager (ARM), enabling users to define resource configurations in meticulous detail. However, this precision often comes at a hidden cost — particularly when assigning roles.

A great example of this is role assignments based on built-in role definitions. At first glance, it seems straightforward: every role in Azure has a unique GUID. If you know the GUID, you can assign that role. But here’s where the friction begins.

Assigning Roles in Bicep: The GUID Approach

You need to lookup a Role Definition by its ID. That is a GUID. Consider the following Bicep snippet:

resource role 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(subscription().id, resourceGroup().id, principalId, roleDefinitionId)
  properties: {
    principalId: principalId
    principalType: 'ServicePrincipal'
    roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')
  }
}

The roleDefinitionId here refers to a well-known Azure role: Cognitive Services OpenAI User. This role is built-in and gives read access and inference capabilities for Azure OpenAI resources. You can confirm it with:

az role definition list --name 5e0bd9bd-7b93-4f28-af87-19fc36ad61bd

This returns a verbose JSON payload defining the role’s properties — its permissions, assignable scopes, and metadata. The role is clearly marked as a BuiltInRole. And therein lies the irony: this is a role most users understand by name, not by GUID. Yet in Bicep, we must interact with it as if the GUID is what matters most.

[
  {
    "assignableScopes": [
      "/"
    ],
    "createdBy": null,
    "createdOn": "2022-10-26T20:23:25.994383+00:00",
    "description": "Ability to view files, models, deployments. Readers can't make any changes They can inference and create images",
    "id": "/subscriptions/fdd15bfe-0311-4f77-8ddf-346fbdc1ebff/providers/Microsoft.Authorization/roleDefinitions/5e0bd9bd-7b93-4f28-af87-19fc36ad61bd",
    "name": "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd",
    "permissions": [
      {
        "actions": [
          "Microsoft.CognitiveServices/*/read",
          "Microsoft.Authorization/roleAssignments/read",
          "Microsoft.Authorization/roleDefinitions/read"
        ],
        "condition": null,
        "conditionVersion": null,
        "dataActions": [
          "Microsoft.CognitiveServices/accounts/OpenAI/*/read",
          "Microsoft.CognitiveServices/accounts/OpenAI/engines/completions/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/engines/search/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/engines/generate/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/deployments/audio/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/deployments/search/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/deployments/completions/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/deployments/chat/completions/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/deployments/realtime/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/deployments/extensions/chat/completions/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/deployments/embeddings/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/images/generations/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/video/generations/*/action",
          "Microsoft.CognitiveServices/accounts/OpenAI/video/generations/*/delete",
          "Microsoft.CognitiveServices/accounts/OpenAI/assistants/*",
          "Microsoft.CognitiveServices/accounts/OpenAI/responses/*"
        ],
        "notActions": [],
        "notDataActions": [
          "Microsoft.CognitiveServices/accounts/OpenAI/stored-completions/read"
        ]
      }
    ],
    "roleName": "Cognitive Services OpenAI User",
    "roleType": "BuiltInRole",
    "type": "Microsoft.Authorization/roleDefinitions",
    "updatedBy": null,
    "updatedOn": "2025-04-25T12:02:02.823851+00:00"
  }
]

Notice some key attributes such as roleType which is of type BuiltInRole. A very common pattern on Azure is to use the plethora of built-in role definitions. You can of course create your own Custom Role Definitions but this has some additional effort to go down this path. Sometimes it makes sense but for most use cases the built-in role definitions are sufficient as long as they are applied to the appropriate Azure scope (i.e., Subscription, Resource Group, Resource) for a given identity.

Terraform: A More Human-Centric Approach

For developers familiar with Bicep, the first instinct when using Terraform is often to replicate the exact ARM logic. This leads to patterns like:

data "azurerm_role_definition" "builtin" {
  role_definition_id = "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd"
}

Then reference the data source in the Role Assignment:

# Step 1: Get Reference to Role Definition from Guid
data "azurerm_role_definition" "builtin" {
  role_definition_id = "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd"
}

# Step 2: Generate a random UUID
resource "random_uuid" "random_name" {
}

# Step 3: Assign it to the Role Assignment
resource "azurerm_role_assignment" "openai_user" {
  name               = "role-${local.principalId}-${random_uuid.random_name.result}"
  scope              = local.resourceId
  role_definition_id = data.azurerm_role_definition.builtin.role_definition_resource_id
  principal_id       = local.principalId
}

Technically correct? Yes. Readable? Not really.

This approach borrows the complexity of ARM — down to randomly generated role assignment names and role definition lookups by GUID — and injects it into Terraform, a tool that’s otherwise known for higher-level abstractions.

I’ve seen people new to Terraform implement it in this way. They probably get either a negative impression about Terraform or don’t see what the point of it is when compared to Bicep. That’s because they are thinking about Terraform with Bicep-eyes. This is a mistake.

Fortunately, Terraform offers a better way.

In Terraform there is an easier way. Because Terraform’s azurerm provider does not just speak in pure ARM syntax it can smooth out the contours of ARM by creating a more simplified interface for common use cases.

# Step 1: Assign it to the Role Assignment
resource "azurerm_role_assignment" "openai_user" {
  scope                = local.resourceId
  role_definition_name = "Cognitive Services OpenAI User"
  principal_id         = local.principalId
}

The azurerm_role_assignment resource eliminates the following points of unnecessary complexity:

  1. Automatically generates a random name for the role assignment.
  2. Automatically looks up the Role Definition using the roleName.

Why does the azurerm Terraform provider do this? Well. If we think about it, who really cares what the Role Assignment’s name is? You can’t even see it in the Azure Portal.

Why GUIDs Feel Foreign

Let’s be honest: no one thinks in GUIDs. Do we really think about Azure Role Definitions using their GUID? Have you ever caught yourself asking yourself the following question:

“Hmmm, does Joe have ‘5e0bd9bd-7b93–4f28-af87–19fc36ad61bd’ access to the Resource Group?”

No way. Maybe if you are a lizard person.

The way operators interact with it is really focused on three pieces of information:

  1. Who we want to grant permissions to (i.e., the Entra ID identity)
  2. What permissions are granted (i.e., the Role Definition)
  3. Where the permissions are granted (i.e., the Azure Scope)

Why do we need anything else? This is the mental model operators use daily. Forcing developers to translate role names into GUIDs adds friction with no practical benefit.

Embracing Simplicity Without Losing Power

Why does the Terraform provider take this path? Because it’s designed with human operators in mind. It assumes that, most of the time, you care more about intent than implementation details. You know the role you want to assign, and you know who should get it and where — so that’s all Terraform asks you to specify.

This isn’t to say that Bicep is doing it wrong. Bicep (and even in Terraform with AzAPI) is a close-to-the-metal language, and that can be a virtue. But in doing so, it exposes low-level mechanics that are often unnecessary in everyday use.

Conclusion

The “hidden tax” of AzAPI and Bicep isn’t about functionality — both are fully capable of expressing complex Azure configurations. The tax is cognitive. It’s about how much you need to know and how much you need to care about things like GUIDs, ARM resource IDs, and name-generation conventions.

When a tool like Terraform offers a higher-level interface that abstracts these details, it’s not dumbing things down — it’s acknowledging how real people use the cloud. And in the case of role assignments, it turns out that what we want is incredibly simple:

  • Who: the identity
  • What: the role
  • Where: the scope

That’s it. No GUIDs. No ceremony. Just clarity.