From f28b45479c266fc65493608c7a1f8b75065a028c Mon Sep 17 00:00:00 2001 From: Tomasz Wegiel Date: Mon, 3 Nov 2025 13:26:51 +0100 Subject: [PATCH] Add Azure Entra authorization --- docs/CHANGELOG.md | 3 ++ .../Configuration/ConnectionSettings.cs | 52 +++++++++++++++---- .../Extensions/ClientOptionsExtensions.cs | 24 ++++++++- .../Configuration/ServiceBusSettings.cs | 8 ++- .../Ev.ServiceBus.Abstractions.csproj | 1 + .../ConnectionSettingsComparer.cs | 9 +++- .../RegistrationService.cs | 23 ++++++-- .../Dispatch/DispatchRegistrationBuilder.cs | 14 +++++ .../Management/Factories/ClientFactory.cs | 18 +++++-- .../ComplexTest.cs | 33 ++++++++++++ 10 files changed, 162 insertions(+), 23 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 933eb6e..6609d0b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.4.0 +- Added + - Introduced a new way to connect to Service Bus using Azure Entra authentication. ## 5.3.1 - Changed - Fix usage of sent counter on receiver instead of received counter diff --git a/src/Ev.ServiceBus.Abstractions/Configuration/ConnectionSettings.cs b/src/Ev.ServiceBus.Abstractions/Configuration/ConnectionSettings.cs index a9f363c..216646a 100644 --- a/src/Ev.ServiceBus.Abstractions/Configuration/ConnectionSettings.cs +++ b/src/Ev.ServiceBus.Abstractions/Configuration/ConnectionSettings.cs @@ -1,5 +1,6 @@ -using System; +using Azure.Core; using Azure.Messaging.ServiceBus; +using System; namespace Ev.ServiceBus.Abstractions; @@ -12,9 +13,23 @@ internal ConnectionSettings(string connectionString, ServiceBusClientOptions opt Endpoint = GetEndpointFromConnectionString(connectionString); } + internal ConnectionSettings(string fullyQualifiedNamespace, TokenCredential credentials, ServiceBusClientOptions options) + { + Options = options; + FullyQualifiedNamespace = fullyQualifiedNamespace; + Credentials = credentials; + Endpoint = GetEndpointFromFullyQualifiedNamespace(fullyQualifiedNamespace); + } + public string Endpoint { get; } - public string ConnectionString { get; } - public ServiceBusClientOptions Options { get; } + + public string? ConnectionString { get; } + + public ServiceBusClientOptions? Options { get; } + + public string? FullyQualifiedNamespace { get; } + + public TokenCredential? Credentials { get; } private string GetEndpointFromConnectionString(string connectionString) { @@ -43,17 +58,34 @@ private string GetEndpointFromConnectionString(string connectionString) return string.Empty; } - public override int GetHashCode() + private string GetEndpointFromFullyQualifiedNamespace(string fullyQualifiedNamespace) { - return Endpoint.GetHashCode(); + return $"sb://{fullyQualifiedNamespace}/"; } + private bool Equals(ConnectionSettings other) => + string.Equals(Endpoint, other.Endpoint, StringComparison.Ordinal) + && string.Equals(ConnectionString, other.ConnectionString, StringComparison.Ordinal) + && Options != null + && Options.Equals(other.Options) + && string.Equals(FullyQualifiedNamespace, other.FullyQualifiedNamespace, StringComparison.Ordinal) + && Equals(Credentials, other.Credentials); + public override bool Equals(object? obj) { - if (obj is not ConnectionSettings settings) - { - return false; - } - return Endpoint.Equals(settings.Endpoint); + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + return Equals((ConnectionSettings)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine( + Endpoint, + ConnectionString, + Options, + FullyQualifiedNamespace, + Credentials); } } \ No newline at end of file diff --git a/src/Ev.ServiceBus.Abstractions/Configuration/Extensions/ClientOptionsExtensions.cs b/src/Ev.ServiceBus.Abstractions/Configuration/Extensions/ClientOptionsExtensions.cs index 1c15222..54194df 100644 --- a/src/Ev.ServiceBus.Abstractions/Configuration/Extensions/ClientOptionsExtensions.cs +++ b/src/Ev.ServiceBus.Abstractions/Configuration/Extensions/ClientOptionsExtensions.cs @@ -1,4 +1,5 @@ -using Azure.Messaging.ServiceBus; +using Azure.Core; +using Azure.Messaging.ServiceBus; // ReSharper disable once CheckNamespace namespace Ev.ServiceBus.Abstractions; @@ -23,4 +24,25 @@ public static TOptions WithConnection( options.ConnectionSettings = new ConnectionSettings(connectionString, connectionOptions); return options; } + + /// + /// Sets the connection to use for this resource using Azure Entra ID. + /// If no connection is set then the default connection will be used. + /// + /// + /// + /// + /// + /// + /// + public static TOptions WithConnection( + this TOptions options, + string fullyQualifiedNamespace, + TokenCredential credentials, + ServiceBusClientOptions connectionOptions) + where TOptions : ClientOptions + { + options.ConnectionSettings = new ConnectionSettings(fullyQualifiedNamespace, credentials, connectionOptions); + return options; + } } \ No newline at end of file diff --git a/src/Ev.ServiceBus.Abstractions/Configuration/ServiceBusSettings.cs b/src/Ev.ServiceBus.Abstractions/Configuration/ServiceBusSettings.cs index a490d4a..af86620 100644 --- a/src/Ev.ServiceBus.Abstractions/Configuration/ServiceBusSettings.cs +++ b/src/Ev.ServiceBus.Abstractions/Configuration/ServiceBusSettings.cs @@ -1,4 +1,5 @@ -using Azure.Messaging.ServiceBus; +using Azure.Core; +using Azure.Messaging.ServiceBus; namespace Ev.ServiceBus.Abstractions; @@ -35,6 +36,11 @@ public void WithConnection(string connectionString, ServiceBusClientOptions opti ConnectionSettings = new ConnectionSettings(connectionString, options); } + public void WithConnection(string fullyQualifiedNamespace, TokenCredential tokenCredential, ServiceBusClientOptions options) + { + ConnectionSettings = new ConnectionSettings(fullyQualifiedNamespace, tokenCredential, options); + } + public void WithIsolation(IsolationBehavior behavior, string? isolationKey = null, string? applicationName = null) { IsolationSettings = new IsolationSettings(behavior, isolationKey, applicationName); diff --git a/src/Ev.ServiceBus.Abstractions/Ev.ServiceBus.Abstractions.csproj b/src/Ev.ServiceBus.Abstractions/Ev.ServiceBus.Abstractions.csproj index 0741c8d..a9f8734 100644 --- a/src/Ev.ServiceBus.Abstractions/Ev.ServiceBus.Abstractions.csproj +++ b/src/Ev.ServiceBus.Abstractions/Ev.ServiceBus.Abstractions.csproj @@ -14,6 +14,7 @@ enable + diff --git a/src/Ev.ServiceBus.HealthChecks/ConnectionSettingsComparer.cs b/src/Ev.ServiceBus.HealthChecks/ConnectionSettingsComparer.cs index 3bed55b..696bb26 100644 --- a/src/Ev.ServiceBus.HealthChecks/ConnectionSettingsComparer.cs +++ b/src/Ev.ServiceBus.HealthChecks/ConnectionSettingsComparer.cs @@ -28,11 +28,16 @@ public bool Equals(ConnectionSettings? x, ConnectionSettings? y) return false; } - return x.ConnectionString == y.ConnectionString; + return x.ConnectionString == y.ConnectionString + && x.FullyQualifiedNamespace == y.FullyQualifiedNamespace + && x.Credentials == y.Credentials; } public int GetHashCode(ConnectionSettings? obj) { - return obj?.ConnectionString?.GetHashCode() ?? 0; + return HashCode.Combine( + obj?.ConnectionString, + obj?.FullyQualifiedNamespace, + obj?.Credentials); } } \ No newline at end of file diff --git a/src/Ev.ServiceBus.HealthChecks/RegistrationService.cs b/src/Ev.ServiceBus.HealthChecks/RegistrationService.cs index 4bf1161..bfc39f6 100644 --- a/src/Ev.ServiceBus.HealthChecks/RegistrationService.cs +++ b/src/Ev.ServiceBus.HealthChecks/RegistrationService.cs @@ -27,14 +27,21 @@ public void Configure(HealthCheckServiceOptions options) return; } - var commonConnectionString = _serviceBusOptions.Value.Settings.ConnectionSettings?.ConnectionString; + var commonSettings = _serviceBusOptions.Value.Settings.ConnectionSettings; + var commonConnectionString = commonSettings?.ConnectionString; + var commonFullyQualifiedNamespace = commonSettings?.FullyQualifiedNamespace; + var commonCredentials = commonSettings?.Credentials; + var resources = _serviceBusOptions.Value.Receivers.Union(_serviceBusOptions.Value.Senders).Distinct() .ToArray(); foreach (var resourceGroup in resources.GroupBy(o => o.ConnectionSettings, new ConnectionSettingsComparer())) { var connectionString = resourceGroup.Key?.ConnectionString ?? commonConnectionString; - if (connectionString == null) + var fullyQualifiedNamespace = resourceGroup.Key?.FullyQualifiedNamespace ?? commonFullyQualifiedNamespace; + var credentials = resourceGroup.Key?.Credentials ?? commonCredentials; + + if (connectionString == null && fullyQualifiedNamespace == null && credentials == null) { continue; } @@ -46,7 +53,9 @@ public void Configure(HealthCheckServiceOptions options) options.Registrations.Add(new HealthCheckRegistration($"Queue:{group.Key}", sp => (IHealthCheck) new AzureServiceBusQueueHealthCheck(new AzureServiceBusQueueHealthCheckOptions(group.Key) { - ConnectionString = connectionString + ConnectionString = connectionString, + FullyQualifiedNamespace = fullyQualifiedNamespace, + Credential = credentials }), null, HealthChecksBuilderExtensions.HealthCheckTags, null)); } @@ -58,7 +67,9 @@ public void Configure(HealthCheckServiceOptions options) options.Registrations.Add(new HealthCheckRegistration($"Topic:{group.Key}", sp => (IHealthCheck) new AzureServiceBusTopicHealthCheck(new AzureServiceBusTopicHealthCheckOptions(group.Key) { - ConnectionString = connectionString + ConnectionString = connectionString, + FullyQualifiedNamespace = fullyQualifiedNamespace, + Credential = credentials, }), null, HealthChecksBuilderExtensions.HealthCheckTags, null)); } @@ -73,7 +84,9 @@ public void Configure(HealthCheckServiceOptions options) options.Registrations.Add(new HealthCheckRegistration($"Subscription:{group.Key.TopicName}/Subscriptions/{group.Key.SubscriptionName}", sp => (IHealthCheck) new AzureServiceBusSubscriptionHealthCheck(new AzureServiceBusSubscriptionHealthCheckHealthCheckOptions(group.Key.TopicName, group.Key.SubscriptionName) { - ConnectionString = connectionString + ConnectionString = connectionString, + FullyQualifiedNamespace = fullyQualifiedNamespace, + Credential = credentials }), null, HealthChecksBuilderExtensions.HealthCheckTags, null)); } diff --git a/src/Ev.ServiceBus/Dispatch/DispatchRegistrationBuilder.cs b/src/Ev.ServiceBus/Dispatch/DispatchRegistrationBuilder.cs index 404701f..bad5528 100644 --- a/src/Ev.ServiceBus/Dispatch/DispatchRegistrationBuilder.cs +++ b/src/Ev.ServiceBus/Dispatch/DispatchRegistrationBuilder.cs @@ -28,6 +28,20 @@ public void CustomizeConnection( _options.WithConnection(connectionString, options); } + /// + /// Sets a specific connection using Azure Entra authorization for the underlying resource. + /// + /// + /// + /// + public void CustomizeConnection( + string fullyQualifiedNamespace, + Azure.Core.TokenCredential credentials, + ServiceBusClientOptions options) + { + _options.WithConnection(fullyQualifiedNamespace, credentials, options); + } + /// /// Registers a class as a payload to serialize and send through the current resource. /// diff --git a/src/Ev.ServiceBus/Management/Factories/ClientFactory.cs b/src/Ev.ServiceBus/Management/Factories/ClientFactory.cs index 8359fef..53641d2 100644 --- a/src/Ev.ServiceBus/Management/Factories/ClientFactory.cs +++ b/src/Ev.ServiceBus/Management/Factories/ClientFactory.cs @@ -1,3 +1,4 @@ +using System; using Azure.Messaging.ServiceBus; using Ev.ServiceBus.Abstractions; @@ -7,11 +8,20 @@ public class ClientFactory : IClientFactory { public ServiceBusClient Create(ConnectionSettings connectionSettings) { - if (connectionSettings.Options != null) + if (!string.IsNullOrWhiteSpace(connectionSettings.ConnectionString)) { - return new ServiceBusClient(connectionSettings.ConnectionString, connectionSettings.Options); + return connectionSettings.Options is not null + ? new ServiceBusClient(connectionSettings.ConnectionString, connectionSettings.Options) + : new ServiceBusClient(connectionSettings.ConnectionString); } - return new ServiceBusClient(connectionSettings.ConnectionString); + if(connectionSettings.Credentials is not null && !string.IsNullOrWhiteSpace(connectionSettings.FullyQualifiedNamespace)) + { + return connectionSettings.Options is not null + ? new ServiceBusClient(connectionSettings.FullyQualifiedNamespace, connectionSettings.Credentials, connectionSettings.Options) + : new ServiceBusClient(connectionSettings.FullyQualifiedNamespace, connectionSettings.Credentials); + } + + throw new InvalidOperationException("Insufficient connection settings: provide either a connection string or both FullyQualifiedNamespace and Credentials."); } -} +} \ No newline at end of file diff --git a/tests/Ev.ServiceBus.HealthChecks.UnitTests/ComplexTest.cs b/tests/Ev.ServiceBus.HealthChecks.UnitTests/ComplexTest.cs index aecb812..8bf8ca2 100644 --- a/tests/Ev.ServiceBus.HealthChecks.UnitTests/ComplexTest.cs +++ b/tests/Ev.ServiceBus.HealthChecks.UnitTests/ComplexTest.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using Azure.Identity; using Azure.Messaging.ServiceBus; using Ev.ServiceBus.Reception; using Ev.ServiceBus.UnitTests.Helpers; @@ -166,6 +167,38 @@ public void MultipleRegistrationsGoesWell_case4() reg => { reg.Name.Should().Be("Subscription:topic/Subscriptions/subscription3"); }); } + [Fact] + public void HealthCheckWorksWithEntraAuthorization() + { + var services = new ServiceCollection(); + + services.AddServiceBus(settings => + { + settings.WithConnection("fullyQualifiedNamespace", new DefaultAzureCredential(), new ServiceBusClientOptions()); + }); + + services.AddHealthChecks().AddEvServiceBusChecks(); + + services.RegisterServiceBusReception().FromSubscription("topic", "subscription", builder => + { + builder.RegisterReception(); + }); + services.RegisterServiceBusReception().FromQueue("queue", builder => + { + builder.RegisterReception(); + }); + + var provider = services.BuildServiceProvider(); + + var healthOptions = provider.GetRequiredService>(); + + healthOptions.Value.Registrations.Count.Should().Be(2); + healthOptions.Value.Registrations.Should().SatisfyRespectively( + reg => { reg.Name.Should().Be("Queue:queue"); }, + reg => { reg.Name.Should().Be("Subscription:topic/Subscriptions/subscription"); } + ); + } + public class RelationCreated { } public class RelationCreatedHandler : IMessageReceptionHandler