Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Resource Group Policy Exemption Terraform Module

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.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • 🛑 Exempts every resource in a resource group from one policy assignment.
  • 🏷️ Records the exemption as Mitigated or Waiver — 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 policyExemptions naming 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.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

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
Loading

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.


🧬 What this module builds

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
Loading

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.

✅ Provider / Versions

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_id is 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 the ForceNew flag in each resource's Go source does.
  • 🔴 name, the scope ID and policy_assignment_id are force-new; exemption_category is not. A miscategorised exemption can be corrected without replacement.
  • ⚠️ The provider validates name only as non-empty. Azure's published rule for policyExemptions is 1–64 characters, no #<>%&:\?/ or control characters, and no trailing period or space. This module enforces the published rule.
  • ⚠️ expires_on is stored as an instant and read back as UTC. A value written with an offset is accepted, then read back in Z form, producing a difference on every plan. This module requires the Z form.
  • ⚠️ metadata is Optional + Computed. Omitting it does not mean empty — Azure populates its own.
  • ⚠️ display_name is capped at 128 characters and description at 512, via StringLenBetween.
  • ✅ Create and Update are the same function upstream, gated by d.IsNewResource() — so an update is a full CreateOrUpdate, not a patch.
  • No tags. Policy exemptions do not support them.

🔑 Required Azure RBAC Roles / Permissions

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.


Azure Prerequisites

  • Microsoft.Authorization is 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.

📁 Module Structure

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

⚙️ Quick Start

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.


🔌 Cross-Module Contract

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

📚 Example Library

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_waiver reports true for 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."

ℹ️ Mitigated asserts that the policy's intent is met another way. Waiver accepts 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 as 2027-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.

⚠️ metadata is Optional + 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_expiry and never_expires are 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_each is 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_category is the one required field that is not force-new, so this is an in-place update. Changing name, resource_group_id or policy_assignment_id replaces 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.id reference — one of the few real dependencies in this family.

💡 The check block 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.


📥 Inputs

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.

🧾 Outputs

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.


🧠 Architecture Notes

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.


🧱 Design Principles

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.

🚀 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: a human applies from CI.


🧪 Testing

What the offline gate covers:

  • terraform validate proves the configuration parses and every type is satisfied.
  • terraform fmt -check proves the HCL is canonically formatted.
  • Feeding deliberately bad .tfvars through terraform console fires 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_waiver in 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.

💬 Example Output

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

🔍 Troubleshooting

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

🔗 Related Docs


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