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. Targetshashicorp/azurerm ~> 4.0.
- 📤 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 issensitive = 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.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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;
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;
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. |
| 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:
nameanddigital_twins_idare 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_secretare not marked sensitive by the provider on this resource type — though they are onazurerm_digital_twins_endpoint_eventhubandazurerm_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_endpointis the topic's HTTPS endpoint URL, not a Resource ID and not a hostname.- No
tags. The universal tail istimeoutsonly. - 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.
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_secretcarries its own authorization. That is convenient and also the reason the token's expiry is your problem — see example 6.
- The
Microsoft.DigitalTwinsandMicrosoft.EventGridresource 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.
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
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.
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-topicdoes not emit them, because this suite's modules do not emit secrets. Theazurerm_eventgrid_topicdata 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 |
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
identityargument 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-topicemitsid,nameandendpointbut 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 needsMicrosoft.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
listKeyson 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_enabledis 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'"
⚠️ nameis 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
nameis 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
nameunchanged 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_enabledis a derived boolean rather than the URL itself, so the posture is reviewable without exposing a SAS token.falseis 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 nameeventgrid-outbefore 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.
| 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.
}| 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.
-
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_eventhubandazurerm_digital_twins_endpoint_servicebusmark their connection strings and dead-letter secrets sensitive; this resource marks none of its three credential fields. Variable-levelsensitive = truepropagates to the argument, so the plan is redacted regardless. State is not, and cannot be. -
dead_lettering_enabledneedsnonsensitive(), 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
nameanddigital_twins_idforce 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 onlytimeouts. Tag the instance and the topic instead.
| 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.
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.
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 typedstring;eventgrid_topic_endpointstarts withhttps://;dead_letter_storage_secret, where supplied, is an HTTPS URL containing a SAS query string;- the
timeoutsvalue 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
providerblock.
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.
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 = truesays undeliverable events are captured rather than dropped.both_access_keys_are_the_same = falsesays key rotation is still possible —truemeans both slots hold the same value and rotating either breaks delivery. Andnameis the string an event route must match.
ℹ️ The constant
trueoutputs 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, andthe_endpoint_name_is_part_of_the_dead_letter_pathbecause the force-newnameis a directory prefix in the dead-letter container.
| 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). |
azurerm_digital_twins_endpoint_eventgrid— provider documentation for this resource.azurerm_digital_twins_instance— the instance this endpoint belongs to.azurerm_eventgrid_topicdata source — the supported way to read the topic's access keys.- Azure Digital Twins endpoints and event routes — how routes select an endpoint, and why Event Grid endpoints cannot use a managed identity.
- Sibling modules in this family:
terraform-azurerm-digital-twins-instance,terraform-azurerm-digital-twins-endpoint-eventhub,terraform-azurerm-digital-twins-endpoint-servicebus,terraform-azurerm-digital-twins-time-series-database-connection. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."