From 9a11a8c44acf1f2e6b7e5faa9154520d2d7c75b0 Mon Sep 17 00:00:00 2001 From: Patrick Hallisey Date: Wed, 1 Mar 2023 14:14:40 -0800 Subject: [PATCH] Add devops-pat and az website stores to secret rotation (#5591) * Add devops-pat and az website stores * Update rotation logic to support post-primary stores and annotation * Add enableWindowsTargeting to secret rotation cli proj * Fix paths in unit tests * Update tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceAccountPersonalAccessTokenStore.cs Co-authored-by: Ben Broderick Phillips --------- Co-authored-by: Ben Broderick Phillips --- ...zure.Sdk.Tools.SecretRotation.Azure.csproj | 10 + .../ISecretProvider.cs | 8 + .../SecretProvider.cs | 46 +++ .../Azure.Sdk.Tools.SecretRotation.Cli.csproj | 2 + .../Commands/RotationCommandBase.cs | 9 +- .../RotationConfiguration.cs | 24 +- .../StoreConfiguration.cs | 3 + .../RotationPlan.cs | 72 +++-- .../SecretStore.cs | 12 +- .../SecretValue.cs | 10 +- ...cretRotation.Stores.AzureAppService.csproj | 11 + .../AzureWebsiteStore.cs | 114 ++++++++ ...s.SecretRotation.Stores.AzureDevOps.csproj | 6 +- .../ServiceAccountPersonalAccessTokenStore.cs | 262 ++++++++++++++++++ .../KeyVaultCertificateStore.cs | 20 +- .../KeyVaultSecretStore.cs | 42 ++- .../CoreTests/RotationConfigurationTests.cs | 4 +- .../Azure.Sdk.Tools.SecretRotation.sln | 13 + 18 files changed, 600 insertions(+), 68 deletions(-) create mode 100644 tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/Azure.Sdk.Tools.SecretRotation.Azure.csproj create mode 100644 tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/ISecretProvider.cs create mode 100644 tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/SecretProvider.cs create mode 100644 tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService.csproj create mode 100644 tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService/AzureWebsiteStore.cs create mode 100644 tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceAccountPersonalAccessTokenStore.cs diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/Azure.Sdk.Tools.SecretRotation.Azure.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/Azure.Sdk.Tools.SecretRotation.Azure.csproj new file mode 100644 index 00000000000..c825ef67ae2 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/Azure.Sdk.Tools.SecretRotation.Azure.csproj @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/ISecretProvider.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/ISecretProvider.cs new file mode 100644 index 00000000000..caf3c75daab --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/ISecretProvider.cs @@ -0,0 +1,8 @@ +using Azure.Core; + +namespace Azure.Sdk.Tools.SecretRotation.Azure; + +public interface ISecretProvider +{ + Task GetSecretValueAsync(TokenCredential credential, Uri secretUri); +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/SecretProvider.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/SecretProvider.cs new file mode 100644 index 00000000000..15a2192e138 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Azure/SecretProvider.cs @@ -0,0 +1,46 @@ +using System.Text.RegularExpressions; +using Azure.Core; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Azure; + +public class SecretProvider : ISecretProvider +{ + private static readonly Regex secretRegex = new (@"(?https://.*?\.vault\.azure\.net)/secrets/(?.*)", RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + private readonly ILogger logger; + + public SecretProvider(ILogger logger) + { + this.logger = logger; + } + + public async Task GetSecretValueAsync(TokenCredential credential, Uri secretUri) + { + Match match = secretRegex.Match(secretUri.AbsoluteUri); + + if (!match.Success) + { + throw new ArgumentException("Unable to parse uri as a Key Vault secret uri"); + } + + string vaultUrl = match.Groups["vault"].Value; + string secretName = match.Groups["secret"].Value; + + try + { + var secretClient = new SecretClient(new Uri(vaultUrl), credential); + + this.logger.LogDebug("Retrieving value for secret '{SecretUrl}'", secretUri); + + KeyVaultSecret secret = await secretClient.GetSecretAsync(secretName); + + return secret.Value; + } + catch (Exception exception) + { + this.logger.LogError(exception, "Unable to read secret {SecretName} from vault {VaultUrl}", secretName, vaultUrl); + throw; + } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Azure.Sdk.Tools.SecretRotation.Cli.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Azure.Sdk.Tools.SecretRotation.Cli.csproj index 484b693ad3e..ebab7571a4f 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Azure.Sdk.Tools.SecretRotation.Cli.csproj +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Azure.Sdk.Tools.SecretRotation.Cli.csproj @@ -4,6 +4,7 @@ Exe net6.0-windows true + true true true secrets @@ -20,6 +21,7 @@ + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/RotationCommandBase.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/RotationCommandBase.cs index 035916d70ad..0241dae23dc 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/RotationCommandBase.cs +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/RotationCommandBase.cs @@ -1,9 +1,11 @@ -using System.CommandLine; +using System.CommandLine; using System.CommandLine.Invocation; using Azure.Identity; +using Azure.Sdk.Tools.SecretRotation.Azure; using Azure.Sdk.Tools.SecretRotation.Configuration; using Azure.Sdk.Tools.SecretRotation.Core; using Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory; +using Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService; using Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps; using Azure.Sdk.Tools.SecretRotation.Stores.Generic; using Azure.Sdk.Tools.SecretRotation.Stores.KeyVault; @@ -68,8 +70,11 @@ private static IDictionary> GetDef [KeyVaultSecretStore.MappingKey] = KeyVaultSecretStore.GetSecretStoreFactory(tokenCredential, logger), [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), - [AadApplicationSecretStore.MappingKey] = AadApplicationSecretStore.GetSecretStoreFactory(tokenCredential, logger) + [AadApplicationSecretStore.MappingKey] = AadApplicationSecretStore.GetSecretStoreFactory(tokenCredential, logger), + [AzureWebsiteStore.MappingKey] = AzureWebsiteStore.GetSecretStoreFactory(tokenCredential, logger), + [AadApplicationSecretStore.MappingKey] = AadApplicationSecretStore.GetSecretStoreFactory(tokenCredential, logger), }; } } diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfiguration.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfiguration.cs index c2845899a59..80b8b8e8996 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfiguration.cs +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfiguration.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using Azure.Sdk.Tools.SecretRotation.Core; using Microsoft.Extensions.Logging; @@ -219,21 +219,18 @@ private IList GetSecondaryStores(PlanConfiguration planConfiguratio AddCapabilityError(validationErrors, store, nameof(store.CanRead), "Primary"); } - if (storeConfiguration.IsOrigin) + bool mustAnnotateCompletion = storeConfiguration.IsOrigin || + planConfiguration.StoreConfigurations.Any(x => x.UpdateAfterPrimary); + + if (mustAnnotateCompletion && !store.CanAnnotate) { - // An origin primary must support post-rotation annotation - if (!store.CanAnnotate) - { - AddCapabilityError(validationErrors, store, nameof(store.CanAnnotate), "Primary + Origin"); - } + AddCapabilityError(validationErrors, store, nameof(store.CanAnnotate), "Primary"); } - else + + if (!storeConfiguration.IsOrigin && !store.CanWrite) { // A non origin primary has to support Write because it doesn't originate values - if (!store.CanWrite) - { - AddCapabilityError(validationErrors, store, nameof(store.CanWrite), "Primary"); - } + AddCapabilityError(validationErrors, store, nameof(store.CanWrite), "Primary"); } return store; @@ -271,6 +268,9 @@ private SecretStore ResolveStore(string configurationKey, StoreConfiguration sto SecretStore store = factory.Invoke(storeConfiguration); store.Name = storeName; + store.UpdateAfterPrimary = storeConfiguration.UpdateAfterPrimary; + store.IsOrigin = storeConfiguration.IsOrigin; + store.IsPrimary = storeConfiguration.IsPrimary; return store; } diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/StoreConfiguration.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/StoreConfiguration.cs index a93ef1d0134..196f75752d7 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/StoreConfiguration.cs +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/StoreConfiguration.cs @@ -20,6 +20,9 @@ public class StoreConfiguration [JsonPropertyName("name")] public string? Name { get; set; } + [JsonPropertyName("updateAfterPrimary")] + public bool UpdateAfterPrimary { get; set; } + public string ResolveStoreName(string configurationKey) { return Name ?? $"{configurationKey} ({Type})"; diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlan.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlan.cs index 73e9e79e8bd..9d4144f03c4 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlan.cs +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlan.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using Azure.Sdk.Tools.SecretRotation.Core; using Microsoft.Extensions.Logging; @@ -134,51 +134,67 @@ private async Task RotateAsync(string operationId, SecretState currentState, boo DateTimeOffset invocationTime = this.timeProvider.GetCurrentDateTimeOffset(); SecretValue newValue = await OriginateNewValueAsync(operationId, invocationTime, currentState, whatIf); + // TODO: some providers will issue secrets for longer than we requested. Should we propagate the real expiration date, or the desired expiration date? + + await WriteValueToPrePrimaryStoresAsync(newValue, currentState, whatIf); - await WriteValueToSecondaryStoresAsync(newValue, currentState, whatIf); + if (OriginStore != PrimaryStore) + { + await WriteValueToPrimaryAsync(newValue, currentState, invocationTime, whatIf); + } - await WriteValueToPrimaryAndOriginAsync(newValue, invocationTime, currentState, whatIf); + await WriteValueToPostPrimaryStoresAsync(newValue, currentState, whatIf); + + await MarkRotationCompleteAsync(newValue, currentState, invocationTime, whatIf); } - private async Task WriteValueToPrimaryAndOriginAsync(SecretValue newValue, - DateTimeOffset invocationTime, - SecretState currentState, + private async Task WriteValueToPrimaryAsync(SecretValue newValue, SecretState currentState, DateTimeOffset invocationTime, bool whatIf) { DateTimeOffset? revokeAfterDate = RevokeAfterPeriod.HasValue ? invocationTime.Add(RevokeAfterPeriod.Value) : null; - // Complete rotation by either annotating the origin or writing to primary - if (OriginStore != PrimaryStore) + if (!PrimaryStore.CanWrite) { - if (!PrimaryStore.CanWrite) - { - // Primary only has to support write when it's not also origin. - throw new RotationException( - $"Rotation plan '{Name}' uses separate Primary and Origin stores, but its primary store type '{OriginStore.GetType()}' does not support CanWrite"); - } - - // New value along with the datetime when old values should be revoked - await PrimaryStore.WriteSecretAsync(newValue, currentState, revokeAfterDate, whatIf); + // Primary only has to support write when it's not also origin. + throw new RotationException( + $"Rotation plan '{Name}' uses separate Primary and Origin stores, but its primary store type '{OriginStore.GetType()}' does not support CanWrite"); } - else - { - if (!OriginStore.CanAnnotate) - { - throw new RotationException( - $"Rotation plan '{Name}' uses a combined Origin and Primary store, but the store type '{OriginStore.GetType()}' does not support CanAnnotate"); - } - await OriginStore.MarkRotationCompleteAsync(newValue, revokeAfterDate, whatIf); + // New value along with the datetime when old values should be revoked + await PrimaryStore.WriteSecretAsync(newValue, currentState, revokeAfterDate, whatIf); + } + + private async Task MarkRotationCompleteAsync(SecretValue newValue, SecretState currentState, DateTimeOffset invocationTime, + bool whatIf) + { + DateTimeOffset? revokeAfterDate = RevokeAfterPeriod.HasValue + ? invocationTime.Add(RevokeAfterPeriod.Value) + : null; + + if (!PrimaryStore.CanAnnotate) + { + throw new RotationException( + $"Rotation plan '{Name}' uses the store type '{PrimaryStore.GetType()}' which does not support CanAnnotate"); } + + await PrimaryStore.MarkRotationCompleteAsync(newValue, revokeAfterDate, whatIf); } - private async Task WriteValueToSecondaryStoresAsync(SecretValue newValue, SecretState currentState, bool whatIf) + + private async Task WriteValueToPrePrimaryStoresAsync(SecretValue newValue, SecretState currentState, bool whatIf) { - // TODO: some providers will issue secrets for longer than we requested. Should we propagate the real expiration date, or the desired expiration date? + foreach (SecretStore secondaryStore in SecondaryStores.Where(store => !store.UpdateAfterPrimary)) + { + // secondaries don't store revocation dates. + await secondaryStore.WriteSecretAsync(newValue, currentState, null, whatIf); + } + } - foreach (SecretStore secondaryStore in SecondaryStores) + private async Task WriteValueToPostPrimaryStoresAsync(SecretValue newValue, SecretState currentState, bool whatIf) + { + foreach (SecretStore secondaryStore in SecondaryStores.Where(store => store.UpdateAfterPrimary)) { // secondaries don't store revocation dates. await secondaryStore.WriteSecretAsync(newValue, currentState, null, whatIf); diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretStore.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretStore.cs index 11c1b1f4bf3..e69ce60fd24 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretStore.cs +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretStore.cs @@ -1,4 +1,4 @@ -namespace Azure.Sdk.Tools.SecretRotation.Core; +namespace Azure.Sdk.Tools.SecretRotation.Core; public abstract class SecretStore { @@ -12,7 +12,15 @@ public abstract class SecretStore public virtual bool CanRevoke => false; - public string? Name { get; set; } + public virtual string? Name { get; set; } + + public virtual bool UpdateAfterPrimary { get; set; } + + public virtual bool IsPrimary { get; set; } + + public virtual bool IsOrigin { get; set; } + + // Read diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretValue.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretValue.cs index f3ebaa15d30..4b8e0015bd0 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretValue.cs +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretValue.cs @@ -1,4 +1,4 @@ -namespace Azure.Sdk.Tools.SecretRotation.Core; +namespace Azure.Sdk.Tools.SecretRotation.Core; public class SecretValue { @@ -10,13 +10,13 @@ public class SecretValue public string Value { get; set; } = string.Empty; /// - /// A state object created by origin stores that can be used in post-rotation annotation. + /// A state object created by primary stores that can be used in post-rotation annotation. /// /// - /// This is a round-trip object that will be returned to the origin IStateStore once all other stores are updated. - /// For example, a Key Vault origin may store the original KeyVaultCertificate reference in OriginState. + /// This is a round-trip object that will be returned to the primary IStateStore once all other stores are updated. + /// For example, a Key Vault primary may store the original KeyVaultCertificate reference in PrimaryState. /// - public object? OriginState { get; set; } + public object? PrimaryState { get; set; } // During propagation, origin and secondary stores can add tags to be written to the primary store during the completion/annotation phase. // These tags are used during revocation to ensure the appropriate origin or downstream resource is revoked. diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService.csproj new file mode 100644 index 00000000000..684b4a635ef --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService/AzureWebsiteStore.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService/AzureWebsiteStore.cs new file mode 100644 index 00000000000..84fa331d458 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService/AzureWebsiteStore.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Core; +using Azure.ResourceManager; +using Azure.ResourceManager.AppService; +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService; + +public class AzureWebsiteStore : SecretStore +{ + public const string MappingKey = "Azure Website"; + + private readonly TokenCredential credential; + private readonly ILogger logger; + private readonly string subscriptionId; + private readonly string resourceGroupName; + private readonly string websiteName; + private readonly RotationAction rotationAction; + + public AzureWebsiteStore(string subscriptionId, string resourceGroupName, string websiteName, RotationAction rotationAction, + TokenCredential credential, ILogger logger) + { + this.subscriptionId = subscriptionId; + this.resourceGroupName = resourceGroupName; + this.websiteName = websiteName; + this.rotationAction = rotationAction; + this.credential = credential; + this.logger = logger; + } + + public override bool CanWrite => true; + + public static Func GetSecretStoreFactory(TokenCredential credential, ILogger logger) + { + return configuration => + { + var parameters = configuration.Parameters?.Deserialize(); + + if (string.IsNullOrEmpty(parameters?.SubscriptionId)) + { + throw new Exception("Missing required parameter 'subscriptionId'"); + } + + if (string.IsNullOrEmpty(parameters?.ResourceGroup)) + { + throw new Exception("Missing required parameter 'resourceGroup'"); + } + + if (string.IsNullOrEmpty(parameters?.Website)) + { + throw new Exception("Missing required parameter 'website'"); + } + + return new AzureWebsiteStore( + parameters.SubscriptionId, + parameters.ResourceGroup, + parameters.Website, + parameters.RotationAction, + credential, + logger); + }; + } + + public override async Task WriteSecretAsync(SecretValue secretValue, SecretState currentState, DateTimeOffset? revokeAfterDate, bool whatIf) + { + ResourceIdentifier resourceId = WebSiteResource.CreateResourceIdentifier(this.subscriptionId, this.resourceGroupName, this.websiteName); + ArmClient client = new ArmClient(this.credential); + WebSiteResource website = client.GetWebSiteResource(resourceId); + + if (this.rotationAction == RotationAction.None) + { + return; + } + + if (!whatIf) + { + this.logger.LogInformation("Restarting Azure Website '{SubscriptionId}/{ResourceGroup}/{Website}'", this.subscriptionId, this.resourceGroupName, this.websiteName); + + await website.RestartAsync(softRestart: false, synchronous: true); + } + else + { + this.logger.LogInformation("WHAT IF: Restart Azure Website '{SubscriptionId}/{ResourceGroup}/{Website}'", this.subscriptionId, this.resourceGroupName, this.websiteName); + } + } + + public enum RotationAction + { + [JsonPropertyName("none")] + None, + + [JsonPropertyName("restartWebsite")] + RestartWebsite, + } + + private class Parameters + { + [JsonPropertyName("subscriptionId")] + public string? SubscriptionId { get; set; } + + [JsonPropertyName("resourceGroup")] + public string? ResourceGroup { get; set; } + + [JsonPropertyName("website")] + public string? Website { get; set; } + + [JsonPropertyName("rotationAction")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public RotationAction RotationAction { get; set; } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps.csproj index e0e08ad6a03..e84be8586b3 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps.csproj +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps.csproj @@ -1,12 +1,12 @@ - - - + + + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceAccountPersonalAccessTokenStore.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceAccountPersonalAccessTokenStore.cs new file mode 100644 index 00000000000..63b714e901a --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceAccountPersonalAccessTokenStore.cs @@ -0,0 +1,262 @@ +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 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, + string organization, + string patDisplayName, + string scopes, + string serviceAccountTenantId, + string serviceAccountName, + Uri serviceAccountPasswordSecret, + RevocationAction revocationAction, + TokenCredential tokenCredential, + ISecretProvider 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; + + public override bool CanRevoke => true; + + public static Func GetSecretStoreFactory(TokenCredential credential, ISecretProvider secretProvider, ILogger logger) + { + return configuration => + { + var parameters = configuration.Parameters?.Deserialize(); + + if (string.IsNullOrEmpty(parameters?.Organization)) + { + throw new Exception("Missing required parameter 'organization'"); + } + + if (string.IsNullOrEmpty(parameters.PatDisplayName)) + { + throw new Exception("Missing required parameter 'patDisplayName'"); + } + + if (string.IsNullOrEmpty(parameters.Scopes)) + { + throw new Exception("Missing required parameter 'scopes'"); + } + + if (string.IsNullOrEmpty(parameters.ServiceAccountTenantId)) + { + throw new Exception("Missing required parameter 'serviceAccountTenantId'"); + } + + if (string.IsNullOrEmpty(parameters.ServiceAccountName)) + { + throw new Exception("Missing required parameter 'serviceAccountName'"); + } + + if (string.IsNullOrEmpty(parameters.ServiceAccountPasswordSecret)) + { + throw new Exception("Missing required parameter 'serviceAccountPasswordSecret'"); + } + + if (!Uri.TryCreate(parameters.ServiceAccountPasswordSecret, UriKind.Absolute, out Uri? serviceAccountPasswordSecret)) + { + throw new Exception("Unable to parse Uri from parameter 'serviceAccountPasswordSecret'"); + } + + return new ServiceAccountPersonalAccessTokenStore( + logger, + parameters.Organization, + parameters.PatDisplayName, + parameters.Scopes, + parameters.ServiceAccountTenantId, + parameters.ServiceAccountName, + serviceAccountPasswordSecret!, + parameters.RevocationAction, + credential, + secretProvider); + }; + } + + public override async Task OriginateValueAsync(SecretState currentState, DateTimeOffset expirationDate, bool whatIf) + { + VssConnection connection = await GetConnectionAsync(); + TokensHttpClient client = connection.GetClient(); + + if (whatIf) + { + this.logger.LogInformation("WHAT IF: Post tokens/pat request to Azure DevOps"); + + return new SecretValue { Value = string.Empty, ExpirationDate = expirationDate }; + } + + this.logger.LogInformation("Posting pat creation request to '{OrganizationUrl}'", + $"https://dev.azure.com/{this.organization}"); + + PatTokenResult result = await client.CreatePatAsync(new PatTokenCreateRequest { AllOrgs = false, DisplayName = this.patDisplayName, Scope = this.scopes, ValidTo = expirationDate.UtcDateTime }); + + string authorizationId = result.PatToken.AuthorizationId.ToString(); + + this.logger.LogInformation("Azure DevOps responded with authorization id '{AuthorizationId}'", authorizationId); + + return new SecretValue + { + Value = result.PatToken.Token, + ExpirationDate = result.PatToken.ValidTo, + Tags = { ["AdoPatAuthorizationId"] = authorizationId } + }; + } + + 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) || + this.revocationAction != RevocationAction.Revoke) + { + return null; + } + + if (!Guid.TryParse(authorizationIdString, out Guid authorizationId)) + { + this.logger.LogWarning("Unable to parse Authorization Id as a Guid: '{AuthorizationId}'", authorizationIdString); + return null; + } + + return async () => + { + VssConnection connection = await GetConnectionAsync(); + TokensHttpClient client = connection.GetClient(); + + // use the application's object id and the keyId to revoke the old password + if (whatIf) + { + this.logger.LogInformation( + "WHAT IF: Remove PAT with authorization id '{AuthorizationId}' from '{OrganizationUrl}'", + authorizationId, + $"https://dev.azure.com/{this.organization}"); + + return; + } + + try + { + this.logger.LogInformation( + "Removing PAT with authorization id '{AuthorizationId}' from '{OrganizationUrl}'", + authorizationId, + $"https://dev.azure.com/{this.organization}"); + + await client.RevokeAsync(authorizationId); + } + catch (SessionTokenNotFoundException) + { + // ignore "not found" exception on delete + } + }; + } + + 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")] + None = 0, + + [JsonPropertyName("revoke")] + Revoke = 1 + } + + private class Parameters + { + [JsonPropertyName("organization")] + public string? Organization { get; set; } + + [JsonPropertyName("patDisplayName")] + public string? PatDisplayName { get; set; } + + [JsonPropertyName("scopes")] + public string? Scopes { get; set; } + + [JsonPropertyName("serviceAccountTenantId")] + public string? ServiceAccountTenantId { get; set; } + + [JsonPropertyName("serviceAccountName")] + public string? ServiceAccountName { get; set; } + + [JsonPropertyName("serviceAccountPasswordSecret")] + public string? ServiceAccountPasswordSecret { get; set; } + + [JsonPropertyName("revocationAction")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public RevocationAction RevocationAction { get; set; } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultCertificateStore.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultCertificateStore.cs index f5b3a56f262..c8bef8ca93b 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultCertificateStore.cs +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultCertificateStore.cs @@ -112,14 +112,22 @@ public override async Task OriginateValueAsync(SecretState currentS await this.certificateClient.StartCreateCertificateAsync(this.certificateName, policy); this.logger.LogInformation("Waiting for certificate operation '{OperationId}' to complete", operation.Id); - Response? response = await operation.WaitForCompletionAsync(); + KeyVaultCertificateWithPolicy certificate = await operation.WaitForCompletionAsync(); - string base64 = Convert.ToBase64String(response.Value.Cer); + string base64 = Convert.ToBase64String(certificate.Cer); - return new SecretValue + var secretValue = new SecretValue { - ExpirationDate = response.Value.Properties.ExpiresOn, OriginState = response.Value, Value = base64 + ExpirationDate = certificate.Properties.ExpiresOn, + Value = base64 }; + + if (this.IsPrimary) + { + secretValue.PrimaryState = certificate; + } + + return secretValue; } public override async Task MarkRotationCompleteAsync(SecretValue secretValue, DateTimeOffset? revokeAfterDate, @@ -133,10 +141,10 @@ public override async Task MarkRotationCompleteAsync(SecretValue secretValue, Da return; } - if (secretValue.OriginState is not KeyVaultCertificateWithPolicy certificate) + if (secretValue.PrimaryState is not KeyVaultCertificateWithPolicy certificate) { throw new RotationException( - "The OriginState value passed to KeyVaultCertificateStore was not of type KeyVaultCertificateWithPolicy"); + "The PrimaryState value passed to KeyVaultCertificateStore was not of type KeyVaultCertificateWithPolicy"); } this.logger.LogInformation("Adding tag 'rotation-complete' to certificate '{CertificateName}' in vault '{Vault}'", diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultSecretStore.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultSecretStore.cs index b4bfff53967..5175bdb649a 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultSecretStore.cs +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultSecretStore.cs @@ -29,7 +29,6 @@ public enum RevocationAction RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(5)); - private readonly bool isPrimaryStore; private readonly ILogger logger; private readonly RevocationAction revocationAction; @@ -41,13 +40,11 @@ public KeyVaultSecretStore(ILogger logger, TokenCredential credential, Uri vaultUri, string secretName, - RevocationAction revocationAction, - bool isPrimaryStore) + RevocationAction revocationAction) { this.vaultUri = vaultUri; this.secretName = secretName; this.revocationAction = revocationAction; - this.isPrimaryStore = isPrimaryStore; this.logger = logger; this.secretClient = new SecretClient(vaultUri, credential); } @@ -55,6 +52,7 @@ public KeyVaultSecretStore(ILogger logger, public override bool CanRead => true; public override bool CanWrite => true; public override bool CanRevoke => true; + public override bool CanAnnotate => true; public static Func GetSecretStoreFactory(TokenCredential credential, ILogger logger) @@ -83,8 +81,7 @@ public static Func GetSecretStoreFactory(TokenC credential, vaultUri, secretName, - revocationAction, - storeConfiguration.IsPrimary); + revocationAction); }; } @@ -184,9 +181,14 @@ public override async Task WriteSecretAsync(SecretValue secretValue, secret = await this.secretClient.SetSecretAsync(secret); - if (revokeAfterDate.HasValue && this.isPrimaryStore) + if (this.IsPrimary) { - await SetRevokeAfterForOldVersionsAsync(secret.Properties.Version, revokeAfterDate.Value); + secretValue.PrimaryState = secret; + + if (revokeAfterDate.HasValue) + { + await SetRevokeAfterForOldVersionsAsync(secret.Properties.Version, revokeAfterDate.Value); + } } } @@ -238,6 +240,30 @@ public override async Task WriteSecretAsync(SecretValue secretValue, }; } + public override async Task MarkRotationCompleteAsync(SecretValue secretValue, DateTimeOffset? revokeAfterDate, bool whatIf) + { + if (whatIf) + { + this.logger.LogInformation( + "WHAT IF: Add tag 'rotation-complete' to secret '{CertificateName}' in vault '{Vault}'", + this.secretName, this.vaultUri); + return; + } + + if (secretValue.PrimaryState is not KeyVaultSecret secret) + { + throw new RotationException( + "The PrimaryState value passed to KeyVaultSecretStore was not of type KeyVaultSecret"); + } + + this.logger.LogInformation("Adding tag 'rotation-complete' to certificate '{CertificateName}' in vault '{Vault}'", + this.secretName, this.vaultUri); + + secret.Properties.Tags.Add("rotation-complete", "true"); + + await this.secretClient.UpdateSecretPropertiesAsync(secret.Properties); + } + private static bool TryGetRevokeAfterDate(IDictionary tags, out DateTimeOffset value) { if (tags.TryGetValue(RevokeAfterTag, out string? tagValue)) diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/RotationConfigurationTests.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/RotationConfigurationTests.cs index 8a9b33fd33e..e83b4148321 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/RotationConfigurationTests.cs +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/RotationConfigurationTests.cs @@ -27,7 +27,7 @@ public void LoadFrom_InvalidPath_ThrowsException() [Test] public void LoadFrom_ValidPath_ReturnConfiguration() { - string validPath = TestFiles.ResolvePath("TestConfigurations/valid/random-string.json"); + string validPath = TestFiles.ResolvePath("TestConfigurations/Valid/random-string.json"); var storeFactories = new Dictionary>(); // Act @@ -39,7 +39,7 @@ public void LoadFrom_ValidPath_ReturnConfiguration() [Test] public void GetPlan_ValidConfiguration_ReturnsPlan() { - string configurationPath = TestFiles.ResolvePath("TestConfigurations/valid/random-string.json"); + string configurationPath = TestFiles.ResolvePath("TestConfigurations/Valid/random-string.json"); var storeFactories = new Dictionary> { diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.sln b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.sln index 199032cb38f..9f76fe41221 100644 --- a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.sln +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.sln @@ -26,6 +26,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Azure", "Azure.Sdk.Tools.SecretRotation.Azure\Azure.Sdk.Tools.SecretRotation.Azure.csproj", "{C7116D4F-2156-4623-BA3A-CE605592D386}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService", "Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService\Azure.Sdk.Tools.SecretRotation.Stores.AzureAppService.csproj", "{653CFBB4-2DA4-4925-A050-F37EF5BE823F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -64,6 +68,14 @@ Global {67C87A2D-AF22-4C36-B99B-7E1A60B07B04}.Debug|Any CPU.Build.0 = Debug|Any CPU {67C87A2D-AF22-4C36-B99B-7E1A60B07B04}.Release|Any CPU.ActiveCfg = Release|Any CPU {67C87A2D-AF22-4C36-B99B-7E1A60B07B04}.Release|Any CPU.Build.0 = Release|Any CPU + {C7116D4F-2156-4623-BA3A-CE605592D386}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7116D4F-2156-4623-BA3A-CE605592D386}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7116D4F-2156-4623-BA3A-CE605592D386}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7116D4F-2156-4623-BA3A-CE605592D386}.Release|Any CPU.Build.0 = Release|Any CPU + {653CFBB4-2DA4-4925-A050-F37EF5BE823F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {653CFBB4-2DA4-4925-A050-F37EF5BE823F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {653CFBB4-2DA4-4925-A050-F37EF5BE823F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {653CFBB4-2DA4-4925-A050-F37EF5BE823F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -73,6 +85,7 @@ Global {DEE3F30A-5B2E-4C44-B273-A9703CAAFE35} = {B06067D2-6F7C-4281-A2AC-4E7DBD94C5DD} {0D73540F-FB12-4555-846F-3E45D93219D7} = {B06067D2-6F7C-4281-A2AC-4E7DBD94C5DD} {67C87A2D-AF22-4C36-B99B-7E1A60B07B04} = {B06067D2-6F7C-4281-A2AC-4E7DBD94C5DD} + {653CFBB4-2DA4-4925-A050-F37EF5BE823F} = {B06067D2-6F7C-4281-A2AC-4E7DBD94C5DD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5C35D5F5-EED1-4FA7-941C-02D71B34D9BA}