Skip to content

microsoftexpert/tf-mod-azure-policy

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🚀 Azure Policy Governance Terraform Module

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_each collection. Built for AzureRM v4.x.

Terraform AzureRM Module Type Resources


🧩 Overview

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 / Modify effects 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-waiver typo fails at plan — not after a 24-hour compliance cycle silently lets a resource through.


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

ℹ️ Purple = this module. Azure = its keystone upstream dependency, tf-mod-azure-managed-identity — without an identity (and a location), DeployIfNotExists / Modify assignments 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.


🧬 What this module builds

⚠️ Aggregation composite — there is no single this. 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 as tf-mod-azure-apim-shared), every resource is a role-named for_each collection. The diagram highlights the foundational definitions layer in purple because assignments compose on it — not because it is a this.

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
Loading

Resource inventory (16 resources + 5 data sources):

  • 📜 azurerm_policy_definition.definitioncustom policy definitions (the building blocks).
  • 🧱 azurerm_policy_set_definition.set_definitioninitiatives at subscription / MG-default scope.
  • 🧱 azurerm_management_group_policy_set_definition.management_group_set_definitioninitiatives 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.

✅ Provider / Versions

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 SystemAssigned or UserAssignednot the combined "SystemAssigned, UserAssigned" form used by most other azurerm resources. The module's validation {} rejects anything else.
  • 📍 location is required whenever an identity is set (the identity is provisioned in that region). Enforced here as a cross-field validation.
  • 🏷️ No policy resource exposes a tags argument in 4.x — this module deliberately omits the universal tags variable. timeouts is supported by every resource and is exposed once.
  • 🌐 azurerm_management_group_policy_remediation has no resource_discovery_mode (the other three scopes do); its schema omits it.
  • 🧮 policy_definition.mode accepts resource-provider modes (e.g. Microsoft.KeyVault.Data) beyond All / Indexed, so it is intentionally not constrained to a hard enum.
  • 📦 JSON-bearing fields (policy_rule, parameters, metadata, parameter_values, override value) are strings — build them with jsonencode(...).

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

Sourced directly from SCOPE.md. Every value object carries the IDs it consumes; every output is a map keyed by the collection key.

Consumes

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)

Emits

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

📚 Example Library

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_reference requires at least one member (provider min_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 / Modify effects 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 + overrides are the provider's safe-deployment primitives — phase a deny in as audit per-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. Managing expires_on in 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_mode is 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 ID
1️⃣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-prod are 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." }
    ]
  }
}

🔒 enforce defaults to true in this module — the empty/typical call enforces. Setting enforce = false (Azure's DoNotEnforce) 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 (or depends_on) so an assignment never plans before its definition exists.


📥 Inputs

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 (no tags — policy resources have no tags argument)
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) })

🧾 Outputs

All outputs are maps keyed by the collection key. None are sensitive (policy config is not secret-bearing). Principal-ID maps are conditionalnull 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

🧠 Architecture Notes

  • Aggregation, no this. Azure Policy has no dominant resource, so — like tf-mod-azure-apim-shared — every resource is a role-named for_each collection. The empty call plans zero resources; each collection is independent and can be used in isolation.

  • for_each keys are identity. Every collection is for_each over a map(object(...)), and the map key is the resource's stable address. The on-Azure name defaults 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-a removed and key-b added. 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 or terraform state mv.

  • map(object) over count, 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 forbids for_each over a value marked sensitive (the keys would leak into the plan as resource addresses). No input here is sensitive, so this module needs no nonsensitive. But if you build a collection's keys from a sensitive upstream output, wrap the keys with nonsensitive at 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_each model 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-collection depends_on inside 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_id comes from a definition output; an exemption's / remediation's policy_assignment_id comes from an assignment output. Wire through outputs and Terraform sequences the apply correctly.

  • SystemAssigned vs UserAssigned. 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-wide DeployIfNotExists; the *_principal_ids output is null for 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's management_group_id are ForceNew. The heredocs label these # IMMUTABLE.

  • mode left flexible on purpose. Beyond All/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.


🧱 Design Principles

  • 🔒 Secure by defaultenforce = true on every assignment; certificate/identity rules fail closed. The risky choice (DoNotEnforce) costs extra characters.
  • 🧬 The type is the contract — deeply-typed object schemas, optional defaults, and validation {} for every closed set (policy_type, identity type, 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 at plan.
  • 🎯 Small blast radius — independent for_each collections mean a change to one assignment never re-plans a definition or a sibling scope.
  • 🧱 main.tf is a total rendererdynamic blocks 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.

🚀 Runbook

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.0never a branch. Tags are immutable; branches drift under you.


🧪 Testing

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 a plan-time type error before validate even runs.


💬 Example Output

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
}

🔍 Troubleshooting

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 prefer UserAssigned.

  • location is required whenever an identity is configured — set location on 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_ids and grant via tf-mod-azure-role-assignment to 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_scopes is 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_on and Azure re-enforced. Because expires_on is 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_mode rejected on a management-group remediation. The azurerm provider does not expose resource_discovery_mode at MG scope — it is absent from management_group_policy_remediations. Use subscription/RG/resource scope if you need ReEvaluateCompliance.

  • Error: Invalid index reading *_principal_ids[...]. The assignment had no identity (or used UserAssigned), so the principal map value is null. Guard with try(...) or only read principal IDs for assignments you gave a SystemAssigned identity.

  • Guest Configuration assignment applies but nothing happens on the VM. The Guest Configuration VM extension isn't installed. Provision it (azurerm_virtual_machine_extension, publisher Microsoft.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 or terraform state mv instead 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 requires min_items = 1; add at least one policy_definition_id.

  • JSON field rejected / odd diff on parameters or metadata. These are strings, not HCL objects — wrap with jsonencode(...). Inconsistent whitespace/ordering between your string and Azure's normalized form can also show a perpetual diff; let jsonencode produce canonical JSON.


🔗 Related Docs

  • Terraform Registry — azurerm_policy_definition, azurerm_policy_set_definition, the four *_policy_assignment, *_policy_exemption, and *_policy_remediation resources, and azurerm_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 the tf-mod-azure-apim-* composite suite (gold-standard composite modules).

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

Packages

 
 
 

Contributors

Languages