Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Alert Processing Rule β€” Action Group Terraform Module

Adds action groups to alerts that already exist, matched by scope and condition. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Posture


🧩 Overview

  • βš™οΈ Creates one azurerm_monitor_alert_processing_rule_action_group, named this.
  • πŸ”΄ This rule acts on alerts it does not own β€” produced by other alert rules, possibly created long after this rule exists, which record nothing about it.
  • 🧬 The provider's eleven condition blocks are supplied as one map and rendered here, because eleven near-identical hand-written blocks is where a typo hides.
  • ⚠️ The operators are not uniform: seven fields take four operators, four take two. Enforced per field.
  • ⚠️ Four fields have closed value sets, including nineteen monitor_service names β€” several containing spaces.
  • ℹ️ An empty conditions map means every alert in scope, and this module says so rather than leaving it implicit.
  • βœ… Its sibling is schema-identical apart from one argument β€” see terraform-azurerm-monitor-alert-processing-rule-suppression.

πŸ’‘ Why it matters: scopes is not a snapshot β€” it is the rule's reach for as long as the rule exists. A subscription-scoped rule with no conditions picks up every future resource automatically, which for an action-group rule is often exactly right and should still be a decision rather than an accident.


❀️ Support this project

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


πŸ—ΊοΈ Where this fits in the family

This diagram is shared with terraform-azurerm-monitor-alert-processing-rule-suppression, because the two resources are schema-identical apart from one argument. Both modules embed the same validated diagram; the node text carries the difference β€” this module adds notifications, the sibling removes them.

flowchart TB
  RG["terraform-azurerm-resource-group -- holds the RULE record only. Unrelated to what the rule acts on"]
  SCOPES["scopes -- a SUBSCRIPTION, a resource group or a single resource. The breadth is the whole story, and it is updatable in place"]
  AGR["terraform-azurerm-monitor-alert-processing-rule-action-group -- ADDS action groups to matching alerts"]
  SUP["terraform-azurerm-monitor-alert-processing-rule-suppression -- SUPPRESSES notifications for matching alerts"]
  AG["terraform-azurerm-monitor-action-group -- consumed by ID, and ONLY by the action-group rule"]
  ALERTS["alerts raised by OTHER alert rules -- rules neither module references, which may be created long after the rule exists and record nothing about it"]
  PEOPLE["whoever the action groups notify"]

  RG -->|"name"| AGR
  RG -->|"name"| SUP
  SCOPES -->|"which alerts are in reach"| AGR
  SCOPES -->|"which alerts are in reach"| SUP
  AG -->|"id as add_action_group_ids"| AGR
  ALERTS -->|"intercepted by"| AGR
  ALERTS -->|"intercepted by"| SUP
  AGR -->|"adds notifications, so a mistake ANNOUNCES itself"| PEOPLE
  SUP -->|"removes notifications, so a mistake is SILENT"| PEOPLE

  style AGR fill:#0078D4,stroke:#004578,color:#ffffff
  style SUP fill:#0078D4,stroke:#004578,color:#ffffff
  style ALERTS fill:#004578,stroke:#00335c,color:#ffffff
  style RG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style SCOPES fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style AG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style PEOPLE fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Loading

🧬 What this module builds

flowchart TB
  IN_ID["name plus resource_group_name -- force-new. The resource group holds the RULE, not what it acts on"]
  IN_SCOPES["scopes -- REQUIRED and updatable in place. Breadth is parsed and reported: subscription, resource_group or resource"]
  IN_AG["add_action_group_ids -- REQUIRED, anchored to Microsoft.Insights/actionGroups. This is the ACTION, and it is ADDITIVE"]
  IN_COND["conditions -- ONE map keyed by field name, rendered into the provider's ELEVEN separate blocks. Keys checked CASE-SENSITIVELY because an unknown key would be silently dropped"]
  IN_SCHED["schedule -- effective window plus REPEATING daily, weekly and monthly recurrence. Two different time formats, and daily requires both times while the others do not"]
  IN_EN["enabled -- defaults true in the provider and mirrored explicitly here"]
  THIS["azurerm_monitor_alert_processing_rule_action_group.this"]
  ALERTS["alerts from OTHER alert rules, for as long as this rule exists -- including on resources that do not exist yet"]
  OUT_ID["id then name then enabled"]
  OUT_SCOPE["scope_breadth, subscription_wide_scopes, has_subscription_wide_scope, matches_every_alert_in_scope"]
  OUT_COND["active_condition_fields, condition_count, conditions_are_combined_with_and, condition_operators_are_not_uniform_across_fields"]
  OUT_SCHED["is_always_in_force, has_open_ended_effective_window, recurrence_window_count, uses_multiple_recurrence_kinds"]
  OUT_FLAG["this_rule_changes_other_peoples_alerts, the_two_alert_processing_rule_resources_are_schema_identical_except_for_the_action, accepts_no_credential"]

  IN_ID --> THIS
  IN_SCOPES --> THIS
  IN_AG --> THIS
  IN_COND --> THIS
  IN_SCHED --> THIS
  IN_EN --> THIS
  THIS -->|"intercepts and adds action groups to"| ALERTS
  THIS --> OUT_ID
  THIS --> OUT_SCOPE
  THIS --> OUT_COND
  THIS --> OUT_SCHED
  THIS --> OUT_FLAG

  style THIS fill:#0078D4,stroke:#004578,color:#ffffff
  style ALERTS fill:#004578,stroke:#00335c,color:#ffffff
  style IN_ID fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_SCOPES fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_AG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_COND fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_SCHED fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style IN_EN fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_ID fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_SCOPE fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_COND fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_SCHED fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
  style OUT_FLAG fill:#f2f4f7,stroke:#98a2b3,color:#1d2939
Loading

Resource inventory

Resource / block Count Notes
azurerm_monitor_alert_processing_rule_action_group.this 1 The keystone.
condition 0–1 Holds up to eleven single-instance sub-blocks, rendered from one map.
schedule 0–1 With a recurrence block whose three kinds are repeating.
The alerts it acts on many Owned by other alert rules. Not in this state.

βœ… Provider / Versions

Item Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None. The caller configures the provider, its authentication, and its mandatory features {} block.
Module type standalone
Keystone azurerm_monitor_alert_processing_rule_action_group.this
ARM type Microsoft.AlertsManagement/actionRules

Schema notes that bite

  • πŸ”΄ The ARM type is actionRules β€” the older name for what the portal and provider call alert processing rules. And it is Microsoft.AlertsManagement, while the action groups it adds live under Microsoft.Insights. Two different resource providers in one resource.
  • πŸ”΄ Eleven condition fields, and the operators differ by field. Seven accept Equals, NotEquals, Contains, DoesNotContain; four accept only Equals and NotEquals.
  • ⚠️ monitor_service has nineteen published values, several containing spaces β€” "ActivityLog Administrative" is one value, and "VM Insights - Health" keeps its spaces.
  • ⚠️ The provider documents an at-least-one-of rule across the eleven condition fields. This module satisfies it by omitting the condition block entirely when the map is empty.
  • ⚠️ daily requires both times; weekly and monthly do not. That asymmetry is the provider's and is mirrored exactly here.
  • ⚠️ Two time formats. effective_from/effective_until are YYYY-MM-DDTHH:MM:SS; recurrence times are HH:MM:SS.
  • ⚠️ time_zone takes WINDOWS zone names, published as a reference table rather than an enforceable set.
  • ℹ️ scopes is NOT force-new, so a rule's reach can be widened in place. Only name and resource_group_name are force-new.
  • ℹ️ enabled defaults to true. Mirrored explicitly in this module.

πŸ”‘ Required Azure RBAC Roles / Permissions

Principal Permission Scope Why
The Terraform identity Microsoft.AlertsManagement/actionRules/write The rule's resource group Create and update
The Terraform identity Microsoft.AlertsManagement/actionRules/read The rule Refresh and plan
The Terraform identity Microsoft.AlertsManagement/actionRules/delete The rule Destroy
The Terraform identity read on each scopes entry Those scopes Azure validates the rule's targets
The Terraform identity Microsoft.Insights/actionGroups/read Each action group To reference it

βœ… Plan access on this module is read-only in the useful sense. No computed secret-bearing attribute exists, and everything it handles is an identifier or a matching rule. Re-derived per resource, not inherited.

πŸ”΄ But the reach is larger than the permissions table suggests. Whoever can edit this configuration can change how alerts behave across an entire subscription, for teams who will never see it. actionRules/write in one resource group is enough to affect alerts everywhere named in scopes.


Azure Prerequisites

  • Microsoft.AlertsManagement registered on the subscription.
  • An existing resource group for the rule record.
  • At least one scope β€” a subscription, resource group or resource ID that already exists.
  • At least one existing Action Group, under Microsoft.Insights/actionGroups.
  • Alert rules that actually fire. This resource processes alerts; it does not create them, and it does nothing at all if nothing in scope raises an alert.

πŸ“ Module Structure

terraform-azurerm-monitor-alert-processing-rule-action-group/
β”œβ”€β”€ providers.tf     # required_version >= 1.12.0; azurerm ~> 4.0. No provider block.
β”œβ”€β”€ variables.tf     # 10 typed inputs, 25 validations.
β”œβ”€β”€ main.tf          # One keystone `this`; eleven condition blocks rendered from one map.
β”œβ”€β”€ outputs.tf       # id first, then scope breadth, the condition facts, and the schedule facts.
β”œβ”€β”€ README.md        # This document.
β”œβ”€β”€ SCOPE.md         # The cross-module contract.
β”œβ”€β”€ LICENSE          # MIT.
└── .gitignore       # The canonical library ignore file.

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "oncall_everything" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                = "apr-oncall-sev01"
  resource_group_name = module.monitor_rg.name

  scopes               = ["/subscriptions/${var.subscription_id}"]
  add_action_group_ids = [module.oncall_action_group.id]

  conditions = {
    severity = { operator = "Equals", values = ["Sev0", "Sev1"] }
  }
}

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
resource_group_name Resource group name terraform-azurerm-resource-group β†’ name
scopes Subscription / RG / resource IDs Whatever the rule should cover
add_action_group_ids Action Group Resource IDs terraform-azurerm-monitor-action-group β†’ id

Emits

Output Description Consumed by
id Rule Resource ID Audit
scope_breadth Per-scope breadth classification check blocks
has_subscription_wide_scope Reach check blocks
matches_every_alert_in_scope No conditions supplied check blocks
active_condition_fields Which of the eleven are in force Review
this_rule_changes_other_peoples_alerts Constant true Read this one

πŸ“š Example Library

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "daytime_team_action_group_id" {
  description = "id of an existing daytime team action group that these examples reference but do not create."
  type        = string
}

variable "dba_action_group_id" {
  description = "id of an existing dba action group that these examples reference but do not create."
  type        = string
}

variable "migration_team_action_group_id" {
  description = "id of an existing migration team action group that these examples reference but do not create."
  type        = string
}

variable "web_vm_id" {
  description = "id of an existing web vm that these examples reference but do not create."
  type        = string
}
1 Β· Page the on-call team for high-severity alerts
module "oncall_sev01" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                = "apr-oncall-sev01"
  resource_group_name = module.monitor_rg.name

  scopes               = ["/subscriptions/${var.subscription_id}"]
  add_action_group_ids = [module.oncall_action_group.id]

  conditions = {
    severity = { operator = "Equals", values = ["Sev0", "Sev1"] }
  }
}

βœ… This is the canonical use of an action-group rule: one place that says "whatever fires at Sev0 or Sev1 anywhere in this subscription, page the on-call team" β€” without editing every alert rule.

ℹ️ The action groups are ADDED, not substituted. Alerts keep whatever their own rules specify and gain these as well.

⚠️ Subscription scope is deliberate here and has_subscription_wide_scope will report true. For this intent that is correct; the output exists so it is a decision rather than a surprise.

2 Β· The eleven condition fields, from one map
module "targeted_rule" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                 = "apr-db-platform"
  resource_group_name  = module.monitor_rg.name
  scopes               = ["/subscriptions/${var.subscription_id}/resourceGroups/rg-data-prod"]
  add_action_group_ids = [var.dba_action_group_id]

  conditions = {
    severity          = { operator = "Equals", values = ["Sev0", "Sev1", "Sev2"] }
    monitor_condition = { operator = "Equals", values = ["Fired"] }
    monitor_service   = { operator = "Equals", values = ["Platform"] }
    alert_rule_name   = { operator = "Contains", values = ["sql", "database"] }
  }
}

🧬 The provider defines eleven separate single-instance blocks inside condition. This module takes one map keyed by field name and renders each β€” the eleven legal keys are alert_context, alert_rule_id, alert_rule_name, description, monitor_condition, monitor_service, severity, signal_type, target_resource, target_resource_group, target_resource_type.

πŸ”΄ The key check is case-sensitive and does not trim. This module routes each entry into the provider block of the same name, so "Severity" would match nothing and be silently dropped β€” the condition would not exist, with no error from Terraform or Azure. Rejecting the key at plan time is the only place that mistake can be caught.

⚠️ Conditions are ANDed; values within a field are ORed. The example above matches an alert that is Sev0–2 and firing and from Platform and whose rule name contains "sql" or "database".

3 Β· The operators are not uniform
module "mixed_operators" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                 = "apr-mixed"
  resource_group_name  = module.monitor_rg.name
  scopes               = ["/subscriptions/${var.subscription_id}"]
  add_action_group_ids = [module.oncall_action_group.id]

  conditions = {
    # Four operators available: free-form text and Resource IDs.
    alert_rule_name = { operator = "DoesNotContain", values = ["canary"] }
    target_resource = { operator = "Contains", values = ["/virtualMachines/"] }

    # Only Equals and NotEquals: closed value sets.
    severity    = { operator = "NotEquals", values = ["Sev4"] }
    signal_type = { operator = "Equals", values = ["Metric"] }
  }
}

⚠️ Seven fields accept Equals, NotEquals, Contains and DoesNotContain β€” alert_context, alert_rule_id, alert_rule_name, description, target_resource, target_resource_group, target_resource_type β€” because they hold free-form text or Resource IDs.

πŸ”΄ Four accept only Equals and NotEquals β€” monitor_condition, monitor_service, severity, signal_type β€” because each has a closed set of values and there is nothing to take a substring of. severity with Contains is rejected at plan time.

βœ… The split is enforced as two checks, one per operator class, rather than applying the wider set everywhere and letting Azure reject it later.

4 Β· The four closed value sets
module "closed_sets" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                 = "apr-closed-sets"
  resource_group_name  = module.monitor_rg.name
  scopes               = ["/subscriptions/${var.subscription_id}"]
  add_action_group_ids = [module.oncall_action_group.id]

  conditions = {
    severity          = { operator = "Equals", values = ["Sev0"] }
    signal_type       = { operator = "Equals", values = ["Metric", "Log"] }
    monitor_condition = { operator = "Equals", values = ["Fired"] }

    # Note the SPACES inside these values -- each is one value, not two.
    monitor_service = {
      operator = "Equals"
      values   = ["ActivityLog Administrative", "Application Insights", "VM Insights - Health"]
    }
  }
}

⚠️ severity is Sev0–Sev4 β€” not "0", not "Critical". Sev0 is the most severe.

⚠️ signal_type is Metric, Log, Unknown, Health; monitor_condition is Fired, Resolved. All capitalised exactly as shown; a wrong-case value is rejected by the same closed-set check.

πŸ”΄ monitor_service has nineteen published values and several contain spaces. "ActivityLog Administrative" is a single value, and "VM Insights - Health" keeps the spaces around its hyphen. Splitting one into two produces two invalid values.

ℹ️ The other seven fields take free-form values, so this module validates only their operator β€” it does not invent constraints on an alert rule's name or description.

5 Β· An unconditional rule, said out loud
module "everything_to_oncall" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                 = "apr-catch-all"
  resource_group_name  = module.monitor_rg.name
  scopes               = ["/subscriptions/${var.subscription_id}"]
  add_action_group_ids = [module.oncall_action_group.id]

  # No conditions: every alert in scope.
  conditions = {}
}

output "reach" {
  value = {
    everything = module.everything_to_oncall.matches_every_alert_in_scope # true
    sub_wide   = module.everything_to_oncall.has_subscription_wide_scope  # true
  }
}

ℹ️ An empty map means every alert in scope, and this module expresses that by omitting the provider's condition block entirely β€” which satisfies its documented at-least-one-of rule by omission rather than by violating it.

βœ… For an action-group rule this is a legitimate and common intent: add an on-call group to everything. It is reported rather than discouraged.

⚠️ But note what "everything in scope" includes: resources that do not exist yet. A subscription-scoped rule picks up future resources automatically, forever. Contrast the sibling suppression module, where the same configuration silences everything.

6 Β· Scope breadth, computed
module "mixed_scopes" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                = "apr-mixed-scopes"
  resource_group_name = module.monitor_rg.name

  scopes = [
    "/subscriptions/${var.subscription_id}",                                   # subscription
    "/subscriptions/${var.subscription_id}/resourceGroups/rg-data-prod",        # resource_group
    var.web_vm_id,                                                          # resource
  ]

  add_action_group_ids = [module.oncall_action_group.id]

  conditions = {
    severity = { operator = "Equals", values = ["Sev0"] }
  }
}

output "breadth" {
  value = module.mixed_scopes.scope_breadth
}

βœ… scope_breadth classifies each entry as subscription, resource_group or resource, derived from the ID's segment count. A plan shows only opaque ID strings; this makes the reach reviewable.

⚠️ Mixing breadths is legal and the widest one wins in practice β€” a subscription entry already covers the other two, so the narrower entries add nothing. subscription_wide_scopes names the culprit.

ℹ️ scopes is not force-new, so reach can be widened in place. Convenient, and also how a narrow rule quietly becomes a broad one across a few commits.

7 Β· A maintenance window
module "migration_window" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                 = "apr-migration-escalation"
  resource_group_name  = module.monitor_rg.name
  scopes               = ["/subscriptions/${var.subscription_id}/resourceGroups/rg-data-prod"]
  add_action_group_ids = [var.migration_team_action_group_id]

  schedule = {
    effective_from  = "2026-08-01T22:00:00"
    effective_until = "2026-08-02T06:00:00"
    time_zone       = "Eastern Standard Time"
  }
}

output "window" {
  value = {
    always     = module.migration_window.is_always_in_force            # false
    open_ended = module.migration_window.has_open_ended_effective_window # false
  }
}

⚠️ Both dates use the full YYYY-MM-DDTHH:MM:SS form, with no timezone suffix β€” the zone is the separate time_zone argument. A date alone is rejected.

πŸ”΄ time_zone takes a WINDOWS zone name such as "Eastern Standard Time", not "America/New_York". Microsoft publishes the values as a reference table rather than an enforceable set, so an IANA name passes plan and misbehaves later β€” the rule is then in force at the wrong hours.

βœ… Supplying effective_until is what makes this a window. Omit it and see example 8.

8 Β· The window that never ends
module "temporary_escalation" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                 = "apr-temporary"
  resource_group_name  = module.monitor_rg.name
  scopes               = ["/subscriptions/${var.subscription_id}"]
  add_action_group_ids = [module.oncall_action_group.id]

  schedule = {
    # A start with no end. This is permanent from 1 August onward.
    effective_from = "2026-08-01T00:00:00"
  }
}

check "windows_actually_close" {
  assert {
    condition     = module.temporary_escalation.has_open_ended_effective_window == false
    error_message = "This rule names a start date but no end date, so it is permanent."
  }
}

πŸ”΄ A configuration that reads as time-bounded and is not. It names a start date β€” exactly what a planned change looks like β€” and from that date onward it never expires. No later plan will show anything to remind anyone.

βœ… has_open_ended_effective_window is reported separately from is_always_in_force, because the two are identical in effect after the start date and look completely different in a configuration.

ℹ️ On this module the consequence is extra notifications. On the sibling suppression module the same shape means alerts silenced indefinitely β€” which is why that module leads with it.

9 Β· Recurrence, and the asymmetry in it
module "business_hours" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                 = "apr-business-hours"
  resource_group_name  = module.monitor_rg.name
  scopes               = ["/subscriptions/${var.subscription_id}"]
  add_action_group_ids = [var.daytime_team_action_group_id]

  schedule = {
    time_zone = "Pacific Standard Time"

    recurrence = {
      # daily REQUIRES both times.
      daily = [{ start_time = "08:00:00", end_time = "18:00:00" }]

      # weekly and monthly make both OPTIONAL, because the days already pin them.
      weekly  = [{ days_of_week = ["Saturday", "Sunday"] }]
      monthly = [{ days_of_month = [1, 15] }]
    }
  }
}

output "recurrence" {
  value = {
    windows     = module.business_hours.recurrence_window_count      # 3
    multi_kind  = module.business_hours.uses_multiple_recurrence_kinds # true
  }
}

⚠️ daily requires both start_time and end_time; weekly and monthly do not. That asymmetry is the provider's and is consistent once seen β€” a daily window has no other way to say when it applies, whereas a weekly or monthly one is already pinned by its days. This module's types mirror it exactly rather than smoothing it into a uniform shape that would then be wrong.

πŸ”΄ All three kinds are REPEATING and combine as a UNION, not an intersection. A daily window plus a weekly one does not mean "daily, but only at weekends" β€” it means "during either". So the total covered time is larger than either alone. uses_multiple_recurrence_kinds reports the combination.

ℹ️ Recurrence times are HH:MM:SS β€” a different format from effective_from's full timestamp. Both are validated separately, each naming its own format.

10 Β· Staging a rule before enabling it
module "staged_rule" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                 = "apr-new-escalation"
  resource_group_name  = module.monitor_rg.name
  scopes               = ["/subscriptions/${var.subscription_id}"]
  add_action_group_ids = [module.oncall_action_group.id]

  conditions = {
    severity = { operator = "Equals", values = ["Sev0"] }
  }

  # Create it, review the breadth outputs, then flip this in a second apply.
  enabled = false

  description = "Escalate Sev0 to on-call. Staged 2026-07-31, pending review of scope breadth."
}

βœ… enabled defaults to true in both the provider and this module, mirrored explicitly so the intent is visible in a configuration rather than implied by absence. So a rule is live on the apply that creates it unless you say otherwise.

ℹ️ Two-step introduction is the safer pattern: create with enabled = false, read scope_breadth and matches_every_alert_in_scope, then enable deliberately.

πŸ’¬ Write the description. An alert processing rule changes what happens to other people's alerts, and the next person wondering why an alert behaved oddly will find this rule before they find whoever created it.

11 Β· Choosing between this module and its sibling
# ADD notifications -- this module. A mistake announces itself: someone gets paged who should not have been.
module "escalate" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                 = "apr-escalate-sev0"
  resource_group_name  = module.monitor_rg.name
  scopes               = ["/subscriptions/${var.subscription_id}"]
  add_action_group_ids = [module.oncall_action_group.id]

  conditions = {
    severity = { operator = "Equals", values = ["Sev0"] }
  }
}

# REMOVE notifications -- the sibling. A mistake is SILENT: nobody is paged who should have been.
module "silence_during_migration" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-suppression.git?ref=v1.0.0"

  name                = "apr-migration-quiet"
  resource_group_name = module.monitor_rg.name
  scopes              = ["/subscriptions/${var.subscription_id}/resourceGroups/rg-data-prod"]

  conditions = {
    monitor_service = { operator = "Equals", values = ["Platform"] }
  }

  schedule = {
    effective_from  = "2026-08-01T22:00:00"
    effective_until = "2026-08-02T06:00:00"
  }
}

βœ… The two resources are schema-identical apart from one argument β€” this module's add_action_group_ids. Verified by diffing the schema dumps, which produced exactly one line of difference. Everything you learn about conditions or scheduling here transfers unchanged.

πŸ”΄ That identical shape is also a hazard. Copying a configuration between them is easy, and copying this module's broad scopes into a suppression rule turns "page the on-call team for everything" into "silence everything".

⚠️ Note the suppression rule above is narrowly scoped and time-bounded. That is the shape a suppression rule should have, and it is the opposite of what an action-group rule usually wants.

12 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

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

  name     = "rg-monitor-prod"
  location = "eastus2"

  tags = {
    environment = "prod"
    workload    = "observability"
  }
}

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

  name                = "ag-oncall-prod"
  resource_group_name = module.monitor_rg.name
  short_name          = "oncall"
}

module "escalate_high_severity" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

  name                = "apr-oncall-sev01"
  resource_group_name = module.monitor_rg.name

  # Subscription-wide on purpose: the point is to cover future resources too.
  scopes = ["/subscriptions/${var.subscription_id}"]

  add_action_group_ids = [module.oncall_action_group.id]

  conditions = {
    severity          = { operator = "Equals", values = ["Sev0", "Sev1"] }
    monitor_condition = { operator = "Equals", values = ["Fired"] }
  }

  # Out of hours only -- the daytime team handles the rest.
  schedule = {
    time_zone = "Eastern Standard Time"

    recurrence = {
      daily  = [{ start_time = "18:00:00", end_time = "08:00:00" }]
      weekly = [{ days_of_week = ["Saturday", "Sunday"] }]
    }
  }

  description = "Page on-call for Sev0/Sev1 firing alerts, out of hours and at weekends."

  tags = {
    environment = "prod"
  }
}

# The composition carries what no single argument check can.
output "alert_routing_posture" {
  value = {
    # Reach, in reviewable form.
    breadth      = module.escalate_high_severity.scope_breadth
    sub_wide     = module.escalate_high_severity.has_subscription_wide_scope
    unconditional = module.escalate_high_severity.matches_every_alert_in_scope

    # What it matches, and how the matching composes.
    fields    = module.escalate_high_severity.active_condition_fields
    anded     = module.escalate_high_severity.conditions_are_combined_with_and

    # When it applies.
    always     = module.escalate_high_severity.is_always_in_force
    open_ended = module.escalate_high_severity.has_open_ended_effective_window
    union      = module.escalate_high_severity.uses_multiple_recurrence_kinds

    # Whose alerts this touches.
    other_peoples = module.escalate_high_severity.this_rule_changes_other_peoples_alerts
  }
}

check "alert_processing_rule_is_understood" {
  assert {
    condition     = module.escalate_high_severity.has_open_ended_effective_window == false
    error_message = "The rule names a start date with no end, so it is permanent."
  }

  assert {
    condition     = module.escalate_high_severity.matches_every_alert_in_scope == false
    error_message = "The rule has no conditions, so it applies to every alert in scope."
  }
}

πŸ’‘ Order comes from references alone β€” the resource group's name and the action group's id. Nothing here needs depends_on.

βœ… The two check assertions are the point. Each asserts something the module reports but deliberately does not refuse: an unbounded effective window and an unconditional match are both legal and sometimes intended. The module makes them visible; the composition decides.

πŸ”΄ terraform destroy removes the rule and nothing else. The alerts it was acting on continue exactly as their own rules define β€” which is the clearest illustration that this resource owns none of them.


πŸ“₯ Inputs

Required (4) β€” name, resource_group_name, scopes, add_action_group_ids.

Matching (1) β€” conditions, a map keyed by field name. Empty means every alert in scope.

Timing (1) β€” schedule. Omitting it means always in force.

Metadata + tail (4) β€” description, enabled (default true), tags, timeouts.

Full input schemas
Name Type Default Required
name string β€” βœ…
resource_group_name string β€” βœ…
scopes list(string) β€” βœ…
add_action_group_ids list(string) β€” βœ…
conditions map(object({ operator, values })) {} β€”
schedule object β€” see below null β€”
description string null β€”
enabled bool true β€”
tags map(string) {} β€”
timeouts object({ create, read, update, delete }) null β€”
# Eleven legal keys. CASE-SENSITIVE -- an unknown key is rejected, because it would
# otherwise be routed nowhere and silently dropped.
conditions = {
  alert_context = {}, alert_rule_id = {}, alert_rule_name = {}, description = {},
  monitor_condition = {}, monitor_service = {}, severity = {}, signal_type = {},
  target_resource = {}, target_resource_group = {}, target_resource_type = {}
  # each value: { operator = string, values = list(string) }
}

# Operators by field:
#   Equals | NotEquals | Contains | DoesNotContain
#     alert_context, alert_rule_id, alert_rule_name, description,
#     target_resource, target_resource_group, target_resource_type
#   Equals | NotEquals ONLY
#     monitor_condition, monitor_service, severity, signal_type

schedule = {
  effective_from  = optional(string)  # "YYYY-MM-DDTHH:MM:SS"
  effective_until = optional(string)  # omit for a window that never closes
  time_zone       = optional(string)  # WINDOWS zone name; provider default "UTC"

  recurrence = optional(object({
    # All three REPEATING and independently optional. They UNION.
    daily   = optional(list(object({ start_time = string, end_time = string })), [])
    weekly  = optional(list(object({ days_of_week = list(string), start_time = optional(string), end_time = optional(string) })), [])
    monthly = optional(list(object({ days_of_month = list(number), start_time = optional(string), end_time = optional(string) })), [])
  }))
}

25 validations, all proven to fire by line number β€” and every one isolated by its own fixture, with no overlaps. Notably: the case-sensitive condition-key check, the two operator classes as separate checks, four closed value sets including the nineteen space-containing monitor_service names, both time formats, and the action group ID anchored to Microsoft.Insights/actionGroups.


🧾 Outputs

Output Description Notes
id / name / resource_group_name / enabled Identity id first
scopes / scope_breadth Reach, classified Read this
subscription_wide_scopes / resource_group_scopes / resource_scopes Per breadth
has_subscription_wide_scope Reach flag check blocks
add_action_group_ids / action_group_names The action Names parsed for readability
active_condition_fields / condition_count What it matches
matches_every_alert_in_scope No conditions check blocks
conditions_are_combined_with_and Constant true AND, not OR
eleven_condition_fields_are_rendered_from_one_map Constant true This module's shape
condition_operators_are_not_uniform_across_fields Constant true Two operator classes
four_condition_fields_have_closed_value_sets Constant true
is_always_in_force / effective_from / effective_until / time_zone Timing
has_open_ended_effective_window A start with no end check blocks
schedule_times_use_windows_time_zone_names Constant true
recurrence_window_count / has_recurrence / uses_multiple_recurrence_kinds Recurrence Union, not intersection
daily_windows_require_both_times_while_weekly_and_monthly_do_not Constant true The provider's asymmetry
two_time_formats_are_in_play Constant true
this_rule_changes_other_peoples_alerts Constant true Read this one
the_two_alert_processing_rule_resources_are_schema_identical_except_for_the_action Constant true Choosing a sibling
accepts_no_credential Constant true

🧠 Architecture Notes

Eleven near-identical blocks become one map. The provider defines eleven single-instance blocks inside condition, each with the same operator plus values pair. Writing eleven of those by hand is how a typo hides, so this module accepts one map keyed by field name and renders them all. The key check is case-sensitive and does not trim, deliberately: entries are routed into the provider block of the same name, so "Severity" would match nothing and be silently dropped β€” the condition would simply not exist, with no error from Terraform and none from Azure. Rejecting the key at plan time is the only place that mistake can be caught, and it is the same reasoning that made a day-key check case-sensitive elsewhere in this suite.

The operators are not uniform, so they are validated per field. Seven fields hold free-form text or Resource IDs and accept the substring operators; four have closed value sets and accept only Equals and NotEquals. Applying the wider set everywhere would have let Contains through on severity, which the provider documents as invalid β€” so the split is enforced as two checks, one per operator class. Four of the eleven also have closed value sets, and monitor_service's nineteen names are the ones that catch people: several contain spaces, so "ActivityLog Administrative" is one value rather than two.

Two levels of matching behave differently. Conditions are ANDed with each other; values within a single field are ORed. Getting that backwards produces a rule that matches far less than intended, and on this module the symptom is visible β€” someone does not get paged who expected to be. On the sibling suppression module the same error is invisible, which is why both modules state the asymmetry explicitly.

An empty condition map is expressed by omitting the block. The provider documents an at-least-one-of rule across the eleven fields; this module satisfies it by not emitting condition at all when the map is empty, rather than by violating it. That makes "every alert in scope" a legal, reachable state β€” appropriate for an action-group rule, and reported through matches_every_alert_in_scope so it is a decision rather than an accident.

Scope is not a snapshot. scopes is the rule's reach for as long as the rule exists, so a subscription-scoped entry covers resources that do not exist yet, created by teams who will never see this configuration. The module classifies each entry's breadth by segment count and emits the classification, because a plan shows only opaque ID strings. And scopes is not force-new, so reach can be widened in place β€” convenient, and also how a narrow rule quietly becomes a broad one over a few commits.

Timing has three traps, all named. A schedule with a start and no end is permanent from that date while reading as time-bounded, so it gets its own flag separate from having no schedule at all. The three recurrence kinds are repeating and combine as a union, so adding a weekly window to a daily one increases the covered time rather than intersecting it. And two different time formats coexist β€” full timestamps for the effective window, bare clock times for recurrence β€” each validated with a message naming its own format. time_zone takes Windows zone names published as a reference table rather than an enforceable set, so an IANA name passes plan and puts the rule in force at the wrong hours.

This resource owns none of the alerts it changes. They come from other alert rules, which record nothing about it, and terraform destroy here leaves them behaving exactly as their own definitions say. That is why the module's headline constant is about reach rather than about any argument.


🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
There is no empty call Four arguments are required β€”
conditions Empty β€” every alert in scope, and reported as such add conditions to narrow
A wrong-case condition key Rejected, because it would otherwise be silently dropped β€”
Operators Enforced per field, two classes β€”
The four closed value sets Enforced exactly as published β€”
Free-form condition values Not constrained β€” this module invents no rules about alert names β€”
scopes breadth Reported per entry, never restricted β€”
add_action_group_ids Anchored to Microsoft.Insights/actionGroups β€”
enabled true β€” the provider's default, mirrored explicitly set false to stage
schedule Absent β€” always in force, and reported add a window
A start with no end Reported, not rejected β€”
Multiple recurrence kinds Reported, because they union rather than intersect β€”
time_zone Not validated against a set β€” Microsoft publishes a table, not an enum β€”

βœ… Nothing here needed a security flip, because nothing in this resource is a security control in the usual sense β€” it routes notifications. What the module contributes is making reach legible: which scopes, how broad, how many conditions, and for how long.


πŸš€ Runbook

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

Pin the module by tag, never by branch:

source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-alert-processing-rule-action-group.git?ref=v1.0.0"

Authored and verified plan-only. No cloud apply happens during authoring or review; a human applies from CI.


πŸ§ͺ Testing

Gate What it proves What it does not
terraform init -backend=false The provider constraint resolves Nothing about Azure
terraform validate HCL parses; types line up; validations are declared It does not run root-module variable validations from a calling configuration
terraform fmt -check Canonical formatting Nothing semantic
terraform console with .tfvars files The real offline harness. All 25 validations proven to fire, by line number Nothing needing an Azure API call
terraform plan Provider-level validation Requires credentials; not run here
Apply Whether the scopes and action groups exist, and whether anything in scope ever fires β€”

All 25 validations were proven individually by line number, unioned across twenty-five deliberately bad fixtures and diffed against the anchored count of validation blocks.

Every fixture isolated exactly one validation β€” there were no overlaps at all, which is unusual in this library and worth recording as the cleanest proof run so far. It happened because the checks partition naturally: emptiness, shape, key membership, two operator classes and four value sets are all disjoint conditions rather than nested ones.

Every derived local was printed across ten good fixtures β€” minimal, subscription-scoped, mixed breadths, three conditions, the space-containing monitor_service values, a closed window, an open-ended one, single and multiple recurrence kinds, and all eleven condition fields at once. That confirmed active_condition_fields reaches 11, that uses_multiple_recurrence_kinds and recurrence_window_count track correctly at 3, and that scope_breadth classifies a subscription, a resource group and a resource distinctly from one mixed list.


πŸ’¬ Example Output

Outputs:

accepts_no_credential = true
action_group_names = [
  "ag-oncall-prod",
]
active_condition_fields = [
  "monitor_condition",
  "severity",
]
add_action_group_ids = [
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-monitor-prod/providers/Microsoft.Insights/actionGroups/ag-oncall-prod",
]
condition_count = 2
condition_operators_are_not_uniform_across_fields = true
conditions_are_combined_with_and = true
daily_windows_require_both_times_while_weekly_and_monthly_do_not = true
effective_from = null
effective_until = null
eleven_condition_fields_are_rendered_from_one_map = true
enabled = true
four_condition_fields_have_closed_value_sets = true
has_open_ended_effective_window = false
has_recurrence = true
has_subscription_wide_scope = true
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-monitor-prod/providers/Microsoft.AlertsManagement/actionRules/apr-oncall-sev01"
is_always_in_force = false
matches_every_alert_in_scope = false
name = "apr-oncall-sev01"
recurrence_window_count = 2
resource_group_name = "rg-monitor-prod"
resource_group_scopes = []
resource_scopes = []
scope_breadth = {
  "/subscriptions/00000000-0000-0000-0000-000000000000" = "subscription"
}
schedule_times_use_windows_time_zone_names = true
scopes = [
  "/subscriptions/00000000-0000-0000-0000-000000000000",
]
subscription_wide_scopes = [
  "/subscriptions/00000000-0000-0000-0000-000000000000",
]
the_two_alert_processing_rule_resources_are_schema_identical_except_for_the_action = true
this_rule_changes_other_peoples_alerts = true
time_zone = "Eastern Standard Time"
two_time_formats_are_in_play = true
uses_multiple_recurrence_kinds = true

πŸ” Troubleshooting

Symptom Cause Fix
Every conditions map key must be one of the eleven... A wrong-case or invented key, e.g. "Severity". Exactly lowercase, exactly as the provider names them.
A condition seems to have no effect On another tool, a wrong key is silently dropped. This module rejects it at plan time β€” that is the point of the check.
operator must be exactly "Equals" or "NotEquals" Contains used on one of the four closed-set fields. Those have no substring to match.
severity value rejected "0", "Critical" or "Sev5". Sev0 through Sev4 only.
monitor_service value rejected A value split at a space, or wrong case. "ActivityLog Administrative" is one value; "VM Insights - Health" keeps its spaces.
The rule matches fewer alerts than expected Conditions are ANDed, not ORed. Remove a condition, or widen a values list.
The rule matches everything conditions = {} or omitted. Check matches_every_alert_in_scope.
effective_from must be a timestamp... A date alone, or a clock time. YYYY-MM-DDTHH:MM:SS, no timezone suffix.
A recurrence time rejected "22:00" instead of "22:00:00". Recurrence uses HH:MM:SS.
The rule fires at the wrong hours time_zone given an IANA name. Windows zone names only. Not validated here.
A daily block rejected for a missing time daily requires both times. weekly and monthly do not β€” the days pin them.
More time covered than expected Multiple recurrence kinds union. Check uses_multiple_recurrence_kinds.
add_action_group_ids rejected An AlertsManagement path, or an action group name. Action groups live under Microsoft.Insights/actionGroups.
The rule affects alerts in another resource group scopes, not resource_group_name, decides reach. The resource group only holds the rule record.
A temporary rule never stopped applying effective_from with no effective_until. See has_open_ended_effective_window.

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."