Exempts a resource group from an Azure Policy assignment, with the category, justification and expiry that make the exemption reviewable. Targets
hashicorp/azurerm ~> 4.0.
- 🛑 Exempts every resource in a resource group from one policy assignment.
- 🏷️ Records the exemption as
MitigatedorWaiver— two materially different claims. - ⏳ Surfaces
never_expires, because an exemption with no end date suppresses a control indefinitely. - 🎯 Can narrow the exemption to named policy definition references inside an initiative rather than the whole thing.
- 🚧 Enforces Azure's published
policyExemptionsnaming rules, which the provider itself does not check. - 🔎 Emits
is_undocumented_permanent_waiver— the one combination an auditor asks about first.
💡 Why it matters: an exemption does not fix anything. It stops a policy from being evaluated, so the resources underneath vanish from the compliance report rather than appearing and passing. That makes the metadata — why, for how long, by whose decision — the substance of the resource rather than decoration.
If this module saved you time:
- ⭐ Star the repository — it genuinely helps other people find it.
- 💼 Connect on LinkedIn — linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee — buymeacoffee.com/microsoftexpert
flowchart TB
DEF["terraform-azurerm-policy-definition"]
SETDEF["terraform-azurerm-policy-set-definition"]
subgraph assign["Assignments, one module per scope"]
MGA["terraform-azurerm-management-group-policy-assignment"]
SUBA["terraform-azurerm-subscription-policy-assignment"]
RGA["terraform-azurerm-policy-assignment"]
end
subgraph exempt["Exemptions, one module per scope, widest first"]
MGE["terraform-azurerm-management-group-policy-exemption"]
SUBE["terraform-azurerm-subscription-policy-exemption"]
RGE["terraform-azurerm-resource-group-policy-exemption"]
RESE["terraform-azurerm-resource-policy-exemption"]
end
RG["terraform-azurerm-resource-group"]
DEF -->|"assigned by"| SETDEF
SETDEF -->|"policy_definition_id"| MGA
SETDEF -->|"policy_definition_id"| SUBA
SETDEF -->|"policy_definition_id"| RGA
MGA -->|"policy_assignment_id"| MGE
SUBA -->|"policy_assignment_id"| SUBE
RGA -->|"policy_assignment_id"| RGE
RGA -->|"policy_assignment_id"| RESE
RG -->|"resource_group_id"| RGE
RG -->|"contains the resource named in resource_id"| RESE
style RGE fill:#0078D4,stroke:#004578,color:#ffffff
style RESE fill:#0078D4,stroke:#004578,color:#ffffff
style RGA fill:#004578,stroke:#004578,color:#ffffff
style MGE fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style SUBE fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style MGA fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style SUBA fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style DEF fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style SETDEF fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style RG fill:#F3F2F1,stroke:#8A8886,color:#201F1E
Azure offers policy exemptions at four scopes, as four separate resource types — management group, subscription, resource group and single resource — and this library has one module for each. They are not interchangeable and cannot be switched by changing a variable. Pick the narrowest that solves the problem.
flowchart TB
SCOPEIN["resource_group_id: WHERE the control stops being evaluated"]
ASSIGNIN["policy_assignment_id: WHICH assignment is suppressed"]
CATIN["exemption_category: Mitigated or Waiver"]
EXPIN["expires_on: the only thing that stops this being permanent"]
REFIN["policy_definition_reference_ids: narrow it to named policies"]
THIS["azurerm_resource_group_policy_exemption.this"]
EFFECT["the assignment is NOT EVALUATED here: nothing is fixed"]
SCOPEOUT["parsed scope facts for a report"]
RISK["never_expires and is_undocumented_permanent_waiver"]
SCOPEIN -->|"FORCE-NEW"| THIS
ASSIGNIN -->|"FORCE-NEW at three of the four scopes"| THIS
CATIN -->|"the one required field that updates in place"| THIS
EXPIN --> THIS
REFIN -->|"omit and the WHOLE assignment is exempt"| THIS
THIS --> EFFECT
THIS --> SCOPEOUT
THIS --> RISK
style THIS fill:#0078D4,stroke:#004578,color:#ffffff
style EFFECT fill:#8A2B06,stroke:#5C1D04,color:#ffffff
style RISK fill:#8A2B06,stroke:#5C1D04,color:#ffffff
style ASSIGNIN fill:#004578,stroke:#004578,color:#ffffff
style SCOPEIN fill:#004578,stroke:#004578,color:#ffffff
style CATIN fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style EXPIN fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style REFIN fill:#F3F2F1,stroke:#8A8886,color:#201F1E
style SCOPEOUT fill:#F3F2F1,stroke:#8A8886,color:#201F1E
This diagram is shared with terraform-azurerm-resource-policy-exemption, deliberately and with no distinction invented: the two resources are identical in the schema apart from the name of the scope field.
| Resource | Cardinality | Notes |
|---|---|---|
azurerm_resource_group_policy_exemption.this |
single, named this |
Named by you, so a group may hold several exemptions against different assignments. |
| Item | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module. The caller configures provider "azurerm" { features {} }, auth and subscription. |
Schema notes that bite, each confirmed against the provider's own source at the pinned version:
- 🔴
policy_assignment_idis force-new here — but NOT at every scope. Resource, resource-group and subscription exemptions all replace when it changes; the management-group exemption updates in place. Three of four behave one way. Nothing in the schema shows this; only theForceNewflag in each resource's Go source does. - 🔴
name, the scope ID andpolicy_assignment_idare force-new;exemption_categoryis not. A miscategorised exemption can be corrected without replacement. ⚠️ The provider validatesnameonly as non-empty. Azure's published rule forpolicyExemptionsis 1–64 characters, no#<>%&:\?/or control characters, and no trailing period or space. This module enforces the published rule.⚠️ expires_onis stored as an instant and read back as UTC. A value written with an offset is accepted, then read back inZform, producing a difference on every plan. This module requires theZform.⚠️ metadataisOptional + Computed. Omitting it does not mean empty — Azure populates its own.⚠️ display_nameis capped at 128 characters anddescriptionat 512, viaStringLenBetween.- ✅ Create and Update are the same function upstream, gated by
d.IsNewResource()— so an update is a fullCreateOrUpdate, not a patch. - No
tags. Policy exemptions do not support them.
| Action | Role | Scope |
|---|---|---|
| Create / update / delete the exemption | Resource Policy Contributor, or a custom role with Microsoft.Authorization/policyExemptions/* |
The resource group |
Read it for plan |
Reader | The resource group |
| Read the assignment being exempted | Reader | Wherever the assignment lives |
🔴 The interesting permission question is governance, not RBAC. Anyone who can write a policy exemption at this scope can switch off a compliance control for an entire resource group, and the built-in role that grants it — Resource Policy Contributor — is often handed out with the assignment-creation duties. Separating who may assign policy from who may exempt from it is a deliberate act; Azure does not do it for you.
✅ No secret is involved. This resource accepts none and emits none.
Microsoft.Authorizationis a built-in provider; nothing to register.- An existing resource group, and an existing policy assignment at or above this scope.
- Where the assignment assigns an initiative and you intend to narrow the exemption, the reference IDs used inside that initiative.
- A review process. This module can record an expiry date and a justification; only a person can act on them.
terraform-azurerm-resource-group-policy-exemption/
├── providers.tf # required_version + pinned azurerm; no provider block
├── variables.tf # 4 required inputs, 6 optional, 22 validations
├── main.tf # the keystone `this` + derived locals
├── outputs.tf # 21 outputs; id first; none sensitive
├── README.md # this file
├── SCOPE.md # the cross-module contract
├── LICENSE # MIT
└── .gitignore # the canonical library ignore set
provider "azurerm" {
features {}
}
module "legacy_exemption" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-policy-exemption.git?ref=v1.0.0"
name = "exempt-legacy-storage"
resource_group_id = module.rg.id
policy_assignment_id = module.require_https.id
exemption_category = "Mitigated"
expires_on = "2027-01-01T00:00:00Z"
description = "Compensating control: fronted by Application Gateway with TLS termination. Ticket OPS-4471."
}The caller configures the provider, its authentication and the mandatory features {} block. The module declares none of them.
Consumes
| Input | Type | Source |
|---|---|---|
resource_group_id |
string |
terraform-azurerm-resource-group → id |
policy_assignment_id |
string |
terraform-azurerm-policy-assignment → id |
policy_definition_reference_ids |
list(string) |
terraform-azurerm-policy-set-definition → policy_definition_reference_ids |
Emits
| Output | Description |
|---|---|
id |
The exemption's Resource ID |
never_expires, has_expiry |
Whether the suppression is time-boxed |
is_waiver, is_mitigated |
Which claim the exemption makes |
is_undocumented_permanent_waiver |
The combination worth blocking |
exempts_whole_assignment, exempted_reference_count |
How wide the exemption is within the assignment |
subscription_id, resource_group_name |
Parsed scope facts for a report |
| 3 constant facts | Assignment changes replace at this scope; an exemption suppresses rather than fixes; the scope covers the whole group |
1 · The smallest real call
module "exemption" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-policy-exemption.git?ref=v1.0.0"
name = "exempt-sandbox"
resource_group_id = var.resource_group_id
policy_assignment_id = var.policy_assignment_id
exemption_category = "Waiver"
}
⚠️ This is legal and it is the shape to be suspicious of: a waiver, with no expiry and no justification.is_undocumented_permanent_waiverreportstruefor exactly this call.
2 · Mitigated, with the compensating control recorded
exemption_category = "Mitigated"
description = "Encrypted at rest with a customer-managed key held in the platform vault. Approved in RISK-214."ℹ️
Mitigatedasserts that the policy's intent is met another way.Waiveraccepts the non-compliance. They are not interchangeable in an audit, so choose the one that is actually true.
3 · Time-boxing the exemption
expires_on = "2027-01-01T00:00:00Z"⏳ Write it in UTC, ending in
Z. An equivalent value with an offset —2027-01-01T01:00:00+01:00— is accepted by Azure, stored as the same instant, and read back as2027-01-01T00:00:00Z, which never matches what you wrote. That is a difference on every plan that no apply settles, so this module rejects the offset form.💡 The module cannot tell you whether a date has already passed: Terraform has no stable notion of "now" that would not itself cause a perpetual diff. It reports only whether a date was set.
4 · Narrowing to named policies inside an initiative
policy_definition_reference_ids = [
"requireHttpsOnStorage",
"requireTls12",
]🎯 Without this, the exemption covers the whole assignment. Where that assignment assigns an initiative, exempting the whole thing because one of its policies is inconvenient suppresses every other check it performs.
⚠️ These are the reference IDs used inside the initiative, not policy definition Resource IDs — the module rejects anything starting with/.
5 · Wiring the references from the initiative module
policy_definition_reference_ids = [
var.security_baseline_reference_ids[0],
]💡 Wiring beats typing: a renamed reference then shows up as a plan diff instead of an exemption that silently stops applying to the policy you meant.
6 · Recording approval metadata
metadata = jsonencode({
requestedBy = "team-payments"
approvedBy = "security-review-board"
reviewOn = "2026-12-01"
ticket = "RISK-214"
})ℹ️ Use
jsonencode({...})rather than hand-written JSON, so a malformed document fails at plan. The module also rejects a JSON array or bare scalar — Azure stores this as a property bag.
⚠️ metadataisOptional + Computed, so omitting it does not mean the exemption has none; Azure populates its own.
7 · Blocking undocumented permanent waivers
check "exemption_is_reviewable" {
assert {
condition = !module.exemption.is_undocumented_permanent_waiver
error_message = "A Waiver with no expiry and no description is not reviewable. Add an expiry date, a justification, or both."
}
}🔎 Each of the three conditions is individually unremarkable, which is why the module emits them combined: a reviewer scanning a plan will not assemble them.
8 · Requiring every exemption to be time-boxed
check "exemptions_expire" {
assert {
condition = module.exemption.has_expiry
error_message = "Policy exemptions must carry an expiry date under this organisation's standard."
}
}💡
has_expiryandnever_expiresare the same fact in both directions, emitted separately so an assertion can read as a positive rather than a negation. Double negatives are where security reviews go wrong.
9 · Several exemptions with `for_each`
locals {
exemptions = {
"exempt-https" = { assignment = var.https_assignment_id, category = "Mitigated" }
"exempt-tags" = { assignment = var.tags_assignment_id, category = "Waiver" }
}
}
module "exemptions" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-policy-exemption.git?ref=v1.0.0"
for_each = local.exemptions
name = each.key
resource_group_id = var.resource_group_id
policy_assignment_id = each.value.assignment
exemption_category = each.value.category
expires_on = "2027-01-01T00:00:00Z"
}ℹ️
for_eachis safe here: the exemption's Resource ID ends in the name you chose, so several coexist against different assignments.
10 · Correcting a miscategorised exemption
# Was "Waiver", corrected to:
exemption_category = "Mitigated"✅
exemption_categoryis the one required field that is not force-new, so this is an in-place update. Changingname,resource_group_idorpolicy_assignment_idreplaces the exemption instead.
11 · Re-pointing at a different assignment
policy_assignment_id = var.new_assignment_id
⚠️ This replaces the exemption at this scope. It is force-new here, at resource scope, and at subscription scope — but not at management-group scope, where the same field updates in place. Three of Azure's four exemption scopes replace and one does not.💡 The replacement is the safer behaviour: it is visible in the plan.
12 · Choosing the right scope
# This module: every resource in the group, including ones added later.
resource_group_id = var.resource_group_id
# Narrower — a different resource type, a different module:
# terraform-azurerm-resource-policy-exemption (exactly one resource)
# Wider — also different resource types:
# terraform-azurerm-subscription-policy-exemption
# terraform-azurerm-management-group-policy-exemption🛑 A resource-group exemption covers resources created later, which nobody exempted deliberately. Where one resource is the problem, use the resource-scoped module.
⚠️ The four scopes are four separate Azure resource types. Moving an exemption between scopes is a destroy and create, not an edit.
13 · Names the module rejects
# All REJECTED:
name = "exempt/legacy" # "/" is in Azure's forbidden set
name = "exempt-legacy." # may not end with a period
name = "exempt-legacy " # may not end with a space🚧 Azure publishes these rules for
policyExemptions; the provider checks only that the name is non-empty, so without this the failure arrives from the API instead of the plan.
14 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-legacy-payments"
location = "eastus2"
}
module "require_https" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-policy-definition.git?ref=v1.0.0"
name = "require-https-storage"
display_name = "Storage accounts must require HTTPS"
}
module "assignment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-policy-assignment.git?ref=v1.0.0"
name = "require-https-on-payments"
resource_group_id = module.rg.id
policy_definition_id = module.require_https.id
}
module "exemption" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group-policy-exemption.git?ref=v1.0.0"
name = "exempt-legacy-payments"
resource_group_id = module.rg.id
policy_assignment_id = module.assignment.id
exemption_category = "Mitigated"
expires_on = "2027-01-01T00:00:00Z"
description = "Legacy gateway terminates TLS upstream; migration tracked in OPS-4471."
metadata = jsonencode({
approvedBy = "security-review-board"
reviewOn = "2026-12-01"
})
}
check "exemption_is_reviewable" {
assert {
condition = !module.exemption.is_undocumented_permanent_waiver
error_message = "This exemption is a permanent, undocumented waiver."
}
}
output "exemption_expires" {
value = module.exemption.expires_on
}🏗️ Read the order: a definition is written, assigned to the resource group, and then exempted at that same scope. The exemption is meaningless without the assignment, and Terraform enforces that ordering through the
module.assignment.idreference — one of the few real dependencies in this family.💡 The
checkblock sits beside the module deliberately. The exemption's value to a future reader is entirely in its metadata, and a check is the only thing that keeps that metadata from being optional in practice as well as in the schema.
| Name | Type | Required | Summary |
|---|---|---|---|
name |
string |
yes | 1–64 chars, Azure's published rule. Force-new. |
resource_group_id |
string |
yes | The exempted group's Resource ID. Force-new. |
policy_assignment_id |
string |
yes | The assignment being exempted. Force-new at this scope. |
exemption_category |
string |
yes | Mitigated or Waiver. Updates in place. |
expires_on |
string |
no | RFC 3339 UTC, ending in Z. Null means never. |
policy_definition_reference_ids |
list(string) |
no | Narrow to named policies inside an initiative. |
display_name |
string |
no | 1–128 characters. |
description |
string |
no | 1–512 characters. |
metadata |
string |
no | JSON object. Optional + Computed. |
timeouts |
object |
no | create / read / update / delete. |
| Output | Description |
|---|---|
id, name |
The exemption. |
resource_group_id, subscription_id, resource_group_name |
The scope, raw and parsed. |
policy_assignment_id, exemption_category, expires_on |
The configuration, as stored. |
never_expires, has_expiry |
Whether the suppression is time-boxed, both directions. |
exempts_whole_assignment, exempted_reference_count, policy_definition_reference_ids |
How wide it is within the assignment. |
is_waiver, is_mitigated |
Which claim is being made. |
is_undocumented_permanent_waiver |
Waiver + no expiry + no description. |
has_description, has_metadata |
Whether a justification was recorded. |
assignment_change_forces_replacement_at_this_scope |
Constant true. |
exemption_suppresses_evaluation_it_does_not_fix_anything |
Constant true. |
scope_covers_every_resource_in_the_group |
Constant true. |
No output is sensitive. This resource involves no secret in either direction.
An exemption suppresses evaluation; it does not fix, remediate or grant anything. The resources under this scope stop being assessed against the assignment, so they leave the compliance report rather than appearing in it and passing. Everything else about the module follows from that: the category, the description and the expiry are not decoration but the only record of why a control was switched off, and the only material a later reader has to judge whether it should still be.
The force-new behaviour of policy_assignment_id is not uniform across Azure's four exemption scopes. Resource, resource-group and subscription exemptions replace when it changes; the management-group exemption updates in place. Three of four one way, one the other, with nothing in the schema to show it — the fact lives only in each resource's ForceNew flag in the provider source. This module states which behaviour applies here rather than leaving a reader to generalise from a sibling, because generalising is exactly what goes wrong.
Azure publishes naming rules the provider does not enforce. policyExemptions is documented as 1–64 characters, no #<>%&:\?/ or control characters, no trailing period or space; the provider checks only non-emptiness. Enforcing the published rule here is not inventing a constraint — it is deferring to the authority the provider skipped — and it moves the failure from an API error to a plan error.
expires_on is a round-trip trap. Azure stores an instant and the provider's read formats it back as RFC 3339 in UTC. A caller writing an equivalent value with an offset gets it accepted, stored, and read back in Z form, which never matches the configuration — a difference on every plan that applying never settles. The module rejects the offset form for that reason rather than on stylistic grounds.
What the module deliberately does not do is judge the date. Terraform has no stable notion of "now": any comparison against the current time would produce a value that changes between plan and apply and cause perpetual churn. So the module reports whether an expiry was set and leaves whether it has passed to something that can actually see a clock.
The governance question is sharper than the RBAC one. Writing an exemption at this scope switches off a control for an entire resource group, and the built-in role that permits it travels with ordinary policy-management duties. Separating who may assign policy from who may exempt from it is a decision an organisation has to make deliberately; nothing in Azure or in this module makes it for them.
| Concern | This module's position | Why |
|---|---|---|
| Expiry | Optional, matching the provider — but reported loudly | A date cannot be invented on a caller's behalf. never_expires and is_undocumented_permanent_waiver make the omission visible instead. |
expires_on format |
UTC Z required |
The offset form round-trips to Z and diffs forever. Mechanical, not stylistic. |
| Exemption width | Whole-assignment allowed, but reported | exempts_whole_assignment flags the case where an entire initiative is suppressed. |
name |
Azure's published rule enforced | The provider checks only non-emptiness; the rule is published, so this defers rather than invents. |
| Reference IDs | Resource IDs rejected | They are reference names used inside an initiative; a Resource ID here is the probable mistake. |
metadata |
Must be a JSON object | Azure stores a property bag; an array or scalar would be accepted by jsondecode but is wrong here. |
| Scope choice | Narrower siblings named in the errors | The four scopes are four resource types and cannot be swapped by changing a variable. |
| Secrets | None accepted, none emitted | Nothing about this resource is a credential. |
terraform init -backend=false
terraform validate
terraform fmt -checkPin the module with ?ref=v1.0.0, never a branch. This library is plan-only: a human applies from CI.
What the offline gate covers:
terraform validateproves the configuration parses and every type is satisfied.terraform fmt -checkproves the HCL is canonically formatted.- Feeding deliberately bad
.tfvarsthroughterraform consolefires the input validations — all 22 validation blocks in this module have been proven reachable, each by a fixture that triggers it, with zero condition-evaluation errors. - All 11 derived locals have been driven to more than one value across the good fixtures, including
is_undocumented_permanent_waiverin both states.
What only a real plan or apply can exercise:
- Whether the resource group and the policy assignment exist.
- Whether the assignment actually sits at or above this scope — Azure rejects it otherwise, and nothing offline can tell.
- Whether the named policy definition references exist inside the assigned initiative.
- Whether the expiry date has already passed.
id = "/subscriptions/.../resourceGroups/rg-legacy-payments/providers/Microsoft.Authorization/policyExemptions/exempt-legacy-payments"
name = "exempt-legacy-payments"
resource_group_name = "rg-legacy-payments"
policy_assignment_id = "/subscriptions/.../providers/Microsoft.Authorization/policyAssignments/require-https-on-payments"
exemption_category = "Mitigated"
expires_on = "2027-01-01T00:00:00Z"
never_expires = false
has_expiry = true
exempts_whole_assignment = true
exempted_reference_count = 0
is_waiver = false
is_mitigated = true
is_undocumented_permanent_waiver = false
has_description = true
has_metadata = true
| Symptom | Cause | Fix |
|---|---|---|
expires_on differs on every plan and never settles |
It was written with an offset; Azure reads it back in UTC Z form |
Write it in UTC ending in Z — this module now rejects the offset form |
| The API rejected the name after a clean plan | The provider validates only non-emptiness | Azure's published rule is 1–64 chars, no #<>%&:\?/, no trailing period or space; this module now enforces it |
| Re-pointing at another assignment replaced the exemption | policy_assignment_id is force-new at this scope |
Expected. It is not force-new at management-group scope, which is where the assumption usually comes from |
| A resource added to the group later is unexpectedly exempt | A resource-group exemption covers everything in the group, including later additions | Use terraform-azurerm-resource-policy-exemption for a single resource |
| An initiative's other policies stopped being evaluated | The exemption covers the whole assignment | Set policy_definition_reference_ids to name only the policies you meant |
| A reference ID was rejected | A policy definition Resource ID was passed instead of an initiative reference name | Use the short reference name; wire it from the initiative module's policy_definition_reference_ids |
| Azure rejected the assignment ID | The assignment does not sit at or above this exemption's scope | An exemption can only exempt an assignment that reaches it |
| The exemption applies but compliance still shows failures | Evaluation is not instant, and the exemption does not fix anything | Wait for the next evaluation cycle; the resources leave the report rather than passing |
azurerm_resource_group_policy_exemption— provider resource reference.- Azure Policy exemption structure — what the categories and expiry mean.
- Naming rules and restrictions for Azure resources — the published
policyExemptionsrule. - Sibling modules:
terraform-azurerm-resource-policy-exemption,terraform-azurerm-subscription-policy-exemption,terraform-azurerm-management-group-policy-exemption,terraform-azurerm-policy-assignment. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."