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
- Data-driven. No policy is written in HCL. The three ALZ object types live as JSON under
lib/and are materialised byfor_each. - Scope-correct. ALZ ships references as scope-relative tokens. Two rewrites make them real:
- an initiative member that points at a custom definition (by name, not a built-in GUID) becomes the management-group-scoped ID of the definition the module just created;
- an assignment that points at a custom initiative via the library’s
.../managementGroups/placeholder/...token hasplaceholderswapped for your management group. - Built-in GUID references (
/providers/Microsoft.Authorization/policyDefinitions/<guid>) pass through untouched.
- Safe by default. Definitions and initiatives are always deployed (the reusable library). The 80 default assignments are opt-in (
deploy_assignments) and audit-only (DoNotEnforce) until you flip them on — because assignment placement is an archetype decision you make once your management-group hierarchy exists.
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
- Cost: Azure Policy definitions, initiatives, and assignments are free; the only spend is what DeployIfNotExists effects create (a Log Analytics workspace, diagnostic settings). Keep
deploy_assignmentsoff and enforcementDoNotEnforcewhile you evaluate. - Cleanup:
terraform destroyremoves the definitions, initiatives, and any assignments; it does not delete resources a Deploy effect already created — remove those separately. - State: this is long-lived, shared governance — use a remote, locked backend (see
versions.tf), and put it in its own state, separate from workloads (blast radius). - RBAC: the principal running Terraform needs Resource Policy Contributor at the target MG; granting assignment identities their roles additionally needs User Access Administrator/Owner.
- Pin the library: vendor a tagged ALZ release, review upgrades as a normal PR, and let CI run
terraform planso policy drift is a reviewable diff.
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
- The ALZ policy library is data — 149 definitions, 42 initiatives, 80 assignments — so deploy it data-driven and keep upgrades to a file swap.
- Two rewrites make the library real at your scope: custom member names → MG-scoped IDs, and the assignments’
placeholdertoken → your management group. Built-in GUIDs pass through. - Ship the catalogue (definitions + initiatives) everywhere; place assignments per archetype, audit-first.
terraform validateover the wholelib/is a real correctness gate — a bad policy or reference fails before it reaches Azure.- Pin a tagged ALZ release, vendor it, and let CI diff upgrades.