diff --git a/.vault-config/shared/dotneteng-status-secrets.yaml b/.vault-config/shared/dotneteng-status-secrets.yaml index 9364de9db..6d50c4756 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-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-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 8b35eb893..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 @@ -29,5 +29,10 @@ "KustoIngestionUri": "https://ingest-engsrvprod.westus.kusto.windows.net", "ManagedIdentityId": "d2580e46-e758-4778-a864-18f909438b45", "UseAzCliAuthentication": false + }, + "AzureDevOps": { + "dnceng": { + "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 73234bbb2..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 @@ -30,5 +30,10 @@ "KustoIngestionUri": "https://ingest-engdata.westus2.kusto.windows.net", "ManagedIdentityId": "e9d81917-4c98-44cc-8a6e-601311ac3c07", "UseAzCliAuthentication": false + }, + "AzureDevOps": { + "dnceng": { + "UseManagedIdentity": true + } } } 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 } }, 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; } }