Efficiently Creating Azure Management Groups In Terraform

As part of a recent project I have been writing a Terraform module to bring all of our tenant IAM settings into state. This includes amongst many other things Azure management groups.

However, avoiding copying a separate resource block for each management group and instead using a for_each loop led me to an interesting dilemma, namely…

  • Management groups can be nested to a level of 6, each level depending on the level above to exist before it can be created. e.g you cant create a level 3 management group if you don’t have management groups at levels 1 & 2 already existing as parents.
  • Nested management groups can get complex. Trying to create an efficient structure and work out the complete parental chain for a new management group in a Terraform loop turned out to be the biggest headache.

So, what was I ultimately trying to achieve? I wanted to be able to supply the following map (as an example of the structure) –

  management_groups = {
    "MH" = {
      name = "Mike Hosker"
      children = {
        "MH-Prod" = {
          name = "Production"
          children = {
            "level-3" = {
              name = "Level 3"
              children = {
                "level-4" = {
                  name = "Level 4"
                  children = {
                    "level-5" = {
                      name = "Level 5"
                      children = {
                        "level-6" = {
                          name = "Level 6"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
        "MH-Dev" = {
          name = "Development"
        }
        "MH-Test" = {
          name = "Test"
        }
      }
    }
  }

Which would produce the following –

Spoiler alert. I achieved it. Read on to find out how…

Firstly, lets tackle the slightly less complex issue of nesting and making sure things are created in the correct order. I did this by creating a resource block for each management group level, then adding a depends_on referencing the parent level. To start with, lets look at level 1 –

locals{
    tenant_root_mgmt_group = "/providers/Microsoft.Management/managementGroups/${data.azurerm_client_config.current.tenant_id}"
}

# ---------------------------------------------------------
# Level 1
# ---------------------------------------------------------

resource "azurerm_management_group" "level_1" {
    for_each = var.management_groups

    name                                                         = each.key
    display_name                                       = each.value.name
    parent_management_group_id = local.tenant_root_mgmt_group
}

Initially I set a local containing the resource ID of the tenant root management group. Ultimately all other management groups we create will live under this.

Level 1 is then pretty simple, its just a case of looping through the var.management_groups variable at the top level and creating them.

Level 2 is where it starts to get a bit more complex – this is mostly due to setting the parent management group correctly and matching the Terraform map hierarchy with what ends up in Azure.

Firstly I create a local –

locals {
  level_2 = zipmap(
    flatten([for key, value in var.management_groups : formatlist("${key}/%s", keys(value.children)) if can(value.children)]),
    flatten([for value in var.management_groups : values(value.children) if can(value.children)])
  )
}

This local does two things – firstly, it creates a map of the children variable maps only, one level down for each parent value. e.g loop through each parent value, get the map set for the children variable and use it to create a new map containing only those results. This will become clearer in a minute after I visualize the changes.

Secondly, it adds to the key (set as the management group ID) the parent management group ID separated using a forward slash. Doing this ensures we can correctly set the parent_management_group_id when looping through the for_each in the management group resource block for the relevant level.

Note also the use of if can(value.children), this skips any parents that don’t have children management groups.

So if var.management_groups was set as above then local.level_2 would contain the following –

level_2 = {
  "MH/MH-Prod" = {
    name = "Production"
    children = {
      "level-3" = {
        name = "Level 3"
        children = {
          "level-4" = {
            name = "Level 4"
            children = {
              "level-5" = {
                name = "Level 5"
                children = {
                  "level-6" = {
                    name = "Level 6"
                  }
                }
              }
            }
          }
        }
      }
    }
  }
  "MH/MH-Dev" = {
    name = "Development"
  }
  "MH/MH-Test" = {
    name = "Test"
  }
}

Moving on to level 3, we use the exact same code however referencing the level 2 local, like so –

locals {
  level_3 = zipmap(
    flatten([for key, value in local.level_2 : formatlist("${key}/%s", keys(value.children)) if can(value.children)]),
    flatten([for value in local.level_2 : values(value.children) if can(value.children)])
  )
}

By referencing local.level_2 we continue the chain, getting the next level down children management groups.

This would result in local.level_3 containing the following –

level_3 = {
  "MH/MH-Prod/level-3" = {
    name = "Level 3"
    children = {
      "level-4" = {
        name = "Level 4"
        children = {
          "level-5" = {
            name = "Level 5"
            children = {
              "level-6" = {
                name = "Level 6"
              }
            }
          }
        }
      }
    }
  }
}

This chain then continues down until level 6, which is the maximum depth supported for Azure management groups.

The resource block for creating the management groups after level 1 looks like the following –

resource "azurerm_management_group" "level_2" {
    for_each = local.level_2

    name                                                         = basename(each.key)
    display_name                                       = each.value.name
    parent_management_group_id = azurerm_management_group.level_1[trimsuffix(each.key, "/${basename(each.key)}")].id

    depends_on = [azurerm_management_group.level_1]
}

In this example for level 2 I have used the basename() function to extract the last part of the key, being the current management group ID. basename() is designed for use with file paths, however works well in this scenario as the key is made up of the management group ID and parents using a forward slash as a delimiter, just like a file path.

For the parent_management_group_id I effectively need the inverse of the name within the key, so the equivalent of the “file” path without the filename itself. Terraform does have a function for this – dirname() which “takes a string containing a filesystem path and removes the last portion from it”, however it is unpredictable due to the path segment separator being different on Windows and Linux host systems.

Therefore I opted to use trimsuffix(each.key, "/${basename(each.key)}") which simply removes the basename(each.key) with a forward slash prefix, from the end of the each.key string containing the management group path. This would convert MH/MH-Prod/level-3 to MH/MH-Prod.

I then used this as the key selector in the parent management group level resource, so for level 2 its azurerm_management_group.level_1. We can then get the .id value which should return and set the correct parent management group ID.

Links to the full code made up into a Terraform module can be found on my Github – https://github.com/mhosker/terraform-azure-management-groups