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 configlive/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 configlive/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