IaC Azure

Terraform Module: Azure RBAC Role Assignment — least-privilege access grants with deterministic, drift-free assignment IDs

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for azurerm_role_assignment: bind built-in or custom roles to any principal at a chosen scope, with condition support and stable name handling. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "rbac_role_assignment" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-rbac-role-assignment?ref=v1.0.0"

  scope        = "..."  # Scope ID (management group, subscription, resource grou…
  principal_id = "..."  # AAD object ID of the user, group, service principal, or…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure role-based access control (RBAC) grants access through a role assignment: a three-part binding of a security principal (user, group, service principal, or managed identity) to a role definition (a named set of Actions/DataActions) at a scope (management group, subscription, resource group, or individual resource). In Terraform, every one of those grants is a single azurerm_role_assignment resource — and in a real estate of dozens of subscriptions you end up writing the same five lines hundreds of times.

This module wraps azurerm_role_assignment so each grant becomes a small, validated module call instead of a hand-rolled resource. Wrapping it in a module pays off because the resource has several sharp edges worth standardising once:

When to use it

Reach for the raw resource instead only for a genuine one-off in throwaway code; anything that ships to production benefits from the validation here.

Module structure

terraform-module-azure-rbac-role-assignment/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_role_assignment, fully wired
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, principal_id, role, scope

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

# Resolve a built-in role definition by name when only the name is provided.
# This lets callers pass "Reader" while still binding by a stable role
# definition ID under the hood, and keeps the scope correct for the lookup.
data "azurerm_role_definition" "builtin" {
  count = var.role_definition_name != null && var.role_definition_id == null ? 1 : 0

  name  = var.role_definition_name
  scope = var.scope
}

locals {
  # Exactly one of the two role inputs is enforced by variable validation,
  # so this collapses cleanly to a single non-null id.
  role_definition_id = coalesce(
    var.role_definition_id,
    one(data.azurerm_role_definition.builtin[*].role_definition_id),
  )
}

resource "azurerm_role_assignment" "this" {
  scope              = var.scope
  principal_id       = var.principal_id
  role_definition_id = local.role_definition_id

  # Set explicitly to skip the AAD object-graph lookup. Required for
  # cross-tenant principals and for principals created in the same apply
  # (avoids replication-lag 404s).
  principal_type                   = var.principal_type
  skip_service_principal_aad_check = var.skip_service_principal_aad_check

  # Pin the assignment GUID for deterministic, replayable IDs. When null,
  # Azure generates one server-side.
  name = var.name

  # ABAC: grant only when the request matches the condition expression.
  condition         = try(var.condition.expression, null)
  condition_version = try(var.condition.version, null)

  # Delegated-managed-identity resource id, used when the assignment is
  # made on behalf of a managed identity in another tenant (Azure Lighthouse
  # / cross-tenant delegation scenarios).
  delegated_managed_identity_resource_id = var.delegated_managed_identity_resource_id

  description = var.description
}

Note: azurerm_role_assignment has no nested blocks of its own in azurerm 4.x — the condition/condition_version pair and principal_type are top-level arguments, which is exactly why a thin validating wrapper is worth more here than the HCL line count suggests.

variables.tf

variable "scope" {
  type        = string
  description = "Scope at which the role applies (management group, subscription, resource group, or resource ID)."

  validation {
    condition     = startswith(var.scope, "/")
    error_message = "scope must be a fully-qualified Azure resource ID beginning with '/'."
  }
}

variable "principal_id" {
  type        = string
  description = "Object (principal) ID of the user, group, service principal, or managed identity to grant access to."

  validation {
    condition     = can(regex("^[0-9a-fA-F-]{36}$", var.principal_id))
    error_message = "principal_id must be a GUID (the AAD object ID, not the app/client ID)."
  }
}

variable "role_definition_name" {
  type        = string
  default     = null
  description = "Built-in or custom role name (e.g. 'Reader'). Mutually exclusive with role_definition_id."
}

variable "role_definition_id" {
  type        = string
  default     = null
  description = "Fully-qualified role definition resource ID. Mutually exclusive with role_definition_name."

  validation {
    condition     = var.role_definition_id == null || can(regex("/providers/Microsoft.Authorization/roleDefinitions/", var.role_definition_id))
    error_message = "role_definition_id must be a full roleDefinitions resource ID."
  }
}

variable "principal_type" {
  type        = string
  default     = null
  description = "Principal type: 'User', 'Group', or 'ServicePrincipal'. Set explicitly for cross-tenant or freshly-created principals."

  validation {
    condition     = var.principal_type == null || contains(["User", "Group", "ServicePrincipal"], var.principal_type)
    error_message = "principal_type must be one of: User, Group, ServicePrincipal."
  }
}

variable "name" {
  type        = string
  default     = null
  description = "Optional GUID to pin the role assignment ID for deterministic, replayable assignments. If null, Azure generates one."

  validation {
    condition     = var.name == null || can(regex("^[0-9a-fA-F-]{36}$", var.name))
    error_message = "name must be a GUID when set."
  }
}

variable "condition" {
  type = object({
    expression = string
    version    = optional(string, "2.0")
  })
  default     = null
  description = "Optional ABAC condition. expression is the role-assignment condition; version defaults to '2.0'."
}

variable "skip_service_principal_aad_check" {
  type        = bool
  default     = false
  description = "Skip the AAD existence check for service principals. Useful when the SP is created in the same apply."
}

variable "delegated_managed_identity_resource_id" {
  type        = string
  default     = null
  description = "Resource ID of a delegated managed identity (cross-tenant / Azure Lighthouse delegation)."
}

variable "description" {
  type        = string
  default     = null
  description = "Human-readable description recorded on the assignment (e.g. ticket reference or justification)."
}

outputs.tf

output "id" {
  description = "Fully-qualified resource ID of the role assignment."
  value       = azurerm_role_assignment.this.id
}

output "name" {
  description = "Name (GUID) of the role assignment, whether pinned or Azure-generated."
  value       = azurerm_role_assignment.this.name
}

output "principal_id" {
  description = "Object ID of the principal that was granted access."
  value       = azurerm_role_assignment.this.principal_id
}

output "role_definition_id" {
  description = "Resolved role definition ID that was assigned."
  value       = azurerm_role_assignment.this.role_definition_id
}

output "scope" {
  description = "Scope at which the assignment was created."
  value       = azurerm_role_assignment.this.scope
}

How to use it

# A Function App with a system-assigned identity that needs to read secrets
# from a Key Vault — assigned in the same plan that creates both.
resource "azurerm_key_vault" "app" {
  name                = "kv-orders-prod"
  resource_group_name = azurerm_resource_group.app.name
  location            = azurerm_resource_group.app.location
  tenant_id           = data.azurerm_client_config.current.tenant_id
  sku_name            = "standard"
  rbac_authorization_enabled = true
}

module "rbac_role_assignment" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-rbac-role-assignment?ref=v1.0.0"

  scope                = azurerm_key_vault.app.id
  principal_id         = azurerm_linux_function_app.orders.identity[0].principal_id
  role_definition_name = "Key Vault Secrets User"

  # The identity is created in this same apply, so skip the existence check
  # and pin the type to avoid replication-lag 404s.
  principal_type                   = "ServicePrincipal"
  skip_service_principal_aad_check = true

  description = "JIRA-4821: orders fn reads app secrets"
}

# Downstream reference: surface the assignment ID for an audit/export module.
output "orders_fn_kv_grant_id" {
  value = module.rbac_role_assignment.id
}

For an ABAC example, pass a condition to scope a blob-data role down to a single tag:

module "rbac_role_assignment" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-rbac-role-assignment?ref=v1.0.0"

  scope                = azurerm_storage_account.data.id
  principal_id         = azuread_group.analysts.object_id
  principal_type       = "Group"
  role_definition_name = "Storage Blob Data Reader"

  condition = {
    expression = <<-EOT
      (
        (!(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read'}))
        OR
        (@Resource[Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags:Project<$key_case_sensitive$>] StringEquals 'Phoenix')
      )
    EOT
    version    = "2.0"
  }
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root config — live/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module config — live/prod/rbac_role_assignment/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-rbac-role-assignment?ref=v1.0.0"
}

inputs = {
  scope = "..."
  principal_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/rbac_role_assignment && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
scope string — Yes Scope ID (management group, subscription, resource group, or resource) the role applies to.
principal_id string — Yes AAD object ID of the user, group, service principal, or managed identity.
role_definition_name string null Conditional Built-in/custom role name. Provide this or role_definition_id.
role_definition_id string null Conditional Full role definition resource ID. Provide this or role_definition_name.
principal_type string null No User, Group, or ServicePrincipal. Set for cross-tenant or just-created principals.
name string null No GUID to pin the assignment ID for deterministic, replayable assignments.
condition object({ expression = string, version = optional(string, "2.0") }) null No Optional ABAC condition and version.
skip_service_principal_aad_check bool false No Skip the SP existence check (SP created in the same apply).
delegated_managed_identity_resource_id string null No Delegated managed identity ID for cross-tenant / Lighthouse delegation.
description string null No Justification or ticket reference recorded on the assignment.

Outputs

Name Description
id Fully-qualified resource ID of the role assignment.
name Name (GUID) of the role assignment, pinned or Azure-generated.
principal_id Object ID of the granted principal.
role_definition_id Resolved role definition ID that was assigned.
scope Scope at which the assignment was created.

Enterprise scenario

A platform team runs a landing-zone repo that onboards each new product subscription. As part of onboarding they call this module in a for_each over a map of { group_object_id => role }, granting the product’s “App Team” AAD group Contributor on its subscription and Reader on the shared connectivity hub, every grant carrying a description with the onboarding ticket. Because the assignment name GUIDs are derived deterministically (e.g. uuidv5 of subscription + group + role), a full DR rebuild in a paired region reproduces byte-identical assignment IDs, so audit tooling and break-glass runbooks keep working unchanged.

Best practices

TerraformAzureRBAC Role AssignmentModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading