Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Digital Twins Event Grid Endpoint Terraform Module

Routes events out of an Azure Digital Twins graph to an Event Grid topic (azurerm_digital_twins_endpoint_eventgrid), wrapping the two access keys the provider leaves unmarked. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources

🧩 Overview

  • 📤 Declares one Digital Twins endpoint targeting an Event Grid topic, as a keystone resource named this.
  • 🔑 This endpoint is key-based, and that is not a provider gap. Azure Digital Twins supports managed-identity authentication for Event Hubs and Service Bus endpoints but not for Event Grid. There is no identity-based option at the service level, so the topic's two access keys are genuinely unavoidable.
  • 🛡️ Fixes a real provider defect. The provider's schema does not mark the Event Grid access keys — or dead_letter_storage_secret — as sensitive, unlike the equivalent fields on the Event Hubs and Service Bus endpoint resources. Left unwrapped they print in cleartext in plan output and CI logs. Every credential-bearing variable here is sensitive = true.
  • 🪤 Validates the dead-letter URL for shape: a container URL with no SAS token means dead-lettering silently does nothing.
  • 📛 Treats the endpoint name as a contract, because an event route in the twin graph refers to the endpoint by name.
  • 🚫 Emits no credentials, and does not re-emit the ones it was given.

💡 Why it matters: Two of this module's three inputs are live credentials, and until you wrap them the provider will happily print them in a plan. That is the concrete, checkable thing this module does. The subtler one is dead-lettering: leave it unset and an event the endpoint cannot deliver is dropped with no record — a telemetry pipeline that looks healthy while losing data.

❤️ Support this project

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


🗺️ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"]
  uai["terraform-azurerm-user-assigned-identity: optional, via identity_ids"]
  dt["terraform-azurerm-digital-twins-instance: the family keystone, owns the system-assigned identity"]
  eg["terraform-azurerm-digital-twins-endpoint-eventgrid: KEY-BASED, no identity option EXISTS"]
  eh["terraform-azurerm-digital-twins-endpoint-eventhub: KEY-BASED, provider gap only"]
  sb["terraform-azurerm-digital-twins-endpoint-servicebus: KEY-BASED, provider gap only, TOPIC not queue"]
  ts["terraform-azurerm-digital-twins-time-series-database-connection: IDENTITY-BASED, no secrets at all"]
  egt["terraform-azurerm-eventgrid-topic: keys read via a data source, the module does not emit them"]
  ehar["terraform-azurerm-eventhub-authorization-rule: SEND only"]
  sbar["terraform-azurerm-servicebus-topic-authorization-rule: SEND only"]
  ehns["terraform-azurerm-eventhub-namespace: the hop for data history"]
  kc["terraform-azurerm-kusto-cluster"]
  kd["terraform-azurerm-kusto-database: must already exist, the table is created FOR you"]
  sa["terraform-azurerm-storage-account: dead-letter container plus a SAS token"]
  ra["terraform-azurerm-role-assignments: grants the DT identity its roles, reducible after setup"]
  routes["event routes in the twin graph: DATA PLANE, name the endpoint, not modelled by Terraform"]

  rg -->|"resource_group_name, location"| dt
  uai -->|"identity_ids"| dt
  dt -->|"id, BY ID"| eg
  dt -->|"id, BY ID"| eh
  dt -->|"id, BY ID"| sb
  dt -->|"id, BY ID"| ts
  egt -->|"endpoint URL plus TWO access keys"| eg
  ehar -->|"two connection strings"| eh
  sbar -->|"two connection strings"| sb
  ehns -->|"id, name, and an sb:// URI you COMPOSE"| ts
  kc -->|"id plus uri"| ts
  kd -->|"name"| ts
  sa -->|"dead_letter_storage_secret"| eg
  sa -->|"dead_letter_storage_secret"| eh
  sa -->|"dead_letter_storage_secret"| sb
  dt -->|"identity_principal_id"| ra
  ra -->|"Event Hubs Data Owner, Kusto Contributor, database Admin"| ts
  eg -->|"name"| routes
  eh -->|"name"| routes
  sb -->|"name"| routes

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class dt keystone;
  class eg,eh,sb,ts me;
  class rg,uai,egt,ehar,sbar,ehns,kc,kd,sa,ra,routes sib;
Loading

🧬 What this module builds

flowchart TB
  why["WHY KEY-BASED: Azure Digital Twins supports managed identity for Event Hubs and Service Bus endpoints but NOT for Event Grid"]
  inherent["so these keys are INHERENT to the endpoint type, not a provider gap"]
  defect["PROVIDER DEFECT THIS MODULE WORKS AROUND: the schema does NOT mark these keys sensitive, unlike the Event Hubs and Service Bus endpoint resources"]
  wrap["so every credential variable here is sensitive = true, keeping them out of plan output and CI logs"]
  state["what no module can fix: the values still land in STATE in cleartext"]
  keys["eventgrid_topic_primary_access_key plus secondary: BOTH required, updatable so rotation is in-place"]
  pair["pass the GENUINE secondary: the pair exists so the service can absorb a rotation"]
  nosource["the eventgrid-topic module does NOT emit its keys, so read them with a data source or from Key Vault"]
  ep["eventgrid_topic_endpoint: the HTTPS endpoint URL, not a Resource ID"]
  dl["dead_letter_storage_secret: optional, a blob URL WITH a SAS token"]
  dlwhy["without it an undeliverable event is DROPPED with no record: silent loss"]
  dlval["validated for shape: no question mark means no SAS token means dead-lettering silently does nothing"]
  fn["name and digital_twins_id are FORCE-NEW, everything else updates in place"]
  notags["no tags on this resource type, so the tail is timeouts only"]
  this["terraform-azurerm-digital-twins-endpoint-eventgrid"]
  res["azurerm_digital_twins_endpoint_eventgrid.this"]
  out["outputs: id, name which event routes refer to, and dead_lettering_enabled. NO credentials re-emitted."]

  why -->|"read this first"| inherent
  inherent -->|"unavoidable"| this
  defect -->|"the real contribution"| wrap
  wrap -->|"but"| state
  state -->|"disclosed"| this
  keys -->|"required"| this
  pair -->|"do not pass primary twice"| keys
  nosource -->|"where to get them"| keys
  ep -->|"the target"| this
  dl -->|"strongly recommended"| this
  dlwhy -->|"the cost of omitting"| dl
  dlval -->|"caught at plan"| dl
  fn -->|"lifecycle"| this
  notags -->|"universal tail"| this
  this -->|"creates"| res
  res -->|"emits"| out

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class res keystone;
  class why,inherent,defect,wrap,state,keys,pair,nosource,ep,dl,dlwhy,dlval,fn,notags,out sib;
Loading

Resource inventory

Resource Count Notes
azurerm_digital_twins_endpoint_eventgrid.this 1 The keystone. A child of the Digital Twins instance; supports no tags, so the universal tail is timeouts only.

✅ 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, and supplies authentication.

Schema notes that bite — confirmed against the live provider schema:

  • name and digital_twins_id are force-new. Everything else updates in place, so rotating the topic's keys or re-pointing at a different topic is not a replacement.
  • The two access keys and dead_letter_storage_secret are not marked sensitive by the provider on this resource type — though they are on azurerm_digital_twins_endpoint_eventhub and azurerm_digital_twins_endpoint_servicebus. This module wraps them.
  • Both access keys are required. The service holds the pair so it can fail over between them without an endpoint update.
  • eventgrid_topic_endpoint is the topic's HTTPS endpoint URL, not a Resource ID and not a hostname.
  • No tags. The universal tail is timeouts only.
  • The endpoint's Resource ID nests beneath the Digital Twins instance, which is where the caller needs write permission — not on the Event Grid topic.
  • The provider models no event-route resource. Routes are data-plane objects created outside Terraform, and they refer to this endpoint by name.

🔑 Required Azure RBAC Roles / Permissions

Least privilege, at the smallest scope that works:

Scope Role / permission Why
The Digital Twins instance Microsoft.DigitalTwins/digitalTwinsInstances/endpoints/write — held by Azure Digital Twins Data Owner, or Contributor on the instance The endpoint is a child of the instance, so this is where the write lands.
The Event Grid topic Microsoft.EventGrid/topics/listKeys/action (read-only) Only if the keys are read with a data source. Not needed when they arrive from Key Vault.

ℹ️ No permission is needed on the storage account: the SAS token inside dead_letter_storage_secret carries its own authorization. That is convenient and also the reason the token's expiry is your problem — see example 6.

Azure Prerequisites

  • The Microsoft.DigitalTwins and Microsoft.EventGrid resource providers registered on the subscription.
  • An existing Digital Twins instance.
  • An existing Event Grid topic, plus its endpoint URL and both access keys.
  • For dead-lettering: an existing storage account, a blob container, and a SAS token that can write to it.
  • ℹ️ Unlike the Event Hubs and Service Bus endpoints, this one needs no managed identity on the instance and no role granted to it — because it cannot use one.

📁 Module Structure

terraform-azurerm-digital-twins-endpoint-eventgrid/
├── providers.tf   # required_version + the pinned azurerm provider. No provider block.
├── variables.tf   # name, digital_twins_id, topic endpoint + both keys, dead_letter_storage_secret, timeouts
├── main.tf        # the keystone azurerm_digital_twins_endpoint_eventgrid.this
├── outputs.tf     # id, name, digital_twins_id, dead_lettering_enabled
├── README.md      # this document
├── SCOPE.md       # the cross-module contract
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

data "azurerm_eventgrid_topic" "twins" {
  name                = "egt-twins-prod"
  resource_group_name = "rg-twins-prod"
}

module "dt_endpoint_eventgrid" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-endpoint-eventgrid.git?ref=v1.0.0"

  name             = "eventgrid-out"
  digital_twins_id = module.dt.id

  eventgrid_topic_endpoint             = data.azurerm_eventgrid_topic.twins.endpoint
  eventgrid_topic_primary_access_key   = data.azurerm_eventgrid_topic.twins.primary_access_key
  eventgrid_topic_secondary_access_key = data.azurerm_eventgrid_topic.twins.secondary_access_key
}

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.

⚠️ This call has no dead-letter destination, so an undeliverable event is dropped with no record. Example 5 adds one, and it is worth adding.

🔌 Cross-Module Contract

Consumes

Input Type Source module
digital_twins_id string terraform-azurerm-digital-twins-instance → id
eventgrid_topic_endpoint string terraform-azurerm-eventgrid-topic → endpoint
eventgrid_topic_primary_access_key string 🔒 a data "azurerm_eventgrid_topic" read, or Key Vault
eventgrid_topic_secondary_access_key string 🔒 a data "azurerm_eventgrid_topic" read, or Key Vault
dead_letter_storage_secret string 🔒 terraform-azurerm-storage-account + a SAS token

ℹ️ The access keys have no sibling-module source on purpose: terraform-azurerm-eventgrid-topic does not emit them, because this suite's modules do not emit secrets. The azurerm_eventgrid_topic data source does expose them, which is the supported read path.

Emits

Output Description Consumed by
id The endpoint's Resource ID, nested beneath the instance. imports, state reading
name The endpoint name an event route refers to. Force-new. event-route configuration
digital_twins_id The instance this endpoint belongs to. review
dead_lettering_enabled Derived boolean — false means undeliverable events are dropped. policy checks, review

📚 Example Library

Values these examples reference but do not create are declared inputs:

variable "eventhub_primary_connection_string" {
  description = "eventhub primary connection string of an existing resource these examples reference."
  type        = string
  sensitive   = true
}

variable "eventhub_secondary_connection_string" {
  description = "eventhub secondary connection string of an existing resource these examples reference."
  type        = string
  sensitive   = true
}

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 "kv_id" {
  description = "id of an existing kv that these examples reference but do not create."
  type        = string
}
1 · The minimal call
module "dt_endpoint_eventgrid" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-endpoint-eventgrid.git?ref=v1.0.0"

  name             = "eventgrid-out"
  digital_twins_id = module.dt.id

  eventgrid_topic_endpoint             = data.azurerm_eventgrid_topic.twins.endpoint
  eventgrid_topic_primary_access_key   = data.azurerm_eventgrid_topic.twins.primary_access_key
  eventgrid_topic_secondary_access_key = data.azurerm_eventgrid_topic.twins.secondary_access_key
}

🔒 Note there is no identity argument to reach for. Event Grid endpoints have no identity-based option at the service level, so this is the whole shape.

2 · Where the keys come from — the data source path
data "azurerm_eventgrid_topic" "twins" {
  name                = module.egt.name
  resource_group_name = module.rg.name
}
eventgrid_topic_primary_access_key   = data.azurerm_eventgrid_topic.twins.primary_access_key
eventgrid_topic_secondary_access_key = data.azurerm_eventgrid_topic.twins.secondary_access_key

ℹ️ terraform-azurerm-eventgrid-topic emits id, name and endpoint but deliberately not the access keys — this suite's modules do not emit secrets. The data source is the supported way to read them, and it needs Microsoft.EventGrid/topics/listKeys/action.

⚠️ Reading them this way puts them in state. That is true of every path to these keys; the resource requires the literal values.

3 · Where the keys come from — the Key Vault path
data "azurerm_key_vault_secret" "egt_primary" {
  name         = "egt-twins-primary"
  key_vault_id = var.kv_id
}

data "azurerm_key_vault_secret" "egt_secondary" {
  name         = "egt-twins-secondary"
  key_vault_id = var.kv_id
}

module "dt_endpoint_eventgrid" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-endpoint-eventgrid.git?ref=v1.0.0"

  name             = "eventgrid-out"
  digital_twins_id = module.dt.id

  eventgrid_topic_endpoint             = module.egt.endpoint
  eventgrid_topic_primary_access_key   = data.azurerm_key_vault_secret.egt_primary.value
  eventgrid_topic_secondary_access_key = data.azurerm_key_vault_secret.egt_secondary.value
}

💡 Prefer this when the topic lives in another subscription, or when the identity running Terraform should not hold listKeys on Event Grid. The trade-off is that the vault copies can drift from the real keys after a rotation.

🔒 Either way the values reach state. The vault path narrows who can read the keys, not where they end up.

4 · What the provider would print without the wrapping
# WITHOUT sensitive = true on the variables — the provider does not mark these fields:
  + eventgrid_topic_primary_access_key   = "Xy9k...actual key material..."
  + eventgrid_topic_secondary_access_key = "Ab3m...actual key material..."

# WITH this module:
  + eventgrid_topic_primary_access_key   = (sensitive value)
  + eventgrid_topic_secondary_access_key = (sensitive value)

🔒 This is the concrete difference the module makes, and it is worth understanding rather than trusting. Sensitivity propagates from a variable to the argument it feeds, so marking the variable is enough to redact the plan — even though the provider's own schema does not.

⚠️ It does not redact state. Treat the state backend as holding live Event Grid keys, and restrict access to it accordingly.

5 · Adding dead-lettering (do this)
module "dt_endpoint_eventgrid" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-endpoint-eventgrid.git?ref=v1.0.0"

  name             = "eventgrid-out"
  digital_twins_id = module.dt.id

  eventgrid_topic_endpoint             = data.azurerm_eventgrid_topic.twins.endpoint
  eventgrid_topic_primary_access_key   = data.azurerm_eventgrid_topic.twins.primary_access_key
  eventgrid_topic_secondary_access_key = data.azurerm_eventgrid_topic.twins.secondary_access_key

  # NOTE: no "?" before the SAS -- the sas attribute already begins with it.
  dead_letter_storage_secret = "https://sttwinsdl.blob.core.windows.net/deadletter${data.azurerm_storage_account_blob_container_sas.dl.sas}"
}

⚠️ Without this, an event the endpoint cannot deliver — after the service's own retries — is dropped with no record. Nothing raises an alarm. In a telemetry pipeline that is the failure mode that costs you the incident you were trying to investigate.

💡 It is not defaulted because there is no storage account the module could invent. dead_lettering_enabled is emitted so its absence is visible in review rather than merely unstated.

6 · The dead-letter URL must carry a SAS token
# ❌ Rejected at plan — no "?" means no SAS token.
dead_letter_storage_secret = "https://sttwinsdl.blob.core.windows.net/deadletter"
Error: Invalid value for variable

  dead_letter_storage_secret must be an HTTPS blob container URL that includes a
  SAS query string, e.g. "https://<account>.blob.core.windows.net/<container>?<SASToken>".
  A container URL with no "?" carries no SAS token, so the service cannot write
  dead-lettered events and they are dropped silently.

💡 Worth catching at plan time precisely because the failure is silent: the service accepts a tokenless URL, the endpoint reports healthy, and dead-lettering does nothing.

⚠️ The validation cannot check that the SAS is valid or unexpired. A SAS token expiring is the same silent failure arriving later, so track its expiry alongside other credential rotations.

7 · Rotating the topic's keys
# Regenerate the topic's primary key in Azure, then re-run:
  ~ eventgrid_topic_primary_access_key = (sensitive value)
Plan: 0 to add, 1 to change, 0 to destroy.

💡 An in-place update, not a replacement — the access keys are not force-new. The endpoint keeps delivering throughout, which is why the service holds a pair.

⚠️ Rotate one key at a time. If both are regenerated before an apply, delivery breaks in the window between the rotation and the apply.

8 · Pass the genuine secondary key
# ❌ Accepted by the provider, and wrong.
eventgrid_topic_primary_access_key   = data.azurerm_eventgrid_topic.twins.primary_access_key
eventgrid_topic_secondary_access_key = data.azurerm_eventgrid_topic.twins.primary_access_key

⚠️ Nothing catches this — two strings, both valid keys. But the pair exists so the service can absorb a single rotation, and with the primary passed twice a rotation breaks delivery instead of being absorbed. This module cannot validate it either: the values are opaque and equality between two sensitive strings is not something to branch on.

9 · The endpoint name is a contract with the twin graph
module "dt_endpoint_eventgrid" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-endpoint-eventgrid.git?ref=v1.0.0"

  name = "eventgrid-out" # an event route names THIS string
  digital_twins_id             = var.digital_twins_id
  eventgrid_topic_endpoint     = "eventgrid-topic-endpoint"
  eventgrid_topic_primary_access_key = var.eventgrid_topic_primary_access_key
  eventgrid_topic_secondary_access_key = var.eventgrid_topic_secondary_access_key
}
# The route, created outside Terraform — the provider models no route resource:
az dt route create -n adt-twins-prod --route-name to-eventgrid \
  --endpoint-name eventgrid-out --filter "type = 'Microsoft.DigitalTwins.Twin.Update'"

⚠️ name is force-new, and a route pointing at the old name stops delivering the moment the endpoint is replaced. Treat it as fixed once routes exist.

ℹ️ Routes are data-plane objects. Terraform creates the endpoint; something else points traffic at it. That boundary is why name is emitted as an output.

10 · Several endpoints on one instance
module "dt_endpoint_eventgrid" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-endpoint-eventgrid.git?ref=v1.0.0"

  name             = "eventgrid-notifications"
  digital_twins_id = module.dt.id
  eventgrid_topic_endpoint     = "eventgrid-topic-endpoint"
  eventgrid_topic_primary_access_key = var.eventgrid_topic_primary_access_key
  eventgrid_topic_secondary_access_key = var.eventgrid_topic_secondary_access_key
}

module "dt_endpoint_eventhub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-endpoint-eventhub.git?ref=v1.0.0"

  name             = "eventhub-analytics"
  digital_twins_id = module.dt.id
  eventhub_primary_connection_string = var.eventhub_primary_connection_string
  eventhub_secondary_connection_string = var.eventhub_secondary_connection_string
}

💡 An instance carries several endpoints, and routes choose between them by filter. Event Grid suits event-driven notification fan-out to subscribers that come and go; Event Hubs suits high-throughput analytics ingestion. Picking per route is the point of having both.

11 · Re-pointing at a different topic
module "dt_endpoint_eventgrid" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-endpoint-eventgrid.git?ref=v1.0.0"

  name             = "eventgrid-out" # unchanged, so routes keep working
  digital_twins_id = module.dt.id

  eventgrid_topic_endpoint             = data.azurerm_eventgrid_topic.new.endpoint
  eventgrid_topic_primary_access_key   = data.azurerm_eventgrid_topic.new.primary_access_key
  eventgrid_topic_secondary_access_key = data.azurerm_eventgrid_topic.new.secondary_access_key
}

💡 An in-place update: the topic fields are not force-new. Keeping name unchanged means every existing route keeps delivering, now to the new topic — a genuinely non-disruptive migration.

⚠️ Events in flight during the switch may land on either topic. Make the consumer idempotent rather than assuming a clean cutover.

12 · Reading the posture in review
output "dt_routing_posture" {
  value = {
    endpoint      = module.dt_endpoint_eventgrid.name
    dead_lettered = module.dt_endpoint_eventgrid.dead_lettering_enabled # expect true
    instance      = module.dt_endpoint_eventgrid.digital_twins_id
  }
}

💡 dead_lettering_enabled is a derived boolean rather than the URL itself, so the posture is reviewable without exposing a SAS token. false is the value to fix.

ℹ️ It needs nonsensitive() internally: sensitivity is contagious in Terraform, so a boolean derived from a sensitive value is itself sensitive. What it reveals is whether a secret was supplied, never any part of its content.

13 · Destroying the endpoint is route-visible
# module.dt_endpoint_eventgrid.azurerm_digital_twins_endpoint_eventgrid.this will be destroyed

⚠️ The Event Grid topic survives — only the routing configuration goes. But any route in the twin graph still naming this endpoint stops delivering, and because routes are not in Terraform's state, nothing in the plan tells you which ones.

💡 Remove or re-point the routes first, then the endpoint. Reversing that order is a silent outage of the events you cared about.

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

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

  name     = "rg-twins-prod"
  location = "eastus"
}

# 1 · The twin graph.
module "dt" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-instance.git?ref=v1.0.0"

  name                = "adt-twins-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  identity = {
    type = "SystemAssigned"
  }
}

# 2 · The Event Grid topic events are routed to.
module "egt" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventgrid-topic.git?ref=v1.0.0"

  name                = "egt-twins-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

# 3 · Its keys are not emitted by that module, so read them.
data "azurerm_eventgrid_topic" "twins" {
  name                = module.egt.name
  resource_group_name = module.rg.name
}

# 4 · Somewhere for undeliverable events to land.
module "st_dl" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

  name                = "sttwinsdlprod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  containers = {
    deadletter = {}
  }
}

# The storage-account module does not emit a connection string — it emits no secrets — so read it.
data "azurerm_storage_account" "dl" {
  name                = module.st_dl.name
  resource_group_name = module.rg.name
}

data "azurerm_storage_account_blob_container_sas" "dl" {
  connection_string = data.azurerm_storage_account.dl.primary_connection_string
  container_name    = "deadletter"
  https_only        = true

  start  = "2026-01-01"
  expiry = "2027-01-01"

  permissions {
    read   = true
    add    = true
    create = true
    write  = true
    delete = false
    list   = false
  }
}

# 5 · The endpoint. This is the module.
module "dt_endpoint_eventgrid" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-digital-twins-endpoint-eventgrid.git?ref=v1.0.0"

  name             = "eventgrid-out"
  digital_twins_id = module.dt.id

  eventgrid_topic_endpoint             = data.azurerm_eventgrid_topic.twins.endpoint
  eventgrid_topic_primary_access_key   = data.azurerm_eventgrid_topic.twins.primary_access_key
  eventgrid_topic_secondary_access_key = data.azurerm_eventgrid_topic.twins.secondary_access_key

  # NOTE: no "?" before the SAS -- the sas attribute already begins with it.
  dead_letter_storage_secret = "https://${module.st_dl.name}.blob.core.windows.net/deadletter${data.azurerm_storage_account_blob_container_sas.dl.sas}"
}

output "twins_routing" {
  value = {
    endpoint_name = module.dt_endpoint_eventgrid.name
    dead_lettered = module.dt_endpoint_eventgrid.dead_lettering_enabled
  }
}

⚠️ The composition stops one step short of working: an event route in the twin graph must name eventgrid-out before anything flows. Routes are data-plane objects with no Terraform resource, so that step is deliberately outside this configuration — see example 9.

🔒 What the composition gets right: the SAS token is scoped to write into one container with no delete or list, the topic's keys are read rather than copied into configuration, and dead-lettering is on so a delivery failure leaves evidence.

📥 Inputs

Input Type Default Notes
name string — Required. Force-new. An event route refers to this.
digital_twins_id string — Required. Force-new.
eventgrid_topic_endpoint string — Required. The topic's HTTPS endpoint URL. Validated.
eventgrid_topic_primary_access_key string — Required. 🔒 Sensitive.
eventgrid_topic_secondary_access_key string — Required. 🔒 Sensitive. Pass the genuine secondary.
dead_letter_storage_secret string null 🔒 Sensitive. Strongly recommended. Four checks: HTTPS with a query string, a non-empty SAS, no doubled ?, a sig= component, and a container path.
timeouts object(...) null create / read / update / delete. Each supplied value is checked for a Go duration unit and for emptiness.
Full schemas
variable "name" {
  type = string
  # ⚠️ Force-new. An event route in the twin graph refers to the endpoint by this name, so replacing
  #    the endpoint stops those routes delivering until they are updated.
}

variable "digital_twins_id" {
  type = string
  # ⚠️ Force-new. The endpoint nests beneath the instance, which is where write permission is needed.
  # Validated twice: the shape is anchored to a digitalTwinsInstances ID, and a second check
  #    diagnoses a right-shape/wrong-CASE value separately. The provider parses this ID
  #    case-sensitively, but a provider schema rule does not fire through a module boundary,
  #    so without the second check a caller gets no offline signal at all.
}

variable "eventgrid_topic_endpoint" {
  type = string
  # Validated: must start with "https://" — this is the topic's `endpoint`, not a Resource ID.
  # Updatable in place.
}

variable "eventgrid_topic_primary_access_key" {
  type      = string
  sensitive = true
  # 🔒 Wrapped sensitive even though the PROVIDER does not mark this field sensitive. Read it from a
  #    data "azurerm_eventgrid_topic" block or from Key Vault; the topic module does not emit keys.
}

variable "eventgrid_topic_secondary_access_key" {
  type      = string
  sensitive = true
  # ⚠️ Pass the GENUINE secondary. Passing the primary twice is accepted and breaks rotation.
}

variable "dead_letter_storage_secret" {
  type      = string
  sensitive = true
  default   = null
  # ⚠️ Without it, an undeliverable event is dropped with no record. Dead-lettering is
  #    off by default, and an event is dead-lettered only on time-to-live expiry or exhausted
  #    delivery attempts.
  # Validated four ways: an https:// URL carrying a query string; the SAS not empty after the
  #    "?"; no doubled "??" (the blob-SAS data source already prefixes the delimiter); a
  #    "sig=" component, without which it is not a SAS at all; and a CONTAINER path rather
  #    than the bare account endpoint. The token needs only WRITE on that container.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
  # Each supplied value must be a Go duration ("30m", "1h30m", "90s") and must not be empty.
  # An undeclared key is silently DISCARDED by Terraform's object-type conversion.
}

🧾 Outputs

Output Description Sensitive
id The endpoint's Resource ID, nested beneath the Digital Twins instance. no
name The endpoint name an event route refers to. Force-new. no
digital_twins_id The instance this endpoint belongs to. no
dead_lettering_enabled Derived. ⚠️ false means undeliverable events are dropped with no record. no
authentication_type Always "KeyBased". Event Grid endpoints cannot use a managed identity — a service limitation, not a provider gap. no
refresh_reads_no_credentials Constant true. Refresh never fetches the topic's keys, so plan rights are not credential access. no
credential_drift_is_undetectable Constant true. Regenerated keys or an expired SAS produce no diff, ever. no
both_access_keys_are_the_same Derived. true means rotation is already broken — reported rather than refused. no
the_endpoint_name_is_part_of_the_dead_letter_path Constant true. The force-new name is a directory prefix, so renaming splits the dead-letter history. no
the_dead_letter_sas_needs_only_write_permission Constant true. The least-privilege target for the SAS this module cannot inspect. no
a_dead_lettered_message_matches_the_original_event_schema Constant true. What a dead-letter consumer can assume about the payload. no
arm_takes_the_topic_resource_id_where_terraform_takes_keys Constant true. Why the keys are inputs here and not in an equivalent ARM template. no

🔒 No credentials are emitted. The access keys and the dead-letter URL belong to the Event Grid topic and the storage account rather than to this endpoint, and re-emitting them would widen their reach for a caller who already holds them.

🧠 Architecture Notes

  • The key-based design is the service's, not the provider's. Azure Digital Twins supports managed-identity authentication for Event Hubs and Service Bus endpoints and explicitly does not for Event Grid. So unlike its two sibling endpoint modules, there is no future provider release that turns this into an identity-based endpoint — the keys are the design.

  • The provider's missing sensitivity markers are the defect this module exists to paper over. azurerm_digital_twins_endpoint_eventhub and azurerm_digital_twins_endpoint_servicebus mark their connection strings and dead-letter secrets sensitive; this resource marks none of its three credential fields. Variable-level sensitive = true propagates to the argument, so the plan is redacted regardless. State is not, and cannot be.

  • dead_lettering_enabled needs nonsensitive(), and that is safe. Sensitivity is contagious in Terraform: a boolean derived from a sensitive value is itself treated as sensitive and cannot be a root output unmarked. Marking the output sensitive would defeat the point of a posture flag, so it is explicitly de-sensitised — it reveals whether a secret was supplied, never any of its content.

  • The dead-letter validation targets a silent failure, which is why it is worth having. The service accepts a container URL with no SAS token; the endpoint then reports healthy and dead-lettering does nothing. What the validation cannot check is whether the token is valid or unexpired, so an expiring SAS reintroduces the same silence later.

  • Force-new is narrow here, and that is useful. Only name and digital_twins_id force replacement. Key rotation and re-pointing at a different topic are both in-place updates, so the endpoint keeps delivering and existing routes are untouched.

  • The name is the seam between Terraform and the data plane. Terraform owns the endpoint; event routes own the traffic, are created outside Terraform, and reference the endpoint by name. Nothing in a plan can tell you which routes a rename or a destroy will break, because routes are not in state.

  • No tags. The resource type does not support them, so the universal tail carries only timeouts. Tag the instance and the topic instead.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Credentials in plan output and CI logs redacted — every credential variable is sensitive = true, overriding the provider's unmarked schema —
Credentials in outputs never emitted; the keys and dead-letter URL are not re-emitted —
Credentials in state disclosed as unavoidable rather than implied to be handled —
Dead-letter destination absent but flagged: dead_lettering_enabled makes the gap visible in review leave it unset, knowingly
Dead-letter URL shape validated for a SAS query string, because a tokenless URL fails silently —
Least privilege the caller's write permission is scoped to the instance, not the topic; listKeys is read-only —
Identity documented as impossible for this endpoint type rather than left as an unexplained absence —
  • Before applying: confirm the secondary key is the genuine secondary, not a second copy of the primary.
  • Before applying: confirm a dead-letter destination is configured, and that its SAS token has a tracked expiry.
  • Before destroying or renaming: confirm which event routes name this endpoint. Terraform cannot tell you.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the source to a tag — ?ref=v1.0.0 — never a branch.
  • Plan-only from here. A human applies from CI.
  • ⚠️ Treat the state backend as holding live Event Grid access keys. Restrict access to it accordingly.
  • ⚠️ Rotate one key at a time, applying between rotations, so delivery is never without a valid key.
  • ⚠️ Before a plan that destroys or replaces this endpoint, check the twin graph's routes — they are not in state.

🧪 Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • name, digital_twins_id, the endpoint URL and both keys are present and typed string;
  • eventgrid_topic_endpoint starts with https://;
  • dead_letter_storage_secret, where supplied, is an HTTPS URL containing a SAS query string;
  • the timeouts value is an object of Go duration strings - but note that a key the type does not declare is SILENTLY DISCARDED rather than refused, so a misspelling here produces no error anywhere and the provider default quietly applies;
  • the module declares no provider block.

What only plan and apply exercise:

  • whether the access keys are correct and current — a stale key fails at apply, not at plan;
  • whether the secondary key is genuinely the secondary (nothing can check this at any stage);
  • whether the SAS token in the dead-letter URL is valid and unexpired;
  • whether the Digital Twins instance exists and the caller can write endpoints to it.

Not exercised by Terraform at all: whether any event route actually points at this endpoint. An endpoint with no route is a working resource that carries no traffic, and no Terraform command will tell you.

💬 Example Output

Outputs:

a_dead_lettered_message_matches_the_original_event_schema = true
arm_takes_the_topic_resource_id_where_terraform_takes_keys = true
authentication_type    = "KeyBased"
both_access_keys_are_the_same = false
credential_drift_is_undetectable = true
dead_lettering_enabled = true
digital_twins_id       = "/subscriptions/00000000-.../providers/Microsoft.DigitalTwins/digitalTwinsInstances/adt-twins-prod"
id                     = "/subscriptions/00000000-.../digitalTwinsInstances/adt-twins-prod/endpoints/eventgrid-out"
name                   = "eventgrid-out"
refresh_reads_no_credentials = true
the_dead_letter_sas_needs_only_write_permission = true
the_endpoint_name_is_part_of_the_dead_letter_path = true

💡 Three lines carry the review signal. dead_lettering_enabled = true says undeliverable events are captured rather than dropped. both_access_keys_are_the_same = false says key rotation is still possible — true means both slots hold the same value and rotating either breaks delivery. And name is the string an event route must match.

ℹ️ The constant true outputs are facts about the service that no field in state reflects: authentication_type = "KeyBased" because Event Grid endpoints cannot use a managed identity at all, and the_endpoint_name_is_part_of_the_dead_letter_path because the force-new name is a directory prefix in the dead-letter container.

🔍 Troubleshooting

Symptom Cause Fix
Access keys appear in cleartext in a plan The variables are not wrapped — the provider does not mark these fields sensitive. Use this module, which wraps them. If you see cleartext, something is passing the keys around outside it.
Apply fails authenticating to the topic A key is stale, or it belongs to a different topic than eventgrid_topic_endpoint. Re-read both keys and the endpoint from the same topic.
Plan rejects eventgrid_topic_endpoint A Resource ID or bare hostname was passed instead of the endpoint URL. Use the topic's endpoint attribute.
Plan rejects dead_letter_storage_secret One of five checks. No ? at all; a ? with nothing after it; a doubled ??; no sig= component; or no container path. Read the message — each names its own cause. The doubled ?? is the commonest: the blob-container SAS data source already prefixes the delimiter, so concatenate its sas attribute without adding one (example 6).
Dead-lettering silently stopped working The SAS token expired. The endpoint keeps reporting healthy. Reissue the token; track its expiry as a scheduled task.
Events stopped arriving after a key rotation Both keys were regenerated before the apply. Apply the new primary, then rotate the secondary. One at a time.
A single rotation broke delivery The primary key was passed as both primary and secondary, so there was no standby to fall back on. Pass the genuine secondary (example 8). Check both_access_keys_are_the_same before rotating — it reports this condition rather than refusing it, so it is visible in the plan output instead of only after delivery stops.
Nothing arrives at the topic, and the endpoint looks fine No event route in the twin graph names this endpoint. Create the route (example 9). Terraform models no route resource.
Routes stopped delivering after a rename name is force-new; the endpoint was replaced under the old routes. Update the routes to the new name, or keep the name fixed.
Apply fails with an authorization error on the instance The caller lacks endpoints/write on the Digital Twins instance. Grant Azure Digital Twins Data Owner, or Contributor on the instance.
The data source fails reading keys The caller lacks Microsoft.EventGrid/topics/listKeys/action. Grant it, or supply the keys from Key Vault (example 3).

🔗 Related Docs

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