Managing Azure RBAC with Terraform

Managing Azure RBAC with Terraform gives you version-controlled, reviewable, repeatable access management. Instead of role assignments applied through the portal or ad-hoc CLI commands, your access policies live in code, are reviewed through pull requests, and can be audited through Git history. This page covers the Terraform resources and patterns you need to manage RBAC effectively.

Why manage RBAC in Terraform

The alternative to managing RBAC in Terraform is managing it imperatively — through the portal, the CLI, or PowerShell scripts. Imperative management has real downsides at scale:

  • There is no single source of truth. Access is scattered across multiple subscriptions with no central view of intent.
  • Changes are invisible. When someone modifies a role assignment in the portal, there is no pull request, no diff, and no record of why the change was made.
  • Drift is invisible. Assignments created manually coexist with Terraform-managed assignments with no way to tell them apart at a glance.
  • Rollback is hard. To undo an incorrectly created assignment, you have to find and delete it manually.

With Terraform, every role assignment is a line of code. Adding access requires a PR. Removing access requires another PR. Your Git history is your audit log. If something goes wrong, terraform plan shows you exactly what will change before it changes.

The azurerm_role_assignment resource

The core Terraform resource for RBAC is azurerm_role_assignment. Here is its basic structure:

resource "azurerm_role_assignment" "example" {
  scope                = azurerm_resource_group.example.id
  role_definition_name = "Reader"
  principal_id         = "USER_OR_GROUP_OBJECT_ID"
}

The three required arguments:

  • scope — the ARM resource ID of the scope where the role applies. This can be a management group ID, subscription ID, resource group ID, or individual resource ID.
  • role_definition_name — the human-readable name of the role, such as “Contributor” or “Storage Blob Data Reader”. Terraform looks up the corresponding role definition ID internally. Alternatively, use role_definition_id to specify the full resource ID of the role definition directly.
  • principal_id — the object ID of the user, group, service principal, or managed identity receiving the role.

Optional arguments include description (a human-readable note about why the assignment was created — highly recommended), condition and condition_version for RBAC conditions, and skip_service_principal_aad_check (set to true for managed identities to avoid a lookup that can fail in some scenarios).

Complete working example: managed identity with Storage Blob Data Reader

This example creates a resource group, a user-assigned managed identity, a storage account, and assigns the managed identity the Storage Blob Data Reader role on the storage account. It demonstrates the full pattern for giving an Azure workload access to storage.

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.90"
    }
  }
}

provider "azurerm" {
  features {}
}

# Resource group for the workload
resource "azurerm_resource_group" "app" {
  name     = "production-app"
  location = "eastus"
}

# User-assigned managed identity for the application
resource "azurerm_user_assigned_identity" "app_identity" {
  name                = "myapp-identity"
  resource_group_name = azurerm_resource_group.app.name
  location            = azurerm_resource_group.app.location
}

# Storage account that the application needs to read from
resource "azurerm_storage_account" "data" {
  name                     = "myappdatastorage"
  resource_group_name      = azurerm_resource_group.app.name
  location                 = azurerm_resource_group.app.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

# Look up the built-in role definition by name
# This is better than hard-coding the GUID
data "azurerm_builtin_role_definition" "storage_blob_data_reader" {
  name = "Storage Blob Data Reader"
}

# Assign the managed identity the Storage Blob Data Reader role
# scoped to the specific storage account — not the whole resource group
resource "azurerm_role_assignment" "app_storage_reader" {
  scope              = azurerm_storage_account.data.id
  role_definition_id = data.azurerm_builtin_role_definition.storage_blob_data_reader.id
  principal_id       = azurerm_user_assigned_identity.app_identity.principal_id

  description = "Allow myapp to read blobs from the data storage account"
}

Why each decision was made

Using a data source for the role definition ID

The data “azurerm_builtin_role_definition” block looks up the role by name and returns its full resource ID. Compare these two approaches:

# Opaque — what role is this?
role_definition_id = "/subscriptions/.../roleDefinitions/2a2b9908-6ea1-4ae2-8e65-a410df84e7d1"

# Clear — immediately readable
role_definition_id = data.azurerm_builtin_role_definition.storage_blob_data_reader.id

The data source approach is self-documenting and does not require anyone to look up a GUID to understand the code. It is also slightly more resilient — if Microsoft ever changes how role IDs are structured (unlikely but possible), the data source lookup would continue to work.

You can also use role_definition_name = “Storage Blob Data Reader” directly on the assignment resource, which is even shorter. The data source approach is useful when you need the ID for multiple resources or when constructing more complex expressions.

Scoping to the storage account, not the resource group

The scope is azurerm_storage_account.data.id — the specific storage account — rather than azurerm_resource_group.app.id. If other storage accounts are added to this resource group later, the managed identity will not automatically get read access to them. This is intentional: following the principle of least privilege, the identity should only access what it currently needs.

Using principal_id from the identity resource

azurerm_user_assigned_identity.app_identity.principal_id gives you the managed identity’s object ID in Microsoft Entra ID — the same value you would use with —assignee-object-id in the CLI. Terraform resolves this automatically through the resource graph, so the role assignment is created after the identity exists.

Adding a description

The description field is optional but worth adding. It appears in the Azure portal’s Access Control (IAM) view and provides context for anyone auditing access later. A description like “Allow myapp to read blobs from the data storage account” is much more useful than no description at all.

Assigning a role to a Microsoft Entra ID group

Assigning roles to groups in Terraform lets you manage group membership separately from role assignments. When a new team member joins, you add them to the group — no Terraform changes needed.

# Look up an existing Entra ID group by name
data "azuread_group" "devops_team" {
  display_name     = "DevOps Team"
  security_enabled = true
}

# Assign Contributor to the group at resource group scope
resource "azurerm_role_assignment" "devops_contributor" {
  scope                = azurerm_resource_group.app.id
  role_definition_name = "Contributor"
  principal_id         = data.azuread_group.devops_team.object_id

  description = "Allow DevOps Team to manage resources in the production-app resource group"
}

Note that data “azuread_group” requires the azuread provider (from hashicorp/azuread), which is separate from the azurerm provider. Add it to your required_providers block:

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.90"
    }
    azuread = {
      source  = "hashicorp/azuread"
      version = "~> 2.47"
    }
  }
}

Creating custom role definitions in Terraform

When you need a custom role (covered in built-in vs custom roles), Terraform’s azurerm_role_definition resource manages the definition. Here is the VM Operator role from that page defined in Terraform:

resource "azurerm_role_definition" "vm_operator" {
  name        = "VM Operator"
  scope       = data.azurerm_subscription.current.id
  description = "Can start, stop, restart, and deallocate virtual machines. Cannot create or delete VMs."

  permissions {
    actions = [
      "Microsoft.Compute/virtualMachines/start/action",
      "Microsoft.Compute/virtualMachines/powerOff/action",
      "Microsoft.Compute/virtualMachines/restart/action",
      "Microsoft.Compute/virtualMachines/deallocate/action",
      "Microsoft.Compute/virtualMachines/read",
      "Microsoft.Compute/virtualMachines/instanceView/read",
      "Microsoft.Compute/virtualMachineScaleSets/read",
      "Microsoft.Compute/virtualMachineScaleSets/virtualMachines/read",
      "Microsoft.Resources/subscriptions/resourceGroups/read",
    ]
    not_actions = []
  }

  assignable_scopes = [
    data.azurerm_subscription.current.id,
  ]
}

data "azurerm_subscription" "current" {}

# Assign the custom role to the ops team
resource "azurerm_role_assignment" "ops_vm_operator" {
  scope              = azurerm_resource_group.app.id
  role_definition_id = azurerm_role_definition.vm_operator.role_definition_resource_id
  principal_id       = data.azuread_group.ops_team.object_id

  description = "Allow ops team to start, stop, and restart VMs in production-app"
}

When referencing a custom role in a role assignment, use role_definition_resource_id (not id) from the azurerm_role_definition resource. The role_definition_resource_id is the full ARM resource ID that the assignment resource expects.

Common patterns and how to handle them

Avoid creating duplicate assignments

Terraform will error if you try to create a role assignment that already exists (same principal, role, and scope). Before importing an environment into Terraform management, audit existing assignments with the CLI and either import them into state or accept that Terraform will show them as existing resources to avoid.

Import an existing role assignment into Terraform state:

terraform import azurerm_role_assignment.existing \
  "/subscriptions/SUBID/resourceGroups/RG/providers/Microsoft.Authorization/roleAssignments/ASSIGNMENT_GUID"

Handling the skip_service_principal_aad_check argument

For managed identities, you may occasionally see an error where Terraform cannot verify the principal exists in Entra ID even though it does. This is a propagation timing issue. Setting skip_service_principal_aad_check = true on the role assignment resource bypasses this check:

resource "azurerm_role_assignment" "app_storage_reader" {
  scope                            = azurerm_storage_account.data.id
  role_definition_name             = "Storage Blob Data Reader"
  principal_id                     = azurerm_user_assigned_identity.app_identity.principal_id
  skip_service_principal_aad_check = true
}

Use this flag for managed identities and service principals, but not for user or group assignments.

Module pattern for reusable access grants

If you frequently assign the same role to different identities at different scopes, a simple Terraform module can reduce repetition:

# modules/storage-reader-access/main.tf
variable "storage_account_id" {}
variable "principal_id" {}
variable "description" { default = "" }

resource "azurerm_role_assignment" "reader" {
  scope                            = var.storage_account_id
  role_definition_name             = "Storage Blob Data Reader"
  principal_id                     = var.principal_id
  skip_service_principal_aad_check = true
  description                      = var.description
}

Then call it wherever you need this pattern, with different storage accounts and principals each time.

Common Terraform RBAC mistakes

  1. Using role_definition_name when the name has changed. While rare, Microsoft occasionally renames built-in roles. If your Terraform code uses a hard-coded role name string in role_definition_name and the name changes, your next plan will fail. Using the data source approach is slightly more robust, and at minimum you should test that role_definition_name values resolve correctly after Azure provider upgrades.
  2. Not using depends_on for role assignments on freshly created identities. Terraform usually resolves this through the resource graph, but in complex modules where the identity is created in a different module, an explicit depends_on may be needed to ensure the identity exists before the assignment is created.
  3. Creating role assignments in the same apply as the resources they protect. When creating a managed identity and assigning it a role in the same Terraform apply, the assignment may fail with a “principal not found” error due to Entra ID propagation latency. The skip_service_principal_aad_check = true argument avoids this.
  4. Not describing role assignments. Every azurerm_role_assignment resource should have a description explaining why the assignment exists. Code comments help too, but the description is visible in the portal and persists in Azure’s records.

Frequently asked questions

Should I hard-code role definition GUIDs in Terraform or use data sources?

Use data sources. The data "azurerm_builtin_role_definition" or data "azurerm_role_definition" resources look up the role by name and return its ID. This makes your code readable and self-documenting. Hard-coded GUIDs are opaque — a reviewer cannot tell what role is being assigned without looking up the GUID separately.

What happens to role assignments in Terraform state when a role assignment is deleted outside of Terraform?

Terraform will detect the drift on the next plan and show the role assignment as needing to be re-created. If you deleted the assignment intentionally and want Terraform to stop managing it, run terraform state rm azurerm_role_assignment.RESOURCE_NAME to remove it from state without re-creating it.

Can Terraform create custom role definitions as well as assignments?

Yes. The azurerm_role_definition resource creates custom role definitions. You define the name, description, permissions (actions, not_actions, data_actions), and assignable_scopes in the resource block. After creation, you reference the role with azurerm_role_definition.RESOURCE_NAME.role_definition_resource_id in an azurerm_role_assignment.

Last verified: 19 March 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.