From ccd03a1e821c254368aaebf75079448ddb50ccff Mon Sep 17 00:00:00 2001 From: Missy Messa Date: Wed, 18 Mar 2026 09:07:08 -0700 Subject: [PATCH 1/3] PAT Migration: dn-bot-dnceng-build-r (telemetry) --- .vault-config/shared/telemetry-secrets.yaml | 13 +- azure-pipelines.yml | 4 + dnceng.sln | 11 + eng/deploy.yaml | 23 ++ ...ureDevOpsClient.PostDeploymentTests.csproj | 19 + .../TelemetryManagedIdentityTests.cs | 146 ++++++++ .../AzureDevOpsClient.Tests.csproj | 1 + .../AzureDevOpsClientAuthTests.cs | 328 ++++++++++++++++++ .../AzureDevOpsClient/AzureDevOpsClient.cs | 55 +++ .../AzureDevOpsClient.csproj | 2 + .../AzureDevOpsClientOptions.cs | 7 + .../.config/settings.Production.json | 5 + .../.config/settings.Staging.json | 5 + .../AzureDevOpsTimeline/.config/settings.json | 1 - 14 files changed, 612 insertions(+), 8 deletions(-) create mode 100644 src/Telemetry/AzureDevOpsClient.PostDeploymentTests/AzureDevOpsClient.PostDeploymentTests.csproj create mode 100644 src/Telemetry/AzureDevOpsClient.PostDeploymentTests/TelemetryManagedIdentityTests.cs create mode 100644 src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClientAuthTests.cs diff --git a/.vault-config/shared/telemetry-secrets.yaml b/.vault-config/shared/telemetry-secrets.yaml index d1dadc201..86ac87cf2 100644 --- a/.vault-config/shared/telemetry-secrets.yaml +++ b/.vault-config/shared/telemetry-secrets.yaml @@ -1,12 +1,11 @@ dn-bot-dnceng-build-r: - type: azure-devops-access-token + type: text parameters: - domainAccountName: dn-bot - domainAccountSecret: - location: helixkv - name: dn-bot-account-redmond - organizations: dnceng - scopes: build + description: >- + DEPRECATED: This PAT has been replaced by Managed Identity authentication. + See work item https://dev.azure.com/dnceng/internal/_workitems/edit/10137. + This secret entry is retained temporarily for rollback safety and can be + removed once the Managed Identity migration is fully validated. dn-bot-dnceng-public-build-r: type: azure-devops-access-token diff --git a/azure-pipelines.yml b/azure-pipelines.yml index f4aa6187f..bde5545fb 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -71,6 +71,10 @@ extends: displayName: 'Publish Secret Manager Scenario Tests' targetPath: $(Build.SourcesDirectory)\artifacts\bin\Microsoft.DncEng.SecretManager.ScenarioTests\$(_BuildConfig)\net8.0 artifactName: Microsoft.DncEng.SecretManager.ScenarioTests + - output: pipelineArtifact + displayName: 'Publish AzureDevOpsClient Post-Deployment Tests' + targetPath: $(Build.SourcesDirectory)\artifacts\bin\AzureDevOpsClient.PostDeploymentTests\$(_BuildConfig)\net8.0 + artifactName: AzureDevOpsClient.PostDeploymentTests variables: # DotNet-Blob-Feed provides: dotnetfeed-storage-access-key-1 # DotNet-Symbol-Server-Pats provides: microsoft-symbol-server-pat, symweb-symbol-server-pat diff --git a/dnceng.sln b/dnceng.sln index 260b33766..5b4907819 100644 --- a/dnceng.sln +++ b/dnceng.sln @@ -55,6 +55,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Monitoring EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Monitoring.Sdk.Tests", "src\Monitoring\Microsoft.DotNet.Monitoring.Sdk.Tests\Microsoft.DotNet.Monitoring.Sdk.Tests.csproj", "{02046914-EAB2-4128-BD3E-06C8730B9D9F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureDevOpsClient.PostDeploymentTests", "src\Telemetry\AzureDevOpsClient.PostDeploymentTests\AzureDevOpsClient.PostDeploymentTests.csproj", "{CEBE08DA-1A16-4ED2-B552-654FB51ACBF8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -203,6 +205,14 @@ Global {02046914-EAB2-4128-BD3E-06C8730B9D9F}.Release|Any CPU.Build.0 = Release|Any CPU {02046914-EAB2-4128-BD3E-06C8730B9D9F}.Release|x64.ActiveCfg = Release|Any CPU {02046914-EAB2-4128-BD3E-06C8730B9D9F}.Release|x64.Build.0 = Release|Any CPU + {CEBE08DA-1A16-4ED2-B552-654FB51ACBF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEBE08DA-1A16-4ED2-B552-654FB51ACBF8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEBE08DA-1A16-4ED2-B552-654FB51ACBF8}.Debug|x64.ActiveCfg = Debug|Any CPU + {CEBE08DA-1A16-4ED2-B552-654FB51ACBF8}.Debug|x64.Build.0 = Debug|Any CPU + {CEBE08DA-1A16-4ED2-B552-654FB51ACBF8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEBE08DA-1A16-4ED2-B552-654FB51ACBF8}.Release|Any CPU.Build.0 = Release|Any CPU + {CEBE08DA-1A16-4ED2-B552-654FB51ACBF8}.Release|x64.ActiveCfg = Release|Any CPU + {CEBE08DA-1A16-4ED2-B552-654FB51ACBF8}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -223,6 +233,7 @@ Global {2DBCBD85-13B6-41DD-A00F-C40116C46B61} = {EC7A0A22-5BCD-4B94-8E17-510A54E42ED1} {BC62FD8B-FC12-434F-9958-4EA0A52D60B2} = {4614FF66-8594-4D7E-BEF8-31611B6087C5} {02046914-EAB2-4128-BD3E-06C8730B9D9F} = {4614FF66-8594-4D7E-BEF8-31611B6087C5} + {CEBE08DA-1A16-4ED2-B552-654FB51ACBF8} = {095B825A-91B7-441A-BF18-3A59838F477A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C72EF6D0-2510-41EB-87F3-7739C6C6D53B} diff --git a/eng/deploy.yaml b/eng/deploy.yaml index 5e3976839..950d16297 100644 --- a/eng/deploy.yaml +++ b/eng/deploy.yaml @@ -226,3 +226,26 @@ stages: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Secret Manager Scenario Tests + + - job: telemetryValidation + displayName: Telemetry MI auth validation + timeoutInMinutes: 15 + steps: + - download: current + artifact: AzureDevOpsClient.PostDeploymentTests + + - task: UseDotNet@2 + displayName: Install .NET 8 + inputs: + version: 8.x + + - task: AzureCLI@2 + inputs: + azureSubscription: 'secret-manager-scenario-tests' + scriptType: 'ps' + scriptLocation: 'inlineScript' + inlineScript: | + dotnet test $(Pipeline.Workspace)/AzureDevOpsClient.PostDeploymentTests/AzureDevOpsClient.PostDeploymentTests.dll --filter "TestCategory=PostDeployment" --logger "trx;LogFilePrefix=TelemetryMIAuth" + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Telemetry Managed Identity Auth Tests diff --git a/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/AzureDevOpsClient.PostDeploymentTests.csproj b/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/AzureDevOpsClient.PostDeploymentTests.csproj new file mode 100644 index 000000000..56261d10c --- /dev/null +++ b/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/AzureDevOpsClient.PostDeploymentTests.csproj @@ -0,0 +1,19 @@ + + + + false + false + true + enable + + + + + + + + + + + + diff --git a/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/TelemetryManagedIdentityTests.cs b/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/TelemetryManagedIdentityTests.cs new file mode 100644 index 000000000..81aa48440 --- /dev/null +++ b/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/TelemetryManagedIdentityTests.cs @@ -0,0 +1,146 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.Internal.AzureDevOps.PostDeploymentTests; + +/// +/// Post-deployment tests that validate bearer token (Entra ID) authentication +/// to Azure DevOps works correctly for the telemetry service. +/// +/// In CI these run in the validateDeployment pipeline stage using an +/// backed by the pipeline service +/// connection. Locally they fall back to +/// from an active az login session. +/// +/// These tests exercise the same code path that the deployed service uses +/// with its Managed Identity — only the token source differs. +/// +[TestFixture] +[Category("PostDeployment")] +public class TelemetryManagedIdentityTests +{ + private TokenCredential _credential = null!; + private ILogger _logger = null!; + + [OneTimeSetUp] + public void Setup() + { + _logger = LoggerFactory + .Create(b => b.AddConsole()) + .CreateLogger(); + + // Pipeline environment: use AzurePipelinesCredential from the service connection + // (same pattern as SecretManager ScenarioTestsBase). + string? clientId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_CLIENT_ID"); + string? tenantId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_TENANT_ID"); + string? serviceConnectionId = Environment.GetEnvironmentVariable("AZURESUBSCRIPTION_SERVICE_CONNECTION_ID"); + string? systemAccessToken = Environment.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN"); + + if (!string.IsNullOrEmpty(clientId) + && !string.IsNullOrEmpty(tenantId) + && !string.IsNullOrEmpty(serviceConnectionId) + && !string.IsNullOrEmpty(systemAccessToken)) + { + _credential = new AzurePipelinesCredential(tenantId, clientId, serviceConnectionId, systemAccessToken); + } + else + { + // Local dev: fall back to az login + _credential = new AzureCliCredential(); + } + } + + /// + /// Validates that the Managed Identity can acquire a bearer token for + /// Azure DevOps and successfully list builds from dnceng/internal. + /// + [Test] + public async Task ManagedIdentity_CanListBuilds_FromDncengInternal() + { + var options = new AzureDevOpsClientOptions + { + Organization = "dnceng", + ManagedIdentityClientId = "placeholder-activates-bearer-path", + MaxParallelRequests = 1, + }; + + var client = new AzureDevOpsClient(options, _logger, new SimpleHttpClientFactory(), _credential); + + var builds = await client.ListBuilds("internal", CancellationToken.None, limit: 3); + + Assert.That(builds, Is.Not.Null); + Assert.That(builds.Length, Is.GreaterThan(0), + "Expected at least one build from dnceng/internal using bearer token auth"); + + TestContext.Out.WriteLine($"Retrieved {builds.Length} build(s) via Managed Identity:"); + foreach (var build in builds) + { + TestContext.Out.WriteLine($" Build #{build.Id} — {build.Definition?.Name} — {build.Status}"); + } + } + + /// + /// Validates that the Managed Identity can read build timeline data, + /// which is the core operation the telemetry service performs. + /// + [Test] + public async Task ManagedIdentity_CanGetTimeline_FromDncengInternal() + { + var options = new AzureDevOpsClientOptions + { + Organization = "dnceng", + ManagedIdentityClientId = "placeholder-activates-bearer-path", + MaxParallelRequests = 1, + }; + + var client = new AzureDevOpsClient(options, _logger, new SimpleHttpClientFactory(), _credential); + + // First get a recent build + var builds = await client.ListBuilds("internal", CancellationToken.None, limit: 1); + Assert.That(builds, Is.Not.Null); + Assert.That(builds.Length, Is.GreaterThan(0), "Need at least one build to test timeline access"); + + var build = builds[0]; + TestContext.Out.WriteLine($"Fetching timeline for build #{build.Id}..."); + + var timeline = await client.GetTimelineAsync("internal", (int)build.Id, CancellationToken.None); + + Assert.That(timeline, Is.Not.Null, "Expected a non-null timeline from dnceng/internal build"); + TestContext.Out.WriteLine($"Timeline has {timeline!.Records?.Length ?? 0} record(s)"); + } + + /// + /// Validates that the bearer token uses the correct Azure DevOps scope + /// by requesting a token directly and inspecting it succeeds. + /// + [Test] + public async Task ManagedIdentity_CanAcquireAzDoToken() + { + var context = new TokenRequestContext( + new[] { AzureDevOpsClient.AzureDevOpsResourceId }); + + var token = await _credential.GetTokenAsync(context, CancellationToken.None); + + Assert.That(token.Token, Is.Not.Null.And.Not.Empty, + "Expected a non-empty bearer token for Azure DevOps"); + Assert.That(token.ExpiresOn, Is.GreaterThan(DateTimeOffset.UtcNow), + "Token should not already be expired"); + + TestContext.Out.WriteLine($"Token acquired (expires {token.ExpiresOn:u}, length={token.Token.Length})"); + } + + private sealed class SimpleHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new HttpClient(); + } +} diff --git a/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClient.Tests.csproj b/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClient.Tests.csproj index ca57f053d..36f66c86f 100644 --- a/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClient.Tests.csproj +++ b/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClient.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClientAuthTests.cs b/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClientAuthTests.cs new file mode 100644 index 000000000..1cc5335bc --- /dev/null +++ b/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClientAuthTests.cs @@ -0,0 +1,328 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Azure.Core; +using Microsoft.DotNet.Internal.Testing.Utility; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using NUnit.Framework; +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.Internal.AzureDevOps.Tests; + +[TestFixture] +public class AzureDevOpsClientAuthTests +{ + private ILogger _logger = + LoggerFactory.Create(builder => builder.AddConsole()).CreateLogger(); + + /// + /// When AccessToken is configured, requests should use Basic authentication + /// with the PAT encoded as base64(":token"). + /// + [Test] + public async Task Client_WithAccessToken_UsesBasicAuth() + { + // Arrange + const string pat = "test-pat-value"; + var expectedBasic = Convert.ToBase64String(Encoding.UTF8.GetBytes($":{pat}")); + + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + JsonConvert.SerializeObject(new { count = 0, value = Array.Empty() }), + Encoding.UTF8, + "application/json") + }); + var factory = new DelegatingHandlerHttpClientFactory(handler); + + var options = new AzureDevOpsClientOptions + { + Organization = "test-org", + AccessToken = pat, + MaxParallelRequests = 1, + }; + + var client = new AzureDevOpsClient(options, _logger, factory, tokenCredential: null); + + // Act + await client.ListBuilds("test-project", CancellationToken.None); + + // Assert + Assert.That(handler.LastRequest, Is.Not.Null, "Expected at least one request to be captured"); + Assert.That(handler.LastRequest!.Headers.Authorization, Is.Not.Null); + Assert.That(handler.LastRequest.Headers.Authorization!.Scheme, Is.EqualTo("Basic")); + Assert.That(handler.LastRequest.Headers.Authorization.Parameter, Is.EqualTo(expectedBasic)); + } + + /// + /// When ManagedIdentityClientId is configured (without AccessToken), requests + /// should use Bearer authentication with a token obtained from the TokenCredential. + /// + [Test] + public async Task Client_WithManagedIdentity_UsesBearerAuth() + { + // Arrange + const string fakeToken = "fake-entra-bearer-token"; + + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + JsonConvert.SerializeObject(new { count = 0, value = Array.Empty() }), + Encoding.UTF8, + "application/json") + }); + var factory = new DelegatingHandlerHttpClientFactory(handler); + + var mockCredential = new FakeTokenCredential(fakeToken); + + var options = new AzureDevOpsClientOptions + { + Organization = "test-org", + ManagedIdentityClientId = "00000000-0000-0000-0000-000000000001", + MaxParallelRequests = 1, + }; + + var client = new AzureDevOpsClient(options, _logger, factory, tokenCredential: mockCredential); + + // Act + await client.ListBuilds("test-project", CancellationToken.None); + + // Assert + Assert.That(handler.LastRequest, Is.Not.Null, "Expected at least one request to be captured"); + Assert.That(handler.LastRequest!.Headers.Authorization, Is.Not.Null); + Assert.That(handler.LastRequest.Headers.Authorization!.Scheme, Is.EqualTo("Bearer")); + Assert.That(handler.LastRequest.Headers.Authorization.Parameter, Is.EqualTo(fakeToken)); + } + + /// + /// When ManagedIdentityClientId is configured, the client should request a token + /// for the Azure DevOps resource scope. + /// + [Test] + public async Task Client_WithManagedIdentity_RequestsCorrectScope() + { + // Arrange + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + JsonConvert.SerializeObject(new { count = 0, value = Array.Empty() }), + Encoding.UTF8, + "application/json") + }); + var factory = new DelegatingHandlerHttpClientFactory(handler); + + var mockCredential = new FakeTokenCredential("token"); + + var options = new AzureDevOpsClientOptions + { + Organization = "test-org", + ManagedIdentityClientId = "00000000-0000-0000-0000-000000000001", + MaxParallelRequests = 1, + }; + + var client = new AzureDevOpsClient(options, _logger, factory, tokenCredential: mockCredential); + + // Act + await client.ListBuilds("test-project", CancellationToken.None); + + // Assert + Assert.That(mockCredential.LastRequestedScopes, Is.Not.Null); + Assert.That(mockCredential.LastRequestedScopes, Does.Contain(AzureDevOpsClient.AzureDevOpsResourceId)); + } + + /// + /// When AccessToken takes precedence over ManagedIdentityClientId if both are set. + /// + [Test] + public async Task Client_WithBothPatAndManagedIdentity_PrefersPatAuth() + { + // Arrange + const string pat = "test-pat-value"; + var expectedBasic = Convert.ToBase64String(Encoding.UTF8.GetBytes($":{pat}")); + + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + JsonConvert.SerializeObject(new { count = 0, value = Array.Empty() }), + Encoding.UTF8, + "application/json") + }); + var factory = new DelegatingHandlerHttpClientFactory(handler); + + var mockCredential = new FakeTokenCredential("should-not-be-used"); + + var options = new AzureDevOpsClientOptions + { + Organization = "test-org", + AccessToken = pat, + ManagedIdentityClientId = "00000000-0000-0000-0000-000000000001", + MaxParallelRequests = 1, + }; + + var client = new AzureDevOpsClient(options, _logger, factory, tokenCredential: mockCredential); + + // Act + await client.ListBuilds("test-project", CancellationToken.None); + + // Assert + Assert.That(handler.LastRequest!.Headers.Authorization!.Scheme, Is.EqualTo("Basic")); + Assert.That(handler.LastRequest.Headers.Authorization.Parameter, Is.EqualTo(expectedBasic)); + Assert.That(mockCredential.GetTokenCallCount, Is.EqualTo(0), + "Token credential should not be called when AccessToken is provided"); + } + + /// + /// When neither AccessToken nor ManagedIdentityClientId is set, no auth header + /// should be present on requests. + /// + [Test] + public async Task Client_WithNoAuth_SendsNoAuthHeader() + { + // Arrange + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + JsonConvert.SerializeObject(new { count = 0, value = Array.Empty() }), + Encoding.UTF8, + "application/json") + }); + var factory = new DelegatingHandlerHttpClientFactory(handler); + + var options = new AzureDevOpsClientOptions + { + Organization = "test-org", + MaxParallelRequests = 1, + }; + + var client = new AzureDevOpsClient(options, _logger, factory, tokenCredential: null); + + // Act + await client.ListBuilds("test-project", CancellationToken.None); + + // Assert + Assert.That(handler.LastRequest!.Headers.Authorization, Is.Null); + } + + /// + /// Token is refreshed on each request to handle token expiry. + /// + [Test] + public async Task Client_WithManagedIdentity_RefreshesTokenPerRequest() + { + // Arrange + var handler = new CapturingHandler(() => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + JsonConvert.SerializeObject(new { count = 0, value = Array.Empty() }), + Encoding.UTF8, + "application/json") + }); + var factory = new DelegatingHandlerHttpClientFactory(handler); + + var mockCredential = new FakeTokenCredential("token"); + + var options = new AzureDevOpsClientOptions + { + Organization = "test-org", + ManagedIdentityClientId = "00000000-0000-0000-0000-000000000001", + MaxParallelRequests = 1, + }; + + var client = new AzureDevOpsClient(options, _logger, factory, tokenCredential: mockCredential); + + // Act - make two requests + await client.ListBuilds("test-project", CancellationToken.None); + await client.ListBuilds("test-project", CancellationToken.None); + + // Assert - token should have been requested at least twice + Assert.That(mockCredential.GetTokenCallCount, Is.GreaterThanOrEqualTo(2)); + } + + #region Test helpers + + /// + /// A fake that returns a predetermined token + /// and records the requested scopes. + /// + private sealed class FakeTokenCredential : TokenCredential + { + private readonly string _token; + private int _callCount; + + public FakeTokenCredential(string token) + { + _token = token; + } + + public string[]? LastRequestedScopes { get; private set; } + public int GetTokenCallCount => _callCount; + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _callCount); + LastRequestedScopes = requestContext.Scopes; + return new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1)); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _callCount); + LastRequestedScopes = requestContext.Scopes; + return new ValueTask(new AccessToken(_token, DateTimeOffset.UtcNow.AddHours(1))); + } + } + + /// + /// A that captures the last request seen and + /// always returns a preconfigured response. + /// + private sealed class CapturingHandler : DelegatingHandler + { + private readonly Func _responseFactory; + + public CapturingHandler(HttpResponseMessage cannedResponse) + : this(() => cannedResponse) + { + } + + public CapturingHandler(Func responseFactory) + { + _responseFactory = responseFactory; + InnerHandler = new HttpClientHandler(); + } + + public HttpRequestMessage? LastRequest { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(_responseFactory()); + } + } + + /// + /// An implementation that returns a client + /// backed by a specific . + /// + private sealed class DelegatingHandlerHttpClientFactory : IHttpClientFactory + { + private readonly DelegatingHandler _handler; + + public DelegatingHandlerHttpClientFactory(DelegatingHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) => new HttpClient(_handler, disposeHandler: false); + } + + #endregion +} diff --git a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs index 7c23b6b6e..fc169d223 100644 --- a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs +++ b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs @@ -13,6 +13,8 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Azure.Core; +using Azure.Identity; using Microsoft.DotNet.Services.Utility; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -24,14 +26,33 @@ namespace Microsoft.DotNet.Internal.AzureDevOps; public sealed class AzureDevOpsClient : IAzureDevOpsClient { + /// + /// The Azure DevOps resource ID used when requesting tokens from Entra ID. + /// + public static readonly string AzureDevOpsResourceId = "499b84ac-1321-427f-aa17-267ca6975798/.default"; + private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly SemaphoreSlim _parallelism; + private readonly TokenCredential? _tokenCredential; public AzureDevOpsClient( AzureDevOpsClientOptions options, ILogger logger, IHttpClientFactory httpClientFactory) + : this(options, logger, httpClientFactory, tokenCredential: null) + { + } + + /// + /// Constructor that allows injecting a for testing + /// or custom authentication scenarios. + /// + public AzureDevOpsClient( + AzureDevOpsClientOptions options, + ILogger logger, + IHttpClientFactory httpClientFactory, + TokenCredential? tokenCredential) { _logger = logger; _logger.LogInformation("Constructing AzureDevOpsClient for org {organization}", options.Organization); @@ -39,13 +60,44 @@ public AzureDevOpsClient( _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.BaseAddress = new Uri($"https://dev.azure.com/{options.Organization}/"); _parallelism = new SemaphoreSlim(options.MaxParallelRequests, options.MaxParallelRequests); + if (!string.IsNullOrEmpty(options.AccessToken)) { + _logger.LogInformation("Using PAT-based authentication for org {organization}", options.Organization); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($":{options.AccessToken}")) ); } + else if (!string.IsNullOrEmpty(options.ManagedIdentityClientId)) + { + _logger.LogInformation( + "Using Managed Identity authentication (ClientId: {clientId}) for org {organization}", + options.ManagedIdentityClientId, + options.Organization); + _tokenCredential = tokenCredential + ?? new ManagedIdentityCredential(options.ManagedIdentityClientId); + } + else + { + _logger.LogWarning("No authentication configured for org {organization}. Requests may fail.", options.Organization); + } + } + + /// + /// If a is configured, acquires a fresh bearer token + /// and sets it on the default request headers. + /// + private async Task EnsureBearerTokenAsync(CancellationToken cancellationToken) + { + if (_tokenCredential == null) + { + return; + } + + var tokenRequestContext = new TokenRequestContext(new[] { AzureDevOpsResourceId }); + AccessToken token = await _tokenCredential.GetTokenAsync(tokenRequestContext, cancellationToken); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); } /// @@ -172,6 +224,7 @@ private async Task GetTimelineRaw(string project, int buildId, string id IReadOnlyList regexes, CancellationToken cancellationToken) { + await EnsureBearerTokenAsync(cancellationToken); using var request = new HttpRequestMessage(HttpMethod.Get, logUri); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/plain")); @@ -288,6 +341,7 @@ private async Task GetJsonResult(string uri, CancellationToken cance await _parallelism.WaitAsync(cancellationToken); try { + await EnsureBearerTokenAsync(cancellationToken); int retry = 5; while (true) { @@ -326,6 +380,7 @@ private async Task PostJsonResult(string uri, string body, Cancellat await _parallelism.WaitAsync(cancellationToken); try { + await EnsureBearerTokenAsync(cancellationToken); int retry = 5; while (true) { diff --git a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.csproj b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.csproj index b7fb9e056..0aaec6fae 100644 --- a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.csproj +++ b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.csproj @@ -6,6 +6,8 @@ + + diff --git a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClientOptions.cs b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClientOptions.cs index 7039b3020..e693de68b 100644 --- a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClientOptions.cs +++ b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClientOptions.cs @@ -9,4 +9,11 @@ public class AzureDevOpsClientOptions public string Organization { get; set; } public int MaxParallelRequests { get; set; } = 4; public string AccessToken { get; set; } + + /// + /// The client ID of the Managed Identity to use for Entra-based authentication. + /// When set (and is not provided), the client will use + /// a Managed Identity to obtain a bearer token for Azure DevOps. + /// + public string ManagedIdentityClientId { get; set; } } diff --git a/src/Telemetry/AzureDevOpsTimeline/.config/settings.Production.json b/src/Telemetry/AzureDevOpsTimeline/.config/settings.Production.json index 80d941628..af62e059a 100644 --- a/src/Telemetry/AzureDevOpsTimeline/.config/settings.Production.json +++ b/src/Telemetry/AzureDevOpsTimeline/.config/settings.Production.json @@ -3,6 +3,11 @@ "Secrets": { "ManagedIdentityId": "d2580e46-e758-4778-a864-18f909438b45" }, + "AzureDevOpsSettings": { + "dnceng": { + "ManagedIdentityClientId": "13eb78dc-2e79-4ae1-afbf-f95c5b1d2a4c" + } + }, "KustoTimelineTelemetry": { "KustoClusterUri": "https://engsrvprod.westus.kusto.windows.net", "KustoIngestionUri": "https://ingest-engsrvprod.westus.kusto.windows.net", diff --git a/src/Telemetry/AzureDevOpsTimeline/.config/settings.Staging.json b/src/Telemetry/AzureDevOpsTimeline/.config/settings.Staging.json index 6cfe9ff90..5f75eed7c 100644 --- a/src/Telemetry/AzureDevOpsTimeline/.config/settings.Staging.json +++ b/src/Telemetry/AzureDevOpsTimeline/.config/settings.Staging.json @@ -3,6 +3,11 @@ "Secrets": { "ManagedIdentityId": "e9d81917-4c98-44cc-8a6e-601311ac3c07" }, + "AzureDevOpsSettings": { + "dnceng": { + "ManagedIdentityClientId": "c05abe9e-b183-4c19-a7c3-6512f976548f" + } + }, "KustoTimelineTelemetry": { "KustoClusterUri": "https://engdata.westus2.kusto.windows.net", "KustoIngestionUri": "https://ingest-engdata.westus2.kusto.windows.net", diff --git a/src/Telemetry/AzureDevOpsTimeline/.config/settings.json b/src/Telemetry/AzureDevOpsTimeline/.config/settings.json index a56d1e944..2032c3f2a 100644 --- a/src/Telemetry/AzureDevOpsTimeline/.config/settings.json +++ b/src/Telemetry/AzureDevOpsTimeline/.config/settings.json @@ -25,7 +25,6 @@ "AzureDevOpsSettings": { "dnceng": { "Organization": "dnceng", - "AccessToken": "[vault(dn-bot-dnceng-build-r)]", "MaxParallelRequests": 4 }, "dnceng-public": { From c1194bcb5ed6dd25c87f94a7d4b779abf8d82591 Mon Sep 17 00:00:00 2001 From: Missy Messa Date: Fri, 20 Mar 2026 18:54:04 -0700 Subject: [PATCH 2/3] Migrate dn-bot-dnceng-workitems-rw to Managed Identity (WI 10135) The AzureDevOpsClient shared library already supports MI auth via ManagedIdentityClientId. This change is config-only: - settings.json: Remove [vault(dn-bot-dnceng-workitems-rw)] from AzureDevOps:dnceng - settings.Production.json: Add AzureDevOps:dnceng:ManagedIdentityClientId (d2580e46-...) - settings.Staging.json: Add AzureDevOps:dnceng:ManagedIdentityClientId (e9d81917-...) - dotneteng-status-secrets.yaml: Remove dn-bot-dnceng-workitems-rw vault entry The AzureDevOpsClient constructor already: 1. Prefers AccessToken (PAT) if present 2. Falls back to ManagedIdentityCredential if ManagedIdentityClientId is set 3. Uses the AzDO resource scope (499b84ac-.../default) for bearer tokens --- .vault-config/shared/dotneteng-status-secrets.yaml | 11 ++--------- .../.config/settings.Production.json | 5 +++++ .../DotNet.Status.Web/.config/settings.Staging.json | 5 +++++ .../DotNet.Status.Web/.config/settings.json | 1 - 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.vault-config/shared/dotneteng-status-secrets.yaml b/.vault-config/shared/dotneteng-status-secrets.yaml index 1606aecc2..0887116d1 100644 --- a/.vault-config/shared/dotneteng-status-secrets.yaml +++ b/.vault-config/shared/dotneteng-status-secrets.yaml @@ -25,15 +25,8 @@ dn-bot-dnceng-build-rw-code-rw-release-rw: name: dn-bot-account-redmond location: helixkv -dn-bot-dnceng-workitems-rw: - type: azure-devops-access-token - parameters: - organizations: dnceng - scopes: work_write - domainAccountName: dn-bot - domainAccountSecret: - name: dn-bot-account-redmond - location: helixkv +# dn-bot-dnceng-workitems-rw: REMOVED — migrated to Managed Identity (WI 10135) +# The AzureDevOpsClient now uses ManagedIdentityCredential with per-env ManagedIdentityClientId. dn-bot-dnceng-build-r-code-r-project-r-profile-r: type: azure-devops-access-token diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Production.json b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Production.json index 8b35eb893..8dede2848 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Production.json +++ b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Production.json @@ -29,5 +29,10 @@ "KustoIngestionUri": "https://ingest-engsrvprod.westus.kusto.windows.net", "ManagedIdentityId": "d2580e46-e758-4778-a864-18f909438b45", "UseAzCliAuthentication": false + }, + "AzureDevOps": { + "dnceng": { + "ManagedIdentityClientId": "d2580e46-e758-4778-a864-18f909438b45" + } } } diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Staging.json b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Staging.json index 73234bbb2..cf7584f32 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Staging.json +++ b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Staging.json @@ -30,5 +30,10 @@ "KustoIngestionUri": "https://ingest-engdata.westus2.kusto.windows.net", "ManagedIdentityId": "e9d81917-4c98-44cc-8a6e-601311ac3c07", "UseAzCliAuthentication": false + }, + "AzureDevOps": { + "dnceng": { + "ManagedIdentityClientId": "e9d81917-4c98-44cc-8a6e-601311ac3c07" + } } } diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.json b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.json index 62f7fafa9..5784557a6 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.json +++ b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.json @@ -25,7 +25,6 @@ }, "dnceng": { "Organization": "dnceng", - "AccessToken": "[vault(dn-bot-dnceng-workitems-rw)]", "MaxParallelRequests": 10 } }, From 6a3aac32317e26b1c70fec58b81d3bfd7869f7ee Mon Sep 17 00:00:00 2001 From: Missy Messa Date: Thu, 26 Mar 2026 17:54:36 -0700 Subject: [PATCH 3/3] Address review feedback: fix MI identity and yaml comment 1. Remove leftover comment from dotneteng-status-secrets.yaml (garath) 2. Replace wrong helix-cluster MI client IDs with UseManagedIdentity flag - Production and Staging settings now use 'UseManagedIdentity: true' - System-assigned MIs don't need a client ID (garath) 3. Add UseManagedIdentity bool to AzureDevOpsClientOptions - When true without ManagedIdentityClientId: system-assigned MI - When true with ManagedIdentityClientId: user-assigned MI 4. Split bearer auth test into user-assigned and system-assigned cases 5. Remove placeholder client IDs from post-deployment tests --- .../shared/dotneteng-status-secrets.yaml | 3 - .../.config/settings.Production.json | 2 +- .../.config/settings.Staging.json | 2 +- .../TelemetryManagedIdentityTests.cs | 4 +- .../AzureDevOpsClientAuthTests.cs | 56 ++++++++++++++++--- .../AzureDevOpsClient/AzureDevOpsClient.cs | 25 ++++++--- .../AzureDevOpsClientOptions.cs | 14 ++++- 7 files changed, 82 insertions(+), 24 deletions(-) diff --git a/.vault-config/shared/dotneteng-status-secrets.yaml b/.vault-config/shared/dotneteng-status-secrets.yaml index 0887116d1..c502efdf6 100644 --- a/.vault-config/shared/dotneteng-status-secrets.yaml +++ b/.vault-config/shared/dotneteng-status-secrets.yaml @@ -25,9 +25,6 @@ dn-bot-dnceng-build-rw-code-rw-release-rw: name: dn-bot-account-redmond location: helixkv -# dn-bot-dnceng-workitems-rw: REMOVED — migrated to Managed Identity (WI 10135) -# The AzureDevOpsClient now uses ManagedIdentityCredential with per-env ManagedIdentityClientId. - dn-bot-dnceng-build-r-code-r-project-r-profile-r: type: azure-devops-access-token parameters: diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Production.json b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Production.json index 8dede2848..443a760ae 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Production.json +++ b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Production.json @@ -32,7 +32,7 @@ }, "AzureDevOps": { "dnceng": { - "ManagedIdentityClientId": "d2580e46-e758-4778-a864-18f909438b45" + "UseManagedIdentity": true } } } diff --git a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Staging.json b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Staging.json index cf7584f32..4b6663092 100644 --- a/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Staging.json +++ b/src/DotNet.Status.Web/DotNet.Status.Web/.config/settings.Staging.json @@ -33,7 +33,7 @@ }, "AzureDevOps": { "dnceng": { - "ManagedIdentityClientId": "e9d81917-4c98-44cc-8a6e-601311ac3c07" + "UseManagedIdentity": true } } } diff --git a/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/TelemetryManagedIdentityTests.cs b/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/TelemetryManagedIdentityTests.cs index 81aa48440..495fdf8ce 100644 --- a/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/TelemetryManagedIdentityTests.cs +++ b/src/Telemetry/AzureDevOpsClient.PostDeploymentTests/TelemetryManagedIdentityTests.cs @@ -70,7 +70,7 @@ public async Task ManagedIdentity_CanListBuilds_FromDncengInternal() var options = new AzureDevOpsClientOptions { Organization = "dnceng", - ManagedIdentityClientId = "placeholder-activates-bearer-path", + UseManagedIdentity = true, MaxParallelRequests = 1, }; @@ -99,7 +99,7 @@ public async Task ManagedIdentity_CanGetTimeline_FromDncengInternal() var options = new AzureDevOpsClientOptions { Organization = "dnceng", - ManagedIdentityClientId = "placeholder-activates-bearer-path", + UseManagedIdentity = true, MaxParallelRequests = 1, }; diff --git a/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClientAuthTests.cs b/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClientAuthTests.cs index 1cc5335bc..112ca4f78 100644 --- a/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClientAuthTests.cs +++ b/src/Telemetry/AzureDevOpsClient.Tests/AzureDevOpsClientAuthTests.cs @@ -62,11 +62,11 @@ public async Task Client_WithAccessToken_UsesBasicAuth() } /// - /// When ManagedIdentityClientId is configured (without AccessToken), requests + /// When UseManagedIdentity is true with a client ID (user-assigned MI), requests /// should use Bearer authentication with a token obtained from the TokenCredential. /// [Test] - public async Task Client_WithManagedIdentity_UsesBearerAuth() + public async Task Client_WithUserAssignedManagedIdentity_UsesBearerAuth() { // Arrange const string fakeToken = "fake-entra-bearer-token"; @@ -85,6 +85,7 @@ public async Task Client_WithManagedIdentity_UsesBearerAuth() var options = new AzureDevOpsClientOptions { Organization = "test-org", + UseManagedIdentity = true, ManagedIdentityClientId = "00000000-0000-0000-0000-000000000001", MaxParallelRequests = 1, }; @@ -102,7 +103,47 @@ public async Task Client_WithManagedIdentity_UsesBearerAuth() } /// - /// When ManagedIdentityClientId is configured, the client should request a token + /// When UseManagedIdentity is true without a client ID (system-assigned MI), requests + /// should use Bearer authentication with a token obtained from the TokenCredential. + /// + [Test] + public async Task Client_WithSystemAssignedManagedIdentity_UsesBearerAuth() + { + // Arrange + const string fakeToken = "fake-system-assigned-token"; + + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + JsonConvert.SerializeObject(new { count = 0, value = Array.Empty() }), + Encoding.UTF8, + "application/json") + }); + var factory = new DelegatingHandlerHttpClientFactory(handler); + + var mockCredential = new FakeTokenCredential(fakeToken); + + var options = new AzureDevOpsClientOptions + { + Organization = "test-org", + UseManagedIdentity = true, + MaxParallelRequests = 1, + }; + + var client = new AzureDevOpsClient(options, _logger, factory, tokenCredential: mockCredential); + + // Act + await client.ListBuilds("test-project", CancellationToken.None); + + // Assert + Assert.That(handler.LastRequest, Is.Not.Null, "Expected at least one request to be captured"); + Assert.That(handler.LastRequest!.Headers.Authorization, Is.Not.Null); + Assert.That(handler.LastRequest.Headers.Authorization!.Scheme, Is.EqualTo("Bearer")); + Assert.That(handler.LastRequest.Headers.Authorization.Parameter, Is.EqualTo(fakeToken)); + } + + /// + /// When UseManagedIdentity is configured, the client should request a token /// for the Azure DevOps resource scope. /// [Test] @@ -123,7 +164,7 @@ public async Task Client_WithManagedIdentity_RequestsCorrectScope() var options = new AzureDevOpsClientOptions { Organization = "test-org", - ManagedIdentityClientId = "00000000-0000-0000-0000-000000000001", + UseManagedIdentity = true, MaxParallelRequests = 1, }; @@ -138,7 +179,7 @@ public async Task Client_WithManagedIdentity_RequestsCorrectScope() } /// - /// When AccessToken takes precedence over ManagedIdentityClientId if both are set. + /// AccessToken takes precedence over UseManagedIdentity if both are set. /// [Test] public async Task Client_WithBothPatAndManagedIdentity_PrefersPatAuth() @@ -162,6 +203,7 @@ public async Task Client_WithBothPatAndManagedIdentity_PrefersPatAuth() { Organization = "test-org", AccessToken = pat, + UseManagedIdentity = true, ManagedIdentityClientId = "00000000-0000-0000-0000-000000000001", MaxParallelRequests = 1, }; @@ -179,7 +221,7 @@ public async Task Client_WithBothPatAndManagedIdentity_PrefersPatAuth() } /// - /// When neither AccessToken nor ManagedIdentityClientId is set, no auth header + /// When neither AccessToken nor UseManagedIdentity is set, no auth header /// should be present on requests. /// [Test] @@ -231,7 +273,7 @@ public async Task Client_WithManagedIdentity_RefreshesTokenPerRequest() var options = new AzureDevOpsClientOptions { Organization = "test-org", - ManagedIdentityClientId = "00000000-0000-0000-0000-000000000001", + UseManagedIdentity = true, MaxParallelRequests = 1, }; diff --git a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs index fc169d223..6aa1882d3 100644 --- a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs +++ b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClient.cs @@ -69,14 +69,25 @@ public AzureDevOpsClient( Convert.ToBase64String(Encoding.UTF8.GetBytes($":{options.AccessToken}")) ); } - else if (!string.IsNullOrEmpty(options.ManagedIdentityClientId)) + else if (options.UseManagedIdentity) { - _logger.LogInformation( - "Using Managed Identity authentication (ClientId: {clientId}) for org {organization}", - options.ManagedIdentityClientId, - options.Organization); - _tokenCredential = tokenCredential - ?? new ManagedIdentityCredential(options.ManagedIdentityClientId); + if (!string.IsNullOrEmpty(options.ManagedIdentityClientId)) + { + _logger.LogInformation( + "Using user-assigned Managed Identity (ClientId: {clientId}) for org {organization}", + options.ManagedIdentityClientId, + options.Organization); + _tokenCredential = tokenCredential + ?? new ManagedIdentityCredential(options.ManagedIdentityClientId); + } + else + { + _logger.LogInformation( + "Using system-assigned Managed Identity for org {organization}", + options.Organization); + _tokenCredential = tokenCredential + ?? new ManagedIdentityCredential(); + } } else { diff --git a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClientOptions.cs b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClientOptions.cs index e693de68b..78ee68bd2 100644 --- a/src/Telemetry/AzureDevOpsClient/AzureDevOpsClientOptions.cs +++ b/src/Telemetry/AzureDevOpsClient/AzureDevOpsClientOptions.cs @@ -11,9 +11,17 @@ public class AzureDevOpsClientOptions public string AccessToken { get; set; } /// - /// The client ID of the Managed Identity to use for Entra-based authentication. - /// When set (and is not provided), the client will use - /// a Managed Identity to obtain a bearer token for Azure DevOps. + /// When true, the client authenticates to Azure DevOps using a Managed Identity + /// instead of a PAT. For system-assigned identities, leave + /// empty. For user-assigned identities, also set . + /// Ignored when is provided. + /// + public bool UseManagedIdentity { get; set; } + + /// + /// The client ID of a user-assigned Managed Identity. Only required when + /// is true and the identity is user-assigned. + /// For system-assigned identities this should be left empty. /// public string ManagedIdentityClientId { get; set; } }