IaC Azure

Terraform Module: The Full Azure Landing Zones (ALZ) Policy Library as Code

Hand-writing Azure Policy is where landing-zone projects go to die. Microsoft’s Azure Landing Zones reference ships 149 custom policy definitions, 42 initiatives (policy set definitions), and 80 default assignments — and it changes almost every month. Transcribing that into Terraform by hand is a full-time job that is stale the day you finish.

So don’t. This module treats the ALZ policy library as data: it reads the official library JSON with fileset + jsondecode, rewrites the handful of scope-relative references to your management group, and emits the matching azurerm resources. Adding or upgrading a policy is dropping a JSON file into lib/ and re-applying — the same approach Microsoft’s own avm-ptn-alz module takes, in ~120 lines you can read in one sitting.

Every code block below is the real module and is validated with terraform validate against all 271 library files.

What this module is

When to use it

Situation Use this module?
You run (or are building) an Azure Landing Zone and want the full ALZ guardrail catalogue as code Yes — it is the catalogue
You want a handful of guardrails (tags, allowed locations) only No — assign a few built-ins directly (see the 80-landing-zone example)
You want Microsoft to own upgrades and never touch policy JSON Consider Azure/avm-ptn-alz (this module is the “own your copy” alternative)
You need assignments spread across Root/Platform/Corp/Online archetypes Yes — deploy the library once, then assign the initiative IDs per archetype

Module structure

modules/alz-policy/
├── versions.tf     # terraform >= 1.6, azurerm ~> 4.0
├── main.tf         # locals (jsondecode + reference rewrite) + the 3 resources
├── variables.tf    # management_group_id, location, deploy_assignments, ...
├── outputs.tf      # definition/initiative/assignment IDs + counts
└── lib/            # the vendored ALZ library — swap for a newer release anytime
    ├── policy_definitions/       # 149 *.alz_policy_definition.json
    ├── policy_set_definitions/   #  42 *.alz_policy_set_definition.json
    └── policy_assignments/       #  80 *.alz_policy_assignment.json

versions.tf

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

The library files are read once into three maps keyed by the policy name. The only non-trivial step is resolved_sets, which pre-computes each initiative’s member references so the resource body stays a clean dynamic block. reverse(split("/policyDefinitions/", id))[0] is the segment after the last /policyDefinitions/ — a custom name (in def_names) or a built-in GUID (not).

locals {
  lib = coalesce(var.library_path, "${path.module}/lib")

  policy_definitions = {
    for f in fileset(local.lib, "policy_definitions/*.json") :
    jsondecode(file("${local.lib}/${f}")).name => jsondecode(file("${local.lib}/${f}"))
  }
  policy_set_definitions = {
    for f in fileset(local.lib, "policy_set_definitions/*.json") :
    jsondecode(file("${local.lib}/${f}")).name => jsondecode(file("${local.lib}/${f}"))
  }
  policy_assignments = {
    for f in fileset(local.lib, "policy_assignments/*.json") :
    jsondecode(file("${local.lib}/${f}")).name => jsondecode(file("${local.lib}/${f}"))
  }

  # The set of custom definition names — used to tell a custom member reference
  # from a built-in GUID reference inside an initiative.
  def_names = keys(local.policy_definitions)

  # Resolve every initiative's member references up front. A custom member
  # (name in def_names) is rewritten to the MG-scoped id of the definition we
  # create; a built-in GUID reference passes through.
  resolved_sets = {
    for sname, s in local.policy_set_definitions : sname => [
      for m in s.properties.policyDefinitions : {
        policy_definition_id = (
          contains(local.def_names, reverse(split("/policyDefinitions/", m.policyDefinitionId))[0])
          ? "${var.management_group_id}/providers/Microsoft.Authorization/policyDefinitions/${reverse(split("/policyDefinitions/", m.policyDefinitionId))[0]}"
          : m.policyDefinitionId
        )
        reference_id       = try(m.policyDefinitionReferenceId, null)
        parameter_values   = try(length(m.parameters), 0) > 0 ? jsonencode(m.parameters) : null
        policy_group_names = try(m.groupNames, null)
      }
    ]
  }
}

# 1. Custom policy DEFINITIONS (149)
resource "azurerm_policy_definition" "this" {
  for_each = local.policy_definitions

  name                = each.value.name
  policy_type         = "Custom"
  mode                = each.value.properties.mode
  display_name        = each.value.properties.displayName
  description         = try(each.value.properties.description, null)
  management_group_id = var.management_group_id

  metadata    = jsonencode(try(each.value.properties.metadata, {}))
  parameters  = try(length(each.value.properties.parameters), 0) > 0 ? jsonencode(each.value.properties.parameters) : null
  policy_rule = jsonencode(each.value.properties.policyRule)
}

# 2. INITIATIVES / policy set definitions (42)
resource "azurerm_policy_set_definition" "this" {
  for_each = local.policy_set_definitions

  name                = each.value.name
  policy_type         = "Custom"
  display_name        = each.value.properties.displayName
  description         = try(each.value.properties.description, null)
  management_group_id = var.management_group_id

  metadata   = jsonencode(try(each.value.properties.metadata, {}))
  parameters = try(length(each.value.properties.parameters), 0) > 0 ? jsonencode(each.value.properties.parameters) : null

  dynamic "policy_definition_reference" {
    for_each = local.resolved_sets[each.key]
    content {
      policy_definition_id = policy_definition_reference.value.policy_definition_id
      reference_id         = policy_definition_reference.value.reference_id
      parameter_values     = policy_definition_reference.value.parameter_values
      policy_group_names   = policy_definition_reference.value.policy_group_names
    }
  }

  dynamic "policy_definition_group" {
    for_each = try(each.value.properties.policyDefinitionGroups, [])
    content {
      name                            = policy_definition_group.value.name
      display_name                    = try(policy_definition_group.value.displayName, null)
      description                     = try(policy_definition_group.value.description, null)
      category                        = try(policy_definition_group.value.category, null)
      additional_metadata_resource_id = try(policy_definition_group.value.additionalMetadataId, null)
    }
  }

  depends_on = [azurerm_policy_definition.this]
}

# 3. Default ASSIGNMENTS (80) — optional
resource "azurerm_management_group_policy_assignment" "this" {
  for_each = var.deploy_assignments ? local.policy_assignments : {}

  name                = each.value.name
  management_group_id = var.management_group_id
  display_name        = try(each.value.properties.displayName, each.value.name)
  description         = try(each.value.properties.description, null)
  location            = var.location

  # Rewrite the library's `.../managementGroups/placeholder/...` scope token to
  # the management group we deploy at; built-in full IDs are left untouched.
  policy_definition_id = replace(
    each.value.properties.policyDefinitionId,
    "/providers/Microsoft.Management/managementGroups/placeholder",
    var.management_group_id
  )

  enforce    = try(each.value.properties.enforcementMode, var.default_enforcement_mode) == "Default"
  parameters = try(length(each.value.properties.parameters), 0) > 0 ? jsonencode(each.value.properties.parameters) : null

  dynamic "identity" {
    for_each = var.assignment_identity == null ? [] : [var.assignment_identity]
    content {
      type = identity.value
    }
  }

  dynamic "non_compliance_message" {
    for_each = try(each.value.properties.nonComplianceMessages, [])
    content {
      content                        = non_compliance_message.value.message
      policy_definition_reference_id = try(non_compliance_message.value.policyDefinitionReferenceId, null)
    }
  }

  depends_on = [azurerm_policy_set_definition.this, azurerm_policy_definition.this]
}

variables.tf

variable "management_group_id" {
  description = "Full ID of the management group to deploy at, e.g. /providers/Microsoft.Management/managementGroups/mg-alz-root."
  type        = string

  validation {
    condition     = can(regex("^/providers/Microsoft.Management/managementGroups/", var.management_group_id))
    error_message = "management_group_id must be a full management-group resource ID."
  }
}

variable "location" {
  description = "Region for the assignment managed identities (DeployIfNotExists/Modify need one)."
  type        = string
  default     = "eastus"
}

variable "default_enforcement_mode" {
  description = "Fallback enforcement when an assignment JSON omits one: 'Default' enforces, 'DoNotEnforce' audits."
  type        = string
  default     = "Default"

  validation {
    condition     = contains(["Default", "DoNotEnforce"], var.default_enforcement_mode)
    error_message = "default_enforcement_mode must be 'Default' or 'DoNotEnforce'."
  }
}

variable "deploy_assignments" {
  description = "Also create the 80 ALZ default assignments at this MG. Off by default; assign per archetype instead."
  type        = bool
  default     = false
}

variable "assignment_identity" {
  description = "Identity for assignments when deploy_assignments = true. Deploy/Modify effects need SystemAssigned."
  type        = string
  default     = "SystemAssigned"
}

variable "library_path" {
  description = "Path to an ALZ library (three sub-dirs). Defaults to the vendored copy in the module."
  type        = string
  default     = null
}

outputs.tf

output "policy_definition_ids" {
  description = "Custom policy definition name => resource ID (149)."
  value       = { for k, r in azurerm_policy_definition.this : k => r.id }
}

output "policy_set_definition_ids" {
  description = "Initiative name => resource ID (42). Assign these per archetype."
  value       = { for k, r in azurerm_policy_set_definition.this : k => r.id }
}

output "assignment_principal_ids" {
  description = "Assignment name => managed-identity principal ID, for the RBAC role grants Deploy/Modify need."
  value       = { for k, r in azurerm_management_group_policy_assignment.this : k => try(r.identity[0].principal_id, null) }
}

output "counts" {
  value = {
    definitions = length(local.policy_definitions)
    initiatives = length(local.policy_set_definitions)
    assignments = length(local.policy_assignments)
  }
}

Getting the library into lib/

Vendor a specific ALZ release so your baseline is reproducible and reviewable — never a moving main. A tiny fetch does it (swap main for a tag when you pin):

RAW=https://raw.githubusercontent.com/Azure/Azure-Landing-Zones-Library/main/platform/alz
API=https://api.github.com/repos/Azure/Azure-Landing-Zones-Library/contents/platform/alz
for kind in policy_definitions policy_set_definitions policy_assignments; do
  mkdir -p "lib/$kind"
  curl -sL "$API/$kind?ref=main" \
    | python3 -c "import sys,json;[print(x['name']) for x in json.load(sys.stdin)]" \
    | while read -r f; do curl -sfL "$RAW/$kind/$f" -o "lib/$kind/$f"; done
done
# -> lib/policy_definitions (149), lib/policy_set_definitions (42), lib/policy_assignments (80)

Upgrading the baseline is re-running that fetch and reviewing the terraform plan diff. No HCL changes.

How to use it

Deploy the whole library at one management group. Definitions and initiatives always land; assignments are opt-in.

data "azurerm_client_config" "current" {}

# Anchor MG (or point at an existing one via management_group_id).
resource "azurerm_management_group" "alz" {
  name         = "mg-kv-alz"
  display_name = "KV ALZ policy anchor"
}

module "alz_policy" {
  source = "../modules/alz-policy"

  management_group_id = azurerm_management_group.alz.id
  location            = "centralindia"

  # Start with the catalogue only; assign per archetype later.
  deploy_assignments = false
}

output "initiatives" {
  value = module.alz_policy.policy_set_definition_ids # 42 IDs to assign per archetype
}

Then assign an initiative at whatever archetype it belongs to. Because the module outputs every initiative’s ID, a per-archetype assignment is a one-liner (reuse the policy-assignment module pattern):

resource "azurerm_management_group_policy_assignment" "guardrails_online" {
  name                 = "Enforce-Guardrails"
  management_group_id  = azurerm_management_group.online.id
  policy_definition_id = module.alz_policy.policy_set_definition_ids["Enforce-Guardrails-Storage"]
  location             = "centralindia"
  enforce              = false # audit first; flip to true to enforce Deny

  identity { type = "SystemAssigned" }
}

Prefer the batteries-included default placement? Set deploy_assignments = true and the module creates all 80 ALZ default assignments at the target MG (audit-only until you set default_enforcement_mode = "Default").

Inputs

Name Type Default Description
management_group_id string Full MG resource ID to deploy the library at.
location string "eastus" Region for assignment managed identities.
default_enforcement_mode string "Default" Default (enforce) or DoNotEnforce (audit) fallback.
deploy_assignments bool false Also create the 80 default assignments at this MG.
assignment_identity string "SystemAssigned" Identity type for assignments.
library_path string null Override the vendored library path.

Outputs

Name Description
policy_definition_ids Map of 149 custom definition name → ID.
policy_set_definition_ids Map of 42 initiative name → ID (assign these per archetype).
assignment_principal_ids Map of assignment name → identity principal ID for RBAC grants.
counts { definitions, initiatives, assignments }.

Common mistakes and troubleshooting

Symptom Cause Fix
management_group_id must be a full management-group resource ID Passed a bare name (mg-alz) not the full ID Pass /providers/Microsoft.Management/managementGroups/<name> (a management-group resource’s .id).
Initiative apply fails: policy definition not found An initiative member points at a custom definition that wasn’t created (partial lib/) Vendor the whole library — initiatives reference definitions by name; depends_on orders them but both must be present.
Assignment apply: does not have authorization to perform action… roleAssignments/write The assignment identity needs its RBAC roles for Deploy/Modify effects Grant each assignment_principal_ids value the roleDefinitionIds from that policy (or Contributor at the MG for a lab).
Reserved keyword / Unsupported argument on policy_definition_reference Provider older than azurerm 4.x Pin azurerm ~> 4.0; the reference block shape changed across majors.
DoNotEnforce still shows non-compliant That is the point — audit mode reports, it does not block Switch enforce = true / default_enforcement_mode = "Default" once you have reviewed impact.
Assignment name too long at plan Some library names exceed the 24-char MG-assignment limit Assign those initiatives explicitly with a short name; the default-assignment path uses the library names as-is.
Plan wants to replace everything after an ALZ upgrade You re-vendored a release with renamed policies Review the diff — renamed policies are replaced by design; pin a release and upgrade deliberately.

Cost, cleanup and production notes

Testing

The module is validated against the complete library:

cd modules/alz-policy
terraform init -backend=false
terraform validate      # Success! (evaluates fileset/jsondecode over all 271 files)

# prove the data-driven counts + reference rewrite offline:
echo '{d=length(local.policy_definitions),i=length(local.policy_set_definitions),a=length(local.policy_assignments)}' | terraform console
# => { "a" = 80, "d" = 149, "i" = 42 }

terraform validate reads and decodes every JSON file and builds each resource’s for_each, so a malformed policy or a broken reference fails fast — before you ever touch a management group.

Interview and exam questions

Q: Why deploy the ALZ policy library data-driven instead of writing each policy in HCL? A: There are ~270 objects that change monthly and are maintained by Microsoft. Reading the JSON with fileset/jsondecode means upgrades are a file swap and a reviewed plan diff, not a rewrite — and you can’t fat-finger a policyRule.

Q: An initiative references both /providers/Microsoft.Authorization/policyDefinitions/<guid> and .../policyDefinitions/Deny-Storage-SFTP. What does the module do with each? A: The GUID is a built-in and passes through unchanged. Deny-Storage-SFTP is a custom definition name (it’s in def_names), so it’s rewritten to ${management_group_id}/providers/Microsoft.Authorization/policyDefinitions/Deny-Storage-SFTP — the MG-scoped ID of the definition the module creates.

Q: What is the managementGroups/placeholder token in the assignment JSON, and how is it handled? A: It’s the ALZ library’s stand-in for “the scope you deploy at.” A single replace(..., "/providers/Microsoft.Management/managementGroups/placeholder", var.management_group_id) turns it into your real MG; built-in assignment IDs don’t contain the token, so the replace is a no-op for them.

Q: Why are assignments opt-in and audit-only by default? A: Definitions/initiatives are inert until assigned; assignment placement is an archetype decision (Root vs Platform vs Corp vs Online). Auditing first (DoNotEnforce) lets you measure impact before a Deny/Deploy effect blocks or changes real resources.

Q: DeployIfNotExists assignments fail with an authorization error. Why? A: Those effects act on your tenant through the assignment’s managed identity, which needs the policy’s roleDefinitionIds granted. Use the assignment_principal_ids output to create the role assignments.

Key takeaways

TerraformAzure PolicyALZLanding ZoneGovernanceazurermManagement GroupsPolicy as CodeIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading