Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Backup Resource Guard Terraform Module

Manages one azurerm_data_protection_resource_guard — the object that puts a Backup vault's destructive operations under multi-user authorisation. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture

🧩 Overview

  • 🎯 Creates one Backup resource guard.
  • 🛡️ Requires a second party to approve destructive vault operations — disabling soft delete, reducing retention, deleting backup data.
  • 🔻 Its one interesting argument is an exclusion list: every entry removes protection. The default is an empty list, which protects everything.
  • 🧊 Three force-new fields, and two of them are invisible to a grep of the provider's resource file.
  • 🏷️ Carries real Azure tags — unlike most of the Data Factory resources alongside it in this library.
  • ⏱️ timeouts.update is accepted and ignored; create bounds an update.

💡 Why it matters: a resource guard is an organisational control wearing a technical shape. It only means anything when the person who can approve a destructive operation is not the person requesting it — which means putting the guard in a different subscription, under different administrators, from the vaults it protects. Azure does not require that, and nothing here enforces it. Two failure modes are entirely silent: the guard protects nothing until a vault references it, and an entry in the exclusion list is a hole in the control, not an addition to it.

❤️ Support this project

If this module saves you time:

🗺️ Where this fits in the family

flowchart TB
  RG["azurerm_resource_group, ideally a DIFFERENT subscription"]
  GUARD["data_protection_resource_guard"]
  VAULT["azurerm_data_protection_backup_vault"]
  OPS["critical operations: disable soft delete, reduce retention, delete backup data"]
  EXCL["vault_critical_operation_exclusion_list REMOVES protection"]

  RG -->|"resource_group_name and location, both FORCE-NEW via commonschema"| GUARD
  VAULT -.->|"the VAULT references the guard by id, not the other way round"| GUARD
  GUARD -->|"requires a second approver for"| OPS
  EXCL -.->|"each entry is a HOLE in that protection"| GUARD

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef key fill:#004578,stroke:#002b47,color:#ffffff
  classDef ext fill:#F0F3F6,stroke:#9AA5B1,color:#1F2933
  class GUARD me
  class VAULT key
  class RG,OPS,EXCL ext
Loading

Note the direction: the vault references the guard, not the other way round. Nothing here records which vaults use it, or whether any do.

🧬 What this module builds

flowchart TB
  VN["name, resource_group_name, location -- THREE force-new, two hidden by commonschema"]
  VE["vault_critical_operation_exclusion_list -- an EXCLUSION list"]
  VT["tags -- this resource really has them"]
  R["azurerm_data_protection_resource_guard.this"]
  EMPTY["EMPTY list is the strongest posture"]
  OID["id, name, location"]
  OFLAG["protects_every_critical_operation, excluded_operation_count"]
  OWARN["this_guard_protects_nothing_until_a_vault_references_it"]

  VN --> R
  VE --> R
  VT --> R
  VE -->|"default"| EMPTY
  R --> OID
  R --> OFLAG
  R --> OWARN

  classDef me fill:#0078D4,stroke:#004578,color:#ffffff
  classDef key fill:#004578,stroke:#002b47,color:#ffffff
  classDef ext fill:#F0F3F6,stroke:#9AA5B1,color:#1F2933
  class R key
  class EMPTY me
  class VN,VE,VT,OID,OFLAG,OWARN ext
Loading
Resource Count Role
azurerm_data_protection_resource_guard.this 1 The keystone. No child resources.

✅ Provider / Versions

Item Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures the provider, its authentication, and the mandatory features {} block
Module type standalone

Schema notes that bite — each verified against the provider source at the pinned line:

  • 🔴 timeouts.update is accepted and ignored. The timeouts block declares all four keys, but the create-and-update function asks for ForCreate — so create bounds an update.
  • 🔴 Two of the three force-new fields are invisible to a grep. The resource file has exactly one ForceNew: true; resource_group_name and location get theirs from shared commonschema helpers.
  • location is normalised — "East US" and "eastus" never diff against each other.
  • The name validator checks two things only: non-empty, and at most 260 characters. No pattern, no character set.
  • Nothing validates the operation names in the exclusion list beyond non-emptiness — a misspelling excludes nothing, silently, in the safe direction.
  • This resource genuinely has tags.
  • No CustomizeDiff, no version gate.

🔑 Required Azure RBAC Roles / Permissions

Role Scope Why
Contributor (or a custom role with Microsoft.DataProtection/resourceGuards/*) the resource group Create, read, update and delete the guard.
Backup MUA Admin (or equivalent) the guard Held by whoever APPROVES guarded operations.
Backup MUA Operator (or equivalent) the guard Held by the vault operator, so they can REQUEST one. Without it they cannot perform critical operations at all.

🔴 Granting the approver role on the guard to the same identity that administers the vault defeats the control entirely. The apply succeeds, the guard exists, every audit view shows multi-user authorisation enabled, and one person can still do everything. Nothing in Terraform or in Azure warns about this — it is the single most important thing to get right about this resource, and it is not expressible in configuration.

Azure Prerequisites

  • The Microsoft.DataProtection resource provider registered in the subscription holding the guard.
  • An existing resource group.
  • A Backup vault that references this guard, created separately. The guard is inert without one.
  • Separate administrators. Not a technical prerequisite, but without it the guard provides very little.

📁 Module Structure

terraform-azurerm-data-protection-resource-guard/
├── providers.tf    # required_version + the pinned azurerm; no provider block
├── variables.tf    # 6 typed inputs, 8 validations, the universal tags tail
├── main.tf         # locals + the single keystone resource
├── outputs.tf      # 22 outputs; id first, then name, then the derived facts
├── README.md       # this file
├── SCOPE.md        # the cross-module contract
├── LICENSE         # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "guard" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  name                = "rg-backup-guard"
  resource_group_name = "rg-backup-guard-eastus"
  location            = "eastus"

  # No exclusions: every critical operation stays protected.
}

🛡️ The empty call is the strongest posture. Leaving vault_critical_operation_exclusion_list unset protects every critical operation — protects_every_critical_operation reports true at plan.

⚠️ The guard does nothing until a Backup vault references its id.

🔌 Cross-Module Contract

Consumes

Input Type Source
resource_group_name string terraform-azurerm-resource-group → name
location string caller — need not match the vaults'

Emits

Output Note
id What a Backup vault references
protects_every_critical_operation The posture, at plan
excluded_operations Every deliberate hole
the_timeout_that_actually_bounds_an_update "create"
force_new_fields Three — two hidden by shared helpers

📚 Example Library

1 · Minimal — protect everything
module "guard" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  name                = "rg-backup-guard"
  resource_group_name = "rg-backup-guard-eastus"
  location            = "eastus"
}

🛡️ The default is maximum protection. Every argument that could weaken it is optional and absent.

2 · The guard in a different subscription — the point of the whole thing
# The guard is created by a DIFFERENT provider alias, pointed at a
# subscription the vault operators do not administer. That separation is what
# makes multi-user authorisation meaningful; putting the guard beside the
# vault is legal, applies cleanly, and protects very little.

provider "azurerm" {
  alias           = "security"
  subscription_id = var.security_subscription_id
  features {}
}

module "guard" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  providers = {
    azurerm = azurerm.security
  }

  name                = "rg-backup-guard"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"

  tags = {
    purpose = "backup-mua"
    owner   = "security"
  }
}

🔴 This is the design, not an optimisation. Azure does not require the separation and nothing in this module can check it — the_guard_should_live_where_the_vault_administrators_do_not states it as a constant because it is the difference between a real control and a checkbox.

ℹ️ Subscription selection is provider configuration, never a module variable — per this suite's convention.

3 · Excluding an operation — and what that means
module "guard_with_hole" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  name                = "rg-backup-guard"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"

  # EVERY ENTRY HERE REMOVES PROTECTION FROM THAT OPERATION.
  vault_critical_operation_exclusion_list = [
    "Microsoft.RecoveryServices/vaults/backupconfig/write",
  ]
}

🔴 Read the direction carefully. This is an exclusion list — the named operation is no longer guarded. It is easy to read as a list of operations to protect, which is exactly backwards, and the mistake applies cleanly with no error.

ℹ️ protects_every_critical_operation flips to false and excluded_operations names it, so a reviewer sees the hole at plan rather than in an incident.

4 · A misspelled operation excludes nothing
module "typo" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  name                = "rg-backup-guard"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"

  vault_critical_operation_exclusion_list = ["ThisIsNotARealOperation"]
}

⚠️ The provider checks only that each entry is a non-empty string. A misspelled operation name is accepted, excludes nothing, and leaves you believing an exception was made that was not. The failure is silent — but in the safe direction: more protection than intended, not less. It still matters when an expected exception does not take effect.

5 · Duplicates are refused
module "no_duplicates" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  name                = "rg-backup-guard"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"

  # This is REFUSED -- "OpA" and "opa" are the same exclusion.
  # vault_critical_operation_exclusion_list = ["OpA", "opa"]

  vault_critical_operation_exclusion_list = ["OpA"]
}

💡 A duplicate excludes nothing extra and reliably means a list was merged twice — one of the few things here worth refusing outright, because it cannot be intentional.

6 · Location casing does not matter
module "loose_location" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  name                = "rg-backup-guard"
  resource_group_name = "rg-security-eastus"
  location            = "East US" # same as "eastus" -- no diff between them
}

ℹ️ The provider normalises this value and suppresses casing and spacing differences, so this module does not enforce a spelling — doing so would refuse input the provider accepts. The location output returns the provider's canonical form, which may not match what you wrote.

7 · Tags — this resource really has them
module "tagged" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  name                = "rg-backup-guard"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"

  tags = {
    purpose     = "backup-mua"
    owner       = "security"
    cost-centre = "1234"
  }
}

🏷️ Real Azure tags, visible in cost reporting and governed by tagging policy — unlike the Data Factory annotations that appear on many neighbouring resources in this library, which are a different thing entirely.

8 · The update timeout that does nothing
module "patient" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  name                = "rg-backup-guard"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"

  timeouts = {
    create = "45m" # <-- this bounds an UPDATE as well
    read   = "10m"
    update = "45m" # <-- accepted, stored, never read
    delete = "45m"
  }
}

⚠️ The provider's create-and-update function asks for the create timeout rather than the create-or-update helper, so update is never consulted. Raise create if an update needs longer than the 30-minute default. the_timeout_that_actually_bounds_an_update emits "create" so a composition can assert on it.

ℹ️ The managed private endpoint module in this library shows the well-behaved contrast: that resource has no update function, so the provider declares no update timeout and Terraform rejects the key outright.

9 · Reviewing the posture before an apply
output "guard_review" {
  value = {
    protects_everything = module.guard.protects_every_critical_operation
    holes               = module.guard.excluded_operations
    hole_count          = module.guard.excluded_operation_count
    replace_on          = module.guard.force_new_fields
    update_bounded_by   = module.guard.the_timeout_that_actually_bounds_an_update
  }
}

💡 Every value is known at plan. protects_every_critical_operation is deliberately phrased positively — the underlying argument is an exclusion list, and double negatives are where security reviews go wrong.

10 · Asserting no exclusions, as your own policy
check "guard_has_no_holes" {
  assert {
    condition     = module.guard.protects_every_critical_operation
    error_message = "The resource guard excludes one or more critical operations from protection."
  }
}

💡 A policy many organisations will want and this module does not impose — an exclusion is a legitimate, deliberate decision, and refusing it in the module would block terraform destroy as well as apply.

11 · Why a replacement is dangerous here
# THREE fields are force-new: name, resource_group_name and location.
# Two of them do not appear as ForceNew in the provider's resource file at all
# -- they come from shared commonschema helpers -- so an audit that greps the
# resource will count one and miss two.
#
# Replacing the guard silently removes multi-user authorisation from every
# vault that referenced it, because nothing in Terraform records which vaults
# those are. That is a security control disappearing with no plan-time signal.

module "guard" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  name                = "rg-backup-guard"
  resource_group_name = "rg-security-eastus"
  location            = "eastus"
}

⚠️ force_new_fields emits all three, and two_of_the_force_new_fields_are_invisible_to_a_grep explains why a reader auditing the provider would disagree.

12 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

provider "azurerm" {
  alias           = "security"
  subscription_id = var.security_subscription_id
  features {}
}

module "security_resource_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  providers = {
    azurerm = azurerm.security
  }

  name     = "rg-security-eastus"
  location = "eastus"
}

module "guard" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-data-protection-resource-guard.git?ref=v1.0.0"

  providers = {
    azurerm = azurerm.security
  }

  name                = "rg-backup-guard"
  resource_group_name = module.security_resource_group.name
  location            = module.security_resource_group.location

  tags = {
    purpose = "backup-mua"
    owner   = "security"
  }
}

output "resource_guard_id" {
  value = module.guard.id
}

🔴 The guard lives in the security subscription; the vaults it protects do not. That separation is the control. A Backup vault in the workload subscription references module.guard.id to place its critical operations under multi-user authorisation — that association is made on the vault, and is deliberately not part of this module.

⚠️ Nothing here records which vaults use this guard. Destroying it, or changing any of its three force-new fields, silently removes the control from all of them.

📥 Inputs

Required: name, resource_group_name, location.

Optional: vault_critical_operation_exclusion_list, tags, timeouts.

Full input schemas
Name Type Default Notes
name string — Force-new. The provider checks only non-empty and ≤ 260 characters.
resource_group_name string — Force-new via commonschema — invisible to a grep of the resource.
location string — Force-new via commonschema. Normalised; casing and spacing never diff.
vault_critical_operation_exclusion_list list(string) [] An EXCLUSION list — each entry REMOVES protection. Empty is strongest. Duplicates refused.
tags map(string) {} Real Azure tags.
timeouts object({create, read, update, delete}) null update is accepted and ignored — create bounds an update.

🧾 Outputs

Output Type Notes
id string What a Backup vault references
name / resource_group_name / location string location is the provider's normalised form
protects_every_critical_operation bool The posture, stated positively
excluded_operation_count / excluded_operations number / list(string) Every deliberate hole
force_new_fields list(string) Three — two hidden by shared helpers
the_timeout_that_actually_bounds_an_update string "create"
tags map(string)
import_address string For terraform import
the CONSTANT outputs bool Facts that are consequential, invisible in state, and inferable from nothing else

Nothing is sensitive: this resource carries no credential.

🧠 Architecture Notes

The control is organisational and the resource is only its shape. A resource guard means something when the approver is not the requester. That requires putting it in a different subscription under different administrators — which Azure does not require and this module cannot check. Granting the approver role to the vault's own administrator produces a guard that exists, appears in every audit view as multi-user authorisation enabled, and stops nothing.

The one interesting argument runs backwards. vault_critical_operation_exclusion_list is an exclusion list: each entry removes protection. The module therefore emits protects_every_critical_operation — phrased positively, because this suite states negated security fields positively as a rule and double negatives are where security reviews go wrong — plus a constant output whose only job is to say the direction out loud.

The guard protects nothing until a vault references it. The association is made on the vault side, so nothing here records which vaults use this guard, or whether any do. That asymmetry also makes destruction dangerous: removing the guard, or changing any force-new field, silently strips multi-user authorisation from every vault that pointed at it, with no plan-time signal.

Two of the three force-new fields are invisible to a grep. The resource file contains exactly one ForceNew: true. resource_group_name and location are built from shared commonschema helpers, and it is the helpers that carry it. Anyone auditing this resource by searching it for ForceNew will count one field and miss two — and the same pattern applies across most of this provider, which is why the module names it rather than merely handling it.

timeouts.update is accepted and ignored. The block declares all four keys; the create-and-update function asks for ForCreate. So create bounds an update. This is the third distinct form of this defect found in this library, and the managed private endpoint module is the well-behaved contrast — no update function, no update timeout declared, key rejected outright.

features {} dependence. As with every module in this suite, the caller supplies provider "azurerm" { features {} } — and for the cross-subscription pattern, an aliased provider.

🧱 Design Principles

Concern Default in the empty call Opt-out
Protection scope Every critical operation protected — the exclusion list is empty add entries, each of which removes protection
Duplicated exclusions Refused, because a duplicate cannot be intentional none
Operation-name correctness Not checkable — a misspelling excludes nothing, silently, in the safe direction none
Name rules Only what the provider actually checks: non-empty, ≤ 260 characters none
Location spelling Not enforced — the provider normalises it none
Approver separation Cannot be expressed in configuration — stated as a constant instead none

Two rules govern the validations. Never invent a constraint that could reject legal input — a validation {} failure blocks terraform destroy as well as apply, which is why neither operation names nor location spellings are enforced. And enforce a configuration rule, report a service or organisational one — duplicates are refused; the posture, the inertness and the approver-separation question are reported.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the module with ?ref=v1.0.0 — never a branch. This module is plan-only; a human applies from CI.

🧪 Testing

Know which command reaches which check. terraform validate on a configuration that CALLS this module checks types and syntax only: it evaluates none of the module's variable values. The 8 validation {} blocks are reached at terraform plan, and none of them needs credentials, because variable validation runs before the provider is configured. To exercise them without a plan, run the module as the root module and drive it through terraform console -var-file=....

What no Terraform stage exercises: whether an operation name in the exclusion list is real, whether any Backup vault references this guard, and — most importantly — whether the approver and the requester are actually different people. None of those has a Terraform signal at any stage.

💬 Example Output

$ terraform output
id                                = "/subscriptions/.../resourceGroups/rg-security-eastus/providers/Microsoft.DataProtection/resourceGuards/rg-backup-guard"
name                              = "rg-backup-guard"
resource_group_name               = "rg-security-eastus"
location                          = "eastus"
protects_every_critical_operation = true
excluded_operation_count          = 0
excluded_operations               = []
force_new_fields                  = ["name", "resource_group_name", "location"]
the_timeout_that_actually_bounds_an_update = "create"
tags                              = { "owner" = "security", "purpose" = "backup-mua" }

🔍 Troubleshooting

Symptom Cause Fix
The guard exists but destructive vault operations still happen unchallenged No vault references it, or the approver role was granted to the same identity that administers the vault. Reference module.guard.id from the vault, and grant the approver role to a different identity.
An expected exception did not take effect The operation name in the exclusion list is misspelled. The provider checks only non-emptiness. Correct the name. excluded_operations shows exactly what was sent.
vault_critical_operation_exclusion_list must not contain duplicate entries. The same operation appears twice, possibly differing only in case. Remove the duplicate. It excluded nothing extra.
Raising timeouts.update did not help a slow update The provider's create-and-update function asks for the create timeout. Raise create. the_timeout_that_actually_bounds_an_update emits the key.
A plan shows a replacement after changing the location location is force-new — via a shared helper, so it does not appear as ForceNew in the provider's resource file. Expected. Note that replacement removes the control from every vault using it.
An audit of the provider found only one force-new field resource_group_name and location get ForceNew from commonschema helpers. Trust force_new_fields, which lists all three.
The location output does not match what was written The provider normalises and diff-suppresses casing and spacing. Expected; "East US" and "eastus" are the same value.

🔗 Related Docs

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