Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Management Group Terraform Module

Manage an Azure management group and its subscription memberships as one keyed unit — the governance anchor other modules assign policy and RBAC against, on hashicorp/azurerm ~> 4.0.

Terraform azurerm module type resources

🧩 Overview

  • 🏛️ Creates one azurerm_management_group — a governance container in the tenant hierarchy.
  • 🔗 Assigns subscriptions to it via azurerm_management_group_subscription_association from a keyed map (for_each).
  • 🌳 Optionally parents the group under another management group, or leaves it directly under the tenant root.
  • 🎯 Emits the group id as the scope that policy, initiative, and RBAC modules target.

💡 Why it matters: management groups are where tenant-wide governance is expressed. Policy and RBAC applied at a management group flow down to every subscription and resource beneath it, so getting the hierarchy and its memberships right is what makes landing-zone governance consistent instead of per-subscription guesswork.

❤️ 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

flowchart TD
  TENANT["Tenant root management group"]
  MG["terraform-azurerm-management-group"]
  AMG["azurerm_management_group"]
  ASSOC["azurerm_management_group_subscription_association (for_each)"]
  SUB["Azure Subscription"]
  POLICY["terraform-azurerm-management-group-policy-assignment"]
  ROLES["terraform-azurerm-role-assignments"]
  MGSET["terraform-azurerm-management-group-policy-set-definition"]
  MGEXEMPT["terraform-azurerm-management-group-policy-exemption"]
  MGREM["terraform-azurerm-management-group-policy-remediation"]
  MGDEPLOY["terraform-azurerm-management-group-template-deployment"]

  TENANT -->|"parent_management_group_id"| MG
  MG --> AMG
  AMG --> ASSOC
  SUB -->|"subscription_id"| ASSOC
  AMG -->|"id (scope)"| POLICY
  AMG -->|"id (scope)"| ROLES
  AMG -->|"management_group_id"| MGSET
  AMG -->|"management_group_id"| MGEXEMPT
  AMG -->|"management_group_id"| MGREM
  AMG -->|"management_group_id"| MGDEPLOY

  classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
  classDef key fill:#004578,color:#ffffff,stroke:#004578;
  class MG this;
  class AMG key;
Loading

🧬 What this module builds

flowchart LR
  I1["name (immutable MG ID)"]
  I2["display_name"]
  I3["parent_management_group_id (optional)"]
  I4["subscription_associations (map)"]

  MG["azurerm_management_group.this"]
  ASSOC["azurerm_management_group_subscription_association.this<br/>for_each = subscription_associations"]

  O1["id"]
  O2["name"]
  O3["display_name"]
  O4["tenant_scoped_id"]
  O5["subscription_association_ids"]

  I1 --> MG
  I2 --> MG
  I3 --> MG
  I4 --> ASSOC
  MG --> ASSOC
  MG --> O1
  MG --> O2
  MG --> O3
  MG --> O4
  ASSOC --> O5

  classDef this fill:#0078D4,color:#ffffff,stroke:#004578,stroke-width:2px;
  classDef key fill:#004578,color:#ffffff,stroke:#004578;
  class MG key;
  class ASSOC this;
Loading

Resource inventory

Resource Cardinality Role
azurerm_management_group.this 1 (keystone) The management group.
azurerm_management_group_subscription_association.this 0..N (for_each) Subscription memberships, keyed by a stable identifier.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription.

Schema notes that bite

  • name is the group's identity — it becomes the .../managementGroups/<name> segment of the Resource ID and is immutable; changing it forces a new group. Prefer an explicit, stable value over letting the platform generate a random UUID.
  • display_name is updatable in place and is not part of the identity.
  • parent_management_group_id is updatable — changing it moves the group to a new parent rather than replacing it. Leave it null to sit directly under the tenant root management group.
  • On each membership record, both management_group_id and subscription_id are immutable; changing either recreates that membership.
  • Setting subscription_ids on the group and managing memberships through association records against the same group is unsupported and produces unpredictable results. This module owns memberships only through the association records and never sets the keystone's subscription list.
  • azurerm_management_group does not support tags — the resource has no tag field, so this module exposes no tags variable.
  • Creating a group under the tenant root requires elevated access at the root (see RBAC below) — a common first-run blocker.

🔑 Required Azure RBAC Roles / Permissions

  • Management Group Contributor at the parent scope (the parent group, or the tenant root management group when parent_management_group_id is null) — or a custom role with Microsoft.Management/managementGroups/read, .../write, and .../subscriptions/write.
  • To create groups directly under the tenant root management group, the caller's identity often needs elevated access at the tenant root — Owner or Management Group Contributor granted at the root group, or the one-time tenant-level access elevation performed by a Global Administrator.
  • The identity must also be able to read the subscriptions referenced in subscription_associations.

Azure Prerequisites

  • The Microsoft.Management resource provider registered on the subscription the provider authenticates against.
  • Any parent management group referenced by parent_management_group_id already exists.
  • Any subscriptions referenced in subscription_associations already exist and are visible to the caller's identity.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; the module declares none of these.

📁 Module Structure

terraform-azurerm-management-group/
├── providers.tf     # required_version + azurerm ~> 4.0; no provider block
├── variables.tf     # name, display_name, parent_management_group_id, subscription_associations, timeouts
├── main.tf          # azurerm_management_group.this + for_each subscription associations
├── outputs.tf       # id, name, display_name, tenant_scoped_id, subscription_association_ids
├── README.md        # this document
├── SCOPE.md         # cross-module contract
├── LICENSE          # MIT
└── .gitignore       # canonical Terraform ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "mg_platform" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-platform"
  display_name = "Platform"
}

ℹ️ Pin the module by immutable tag (?ref=v1.0.0), never a branch. The caller configures the provider, authentication, subscription, and the mandatory features {} block.

🔌 Cross-Module Contract

Consumes

Input Type From
parent_management_group_id string a parent terraform-azurerm-management-group (id), or null for the tenant root
subscription_associations[*].subscription_id string a subscription's Resource ID (/subscriptions/<guid>)

Emits

Output Description Consumed by
id Management group Resource ID terraform-azurerm-role-assignments, terraform-azurerm-management-group-policy-assignment, terraform-azurerm-management-group-policy-set-definition, terraform-azurerm-management-group-policy-exemption, terraform-azurerm-management-group-policy-remediation, terraform-azurerm-management-group-template-deployment, child groups (parent_management_group_id)
name Management group name (tenant-unique ID) references / tooling
display_name Friendly display name documentation
tenant_scoped_id Tenant-scoped ID (computed) tenant-level tooling
subscription_association_ids map: membership key → association ID audit

📚 Example Library

1 · Minimal (child of the tenant root)
module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-platform"
  display_name = "Platform"
}

ℹ️ With parent_management_group_id omitted, the group is created directly under the tenant root management group.

2 · Nested under a parent management group
module "mg_connectivity" {
  source                     = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name                       = "mg-connectivity"
  display_name               = "Connectivity"
  parent_management_group_id = var.platform_mg_id
}

💡 parent_management_group_id takes the parent's full Resource ID (/providers/Microsoft.Management/managementGroups/<parent>).

3 · One subscription membership
module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-corp"
  display_name = "Corp"

  subscription_associations = {
    prod = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000000" }
  }
}

🔒 subscription_id is the full subscription Resource ID, not the bare GUID.

4 · Multiple memberships (keyed map)
module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-landingzones"
  display_name = "Landing Zones"

  subscription_associations = {
    prod    = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000001" }
    nonprod = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000002" }
    sandbox = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000003" }
  }
}

💡 Stable keys (prod, nonprod, …) mean removing one membership never re-indexes the others.

5 · Membership from a subscription data source
data "azurerm_subscription" "prod" {
  subscription_id = "00000000-0000-0000-0000-000000000001"
}

module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-corp"
  display_name = "Corp"

  subscription_associations = {
    prod = { subscription_id = data.azurerm_subscription.prod.id }
  }
}

ℹ️ A subscription data source's id is exactly the /subscriptions/<guid> form this input expects.

6 · for_each memberships at scale
locals {
  subscription_ids = {
    prod    = "/subscriptions/00000000-0000-0000-0000-000000000001"
    nonprod = "/subscriptions/00000000-0000-0000-0000-000000000002"
    dev     = "/subscriptions/00000000-0000-0000-0000-000000000003"
  }
}

module "mg" {
  source                    = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name                      = "mg-workloads"
  display_name              = "Workloads"
  subscription_associations = { for k, id in local.subscription_ids : k => { subscription_id = id } }
}
7 · A two-level hierarchy (parent + child)
module "mg_platform" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-platform"
  display_name = "Platform"
}

module "mg_identity" {
  source                     = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name                       = "mg-identity"
  display_name               = "Identity"
  parent_management_group_id = module.mg_platform.id
}

💡 Wire the child's parent_management_group_id to the parent module's id output — Terraform then creates the parent first.

8 · Landing-zone style tree
module "mg_root" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-contoso"
  display_name = "Contoso"
}

module "mg_platform" {
  source                     = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name                       = "mg-platform"
  display_name               = "Platform"
  parent_management_group_id = module.mg_root.id
}

module "mg_landingzones" {
  source                     = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name                       = "mg-landingzones"
  display_name               = "Landing Zones"
  parent_management_group_id = module.mg_root.id
}

module "mg_corp" {
  source                     = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name                       = "mg-corp"
  display_name               = "Corp"
  parent_management_group_id = module.mg_landingzones.id
}
9 · Group with memberships and a custom name
module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
  display_name = "Legacy Workloads"

  subscription_associations = {
    legacy = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000009" }
  }
}

⚠️ A GUID name is valid but opaque; a slug like mg-legacy reads better in the hierarchy. Either way, name is immutable.

10 · Custom timeouts
module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-platform"
  display_name = "Platform"
  timeouts     = { create = "30m", delete = "30m" }
}

ℹ️ update applies to the group only; each membership record has no update phase (create/read/delete).

11 · Governance anchor — RBAC at the group scope
module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-platform"
  display_name = "Platform"
}

module "roles" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
  scope  = module.mg.id

  role_assignments = {
    platform_readers = {
      role_definition_name = "Reader"
      principal_id         = var.platform_team_group_object_id
    }
  }
}

🔒 Roles assigned at the management group inherit down to every subscription and resource beneath it — grant least privilege here deliberately.

12 · Governance anchor — policy at the group scope
module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-landingzones"
  display_name = "Landing Zones"
}

module "policy" {
  source               = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-policy-assignment.git?ref=v1.0.0"
  name                 = "require-tag-costcenter"
  management_group_id  = module.mg.id
  policy_definition_id = var.require_tag_policy_id
}

💡 Assigning at the management group is what makes a guardrail apply tenant-wide from a single record. Use the management-group-scoped assignment module here — the resource-group-scoped one takes a resource_group_id and cannot assign at this scope. Note also that assignment names are limited to 24 characters at management-group scope.

13 · Diagnostic settings on the group activity log
module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-platform"
  display_name = "Platform"
}

module "diag" {
  source                     = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-diagnostic-setting.git?ref=v1.0.0"
  name                       = "mg-activity-to-law"
  target_resource_id         = module.mg.id
  log_analytics_workspace_id = var.law_id
}

ℹ️ The group's activity log flows to a Log Analytics workspace owned by a sibling module.

14 · 🏗️ End-to-end composition

A management group, subscriptions attached to it, then RBAC and policy applied at its scope — a minimal governance root wired from real sibling outputs.

provider "azurerm" {
  features {}
}

module "mg" {
  source       = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group.git?ref=v1.0.0"
  name         = "mg-platform"
  display_name = "Platform"

  subscription_associations = {
    prod    = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000001" }
    nonprod = { subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000002" }
  }
}

module "roles" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
  scope  = module.mg.id

  role_assignments = {
    platform_owners = {
      role_definition_name = "Owner"
      principal_id         = var.platform_admins_object_id
    }
  }
}

module "policy" {
  source               = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-policy-assignment.git?ref=v1.0.0"
  name                 = "deny-public-storage"
  management_group_id  = module.mg.id
  policy_definition_id = var.deny_public_storage_policy_id
}

💡 The management group id is the single scope both the RBAC and the policy module consume — the group is the anchor the rest of the governance stack hangs from.

📥 Inputs

Name Type Required Default Description
name string ✅ — Tenant-unique management group ID. Immutable.
display_name string ✅ — Friendly display name. Updatable.
parent_management_group_id string — null Parent group Resource ID; null = tenant root. Updatable (moves the group).
subscription_associations map(object) — {} Subscription memberships, keyed by a stable identifier.
timeouts object — null Optional operation timeouts.

ℹ️ This resource type does not support tags, so the module intentionally exposes no tags variable.

Full variable schemas
variable "name" {
  type = string # immutable; the .../managementGroups/<name> Resource ID segment
}

variable "display_name" {
  type = string # updatable friendly name
}

variable "parent_management_group_id" {
  type    = string # full parent Resource ID; null parents under the tenant root
  default = null
}

variable "subscription_associations" {
  type = map(object({
    subscription_id = string # full subscription Resource ID: /subscriptions/<guid>
  }))
  default = {}
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string) # applies to the group only
    delete = optional(string)
  })
  default = null
}

🧾 Outputs

Output Description Kind
id The Azure Resource ID of the management group (/providers/Microsoft.Management/managementGroups/) Passthrough
name The name (tenant-unique ID) of the management group Passthrough
display_name The friendly display name of the management group Passthrough
tenant_scoped_id The management group ID scoped to the tenant, in the form /tenants//providers/Microsoft.Management/managementGroups/ (computed) Passthrough
subscription_association_ids Map of subscription-association key => the association record's Azure Resource ID (empty when no memberships are configured) Derived
parent_management_group_id The Resource ID of this group's parent, or the tenant root group when none was given Passthrough
is_parented_at_tenant_root True when no parent was specified, in which case the provider parents this group directly under the TENANT ROOT group - the widest position in the hierarchy Derived
subscription_association_count How many subscriptions this module associates with the group Passthrough
associated_subscription_ids The full subscription Resource IDs associated with this group by this module, sorted Derived
removing_a_subscription_sends_it_to_tenant_root Always true Constant
losing_read_access_makes_terraform_plan_a_recreate Always true Constant
destroy_reports_success_without_deleting_when_read_is_forbidden Always true Constant
recreating_this_group_detaches_everything_beneath_it Always true Constant

🧠 Architecture Notes

  • No tags. azurerm_management_group has no tag field, so the module exposes no tags variable. Governance metadata for a group lives in naming conventions and in policy/RBAC applied at its scope, not in tags.
  • Immutable identity. name is the group's Resource ID segment and forces replacement when changed. display_name is updatable, and parent_management_group_id is updatable in place (it moves the group rather than recreating it).
  • The module owns memberships — one way. Memberships are separate azurerm_management_group_subscription_association records via for_each, keyed by a stable identifier. The keystone's subscription_ids is deliberately left unset because managing memberships both there and through association records against the same group is unsupported and yields perpetual, unpredictable diffs.
  • Membership fields are immutable. Changing a membership's target group or subscription recreates that record; the stable map key keeps unrelated memberships untouched.
  • Ordering is implicit. Each membership references the keystone's id, so Terraform creates the group before associating subscriptions — no depends_on needed.
  • Governance anchor. The primary output is id; sibling modules assign policy, initiatives, and RBAC at that scope, and child groups reference it as their parent.
  • features {} dependence. No provider {} block here; the caller configures provider "azurerm" { features {} }.

🧱 Design Principles

Concern Secure default (empty call) Opt-out / opt-in
Membership management Owned only via association records; keystone subscription_ids unset add entries to subscription_associations
Placement Directly under the tenant root set parent_management_group_id
Least-privilege scope Group emits id for narrow, deliberate RBAC/policy scoping assign roles/policy at the smallest group that works
Identity stability Explicit, caller-chosen name (immutable) supply a GUID if a random ID is preferred

🚀 Runbook

cd terraform-azurerm-management-group
terraform init -backend=false
terraform validate
terraform fmt -check
Remove-Item -Recurse -Force .terraform -ErrorAction SilentlyContinue

Pin the module by tag (?ref=v1.0.0), never a branch. Plan-only during authoring; a human runs plan/apply from CI.

🧪 Testing

The offline proof gate — terraform init -backend=false, terraform validate, terraform fmt -check — proves the configuration is type-correct against the pinned azurerm ~> 4.0 schema and canonically formatted, with no cloud calls. What it does not exercise: whether the caller's identity has tenant-root access to create the group, whether referenced parent groups and subscriptions exist, and the dual-management conflict that only appears when a group's subscription list and association records fight over the same subscription — those surface only under terraform plan/apply against real credentials from CI.

💬 Example Output

$ terraform output
id           = "/providers/Microsoft.Management/managementGroups/mg-platform"
name         = "mg-platform"
display_name = "Platform"
tenant_scoped_id = "/tenants/11111111-1111-1111-1111-111111111111/providers/Microsoft.Management/managementGroups/mg-platform"
subscription_association_ids = {
  "prod"    = "/providers/Microsoft.Management/managementGroups/mg-platform/subscriptions/00000000-0000-0000-0000-000000000001"
  "nonprod" = "/providers/Microsoft.Management/managementGroups/mg-platform/subscriptions/00000000-0000-0000-0000-000000000002"
}

🔍 Troubleshooting

Symptom Cause Fix
AuthorizationFailed creating a group under the tenant root Identity lacks elevated access at the tenant root management group Grant Management Group Contributor/Owner at the root, or have a Global Administrator elevate tenant access once.
Group wants to be replaced on every plan name was changed name is immutable; keep it stable, or accept the recreation deliberately.
Perpetual diff on subscription membership subscription_ids set on the group while also using association records Manage memberships only through subscription_associations; leave the keystone list unset (this module already does).
Membership rejected / recreated Wrong subscription_id format, or the field changed Use the full /subscriptions/<guid> Resource ID; both membership fields are immutable.
ParentManagementGroupNotFound parent_management_group_id points at a group that does not exist yet Wire it to a parent module's id, or create the parent first.
Subscription not found during association Subscription not visible to the caller's identity Grant the identity read access to the target subscription.

🔗 Related Docs

  • Provider resources: azurerm_management_group, azurerm_management_group_subscription_association
  • Sibling modules: terraform-azurerm-role-assignments, terraform-azurerm-management-group-policy-assignment, terraform-azurerm-management-group-policy-set-definition, terraform-azurerm-management-group-policy-exemption, terraform-azurerm-management-group-policy-remediation, terraform-azurerm-management-group-template-deployment, terraform-azurerm-monitor-diagnostic-setting
  • This module's cross-module contract: SCOPE.md

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