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:
- Built-in vs. custom roles. You can pass a friendly
role_definition_name(e.g.Reader) or a fullrole_definition_id, but never both. The module exposes both and validates that exactly one is set, so a typo fails at plan time rather than apply. - ABAC conditions. azurerm 4.x supports attribute-based conditions (
condition+condition_version) — the mechanism behind “Storage Blob Data Reader, but only for blobs taggedProject=Phoenix”. These are easy to get wrong by hand; the module gates them behind a single optional object. - Cross-tenant principals. Assigning to a principal from another tenant needs
principal_typeset explicitly so Azure does not try (and fail) to resolve the object graph. The module surfaces it as a first-class input. - Deterministic IDs. Role-assignment GUIDs are normally server-generated, which makes them noisy in state. The module lets you pin a stable
name(a GUID) so re-runs and disaster-recovery rebuilds reproduce the same assignment ID.
When to use it
- You manage access for many subscriptions or resource groups from a landing-zone or platform repo and want every grant to read identically.
- You assign roles to managed identities or service principals created elsewhere in the same plan (e.g. an AKS kubelet identity, a Function App’s system-assigned identity).
- You need conditional (ABAC) data-plane grants on Storage or other services and want the condition wiring validated and version-pinned.
- You want idempotent, replayable assignments — pinned GUIDs so a
terraform destroy/re-apply or a region failover recreates the exact same assignment object.
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_assignmenthas no nested blocks of its own in azurerm 4.x — thecondition/condition_versionpair andprincipal_typeare 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
- Grant least privilege at the tightest scope. Prefer a resource-group or resource scope over a subscription, and a subscription over a management group. Assignments inherit downward, so a broad scope silently widens blast radius.
- Assign to groups, not individuals. Bind roles to AAD security groups (
principal_type = "Group") and manage membership separately — it keeps assignments stable and lets joiner/leaver changes happen without a Terraform run. - Always set
principal_typefor fresh or cross-tenant principals, and addskip_service_principal_aad_check = truewhen the SP/identity is created in the same apply. This is the single biggest cause of intermittentPrincipalNotFound/ 404 failures on first apply. - Pin
namefor anything that must be replayable. Deterministic GUIDs (e.g. viauuidv5) make assignments reproducible across destroy/re-apply and region failover, and keep state diffs clean instead of churning server-generated IDs. - Use ABAC conditions instead of bespoke data roles. A
Storage Blob Data Readerplus a tag condition is cheaper to govern and audit than minting a custom role per data boundary — and it version-pins cleanly withcondition_version = "2.0". - Record justification in
description. A ticket reference on every grant turns an Azure access review into a five-minute exercise and gives security a paper trail without leaving Terraform.