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}