Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4675390
initial commit
bachuv Aug 13, 2025
9d5c127
adding test
bachuv Aug 15, 2025
34687d3
reverted change to useTrace
bachuv Aug 15, 2025
73e46d0
adding client id support
bachuv Sep 3, 2025
6988fa5
updated managed identity settings
bachuv Sep 4, 2025
4fc4787
updated to use EventGrid__topicEndpoint app setting
bachuv Sep 4, 2025
38c5a14
Merge branch 'dev' into vabachu/event-grid-managed-identity
bachuv Sep 4, 2025
3b14265
removed topicKeySettingsConfigured
bachuv Sep 4, 2025
289bf56
updated tests
bachuv Sep 4, 2025
c63f547
updated durablehttptests to include clientid in managedidentityoptions
bachuv Sep 4, 2025
08122f6
added tests
bachuv Sep 5, 2025
33fa674
renaming
bachuv Sep 5, 2025
b756d3a
updated properties to internal
bachuv Sep 5, 2025
e1960d5
Merge branch 'dev' into vabachu/event-grid-managed-identity
bachuv Sep 5, 2025
ba89603
remove blank line
bachuv Sep 5, 2025
8921104
added test categories
bachuv Sep 8, 2025
4dd86a4
updated tests and added SetUpAuthentication helper method
bachuv Sep 9, 2025
96f9ca1
removed blank line
bachuv Sep 9, 2025
d93e0c9
added clientId parameter comment
bachuv Sep 9, 2025
4cebc87
addressed PR feedback
bachuv Sep 9, 2025
3e99a15
fixed some BindingTests because storageProviderType was getting read …
bachuv Sep 9, 2025
09bfd63
addressed pr feedback
bachuv Sep 10, 2025
f9913e8
addressed another round of PR feedback
bachuv Sep 11, 2025
9dff577
updated package versions
bachuv Sep 11, 2025
34245b8
updated constructors in ManagedIdentityOptions
bachuv Sep 11, 2025
3500688
addressed some pr feedback
bachuv Sep 15, 2025
97d98f6
Merge branch 'dev' into vabachu/event-grid-managed-identity
bachuv Sep 16, 2025
aa3644d
updated LifeCycleNotificationHelper to use lazy initialization
bachuv Sep 16, 2025
93248fb
update constructors in ManagedIdentityOptions
bachuv Sep 16, 2025
3de953f
updated tests
bachuv Sep 16, 2025
3ff8310
Merge branch 'dev' into vabachu/event-grid-managed-identity
bachuv Sep 16, 2025
84447ca
Revert my commits after 97d98f6e
bachuv Sep 16, 2025
4ff303d
added back ManagedIdentityOptions constructor changes
bachuv Sep 16, 2025
5ec24a0
moved SetUpAuthenticationAsync() call to StartTaskHubWorkerIfNotStart…
bachuv Sep 16, 2025
d670067
removed default constructor
bachuv Sep 16, 2025
d3e30f8
moved SetUpAuthenticationAsync to after the task hub worker is created
bachuv Sep 16, 2025
c386612
added call to RefreshAccessTokenAsync() in SendNotificationAsync
bachuv Sep 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/WebJobs.Extensions.DurableTask/DurableTaskExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -508,10 +508,17 @@ private void TraceConfigurationSettings()
private ILifeCycleNotificationHelper CreateLifeCycleNotificationHelper()
{
// First: EventGrid
if (this.Options.Notifications.EventGrid != null
&& (!string.IsNullOrEmpty(this.Options.Notifications.EventGrid.TopicEndpoint) || !string.IsNullOrEmpty(this.Options.Notifications.EventGrid.KeySettingName)))

EventGridNotificationOptions eventGridOptions = this.Options.Notifications.EventGrid;
bool topicKeySettingOrKeySettingNameConfigured = eventGridOptions != null
&& (!string.IsNullOrEmpty(eventGridOptions.TopicEndpoint)
|| !string.IsNullOrEmpty(eventGridOptions.KeySettingName));
bool usingManagedIdentity = !string.IsNullOrEmpty(this.nameResolver.Resolve(EventGridLifeCycleNotificationHelper.TopicEndpointKey));

if (topicKeySettingOrKeySettingNameConfigured || usingManagedIdentity)
{
return new EventGridLifeCycleNotificationHelper(this.Options, this.nameResolver, this.TraceHelper);
var notificationHelper = new EventGridLifeCycleNotificationHelper(this.Options, this.nameResolver, this.TraceHelper);
return notificationHelper;
}

// Fallback: Disable Notification
Expand Down Expand Up @@ -1325,6 +1332,11 @@ internal async Task<bool> StartTaskHubWorkerIfNotStartedAsync()
this.taskHubWorker.TaskOrchestrationDispatcher.IncludeDetails = true;
}

if (this.LifeCycleNotificationHelper is EventGridLifeCycleNotificationHelper lifeCycleNotificationHelper)
{
await lifeCycleNotificationHelper.SetUpAuthenticationAsync();
}

this.isTaskHubWorkerStarted = true;
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask
{
internal class EventGridLifeCycleNotificationHelper : ILifeCycleNotificationHelper
{
internal const string TopicEndpointKey = "EventGrid:topicEndpoint";
private const string CredentialKey = "EventGrid:credential";
private const string ClientIdKey = "EventGrid:clientId";
private const string ManagedIdentityValue = "managedidentity";

private readonly DurableTaskOptions options;
private readonly EndToEndTraceHelper traceHelper;
private readonly bool useTrace;
Expand All @@ -37,96 +42,141 @@ public EventGridLifeCycleNotificationHelper(
if (nameResolver == null)
{
throw new ArgumentNullException(nameof(nameResolver));
}

if (options.Notifications == null)
{
throw new ArgumentNullException(nameof(options.Notifications));
}

var eventGridNotificationsConfig = options.Notifications.EventGrid ?? throw new ArgumentNullException(nameof(options.Notifications.EventGrid));
}

EventGridNotificationOptions eventGridNotificationsConfig = null;

this.eventGridKeyValue = nameResolver.Resolve(eventGridNotificationsConfig.KeySettingName);
this.eventGridTopicEndpoint = eventGridNotificationsConfig.TopicEndpoint;
this.UseManagedIdentity = false;

if (nameResolver.TryResolveWholeString(eventGridNotificationsConfig.TopicEndpoint, out var endpoint))
{
this.eventGridTopicEndpoint = endpoint;
}
// Check to see if we have a topic name app setting defined. If so, we will use managed identity to authenticate.
string topicEndpoint = nameResolver.Resolve(TopicEndpointKey);
if (!string.IsNullOrEmpty(topicEndpoint))
{
this.UseManagedIdentity = true;
this.eventGridTopicEndpoint = topicEndpoint;

string clientId = nameResolver.Resolve(ClientIdKey);
if (string.Equals(nameResolver.Resolve(CredentialKey), ManagedIdentityValue) &&
!string.IsNullOrEmpty(clientId))
{
// Use user assigned managed identity
this.ManagedIdentityTokenSource = new ManagedIdentityTokenSource("https://eventgrid.azure.net/.default", new ManagedIdentityOptions(null, null, clientId));
}
else
{
// Use system assigned managed identity
this.ManagedIdentityTokenSource = new ManagedIdentityTokenSource("https://eventgrid.azure.net/.default");
}
}
else
{
// Set up configuration for key based authentication
eventGridNotificationsConfig = options.Notifications.EventGrid ?? throw new ArgumentNullException(nameof(options.Notifications.EventGrid));

this.eventGridKeyValue = nameResolver.Resolve(eventGridNotificationsConfig.KeySettingName);

this.eventGridTopicEndpoint = eventGridNotificationsConfig.TopicEndpoint;

if (nameResolver.TryResolveWholeString(eventGridNotificationsConfig.TopicEndpoint, out var endpoint))
{
this.eventGridTopicEndpoint = endpoint;
}
}

// Log warning if both managed identity and key based authentication are configured.
if (this.UseManagedIdentity && options.Notifications != null && options.Notifications.EventGrid != null && (!string.IsNullOrEmpty(options.Notifications.EventGrid.TopicEndpoint) || !string.IsNullOrEmpty(options.Notifications.EventGrid.KeySettingName)))
{
this.traceHelper.ExtensionWarningEvent(
hubName: this.options.HubName,
functionName: "",
instanceId: "",
"Both managed identity and key based authentication are configured for Event Grid notifications. Managed Identity will be used for authentication. Please configure either managed identity or key based authentication for best results.");
}

// Check if we have the minimum required settings to enable Event Grid notifications with key based authentication.
bool eventGridNotificationSettingsConfigured = false;

if (eventGridNotificationsConfig != null)
{
if (!string.IsNullOrEmpty(eventGridNotificationsConfig.KeySettingName) && string.IsNullOrEmpty(this.eventGridKeyValue))
{
throw new ArgumentException($"Failed to start lifecycle notification feature. Please check the configuration values for {eventGridNotificationsConfig.KeySettingName} on AppSettings.");
}

if (string.IsNullOrEmpty(eventGridNotificationsConfig.KeySettingName) || string.IsNullOrEmpty(eventGridNotificationsConfig.TopicEndpoint))
{
throw new ArgumentException($"Failed to start lifecycle notification feature. Please check the configuration values for {eventGridNotificationsConfig.TopicEndpoint} and {eventGridNotificationsConfig.KeySettingName}.");
}

eventGridNotificationSettingsConfigured = true;
}

if (!string.IsNullOrEmpty(this.eventGridTopicEndpoint))
{
if (!string.IsNullOrEmpty(eventGridNotificationsConfig.KeySettingName))
{
this.useTrace = true;
if (this.UseManagedIdentity || eventGridNotificationSettingsConfigured)
{
this.useTrace = true;

var retryStatusCode = eventGridNotificationsConfig.PublishRetryHttpStatus?
.Where(x => Enum.IsDefined(typeof(HttpStatusCode), x))
.Select(x => (HttpStatusCode)x)
.ToArray()
?? Array.Empty<HttpStatusCode>();

if (eventGridNotificationsConfig.PublishEventTypes == null || eventGridNotificationsConfig.PublishEventTypes.Length == 0)
var retryStatusCode = (eventGridNotificationsConfig != null && eventGridNotificationsConfig.PublishRetryHttpStatus != null) ?
eventGridNotificationsConfig.PublishRetryHttpStatus?
.Where(x => Enum.IsDefined(typeof(HttpStatusCode), x))
.Select(x => (HttpStatusCode)x)
.ToArray()
: Array.Empty<HttpStatusCode>();

if (eventGridNotificationsConfig == null || eventGridNotificationsConfig.PublishEventTypes == null || eventGridNotificationsConfig.PublishEventTypes.Length == 0)
{
this.eventGridPublishEventTypes = (OrchestrationRuntimeStatus[])Enum.GetValues(typeof(OrchestrationRuntimeStatus));
}
else
{
var startedIndex = Array.FindIndex(eventGridNotificationsConfig.PublishEventTypes, x => x == "Started");
if (startedIndex > -1)
{
this.eventGridPublishEventTypes = (OrchestrationRuntimeStatus[])Enum.GetValues(typeof(OrchestrationRuntimeStatus));
eventGridNotificationsConfig.PublishEventTypes[startedIndex] = OrchestrationRuntimeStatus.Running.ToString();
}
else
{
var startedIndex = Array.FindIndex(eventGridNotificationsConfig.PublishEventTypes, x => x == "Started");
if (startedIndex > -1)
{
eventGridNotificationsConfig.PublishEventTypes[startedIndex] = OrchestrationRuntimeStatus.Running.ToString();
}

OrchestrationRuntimeStatus ParseAndvalidateEvents(string @event)
OrchestrationRuntimeStatus ParseAndvalidateEvents(string @event)
{
var success = Enum.TryParse(@event, out OrchestrationRuntimeStatus @enum);
if (success)
{
var success = Enum.TryParse(@event, out OrchestrationRuntimeStatus @enum);
if (success)
{
switch (@enum)
{
case OrchestrationRuntimeStatus.Canceled:
case OrchestrationRuntimeStatus.ContinuedAsNew:
case OrchestrationRuntimeStatus.Pending:
success = false;
break;
default:
break;
}
}

if (!success)
switch (@enum)
{
throw new ArgumentException("Failed to start lifecycle notification feature. Unsupported event types detected in 'EventGridPublishEventTypes'. You may only specify one or more of the following 'Started', 'Completed', 'Failed', 'Terminated'.");
case OrchestrationRuntimeStatus.Canceled:
case OrchestrationRuntimeStatus.ContinuedAsNew:
case OrchestrationRuntimeStatus.Pending:
success = false;
break;
default:
break;
}
}

return @enum;
if (!success)
{
throw new ArgumentException("Failed to start lifecycle notification feature. Unsupported event types detected in 'EventGridPublishEventTypes'. You may only specify one or more of the following 'Started', 'Completed', 'Failed', 'Terminated'.");
}

this.eventGridPublishEventTypes = eventGridNotificationsConfig.PublishEventTypes.Select(x => ParseAndvalidateEvents(x)).ToArray();
}

// Currently, we support Event Grid Custom Topic for notify the lifecycle event of an orchestrator.
// For more detail about the Event Grid, please refer this document.
// Post to custom topic for Azure Event Grid
// https://docs.microsoft.com/en-us/azure/event-grid/post-to-custom-topic
this.HttpMessageHandler = options.NotificationHandler ?? new HttpRetryMessageHandler(
new HttpClientHandler(),
eventGridNotificationsConfig.PublishRetryCount,
eventGridNotificationsConfig.PublishRetryInterval,
retryStatusCode);

if (string.IsNullOrEmpty(this.eventGridKeyValue))
{
throw new ArgumentException($"Failed to start lifecycle notification feature. Please check the configuration values for {eventGridNotificationsConfig.KeySettingName} on AppSettings.");
}
}
else
{
throw new ArgumentException($"Failed to start lifecycle notification feature. Please check the configuration values for {eventGridNotificationsConfig.TopicEndpoint} and {eventGridNotificationsConfig.KeySettingName}.");
return @enum;
}

this.eventGridPublishEventTypes = eventGridNotificationsConfig.PublishEventTypes.Select(x => ParseAndvalidateEvents(x)).ToArray();
}
}
}

// Currently, we support Event Grid Custom Topic for notify the lifecycle event of an orchestrator.
// For more detail about the Event Grid, please refer this document.
// Post to custom topic for Azure Event Grid
// https://docs.microsoft.com/en-us/azure/event-grid/post-to-custom-topic
this.HttpMessageHandler = options.NotificationHandler ?? new HttpRetryMessageHandler(
new HttpClientHandler(),
eventGridNotificationsConfig != null ? eventGridNotificationsConfig.PublishRetryCount : 0,
eventGridNotificationsConfig != null ? eventGridNotificationsConfig.PublishRetryInterval : TimeSpan.FromMinutes(5),
retryStatusCode);
}
}

internal bool UseManagedIdentity { get; private set; }

internal ManagedIdentityTokenSource ManagedIdentityTokenSource { get; private set; }

public string EventGridKeyValue => this.eventGridKeyValue;

Expand All @@ -139,11 +189,33 @@ public HttpMessageHandler HttpMessageHandler
{
httpClient?.Dispose();
httpMessageHandler = value;
httpClient = new HttpClient(httpMessageHandler);
httpClient.DefaultRequestHeaders.Add("aeg-sas-key", this.eventGridKeyValue);
httpClient = new HttpClient(httpMessageHandler);
}
}

internal async Task SetUpAuthenticationAsync()
{
if (this.UseManagedIdentity)
{
// Use Bearer token for Managed Identity
await this.RefreshAccessTokenAsync();
}
else
{
// Use key-based authentication
if (httpClient != null)
{
httpClient.DefaultRequestHeaders.Add("aeg-sas-key", this.eventGridKeyValue);
}
}
}

private async Task RefreshAccessTokenAsync()
{
string accessToken = await this.ManagedIdentityTokenSource.GetTokenAsync();
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
}

private async Task SendNotificationAsync(
EventGridEvent[] eventGridEventArray,
string hubName,
Expand All @@ -161,6 +233,11 @@ private async Task SendNotificationAsync(
HttpResponseMessage result = null;
try
{
if (this.UseManagedIdentity)
{
await this.RefreshAccessTokenAsync();
}

result = await httpClient.PostAsync(this.eventGridTopicEndpoint, content);
}
catch (Exception e)
Expand Down
19 changes: 18 additions & 1 deletion src/WebJobs.Extensions.DurableTask/ManagedIdentityOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,26 @@ namespace Microsoft.Azure.WebJobs.Extensions.DurableTask
/// </summary>
public class ManagedIdentityOptions
{
/// <summary>
/// <summary>
/// Initializes a new instance of the <see cref="ManagedIdentityOptions"/> class.
/// </summary>
/// <param name="authorityHost">The host of the Azure Active Directory authority.</param>
/// <param name="tenantId">The tenant id of the user to authenticate.</param>
public ManagedIdentityOptions(Uri authorityHost = null, string tenantId = null)
: this(authorityHost, tenantId, null)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="ManagedIdentityOptions"/> class.
/// </summary>
/// <param name="clientId">The client id of the user assigned managed identity.</param>
[JsonConstructor]
public ManagedIdentityOptions(Uri authorityHost, string tenantId, string clientId)
{
this.AuthorityHost = authorityHost;
this.TenantId = tenantId;
this.ClientId = clientId;
}

/// <summary>
Expand All @@ -33,5 +44,11 @@ public ManagedIdentityOptions(Uri authorityHost = null, string tenantId = null)
/// </summary>
[JsonProperty("tenantid")]
public string TenantId { get; set; }

/// <summary>
/// The client id of the user assigned managed identity.
/// </summary>
[JsonProperty("clientid")]
public string ClientId { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ public async Task<string> GetTokenAsync()
defaultAzureCredentialOptions.InteractiveBrowserTenantId = this.Options.TenantId;
}

if (!string.IsNullOrEmpty(this.Options?.ClientId))
{
defaultAzureCredentialOptions.ManagedIdentityClientId = this.Options.ClientId;
}

defaultCredential = this.Options == null ? new DefaultAzureCredential() : new DefaultAzureCredential(defaultAzureCredentialOptions); // CodeQL [SM05137] Use DefaultAzureCredential explicitly for local development and is decided by the user
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One change we should consider in the future is the ability to choose a credential type other than DefaultAzureCredential. This credential type is convenient but can cause problems or unexpected behavior in some setups because it may not always choose to use managed identity, even if managed identity is available.


AccessToken defaultToken = await defaultCredential.GetTokenAsync(context);
Expand Down
Loading
Loading