Defines a tenant-scoped custom Microsoft Entra ID directory role from a hand-picked set of
microsoft.directory/*resource actions — deeply-typedobjectschema, every closed value set validated,try-total renderer, secure-by-default assignability. Built for azuread v3.x.
This module manages a single azuread_custom_directory_role — a custom Entra ID role definition:
- 🎯 Builds a custom role from one or more permission blocks, each contributing a set of
microsoft.directory/*resource actions. The role's effective privilege is the union of every block. - 🔀 Toggles assignability with
enabled— the role can exist as a definition before it is made assignable. - 🧾 Carries a caller-owned
role_versionstring for your own change-tracking of the definition. - 🪪 Optionally pins a stable
template_idso the same role keeps one identifier across directories (dev/prod tenants). - 📤 Emits
object_id— the universal key consumed by directory role assignments, PIM eligibility, and access package associations. - ✅ Built and validated against
hashicorp/azuread>= 2.0, < 4.0on Terraform>= 1.12.0— the module declares the provider requirement only, with noprovider "azuread" {}block.
💡 Why it matters: custom roles deliver least-privilege administration — grant exactly the directory actions a team needs (e.g. manage app credentials only) instead of a broad built-in role like Application Administrator. This module makes that definition typed, reviewable, and reproducible.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
This module is a leaf role-definition module — it takes no inputs from other terraform-azuread-* modules, building its permission set entirely from caller-supplied microsoft.directory/* actions. Per SCOPE.md, the role only becomes usable once its object_id is bound to a principal via an azuread_directory_role_assignment in root/composition code (that assignment resource is explicitly out of scope for this module).
flowchart LR
THIS["terraform-azuread-custom-directory-role<br/>(THIS MODULE)"]
ASSIGN["azuread_directory_role_assignment<br/>(root composition, out of scope)"]
PRINCIPAL["terraform-azuread-user / terraform-azuread-group /<br/>terraform-azuread-service-principal"]
THIS -->|"object_id => role_id"| ASSIGN
PRINCIPAL -->|"object_id => principal_object_id"| ASSIGN
style THIS fill:#8957E5,color:#fff
This module consumes no other module's outputs — its permission set is static configuration; it emits object_id, consumed by an azuread_directory_role_assignment alongside a principal's object_id (user, group, or service principal) — see the Emits table in SCOPE.md.
A single resource with a for_each-free but repeatable permissions block — every list entry is rendered as its own dynamic permissions block, whose privileges union together server-side.
flowchart TD
subgraph mod["terraform-azuread-custom-directory-role"]
THIS["azuread_custom_directory_role.this<br/>(keystone)<br/>display_name, enabled, template_id"]
PERM["dynamic permissions<br/>(for_each var.permissions)<br/>allowed_resource_actions per block"]
end
THIS --> PERM
style THIS fill:#8957E5,color:#fff
terraform-azuread-custom-directory-role/
├── providers.tf # Terraform & azuread provider version pins (no provider block)
├── variables.tf # Typed inputs: display_name, permissions, enabled, role_version, …
├── main.tf # Single resource: azuread_custom_directory_role.this
├── outputs.tf # object_id (primary), template_id, display_name, enabled, role_version
├── SCOPE.md # Cross-module contract + Graph API permissions
└── README.md # This file
module "app_credential_manager_role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "App Credential Manager"
enabled = true
permissions = [
{
allowed_resource_actions = [
"microsoft.directory/applications/credentials/update",
"microsoft.directory/applications/basic/update",
]
},
]
}The Terraform service principal must hold the following Microsoft Graph application permission before apply will succeed:
| Permission | Type | Required for |
|---|---|---|
RoleManagement.ReadWrite.Directory |
Application | Creating, updating, and deleting custom directory role definitions (unifiedRoleDefinition under /roleManagement/directory/roleDefinitions) |
Higher-privileged alternative (avoid unless already granted for other reasons): Directory.ReadWrite.All. The least-privileged read-only permission for the same surface is RoleManagement.Read.Directory.
⚠️ Admin consent + high privilege.RoleManagement.ReadWrite.Directoryrequires tenant admin consent and is a self-elevation-capable permission — "an application can grant additional privileges to itself, other applications, or any user." Grant it only to a tightly controlled Terraform SP and review its use. (Microsoft Graph permissions reference.)
⚠️ Licensing. Custom directory roles require Microsoft Entra ID P1 — one P1 license per user holding a custom-role assignment. P2 is a superset and also satisfies this. Creating the definition needs the permission above; assigning it to users is what consumes P1 licenses. (Entra RBAC overview.)
⚠️ Acting principal. When Terraform authenticates as a user rather than an SP, that user must hold the Privileged Role Administrator (or Global Administrator) directory role.
Derived from the module's outputs.tf — primary output is object_id (not id):
| This module output | Feeds into |
|---|---|
object_id |
azuread_directory_role_assignment.role_id (custom-role assignment), terraform-azuread-pim-group eligibility, access package resource associations |
template_id |
Cross-directory role identity matching (pin the same role in another tenant) |
display_name |
Logging, audit, portal cross-reference |
enabled |
Conditional wiring / guards in the caller |
role_version |
Caller-side change tracking of the definition |
object_idis the assignment key. A custom role is assigned by referencing itsobject_idasrole_idinazuread_directory_role_assignment— unlike built-in roles, which are referenced bytemplate_id. Wiremodule.<role>.object_id, nottemplate_id, into custom-role assignments.- The definition grants nothing on its own. Creating the role — even
enabled = true— confers no access until a separate assignment binds it to a principal.enabledcontrols whether the definition is assignable, not whether anyone holds it. This is whyenabledhas no default: assignability is a deliberate decision the caller must make. - Permissions are a union of sets. Each
permissionsblock contributes aset(string)of actions; the provider block is itself a set, so duplicate blocks and duplicate actions are de-duplicated by the API. There is no ordering significance. template_idis immutable (ForceNew). Set it only when you need a stable identifier across directories. Changing it later destroys and recreates the role — which breaks every existing assignment that referenced the oldobject_id.display_namemust be unique. A duplicate name silently creates a separate role object rather than updating the existing one. Keep names canonical and -prefixed.role_versionis opaque to Azure. Any 1–128 character string; it carries no semantic meaning to the API. Defaults to"1". Use it for your own definition change-tracking. The variable is namedrole_version, notversion, becauseversionis a reserved variable name in Terraform (it has special meaning inside module blocks);role_versionmaps to the provider'sversionargument.- Permission strings are validated by Azure, not Terraform. This module checks that at least one block and at least one action are supplied, but the correctness of each
microsoft.directory/...action is enforced by the Graph API at apply time — a typo surfaces as an apply error, not a plan error.
1 · Minimal — smallest call that creates a real role
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "Read-Only App Inspector"
enabled = true
permissions = [
{ allowed_resource_actions = ["microsoft.directory/applications/standard/read"] },
]
}2 · With description
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "App Credential Manager"
description = "Rotate and update credentials on app registrations. No create/delete."
enabled = true
permissions = [
{ allowed_resource_actions = ["microsoft.directory/applications/credentials/update"] },
]
}3 · Multiple permission blocks — effective privilege is the union
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "App & Group Editor"
enabled = true
permissions = [
{
allowed_resource_actions = [
"microsoft.directory/applications/basic/update",
"microsoft.directory/applications/standard/read",
]
},
{
allowed_resource_actions = [
"microsoft.directory/groups/basic/update",
"microsoft.directory/groups/members/update",
]
},
]
}4 · Disabled definition — exists but not yet assignable
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "Draft Role (not live)"
enabled = false # definition created; cannot be assigned until flipped to true
permissions = [
{ allowed_resource_actions = ["microsoft.directory/users/standard/read"] },
]
}5 · Explicit role_version — caller-side change tracking
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "App Credential Manager"
enabled = true
role_version = "2025.06" # any 1–128 char string; defaults to "1"
permissions = [
{ allowed_resource_actions = ["microsoft.directory/applications/credentials/update"] },
]
}6 · Stable template_id across directories (⚠️ immutable)
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "Platform Role"
enabled = true
template_id = "5b3c2f8e-7a1d-4e9b-8c6f-0a1b2c3d4e5f" # IMMUTABLE — same ID in dev & prod tenants
permissions = [
{ allowed_resource_actions = ["microsoft.directory/applications/standard/read"] },
]
}7 · Application-management role (single-tenant subtype)
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "Single-Tenant App Manager"
description = "Manage basic + credential properties of single-tenant app registrations only."
enabled = true
permissions = [
{
allowed_resource_actions = [
"microsoft.directory/applications.myOrganization/basic/update",
"microsoft.directory/applications.myOrganization/credentials/update",
]
},
]
}8 · Group-management role
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "Group Lifecycle Manager"
enabled = true
permissions = [
{
allowed_resource_actions = [
"microsoft.directory/groups/create",
"microsoft.directory/groups/delete",
"microsoft.directory/groups/basic/update",
"microsoft.directory/groups/members/update",
"microsoft.directory/groups/owners/update",
]
},
]
}9 · User-management role
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "User Contact Editor"
enabled = true
permissions = [
{
allowed_resource_actions = [
"microsoft.directory/users/standard/read",
"microsoft.directory/users/contactInfo/update",
]
},
]
}10 · Production-ready — full -compliant configuration
module "app_credential_manager_role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "App Credential Manager"
description = "Least-privilege role: rotate app credentials and read app config. No create/delete/owners."
enabled = true
role_version = "2025.06"
permissions = [
{
allowed_resource_actions = [
"microsoft.directory/applications/credentials/update",
"microsoft.directory/applications/basic/update",
"microsoft.directory/applications/standard/read",
]
},
]
timeouts = {
create = "10m"
delete = "10m"
}
}11 · Hardened — minimum-privilege single action, fully specified
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "App Secret Rotator (least privilege)"
description = "Rotate application credentials and nothing else."
enabled = true
role_version = "1"
permissions = [
{ allowed_resource_actions = ["microsoft.directory/applications/credentials/update"] },
]
}12 · With operation timeouts
module "role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "Slow-Tenant Role"
enabled = true
permissions = [
{ allowed_resource_actions = ["microsoft.directory/groups/basic/update"] },
]
timeouts = {
create = "15m"
read = "5m"
update = "10m"
delete = "10m"
}
}13 · Cross-module wiring — assign the role to a user (uses object_id)
module "app_credential_manager_role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "App Credential Manager"
enabled = true
permissions = [
{ allowed_resource_actions = ["microsoft.directory/applications/credentials/update"] },
]
}
data "azuread_user" "ops_lead" {
user_principal_name = "ops.lead@financialpartners.com"
}
# Custom roles are assigned by object_id (built-in roles use template_id).
resource "azuread_directory_role_assignment" "ops_lead" {
role_id = module.app_credential_manager_role.object_id
principal_object_id = data.azuread_user.ops_lead.object_id
}14 · Cross-module wiring — scoped assignment over a single application
module "app_editor_role" {
source = "git::https://github.com/microsoftexpert/terraform-azuread-custom-directory-role?ref=v1.0.0"
display_name = "Scoped App Editor"
enabled = true
permissions = [
{ allowed_resource_actions = ["microsoft.directory/applications/basic/update"] },
]
}
resource "azuread_application_registration" "target" {
display_name = "Target App"
}
data "azuread_user" "delegate" {
user_principal_name = "delegate@financialpartners.com"
}
resource "azuread_directory_role_assignment" "scoped" {
role_id = module.app_editor_role.object_id
principal_object_id = data.azuread_user.delegate.object_id
directory_scope_id = format("/%s", azuread_application_registration.target.object_id)
}Required
display_namestring— Unique display name shown in the Entra portal. A duplicate name creates a separate role object.permissionslist(object({ allowed_resource_actions = set(string) }))— One or more permission blocks; effective privilege is their union. Validated: ≥ 1 block, and ≥ 1 action per block.enabledbool— Whether the role is assignable. No default — a deliberate security decision.
Optional
descriptionstring— Portal description. Defaultnull.role_versionstring— Caller-owned version string, 1–128 chars (maps to providerversion). Default"1".template_idstring— Stable cross-directory identifier. # IMMUTABLE (ForceNew). Defaultnull(API generates one).timeoutsobject({ create, read, update, delete = optional(string) })— Operation timeouts. Default{}.
Full object schemas
variable "display_name" {
type = string
# validation: non-empty after trimspace
}
variable "permissions" {
type = list(object({
allowed_resource_actions = set(string)
}))
# validation: length >= 1
# validation: every block has length(allowed_resource_actions) >= 1
}
variable "description" {
type = string
default = null
}
variable "enabled" {
type = bool # required — no default
}
variable "role_version" {
type = string
default = "1"
# validation: 1 <= length <= 128
}
variable "template_id" {
type = string
default = null # IMMUTABLE — ForceNew
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = {}
}Primary
object_id(string) — Object ID of the custom role — the universal key for assignments. Always present; primary output.
Definition metadata
template_id(string) — Template identifier (caller-supplied or API-generated). Always present (computed when not supplied).display_name(string) — Display name of the role. Always present.enabled(bool) — Whether the role is enabled for assignment. Always present.role_version(string) — Version string of the role definition (providerversion). Always present.
ℹ️ No output is
sensitive— this resource produces no credential material (no passwords, secrets, certificates, or thumbprints). There are no write-only outputs.
- The type is the contract.
permissionsmirrors the provider's set-nested block exactly — a malformed nested key is a plan-time type error, not a runtime surprise. - Secure-by-default.
enabledhas no default, forcing an explicit assignability decision. Defaults that exist are the safe choice. main.tfis a total renderer.dynamicblocks forpermissionsandtimeouts;try(x, null)on every optional nested field — the renderer never errors on an absent key.object_idfirst. The primary output is the universal azuread key, matching every otherterraform-azuread-*module.- No hidden surface. No
provider {}block, noresource_group_name, notags(unsupported by the resource).
cd C:\GitHubCode\newazureadmodules\terraform-azuread-custom-directory-role
terraform init -backend=false
terraform validate
terraform fmt -check
⚠️ Noplan/applyoffline.azureadmodules require live tenant credentials (tenant_id, client_id, client_secret/cert) and the Graph permission above. The offline gate (init -backend=false+validate+fmt -check) confirms structural correctness; live testing belongs in a non-production tenant with a dedicated Terraform SP.
-
Insufficient privileges to complete the operation/ 403 on apply. The Terraform SP lacksRoleManagement.ReadWrite.Directory(admin-consented), or — when authenticating as a user — the user lacks the Privileged Role Administrator role. Fix: grant the application permission and admin-consent it, or assign the directory role to the acting user. A freshly granted permission can take a few minutes to take effect. -
Custom role creation rejected /
directoryRolefeature unavailable. The tenant has no Entra ID P1 entitlement. Custom roles require P1 (P2 is a superset). Fix: confirm tenant licensing; note that P1 is consumed per user holding a custom-role assignment, not for creating the definition. -
Assignment fails with the role's
template_id. Custom roles are assigned byobject_id, nottemplate_id— only built-in roles usetemplate_id. Fix: wiremodule.<role>.object_idintoazuread_directory_role_assignment.role_id. -
An invalid permission string only fails at apply, not plan. This module validates shape (≥ 1 block, ≥ 1 action) but not the correctness of each
microsoft.directory/...action — Azure validates those server-side. Fix: verify each action against the Microsoft Entra permissions reference; a typo returns an apply-time error naming the bad action. -
A second role appeared instead of an update.
display_namemust be unique; a duplicate name creates a separate object rather than updating the existing one. Fix: keep names canonical/-prefixed and change them through state, not by re-typing. -
Every change to
template_idwants to replace the role. Expected —template_idis immutable (ForceNew). Recreating the role mints a newobject_id, silently orphaning all existing assignments. Fix: never edittemplate_idon a live role; choose it once at creation. -
New role isn't usable immediately after
apply. Microsoft Graph is eventually consistent — a newly created role (and itsobject_id) can take a short while to replicate before assignments referencing it succeed. Fix: if a dependentazuread_directory_role_assignmentfails right after creation, re-apply; do not restructure the config. Terraform's dependency ordering handles this within a single apply in most cases.
- azuread_custom_directory_role — provider docs
- azuread_directory_role_assignment — assign the role
- Create a custom role in Microsoft Entra ID
- Custom role permissions reference (
microsoft.directory/*) - Microsoft Graph permissions reference
SCOPE.md— cross-module contract and Graph API permissions
💙 "Infrastructure as Code should be standardized, consistent, and secure."

