Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Event Grid Event Subscription Terraform Module

Subscribes a delivery endpoint to events raised at a given scope — an Event Grid topic, domain, or system topic, or a resource group / subscription — with optional filtering, dead-lettering, retry, and identity-based delivery. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Secret-aware


🧩 Overview

This module creates and manages a single Event Grid event subscription and everything that hangs off it:

  • 🎯 A subscription at any scope — an Event Grid topic, domain, or system topic, or a resource group / subscription for platform events.
  • 📮 Exactly one delivery endpoint — Event Hub, Service Bus queue or topic, Storage Queue, Azure Function, webhook, or Hybrid Connection.
  • 🔎 Optional subject and advanced (attribute-based) filtering across 19 operators.
  • 🧾 Optional retry policy, dead-lettering to a Storage blob container, and identity-based delivery / dead-lettering.
  • 🔐 Optional delivery properties (headers) whose values may carry a secret — wrapped so they never surface in plan output.

💡 Why it matters: an event subscription is the wire between a producer and a consumer. Getting its filter, retry, and dead-letter behavior right — and keeping a secret delivery header out of state diffs — is the difference between a reliable event pipeline and silently dropped events or a leaked credential.


❤️ Support this project

If this module saves you time, a little support goes a long way:


🗺️ Where this fits in the family

flowchart LR
  scope["terraform-azurerm-eventgrid-topic / -domain / -system-topic"]
  me["terraform-azurerm-eventgrid-event-subscription"]
  v["azurerm_eventgrid_event_subscription"]
  tgt["delivery target (Event Hub / Service Bus / Storage Queue / Function / webhook)"]
  dl["dead-letter Storage account"]
  scope -->|"scope"| me
  me -->|"creates"| v
  v -->|"delivers to"| tgt
  v -.->|"dead-letters to"| dl
  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef target fill:#004578,stroke:#002d4d,color:#ffffff;
  classDef ext fill:#f2f2f2,stroke:#c8c8c8,color:#111111;
  class me me;
  class v target;
  class scope,tgt,dl ext;
Loading

The scope module (topic / domain / system topic) feeds this module a scope; a delivery-target module feeds an endpoint ID; an optional Storage account receives dead-lettered events.


🧬 What this module builds

flowchart LR
  in_scope["scope / event_delivery_schema"]
  in_ep["exactly one endpoint (eventhub / service_bus / storage_queue / webhook / azure_function / hybrid)"]
  in_filter["subject_filter / advanced_filter / included_event_types"]
  in_rel["retry_policy / dead_letter / delivery_identity / delivery_properties"]
  res["azurerm_eventgrid_event_subscription.this"]
  out_id["id / name"]
  in_scope -->|"input"| res
  in_ep -->|"input"| res
  in_filter -->|"input"| res
  in_rel -->|"input (secrets wrapped)"| res
  res -->|"output"| out_id
  classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
  class res me;
Loading

Resource inventory (block set on the keystone azurerm_eventgrid_event_subscription.this):

Block / field Cardinality Role
name, scope, event_delivery_schema scalar Identity + schema (all force-new).
eventhub_endpoint_id / hybrid_connection_endpoint_id / service_bus_queue_endpoint_id / service_bus_topic_endpoint_id scalar Delivery endpoint (one of seven).
azure_function_endpoint / storage_queue_endpoint / webhook_endpoint dynamic (0–1) Delivery endpoint (one of seven).
subject_filter dynamic (0–1) Subject prefix/suffix filter.
advanced_filter dynamic (0–1) 19 operator sub-blocks (bool / number / string).
included_event_types, labels, advanced_filtering_on_arrays_enabled, expiration_time_utc scalar Filtering / metadata.
retry_policy dynamic (0–1) Max attempts + TTL.
storage_blob_dead_letter_destination dynamic (0–1) Dead-letter target.
delivery_identity / dead_letter_identity dynamic (0–1 each) Managed identity for delivery / dead-lettering.
delivery_property dynamic (0–N) Headers; secret values wrapped sensitive().
timeouts dynamic (0–1) Per-operation timeouts.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module — the caller configures provider "azurerm", including the mandatory features {} block, plus auth.

Schema notes that bite:

  • 🔒 name, scope, and event_delivery_schema are force-new — changing any of them replaces the subscription.
  • 🎯 Exactly one delivery endpoint must be supplied across the seven endpoint inputs; the module enforces this at parse time with a validation {} block.
  • 🪝 dead_letter_identity requires storage_blob_dead_letter_destination to also be set, or Azure rejects the apply.
  • 🔐 A delivery_property value is treated as sensitive — it is wrapped sensitive() in main.tf and never appears in plan output.
  • 🔎 advanced_filter exposes 19 operators (bool_equals, is_not_null, is_null_or_undefined, six number_* scalars, number_in / number_not_in / number_in_range / number_not_in_range, and eight string_* list operators).

🔑 Required Azure RBAC Roles / Permissions

Least-privilege, at the source scope plus the delivery target:

  • Microsoft.EventGrid/eventSubscriptions/write, /read, /delete at the scope (the built-in EventGrid Contributor covers this at the target scope).
  • For identity-based delivery, the delivery identity needs the data-plane send role on the target, for example:
    • Azure Event Hubs Data Sender
    • Azure Service Bus Data Sender
    • Storage Queue Data Message Sender
  • For identity-based dead-lettering, the dead-letter identity needs blob write access on the destination container (Storage Blob Data Contributor).

🧰 Azure Prerequisites

  • An existing source scope (Event Grid topic / domain / system topic, or a resource group / subscription) and an existing delivery target.
  • The Microsoft.EventGrid resource provider registered on the subscription.
  • For dead-lettering with an identity, a Storage account and the identity's blob write role on the destination container.

📁 Module Structure

terraform-azurerm-eventgrid-event-subscription/
├── providers.tf     # required_version + azurerm ~> 4.0 pin; no provider block
├── variables.tf     # deeply-typed object() schemas; exactly-one-endpoint + enum validations; timeouts tail
├── main.tf          # keystone azurerm_eventgrid_event_subscription.this; dynamic endpoint/filter blocks; sensitive() delivery_property values
├── outputs.tf       # id (first), name
├── README.md        # this file
├── SCOPE.md         # cross-module contract
├── LICENSE          # MIT
└── .gitignore       # canonical library ignore set

⚙️ Quick Start

The smallest real call — a scope and exactly one endpoint:

provider "azurerm" {
  features {}
}

module "order_events" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "order-events-to-hub"
  scope = azurerm_eventgrid_topic.orders.id

  eventhub_endpoint_id = azurerm_eventhub.orders.id
}

ℹ️ The caller configures the provider, authentication, and the mandatory features {} block. This module never declares a provider block.


🔌 Cross-Module Contract

Consumes:

Input Type Source module
scope string terraform-azurerm-eventgrid-topic / -domain / -system-topic
eventhub_endpoint_id string terraform-azurerm-eventhub-namespace (an event hub)
service_bus_queue_endpoint_id / service_bus_topic_endpoint_id string terraform-azurerm-servicebus-namespace
storage_queue_endpoint.storage_account_id / storage_blob_dead_letter_destination.storage_account_id string terraform-azurerm-storage-account
delivery_identity.user_assigned_identity / dead_letter_identity.user_assigned_identity string terraform-azurerm-user-assigned-identity

Emits:

Output Description Consumed by
id Resource ID of the event subscription references
name Subscription name references

📚 Example Library

1 · Webhook endpoint (Entra-protected)
module "webhook_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-to-webhook"
  scope = azurerm_eventgrid_topic.orders.id

  webhook_endpoint = {
    url                            = "https://api.contoso.com/eventgrid/orders"
    active_directory_app_id_or_uri = "api://contoso-eventgrid"
    active_directory_tenant_id     = var.tenant_id
    max_events_per_batch           = 10
  }
}

🔒 Prefer an Entra-protected webhook (active_directory_*) over an unauthenticated URL so Event Grid presents a bearer token the endpoint can validate.

2 · Event Hub endpoint
module "hub_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-to-hub"
  scope = azurerm_eventgrid_topic.orders.id

  eventhub_endpoint_id = azurerm_eventhub.orders.id
}

💡 The endpoint is a specific event hub ID, not the namespace ID.

3 · Service Bus queue endpoint
module "sbq_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-to-sbq"
  scope = azurerm_eventgrid_topic.orders.id

  service_bus_queue_endpoint_id = azurerm_servicebus_queue.orders.id
}
4 · Service Bus topic endpoint
module "sbt_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-to-sbt"
  scope = azurerm_eventgrid_topic.orders.id

  service_bus_topic_endpoint_id = azurerm_servicebus_topic.orders.id
}

⚠️ Supply exactly one endpoint. Passing both a queue and a topic ID fails the parse-time validation {} block.

5 · Storage Queue endpoint
module "queue_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-to-queue"
  scope = azurerm_eventgrid_topic.orders.id

  storage_queue_endpoint = {
    storage_account_id                    = azurerm_storage_account.events.id
    queue_name                            = "orders"
    queue_message_time_to_live_in_seconds = 604800
  }
}
6 · Azure Function endpoint
module "fn_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-to-function"
  scope = azurerm_eventgrid_topic.orders.id

  azure_function_endpoint = {
    function_id                       = "${azurerm_linux_function_app.processor.id}/functions/HandleOrder"
    max_events_per_batch              = 1
    preferred_batch_size_in_kilobytes = 64
  }
}

ℹ️ function_id is the fully-qualified function resource ID (the app ID plus /functions/<name>).

7 · Subject filter
module "subject_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-prefix-filtered"
  scope = azurerm_eventgrid_topic.orders.id

  eventhub_endpoint_id = azurerm_eventhub.orders.id

  subject_filter = {
    subject_begins_with = "/orders/us/"
    subject_ends_with   = ".json"
    case_sensitive      = false
  }
}

💡 Subject filters are cheap and evaluated first — use them before reaching for advanced filters.

8 · Advanced filter — string_in
module "color_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-red-blue"
  scope = azurerm_eventgrid_topic.orders.id

  eventhub_endpoint_id = azurerm_eventhub.orders.id

  advanced_filter = {
    string_in = [{ key = "data.color", values = ["red", "blue"] }]
  }
}
9 · Advanced filter — number_greater_than + bool_equals
module "priority_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "high-value-priority-orders"
  scope = azurerm_eventgrid_topic.orders.id

  eventhub_endpoint_id = azurerm_eventhub.orders.id

  advanced_filter = {
    number_greater_than = [{ key = "data.amount", value = 1000 }]
    bool_equals         = [{ key = "data.priority", value = true }]
  }

  advanced_filtering_on_arrays_enabled = true
}

⚠️ A type mismatch (e.g. number_greater_than against a string field) fails at delivery evaluation, not at plan — keep the operator aligned with the attribute type.

10 · Dead-letter + dead-letter identity
module "dlq_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-with-deadletter"
  scope = azurerm_eventgrid_topic.orders.id

  eventhub_endpoint_id = azurerm_eventhub.orders.id

  storage_blob_dead_letter_destination = {
    storage_account_id          = azurerm_storage_account.events.id
    storage_blob_container_name = "deadletter"
  }

  dead_letter_identity = {
    type = "SystemAssigned"
  }
}

⚠️ dead_letter_identity requires storage_blob_dead_letter_destination — set both or neither.

11 · Retry policy
module "retry_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-tight-retry"
  scope = azurerm_eventgrid_topic.orders.id

  eventhub_endpoint_id = azurerm_eventhub.orders.id

  retry_policy = {
    max_delivery_attempts = 10
    event_time_to_live    = 60
  }
}

💡 event_time_to_live is in minutes; events older than this are dropped (or dead-lettered, if configured).

12 · Delivery properties with a secret header
module "header_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-to-authenticated-webhook"
  scope = azurerm_eventgrid_topic.orders.id

  webhook_endpoint = {
    url = "https://api.contoso.com/eventgrid/orders"
  }

  delivery_properties = [
    {
      header_name = "X-Environment"
      type        = "Static"
      value       = "production"
    },
    {
      header_name = "Authorization"
      type        = "Static"
      value       = var.webhook_bearer_token # provision out of band; never commit
      secret      = true
    },
  ]
}

🔒 Mark credential-bearing headers secret = true. The module wraps the value sensitive() so it never appears in plan output. Pass the value as a reference from a variable or a Key Vault data source — do not commit it.

13 · Identity-based delivery to Event Hub
module "identity_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name  = "orders-identity-delivery"
  scope = azurerm_eventgrid_topic.orders.id

  eventhub_endpoint_id = azurerm_eventhub.orders.id

  delivery_identity = {
    type                   = "UserAssigned"
    user_assigned_identity = azurerm_user_assigned_identity.eventgrid.id
  }
}

🔒 Prefer a managed identity with the Azure Event Hubs Data Sender role over an embedded credential. Grant the role before applying, or delivery fails silently.

14 · CloudEvent schema + included event types
module "cloudevent_sub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name                  = "orders-cloudevents"
  scope                 = azurerm_eventgrid_topic.orders.id
  event_delivery_schema = "CloudEventSchemaV1_0"

  eventhub_endpoint_id = azurerm_eventhub.orders.id

  included_event_types = ["Contoso.Orders.Created", "Contoso.Orders.Cancelled"]
  labels               = ["team-fulfilment", "prod"]
}

⚠️ event_delivery_schema is force-new — switching schema after creation replaces the subscription.

15 · 🏗️ End-to-end composition

Wire a resource group, an Event Grid topic, an Event Hubs namespace (delivery), a Storage account (dead-letter), and a user-assigned identity into a single subscription:

provider "azurerm" {
  features {}
}

module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-events-prod"
  location = "eastus2"
}

module "topic" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-topic.git?ref=v1.0.0"
  name                = "orders-topic"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "hub" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"
  name                = "orders-ns"
  resource_group_name = module.rg.name
  location            = module.rg.location

  event_hubs = {
    orders = { name = "orders", partition_count = 4, message_retention = 7 }
  }
}

module "deadletter" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
  name                = "steventsdlq01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  containers = {
    deadletter = {}
  }
}

module "eventgrid_identity" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
  name                = "id-eventgrid-delivery"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

module "subscription" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-event-subscription.git?ref=v1.0.0"

  name                  = "orders-to-hub"
  scope                 = module.topic.id
  event_delivery_schema = "CloudEventSchemaV1_0"

  eventhub_endpoint_id = module.hub.eventhub_ids["orders"]

  delivery_identity = {
    type                   = "UserAssigned"
    user_assigned_identity = module.eventgrid_identity.id
  }

  subject_filter = {
    subject_begins_with = "/orders/us/"
  }

  advanced_filter = {
    number_greater_than = [{ key = "data.amount", value = 500 }]
  }

  storage_blob_dead_letter_destination = {
    storage_account_id          = module.deadletter.id
    storage_blob_container_name = "deadletter"
  }

  dead_letter_identity = {
    type                   = "UserAssigned"
    user_assigned_identity = module.eventgrid_identity.id
  }

  retry_policy = {
    max_delivery_attempts = 20
    event_time_to_live    = 120
  }
}

💡 The delivery identity must hold Azure Event Hubs Data Sender on the hub and Storage Blob Data Contributor on the dead-letter container before this applies cleanly. Grant those role assignments in the same composition.


📥 Inputs

Identity & schema: name (required), scope (required), event_delivery_schema, expiration_time_utc, included_event_types, labels, advanced_filtering_on_arrays_enabled.

Delivery endpoint (supply exactly one): eventhub_endpoint_id, hybrid_connection_endpoint_id, service_bus_queue_endpoint_id, service_bus_topic_endpoint_id, azure_function_endpoint, storage_queue_endpoint, webhook_endpoint.

Filtering: subject_filter, advanced_filter.

Reliability & identity: retry_policy, storage_blob_dead_letter_destination, delivery_identity, dead_letter_identity, delivery_properties.

Universal tail: timeouts (no tags — this resource does not support them).

Full input schemas
variable "name" { type = string }  # force-new
variable "scope" { type = string } # force-new

variable "event_delivery_schema" { # force-new; null = EventGridSchema
  type    = string                 # EventGridSchema | CloudEventSchemaV1_0 | CustomInputSchema
  default = null
}

variable "expiration_time_utc"                  { type = string;       default = null }
variable "included_event_types"                 { type = list(string); default = null }
variable "labels"                               { type = list(string); default = null }
variable "advanced_filtering_on_arrays_enabled" { type = bool;         default = null }

# --- Delivery endpoint — supply exactly one -------------------------------
variable "eventhub_endpoint_id"          { type = string; default = null }
variable "hybrid_connection_endpoint_id" { type = string; default = null }
variable "service_bus_queue_endpoint_id" { type = string; default = null }
variable "service_bus_topic_endpoint_id" { type = string; default = null }

variable "azure_function_endpoint" {
  type = object({
    function_id                       = string
    max_events_per_batch              = optional(number)
    preferred_batch_size_in_kilobytes = optional(number)
  })
  default = null
}

variable "storage_queue_endpoint" {
  type = object({
    storage_account_id                    = string
    queue_name                            = string
    queue_message_time_to_live_in_seconds = optional(number)
  })
  default = null
}

variable "webhook_endpoint" {
  type = object({
    url                               = string
    active_directory_app_id_or_uri    = optional(string)
    active_directory_tenant_id        = optional(string)
    max_events_per_batch              = optional(number)
    preferred_batch_size_in_kilobytes = optional(number)
  })
  default = null
}

# --- Filtering ------------------------------------------------------------
variable "subject_filter" {
  type = object({
    subject_begins_with = optional(string)
    subject_ends_with   = optional(string)
    case_sensitive      = optional(bool)
  })
  default = null
}

variable "advanced_filter" {
  type = object({
    bool_equals                   = optional(list(object({ key = string, value = bool })))
    is_not_null                   = optional(list(object({ key = string })))
    is_null_or_undefined          = optional(list(object({ key = string })))
    number_greater_than           = optional(list(object({ key = string, value = number })))
    number_greater_than_or_equals = optional(list(object({ key = string, value = number })))
    number_less_than              = optional(list(object({ key = string, value = number })))
    number_less_than_or_equals    = optional(list(object({ key = string, value = number })))
    number_in                     = optional(list(object({ key = string, values = list(number) })))
    number_not_in                 = optional(list(object({ key = string, values = list(number) })))
    number_in_range               = optional(list(object({ key = string, values = list(list(number)) })))
    number_not_in_range           = optional(list(object({ key = string, values = list(list(number)) })))
    string_begins_with            = optional(list(object({ key = string, values = list(string) })))
    string_not_begins_with        = optional(list(object({ key = string, values = list(string) })))
    string_ends_with              = optional(list(object({ key = string, values = list(string) })))
    string_not_ends_with          = optional(list(object({ key = string, values = list(string) })))
    string_contains               = optional(list(object({ key = string, values = list(string) })))
    string_not_contains           = optional(list(object({ key = string, values = list(string) })))
    string_in                     = optional(list(object({ key = string, values = list(string) })))
    string_not_in                 = optional(list(object({ key = string, values = list(string) })))
  })
  default = null
}

# --- Reliability & identity ----------------------------------------------
variable "delivery_identity" {         # type = SystemAssigned | UserAssigned
  type = object({
    type                   = string
    user_assigned_identity = optional(string)
  })
  default = null
}

variable "dead_letter_identity" {      # requires storage_blob_dead_letter_destination
  type = object({
    type                   = string    # SystemAssigned | UserAssigned
    user_assigned_identity = optional(string)
  })
  default = null
}

variable "storage_blob_dead_letter_destination" {
  type = object({
    storage_account_id          = string
    storage_blob_container_name = string
  })
  default = null
}

variable "retry_policy" {
  type = object({
    max_delivery_attempts = number
    event_time_to_live    = number
  })
  default = null
}

variable "delivery_properties" {       # value is SENSITIVE (wrapped in main.tf)
  type = list(object({
    header_name  = string
    type         = string
    source_field = optional(string)
    value        = optional(string)
    secret       = optional(bool)
  }))
  default = []
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

🧾 Outputs

Output Description Kind
id Passthrough
name Name of the event subscription Passthrough
scope Resource ID of the topic, domain, system topic, resource group or subscription this subscription listens to Passthrough
event_delivery_schema Envelope events are delivered in: "EventGridSchema" (the default), "CloudEventSchemaV1_0" or "CustomInputSchema" Passthrough
delivery_endpoint_kind Passthrough
delivery_target_id Passthrough
eventhub_endpoint_id Event hub the subscription delivers to, or null Passthrough
hybrid_connection_endpoint_id Relay hybrid connection the subscription delivers to, or null Passthrough
service_bus_queue_endpoint_id Service Bus queue the subscription delivers to, or null Passthrough
service_bus_topic_endpoint_id Service Bus topic the subscription delivers to, or null Passthrough
storage_queue_name Name of the Storage queue events are delivered into, or null when the destination is not a Storage queue Derived
webhook_base_url Derived
has_webhook_endpoint Whether the destination is a webhook Derived
webhook_full_url_is_stored_unredacted_in_state Derived
refresh_requires_get_full_url_permission Constant
webhook_entra_configuration_is_incomplete Passthrough
webhook_is_entra_protected Whether the webhook is configured to receive an Entra ID bearer token, stated positively: both Entra fields are present Passthrough
webhook_creation_requires_a_live_handler Derived
included_event_types Event types this subscription accepts, read back from Azure Passthrough
advanced_filter_value_count Derived
advanced_filter_values_remaining How many of the 25 advanced-filter values are still unspent Derived
advanced_filter_keys Every event attribute the advanced filter tests, de-duplicated Derived
subscription_filters_nothing Passthrough
subject_filter_never_converges Passthrough
array_filtering_is_disabled Passthrough
max_delivery_attempts Delivery attempts before Event Grid gives up on an event, read back from Azure Derived
event_time_to_live_minutes How long, in minutes, Event Grid keeps trying before abandoning an event, read back from Azure Derived
uses_default_retry_policy Whether the retry behaviour is Azure's default rather than one this configuration chose Derived
dead_letter_enabled Whether undeliverable events are written to a Storage blob container instead of being discarded Derived
dead_letter_container_id Resource ID of the Storage account receiving dead-lettered events, or null when dead-lettering is not configured Derived
undeliverable_events_are_discarded Derived
dead_lettering_is_best_effort Derived
delivery_identity_type Managed identity type Event Grid uses to deliver events ("SystemAssigned" or "UserAssigned"), or null when delivery runs as Event Grid's own service principal Derived
dead_letter_identity_type Managed identity type used for writing dead-lettered events, or null when Event Grid's own service principal writes them Derived
delivery_uses_managed_identity Whether delivery authenticates as a managed identity rather than relying on Event Grid's service principal or on a credential baked into the endpoint Derived
identity_holds_no_permission_from_this_module Derived
delivery_property_names Header names attached to each delivered event, in configuration order Derived
has_secret_delivery_property Whether any delivery header is flagged secret Derived
delivery_properties_are_discarded Derived
secret_delivery_property_values_are_in_terraform_state Derived
module_emits_no_delivery_property_value Constant
expiration_time_utc Moment after which Azure deletes the subscription, or an empty string when it never expires Passthrough
expires_and_is_then_deleted Derived
supports_no_tags Constant
creates_no_role_assignment Always true Constant
replacement_loses_events_in_flight Constant

This module emits no secrets. Secret delivery_property values are consumed as inputs and never re-emitted.


🧠 Architecture Notes

  • Force-new fields. name, scope, and event_delivery_schema are immutable. Terraform replaces the subscription when any of them changes; plan for a brief gap in delivery when that happens.
  • Exactly-one-endpoint rule. The seven endpoint inputs are mutually exclusive. A validation {} block on webhook_endpoint counts the non-null endpoints and rejects the plan unless exactly one is set — the error is raised at parse time, before any Azure call.
  • Dead-letter identity dependency. dead_letter_identity is only meaningful alongside storage_blob_dead_letter_destination; Azure rejects an identity without a destination. Set both together.
  • Sensitive delivery properties. In main.tf each delivery_property.value is wrapped sensitive(), so a secret header (bearer token, API key) never appears in plan output or logs. Always mark such a property secret = true and pass its value by reference.
  • features {} dependence. The azurerm provider will not initialize without a caller-side provider "azurerm" { features {} } block. This module deliberately omits any provider block; if init fails in isolation, that missing caller block is the usual cause.
  • for_each over dynamic blocks. Every optional endpoint / filter / identity block is rendered via a presence-guarded dynamic block with try(...) on optional fields, so an omitted input renders nothing rather than a null-bearing block.

🧱 Design Principles

Principle How this module applies it
Deeply-typed nested schemas Every endpoint, filter, identity, retry, and dead-letter block is a typed object() — a typo in a nested key is a parse-time type error, not a runtime surprise.
Parse-time validation Exactly-one-endpoint is enforced with a validation {} block; event_delivery_schema, delivery_identity.type, and dead_letter_identity.type are enum-validated with actionable messages.
Secrets wrapped sensitive() delivery_property values are wrapped so secret headers never render in plan output; mark them secret = true and pass by reference.
Identity-preferred delivery delivery_identity / dead_letter_identity are exposed so a managed identity can carry delivery, avoiding embedded credentials.
No tags The resource does not support tags; the universal tail is timeouts only.

🚀 Runbook

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

Pin the module by tag — ?ref=v1.0.0 — never a branch. This library is plan-only; a human applies from CI after review. Never run terraform apply from an authoring workstation.


🧪 Testing

The offline proof gate covers structure and typing without touching Azure:

  • terraform init -backend=false — resolves the provider without a backend.
  • terraform validate — proves the schema, the deeply-typed object() inputs, the exactly-one-endpoint validation {} block, and every enum validation.
  • terraform fmt -check — proves canonical formatting.

What only terraform plan (against a real subscription and provider auth) exercises: whether the scope and endpoint IDs resolve, whether the delivery identity holds the required data-plane role, and whether Azure accepts the filter/retry/dead-letter combination. Those are integration concerns a human runs from CI.


💬 Example Output

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

id   = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-events-prod/providers/Microsoft.EventGrid/topics/orders-topic/providers/Microsoft.EventGrid/eventSubscriptions/orders-to-hub"
name = "orders-to-hub"

A secret delivery_property value is never printed — it renders as <sensitive> in plan and apply output.


🔍 Troubleshooting

Symptom Cause Fix
Supply exactly one delivery endpoint at plan Zero or more than one endpoint input set. Provide exactly one of the seven endpoint inputs; remove the extras.
Apply rejected — dead-letter identity without destination dead_letter_identity set but storage_blob_dead_letter_destination omitted. Set storage_blob_dead_letter_destination too, or remove dead_letter_identity.
Events not matching an advanced filter Operator type does not match the attribute type (e.g. number_greater_than on a string). Align the operator with the attribute's JSON type; check advanced_filtering_on_arrays_enabled for array-valued keys.
event_delivery_schema change forces replacement event_delivery_schema is force-new. Expected — accept the replacement, or create a second subscription and cut over.
Delivery fails silently with an identity The delivery identity lacks the target's data-plane send role. Grant the identity the appropriate Data Sender role at the target before applying.
Provider fails to initialize in isolation Caller has no provider "azurerm" { features {} } block. Add the features {} block in the root module — the module never carries one.

🔗 Related Docs

  • Provider resource: azurerm_eventgrid_event_subscription
  • Scope siblings: terraform-azurerm-eventgrid-topic, terraform-azurerm-eventgrid-domain, terraform-azurerm-eventgrid-system-topic
  • Where the parent is a system topic, prefer terraform-azurerm-eventgrid-system-topic-event-subscription: it addresses the topic by name plus resource group rather than by a scope Resource ID, so a parent reference is not interchangeable between the two modules
  • Delivery-target siblings: terraform-azurerm-eventhub-namespace, terraform-azurerm-servicebus-namespace, terraform-azurerm-storage-account, terraform-azurerm-user-assigned-identity
  • This module's cross-module contract: SCOPE.md

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