EventGrid: Restricting Managed Identity for Delivery and Destination Allowlist
The first Event Grid policy I wrote controlled which managed identity types could be used on system topics. That closed one gap. But it didn't prevent teams from creating event subscriptions that use key-based authentication for delivery, or that route events to arbitrary destinations. This policy enforces both controls simultaneously: every event subscription must use managed identity delivery, and the destination must be on an approved allowlist.
The reason I combined these into a single policy instead of writing two separate ones is practical. In every enterprise deployment I've worked on, both controls matter equally. A subscription that passes the destination check but uses key-based authentication is still wrong. A subscription that uses managed identity but delivers to an unapproved webhook is also wrong. Combining them means one compliance signal, one assignment to manage, and one exemption process if a team needs relief.
What the policy does
This policy targets Microsoft.EventGrid/systemTopics/eventSubscriptions and enforces two layers of validation:
Layer 1 — Require managed identity delivery. The policy denies a subscription if any of these conditions are true:
- The top-level
destinationfield exists (this is the key-based delivery path) - The
deliveryWithResourceIdentityfield is absent entirely - The
deliveryWithResourceIdentity.identity.typeisn't in the allowed list (defaults to["SystemAssigned"])
Layer 2 — Allowlist destination resources. When managed identity delivery is used, the destination lives under deliveryWithResourceIdentity.destination, not the top-level destination path. This is the critical detail that most people miss. The policy checks each destination type separately:
| Endpoint type | Parameter | Field checked |
|---|---|---|
EventHub | allowedEventHubResourceIds | deliveryWithResourceIdentity.destination.EventHub.resourceId |
ServiceBusQueue | allowedServiceBusQueueResourceIds | deliveryWithResourceIdentity.destination.ServiceBusQueue.resourceId |
ServiceBusTopic | allowedServiceBusTopicResourceIds | deliveryWithResourceIdentity.destination.ServiceBusTopic.resourceId |
StorageQueue | allowedStorageAccountResourceIds | deliveryWithResourceIdentity.destination.StorageQueue.resourceId |
AzureFunction | allowedAzureFunctionResourceIds | deliveryWithResourceIdentity.destination.AzureFunction.resourceId |
WebHook | allowedWebhookEndpointUrls | deliveryWithResourceIdentity.destination.WebHook.endpointUrl |
HybridConnection | allowedHybridConnectionResourceIds | deliveryWithResourceIdentity.destination.HybridConnection.resourceId |
NamespaceTopic | allowedNamespaceTopicResourceIds | deliveryWithResourceIdentity.destination.NamespaceTopic.resourceId |
PartnerDestination | allowedPartnerDestinationResourceIds | deliveryWithResourceIdentity.destination.PartnerDestination.resourceId |
MonitorAlert | allowMonitorAlertDestination | Special case: set to "Deny" to block entirely |
An empty allowlist for any resource-based destination type blocks all destinations of that type. If I set allowedEventHubResourceIds to [], no Event Hub subscriptions can be created. That's useful when I want to explicitly prohibit certain destination classes in a landing zone.
MonitorAlert is handled differently because it doesn't have a single resource ID field. It uses an array of action group IDs, which the policy can't easily validate with an in check. The allowMonitorAlertDestination parameter is a simple "Allow" or "Deny" toggle. I typically set it to "Deny" in Corp landing zones where routing to Monitor Alerts isn't a pattern we use.
Policy definition
{
"properties": {
"displayName": "pol-eventgrid-restrict-mi-and-destination",
"policyType": "Custom",
"mode": "All",
"description": "",
"metadata": {
"category": "Event Grid",
"version": "1.0.0"
},
"version": "1.0.0",
"parameters": {
"effect": {
"type": "String",
"metadata": {
"displayName": "Effect",
"description": "Audit reports non-compliant resources without blocking. Deny blocks the request. Start with Audit."
},
"allowedValues": [
"Audit",
"Deny",
"Disabled"
],
"defaultValue": "Deny"
},
"allowedIdentityTypes": {
"type": "Array",
"metadata": {
"displayName": "Allowed Managed Identity Types",
"description": "Permitted values for deliveryWithResourceIdentity.identity.type. ARM returns 'SystemAssigned, UserAssigned' (with space) for dual. Default is SystemAssigned only."
},
"defaultValue": [
"SystemAssigned"
]
},
"allowedEventHubResourceIds": {
"type": "Array",
"metadata": {
"displayName": "Allowed Event Hub Resource IDs",
"description": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.EventHub/namespaces/{ns}/eventhubs/{eh}"
},
"defaultValue": []
},
"allowedServiceBusQueueResourceIds": {
"type": "Array",
"metadata": {
"displayName": "Allowed Service Bus Queue Resource IDs",
"description": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ServiceBus/namespaces/{ns}/queues/{queue}"
},
"defaultValue": []
},
"allowedServiceBusTopicResourceIds": {
"type": "Array",
"metadata": {
"displayName": "Allowed Service Bus Topic Resource IDs",
"description": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ServiceBus/namespaces/{ns}/topics/{topic}"
},
"defaultValue": []
},
"allowedStorageAccountResourceIds": {
"type": "Array",
"metadata": {
"displayName": "Allowed Storage Account Resource IDs (Storage Queue destinations)",
"description": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{account}"
},
"defaultValue": []
},
"allowedAzureFunctionResourceIds": {
"type": "Array",
"metadata": {
"displayName": "Allowed Azure Function Resource IDs",
"description": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Web/sites/{app}/functions/{fn}"
},
"defaultValue": []
},
"allowedWebhookEndpointUrls": {
"type": "Array",
"metadata": {
"displayName": "Allowed WebHook Endpoint URLs",
"description": "Exact endpoint URLs permitted as webhook destinations."
},
"defaultValue": []
},
"allowedHybridConnectionResourceIds": {
"type": "Array",
"metadata": {
"displayName": "Allowed Hybrid Connection Resource IDs",
"description": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Relay/namespaces/{ns}/hybridConnections/{hc}"
},
"defaultValue": []
},
"allowedNamespaceTopicResourceIds": {
"type": "Array",
"metadata": {
"displayName": "Allowed Namespace Topic Resource IDs",
"description": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.EventGrid/namespaces/{ns}/topics/{topic}"
},
"defaultValue": []
},
"allowedPartnerDestinationResourceIds": {
"type": "Array",
"metadata": {
"displayName": "Allowed Partner Destination Resource IDs",
"description": "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.EventGrid/partnerDestinations/{dest}"
},
"defaultValue": []
},
"allowMonitorAlertDestination": {
"type": "String",
"metadata": {
"displayName": "Allow Monitor Alert Destination",
"description": "MonitorAlert has no single resourceId - it uses an array of action group IDs. Set to Allow to permit it, Deny to block it entirely."
},
"allowedValues": [
"Allow",
"Deny"
],
"defaultValue": "Deny"
}
},
"policyRule": {
"if": {
"allOf": [
{
"field": "type",
"equals": "Microsoft.EventGrid/systemTopics/eventSubscriptions"
},
{
"anyOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/destination",
"exists": "true"
},
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity",
"exists": "false"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.identity.type",
"in": "[parameters('allowedIdentityTypes')]"
}
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "EventHub"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.EventHub.resourceId",
"in": "[parameters('allowedEventHubResourceIds')]"
}
}
]
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "ServiceBusQueue"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.ServiceBusQueue.resourceId",
"in": "[parameters('allowedServiceBusQueueResourceIds')]"
}
}
]
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "ServiceBusTopic"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.ServiceBusTopic.resourceId",
"in": "[parameters('allowedServiceBusTopicResourceIds')]"
}
}
]
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "StorageQueue"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.StorageQueue.resourceId",
"in": "[parameters('allowedStorageAccountResourceIds')]"
}
}
]
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "AzureFunction"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.AzureFunction.resourceId",
"in": "[parameters('allowedAzureFunctionResourceIds')]"
}
}
]
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "WebHook"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.WebHook.endpointUrl",
"in": "[parameters('allowedWebhookEndpointUrls')]"
}
}
]
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "HybridConnection"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.HybridConnection.resourceId",
"in": "[parameters('allowedHybridConnectionResourceIds')]"
}
}
]
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "NamespaceTopic"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.NamespaceTopic.resourceId",
"in": "[parameters('allowedNamespaceTopicResourceIds')]"
}
}
]
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "PartnerDestination"
},
{
"not": {
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.PartnerDestination.resourceId",
"in": "[parameters('allowedPartnerDestinationResourceIds')]"
}
}
]
},
{
"allOf": [
{
"field": "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.destination.endpointType",
"equals": "MonitorAlert"
},
{
"value": "[parameters('allowMonitorAlertDestination')]",
"equals": "Deny"
}
]
}
]
}
]
},
"then": {
"effect": "[parameters('effect')]"
}
}
}
}
Terraform assignment
I assign this at the landing zone management group level. The allowlists vary by workload class, so I don't use a single root-level assignment. Corp workloads route to internal Service Bus namespaces, Online workloads sometimes need webhooks to SaaS endpoints.
resource "azurerm_policy_definition" "eventgrid_restrict_mi_destination" {
name = "pol-eventgrid-restrict-mi-and-destination"
policy_type = "Custom"
mode = "All"
display_name = "EventGrid: Restrict Managed Identity for Delivery and Destination Allowlist"
metadata = jsonencode({
category = "Event Grid"
version = "1.0.0"
})
policy_rule = jsonencode({
if = {
allOf = [
{
field = "type"
equals = "Microsoft.EventGrid/systemTopics/eventSubscriptions"
},
{
anyOf = [
{
field = "Microsoft.EventGrid/systemTopics/eventSubscriptions/destination"
exists = "true"
},
{
field = "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity"
exists = "false"
},
{
not = {
field = "Microsoft.EventGrid/systemTopics/eventSubscriptions/deliveryWithResourceIdentity.identity.type"
in = "[parameters('allowedIdentityTypes')]"
}
},
# ... (all the destination type checks - abbreviated for brevity)
]
}
]
}
then = {
effect = "[parameters('effect')]"
}
})
parameters = jsonencode({
effect = { type = "String", defaultValue = "Deny", allowedValues = ["Audit", "Deny", "Disabled"], metadata = { displayName = "Effect" } }
allowedIdentityTypes = { type = "Array", defaultValue = ["SystemAssigned"], metadata = { displayName = "Allowed Managed Identity Types" } }
allowedEventHubResourceIds = { type = "Array", defaultValue = [], metadata = { displayName = "Allowed Event Hub Resource IDs" } }
allowedServiceBusQueueResourceIds = { type = "Array", defaultValue = [], metadata = { displayName = "Allowed Service Bus Queue Resource IDs" } }
allowedServiceBusTopicResourceIds = { type = "Array", defaultValue = [], metadata = { displayName = "Allowed Service Bus Topic Resource IDs" } }
allowedStorageAccountResourceIds = { type = "Array", defaultValue = [], metadata = { displayName = "Allowed Storage Account Resource IDs" } }
allowedAzureFunctionResourceIds = { type = "Array", defaultValue = [], metadata = { displayName = "Allowed Azure Function Resource IDs" } }
allowedWebhookEndpointUrls = { type = "Array", defaultValue = [], metadata = { displayName = "Allowed WebHook Endpoint URLs" } }
allowedHybridConnectionResourceIds = { type = "Array", defaultValue = [], metadata = { displayName = "Allowed Hybrid Connection Resource IDs" } }
allowedNamespaceTopicResourceIds = { type = "Array", defaultValue = [], metadata = { displayName = "Allowed Namespace Topic Resource IDs" } }
allowedPartnerDestinationResourceIds = { type = "Array", defaultValue = [], metadata = { displayName = "Allowed Partner Destination Resource IDs" } }
allowMonitorAlertDestination = { type = "String", defaultValue = "Deny", allowedValues = ["Allow", "Deny"], metadata = { displayName = "Allow Monitor Alert Destination" } }
})
}
resource "azurerm_management_group_policy_assignment" "eventgrid_restrict_mi_destination_corp" {
name = "eg-restrict-mi-dest-corp"
management_group_id = azurerm_management_group.corp.id
policy_definition_id = azurerm_policy_definition.eventgrid_restrict_mi_destination.id
display_name = "EventGrid: Restrict MI and Destinations (Corp)"
enforce = true
parameters = jsonencode({
effect = {
value = "Deny"
}
allowedIdentityTypes = {
value = ["SystemAssigned"]
}
allowedEventHubResourceIds = {
value = [
"/subscriptions/3679dc70-220c-475b-8bf6-983e51dfdbc9/resourceGroups/rg-eventing-prod/providers/Microsoft.EventHub/namespaces/evhns-platform-prod/eventhubs/evh-corp-events"
]
}
allowedServiceBusQueueResourceIds = { value = [] }
allowedServiceBusTopicResourceIds = { value = [] }
allowedStorageAccountResourceIds = { value = [] }
allowedAzureFunctionResourceIds = { value = [] }
allowedWebhookEndpointUrls = { value = [] }
allowedHybridConnectionResourceIds = { value = [] }
allowedNamespaceTopicResourceIds = { value = [] }
allowedPartnerDestinationResourceIds = { value = [] }
allowMonitorAlertDestination = { value = "Deny" }
})
}
I pin allowedIdentityTypes to ["SystemAssigned"] in Corp. User-assigned identities require separate lifecycle management, and I've seen environments where the identity gets deleted but the subscription keeps trying to use it, causing silent delivery failures.
Things I've gotten wrong
The destination alias path changes when managed identity is used. This is the single most common mistake I see when people try to extend a basic destination-restriction policy. If the subscription uses the top-level destination field (key-based auth), the resource ID is at destination.EventHub.resourceId. If the subscription uses deliveryWithResourceIdentity, the resource ID is at deliveryWithResourceIdentity.destination.EventHub.resourceId. They're different paths. The policy checks the managed identity path because that's what the policy requires.
StorageQueue still uses the storage account ID, not the queue ID. The resource ID in deliveryWithResourceIdentity.destination.StorageQueue.resourceId is the parent storage account resource ID, not the queue within it. I put the queue URL in the allowlist the first time and couldn't figure out why the policy kept denying compliant subscriptions.
The top-level destination check is necessary even though it feels redundant. When deliveryWithResourceIdentity is set, the top-level destination field shouldn't exist. But some older Terraform provider versions and SDK versions set both fields. Explicitly denying if destination exists catches those edge cases.
Webhook URLs need to be exact matches. The in operator does string equality. If the webhook URL includes a query string, trailing slash, or any variation that differs from what's in the allowlist, it won't match. I normalize URLs in the allowlist and document that any URL variant needs its own entry.
Rolling out with Deny from day one breaks things. Two controls means twice as many ways to be non-compliant. I start with Audit, run the compliance scan, separate findings into "wrong identity type" and "unapproved destination" buckets, remediate each, then flip to Deny. The blast radius is larger with a combined policy.
MonitorAlert destinations can't be validated with a resource ID check. The MonitorAlert destination type uses an array of action group IDs, not a single resource ID field. I can't use the in operator to check it against an allowlist. The allowMonitorAlertDestination parameter is a blunt toggle — either allow all MonitorAlert destinations or block them entirely. In Corp landing zones, I set it to "Deny" because we don't use that pattern.
Why not two separate policies
I've run both approaches. With two separate policies, you get two compliance states to reconcile, and it's possible for a subscription to pass the destination check but fail the identity check or vice versa. When both controls matter equally (and for me they do), combining them into one definition means one compliance signal, one assignment to manage, one exemption process if a team needs relief from both.
The trade-off is flexibility. If a team needs to relax only the destination check without touching the identity requirement, a combined policy makes that harder. In practice I haven't needed that granularity, but if I did, I'd split them back out.