From 16404705d42ebfbbb55d9aa885e7456ad9cceba6 Mon Sep 17 00:00:00 2001 From: Patrick Hallisey Date: Mon, 15 Apr 2024 10:50:49 -0700 Subject: [PATCH] Add service account login to abstract devops store base class (#8082) --- .../Commands/RotationCommandBase.cs | 2 +- .../AzureDevOpsStore.cs | 104 ++++++++++++++++++ .../ServiceAccountPersonalAccessTokenStore.cs | 74 +++---------- .../ServiceConnectionParameterStore.cs | 83 ++++++++------ 4 files changed, 171 insertions(+), 92 deletions(-) create mode 100644 tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/AzureDevOpsStore.cs diff --git a/tools/secret-management/Azure.Sdk.Tools.SecretManagement.Cli/Commands/RotationCommandBase.cs b/tools/secret-management/Azure.Sdk.Tools.SecretManagement.Cli/Commands/RotationCommandBase.cs index f5eacac0c96..28e73cc965f 100644 --- a/tools/secret-management/Azure.Sdk.Tools.SecretManagement.Cli/Commands/RotationCommandBase.cs +++ b/tools/secret-management/Azure.Sdk.Tools.SecretManagement.Cli/Commands/RotationCommandBase.cs @@ -97,7 +97,7 @@ private static IDictionary> GetDef [KeyVaultCertificateStore.MappingKey] = KeyVaultCertificateStore.GetSecretStoreFactory(tokenCredential, logger), [ManualActionStore.MappingKey] = ManualActionStore.GetSecretStoreFactory(logger, new ConsoleValueProvider()), [ServiceAccountPersonalAccessTokenStore.MappingKey] = ServiceAccountPersonalAccessTokenStore.GetSecretStoreFactory(tokenCredential, new SecretProvider(logger), logger), - [ServiceConnectionParameterStore.MappingKey] = ServiceConnectionParameterStore.GetSecretStoreFactory(tokenCredential, logger), + [ServiceConnectionParameterStore.MappingKey] = ServiceConnectionParameterStore.GetSecretStoreFactory(tokenCredential, new SecretProvider(logger), logger), [AadApplicationSecretStore.MappingKey] = AadApplicationSecretStore.GetSecretStoreFactory(tokenCredential, logger), [AzureWebsiteStore.MappingKey] = AzureWebsiteStore.GetSecretStoreFactory(tokenCredential, logger), [AadApplicationSecretStore.MappingKey] = AadApplicationSecretStore.GetSecretStoreFactory(tokenCredential, logger), diff --git a/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/AzureDevOpsStore.cs b/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/AzureDevOpsStore.cs new file mode 100644 index 00000000000..958e2dd7e03 --- /dev/null +++ b/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/AzureDevOpsStore.cs @@ -0,0 +1,104 @@ +using Azure.Core; +using Azure.Identity; +using Azure.Sdk.Tools.SecretRotation.Azure; +using Azure.Sdk.Tools.SecretRotation.Core; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.Services.Client; +using Microsoft.VisualStudio.Services.Common; +using Microsoft.VisualStudio.Services.WebApi; +using AccessToken = Azure.Core.AccessToken; + +namespace Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps; + +public abstract class AzureDevOpsStore : SecretStore +{ + private const string AzureDevOpsApplicationId = "499b84ac-1321-427f-aa17-267ca6975798"; + private const string AzureCliApplicationId = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"; + + protected readonly ILogger logger; + protected readonly string organization; + private readonly string? serviceAccountTenantId; + private readonly string? serviceAccountName; + private readonly Uri? serviceAccountPasswordSecret; + private readonly TokenCredential tokenCredential; + private readonly ISecretProvider secretProvider; + + protected AzureDevOpsStore(ILogger logger, + string organization, + string? serviceAccountTenantId, + string? serviceAccountName, + Uri? serviceAccountPasswordSecret, + TokenCredential tokenCredential, + ISecretProvider secretProvider) + { + this.logger = logger; + this.organization = organization; + this.serviceAccountTenantId = serviceAccountTenantId; + this.serviceAccountName = serviceAccountName; + this.serviceAccountPasswordSecret = serviceAccountPasswordSecret; + this.tokenCredential = tokenCredential; + this.secretProvider = secretProvider; + } + + protected async Task GetConnectionAsync() + { + string[] scopes = { "499b84ac-1321-427f-aa17-267ca6975798/.default" }; + string? parentRequestId = null; + + VssCredentials vssCredentials = this.serviceAccountPasswordSecret != null + ? await GetServiceAccountCredentialsAsync() + : await GetTokenPrincipleCredentialsAsync(scopes, parentRequestId); + + VssConnection connection = new(new Uri($"https://vssps.dev.azure.com/{this.organization}"), vssCredentials); + + await connection.ConnectAsync(); + + return connection; + } + + private static async Task GetDevopsAadBearerTokenAsync(TokenCredential credential) + { + string[] scopes = { $"{AzureDevOpsApplicationId}/.default" }; + + var tokenRequestContext = new TokenRequestContext(scopes, parentRequestId: null); + + AccessToken authenticationResult = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None); + + return authenticationResult; + } + + private async Task GetTokenPrincipleCredentialsAsync(string[] scopes, string? parentRequestId) + { + TokenRequestContext tokenRequestContext = new (scopes, parentRequestId); + + AccessToken authenticationResult = await this.tokenCredential.GetTokenAsync( + tokenRequestContext, + CancellationToken.None); + + VssAadCredential credentials = new (new VssAadToken("Bearer", authenticationResult.Token)); + + return credentials; + } + + private async Task GetServiceAccountCredentialsAsync() + { + if (this.serviceAccountPasswordSecret == null) + { + throw new ArgumentNullException(nameof(serviceAccountPasswordSecret)); + } + + this.logger.LogDebug("Getting service account password from secret '{SecretName}'", this.serviceAccountPasswordSecret); + + string serviceAccountPassword = await this.secretProvider.GetSecretValueAsync(this.tokenCredential, this.serviceAccountPasswordSecret); + + this.logger.LogDebug("Getting token and devops client for 'dev.azure.com/{Organization}'", this.organization); + + UsernamePasswordCredential serviceAccountCredential = new (this.serviceAccountName, serviceAccountPassword, this.serviceAccountTenantId, AzureCliApplicationId); + + AccessToken token = await GetDevopsAadBearerTokenAsync(serviceAccountCredential); + + VssAadCredential credentials = new (new VssAadToken("Bearer", token.Token)); + + return credentials; + } +} diff --git a/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceAccountPersonalAccessTokenStore.cs b/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceAccountPersonalAccessTokenStore.cs index ca5f0f49023..bbe3868b412 100644 --- a/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceAccountPersonalAccessTokenStore.cs +++ b/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceAccountPersonalAccessTokenStore.cs @@ -1,38 +1,27 @@ +using System.Net; using System.Text.Json; using System.Text.Json.Serialization; using Azure.Core; -using Azure.Identity; using Azure.Sdk.Tools.SecretRotation.Azure; using Azure.Sdk.Tools.SecretRotation.Configuration; using Azure.Sdk.Tools.SecretRotation.Core; using Microsoft.Extensions.Logging; -using Microsoft.VisualStudio.Services.Client; -using Microsoft.VisualStudio.Services.Common; using Microsoft.VisualStudio.Services.DelegatedAuthorization; using Microsoft.VisualStudio.Services.DelegatedAuthorization.Client; using Microsoft.VisualStudio.Services.WebApi; -using AccessToken = Azure.Core.AccessToken; namespace Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps; -public class ServiceAccountPersonalAccessTokenStore : SecretStore +public class ServiceAccountPersonalAccessTokenStore : AzureDevOpsStore { public const string MappingKey = "Service Account ADO PAT"; - private const string AzureDevOpsApplicationId = "499b84ac-1321-427f-aa17-267ca6975798"; - private const string AzureCliApplicationId = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"; - private readonly ILogger logger; - private readonly string organization; private readonly string patDisplayName; private readonly string scopes; - private readonly string serviceAccountTenantId; - private readonly string serviceAccountName; - private readonly Uri serviceAccountPasswordSecret; private readonly RevocationAction revocationAction; - private readonly TokenCredential tokenCredential; - private readonly ISecretProvider secretProvider; - private ServiceAccountPersonalAccessTokenStore(ILogger logger, + private ServiceAccountPersonalAccessTokenStore( + ILogger logger, string organization, string patDisplayName, string scopes, @@ -42,17 +31,18 @@ private ServiceAccountPersonalAccessTokenStore(ILogger logger, RevocationAction revocationAction, TokenCredential tokenCredential, ISecretProvider secretProvider) + : base( + logger, + organization, + serviceAccountTenantId, + serviceAccountName, + serviceAccountPasswordSecret, + tokenCredential, + secretProvider) { - this.logger = logger; - this.organization = organization; this.patDisplayName = patDisplayName; this.scopes = scopes; - this.serviceAccountTenantId = serviceAccountTenantId; - this.serviceAccountName = serviceAccountName; - this.serviceAccountPasswordSecret = serviceAccountPasswordSecret; this.revocationAction = revocationAction; - this.tokenCredential = tokenCredential; - this.secretProvider = secretProvider; } public override bool CanOriginate => true; @@ -131,7 +121,8 @@ public override async Task OriginateValueAsync(SecretState currentS PatTokenResult result = await client.CreatePatAsync(new PatTokenCreateRequest { AllOrgs = false, DisplayName = this.patDisplayName, Scope = this.scopes, ValidTo = expirationDate.UtcDateTime }); - if (result.PatTokenError != SessionTokenError.None) { + if (result.PatTokenError != SessionTokenError.None) + { throw new RotationException($"Unable to create PAT: {result.PatTokenError}"); } @@ -147,17 +138,6 @@ public override async Task OriginateValueAsync(SecretState currentS }; } - private static async Task GetDevopsBearerTokenAsync(TokenCredential credential) - { - string[] scopes = { $"{AzureDevOpsApplicationId}/.default" }; - - var tokenRequestContext = new TokenRequestContext(scopes, parentRequestId: null); - - AccessToken authenticationResult = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None); - - return authenticationResult; - } - public override Func? GetRevocationActionAsync(SecretState secretState, bool whatIf) { if (!secretState.Tags.TryGetValue("AdoPatAuthorizationId", out string? authorizationIdString) || @@ -204,32 +184,6 @@ private static async Task GetDevopsBearerTokenAsync(TokenCredential }; } - private static async Task GetVssCredentials(TokenCredential credential) - { - AccessToken token = await GetDevopsBearerTokenAsync(credential); - - return new VssAadCredential(new VssAadToken("Bearer", token.Token)); - } - - private async Task GetConnectionAsync() - { - this.logger.LogDebug("Getting service account password from secret '{SecretName}'", this.serviceAccountPasswordSecret); - - string serviceAccountPassword = await this.secretProvider.GetSecretValueAsync(this.tokenCredential, this.serviceAccountPasswordSecret); - - this.logger.LogDebug("Getting token and devops client for 'dev.azure.com/{Organization}'", this.organization); - - var serviceAccountCredential = new UsernamePasswordCredential(this.serviceAccountName, serviceAccountPassword, this.serviceAccountTenantId, AzureCliApplicationId); - - VssCredentials vssCredentials = await GetVssCredentials(serviceAccountCredential); - - var connection = new VssConnection(new Uri($"https://vssps.dev.azure.com/{this.organization}"), vssCredentials); - - await connection.ConnectAsync(); - - return connection; - } - private enum RevocationAction { [JsonPropertyName("none")] diff --git a/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceConnectionParameterStore.cs b/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceConnectionParameterStore.cs index 995795a5708..722ba62f43d 100644 --- a/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceConnectionParameterStore.cs +++ b/tools/secret-management/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceConnectionParameterStore.cs @@ -1,40 +1,52 @@ using System.Text.Json; using System.Text.Json.Serialization; using Azure.Core; +using Azure.Sdk.Tools.SecretRotation.Azure; using Azure.Sdk.Tools.SecretRotation.Configuration; using Azure.Sdk.Tools.SecretRotation.Core; using Microsoft.Extensions.Logging; -using Microsoft.VisualStudio.Services.Client; using Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi; using Microsoft.VisualStudio.Services.WebApi; namespace Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps; -public class ServiceConnectionParameterStore : SecretStore +public class ServiceConnectionParameterStore : AzureDevOpsStore { public const string MappingKey = "ADO Service Connection Parameter"; - private readonly string accountName; private readonly string connectionId; - private readonly TokenCredential credential; - private readonly ILogger logger; private readonly string parameterName; private readonly string projectName; - public ServiceConnectionParameterStore(string accountName, string projectName, string connectionId, - string parameterName, TokenCredential credential, ILogger logger) + public ServiceConnectionParameterStore( + string organization, + string projectName, + string connectionId, + string parameterName, + string? serviceAccountTenantId, + string? serviceAccountName, + Uri? serviceAccountPasswordSecret, + TokenCredential tokenCredential, + ISecretProvider secretProvider, + ILogger logger) + : base( + logger, + organization, + serviceAccountTenantId, + serviceAccountName, + serviceAccountPasswordSecret, + tokenCredential, + secretProvider) { - this.accountName = accountName; this.projectName = projectName; this.connectionId = connectionId; this.parameterName = parameterName; - this.credential = credential; - this.logger = logger; } public override bool CanWrite => true; public static Func GetSecretStoreFactory(TokenCredential credential, + ISecretProvider secretProvider, ILogger logger) { return configuration => @@ -61,12 +73,31 @@ public static Func GetSecretStoreFactory(TokenC throw new Exception("Missing required parameter 'parameterName'"); } + var passwordSecretParsed = Uri.TryCreate(parameters.ServiceAccountPasswordSecret, UriKind.Absolute, out Uri? serviceAccountPasswordSecret); + + if (!string.IsNullOrEmpty(parameters.ServiceAccountName)) + { + if (string.IsNullOrEmpty(parameters.ServiceAccountPasswordSecret)) + { + throw new Exception("Missing required parameter 'serviceAccountPasswordSecret'"); + } + + if (!passwordSecretParsed) + { + throw new Exception("Unable to parse Uri from parameter 'serviceAccountPasswordSecret'"); + } + } + return new ServiceConnectionParameterStore( parameters.AccountName, parameters.ProjectName, parameters.ConnectionId, parameters.ParameterName, + parameters.ServiceAccountTenantId, + parameters.ServiceAccountName, + serviceAccountPasswordSecret, credential, + secretProvider, logger); }; } @@ -74,7 +105,7 @@ public static Func GetSecretStoreFactory(TokenC public override async Task WriteSecretAsync(SecretValue secretValue, SecretState currentState, DateTimeOffset? revokeAfterDate, bool whatIf) { - this.logger.LogDebug("Getting token and devops client for dev.azure.com/{AccountName}", this.accountName); + this.logger.LogDebug("Getting token and devops client for dev.azure.com/{Organization}", this.organization); VssConnection connection = await GetConnectionAsync(); var client = await connection.GetClientAsync(); @@ -106,26 +137,6 @@ public override async Task WriteSecretAsync(SecretValue secretValue, SecretState } } - private async Task GetConnectionAsync() - { - string[] scopes = { "499b84ac-1321-427f-aa17-267ca6975798/.default" }; - string? parentRequestId = null; - - var tokenRequestContext = new TokenRequestContext(scopes, parentRequestId); - - AccessToken authenticationResult = await this.credential.GetTokenAsync( - tokenRequestContext, - CancellationToken.None); - - var connection = new VssConnection( - new Uri($"https://dev.azure.com/{this.accountName}"), - new VssAadCredential(new VssAadToken("Bearer", authenticationResult.Token))); - - await connection.ConnectAsync(); - - return connection; - } - private class Parameters { [JsonPropertyName("accountName")] @@ -139,5 +150,15 @@ private class Parameters [JsonPropertyName("parameterName")] public string? ParameterName { get; set; } + + [JsonPropertyName("serviceAccountTenantId")] + public string? ServiceAccountTenantId { get; set; } + + [JsonPropertyName("serviceAccountName")] + public string? ServiceAccountName { get; set; } + + [JsonPropertyName("serviceAccountPasswordSecret")] + public string? ServiceAccountPasswordSecret { get; set; } + } }