The whole Azure Policy lifecycle behind one deeply-typed boundary — custom definitions, initiatives, assignments at every scope, exemptions, remediations and Guest Configuration, each a stable
for_eachcollection. Built for AzureRM v4.x.
This module is the governance plane for an landing zone. From one call you can:
- 📜 Author custom policy definitions (
azurerm_policy_definition) with JSON rules, parameters and metadata. - 🧱 Group them into initiatives — both subscription/MG-default (
azurerm_policy_set_definition) and management-group-scoped (azurerm_management_group_policy_set_definition). - 🎯 Assign policies or initiatives at all four scopes — management group, subscription, resource group, and individual resource.
- 🔐 Attach managed identities to assignments so
DeployIfNotExists/Modifyeffects can remediate, and emit their principal IDs for role wiring. - 🪪 Carve out exemptions (
Waiver/Mitigated) with managed expiry, at every scope. - 🛠️ Drive remediation tasks that bring existing non-compliant resources into line, at every scope.
- 🖥️ Apply Guest Configuration (machine configuration) assignments to VMs.
- 🔎 Look up existing custom/built-in definitions, initiatives, assignments and Guest Configuration assignments via read-only data sources.
💡 Why it matters: governance is expressed as typed data, not hand-rolled JSON sprinkled across repos. A malformed assignment, a missing remediation identity, or a
Waiver-vs-waivertypo fails atplan— not after a 24-hour compliance cycle silently lets a resource through.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
flowchart TD
MG["tf-mod-azure-management-group"] -->|management_group_id| POL
SUB["subscription scope"] -->|subscription_id| POL
RG["tf-mod-azure-resource-group"] -->|resource_group_id| POL
MI["tf-mod-azure-managed-identity"] -->|identity.identity_ids| POL
VM["VM module"] -->|virtual_machine_id| POL
POL["tf-mod-azure-policy"] -->|"*_assignment_principal_ids"| RA["tf-mod-azure-role-assignment"]
POL -->|"*_ids (audit / inventory)"| MON["tf-mod-azure-monitor / dashboards"]
style POL fill:#8957E5,stroke:#0078D4,stroke-width:3px,color:#fff
style MI fill:#0078D4,color:#fff
ℹ️ Purple = this module. Azure = its keystone upstream dependency,
tf-mod-azure-managed-identity— without an identity (and alocation),DeployIfNotExists/Modifyassignments can never remediate.
This module consumes scope IDs and identity IDs from the platform layer, and emits assignment principal IDs that tf-mod-azure-role-assignment turns into the RBAC a remediation actually needs — see the Cross-Module Contract.
⚠️ Aggregation composite — there is no singlethis. Azure Policy has no dominant resource: definitions, assignments, exemptions and remediations are independent peers across four scopes. Following this module suite's aggregation-module convention (same shape astf-mod-azure-apim-shared), every resource is a role-namedfor_eachcollection. The diagram highlights the foundational definitions layer in purple because assignments compose on it — not because it is athis.
flowchart TD
subgraph DEFS["📜 Definitions layer (foundational)"]
PD["azurerm_policy_definition<br/>.definition"]
PSD["azurerm_policy_set_definition<br/>.set_definition"]
MGPSD["azurerm_management_group_policy_set_definition<br/>.management_group_set_definition"]
end
subgraph ASSIGN["🎯 Assignments (one per scope)"]
MGA["azurerm_management_group_policy_assignment"]
SA["azurerm_subscription_policy_assignment"]
RGA["azurerm_resource_group_policy_assignment"]
RA["azurerm_resource_policy_assignment"]
end
subgraph EXEMPT["🪪 Exemptions (one per scope)"]
MGE["azurerm_management_group_policy_exemption.management_group_exemption<br/>azurerm_subscription_policy_exemption.subscription_exemption<br/>azurerm_resource_group_policy_exemption.resource_group_exemption<br/>azurerm_resource_policy_exemption.resource_exemption"]
end
subgraph REMED["🛠️ Remediations (one per scope)"]
MGR["azurerm_management_group_policy_remediation.management_group_remediation<br/>azurerm_subscription_policy_remediation.subscription_remediation<br/>azurerm_resource_group_policy_remediation.resource_group_remediation<br/>azurerm_resource_policy_remediation.resource_remediation"]
end
VMC["🖥️ azurerm_policy_virtual_machine_configuration_assignment"]
PD --> PSD
PD --> MGPSD
PSD --> ASSIGN
MGPSD --> MGA
PD --> ASSIGN
ASSIGN -->|policy_assignment_id| EXEMPT
ASSIGN -->|policy_assignment_id| REMED
style PD fill:#8957E5,stroke:#0078D4,stroke-width:3px,color:#fff
style PSD fill:#8957E5,color:#fff
style MGPSD fill:#8957E5,color:#fff
Resource inventory (16 resources + 5 data sources):
- 📜
azurerm_policy_definition.definition— custom policy definitions (the building blocks). - 🧱
azurerm_policy_set_definition.set_definition— initiatives at subscription / MG-default scope. - 🧱
azurerm_management_group_policy_set_definition.management_group_set_definition— initiatives created directly at a management group. - 🎯
azurerm_management_group_policy_assignment.management_group_assignment— assignment at management-group scope. - 🎯
azurerm_subscription_policy_assignment.subscription_assignment— assignment at subscription scope. - 🎯
azurerm_resource_group_policy_assignment.resource_group_assignment— assignment at resource-group scope. - 🎯
azurerm_resource_policy_assignment.resource_assignment— assignment at individual-resource scope. - 🪪
azurerm_management_group_policy_exemption.management_group_exemption— exemption at MG scope. - 🪪
azurerm_subscription_policy_exemption.subscription_exemption— exemption at subscription scope. - 🪪
azurerm_resource_group_policy_exemption.resource_group_exemption— exemption at RG scope. - 🪪
azurerm_resource_policy_exemption.resource_exemption— exemption at resource scope. - 🛠️
azurerm_management_group_policy_remediation.management_group_remediation— remediation at MG scope. - 🛠️
azurerm_subscription_policy_remediation.subscription_remediation— remediation at subscription scope. - 🛠️
azurerm_resource_group_policy_remediation.resource_group_remediation— remediation at RG scope. - 🛠️
azurerm_resource_policy_remediation.resource_remediation— remediation at resource scope. - 🖥️
azurerm_policy_virtual_machine_configuration_assignment.vm_configuration_assignment— Guest Configuration assignment on a VM. - 🔎
data.azurerm_policy_definition.definition_lookup— resolve an existing custom/built-in definition. - 🔎
data.azurerm_policy_definition_built_in.built_in_lookup— resolve a built-in definition specifically. - 🔎
data.azurerm_policy_set_definition.set_definition_lookup— resolve an existing initiative. - 🔎
data.azurerm_policy_assignment.assignment_lookup— resolve an existing assignment by name + scope. - 🔎
data.azurerm_policy_virtual_machine_configuration_assignment.vm_configuration_lookup— resolve an existing Guest Configuration assignment.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| AzureRM provider | >= 4.0, < 5.0 |
| Provider config | None in-module — the root/spec configures provider "azurerm". |
4.x schema notes that bite:
- 🧩 Policy assignments accept
SystemAssignedorUserAssigned— not the combined"SystemAssigned, UserAssigned"form used by most other azurerm resources. The module'svalidation {}rejects anything else. - 📍
locationis required whenever anidentityis set (the identity is provisioned in that region). Enforced here as a cross-field validation. - 🏷️ No policy resource exposes a
tagsargument in 4.x — this module deliberately omits the universaltagsvariable.timeoutsis supported by every resource and is exposed once. - 🌐
azurerm_management_group_policy_remediationhas noresource_discovery_mode(the other three scopes do); its schema omits it. - 🧮
policy_definition.modeaccepts resource-provider modes (e.g.Microsoft.KeyVault.Data) beyondAll/Indexed, so it is intentionally not constrained to a hard enum. - 📦 JSON-bearing fields (
policy_rule,parameters,metadata,parameter_values, overridevalue) are strings — build them withjsonencode(...).
tf-mod-azure-policy/
├── providers.tf # Terraform + AzureRM version pins (no provider config)
├── variables.tf # 21 deeply-typed collection maps, heredoc schemas, validations
├── main.tf # Aggregation renderer — 16 for_each resources + 5 data sources
├── outputs.tf # Composition contract: ID maps + assignment principal-ID maps
├── SCOPE.md # Composite scope contract (allow-list, consumes/emits, gotchas)
└── README.md # You are here
The smallest call that produces a real resource — one custom definition:
module "policy" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-policy?ref=v1.0.0"
policy_definitions = {
require-env-tag = {
display_name = "Require an 'environment' tag on resources"
policy_rule = jsonencode({
if = { field = "tags['environment']", exists = "false" }
then = { effect = "deny" }
})
}
}
}
⚠️ Always pin the source with?ref=v1.0.0, never a branch — module tags are immutable.
Sourced directly from SCOPE.md. Every value object carries the IDs it consumes; every output is a map keyed by the collection key.
| Input | Type | Source |
|---|---|---|
🏛️ *.management_group_id |
string |
tf-mod-azure-management-group (group ID) |
🧾 *.subscription_id / *.resource_group_id / *.resource_id |
string |
scope owner / tf-mod-azure-resource-group |
📜 *.policy_definition_id |
string |
this module's policy_definition_ids / *_set_definition_ids, or a built-in ID |
🪪 *.identity.identity_ids |
list(string) |
tf-mod-azure-managed-identity (id) |
🖥️ vm_configuration_assignments[*].virtual_machine_id |
string |
VM module (id) |
| Output | Description | Consumed by |
|---|---|---|
policy_definition_ids |
Custom policy definition IDs | Assignments (policy_definition_id), initiatives |
policy_definition_role_definition_ids |
Roles a policy rule needs | tf-mod-azure-role-assignment (role_definition_id) |
policy_set_definition_ids / management_group_policy_set_definition_ids |
Initiative IDs | Assignments (policy_definition_id) |
*_policy_assignment_ids (4 scopes) |
Assignment IDs | Exemptions / remediations (policy_assignment_id) |
*_policy_assignment_principal_ids (4 scopes) |
System-assigned identity principal IDs (null when none) | tf-mod-azure-role-assignment (principal_id) for DeployIfNotExists/Modify roles |
*_policy_exemption_ids (4 scopes) |
Exemption IDs | Audit / inventory |
*_policy_remediation_ids (4 scopes) |
Remediation IDs | Audit / inventory |
vm_configuration_assignment_ids |
Guest Configuration assignment IDs | Compliance dashboards |
policy_definition_lookup_ids / policy_definition_built_in_lookup_ids / policy_set_definition_lookup_ids / policy_assignment_lookup_ids / vm_configuration_assignment_lookup_ids |
Resolved IDs from read-only lookups | Wiring assignments to pre-existing/built-in definitions |
Each example is self-contained. Map keys are identity — choose stable slugs (see Architecture Notes).
1️⃣ Minimal — a single custom policy definition
module "policy" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-policy?ref=v1.0.0"
policy_definitions = {
deny-public-ip = {
display_name = "Deny public IP addresses"
description = "Blocks creation of public IPs outside the perimeter."
policy_rule = jsonencode({
if = { field = "type", equals = "Microsoft.Network/publicIPAddresses" }
then = { effect = "deny" }
})
}
}
}2️⃣ Custom initiative (set definition) grouping member policies
policy_set_definitions = {
tagging-baseline = {
display_name = "tagging baseline"
description = "Bundles the org-wide required-tag policies."
parameters = jsonencode({
tagName = { type = "String", metadata = { displayName = "Tag name" } }
})
policy_definition_reference = [
{
policy_definition_id = module.policy.policy_definition_ids["require-env-tag"]
reference_id = "require-env-tag"
parameter_values = jsonencode({ tagName = { value = "environment" } })
}
]
policy_definition_group = [
{ name = "tagging", display_name = "Tagging", category = "Governance" }
]
}
}💡
policy_definition_referencerequires at least one member (providermin_items = 1); the module validates this with an actionable message.
3️⃣ Management-group-scoped initiative
management_group_policy_set_definitions = {
security-baseline = {
display_name = "Security baseline (MG)"
management_group_id = module.platform_mg.id # REQUIRED + IMMUTABLE at this scope
policy_definition_reference = [
{ policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/06a78e20-9358-41c9-923c-fb736d382a4d" } # built-in
]
}
}4️⃣ Subscription assignment — Audit, no identity
subscription_policy_assignments = {
audit-locations = {
subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000000"
policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c" # Allowed locations (built-in)
display_name = "Audit resource locations"
parameters = jsonencode({ listOfAllowedLocations = { value = ["eastus2", "centralus"] } })
# enforce defaults to true (effects enforced); no identity needed for audit-only effects
}
}5️⃣ DeployIfNotExists assignment + SystemAssigned identity + role wiring
subscription_policy_assignments = {
deploy-diag = {
subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000000"
policy_definition_id = module.policy.policy_set_definition_ids["diag-baseline"]
location = "eastus2" # REQUIRED whenever identity is set
identity = {
type = "SystemAssigned"
}
}
}
# Grant the assignment's identity the roles the policy rule declares:
module "remediation_roles" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-role-assignment?ref=v1.0.0"
principal_id = module.policy.subscription_policy_assignment_principal_ids["deploy-diag"]
# role_definition_id from module.policy.policy_definition_role_definition_ids[...]
}🔒
DeployIfNotExists/Modifyeffects cannot remediate without this identity and the role assignment. Granting it requires User Access Administrator (or Owner) on the scope.
6️⃣ Assignment with a shared UserAssigned identity
management_group_policy_assignments = {
deploy-mma = {
management_group_id = module.platform_mg.id
policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/..."
location = "eastus2"
identity = {
type = "UserAssigned"
identity_ids = [module.policy_identity.id] # REQUIRED (non-empty) for UserAssigned
}
}
}💡 Prefer UserAssigned at scale — system-assigned identities create/delete one Entra object per assignment per region, which churns the directory.
7️⃣ Non-compliance messages + scope exclusions (not_scopes)
subscription_policy_assignments = {
enforce-sku = {
subscription_id = "/subscriptions/0000.../"
policy_definition_id = module.policy.policy_definition_ids["allowed-vm-skus"]
not_scopes = ["/subscriptions/0000.../resourceGroups/sandbox-rg"] # up to 400 exclusions
non_compliance_message = [
{ content = "VM SKU not on the allow-list. See the cloud platform wiki." }
]
}
}8️⃣ Safe deployment (SDP) — overrides + resource_selectors
subscription_policy_assignments = {
rollout-deny = {
subscription_id = "/subscriptions/0000.../"
policy_definition_id = module.policy.policy_definition_ids["deny-public-ip"]
# Gradually roll out: only enforce in two regions to start.
resource_selectors = [
{
name = "by-region"
selectors = [{ kind = "resourceLocation", in = ["eastus2", "centralus"] }]
}
]
# Temporarily soften the effect without editing the definition.
overrides = [
{
value = "Audit"
selectors = [{ kind = "policyDefinitionReferenceId", in = ["deny-public-ip"] }]
}
]
}
}💡
resource_selectors+overridesare the provider's safe-deployment primitives — phase adenyin asauditper-region before flipping it on everywhere.
9️⃣ Exemptions with managed expiry
subscription_policy_exemptions = {
legacy-app-waiver = {
subscription_id = "/subscriptions/0000.../"
policy_assignment_id = module.policy.subscription_policy_assignment_ids["enforce-sku"]
exemption_category = "Waiver" # "Waiver" | "Mitigated"
expires_on = "2026-12-31T23:59:59Z" # RFC3339 UTC — manage it here
description = "Legacy app retires Q4 2026; tracked in GOV-1421."
policy_definition_reference_ids = ["allowed-vm-skus"] # limit to one initiative member
}
}
⚠️ Exemptions expire silently in Azure. Managingexpires_onin Terraform means renewal/removal is a reviewed code change, not a surprise re-enforcement.
🔟 Remediation task for existing non-compliant resources
subscription_policy_remediations = {
backfill-diag = {
subscription_id = "/subscriptions/0000.../"
policy_assignment_id = module.policy.subscription_policy_assignment_ids["deploy-diag"]
resource_discovery_mode = "ReEvaluateCompliance" # "ExistingNonCompliant" | "ReEvaluateCompliance"
failure_percentage = 0.1
parallel_deployments = 10
resource_count = 500
}
}ℹ️ A remediation handles up to 50,000 resources per task and is deleted by Azure 60 days after its last modification.
resource_discovery_modeis unavailable at management-group scope.
1️⃣1️⃣ Guest Configuration (machine configuration) assignment
vm_configuration_assignments = {
windows-baseline = {
location = "eastus2"
virtual_machine_id = module.vm.id
configuration = {
assignment_type = "ApplyAndMonitor" # Audit | ApplyAndAutoCorrect | ApplyAndMonitor | DeployAndAutoCorrect
version = "1.*"
parameter = [
{ name = "Minimum Password Length;ExpectedValue", value = "16" }
]
}
}
}
⚠️ The target VM must already have the Guest Configuration extension installed, or the assignment is created but never executes.
1️⃣2️⃣ Read-only lookups — built-in definition + existing assignment
policy_definition_built_in_lookups = {
allowed-locations = { display_name = "Allowed locations" } # exactly one of name / display_name
}
policy_assignment_lookups = {
existing-audit = {
name = "audit-locations"
scope_id = "/subscriptions/0000.../"
}
}
# module.policy.policy_definition_built_in_lookup_ids["allowed-locations"] -> resolved definition ID1️⃣3️⃣ for_each at scale — many assignments from one map
locals {
baseline_subscriptions = {
"sub-prod" = "/subscriptions/1111..."
"sub-nonprod" = "/subscriptions/2222..."
"sub-sandbox" = "/subscriptions/3333..."
}
}
module "policy" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-policy?ref=v1.0.0"
subscription_policy_assignments = {
for slug, sub_id in local.baseline_subscriptions :
"baseline-${slug}" => { # stable, human-readable keys
subscription_id = sub_id
policy_definition_id = module.policy_defs.policy_set_definition_ids["casey-baseline"]
display_name = "baseline (${slug})"
}
}
}💡 Keys like
baseline-sub-prodare identity. Reordering the map is free; renaming a key destroys and recreates that assignment. See Architecture Notes.
1️⃣4️⃣ Security / hardening — enforced deny initiative at the management group
management_group_policy_set_definitions = {
casey-guardrails = {
display_name = "guardrails (deny)"
management_group_id = module.platform_mg.id
policy_definition_reference = [
{ policy_definition_id = module.policy.policy_definition_ids["deny-public-ip"], reference_id = "deny-public-ip" },
{ policy_definition_id = module.policy.policy_definition_ids["allowed-locations"], reference_id = "allowed-locations" },
]
}
}
management_group_policy_assignments = {
casey-guardrails = {
management_group_id = module.platform_mg.id
policy_definition_id = module.policy.management_group_policy_set_definition_ids["casey-guardrails"]
enforce = true # SECURE DEFAULT: effects are enforced, not DoNotEnforce
non_compliance_message = [
{ content = "Blocked by guardrails. Request a reviewed exemption if required." }
]
}
}🔒
enforcedefaults totruein this module — the empty/typical call enforces. Settingenforce = false(Azure'sDoNotEnforce) is an explicit, reviewable decision.
🏗️ 1️⃣5️⃣ End-to-end composition (mandatory) — define → bundle → assign → remediate → exempt
# 1 · Platform inputs (sibling modules)
module "platform_mg" { source = "git::https://github.com/microsoftexpert/tf-mod-azure-management-group?ref=v1.0.0" /*... */ }
module "policy_identity" { source = "git::https://github.com/microsoftexpert/tf-mod-azure-managed-identity?ref=v1.0.0" /*... */ }
# 2 · Governance — one tf-mod-azure-policy call wires its own layers via outputs -> inputs
module "policy" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-policy?ref=v1.0.0"
# a) Definitions
policy_definitions = {
deny-public-ip = { display_name = "Deny public IP", policy_rule = jsonencode({ if = { field = "type", equals = "Microsoft.Network/publicIPAddresses" }, then = { effect = "deny" } }) }
allowed-locations = { display_name = "Allowed locations", policy_rule = jsonencode({ /*... */ }) }
}
# b) Initiative (references the definitions above by output)
management_group_policy_set_definitions = {
casey-baseline = {
display_name = "baseline"
management_group_id = module.platform_mg.id
policy_definition_reference = [
{ policy_definition_id = module.policy.policy_definition_ids["deny-public-ip"], reference_id = "deny-public-ip" },
{ policy_definition_id = module.policy.policy_definition_ids["allowed-locations"], reference_id = "allowed-locations" },
]
}
}
# c) Assignment with a shared identity so DeployIfNotExists members can remediate
management_group_policy_assignments = {
casey-baseline = {
management_group_id = module.platform_mg.id
policy_definition_id = module.policy.management_group_policy_set_definition_ids["casey-baseline"]
location = "eastus2"
identity = { type = "UserAssigned", identity_ids = [module.policy_identity.id] }
}
}
# d) Remediation + a reviewed exemption
management_group_policy_remediations = {
casey-baseline = {
management_group_id = module.platform_mg.id
policy_assignment_id = module.policy.management_group_policy_assignment_ids["casey-baseline"]
}
}
management_group_policy_exemptions = {
sandbox-waiver = {
management_group_id = module.platform_mg.id
policy_assignment_id = module.policy.management_group_policy_assignment_ids["casey-baseline"]
exemption_category = "Waiver"
expires_on = "2026-12-31T23:59:59Z"
}
}
}
# 3 · Grant the assignment identity its required roles (outside this module)
module "remediation_role" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-role-assignment?ref=v1.0.0"
principal_id = module.policy.management_group_policy_assignment_principal_ids["casey-baseline"] # null for UserAssigned — grant on the UAI instead
# role_definition_id derived from module.policy.policy_definition_role_definition_ids[...]
}
⚠️ Within one apply Terraform builds these in dependency order via the output→input wiring. If you split layers across calls, reference IDs through outputs (ordepends_on) so an assignment never plans before its definition exists.
High-level groups (all collections default to {} — the empty call plans zero resources):
- 📜 Definitions:
policy_definitions,policy_set_definitions,management_group_policy_set_definitions - 🎯 Assignments:
management_group_policy_assignments,subscription_policy_assignments,resource_group_policy_assignments,resource_policy_assignments - 🪪 Exemptions:
management_group_policy_exemptions,subscription_policy_exemptions,resource_group_policy_exemptions,resource_policy_exemptions - 🛠️ Remediations:
management_group_policy_remediations,subscription_policy_remediations,resource_group_policy_remediations,resource_policy_remediations - 🖥️ Guest Configuration:
vm_configuration_assignments - 🔎 Lookups (read-only):
policy_definition_lookups,policy_definition_built_in_lookups,policy_set_definition_lookups,policy_assignment_lookups,vm_configuration_assignment_lookups - ⏱️ Lifecycle:
timeouts(notags— policy resources have notagsargument)
Full object schemas for the nested map inputs
# Policy definition (map key = name unless `name` set)
policy_definitions = map(object({
name = optional(string)
display_name = string # REQUIRED
policy_rule = optional(string) # JSON
mode = optional(string, "All") # "All" | "Indexed" | RP mode (flexible)
policy_type = optional(string, "Custom") # BuiltIn | Custom | NotSpecified | Static
description = optional(string)
metadata = optional(string) # JSON
parameters = optional(string) # JSON
management_group_id = optional(string) # IMMUTABLE; null = current subscription scope
}))
# Initiative (subscription/MG-default scope). management_group_* variant requires management_group_id.
policy_set_definitions = map(object({
name = optional(string)
display_name = string # REQUIRED
policy_type = optional(string, "Custom")
description = optional(string)
metadata = optional(string) # JSON
parameters = optional(string) # JSON
management_group_id = optional(string) # IMMUTABLE
policy_definition_reference = list(object({ # REQUIRED — >= 1 member
policy_definition_id = string
parameter_values = optional(string) # JSON
reference_id = optional(string)
policy_group_names = optional(list(string))
version = optional(string)
}))
policy_definition_group = optional(list(object({
name = string
display_name = optional(string)
category = optional(string)
description = optional(string)
additional_metadata_resource_id = optional(string)
})), [])
}))
# Assignment (one map per scope; scope field differs: management_group_id |
# subscription_id | resource_group_id | resource_id)
<scope>_policy_assignments = map(object({
name = optional(string)
<scope_id field> = string # REQUIRED, IMMUTABLE
policy_definition_id = string # REQUIRED
display_name = optional(string)
description = optional(string)
enforce = optional(bool, true) # SECURE DEFAULT
location = optional(string) # REQUIRED when identity set
metadata = optional(string) # JSON
not_scopes = optional(list(string), [])
parameters = optional(string) # JSON
identity = optional(object({
type = string # SystemAssigned | UserAssigned
identity_ids = optional(list(string), []) # REQUIRED (non-empty) for UserAssigned
}))
non_compliance_message = optional(list(object({
content = string
policy_definition_reference_id = optional(string)
})), [])
overrides = optional(list(object({
value = string
selectors = optional(list(object({ kind = optional(string), in = optional(list(string)), not_in = optional(list(string)) })), [])
})), [])
resource_selectors = optional(list(object({
name = optional(string)
selectors = list(object({ kind = string, in = optional(list(string)), not_in = optional(list(string)) }))
})), [])
}))
# Exemption (one map per scope)
<scope>_policy_exemptions = map(object({
name = optional(string)
<scope_id field> = string # REQUIRED, IMMUTABLE
policy_assignment_id = string # REQUIRED
exemption_category = string # REQUIRED: Waiver | Mitigated
display_name = optional(string)
description = optional(string)
expires_on = optional(string) # RFC3339 UTC
metadata = optional(string) # JSON
policy_definition_reference_ids = optional(list(string), [])
}))
# Remediation (one map per scope; management-group variant has NO resource_discovery_mode)
<scope>_policy_remediations = map(object({
name = optional(string)
<scope_id field> = string # REQUIRED, IMMUTABLE
policy_assignment_id = string # REQUIRED
policy_definition_reference_id = optional(string)
resource_discovery_mode = optional(string) # sub/RG/resource only: ExistingNonCompliant | ReEvaluateCompliance
location_filters = optional(list(string), [])
failure_percentage = optional(number)
parallel_deployments = optional(number)
resource_count = optional(number)
}))
# Guest Configuration assignment
vm_configuration_assignments = map(object({
name = optional(string)
location = string # REQUIRED, IMMUTABLE
virtual_machine_id = string # REQUIRED, IMMUTABLE
configuration = object({
assignment_type = optional(string) # Audit | ApplyAndAutoCorrect | ApplyAndMonitor | DeployAndAutoCorrect
version = optional(string)
content_uri = optional(string)
content_hash = optional(string) # SHA256, UPPERCASE
parameter = optional(list(object({ name = string, value = string })), [])
})
}))
# Lookups (read-only data sources)
policy_definition_lookups = map(object({ name = optional(string), display_name = optional(string), management_group_name = optional(string) })) # exactly one of name/display_name
policy_definition_built_in_lookups = map(object({ name = optional(string), display_name = optional(string), management_group_name = optional(string) }))
policy_set_definition_lookups = map(object({ name = optional(string), display_name = optional(string), management_group_name = optional(string) }))
policy_assignment_lookups = map(object({ name = string, scope_id = string }))
vm_configuration_assignment_lookups = map(object({ name = string, resource_group_name = string, virtual_machine_name = string }))
timeouts = object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) })All outputs are maps keyed by the collection key. None are sensitive (policy config is not secret-bearing). Principal-ID maps are conditional — null for entries with no identity or a UserAssigned identity.
| Output | Description |
|---|---|
policy_definition_ids |
Custom definition key → ID |
policy_definition_role_definition_ids |
Definition key → role IDs the rule references |
policy_set_definition_ids |
Initiative key → ID |
management_group_policy_set_definition_ids |
MG initiative key → ID |
management_group_policy_assignment_ids |
MG assignment key → ID |
subscription_policy_assignment_ids |
Subscription assignment key → ID |
resource_group_policy_assignment_ids |
RG assignment key → ID |
resource_policy_assignment_ids |
Resource assignment key → ID |
management_group_policy_assignment_principal_ids |
MG assignment key → identity principal ID (null when none) |
subscription_policy_assignment_principal_ids |
Subscription assignment key → principal ID (null when none) |
resource_group_policy_assignment_principal_ids |
RG assignment key → principal ID (null when none) |
resource_policy_assignment_principal_ids |
Resource assignment key → principal ID (null when none) |
management_group_policy_exemption_ids |
MG exemption key → ID |
subscription_policy_exemption_ids |
Subscription exemption key → ID |
resource_group_policy_exemption_ids |
RG exemption key → ID |
resource_policy_exemption_ids |
Resource exemption key → ID |
management_group_policy_remediation_ids |
MG remediation key → ID |
subscription_policy_remediation_ids |
Subscription remediation key → ID |
resource_group_policy_remediation_ids |
RG remediation key → ID |
resource_policy_remediation_ids |
Resource remediation key → ID |
vm_configuration_assignment_ids |
Guest Configuration key → ID |
policy_definition_lookup_ids / policy_definition_built_in_lookup_ids / policy_set_definition_lookup_ids / policy_assignment_lookup_ids / vm_configuration_assignment_lookup_ids |
Lookup key → resolved ID |
-
Aggregation, no
this. Azure Policy has no dominant resource, so — liketf-mod-azure-apim-shared— every resource is a role-namedfor_eachcollection. The empty call plans zero resources; each collection is independent and can be used in isolation. -
for_eachkeys are identity. Every collection isfor_eachover amap(object(...)), and the map key is the resource's stable address. The on-Azurenamedefaults to the key (coalesce(value.name, key)). Consequences: -
Reordering a map is free — keys, not positions, address resources.
-
Renaming a key (or changing an immutable field like the scope ID) is a destroy-and-recreate: Terraform sees
key-aremoved andkey-badded. For an assignment, that briefly drops enforcement and re-creates its identity. Choose keys to last. -
To rename without recreating, use a
moved {}block at the call site orterraform state mv. -
map(object)overcount, never positional lists. Lists shift every downstream index when one entry is inserted; maps don't. This is why all 16 collections (and 5 lookups) are maps. -
Keys that contain secrets →
nonsensitive. Terraform forbidsfor_eachover a value markedsensitive(the keys would leak into the plan as resource addresses). No input here issensitive, so this module needs nononsensitive. But if you build a collection's keys from a sensitive upstream output, wrap the keys withnonsensitiveat the call site — e.g.for k, v in nonsensitive(local.secret_keyed_map):...— keeping the values sensitive. Keys become resource addresses and cannot be secret. -
Large composite, surgical applies. With 21 resource/data-source types, you rarely want to plan all of them. The
for_eachmodel makes partial applies safe:terraform apply -target='module.policy.azurerm_subscription_policy_assignment.subscription_assignment["baseline-sub-prod"]'touches exactly one assignment. Because collections are independent (no cross-collectiondepends_oninside the module), targeting one never forces a re-plan of another. Use targeting for incident response (push one guardrail now); use full applies for the steady state. -
Definitions → assignments → exemptions/remediations is the natural order. The module imposes no internal ordering, but the data dependencies do it for you: an assignment's
policy_definition_idcomes from a definition output; an exemption's / remediation'spolicy_assignment_idcomes from an assignment output. Wire through outputs and Terraform sequences the apply correctly. -
SystemAssignedvsUserAssigned. Built-in remediation policies at scale (e.g. monitoring-agent deployment) churn the directory if every assignment gets its own system-assigned identity. Prefer a shared user-assigned identity for fleet-wideDeployIfNotExists; the*_principal_idsoutput isnullfor those (grant roles on the UAI itself instead). -
Immutable fields.
name, every scope ID (management_group_id/subscription_id/resource_group_id/resource_id), and a definition'smanagement_group_idare ForceNew. The heredocs label these# IMMUTABLE. -
modeleft flexible on purpose. BeyondAll/Indexed, resource-provider modes (e.g.Microsoft.KeyVault.Data,Microsoft.Kubernetes.Data) are valid, so no hard enum is imposed — over-constraining it would break legitimate RP-mode definitions.
- 🔒 Secure by default —
enforce = trueon every assignment; certificate/identity rules fail closed. The risky choice (DoNotEnforce) costs extra characters. - 🧬 The type is the contract — deeply-typed
objectschemas,optionaldefaults, andvalidation {}for every closed set (policy_type, identitytype,exemption_category,resource_discovery_mode,assignment_type) plus cross-field rules (UserAssigned⇒ids, identity⇒location, initiative⇒≥1 member, lookup name-XOR-display-name). Malformed input fails atplan. - 🎯 Small blast radius — independent
for_eachcollections mean a change to one assignment never re-plans a definition or a sibling scope. - 🧱
main.tfis a total renderer —dynamicblocks for every optional/repeating block,try(x, null)on every optional nested field. No business logic. - 🔑 Secrets stay out of state — policy parameters reference Key Vault by ID; no secret material is stored here, so nothing is
sensitive. - ♻️ Definitions are referenced by ID — assignments compose on definition outputs, so a rule edit doesn't re-create the assignment.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan -out tfplan
terraform apply tfplan
terraform output
⚠️ Pin the module source with?ref=v1.0.0— never a branch. Tags are immutable; branches drift under you.
The offline proof gate (no cloud credentials needed):
terraform fmt -check # zero formatting drift
terraform validate # configuration is valid
tflint # provider-aware linting✅ This module ships green on all three. The deeply-typed schemas and
validation {}blocks turn most misuse into aplan-time type error beforevalidateeven runs.
policy_definition_ids = {
"deny-public-ip" = "/subscriptions/0000.../providers/Microsoft.Authorization/policyDefinitions/deny-public-ip"
"allowed-locations" = "/subscriptions/0000.../providers/Microsoft.Authorization/policyDefinitions/allowed-locations"
}
subscription_policy_assignment_ids = {
"deploy-diag" = "/subscriptions/0000.../providers/Microsoft.Authorization/policyAssignments/deploy-diag"
}
subscription_policy_assignment_principal_ids = {
"deploy-diag" = "11111111-2222-3333-4444-555555555555" # null when no identity / UserAssigned
}
The most valuable section — hard-won issues and how to resolve them. Several incorporate current Microsoft Learn limits and azurerm 4.x behavior.
-
identity.type must be 'SystemAssigned' or 'UserAssigned'— you passed the combined"SystemAssigned, UserAssigned"form valid elsewhere in azurerm. Policy assignments accept only one. Pick one; for fleet remediation preferUserAssigned. -
location is required whenever an identity is configured— setlocationon the same assignment object. The managed identity is provisioned in that region; Azure rejects an identity-bearing assignment without it. -
Remediation runs but resources stay non-compliant / "identity does not have permission." The assignment identity lacks the roles the policy rule declares. Read them from
policy_definition_role_definition_idsand grant viatf-mod-azure-role-assignmentto the*_principal_ids(system-assigned) or to the UAI (user-assigned). Granting these roles requires User Access Administrator (or Owner) on the scope — a plain Contributor can trigger remediation but cannot create the role assignment. -
Compliance state looks wrong right after
apply. Azure evaluates on a 24-hour cycle, and a fresh assignment can take up to ~30 minutes to evaluate existing resources. New/updated resources are evaluated on write; existing ones wait for the cycle or a manual remediation/scan. Don't gate CI on immediate compliance. -
The maximum number of policy... has been reached.Per-scope hard limits (Microsoft Learn): 500 policy definitions, 200 initiative definitions, 200 assignments, 1000 exemptions; an initiative may contain 1000 policies;not_scopesis capped at 400 exclusions; a remediation task covers up to 50,000 resources. Split across scopes or consolidate into initiatives. -
An exemption silently stopped working. It hit
expires_onand Azure re-enforced. Becauseexpires_onis managed here, renew/remove it as a reviewed code change rather than discovering it in a compliance report. -
A remediation task "disappeared" from the portal. Azure deletes remediation task resources 60 days after their last modification. The Terraform resource still exists in state; a re-apply (or any update) recreates the task. This is expected Azure behavior, not drift you caused.
-
resource_discovery_moderejected on a management-group remediation. The azurerm provider does not exposeresource_discovery_modeat MG scope — it is absent frommanagement_group_policy_remediations. Use subscription/RG/resource scope if you needReEvaluateCompliance. -
Error: Invalid indexreading*_principal_ids[...]. The assignment had no identity (or usedUserAssigned), so the principal map value isnull. Guard withtry(...)or only read principal IDs for assignments you gave aSystemAssignedidentity. -
Guest Configuration assignment applies but nothing happens on the VM. The Guest Configuration VM extension isn't installed. Provision it (
azurerm_virtual_machine_extension, publisherMicrosoft.GuestConfiguration) before — or alongside — the assignment. -
Renaming a map key destroyed and recreated the resource. Keys are identity (see Architecture Notes). To rename safely, add a
moved {}block orterraform state mvinstead of editing the key in place. -
policy_definition_reference must contain at least one member policy— an initiative with an empty member list. The provider requiresmin_items = 1; add at least onepolicy_definition_id. -
JSON field rejected / odd diff on
parametersormetadata. These are strings, not HCL objects — wrap withjsonencode(...). Inconsistent whitespace/ordering between your string and Azure's normalized form can also show a perpetual diff; letjsonencodeproduce canonical JSON.
- Terraform Registry —
azurerm_policy_definition,azurerm_policy_set_definition, the four*_policy_assignment,*_policy_exemption, and*_policy_remediationresources, andazurerm_policy_virtual_machine_configuration_assignment. - Microsoft Learn — What is Azure Policy? (definitions, initiatives, assignments, effects).
- Microsoft Learn — Azure Policy definitions effect basics (effect order, cumulative-most-restrictive layering).
- Microsoft Learn — Azure Policy remediation task structure and Use Azure Policy to assign managed identities.
- Microsoft Learn — Azure subscription and service limits (Azure Policy limits).
- —
SCOPE.md(this repo) and thetf-mod-azure-apim-*composite suite (gold-standard composite modules).
💙 "Infrastructure as Code should be standardized, consistent, and secure."