Skip to content

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

🛡️ Azure AD Custom Directory Role Terraform Module

Defines a tenant-scoped custom Microsoft Entra ID directory role from a hand-picked set of microsoft.directory/* resource actions — deeply-typed object schema, every closed value set validated, try-total renderer, secure-by-default assignability. Built for azuread v3.x.

Terraform azuread module type resources


🧩 Overview

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_version string for your own change-tracking of the definition.
  • 🪪 Optionally pins a stable template_id so 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.0 on Terraform >= 1.12.0 — the module declares the provider requirement only, with no provider "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.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


🗺️ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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
Loading

📁 Module Structure

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

⚙️ Quick Start

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",
      ]
    },
  ]
}

🔑 Graph API Permissions Required

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.Directory requires 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.


🔌 Typical wiring

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

🧠 Architecture Notes

  • object_id is the assignment key. A custom role is assigned by referencing its object_id as role_id in azuread_directory_role_assignment — unlike built-in roles, which are referenced by template_id. Wire module.<role>.object_id, not template_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. enabled controls whether the definition is assignable, not whether anyone holds it. This is why enabled has no default: assignability is a deliberate decision the caller must make.
  • Permissions are a union of sets. Each permissions block contributes a set(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_id is 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 old object_id.
  • display_name must be unique. A duplicate name silently creates a separate role object rather than updating the existing one. Keep names canonical and -prefixed.
  • role_version is 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 named role_version, not version, because version is a reserved variable name in Terraform (it has special meaning inside module blocks); role_version maps to the provider's version argument.
  • 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.

📚 Example Library (copy-paste)

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)
}

📦 Inputs (high-level)

Required

  • display_name string — Unique display name shown in the Entra portal. A duplicate name creates a separate role object.
  • permissions list(object({ allowed_resource_actions = set(string) })) — One or more permission blocks; effective privilege is their union. Validated: ≥ 1 block, and ≥ 1 action per block.
  • enabled bool — Whether the role is assignable. No default — a deliberate security decision.

Optional

  • description string — Portal description. Default null.
  • role_version string — Caller-owned version string, 1–128 chars (maps to provider version). Default "1".
  • template_id string — Stable cross-directory identifier. # IMMUTABLE (ForceNew). Default null (API generates one).
  • timeouts object({ 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 = {}
}

🧾 Outputs

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 (provider version). Always present.

ℹ️ No output is sensitive — this resource produces no credential material (no passwords, secrets, certificates, or thumbprints). There are no write-only outputs.


🧱 Design Principles

  • The type is the contract. permissions mirrors the provider's set-nested block exactly — a malformed nested key is a plan-time type error, not a runtime surprise.
  • Secure-by-default. enabled has no default, forcing an explicit assignability decision. Defaults that exist are the safe choice.
  • main.tf is a total renderer. dynamic blocks for permissions and timeouts; try(x, null) on every optional nested field — the renderer never errors on an absent key.
  • object_id first. The primary output is the universal azuread key, matching every other terraform-azuread-* module.
  • No hidden surface. No provider {} block, no resource_group_name, no tags (unsupported by the resource).

🚀 Runbook

cd C:\GitHubCode\newazureadmodules\terraform-azuread-custom-directory-role
terraform init -backend=false
terraform validate
terraform fmt -check

⚠️ No plan / apply offline. azuread modules 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.


🔍 Troubleshooting

  • Insufficient privileges to complete the operation / 403 on apply. The Terraform SP lacks RoleManagement.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 / directoryRole feature 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 by object_id, not template_id — only built-in roles use template_id. Fix: wire module.<role>.object_id into azuread_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_name must 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_id wants to replace the role. Expected — template_id is immutable (ForceNew). Recreating the role mints a new object_id, silently orphaning all existing assignments. Fix: never edit template_id on 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 its object_id) can take a short while to replicate before assignments referencing it succeed. Fix: if a dependent azuread_directory_role_assignment fails right after creation, re-apply; do not restructure the config. Terraform's dependency ordering handles this within a single apply in most cases.


🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."

Releases

Packages

Contributors

Languages