Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Subscription Policy Exemption Terraform Module

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. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources


🧩 Overview

  • 🪪 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_on as 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_categoryMitigated (a compensating control exists) or Waiver (the risk is accepted) — because the two say different things to an auditor.
  • 🔁 policy_assignment_id is 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 description and the approval trail in metadata.

💡 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.

❤️ Support this project

If this module saves you time, please consider supporting its continued development:


🗺️ Where this fits in the family

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;
Loading

🧬 What this module builds

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;
Loading

Resource inventory

Resource Count Role
azurerm_subscription_policy_exemption.this 1 The keystone exemption, with its timeouts block.

✅ Provider / Versions

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_id is a Resource ID (/subscriptions/<guid>), not a bare GUID.
  • name, subscription_id, and policy_assignment_id are all force-new.
  • policy_assignment_id being 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_on is 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_on must 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_ids is 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.
  • metadata is optional-and-computed, so a plan may show a diff on a field you did not set.
  • This resource type has no tags surface. Governance metadata goes in metadata.

🔑 Required Azure RBAC Roles / Permissions

  • Resource Policy Contributor at the target subscription, which covers Microsoft.Authorization/policyExemptions/*.
  • Additionally, Microsoft.Authorization/policyAssignments/exempt/action on 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/action permission is needed on that assignment, at its own scope — not merely on this subscription.

Azure Prerequisites

  • 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 description and metadata.
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription.

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

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

📚 Example Library

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 from terraform-azurerm-subscription's subscription_resource_id output — not its id, which is the alias Resource ID — or from data.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."

⚠️ Mitigated asserts that the policy's intent is met another way. If no compensating control exists, the honest value is Waiver. 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 Waiver with 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/action is 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" })
}

⚠️ A null expires_on means forever, and a permanent exemption tends to outlive both the reason for it and the person who added it. The module emits expires_on as an output so this shows up in review. If you take this path, put the compensating review process in metadata.

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, so metadata is where requester, approver, and review-date information lives. Use jsonencode({...}) 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 plan
Error: 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 value
Error: 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 null in expires_on or references marks 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 create only 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.


📥 Inputs

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
}

🧾 Outputs

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.

🧠 Architecture Notes

  • 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_on is 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 a null as 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_id is 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/action permission 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_ids from the initiative's output map so a renamed reference surfaces as a diff rather than an exemption that silently stops applying.
  • metadata diffs you did not cause. The field is optional-and-computed, so the service may write entries into it.
  • features {} dependence. The module carries no provider {} block. If it appears not to initialize in isolation, the cause is a missing caller-side provider "azurerm" { features {} }.
  • No tags. This resource type has no taggable surface, so the universal tail carries timeouts only, and governance fields live in metadata.

🧱 Design Principles

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)

🚀 Runbook

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 / apply from CI against real credentials.

🧪 Testing

  • terraform validate proves the configuration is type-correct against the pinned provider schema. It does not evaluate this module's validation {} blocks — those are conditions on variables, which validate never resolves.
  • terraform plan with real values is what exercises the module's nineteen checks, offline and without credentials: the four name rules (length, illegal characters, no trailing period or space, not a pasted Resource ID); the exemption_category enum; the UTC Z requirement on expires_on; the three policy_definition_reference_ids rules (non-blank, no duplicates, not a policy definition Resource ID); the two metadata rules (valid JSON, decoding to an object); the display_name and description length bounds; and the three policy_assignment_id rules — the marker present, present exactly once, and no surrounding whitespace.
  • The policy_assignment_id pattern is deliberately not anchored at the start, and must stay that way: the provider treats everything before the policyAssignments marker 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 -check enforces 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 holds policyAssignments/exempt/action are apply-time facts.

💬 Example Output

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",
]

🔍 Troubleshooting

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.

🔗 Related Docs


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