Exempts a subscription, or named policies within an assigned initiative, from a policy assignment (
azurerm_subscription_policy_exemption) — narrowly, with a justification and an end date. Targetshashicorp/azurerm ~> 4.0.
- 🪪 Creates one policy exemption at subscription scope as a keystone resource named
this. - 🎯 Narrows to named policies within an initiative via
policy_definition_reference_ids, rather than exempting an assignment wholesale. - ⏳ Validates
expires_onas a real RFC 3339 timestamp at plan time, and emits it as an output so an exemption with no end date is visible. - 📋 Requires an explicit
exemption_category—Mitigated(a compensating control exists) orWaiver(the risk is accepted) — because the two say different things to an auditor. - 🔁
policy_assignment_idis force-new here, unlike the management-group-scoped exemption where re-pointing is an in-place update — so a re-point shows plainly in the plan. - 📝 Carries the justification in
descriptionand the approval trail inmetadata.
💡 Why it matters: This is the one module in the library whose job is to suppress a control, so its design goal is not a hardened default but a legible one. A permanent, undocumented, whole-initiative exemption is the failure mode — every input here exists to make the narrow, expiring, justified version the easy one to write.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart LR
sub["terraform-azurerm-subscription"]
psd["terraform-azurerm-policy-set-definition"]
mgpa["terraform-azurerm-management-group-policy-assignment"]
pa["terraform-azurerm-subscription-policy-assignment"]
rem["terraform-azurerm-subscription-policy-remediation"]
this["terraform-azurerm-subscription-policy-exemption"]
ex["azurerm_subscription_policy_exemption"]
sub -->|"subscription_id"| this
psd -->|"policy_definition_reference_ids"| this
psd -->|"initiative id to assign"| pa
pa -->|"policy_assignment_id"| this
mgpa -->|"inherited assignment above this scope"| this
pa -->|"policy_assignment_id"| rem
this -->|"creates"| ex
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this me;
class ex keystone;
class sub,psd,mgpa,pa,rem sib;
flowchart TB
ident["name / display_name / description"]
cat["exemption_category: Mitigated or Waiver"]
exp["expires_on: RFC 3339, null means never"]
meta["metadata: jsonencode requester and approver"]
refs["policy_definition_reference_ids"]
paid["policy_assignment_id: force-new at this scope"]
this["terraform-azurerm-subscription-policy-exemption"]
ex["azurerm_subscription_policy_exemption.this"]
pa["policy assignment being exempted"]
scope["every resource group and resource in the subscription"]
out["outputs: id, name, exemption_category, expires_on"]
ident -->|"identity and justification"| this
cat -->|"required, validated enum"| this
exp -->|"validated RFC 3339"| this
meta -->|"governance metadata"| this
refs -->|"narrow to named policies"| this
paid -->|"re-pointing replaces the exemption"| this
this -->|"creates"| ex
ex -->|"suppresses"| pa
ex -->|"applies at"| scope
ex -->|"emits"| out
classDef me fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this me;
class ex keystone;
class ident,cat,exp,meta,refs,paid,pa,scope,out sib;
Resource inventory
| Resource | Count | Role |
|---|---|---|
azurerm_subscription_policy_exemption.this |
1 | The keystone exemption, with its timeouts block. |
| Requirement | 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 (verified against the live provider schema):
subscription_idis a Resource ID (/subscriptions/<guid>), not a bare GUID.name,subscription_id, andpolicy_assignment_idare all force-new.policy_assignment_idbeing force-new is the opposite of the management-group-scoped exemption resource, where re-pointing the assignment is an in-place update. Here a replacement shows plainly in the plan — the safer of the two behaviours — but it also means re-pointing briefly removes the exemption.expires_onis optional. Omitted, the exemption never expires — this module validates the format at plan and emits the value as an output so a permanent exemption is visible in review.expires_onmust be RFC 3339. The provider reports a malformed value at apply; this module rejects it at plan.- Exempting an assignment as a whole suppresses every policy in it. Where the assignment assigns an initiative, naming
policy_definition_reference_idsis almost always the correct narrower choice. - An exemption affects the one assignment it names. Because assignments stack across scopes, exempting a subscription from a subscription-scoped assignment does nothing about a management-group-scoped assignment of the same policy — that needs its own exemption at that scope.
- Compliance reporting lags an exemption; some views do not clear historical records immediately.
metadatais optional-and-computed, so a plan may show a diff on a field you did not set.- This resource type has no
tagssurface. Governance metadata goes inmetadata.
Resource Policy Contributorat the target subscription, which coversMicrosoft.Authorization/policyExemptions/*.- Additionally,
Microsoft.Authorization/policyAssignments/exempt/actionon the assignment being exempted. This is a distinct, deliberately separate permission — Azure treats "may create exemptions" as a stronger grant than "may create policy resources", precisely because an exemption removes a control. - When exempting an assignment that lives at a management group above this subscription, the
exempt/actionpermission is needed on that assignment, at its own scope — not merely on this subscription.
- An existing subscription, and its Resource ID (
/subscriptions/<guid>) — not a bare GUID. - An existing policy assignment at or above this subscription to exempt.
- When naming individual policies, the reference IDs of the assigned initiative — read them from the initiative rather than transcribing them.
- An agreed justification and, in most governance processes, an approval record to put in
descriptionandmetadata. - The caller configures the
provider "azurerm" { features {} }block, auth, and subscription.
terraform-azurerm-subscription-policy-exemption/
├── providers.tf # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf # deeply-typed schemas, validated enum + RFC 3339 check, timeouts tail
├── main.tf # keystone azurerm_subscription_policy_exemption.this; dynamic timeouts
├── outputs.tf # id, name, scope, assignment, category, expires_on, reference ids
├── README.md # this document
├── SCOPE.md # cross-module contract
├── LICENSE # MIT
└── .gitignore # canonical library ignore set
provider "azurerm" {
features {}
}
module "exemption" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-subscription-policy-exemption.git?ref=v1.0.0"
name = "legacy-estate-diagnostics-waiver"
subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000000"
policy_assignment_id = var.baseline_assignment_id
exemption_category = "Mitigated"
expires_on = "2027-01-01T00:00:00Z"
display_name = "Legacy estate — diagnostics waiver"
description = "Diagnostics are collected by the legacy agent estate; migration tracked in GOV-1421."
metadata = jsonencode({
requester = "platform-operations"
approver = "risk-committee"
reviewDate = "2026-10-01"
ticket = "GOV-1421"
})
}ℹ️ The caller owns the provider, its authentication, and the mandatory
features {}block. This module never declares them.
Consumes
| Input | Type | Source module |
|---|---|---|
subscription_id |
string |
terraform-azurerm-subscription (subscription_resource_id) or caller (a /subscriptions/<guid> Resource ID) |
policy_assignment_id |
string |
terraform-azurerm-subscription-policy-assignment (id) or terraform-azurerm-management-group-policy-assignment (id) |
policy_definition_reference_ids |
list(string) |
an initiative module's policy_definition_reference_ids output |
metadata |
string (JSON) |
caller |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Exemption Resource ID (first) | compliance reporting, audit inventories |
name |
Exemption name | review tooling |
subscription_id |
The scope the exemption applies at | composition wiring |
policy_assignment_id |
The assignment being exempted | audit trace back to the control |
exemption_category |
Mitigated or Waiver |
compliance reporting |
expires_on |
Expiry timestamp, or null for never |
review tooling — a null here is the finding |
policy_definition_reference_ids |
The individual references exempted, or null for the whole assignment |
audit scoping |
1 · Minimal exemption with an end date
module "exemption" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-subscription-policy-exemption.git?ref=v1.0.0"
name = "migration-window"
subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000000"
policy_assignment_id = var.assignment_id
exemption_category = "Waiver"
expires_on = "2026-12-31T23:59:59Z"
}🔒 Even the smallest call names a category and an end date. Neither is defaulted, because neither can be guessed correctly on your behalf.
2 · The `subscription_id` form that catches people out
subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000000" # ✅ a Resource ID
# subscription_id = "00000000-0000-0000-0000-000000000000" # ❌ a bare GUID
⚠️ Wire it fromterraform-azurerm-subscription'ssubscription_resource_idoutput — not itsid, which is the alias Resource ID — or fromdata.azurerm_subscription.current.id. Do not compose the string by hand.
3 · `Mitigated` — a compensating control exists
exemption_category = "Mitigated"
description = "Disk encryption is provided by the guest OS with a customer-managed key; the policy checks only the Azure-managed path."
⚠️ Mitigatedasserts that the policy's intent is met another way. If no compensating control exists, the honest value isWaiver. An auditor reads this field.
4 · `Waiver` — the non-compliance is accepted
exemption_category = "Waiver"
description = "Non-compliance accepted for the decommissioning estate; no remediation planned. Risk accepted by the risk committee, GOV-1508."
metadata = jsonencode({ approver = "risk-committee", ticket = "GOV-1508" })💡 A
Waiverwith a named approver and a ticket is defensible. One with neither is a finding waiting to happen.
5 · Exempting named policies inside an initiative
policy_definition_reference_ids = [
module.baseline_initiative.policy_definition_reference_ids["require-diagnostics"],
]🔒 This is the narrow choice. Omitting the field exempts the assignment as a whole, suppressing every other policy the initiative checks.
6 · Exempting an assignment inherited from a management group
# The obligation was created by an assignment at a management group above this subscription.
policy_assignment_id = var.central_baseline_assignment_id
subscription_id = data.azurerm_subscription.current.id
⚠️ Assignments stack across scopes, so the exemption must target the assignment that actually created the obligation — not a same-named subscription-scoped one. Note the RBAC consequence:policyAssignments/exempt/actionis needed on that management-group assignment, at its own scope.
7 · Re-pointing an exemption replaces it here
# Was: policy_assignment_id = var.old_assignment_id
policy_assignment_id = var.new_assignment_id# azurerm_subscription_policy_exemption.this must be replaced
-/+ resource "azurerm_subscription_policy_exemption" "this" {
~ policy_assignment_id = "..." -> "..." # forces replacement
}
⚠️ Force-new at this scope, unlike the management-group-scoped exemption where the same change is an in-place update. The visible replacement is the safer behaviour — but the exemption is briefly absent during the apply, so the control applies again in that window.
8 · A never-expiring exemption, deliberately
module "permanent_exemption" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-subscription-policy-exemption.git?ref=v1.0.0"
name = "sandbox-permanent-waiver"
subscription_id = var.sandbox_subscription_id
policy_assignment_id = var.assignment_id
exemption_category = "Waiver"
expires_on = null # never expires — see below
description = "Sandbox subscription is explicitly out of scope for production controls; reviewed annually under GOV-0900."
metadata = jsonencode({ approver = "risk-committee", annualReview = true, ticket = "GOV-0900" })
}
⚠️ Anullexpires_onmeans forever, and a permanent exemption tends to outlive both the reason for it and the person who added it. The module emitsexpires_onas an output so this shows up in review. If you take this path, put the compensating review process inmetadata.
9 · Tying the end date to remediation work
expires_on = "2026-09-30T00:00:00Z" # the migration's committed completion date
description = "Exempt until the storage estate finishes migrating to private endpoints; target 2026-09-30, tracked in NET-2210."💡 An exemption whose end date matches the delivery date of the fix expires itself. That is the version that does not need a cleanup project later.
10 · Carrying the approval trail in metadata
metadata = jsonencode({
requester = "app-team-payments"
approver = "security-review-board"
approvedOn = "2026-07-15"
reviewDate = "2026-10-15"
ticket = "SEC-3391"
riskAccepted = "medium"
})ℹ️ This resource type has no
tags, sometadatais where requester, approver, and review-date information lives. Usejsonencode({...})so a malformed document fails at plan.
11 · Rejecting a malformed date at plan time
expires_on = "2027-01-01" # ❌ not RFC 3339 — fails at planError: Invalid value for variable
expires_on must be an RFC 3339 timestamp, e.g. "2027-01-01T00:00:00Z".
💡 The provider would accept this string and fail at apply. The module checks it with
can(timeadd(var.expires_on, "0s")), which only succeeds on a valid RFC 3339 timestamp.
12 · Rejecting an invalid category at plan time
exemption_category = "Exempt" # ❌ not a legal valueError: Invalid value for variable
exemption_category must be one of: Mitigated, Waiver.
ℹ️ The closed set is enforced by a
validation {}block, so a typo never reaches the ARM API.
13 · Several scoped exemptions from one keyed map
locals {
waivers = {
diagnostics = {
reference = "require-diagnostics"
category = "Mitigated"
expires = "2027-01-01T00:00:00Z"
reason = "Collected by the legacy agent estate; migration tracked in GOV-1421."
}
tagging = {
reference = "require-cost-center-tag"
category = "Waiver"
expires = "2026-10-01T00:00:00Z"
reason = "Cost centre held in a separate inventory for the decommissioning estate."
}
}
}
module "waivers" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-subscription-policy-exemption.git?ref=v1.0.0"
for_each = local.waivers
name = "baseline-${each.key}-waiver"
subscription_id = data.azurerm_subscription.current.id
policy_assignment_id = module.baseline_assignment.id
exemption_category = each.value.category
expires_on = each.value.expires
description = each.value.reason
policy_definition_reference_ids = [
module.baseline_initiative.policy_definition_reference_ids[each.value.reference],
]
}💡 One narrow exemption per policy, each with its own category and end date, beats one broad exemption covering everything.
14 · Reviewing the estate's exemptions from outputs
output "exemption_review" {
description = "One row per exemption, for a governance review. A null expiry is the row to look at first."
value = {
for k, m in module.waivers : k => {
id = m.id
category = m.exemption_category
expires_on = m.expires_on
assignment = m.policy_assignment_id
references = m.policy_definition_reference_ids
}
}
}💡 Every field a review needs is an output precisely so this table can be built without querying Azure. A
nullinexpires_onorreferencesmarks the broad, permanent cases.
15 · Custom timeouts
timeouts = {
create = "30m"
read = "5m"
update = "30m"
delete = "30m"
}ℹ️ The defaults are fine for a normal exemption. Raise
createonly if policy operations in your tenant are consistently slow.
16 · 🏗️ End-to-end composition
provider "azurerm" {
features {}
}
data "azurerm_subscription" "current" {}
# 1 · A custom definition, authored centrally at a management group.
module "diagnostics_policy" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-policy-definition.git?ref=v1.0.0"
name = "require-diagnostic-settings"
display_name = "Require diagnostic settings"
policy_type = "Custom"
mode = "Indexed"
management_group_id = var.platform_management_group_id
}
# 2 · The initiative bundling it, with stable reference IDs.
module "baseline_initiative" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-management-group-policy-set-definition.git?ref=v1.0.0"
name = "platform-baseline"
management_group_id = var.platform_management_group_id
display_name = "Platform Baseline Controls"
policy_definition_references = {
require-diagnostics = { policy_definition_id = module.diagnostics_policy.id }
require-https-storage = { policy_definition_id = var.https_only_builtin_definition_id }
}
}
# 3 · The assignment at this subscription — what creates the obligation.
module "baseline_assignment" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-subscription-policy-assignment.git?ref=v1.0.0"
name = "platform-baseline"
subscription_id = data.azurerm_subscription.current.id
policy_definition_id = module.baseline_initiative.id
}
# 4 · A narrow, time-boxed exemption for one policy — this module.
module "diagnostics_waiver" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-subscription-policy-exemption.git?ref=v1.0.0"
name = "legacy-diagnostics-waiver"
subscription_id = data.azurerm_subscription.current.id
policy_assignment_id = module.baseline_assignment.id
exemption_category = "Mitigated"
expires_on = "2027-01-01T00:00:00Z"
display_name = "Legacy estate — diagnostics waiver"
description = "Diagnostics are collected by the legacy agent estate; migration to platform diagnostics tracked in GOV-1421."
metadata = jsonencode({
requester = "platform-operations"
approver = "risk-committee"
ticket = "GOV-1421"
reviewDate = "2026-10-01"
})
# Exempt only the diagnostics policy. require-https-storage stays enforced.
policy_definition_reference_ids = [
module.baseline_initiative.policy_definition_reference_ids["require-diagnostics"],
]
}
# 5 · Remediate the policy that is still enforced, at a bounded pace.
module "https_remediation" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-subscription-policy-remediation.git?ref=v1.0.0"
name = "remediate-https-2026-08"
subscription_id = data.azurerm_subscription.current.id
policy_assignment_id = module.baseline_assignment.id
policy_definition_reference_id = module.baseline_initiative.policy_definition_reference_ids["require-https-storage"]
resource_count = 50
parallel_deployments = 5
failure_percentage = 0.1
location_filters = ["eastus2"]
}💡 This wiring shows the intended dependency order: definition → initiative (both centrally at a management group) → assignment at the subscription → exemption, with a remediation acting on what remains enforced. Note how the exemption and the remediation reference the same initiative's reference-ID map, which is what keeps the two consistent. Output names on sibling modules are illustrative; match them to the versions you pin.
Required: name, subscription_id (a Resource ID), policy_assignment_id, exemption_category.
Expiry (validated, emitted): expires_on.
Narrowing: policy_definition_reference_ids.
Documentation: display_name, description, metadata.
Universal tail: timeouts. This resource type does not support tags.
Full object() schemas
variable "name" { type = string } # force-new
variable "subscription_id" { type = string } # "/subscriptions/<guid>" Resource ID; force-new
variable "policy_assignment_id" { type = string } # force-new at this scope (updatable at MG scope)
variable "exemption_category" {
type = string # Mitigated | Waiver — validated, no default
}
variable "expires_on" {
type = string # RFC 3339, e.g. "2027-01-01T00:00:00Z"; null = never expires
default = null
# validation: can(timeadd(var.expires_on, "0s"))
}
variable "policy_definition_reference_ids" {
type = list(string) # null exempts the whole assignment
default = null
}
variable "display_name" { type = string, default = null }
variable "description" { type = string, default = null }
variable "metadata" { type = string, default = null } # JSON; optional-and-computed at the provider
variable "timeouts" {
type = object({ create = optional(string), read = optional(string), update = optional(string), delete = optional(string) })
default = null
}| Output | Description | Kind |
|---|---|---|
id |
The Azure Resource ID of the policy exemption | Passthrough |
name |
The name of the policy exemption | Passthrough |
subscription_id |
The subscription the exemption applies at | Passthrough |
policy_assignment_id |
The policy assignment being exempted | Passthrough |
exemption_category |
Whether the exemption claims a compensating control (Mitigated) or accepts the non-compliance (Waiver) | Passthrough |
expires_on |
When the exemption stops applying, or null if it never expires | Passthrough |
policy_definition_reference_ids |
The individual policy definition references exempted within an initiative, or null when the whole assignment is exempted | Passthrough |
never_expires |
True when this exemption has no expiry date - which the provider permits, since expires_on is optional |
Derived |
is_a_waiver_rather_than_a_mitigation |
True when the exemption is categorised as a Waiver rather than Mitigated | Derived |
exempts_the_whole_assignment_unless_reference_ids_are_given |
True when no policy_definition_reference_ids are supplied, in which case the exemption covers the ENTIRE policy assignment |
Passthrough |
is_mitigated / is_waiver |
Which claim the exemption makes. A Mitigated exemption asserts a compensating control exists; a Waiver sets the requirement aside |
Derived |
has_expiry / has_description / has_metadata |
Whether each governance field was supplied | Derived |
exempted_reference_count |
How many individual references are exempted. Zero means the whole assignment | Derived |
is_undocumented_permanent_waiver |
The one to assert on in a review. True when the exemption is a Waiver, never expires, and carries no justification — an unbounded, unexplained acceptance of non-compliance that Azure will never raise | Derived |
scope_covers_every_resource_in_the_subscription |
Always true. Every resource group and every resource, including ones created long after the exemption was written |
Constant |
exemption_suppresses_evaluation_it_does_not_fix_anything |
Always true. The condition the policy was written to catch is still there; only the reporting has stopped |
Constant |
assignment_change_forces_replacement_at_this_scope |
Always true here, and not on the management-group sibling — policy_assignment_id is force-new on three of the four exemption resources and not on that one |
Constant |
No secret is accepted or emitted. An exemption carries scope, justification, and metadata only.
- This module suppresses a control. Every other module in the library gets a hardened default; here there is no hardened default to give — an exemption either exists or it does not. So the design goal is legibility: a required category, a format-checked end date, a documented narrowing path, and outputs that make the broad or permanent cases visible without querying Azure.
expires_onis the field that matters. Omitted, the exemption never expires, and a permanent exemption outlives its justification. The provider makes it optional and so does this module — a date cannot be invented for you — but it is validated for format and emitted as an output. Treat anullas a decision to defend in review, not a default.- The date check is a type check in disguise.
can(timeadd(var.expires_on, "0s"))succeeds only for an RFC 3339 timestamp, which turns an apply-time API rejection into a plan-time error without hand-parsing the string. - Force-new
policy_assignment_idis a scope-specific behaviour, and the better one. At this scope, re-pointing an exemption at a different assignment replaces the resource, so the change is unmissable in a plan. At management-group scope the same edit is an in-place update that reads as a modest diff. The trade-off here is a brief window during apply where the exemption is absent and the control applies again. - Exemptions are per-assignment, and assignments stack. Exempting this subscription from a subscription-scoped assignment does nothing about a management-group-scoped assignment of the same policy. When a control keeps applying after an exemption, the usual cause is that a different scope created the obligation — and the
exempt/actionpermission is then needed at that scope too. - Narrowing beats breadth. Exempting an assignment as a whole suppresses every policy in an initiative. Wire
policy_definition_reference_idsfrom the initiative's output map so a renamed reference surfaces as a diff rather than an exemption that silently stops applying. metadatadiffs you did not cause. The field is optional-and-computed, so the service may write entries into it.features {}dependence. The module carries noprovider {}block. If it appears not to initialize in isolation, the cause is a missing caller-sideprovider "azurerm" { features {} }.- No tags. This resource type has no taggable surface, so the universal tail carries
timeoutsonly, and governance fields live inmetadata.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Exemption breadth | steered to named policy_definition_reference_ids |
omit them to exempt the whole assignment |
| Expiry | expires_on validated and emitted as an output |
pass null for an exemption that never expires |
| Justification claim | exemption_category required, no default |
— (no default; Mitigated and Waiver are not interchangeable) |
| Scope | required input, documented as subscription-wide | — (prefer a narrower-scope sibling module) |
| Audit trail | description + metadata steered in the schema |
omit them |
| Date correctness | RFC 3339 enforced at plan | — (no opt-out) |
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the module with
?ref=v1.0.0— never a branch. - This library is plan-only during authoring; a human runs
terraform plan/applyfrom CI against real credentials.
terraform validateproves the configuration is type-correct against the pinned provider schema. It does not evaluate this module'svalidation {}blocks — those are conditions on variables, whichvalidatenever resolves.terraform planwith real values is what exercises the module's nineteen checks, offline and without credentials: the fournamerules (length, illegal characters, no trailing period or space, not a pasted Resource ID); theexemption_categoryenum; the UTCZrequirement onexpires_on; the threepolicy_definition_reference_idsrules (non-blank, no duplicates, not a policy definition Resource ID); the twometadatarules (valid JSON, decoding to an object); thedisplay_nameanddescriptionlength bounds; and the threepolicy_assignment_idrules — the marker present, present exactly once, and no surrounding whitespace.- The
policy_assignment_idpattern is deliberately not anchored at the start, and must stay that way: the provider treats everything before thepolicyAssignmentsmarker as the assignment's scope, which is legitimately a subscription, a resource group, a management group, or any resource. Anchoring it would refuse legal management-group-scoped assignment IDs — the normal case for an exemption. terraform fmt -checkenforces canonical formatting.- Neither command calls Azure. Only
terraform plan(run by a human, from CI) exercises the ARM API — the module ships without any cloud apply. Whether the referenced assignment exists, whether it sits at or above this scope, and whether the identity holdspolicyAssignments/exempt/actionare apply-time facts.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/policyExemptions/legacy-diagnostics-waiver"
name = "legacy-diagnostics-waiver"
subscription_id = "/subscriptions/00000000-0000-0000-0000-000000000000"
policy_assignment_id = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/policyAssignments/platform-baseline"
exemption_category = "Mitigated"
expires_on = "2027-01-01T00:00:00Z"
policy_definition_reference_ids = [
"require-diagnostics",
]
| Symptom | Cause | Fix |
|---|---|---|
Provider configuration not present / features error |
No caller-side provider "azurerm" { features {} }. |
Add the provider block with features {} in the root module. |
| Apply fails: invalid scope | subscription_id was passed as a bare GUID. |
Use the /subscriptions/<guid> Resource ID form. |
Plan error: exemption_category must be one of |
A value outside Mitigated / Waiver. |
Pick the one that is actually true — Mitigated claims a compensating control, Waiver accepts the risk. |
Plan error: expires_on must be an RFC 3339 timestamp |
A date-only or otherwise malformed string. | Use the full form, e.g. "2027-01-01T00:00:00Z". |
| Apply fails: authorization failed on the assignment | The identity has Resource Policy Contributor but not Microsoft.Authorization/policyAssignments/exempt/action. |
Grant the exempt/action permission on the assignment — at that assignment's own scope if it lives at a management group. |
| Apply fails: policy assignment not found | policy_assignment_id points at an assignment that does not exist, or one below this scope. |
Wire the ID from the assignment module's output; the assignment must be at or above the exemption's scope. |
| The control still applies after the exemption | A different scope created the obligation — assignments stack. | Find the assigning scope and create an exemption targeting that assignment. |
| Exemption applied but resources still show non-compliant | Compliance reporting lags an exemption; some views do not clear historical records immediately. | Wait for the next evaluation cycle before concluding the exemption did not take. |
| More policies stopped being enforced than expected | policy_definition_reference_ids was omitted, so the whole assignment is exempt. |
Name the specific references you intend to exempt. |
| An exemption silently stopped applying | Its reference ID changed — usually a renamed key in the initiative. | Wire from the initiative's policy_definition_reference_ids output instead of hard-coding strings. |
| Plan shows a replacement after re-pointing the assignment | policy_assignment_id is force-new at this scope. |
Expected. Note the exemption is briefly absent during the apply. |
- azurerm provider —
azurerm_subscription_policy_exemption - Azure Policy exemption structure
- Azure Policy compliance data
- Scope in Azure Policy
- Sibling modules:
terraform-azurerm-subscription-policy-assignment,terraform-azurerm-subscription-policy-remediation,terraform-azurerm-management-group-policy-exemption,terraform-azurerm-management-group-policy-assignment,terraform-azurerm-policy-set-definition,terraform-azurerm-management-group-policy-set-definition. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."