diff --git a/.vault-config/service-connections-devdiv.yaml b/.vault-config/service-connections-devdiv.yaml index f0687a6ae..133e6d540 100644 --- a/.vault-config/service-connections-devdiv.yaml +++ b/.vault-config/service-connections-devdiv.yaml @@ -24,7 +24,7 @@ secrets: name: dn-bot-account-redmond organizations: dnceng scopes: packaging_write - + dnceng-test-tools-feed: type: azure-devops-service-endpoint parameters: @@ -127,17 +127,4 @@ secrets: location: helixkv name: dn-bot-account-redmond organizations: dnceng - scopes: packaging_write - - dnceng/internal: - type: azure-devops-service-endpoint - parameters: - authorization: - type: azure-devops-access-token - parameters: - domainAccountName: dn-bot - domainAccountSecret: - location: helixkv - name: dn-bot-account-redmond - organizations: dnceng - scopes: code \ No newline at end of file + scopes: packaging_write \ No newline at end of file diff --git a/.vault-config/shared/dotneteng-status-secrets.yaml b/.vault-config/shared/dotneteng-status-secrets.yaml index 1606aecc2..9364de9db 100644 --- a/.vault-config/shared/dotneteng-status-secrets.yaml +++ b/.vault-config/shared/dotneteng-status-secrets.yaml @@ -15,16 +15,6 @@ app-insights-connection-string: parameters: description: The connection string for application insights. Go to the Azure resource for application insights -> Configure -> Properties -> Get the connection string -dn-bot-dnceng-build-rw-code-rw-release-rw: - type: azure-devops-access-token - parameters: - organizations: dnceng - scopes: build_execute code_write release_execute - domainAccountName: dn-bot - domainAccountSecret: - name: dn-bot-account-redmond - location: helixkv - dn-bot-dnceng-workitems-rw: type: azure-devops-access-token parameters: diff --git a/.vault-config/shared/telemetry-secrets.yaml b/.vault-config/shared/telemetry-secrets.yaml index d1dadc201..9ce4410a0 100644 --- a/.vault-config/shared/telemetry-secrets.yaml +++ b/.vault-config/shared/telemetry-secrets.yaml @@ -1,13 +1,3 @@ -dn-bot-dnceng-build-r: - type: azure-devops-access-token - parameters: - domainAccountName: dn-bot - domainAccountSecret: - location: helixkv - name: dn-bot-account-redmond - organizations: dnceng - scopes: build - dn-bot-dnceng-public-build-r: type: azure-devops-access-token parameters: 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/SecretManager/Microsoft.DncEng.SecretManager/SecretTypes/GitHubAppSecret.cs b/src/SecretManager/Microsoft.DncEng.SecretManager/SecretTypes/GitHubAppSecret.cs index d85f8d694..c68c62b44 100644 --- a/src/SecretManager/Microsoft.DncEng.SecretManager/SecretTypes/GitHubAppSecret.cs +++ b/src/SecretManager/Microsoft.DncEng.SecretManager/SecretTypes/GitHubAppSecret.cs @@ -91,9 +91,13 @@ public override async Task> RotateValues(Parameters parameters, if (parameters.HasWebhookSecret) { - webhookSecret = await _console.PromptAndValidateAsync("Webhook Secret", - "is required", - l => !string.IsNullOrWhiteSpace(l)); + bool changeWebhookSecret = isNew || await _console.ConfirmAsync("Do you want to change the webhook secret? (yes/no): "); + if (changeWebhookSecret) + { + webhookSecret = await _console.PromptAndValidateAsync("Webhook Secret", + "is required", + l => !string.IsNullOrWhiteSpace(l)); + } } 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": {