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 new file mode 100644 index 00000000000..484b693ad3e --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Azure.Sdk.Tools.SecretRotation.Cli.csproj @@ -0,0 +1,44 @@ + + + + Exe + net6.0-windows + true + true + true + secrets + + + + + + + + + + + + + + + + + + + + + + + + false + + + + + + Windows + true + + + + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/RotateCommand.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/RotateCommand.cs new file mode 100644 index 00000000000..38f529660b7 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/RotateCommand.cs @@ -0,0 +1,68 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; + +namespace Azure.Sdk.Tools.SecretRotation.Cli.Commands; + +public class RotateCommand : RotationCommandBase +{ + private readonly Option allOption = new(new[] { "--all", "-a" }, "Rotate all secrets"); + private readonly Option secretsOption = new(new[] { "--secrets", "-s" }, "Rotate only the specified secrets"); + private readonly Option expiringOption = new(new[] { "--expiring", "-e" }, "Only rotate expiring secrets"); + private readonly Option whatIfOption = new(new[] { "--dry-run", "-d" }, "Preview the changes that will be made without submitting them."); + + public RotateCommand() : base("rotate", "Rotate one, expiring or all secrets") + { + AddOption(this.expiringOption); + AddOption(this.whatIfOption); + AddOption(this.allOption); + AddOption(this.secretsOption); + AddValidator(ValidateOptions); + } + + protected override async Task HandleCommandAsync(ILogger logger, RotationConfiguration rotationConfiguration, + InvocationContext invocationContext) + { + bool onlyRotateExpiring = invocationContext.ParseResult.GetValueForOption(this.expiringOption); + bool all = invocationContext.ParseResult.GetValueForOption(this.allOption); + bool whatIf = invocationContext.ParseResult.GetValueForOption(this.whatIfOption); + + var timeProvider = new TimeProvider(); + + IEnumerable plans; + + if (all) + { + plans = rotationConfiguration.GetAllRotationPlans(logger, timeProvider); + } + else + { + string[] secretNames = invocationContext.ParseResult.GetValueForOption(this.secretsOption)!; + + plans = rotationConfiguration.GetRotationPlans(logger, secretNames, timeProvider); + } + + foreach (RotationPlan plan in plans) + { + await plan.ExecuteAsync(onlyRotateExpiring, whatIf); + } + } + + private void ValidateOptions(CommandResult commandResult) + { + bool secretsUsed = commandResult.FindResultFor(this.secretsOption) is not null; + bool allUsed = commandResult.FindResultFor(this.allOption) is not null; + + if (!(secretsUsed || allUsed)) + { + commandResult.ErrorMessage = "Either the --secrets or the --all option must be provided."; + } + + if (secretsUsed && allUsed) + { + commandResult.ErrorMessage = "The --secrets and --all options cannot both be provided."; + } + } +} 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 new file mode 100644 index 00000000000..035916d70ad --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/RotationCommandBase.cs @@ -0,0 +1,75 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using Azure.Identity; +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.AzureDevOps; +using Azure.Sdk.Tools.SecretRotation.Stores.Generic; +using Azure.Sdk.Tools.SecretRotation.Stores.KeyVault; +using Microsoft.Extensions.Logging.Console; + +namespace Azure.Sdk.Tools.SecretRotation.Cli.Commands; + +public abstract class RotationCommandBase : Command +{ + private readonly Option configOption = new(new[] { "--config", "-c" }, "Configuration path") + { + IsRequired = true, + Arity = ArgumentArity.ExactlyOne + }; + + private readonly Option verboseOption = new(new[] { "--verbose", "-v" }, "Verbose output"); + + protected RotationCommandBase(string name, string description) : base(name, description) + { + AddOption(this.configOption); + AddOption(this.verboseOption); + this.SetHandler(ParseAndHandleCommandAsync); + } + + protected abstract Task HandleCommandAsync(ILogger logger, RotationConfiguration rotationConfiguration, + InvocationContext invocationContext); + + private async Task ParseAndHandleCommandAsync(InvocationContext invocationContext) + { + string configPath = invocationContext.ParseResult.GetValueForOption(this.configOption)!; + bool verbose = invocationContext.ParseResult.GetValueForOption(this.verboseOption); + + LogLevel logLevel = verbose ? LogLevel.Trace : LogLevel.Information; + + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder + .AddConsoleFormatter() + .AddConsole(options => options.FormatterName = SimplerConsoleFormatter.FormatterName) + .SetMinimumLevel(logLevel)); + + ILogger logger = loggerFactory.CreateLogger(string.Empty); + + logger.LogDebug("Parsing configuration"); + + // TODO: Pass a logger to the token so it can verbose log getting tokens. + var tokenCredential = new AzureCliCredential(); + + IDictionary> secretStoreFactories = + GetDefaultSecretStoreFactories(tokenCredential, logger); + + // TODO: Pass a logger to RotationConfiguration so it can verbose log when reading from files. + RotationConfiguration rotationConfiguration = RotationConfiguration.From(configPath, secretStoreFactories); + + await HandleCommandAsync(logger, rotationConfiguration, invocationContext); + } + + private static IDictionary> GetDefaultSecretStoreFactories( + AzureCliCredential tokenCredential, ILogger logger) + { + return new Dictionary> + { + [RandomStringGenerator.MappingKey] = RandomStringGenerator.GetSecretStoreFactory(logger), + [KeyVaultSecretStore.MappingKey] = KeyVaultSecretStore.GetSecretStoreFactory(tokenCredential, logger), + [KeyVaultCertificateStore.MappingKey] = KeyVaultCertificateStore.GetSecretStoreFactory(tokenCredential, logger), + [ManualActionStore.MappingKey] = ManualActionStore.GetSecretStoreFactory(logger, new ConsoleValueProvider()), + [ServiceConnectionParameterStore.MappingKey] = ServiceConnectionParameterStore.GetSecretStoreFactory(tokenCredential, logger), + [AadApplicationSecretStore.MappingKey] = AadApplicationSecretStore.GetSecretStoreFactory(tokenCredential, logger) + }; + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/StatusCommand.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/StatusCommand.cs new file mode 100644 index 00000000000..46e2612d033 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Commands/StatusCommand.cs @@ -0,0 +1,29 @@ +using System.CommandLine.Invocation; +using System.Text.Json; +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; + +namespace Azure.Sdk.Tools.SecretRotation.Cli.Commands; + +public class StatusCommand : RotationCommandBase +{ + public StatusCommand() : base("status", "Show secret rotation status") + { + } + + protected override async Task HandleCommandAsync(ILogger logger, RotationConfiguration rotationConfiguration, + InvocationContext invocationContext) + { + var timeProvider = new TimeProvider(); + IEnumerable plans = rotationConfiguration.GetAllRotationPlans(logger, timeProvider); + + foreach (RotationPlan plan in plans) + { + Console.WriteLine(); + Console.WriteLine($"{plan.Name}:"); + + RotationPlanStatus status = await plan.GetStatusAsync(); + Console.WriteLine(JsonSerializer.Serialize(status, new JsonSerializerOptions { WriteIndented = true })); + } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/ConsoleValueProvider.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/ConsoleValueProvider.cs new file mode 100644 index 00000000000..349604227d5 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/ConsoleValueProvider.cs @@ -0,0 +1,88 @@ +using System.Text; +using System.Windows.Forms; +using Azure.Sdk.Tools.SecretRotation.Stores.Generic; + +namespace Azure.Sdk.Tools.SecretRotation.Cli; + +internal class ConsoleValueProvider : IUserValueProvider +{ + public string GetValue(string prompt, bool secret) + { + Console.Write($"{prompt}: "); + StringBuilder keysPressed = new(); + while (true) + { + ConsoleKeyInfo key = Console.ReadKey(secret); + + switch (key.Key) + { + case ConsoleKey.Enter: + Console.WriteLine(); + return keysPressed.ToString(); + case ConsoleKey.Backspace: + { + if (keysPressed.Length > 0) + { + Console.Write(' '); + Console.Write(key.KeyChar); + keysPressed.Length -= 1; + } + + break; + } + default: + keysPressed.Append(key.KeyChar); + break; + } + } + } + + public void PromptUser(string prompt, string? oldValue, string? newValue) + { + Console.WriteLine(); + Console.WriteLine(prompt); + + bool promptNew = !string.IsNullOrEmpty(newValue); + bool promptOld = !string.IsNullOrEmpty(oldValue); + + if (promptOld) + { + Console.WriteLine("Press Ctrl-O to copy the old value to the clipboard."); + } + + if (promptNew) + { + Console.WriteLine("Press Ctrl-N to copy the new value to the clipboard."); + } + + Console.WriteLine("Press Enter to continue."); + + while (true) + { + ConsoleKeyInfo key = Console.ReadKey(true); + + if (key is { Key: ConsoleKey.Enter }) + { + break; + } + + if (promptOld && key is { Modifiers: ConsoleModifiers.Control, Key: ConsoleKey.O }) + { + SetClipboard(oldValue!); + } + + if (promptNew && key is { Modifiers: ConsoleModifiers.Control, Key: ConsoleKey.N }) + { + SetClipboard(newValue!); + } + } + } + + private static void SetClipboard(string value) + { + Thread thread = new(() => Clipboard.SetText(value)); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/GlobalUsings.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/GlobalUsings.cs new file mode 100644 index 00000000000..68f5d22b532 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/GlobalUsings.cs @@ -0,0 +1,3 @@ +// Global using directives + +global using Microsoft.Extensions.Logging; diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Program.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Program.cs new file mode 100644 index 00000000000..e3b4bc6dca8 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/Program.cs @@ -0,0 +1,98 @@ +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using System.Text; +using Azure.Sdk.Tools.SecretRotation.Cli.Commands; +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; + +namespace Azure.Sdk.Tools.SecretRotation.Cli; + +public class Program +{ + public static async Task Main(string[] args) + { + var rootCommand = new RootCommand("Secrets rotation tool"); + rootCommand.AddCommand(new StatusCommand()); + rootCommand.AddCommand(new RotateCommand()); + + CommandLineBuilder cliBuilder = new CommandLineBuilder(rootCommand) + .UseDefaults() + .UseExceptionHandler(HandleExceptions); + + return await cliBuilder.Build().InvokeAsync(args); + } + + private static void HandleExceptions(Exception exception, InvocationContext invocationContext) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(BuildErrorMessage(exception).TrimEnd()); + Console.ResetColor(); + } + + private static string BuildErrorMessage(Exception exception) + { + var builder = new StringBuilder(); + + if (exception is RotationConfigurationException) + { + builder.AppendLine("Configuration error:"); + AppendKnownExceptionMessage(builder, exception); + } + else if (exception is RotationException) + { + builder.AppendLine("Rotation error:"); + AppendKnownExceptionMessage(builder, exception); + } + else if (exception is RotationCliException) + { + builder.AppendLine("Command invocation error:"); + AppendKnownExceptionMessage(builder, exception); + } + else + { + AppendUnknownExceptionMessage(builder, exception); + } + + return builder.ToString(); + } + + private static void AppendKnownExceptionMessage(StringBuilder builder, Exception exception) + { + builder.Append(" "); + builder.AppendJoin("\n ", exception.Message.Split("\n")); + + if (exception.InnerException != null) + { + builder.Append("\n "); + builder.AppendJoin("\n ", exception.InnerException.Message.Split("\n")); + } + } + + private static void AppendUnknownExceptionMessage(StringBuilder builder, Exception exception, int indent = 0, + bool isInnerException = false) + { + string indentString = new string(' ', indent); + string prefix = isInnerException ? "----> " : ""; + string[] messageStrings = $"{prefix}{exception.GetType().Name}: {exception.Message}".Split('\n'); + + foreach (string messageString in messageStrings) + { + builder.Append(indentString); + builder.AppendLine(messageString); + } + + if (exception is AggregateException aggregateException) + { + foreach (Exception innerException in aggregateException.InnerExceptions) + { + AppendUnknownExceptionMessage(builder, innerException, indent + 2, true); + } + } + else if (exception.InnerException != null) + { + AppendUnknownExceptionMessage(builder, exception.InnerException, indent + 2, true); + } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/RotationCliException.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/RotationCliException.cs new file mode 100644 index 00000000000..18df51f2d31 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/RotationCliException.cs @@ -0,0 +1,8 @@ +namespace Azure.Sdk.Tools.SecretRotation.Cli; + +public class RotationCliException : Exception +{ + public RotationCliException(string message) : base(message) + { + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/SimplerConsoleFormatter.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/SimplerConsoleFormatter.cs new file mode 100644 index 00000000000..c2a6938a069 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Cli/SimplerConsoleFormatter.cs @@ -0,0 +1,175 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; +using Microsoft.Extensions.Options; + +namespace Azure.Sdk.Tools.SecretRotation.Cli; + +internal sealed class SimplerConsoleFormatter : ConsoleFormatter +{ + public const string FormatterName = "simpler"; + + private const string DefaultForegroundColor = "\x1B[39m\x1B[22m"; // reset to default foreground color + private const string DefaultBackgroundColor = "\x1B[49m"; // reset to the background color + + public SimplerConsoleFormatter(IOptions options) : base(FormatterName) + { + FormatterOptions = options.Value; + } + + internal ConsoleFormatterOptions FormatterOptions { get; set; } + + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, + TextWriter textWriter) + { + string? message = logEntry.Formatter(logEntry.State, logEntry.Exception); + + if (logEntry.Exception == null && message == null) + { + return; + } + + string? timestampFormat = FormatterOptions.TimestampFormat; + + if (timestampFormat != null) + { + DateTimeOffset currentDateTime = GetCurrentDateTime(); + string timestamp = currentDateTime.ToString(timestampFormat); + textWriter.Write(timestamp); + } + + LogLevel logLevel = logEntry.LogLevel; + (ConsoleColor Foreground, ConsoleColor Background)? logLevelColors = GetLogLevelConsoleColors(logLevel); + string? logLevelString = GetLogLevelString(logLevel); + + if (logLevelString != null) + { + WriteColoredMessage(textWriter, logLevelString, logLevelColors?.Background, logLevelColors?.Foreground); + } + + CreateDefaultLogMessage(textWriter, logEntry, message, scopeProvider); + } + + private static void CreateDefaultLogMessage(TextWriter textWriter, in LogEntry logEntry, + string message, IExternalScopeProvider? scopeProvider) + { + // Example: + // info: ConsoleApp.Program[10] + // Request received + // scope information + textWriter.Write(message.TrimEnd()); + + // Example: + // System.InvalidOperationException + // at Namespace.Class.Function() in File:line X + Exception? exception = logEntry.Exception; + if (exception != null) + { + // exception message + textWriter.Write(' '); + textWriter.Write(exception.ToString()); + } + + textWriter.Write(Environment.NewLine); + } + + + private DateTimeOffset GetCurrentDateTime() + { + return FormatterOptions.UseUtcTimestamp ? DateTimeOffset.UtcNow : DateTimeOffset.Now; + } + + private static string? GetLogLevelString(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => "trce: ", + LogLevel.Debug => "dbug: ", + LogLevel.Information => null, + LogLevel.Warning => "warn: ", + LogLevel.Error => "fail: ", + LogLevel.Critical => "crit: ", + _ => throw new ArgumentOutOfRangeException(nameof(logLevel)) + }; + } + + private static void WriteColoredMessage(TextWriter textWriter, string message, ConsoleColor? background, + ConsoleColor? foreground) + { + // Order: backgroundcolor, foregroundcolor, Message, reset foregroundcolor, reset backgroundcolor + if (background.HasValue) + { + textWriter.Write(GetBackgroundColorEscapeCode(background.Value)); + } + + if (foreground.HasValue) + { + textWriter.Write(GetForegroundColorEscapeCode(foreground.Value)); + } + + textWriter.Write(message); + if (foreground.HasValue) + { + textWriter.Write(DefaultForegroundColor); // reset to default foreground color + } + + if (background.HasValue) + { + textWriter.Write(DefaultBackgroundColor); // reset to the background color + } + } + + private static string GetForegroundColorEscapeCode(ConsoleColor color) + { + return color switch + { + ConsoleColor.Black => "\x1B[30m", + ConsoleColor.DarkRed => "\x1B[31m", + ConsoleColor.DarkGreen => "\x1B[32m", + ConsoleColor.DarkYellow => "\x1B[33m", + ConsoleColor.DarkBlue => "\x1B[34m", + ConsoleColor.DarkMagenta => "\x1B[35m", + ConsoleColor.DarkCyan => "\x1B[36m", + ConsoleColor.Gray => "\x1B[37m", + ConsoleColor.Red => "\x1B[1m\x1B[31m", + ConsoleColor.Green => "\x1B[1m\x1B[32m", + ConsoleColor.Yellow => "\x1B[1m\x1B[33m", + ConsoleColor.Blue => "\x1B[1m\x1B[34m", + ConsoleColor.Magenta => "\x1B[1m\x1B[35m", + ConsoleColor.Cyan => "\x1B[1m\x1B[36m", + ConsoleColor.White => "\x1B[1m\x1B[37m", + _ => DefaultForegroundColor // default foreground color + }; + } + + private static string GetBackgroundColorEscapeCode(ConsoleColor color) + { + return color switch + { + ConsoleColor.Black => "\x1B[40m", + ConsoleColor.DarkRed => "\x1B[41m", + ConsoleColor.DarkGreen => "\x1B[42m", + ConsoleColor.DarkYellow => "\x1B[43m", + ConsoleColor.DarkBlue => "\x1B[44m", + ConsoleColor.DarkMagenta => "\x1B[45m", + ConsoleColor.DarkCyan => "\x1B[46m", + ConsoleColor.Gray => "\x1B[47m", + _ => DefaultBackgroundColor // Use default background color + }; + } + + private static (ConsoleColor Foreground, ConsoleColor Background)? GetLogLevelConsoleColors(LogLevel logLevel) + { + // We must explicitly set the background color if we are setting the foreground color, + // since just setting one can look bad on the users console. + return logLevel switch + { + LogLevel.Trace => (ConsoleColor.Blue, ConsoleColor.Black), + LogLevel.Debug => (ConsoleColor.Blue, ConsoleColor.Black), + LogLevel.Information => (ConsoleColor.DarkGreen, ConsoleColor.Black), + LogLevel.Warning => (ConsoleColor.Yellow, ConsoleColor.Black), + LogLevel.Error => (ConsoleColor.Black, ConsoleColor.DarkRed), + LogLevel.Critical => (ConsoleColor.White, ConsoleColor.DarkRed), + _ => null + }; + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/Azure.Sdk.Tools.SecretRotation.Configuration.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/Azure.Sdk.Tools.SecretRotation.Configuration.csproj new file mode 100644 index 00000000000..35f818ed7b5 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/Azure.Sdk.Tools.SecretRotation.Configuration.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/PlanConfiguration.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/PlanConfiguration.cs new file mode 100644 index 00000000000..ba7eb9b3e2e --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/PlanConfiguration.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Azure.Sdk.Tools.SecretRotation.Configuration; + +public class PlanConfiguration +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("rotationThreshold")] + public TimeSpan? RotationThreshold { get; set; } + + [JsonPropertyName("rotationPeriod")] + public TimeSpan? RotationPeriod { get; set; } + + [JsonPropertyName("revokeAfterPeriod")] + public TimeSpan? RevokeAfterPeriod { get; set; } + + [JsonPropertyName("stores")] + public StoreConfiguration[] StoreConfigurations { get; set; } = Array.Empty(); + + public static PlanConfiguration FromFile(string path) + { + string fileContents = GetFileContents(path); + + PlanConfiguration configuration = ParseConfiguration(path, fileContents); + + if (string.IsNullOrEmpty(configuration.Name)) + { + configuration.Name = Path.GetFileNameWithoutExtension(path); + } + + return configuration; + } + + private static string GetFileContents(string path) + { + try + { + var file = new FileInfo(path); + return File.ReadAllText(file.FullName); + } + catch (Exception ex) + { + throw new RotationConfigurationException($"Error reading configuration file '{path}'.", ex); + } + } + + private static PlanConfiguration ParseConfiguration(string filePath, string fileContents) + { + try + { + PlanConfiguration planConfiguration = JsonSerializer.Deserialize(fileContents) + ?? throw new RotationConfigurationException($"Error reading configuration file '{filePath}'. Configuration deserialized to null."); + + return planConfiguration; + } + catch (JsonException ex) + { + throw new RotationConfigurationException($"Error deserializing json from file '{filePath}'.", ex); + } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfiguration.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfiguration.cs new file mode 100644 index 00000000000..c2845899a59 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfiguration.cs @@ -0,0 +1,282 @@ +using System.Collections.ObjectModel; +using Azure.Sdk.Tools.SecretRotation.Core; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Configuration; + +public class RotationConfiguration +{ + private readonly IDictionary> storeFactories; + + private RotationConfiguration( + IDictionary> storeFactories, + IEnumerable planConfigurations) + { + this.storeFactories = storeFactories; + PlanConfigurations = new ReadOnlyCollection(planConfigurations.ToArray()); + } + + public ReadOnlyCollection PlanConfigurations { get; } + + public static RotationConfiguration From(string path, + IDictionary> storeFactories) + { + List planConfigurations = new(); + + if (Directory.Exists(path)) + { + planConfigurations.AddRange(Directory.EnumerateFiles(path, "*.json", SearchOption.TopDirectoryOnly) + .Select(PlanConfiguration.FromFile)); + } + else + { + planConfigurations.Add(PlanConfiguration.FromFile(path)); + } + + var configuration = new RotationConfiguration(storeFactories, planConfigurations); + + return configuration; + } + + public RotationPlan? GetRotationPlan(string name, ILogger logger, TimeProvider timeProvider) + { + PlanConfiguration? planConfiguration = + PlanConfigurations.FirstOrDefault(configuration => configuration.Name == name); + + return planConfiguration != null + ? ResolveRotationPlan(planConfiguration, logger, timeProvider) + : null; + } + + public IEnumerable GetRotationPlans(ILogger logger, IEnumerable secretNames, + TimeProvider timeProvider) + { + var namedPlans = secretNames + .Select(secretName => new + { + SecretName = secretName, + RotationPlan = GetRotationPlan(secretName, logger, timeProvider), + }) + .ToArray(); + + string[] invalidNames = namedPlans + .Where(x => x.RotationPlan == null) + .Select(x => x.SecretName) + .ToArray(); + + if (invalidNames.Any()) + { + throw new RotationConfigurationException($"Unknown rotation plan names: '{string.Join("', '", invalidNames)}'"); + } + + return namedPlans + .Select(x => x.RotationPlan!) + .ToArray(); + } + + public IEnumerable GetAllRotationPlans(ILogger logger, TimeProvider timeProvider) + { + return PlanConfigurations.Select(planConfiguration => + ResolveRotationPlan(planConfiguration, logger, timeProvider)); + } + + private RotationPlan ResolveRotationPlan(PlanConfiguration planConfiguration, ILogger logger, + TimeProvider timeProvider) + { + string? name = planConfiguration.Name; + + if (string.IsNullOrEmpty(name)) + { + throw new RotationConfigurationException("Error processing plan configuration. Name is null or empty."); + } + + var validationErrors = new List(); + SecretStore? origin = GetOriginStore(planConfiguration, validationErrors); + SecretStore? primary = GetPrimaryStore(planConfiguration, origin, validationErrors); + IList secondaries = GetSecondaryStores(planConfiguration, validationErrors); + + string errorPrefix = $"Error processing plan configuration '{name}'."; + + if (origin == null) + { + validationErrors.Add($"{errorPrefix} Unable to resolve origin store."); + } + + if (primary == null) + { + validationErrors.Add($"{errorPrefix} Unable to resolve primary store."); + } + + if (planConfiguration.RotationPeriod == null) + { + validationErrors.Add($"{errorPrefix} Property 'rotationPeriod' cannot be null"); + } + + if (planConfiguration.RotationThreshold == null) + { + validationErrors.Add($"{errorPrefix} Property 'rotationThreshold' cannot be null"); + } + + if (validationErrors.Any()) + { + throw new RotationConfigurationException(string.Join('\n', validationErrors)); + } + + var plan = new RotationPlan( + logger, + timeProvider, + name, + origin!, + primary!, + secondaries, + planConfiguration.RotationThreshold!.Value, + planConfiguration.RotationPeriod!.Value, + planConfiguration.RevokeAfterPeriod); + + return plan; + } + + private IList GetSecondaryStores(PlanConfiguration planConfiguration, List validationErrors) + { + var secondaryStores = new List(); + + (StoreConfiguration Configuration, int Index)[] secondaryStoreConfigurations = planConfiguration + .StoreConfigurations + .Select((configuration, index) => (Configuration: configuration, Index: index)) + .Where(x => !x.Configuration.IsPrimary && !x.Configuration.IsOrigin) + .ToArray(); + + foreach ((StoreConfiguration storeConfiguration, int index) in secondaryStoreConfigurations) + { + string configurationKey = $"{planConfiguration.Name}.stores[{index}]"; + SecretStore store = ResolveStore(configurationKey, storeConfiguration); + + if (!store.CanWrite) + { + AddCapabilityError(validationErrors, store, nameof(store.CanWrite), "Secondary"); + } + + secondaryStores.Add(store); + } + + return secondaryStores; + } + + private SecretStore? GetOriginStore(PlanConfiguration planConfiguration, List validationErrors) + { + (StoreConfiguration Configuration, int Index)[] originStoreConfigurations = planConfiguration + .StoreConfigurations + .Select((configuration, index) => (Configuration: configuration, Index: index)) + .Where(x => x.Configuration.IsOrigin) + .ToArray(); + + if (originStoreConfigurations.Length != 1) + { + validationErrors.Add($"Error processing plan configuration '{planConfiguration.Name}'. " + + $"Exactly 1 store should be marked IsOrigin"); + return null; + } + + (StoreConfiguration Configuration, int Index) storeConfigurationAndIndex = originStoreConfigurations[0]; + string configurationKey = $"{planConfiguration.Name}.stores[{storeConfigurationAndIndex.Index}]"; + + SecretStore store = ResolveStore(configurationKey, storeConfigurationAndIndex.Configuration); + + if (!store.CanOriginate) + { + AddCapabilityError(validationErrors, store, nameof(store.CanOriginate), "Origin"); + } + + return store; + } + + private SecretStore? GetPrimaryStore(PlanConfiguration planConfiguration, SecretStore? originStore, + List validationErrors) + { + (StoreConfiguration Configuration, int Index)[] primaryStoreConfigurations = planConfiguration + .StoreConfigurations + .Select((configuration, index) => (Configuration: configuration, Index: index)) + .Where(x => x.Configuration.IsPrimary) + .ToArray(); + + if (primaryStoreConfigurations.Length != 1) + { + validationErrors.Add($"Error processing plan configuration '{planConfiguration.Name}'. " + + $"Exactly 1 store should be marked IsPrimary"); + return null; + } + + StoreConfiguration storeConfiguration = primaryStoreConfigurations[0].Configuration; + int index = primaryStoreConfigurations[0].Index; + string configurationKey = $"{planConfiguration.Name}.stores[{index}]"; + + SecretStore store = storeConfiguration.IsOrigin && originStore != null + ? originStore + : ResolveStore(configurationKey, storeConfiguration); + + if (!store.CanRead) + { + AddCapabilityError(validationErrors, store, nameof(store.CanRead), "Primary"); + } + + if (storeConfiguration.IsOrigin) + { + // An origin primary must support post-rotation annotation + if (!store.CanAnnotate) + { + AddCapabilityError(validationErrors, store, nameof(store.CanAnnotate), "Primary + Origin"); + } + } + else + { + // A non origin primary has to support Write because it doesn't originate values + if (!store.CanWrite) + { + AddCapabilityError(validationErrors, store, nameof(store.CanWrite), "Primary"); + } + } + + return store; + } + + private void AddCapabilityError(List validationErrors, SecretStore store, string capabilityName, + string storeUsage) + { + string typeName = store.GetType().Name; + string errorMessage = $"Error processing store configuration for store named '{store.Name}'. " + + $"Store type '{typeName}' cannot be used as {storeUsage}. " + + $"{capabilityName} returned false"; + validationErrors.Add(errorMessage); + } + + private SecretStore ResolveStore(string configurationKey, StoreConfiguration storeConfiguration) + { + string storeName = storeConfiguration.ResolveStoreName(configurationKey); + + if (storeConfiguration.Type == null) + { + throw new RotationConfigurationException( + $"Error processing store configuration for store named '{storeName}'. Type cannot be null."); + } + + if (!this.storeFactories.TryGetValue(storeConfiguration.Type, + out Func? factory)) + { + throw new RotationConfigurationException($"Error processing store configuration for store named '{storeName}'. " + + $"Store type '{storeConfiguration.Type}' not registered"); + } + + try + { + SecretStore store = factory.Invoke(storeConfiguration); + + store.Name = storeName; + + return store; + } + catch (Exception ex) + { + throw new RotationConfigurationException($"Error processing store configuration for store named '{storeName}'.", ex); + } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfigurationException.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfigurationException.cs new file mode 100644 index 00000000000..64a57e9ac0f --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/RotationConfigurationException.cs @@ -0,0 +1,9 @@ +namespace Azure.Sdk.Tools.SecretRotation.Configuration; + +public class RotationConfigurationException : Exception +{ + public RotationConfigurationException(string message, Exception? innerException = default) : base(message, + innerException) + { + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/StoreConfiguration.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/StoreConfiguration.cs new file mode 100644 index 00000000000..a93ef1d0134 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/StoreConfiguration.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Azure.Sdk.Tools.SecretRotation.Configuration; + +public class StoreConfiguration +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("parameters")] + public JsonObject? Parameters { get; set; } + + [JsonPropertyName("isOrigin")] + public bool IsOrigin { get; set; } + + [JsonPropertyName("isPrimary")] + public bool IsPrimary { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + public string ResolveStoreName(string configurationKey) + { + return Name ?? $"{configurationKey} ({Type})"; + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/secretrotation.schema.json b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/secretrotation.schema.json new file mode 100644 index 00000000000..4689d18a052 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Configuration/secretrotation.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/product.schema.json", + "title": "PlanConfiguration", + "description": "A secret rotation plan", + "type": "object", + "properties": { + "name": { + "description": "The rotation plan name", + "type": "string" + }, + "rotationPeriod": { + "description": "Time span indicating when the secret should expire after rotation", + "type": "string" + }, + "rotationThreshold": { + "description": "Time span indicating when the secret should be rotated before it expires", + "type": "string" + }, + "revokeAfterPeriod": { + "description": "Time span indicating when the old secret values should be revoked after rotation", + "type": "string" + }, + "stores": { + "description": "The stores participating in the rotation plan", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "The store name", + "type": "string" + }, + "type": { + "description": "The store type", + "type": "string" + }, + "isOrigin": { + "description": "Is this store the origin of the secret?", + "type": "boolean" + }, + "isPrimary": { + "description": "Is this store the primary store for the secret metadata?", + "type": "boolean" + }, + "parameters": { + "description": "Store specific parameters", + "type": "object", + "propertyValues": { "type": "string" }, + "additionalProperties": true + } + } + }, + "minItems": 1 + } + }, + "required": [ "rotationThreshold", "rotationPeriod", "stores" ], + "unevaluatedProperties": false +} \ No newline at end of file diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/Azure.Sdk.Tools.SecretRotation.Core.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/Azure.Sdk.Tools.SecretRotation.Core.csproj new file mode 100644 index 00000000000..b55d9fa2b25 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/Azure.Sdk.Tools.SecretRotation.Core.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationException.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationException.cs new file mode 100644 index 00000000000..001ae8cffed --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationException.cs @@ -0,0 +1,8 @@ +namespace Azure.Sdk.Tools.SecretRotation.Core; + +public class RotationException : Exception +{ + public RotationException(string message, Exception? innerException = default) : base(message, innerException) + { + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlan.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlan.cs new file mode 100644 index 00000000000..73e9e79e8bd --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlan.cs @@ -0,0 +1,233 @@ +using System.Collections.ObjectModel; +using Azure.Sdk.Tools.SecretRotation.Core; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Core; + +public class RotationPlan +{ + private readonly ILogger logger; + private readonly TimeProvider timeProvider; + + public RotationPlan(ILogger logger, + TimeProvider timeProvider, + string name, + SecretStore originStore, + SecretStore primaryStore, + IList secondaryStores, + TimeSpan rotationThreshold, + TimeSpan rotationPeriod, + TimeSpan? revokeAfterPeriod) + { + this.logger = logger; + this.timeProvider = timeProvider; + Name = name; + OriginStore = originStore; + PrimaryStore = primaryStore; + RotationThreshold = rotationThreshold; + RotationPeriod = rotationPeriod; + RevokeAfterPeriod = revokeAfterPeriod; + SecondaryStores = new ReadOnlyCollection(secondaryStores); + } + + public string Name { get; } + + public SecretStore OriginStore { get; } + + public SecretStore PrimaryStore { get; } + + public IReadOnlyCollection SecondaryStores { get; } + + public TimeSpan RotationThreshold { get; } + + public TimeSpan RotationPeriod { get; } + + public TimeSpan? RevokeAfterPeriod { get; } + + public async Task ExecuteAsync(bool onlyRotateExpiring, bool whatIf) + { + string operationId = Guid.NewGuid().ToString(); + using IDisposable? loggingScope = this.logger.BeginScope(operationId); + this.logger.LogInformation("\nProcessing rotation plan '{PlanName}'", Name); + + DateTimeOffset shouldRotateDate = this.timeProvider.GetCurrentDateTimeOffset().Add(RotationThreshold); + + this.logger.LogInformation("Getting current state of plan"); + + if (onlyRotateExpiring) + { + this.logger.LogInformation("'{PlanName}' should be rotated if it expires on or before {ShouldRotateDate}", + Name, shouldRotateDate); + } + + SecretState currentState = await PrimaryStore.GetCurrentStateAsync(); + + // TODO: Add secondary store state checks (detect drift) + + // any rotationPlan with an expiration date falling before now + threshold is "expiring" + this.logger.LogInformation("'{PlanName}' expires on {ExpirationDate}.", Name, currentState.ExpirationDate); + if (onlyRotateExpiring && currentState.ExpirationDate > shouldRotateDate) + { + this.logger.LogInformation( + "Skipping rotation of plan '{PlanName}' because it expires after {ShouldRotateDate}", Name, + shouldRotateDate); + } + else + { + await RotateAsync(operationId, currentState, whatIf); + } + + await RevokeRotationArtifactsAsync(whatIf); + } + + public async Task GetStatusAsync() + { + DateTimeOffset invocationTime = this.timeProvider.GetCurrentDateTimeOffset(); + + SecretState primaryStoreState = await PrimaryStore.GetCurrentStateAsync(); + + IEnumerable rotationArtifacts = await PrimaryStore.GetRotationArtifactsAsync(); + + var secondaryStoreStates = new List(); + + foreach (SecretStore secondaryStore in SecondaryStores) + { + if (secondaryStore.CanRead) + { + secondaryStoreStates.Add(await secondaryStore.GetCurrentStateAsync()); + } + } + + SecretState[] allStates = secondaryStoreStates.Prepend(primaryStoreState).ToArray(); + + bool anyExpired = allStates.Any(state => state.ExpirationDate <= invocationTime); + + DateTimeOffset thresholdDate = this.timeProvider.GetCurrentDateTimeOffset().Add(RotationThreshold); + + bool anyThresholdExpired = allStates.Any(state => state.ExpirationDate <= thresholdDate); + + bool anyRequireRevocation = rotationArtifacts.Any(state => state.RevokeAfterDate <= invocationTime); + + var status = new RotationPlanStatus + { + Expired = anyExpired, + ThresholdExpired = anyThresholdExpired, + RequiresRevocation = anyRequireRevocation, + PrimaryStoreState = primaryStoreState, + SecondaryStoreStates = secondaryStoreStates.ToArray() + }; + + return status; + } + + private async Task RotateAsync(string operationId, SecretState currentState, bool whatIf) + { + /* + * General flow: + * Get a new secret value from origin + * Store the new value in all secondaries + * If origin != primary, store the secret in primary. Update of primary indicates completed rotation. + * If origin == primary, annotate origin to indicate complete. Annotation of origin indicates completed rotation. + * A user can combine origin and primary only when origin can be marked as complete in some way (e.g. Key Vault Certificates can be tagged) + */ + + DateTimeOffset invocationTime = this.timeProvider.GetCurrentDateTimeOffset(); + + SecretValue newValue = await OriginateNewValueAsync(operationId, invocationTime, currentState, whatIf); + + await WriteValueToSecondaryStoresAsync(newValue, currentState, whatIf); + + await WriteValueToPrimaryAndOriginAsync(newValue, invocationTime, currentState, whatIf); + } + + private async Task WriteValueToPrimaryAndOriginAsync(SecretValue newValue, + DateTimeOffset invocationTime, + SecretState currentState, + 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) + { + // 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); + } + 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); + } + } + + private async Task WriteValueToSecondaryStoresAsync(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) + { + // secondaries don't store revocation dates. + await secondaryStore.WriteSecretAsync(newValue, currentState, null, whatIf); + } + } + + private async Task OriginateNewValueAsync(string operationId, + DateTimeOffset invocationTime, + SecretState currentState, + bool whatIf) + { + DateTimeOffset newExpirationDate = invocationTime.Add(RotationPeriod); + + SecretValue newValue = await OriginStore.OriginateValueAsync(currentState, newExpirationDate, whatIf); + newValue.ExpirationDate ??= newExpirationDate; + newValue.OperationId = operationId; + return newValue; + } + + private async Task RevokeRotationArtifactsAsync(bool whatIf) + { + DateTimeOffset invocationTime = this.timeProvider.GetCurrentDateTimeOffset(); + + IEnumerable rotationArtifacts = await PrimaryStore.GetRotationArtifactsAsync(); + + SecretStore[] storesSupportingRevocation = SecondaryStores + .Append(OriginStore) + .Append(PrimaryStore) + .Where(store => store.CanRevoke) + .Distinct() // Origin and Primary may be the same store + .ToArray(); + + foreach (SecretState rotationArtifact in rotationArtifacts.Where( + state => state.RevokeAfterDate < invocationTime)) + { + this.logger.LogInformation( + "Revoking secret operation id '{OperationId}' and revoke after date {RevokeAfterDate}", + rotationArtifact.OperationId, rotationArtifact.RevokeAfterDate); + + foreach (SecretStore stateStore in storesSupportingRevocation) + { + Func? revocationAction = stateStore.GetRevocationActionAsync(rotationArtifact, whatIf); + + if (revocationAction != null) + { + this.logger.LogInformation("Processing revocation action for store '{StateStore}'", stateStore.Name); + await revocationAction(); + } + } + } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlanStatus.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlanStatus.cs new file mode 100644 index 00000000000..446509d4414 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/RotationPlanStatus.cs @@ -0,0 +1,16 @@ +using Azure.Sdk.Tools.SecretRotation.Core; + +namespace Azure.Sdk.Tools.SecretRotation.Core; + +public class RotationPlanStatus +{ + public bool Expired { get; set; } + + public bool ThresholdExpired { get; set; } + + public bool RequiresRevocation { get; set; } + + public SecretState? PrimaryStoreState { get; set; } + + public IReadOnlyList? SecondaryStoreStates { get; set; } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretState.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretState.cs new file mode 100644 index 00000000000..4489e1386ef --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretState.cs @@ -0,0 +1,20 @@ +namespace Azure.Sdk.Tools.SecretRotation.Core; + +public class SecretState +{ + public string? Id { get; set; } + + public string? OperationId { get; set; } + + public DateTimeOffset? ExpirationDate { get; set; } + + public DateTimeOffset? RevokeAfterDate { get; set; } + + public int? StatusCode { get; set; } + + public string? ErrorMessage { get; set; } + + public IDictionary Tags { get; init; } = new Dictionary(); + + public string? Value { get; set; } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretStore.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretStore.cs new file mode 100644 index 00000000000..11c1b1f4bf3 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretStore.cs @@ -0,0 +1,58 @@ +namespace Azure.Sdk.Tools.SecretRotation.Core; + +public abstract class SecretStore +{ + public virtual bool CanRead => false; + + public virtual bool CanOriginate => false; + + public virtual bool CanAnnotate => false; + + public virtual bool CanWrite => false; + + public virtual bool CanRevoke => false; + + public string? Name { get; set; } + + // Read + + public virtual Task GetCurrentStateAsync() + { + throw new NotImplementedException(); + } + + public virtual Task> GetRotationArtifactsAsync() + { + throw new NotImplementedException(); + } + + // Annotate + + public virtual Task MarkRotationCompleteAsync(SecretValue secretValue, DateTimeOffset? revokeAfterDate, bool whatIf) + { + throw new NotImplementedException(); + } + + // Revocation + + public virtual Func? GetRevocationActionAsync(SecretState secretState, bool whatIf) + { + throw new NotImplementedException(); + } + + // Originate + + public virtual Task OriginateValueAsync(SecretState currentState, DateTimeOffset expirationDate, + bool whatIf) + { + throw new NotImplementedException(); + } + + // Write + + public virtual Task WriteSecretAsync(SecretValue secretValue, SecretState currentState, + DateTimeOffset? revokeAfterDate, bool whatIf) + { + throw new NotImplementedException(); + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretValue.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretValue.cs new file mode 100644 index 00000000000..f3ebaa15d30 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/SecretValue.cs @@ -0,0 +1,24 @@ +namespace Azure.Sdk.Tools.SecretRotation.Core; + +public class SecretValue +{ + public string? OperationId { get; set; } + + public DateTimeOffset? ExpirationDate { get; set; } + + // TODO: Use SecureString if possible + public string Value { get; set; } = string.Empty; + + /// + /// A state object created by origin 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. + /// + public object? OriginState { 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. + public Dictionary Tags { get; init; } = new(); +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/TimeProvider.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/TimeProvider.cs new file mode 100644 index 00000000000..b81d57a4581 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Core/TimeProvider.cs @@ -0,0 +1,9 @@ +namespace Azure.Sdk.Tools.SecretRotation.Core; + +public class TimeProvider +{ + public virtual DateTimeOffset GetCurrentDateTimeOffset() + { + return DateTimeOffset.UtcNow; + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory/AadApplicationSecretStore.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory/AadApplicationSecretStore.cs new file mode 100644 index 00000000000..467f7f89313 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory/AadApplicationSecretStore.cs @@ -0,0 +1,232 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Core; +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Graph; + +namespace Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory; + +public class AadApplicationSecretStore : SecretStore +{ + public enum RevocationAction + { + None = 0, + Delete = 1 + } + + public const string MappingKey = "AAD Application Secret"; + + private readonly string applicationId; + private readonly TokenCredential credential; + private readonly string displayName; + private readonly ILogger logger; + private readonly RevocationAction revocationAction; + + public AadApplicationSecretStore(string applicationId, string displayName, RevocationAction revocationAction, + TokenCredential credential, ILogger logger) + { + this.applicationId = applicationId; + this.displayName = displayName; + this.revocationAction = revocationAction; + this.credential = credential; + this.logger = logger; + } + + public override bool CanOriginate => true; + + public override bool CanRevoke => true; + + public static Func GetSecretStoreFactory(TokenCredential credential, + ILogger logger) + { + return configuration => + { + var parameters = configuration.Parameters?.Deserialize(); + + if (string.IsNullOrEmpty(parameters?.ApplicationId)) + { + throw new Exception("Missing required parameter 'applicationId'"); + } + + if (string.IsNullOrEmpty(parameters?.DisplayName)) + { + throw new Exception("Missing required parameter 'displayName'"); + } + + return new AadApplicationSecretStore(parameters.ApplicationId, parameters.DisplayName, + parameters.RevocationAction, credential, logger); + }; + } + + public override async Task OriginateValueAsync(SecretState currentState, DateTimeOffset expirationDate, + bool whatIf) + { + GraphServiceClient graphClient = GetGraphServiceClient(); + + this.logger.LogInformation("Getting details for application '{ApplicationId}' from graph api.", + this.applicationId); + + IGraphServiceApplicationsCollectionPage? applications = + await graphClient.Applications.Request().Filter($"appId eq '{this.applicationId}'").GetAsync(); + + Application application = applications.FirstOrDefault() + ?? throw new RotationException($"Unable to locate AAD application with id '{this.applicationId}'"); + + this.logger.LogInformation("Found AAD application with id '{ApplicationId}', object id '{ObjectId}'", + this.applicationId, + application.Id); + + var credential = new PasswordCredential + { + DisplayName = this.displayName, StartDateTime = DateTimeOffset.UtcNow, EndDateTime = expirationDate + }; + + if (whatIf) + { + this.logger.LogInformation( + "WHAT IF: Post 'add password' request to graph api for application object id '{ObjectId}'", + application.Id); + + return new SecretValue { Value = string.Empty, ExpirationDate = expirationDate }; + } + + this.logger.LogInformation("Posting new password request to graph api for application object id '{ObjectId}'", + application.Id); + + PasswordCredential? newSecret = + await graphClient.Applications[application.Id].AddPassword(credential).Request().PostAsync(); + + this.logger.LogInformation("Graph api responded with key id '{KeyId}'", newSecret.KeyId); + + string keyId = newSecret.KeyId.ToString()!; + + return new SecretValue + { + Value = newSecret.SecretText, + ExpirationDate = newSecret.EndDateTime, + Tags = { ["AadApplicationId"] = this.applicationId, ["AadSecretId"] = keyId } + }; + } + + public override Func? GetRevocationActionAsync(SecretState secretState, bool whatIf) + { + if (!secretState.Tags.TryGetValue("AadSecretId", out string? keyIdString) || + this.revocationAction != RevocationAction.Delete) + { + return null; + } + + if (!Guid.TryParse(keyIdString, out Guid aadKeyId)) + { + this.logger.LogWarning("Unable to parse OperationId as a Guid: '{OperationId}'", secretState.OperationId); + return null; + } + + return async () => + { + GraphServiceClient graphClient = GetGraphServiceClient(); + + Application application = await GetApplicationAsync(graphClient) + ?? throw new RotationException($"Unable to locate AAD application with id '{this.applicationId}'"); + + // use the application's object id and the keyId to revoke the old password + if (whatIf) + { + this.logger.LogInformation( + "WHAT IF: Post 'remove password' request to graph api for application object id '{ObjectId}' and password id '{PasswordId}'", + application.Id, + aadKeyId); + } + else + { + try + { + await graphClient.Applications[application.Id].RemovePassword(aadKeyId).Request().PostAsync(); + } + catch (ServiceException ex) when + (ex.Error.Message.StartsWith("No password credential found with keyId")) + { + // ignore "not found" exception on delete + } + } + }; + } + + private async Task GetApplicationAsync(GraphServiceClient graphClient) + { + this.logger.LogInformation("Getting details for application '{ApplicationId}' from graph api.", + this.applicationId); + IGraphServiceApplicationsCollectionPage? applications = + await graphClient.Applications.Request().Filter($"appId eq '{this.applicationId}'").GetAsync(); + Application? application = applications.FirstOrDefault(); + return application; + } + + private GraphServiceClient GetGraphServiceClient() + { + string[] scopes = { "https://graph.microsoft.com/.default" }; + + GraphServiceClient graphClient = new(this.credential, scopes, new LoggingHttpProvider(this.logger)); + return graphClient; + } + + private class Parameters + { + [JsonPropertyName("applicationId")] + public string? ApplicationId { get; set; } + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("revocationAction")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public RevocationAction RevocationAction { get; set; } + } + + private class LoggingHttpProvider : IHttpProvider + { + private readonly HttpProvider internalProvider; + private readonly ILogger logger; + + public LoggingHttpProvider(ILogger logger) + { + this.logger = logger; + this.internalProvider = new HttpProvider(); + } + + public void Dispose() + { + this.internalProvider.Dispose(); + } + + public async Task SendAsync(HttpRequestMessage request) + { + this.logger.LogDebug("Sending graph request: {Method} {Url}", request.Method, request.RequestUri); + HttpResponseMessage? response = await this.internalProvider.SendAsync(request); + this.logger.LogDebug("Graph response of {StatusCode} received for {Method} {Url}", + (int)response.StatusCode, request.Method, request.RequestUri); + return response; + } + + public async Task SendAsync(HttpRequestMessage request, + HttpCompletionOption completionOption, CancellationToken cancellationToken) + { + this.logger.LogDebug("Sending graph request: {Method} {Url}", request.Method, request.RequestUri); + HttpResponseMessage? response = + await this.internalProvider.SendAsync(request, completionOption, cancellationToken); + this.logger.LogDebug("Graph response of {StatusCode} received for {Method} {Url}", + (int)response.StatusCode, request.Method, request.RequestUri); + return response; + } + + public ISerializer Serializer => this.internalProvider.Serializer; + + public TimeSpan OverallTimeout + { + get => this.internalProvider.OverallTimeout; + set => this.internalProvider.OverallTimeout = value; + } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory/Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory/Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory.csproj new file mode 100644 index 00000000000..a143fb620c6 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory/Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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 new file mode 100644 index 00000000000..e0e08ad6a03 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceConnectionParameterStore.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceConnectionParameterStore.cs new file mode 100644 index 00000000000..da472c6666f --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps/ServiceConnectionParameterStore.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Core; +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 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) + { + 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, + ILogger logger) + { + return configuration => + { + var parameters = configuration.Parameters?.Deserialize(); + + if (string.IsNullOrEmpty(parameters?.AccountName)) + { + throw new Exception("Missing required parameter 'accountName'"); + } + + if (string.IsNullOrEmpty(parameters?.ProjectName)) + { + throw new Exception("Missing required parameter 'projectName'"); + } + + if (string.IsNullOrEmpty(parameters?.ConnectionId)) + { + throw new Exception("Missing required parameter 'connectionId'"); + } + + if (string.IsNullOrEmpty(parameters?.ParameterName)) + { + throw new Exception("Missing required parameter 'parameterName'"); + } + + return new ServiceConnectionParameterStore( + parameters.AccountName, + parameters.ProjectName, + parameters.ConnectionId, + parameters.ParameterName, + credential, + logger); + }; + } + + 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); + VssConnection connection = await GetConnectionAsync(); + var client = await connection.GetClientAsync(); + + Guid endpointId = Guid.Parse(this.connectionId); + + this.logger.LogDebug("Getting service endpoint details for endpoint '{EndpointId}'", endpointId); + ServiceEndpoint? endpointDetails = await client.GetServiceEndpointDetailsAsync(this.projectName, endpointId); + + endpointDetails.Authorization.Parameters[this.parameterName] = secretValue.Value; + + if (!whatIf) + { + this.logger.LogInformation( + "Updating parameter '{ParameterName}' on service connection '{ConnectionId}'", + this.parameterName, + this.connectionId); + await client.UpdateServiceEndpointAsync(endpointId, endpointDetails); + } + else + { + this.logger.LogInformation( + "WHAT IF: Update parameter '{ParameterName}' on service connection '{ConnectionId}'", this.parameterName, + this.connectionId); + } + } + + 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")] + public string? AccountName { get; set; } + + [JsonPropertyName("projectName")] + public string? ProjectName { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } + + [JsonPropertyName("parameterName")] + public string? ParameterName { get; set; } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/Azure.Sdk.Tools.SecretRotation.Stores.Generic.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/Azure.Sdk.Tools.SecretRotation.Stores.Generic.csproj new file mode 100644 index 00000000000..8c649da796a --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/Azure.Sdk.Tools.SecretRotation.Stores.Generic.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/IUserValueProvider.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/IUserValueProvider.cs new file mode 100644 index 00000000000..a48c61b7532 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/IUserValueProvider.cs @@ -0,0 +1,8 @@ +namespace Azure.Sdk.Tools.SecretRotation.Stores.Generic; + +public interface IUserValueProvider +{ + string? GetValue(string prompt, bool secret = false); + + void PromptUser(string prompt, string? oldValue = default, string? newValue = default); +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/ManualActionStore.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/ManualActionStore.cs new file mode 100644 index 00000000000..5c499958ecc --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/ManualActionStore.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Stores.Generic; + +public class ManualActionStore : SecretStore +{ + public const string MappingKey = "Manual Action"; + private readonly ILogger logger; + private readonly IUserValueProvider valueProvider; + private readonly string prompt; + + public ManualActionStore(ILogger logger, IUserValueProvider valueProvider, string prompt) + { + this.logger = logger; + this.valueProvider = valueProvider; + this.prompt = prompt; + } + + public override bool CanOriginate => true; + + public override bool CanWrite => true; + + public static Func GetSecretStoreFactory(ILogger logger, IUserValueProvider valueProvider) + { + return configuration => + { + var parameters = configuration.Parameters?.Deserialize(); + + if (parameters?.Prompt == null) + { + throw new RotationConfigurationException("Missing required parameter 'prompt'"); + } + + return new ManualActionStore(logger, valueProvider, parameters.Prompt); + }; + } + + public override Task OriginateValueAsync(SecretState currentState, + DateTimeOffset expirationDate, + bool whatIf) + { + string filledPrompt = FillPromptTokens(expirationDate); + + this.valueProvider.PromptUser(filledPrompt, oldValue: currentState.Value); + + string newValue = GetNewValueFromUser(); + + DateTimeOffset newExpirationDate = GetExpirationDateFromUser(); + + return Task.FromResult(new SecretValue { ExpirationDate = newExpirationDate, Value = newValue }); + } + + public override Task WriteSecretAsync(SecretValue secretValue, + SecretState currentState, + DateTimeOffset? revokeAfterDate, + bool whatIf) + { + string filledPrompt = FillPromptTokens(secretValue.ExpirationDate); + + this.valueProvider.PromptUser(filledPrompt, oldValue: currentState.Value, newValue: secretValue.Value); + + return Task.CompletedTask; + } + + private string FillPromptTokens(DateTimeOffset? expirationDate) + { + string targetDate = expirationDate.HasValue ? expirationDate.Value.ToString("o") : ""; + + return this.prompt.Replace("{{TargetDate}}", targetDate); + } + + private string GetNewValueFromUser() + { + while (true) + { + string? newValue = this.valueProvider.GetValue("Secret Value", secret: true); + + if (string.IsNullOrEmpty(newValue)) + { + this.logger.LogInformation("The value cannot be a null or empty string."); + } + else + { + return newValue; + } + } + } + + private DateTimeOffset GetExpirationDateFromUser() + { + while (true) + { + string? newDateString = this.valueProvider.GetValue("Expiration Date"); + + if (DateTimeOffset.TryParse(newDateString, out var parsed)) + { + return parsed; + } + + this.logger.LogInformation("Unable to parse date string."); + } + } + + private class Parameters + { + [JsonPropertyName("prompt")] + public string? Prompt { get; set; } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/RandomStringGenerator.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/RandomStringGenerator.cs new file mode 100644 index 00000000000..fe2f38aca7d --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.Generic/RandomStringGenerator.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Stores.Generic; + +public class RandomStringGenerator : SecretStore +{ + public const string MappingKey = "Random String"; + private readonly List characterClasses; + private readonly int length; + private readonly ILogger logger; + + public RandomStringGenerator(int length, bool useLowercase, bool useUpperCase, bool useNumbers, bool useSpecial, + ILogger logger) + { + this.length = length; + this.logger = logger; + + this.characterClasses = new List(); + + if (useLowercase) + { + this.characterClasses.Add("abcdefghijklmnopqrstuvqxyz"); + } + + if (useUpperCase) + { + this.characterClasses.Add("ABCDEFGHIJKLMNOPQRSTUVQXYZ"); + } + + if (useNumbers) + { + this.characterClasses.Add("1234567890"); + } + + if (useSpecial) + { + this.characterClasses.Add("!@#$%^&*()"); + } + + if (this.characterClasses.Count == 0) + { + throw new ArgumentException("No character classes enabled for RandomStringGenerator"); + } + } + + public override bool CanOriginate => true; + + public static Func GetSecretStoreFactory(ILogger logger) + { + return configuration => + { + var parameters = configuration.Parameters?.Deserialize(); + + if (parameters?.Length == null) + { + throw new RotationConfigurationException("Missing required parameter 'length'"); + } + + return new RandomStringGenerator( + parameters.Length.Value, + parameters.UseLowercase, + parameters.UseUppercase, + parameters.UseNumbers, + parameters.UseSpecialCharacters, + logger); + }; + } + + public override Task OriginateValueAsync(SecretState currentState, DateTimeOffset expirationDate, + bool whatIf) + { + // Add all the in-play character classes into a common set and generate a high entropy string + char[] availableCharacters = this.characterClasses.SelectMany(x => x).ToArray(); + + char[] resultCharacters = new char[this.length]; + + for (int i = 0; i < this.length; i++) + { + resultCharacters[i] = availableCharacters[Random.Shared.Next(availableCharacters.Length)]; + } + + // To ensure all classes are represented, allow each class to write a random character to a random index + // without reusing any index + List characterIndices = Enumerable.Range(0, resultCharacters.Length).ToList(); + + foreach (string characterClass in this.characterClasses) + { + // pick a random available index + int randomCharacterIndex = characterIndices[Random.Shared.Next(characterIndices.Count)]; + + // pick a random character from the current character set + char randomCharacter = characterClass[Random.Shared.Next(characterClass.Length)]; + + // place the character at the index + resultCharacters[randomCharacterIndex] = randomCharacter; + + // remove the index from the list of available indices + characterIndices.Remove(randomCharacterIndex); + + // If our string length is less than the number of character classes in play, we will run out of indices. + if (characterIndices.Count == 0) + { + break; + } + } + + return Task.FromResult(new SecretValue { ExpirationDate = default, Value = new string(resultCharacters) }); + } + + private class Parameters + { + [JsonPropertyName("length")] + public int? Length { get; set; } + + [JsonPropertyName("useLowercase")] + public bool UseLowercase { get; set; } + + [JsonPropertyName("useUppercase")] + public bool UseUppercase { get; set; } + + [JsonPropertyName("useNumbers")] + public bool UseNumbers { get; set; } + + [JsonPropertyName("useSpecialCharacters")] + public bool UseSpecialCharacters { get; set; } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault.csproj new file mode 100644 index 00000000000..8c62f2d4bfd --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault.csproj @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000000..f5b3a56f262 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultCertificateStore.cs @@ -0,0 +1,184 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Azure.Core; +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; +using Azure.Security.KeyVault.Certificates; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Stores.KeyVault; + +public class KeyVaultCertificateStore : SecretStore +{ + public const string MappingKey = "Key Vault Certificate"; + + private static readonly Regex uriRegex = new( + @"^(?https://.+?)/certificates/(?[^/]+)$", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromSeconds(5)); + + private readonly CertificateClient certificateClient; + private readonly string certificateName; + private readonly ILogger logger; + private readonly Uri vaultUri; + + public KeyVaultCertificateStore( + Uri vaultUri, + string certificateName, + TokenCredential credential, + ILogger logger) + { + this.vaultUri = vaultUri; + this.certificateName = certificateName; + this.logger = logger; + this.certificateClient = new CertificateClient(vaultUri, credential); + } + + public override bool CanRead => true; + + public override bool CanOriginate => true; + + public override bool CanAnnotate => true; + + public override bool CanRevoke => true; + + public static Func GetSecretStoreFactory(TokenCredential credential, + ILogger logger) + { + return configuration => + { + var parameters = configuration.Parameters?.Deserialize(); + + if (string.IsNullOrEmpty(parameters?.CertificateUri)) + { + throw new Exception("Missing required parameter 'certificateUri'"); + } + + Match match = uriRegex.Match(parameters.CertificateUri); + string vaultUrl = match.Groups["VaultUri"].Value; + string certificateName = match.Groups["CertificateName"].Value; + + if (!match.Success || !Uri.TryCreate(vaultUrl, UriKind.Absolute, out Uri? vaultUri)) + { + throw new Exception("Unable to parse parameter 'certificateUri'"); + } + + return new KeyVaultCertificateStore(vaultUri, certificateName, credential, logger); + }; + } + + public override async Task GetCurrentStateAsync() + { + try + { + this.logger.LogDebug("Getting certificate '{SecretName}' from vault '{VaultUri}'", this.certificateName, + this.vaultUri); + Response? response = + await this.certificateClient.GetCertificateAsync(this.certificateName); + + return new SecretState + { + ExpirationDate = response.Value.Properties.ExpiresOn, StatusCode = response.GetRawResponse().Status + }; + } + catch (RequestFailedException ex) + { + return new SecretState { ExpirationDate = null, StatusCode = ex.Status, ErrorMessage = ex.Message }; + } + } + + public override async Task OriginateValueAsync(SecretState currentState, DateTimeOffset expirationDate, + bool whatIf) + { + this.logger.LogInformation("Getting certificate policy for certificate '{CertificateName}' in vault '{Vault}'", + this.certificateName, this.vaultUri); + + Response? policy = + await this.certificateClient.GetCertificatePolicyAsync(this.certificateName); + + if (whatIf) + { + this.logger.LogInformation( + "WHAT IF: Create new version for certificate '{CertificateName}' in vault '{Vault}'", this.certificateName, + this.vaultUri); + return new SecretValue { ExpirationDate = expirationDate }; + } + + this.logger.LogInformation( + "Starting new certificate operation for certificate '{CertificateName}' in vault '{Vault}'", + this.certificateName, this.vaultUri); + CertificateOperation? operation = + await this.certificateClient.StartCreateCertificateAsync(this.certificateName, policy); + + this.logger.LogInformation("Waiting for certificate operation '{OperationId}' to complete", operation.Id); + Response? response = await operation.WaitForCompletionAsync(); + + string base64 = Convert.ToBase64String(response.Value.Cer); + + return new SecretValue + { + ExpirationDate = response.Value.Properties.ExpiresOn, OriginState = response.Value, Value = base64 + }; + } + + public override async Task MarkRotationCompleteAsync(SecretValue secretValue, DateTimeOffset? revokeAfterDate, + bool whatIf) + { + if (whatIf) + { + this.logger.LogInformation( + "WHAT IF: Add tag 'rotation-complete' to certificate '{CertificateName}' in vault '{Vault}'", + this.certificateName, this.vaultUri); + return; + } + + if (secretValue.OriginState is not KeyVaultCertificateWithPolicy certificate) + { + throw new RotationException( + "The OriginState value passed to KeyVaultCertificateStore was not of type KeyVaultCertificateWithPolicy"); + } + + this.logger.LogInformation("Adding tag 'rotation-complete' to certificate '{CertificateName}' in vault '{Vault}'", + this.certificateName, this.vaultUri); + + certificate.Properties.Tags.Add("rotation-complete", "true"); + + await this.certificateClient.UpdateCertificatePropertiesAsync(certificate.Properties); + } + + public override async Task> GetRotationArtifactsAsync() + { + var results = new List(); + + await foreach (CertificateProperties? version in + this.certificateClient.GetPropertiesOfCertificateVersionsAsync(this.certificateName)) + { + if (!version.Tags.TryGetValue("revokeAfter", out string? revokeAfterString)) + { + continue; + } + + if (!DateTimeOffset.TryParse(revokeAfterString, out DateTimeOffset revokeAfterDate)) + { + // TODO: Warning + continue; + } + + results.Add(new SecretState { Tags = version.Tags, RevokeAfterDate = revokeAfterDate }); + } + + return results; + } + + public override Func? GetRevocationActionAsync(SecretState secretState, bool whatIf) + { + throw new NotImplementedException(); + } + + private class Parameters + { + [JsonPropertyName("certificateUri")] + public string? CertificateUri { get; set; } + } +} 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 new file mode 100644 index 00000000000..b4bfff53967 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Stores.KeyVault/KeyVaultSecretStore.cs @@ -0,0 +1,289 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Azure.Core; +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Stores.KeyVault; + +public class KeyVaultSecretStore : SecretStore +{ + public enum RevocationAction + { + [JsonPropertyName("none")] + None = 0, + [JsonPropertyName("disableVersion")] + DisableVersion = 1 + } + + public const string MappingKey = "Key Vault Secret"; + private const string RevokeAfterTag = "RevokeAfter"; + private const string OperationIdTag = "OperationId"; + private const string RevokedTag = "Revoked"; + + private static readonly Regex uriRegex = new( + @"^(?https://.+?)/secrets/(?[^/]+)$", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromSeconds(5)); + + private readonly bool isPrimaryStore; + private readonly ILogger logger; + private readonly RevocationAction revocationAction; + + private readonly SecretClient secretClient; + private readonly string secretName; + private readonly Uri vaultUri; + + public KeyVaultSecretStore(ILogger logger, + TokenCredential credential, + Uri vaultUri, + string secretName, + RevocationAction revocationAction, + bool isPrimaryStore) + { + this.vaultUri = vaultUri; + this.secretName = secretName; + this.revocationAction = revocationAction; + this.isPrimaryStore = isPrimaryStore; + this.logger = logger; + this.secretClient = new SecretClient(vaultUri, credential); + } + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanRevoke => true; + + public static Func GetSecretStoreFactory(TokenCredential credential, + ILogger logger) + { + return storeConfiguration => + { + var parameters = storeConfiguration.Parameters?.Deserialize(); + + if (string.IsNullOrEmpty(parameters?.SecretUri)) + { + throw new Exception("Missing required parameter 'secretUri'"); + } + + Match match = uriRegex.Match(parameters.SecretUri); + string vaultUrl = match.Groups["VaultUri"].Value; + string secretName = match.Groups["SecretName"].Value; + + if (!match.Success || !Uri.TryCreate(vaultUrl, UriKind.Absolute, out Uri? vaultUri)) + { + throw new Exception("Unable to parse parameter 'secretUri'"); + } + + RevocationAction revocationAction = parameters.RevocationAction; + + return new KeyVaultSecretStore(logger, + credential, + vaultUri, + secretName, + revocationAction, + storeConfiguration.IsPrimary); + }; + } + + public override async Task GetCurrentStateAsync() + { + try + { + this.logger.LogDebug("Getting secret '{SecretName}' from vault '{VaultUri}'", this.secretName, + this.vaultUri); + + KeyVaultSecret secret = await this.secretClient.GetSecretAsync(this.secretName); + + secret.Properties.Tags.TryGetValue(OperationIdTag, out string? originId); + + var result = new SecretState + { + Id = secret.Properties.Version, + OperationId = originId, + ExpirationDate = secret.Properties.ExpiresOn, + Tags = secret.Properties.Tags, + Value = secret.Value, + }; + + return result; + } + catch (RequestFailedException ex) + { + return new SecretState { ExpirationDate = null, StatusCode = ex.Status, ErrorMessage = ex.Message }; + } + } + + public override async Task> GetRotationArtifactsAsync() + { + var results = new List(); + + await foreach (SecretProperties? versionProperties in this.secretClient.GetPropertiesOfSecretVersionsAsync( + this.secretName)) + { + if (versionProperties.Enabled == false + || versionProperties.Tags.ContainsKey(RevokedTag) + || versionProperties.Tags.TryGetValue(RevokeAfterTag, out string? revokeAfterString) == false) + { + continue; + } + + if (!DateTimeOffset.TryParse(revokeAfterString, out DateTimeOffset revokeAfterDate)) + { + // TODO: Warn about improperly formatted revokeAfter value + continue; + } + + versionProperties.Tags.TryGetValue(OperationIdTag, out string? operationId); + + results.Add(new SecretState + { + Id = versionProperties.Id.ToString(), + OperationId = operationId, + Tags = versionProperties.Tags, + RevokeAfterDate = revokeAfterDate + }); + } + + return results; + } + + public override async Task WriteSecretAsync(SecretValue secretValue, + SecretState? currentState, + DateTimeOffset? revokeAfterDate, + bool whatIf) + { + var secret = new KeyVaultSecret(this.secretName, secretValue.Value) + { + Properties = { ExpiresOn = secretValue.ExpirationDate } + }; + + foreach ((string key, string value) in secretValue.Tags) + { + secret.Properties.Tags[key] = value; + } + + if (!string.IsNullOrEmpty(OperationIdTag)) + { + // TODO: Warn if Tags already contains this key? + secret.Properties.Tags[OperationIdTag] = secretValue.OperationId; + } + + if (whatIf) + { + this.logger.LogInformation( + "WHAT IF: Set value for secret '{SecretName}' in vault '{VaultUri}'", this.secretName, this.vaultUri); + + return; + } + + this.logger.LogInformation( + "Setting value for secret '{SecretName}' in vault '{VaultUri}'", this.secretName, this.vaultUri); + + secret = await this.secretClient.SetSecretAsync(secret); + + if (revokeAfterDate.HasValue && this.isPrimaryStore) + { + await SetRevokeAfterForOldVersionsAsync(secret.Properties.Version, revokeAfterDate.Value); + } + } + + public override Func? GetRevocationActionAsync(SecretState secretState, bool whatIf) + { + if (this.revocationAction == RevocationAction.None || string.IsNullOrEmpty(secretState.Id)) + { + return null; + } + + return async () => + { + await foreach (SecretProperties? version in this.secretClient.GetPropertiesOfSecretVersionsAsync( + this.secretName)) + { + if (version.Id.ToString() != secretState.Id) + { + continue; + } + + if (this.revocationAction == RevocationAction.DisableVersion) + { + if (whatIf) + { + this.logger.LogInformation( + "WHAT IF: Disable verion '{VersionId}' of '{SecretName}' in vault '{VaultUri}'", + version.Version, this.secretName, this.vaultUri); + } + else + { + version.Enabled = false; + } + } + + version.Tags.Remove(RevokeAfterTag); + version.Tags[RevokedTag] = "true"; + + if (whatIf) + { + this.logger.LogInformation( + $"WHAT IF: Remove tag '{RevokeAfterTag}' and set tag '{RevokedTag}' to true on secret '{{SecretName}}' in vault '{{VaultUri}}'", + this.secretName, this.vaultUri); + + return; + } + + await this.secretClient.UpdateSecretPropertiesAsync(version); + } + }; + } + + private static bool TryGetRevokeAfterDate(IDictionary tags, out DateTimeOffset value) + { + if (tags.TryGetValue(RevokeAfterTag, out string? tagValue)) + { + if (DateTimeOffset.TryParse(tagValue, out value)) + { + return true; + } + } + + value = default; + return false; + } + + private async Task SetRevokeAfterForOldVersionsAsync(string currentVersionId, DateTimeOffset revokeAfterDate) + { + AsyncPageable? allSecretVersions = + this.secretClient.GetPropertiesOfSecretVersionsAsync(this.secretName); + + await foreach (SecretProperties version in allSecretVersions) + { + if (version.Version == currentVersionId) + { + // Skip the current version + continue; + } + + if (TryGetRevokeAfterDate(version.Tags, out DateTimeOffset existingRevokeAfterDate) && + existingRevokeAfterDate < revokeAfterDate) + { + // Skip if already revoking before revokeAfterDate + continue; + } + + version.Tags[RevokeAfterTag] = revokeAfterDate.ToString("O"); + await this.secretClient.UpdateSecretPropertiesAsync(version); + } + } + + private class Parameters + { + [JsonPropertyName("secretUri")] + public string? SecretUri { get; set; } + + [JsonPropertyName("revocationAction")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public RevocationAction RevocationAction { get; set; } + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/Azure.Sdk.Tools.SecretRotation.Tests.csproj b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/Azure.Sdk.Tools.SecretRotation.Tests.csproj new file mode 100644 index 00000000000..90c55a1e3a7 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/Azure.Sdk.Tools.SecretRotation.Tests.csproj @@ -0,0 +1,25 @@ + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + Always + + + + diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/PlanConfigurationTests.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/PlanConfigurationTests.cs new file mode 100644 index 00000000000..040fba53afa --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/PlanConfigurationTests.cs @@ -0,0 +1,33 @@ +using Azure.Sdk.Tools.SecretRotation.Configuration; + +namespace Azure.Sdk.Tools.SecretRotation.Tests.CoreTests; + +public class PlanConfigurationTests +{ + [Test] + public void FromFile_MissingFile_ThrowsException() + { + string configurationPath = TestFiles.ResolvePath("TestConfigurations/missing.json"); + + Assert.Throws(() => PlanConfiguration.FromFile(configurationPath)); + } + + [Test] + public void FromFile_InvalidPath_ThrowsException() + { + string configurationPath = @"&invalid:path?"; + + Assert.Throws(() => PlanConfiguration.FromFile(configurationPath)); + } + + [Test] + public void FromFile_ValidPath_ReturnConfiguration() + { + string configurationPath = TestFiles.ResolvePath("TestConfigurations/Valid/random-string.json"); + + // Act + PlanConfiguration configuration = PlanConfiguration.FromFile(configurationPath); + + Assert.NotNull(configuration); + } +} 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 new file mode 100644 index 00000000000..8a9b33fd33e --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/RotationConfigurationTests.cs @@ -0,0 +1,57 @@ +using Azure.Sdk.Tools.SecretRotation.Configuration; +using Azure.Sdk.Tools.SecretRotation.Core; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Tests.CoreTests; + +public class RotationConfigurationTests +{ + [Test] + public void LoadFrom_MissingFile_ThrowsException() + { + string missingPath = TestFiles.ResolvePath("TestConfigurations/missing.json"); + var storeFactories = new Dictionary>(); + + Assert.Throws(() => RotationConfiguration.From(missingPath, storeFactories)); + } + + [Test] + public void LoadFrom_InvalidPath_ThrowsException() + { + string invalidPath = @"&invalid:path?"; + var storeFactories = new Dictionary>(); + + Assert.Throws(() => RotationConfiguration.From(invalidPath, storeFactories)); + } + + [Test] + public void LoadFrom_ValidPath_ReturnConfiguration() + { + string validPath = TestFiles.ResolvePath("TestConfigurations/valid/random-string.json"); + var storeFactories = new Dictionary>(); + + // Act + RotationConfiguration configuration = RotationConfiguration.From(validPath, storeFactories); + + Assert.NotNull(configuration); + } + + [Test] + public void GetPlan_ValidConfiguration_ReturnsPlan() + { + string configurationPath = TestFiles.ResolvePath("TestConfigurations/valid/random-string.json"); + + var storeFactories = new Dictionary> + { + ["Random String"] = _ => Mock.Of(x => x.CanOriginate), + ["Key Vault Secret"] = _ => Mock.Of(x => x.CanWrite && x.CanRead) + }; + + RotationConfiguration configuration = RotationConfiguration.From(configurationPath, storeFactories); + + // Act + RotationPlan? plan = configuration.GetRotationPlan("random-string", Mock.Of(), new TimeProvider()); + + Assert.NotNull(plan); + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/RotationPlanTests.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/RotationPlanTests.cs new file mode 100644 index 00000000000..95f432bce86 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/CoreTests/RotationPlanTests.cs @@ -0,0 +1,169 @@ +using Azure.Sdk.Tools.SecretRotation.Core; +using Microsoft.Extensions.Logging; + +namespace Azure.Sdk.Tools.SecretRotation.Tests.CoreTests; + +public class RotationPlanTests +{ + /// + /// Given a rotation plan with a primary or secondary store having an expiration date after the rotation threshold + /// When GetStatusAsync is called + /// Then the result's Expired and ThresholdExpired properties should return False + /// + /// Given a primary or secondary expiration date before or on the rotation threshold + /// When GetStatusAsync is called + /// Then the result's ThresholdExpired property should return True + /// + /// Given a primary or secondary expiration date before or on the invocation instant + /// When GetStatusAsync is called + /// Then the result's Expired property should return True + /// + [Theory] + [TestCase(24, 0, 30, true, true)] + [TestCase(24, -1, 30, true, true)] + [TestCase(24, -1, 23, true, true)] + [TestCase(24, 30, 0, true, true)] + [TestCase(24, 30, -1, true, true)] + [TestCase(24, 23, -1, true, true)] + [TestCase(24, 24, 30, false, true)] + [TestCase(24, 23, 30, false, true)] + [TestCase(24, 30, 24, false, true)] + [TestCase(24, 30, 23, false, true)] + [TestCase(24, 25, 25, false, false)] + public async Task GetStatusAsync_ExpectExpirationState( + int thresholdHours, + int hoursUntilPrimaryExpires, + int hoursUntilSecondaryExpires, + bool expectExpired, + bool expectThresholdExpired) + { + DateTimeOffset staticTestTime = DateTimeOffset.Parse("2020-06-01T12:00:00Z"); + TimeSpan threshold = TimeSpan.FromHours(thresholdHours); + + var primaryState = + new SecretState { ExpirationDate = staticTestTime.AddHours(hoursUntilPrimaryExpires) }; // after threshold + var secondaryState = + new SecretState + { + ExpirationDate = staticTestTime.AddHours(hoursUntilSecondaryExpires) + }; // before threshold + + var rotationPlan = new RotationPlan( + Mock.Of(), + Mock.Of(x => x.GetCurrentDateTimeOffset() == staticTestTime), + "TestPlan", + Mock.Of(), + Mock.Of(x => x.GetCurrentStateAsync() == Task.FromResult(primaryState)), + new[] + { + Mock.Of(x => x.CanRead && x.GetCurrentStateAsync() == Task.FromResult(secondaryState)) + }, + threshold, + default, + default); + + // Act + RotationPlanStatus status = await rotationPlan.GetStatusAsync(); + + Assert.AreEqual(expectExpired, status.Expired); + Assert.AreEqual(expectThresholdExpired, status.ThresholdExpired); + } + + /// + /// Given a rotation artifact with a RevokeAfterDate after than the invocation time + /// When GetStatusAsync is called + /// Then the result's RequiresRevocation property should return False + /// + /// Given a rotation plan with a RevokeAfterDate before or on the invocation time + /// When GetStatusAsync is called + /// Then the result's RequiresRevocation property should return True + /// + [Test] + [TestCase(1, false)] + [TestCase(0, true)] + [TestCase(-1, true)] + public async Task GetStatusAsync_RequiresRevocation(int hoursUntilRevocation, bool expectRequiresRevocation) + { + DateTimeOffset staticTestTime = DateTimeOffset.Parse("2020-06-01T12:00:00Z"); + + var rotationArtifacts = new[] + { + new SecretState { RevokeAfterDate = staticTestTime.AddHours(hoursUntilRevocation) } // not yet revokable + }; + + var rotationPlan = new RotationPlan( + Mock.Of(), + Mock.Of(x => x.GetCurrentDateTimeOffset() == staticTestTime), + "TestPlan", + Mock.Of(), + Mock.Of( + x => x.GetCurrentStateAsync() == Task.FromResult(new SecretState()) && + x.GetRotationArtifactsAsync() == Task.FromResult(rotationArtifacts.AsEnumerable())), + Array.Empty(), + default, + default, + default); + + // Act + RotationPlanStatus status = await rotationPlan.GetStatusAsync(); + + Assert.AreEqual(expectRequiresRevocation, status.RequiresRevocation); + } + + /// + /// Given a rotation plan with: + /// A primary store with a rotation artifact requiring revocation + /// A secondary store with an action to perform during revocation + /// When ExecuteAsync is called on the plan + /// Then the secondary store's revocation action should be invoked + /// + [Test] + public async Task RotatePlansAsync_RequiresRevocation_DoesRevocation() + { + DateTimeOffset staticTestTime = DateTimeOffset.Parse("2020-06-01T12:00:00Z"); + + var primaryState = new SecretState { ExpirationDate = staticTestTime.AddDays(5) }; // after threshold + + var rotationArtifacts = new[] + { + new SecretState + { + RevokeAfterDate = staticTestTime.AddDays(-1), Tags = { ["value"] = "OldValue" } + } // revokable + }; + + string externalState = "NotRevoked"; + + Func revocationAction = () => + { + externalState = "Revoked"; + return Task.CompletedTask; + }; + + var originStore = Mock.Of(); + + var primaryStore = Mock.Of(x => + x.GetCurrentStateAsync() == Task.FromResult(primaryState) && + x.GetRotationArtifactsAsync() == Task.FromResult(rotationArtifacts.AsEnumerable())); + + var secondaryStore = Mock.Of(x => + x.CanRevoke && + x.GetRevocationActionAsync(It.IsAny(), It.IsAny()) == revocationAction); + + var rotationPlan = new RotationPlan( + Mock.Of(), + Mock.Of(x => x.GetCurrentDateTimeOffset() == staticTestTime), + "TestPlan", + originStore, + primaryStore, + new[] { secondaryStore }, + TimeSpan.FromDays(1), + TimeSpan.FromDays(2), + default); + + // Act + await rotationPlan.ExecuteAsync(true, false); + + Assert.AreEqual("Revoked", externalState); + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/GlobalUsings.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/GlobalUsings.cs new file mode 100644 index 00000000000..273c94f15bd --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using NUnit; +global using NUnit.Framework; +global using Moq; diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestConfigurations/Valid/manual-value.json b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestConfigurations/Valid/manual-value.json new file mode 100644 index 00000000000..296b062af2d --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestConfigurations/Valid/manual-value.json @@ -0,0 +1,32 @@ +{ + "rotationPeriod": "1.00:00:00", + "rotationThreshold": "23:59:00", + "revokeAfterPeriod": "00:05:00", + "stores": [ + { + "type": "Random String", + "isOrigin": true, + "parameters": { + "length": 16, + "useLowercase": true, + "useUppercase": true, + "useNumbers": true, + "useSpecialCharacters": true + } + }, + { + "type": "Key Vault Secret", + "isPrimary": true, + "parameters": { + "secretUri": "https://azsdk-rotation-test.vault.azure.net/secrets/manual-value", + "revocationAction": "disableVersion" + } + }, + { + "type": "Manual Action", + "parameters": { + "prompt": "Please update the password in all external stores." + } + } + ] +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestConfigurations/Valid/random-string.json b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestConfigurations/Valid/random-string.json new file mode 100644 index 00000000000..f02cd7b2f29 --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestConfigurations/Valid/random-string.json @@ -0,0 +1,25 @@ +{ + "rotationThreshold": "9.00:00:00", + "rotationPeriod": "12.00:00:00", + "stores": [ + { + "type": "Random String", + "isOrigin": true, + "parameters": { + "length": 20, + "useLowercase": true, + "useUppercase": true, + "useNumbers": true, + "useSpecialCharacters": true + } + }, + { + "type": "Key Vault Secret", + "isPrimary": true, + "parameters": { + "secretUri": "https://azsdk-rotation-test.vault.azure.net/secrets/random-string", + "revocationAction": "disableVersion" + } + } + ] +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestConfigurations/null.json b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestConfigurations/null.json new file mode 100644 index 00000000000..19765bd501b --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestConfigurations/null.json @@ -0,0 +1 @@ +null diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestFiles.cs b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestFiles.cs new file mode 100644 index 00000000000..64facde317e --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.Tests/TestFiles.cs @@ -0,0 +1,19 @@ +namespace Azure.Sdk.Tools.SecretRotation.Tests; + +public class TestFiles +{ + private static readonly Lazy assemblyDirectory = new(() => + { + string assemblyPath = typeof(TestFiles).Assembly.Location; + + return Path.GetDirectoryName(assemblyPath) + ?? throw new Exception($"Unable to resolve directory from assembly location '{assemblyPath}'"); + }, LazyThreadSafetyMode.ExecutionAndPublication); + + public static string ResolvePath(string relativePath) + { + string filePath = Path.Combine(assemblyDirectory.Value, relativePath); + + return filePath; + } +} diff --git a/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.sln b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.sln new file mode 100644 index 00000000000..199032cb38f --- /dev/null +++ b/tools/secret-rotation/Azure.Sdk.Tools.SecretRotation.sln @@ -0,0 +1,80 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Cli", "Azure.Sdk.Tools.SecretRotation.Cli\Azure.Sdk.Tools.SecretRotation.Cli.csproj", "{F61474F0-89E3-431A-89C0-0303259F7AF9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Tests", "Azure.Sdk.Tools.SecretRotation.Tests\Azure.Sdk.Tools.SecretRotation.Tests.csproj", "{461CF7BF-A1C8-4036-BA7B-E080DB059897}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Core", "Azure.Sdk.Tools.SecretRotation.Core\Azure.Sdk.Tools.SecretRotation.Core.csproj", "{C7310BFE-C078-4C7A-BB98-17775895D91E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Stores.KeyVault", "Azure.Sdk.Tools.SecretRotation.Stores.KeyVault\Azure.Sdk.Tools.SecretRotation.Stores.KeyVault.csproj", "{D3F9D760-D035-4816-8611-4884D3DD124B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Stores", "Stores", "{B06067D2-6F7C-4281-A2AC-4E7DBD94C5DD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps", "Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps\Azure.Sdk.Tools.SecretRotation.Stores.AzureDevOps.csproj", "{DEE3F30A-5B2E-4C44-B273-A9703CAAFE35}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Configuration", "Azure.Sdk.Tools.SecretRotation.Configuration\Azure.Sdk.Tools.SecretRotation.Configuration.csproj", "{736B6014-2F61-4692-8208-CB19665305F0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory", "Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory\Azure.Sdk.Tools.SecretRotation.Stores.AzureActiveDirectory.csproj", "{0D73540F-FB12-4555-846F-3E45D93219D7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.Sdk.Tools.SecretRotation.Stores.Generic", "Azure.Sdk.Tools.SecretRotation.Stores.Generic\Azure.Sdk.Tools.SecretRotation.Stores.Generic.csproj", "{67C87A2D-AF22-4C36-B99B-7E1A60B07B04}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9B29A398-A5EB-4FB5-AEF6-81B40E9565CB}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F61474F0-89E3-431A-89C0-0303259F7AF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F61474F0-89E3-431A-89C0-0303259F7AF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F61474F0-89E3-431A-89C0-0303259F7AF9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F61474F0-89E3-431A-89C0-0303259F7AF9}.Release|Any CPU.Build.0 = Release|Any CPU + {461CF7BF-A1C8-4036-BA7B-E080DB059897}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {461CF7BF-A1C8-4036-BA7B-E080DB059897}.Debug|Any CPU.Build.0 = Debug|Any CPU + {461CF7BF-A1C8-4036-BA7B-E080DB059897}.Release|Any CPU.ActiveCfg = Release|Any CPU + {461CF7BF-A1C8-4036-BA7B-E080DB059897}.Release|Any CPU.Build.0 = Release|Any CPU + {C7310BFE-C078-4C7A-BB98-17775895D91E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7310BFE-C078-4C7A-BB98-17775895D91E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7310BFE-C078-4C7A-BB98-17775895D91E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7310BFE-C078-4C7A-BB98-17775895D91E}.Release|Any CPU.Build.0 = Release|Any CPU + {D3F9D760-D035-4816-8611-4884D3DD124B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3F9D760-D035-4816-8611-4884D3DD124B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3F9D760-D035-4816-8611-4884D3DD124B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3F9D760-D035-4816-8611-4884D3DD124B}.Release|Any CPU.Build.0 = Release|Any CPU + {DEE3F30A-5B2E-4C44-B273-A9703CAAFE35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DEE3F30A-5B2E-4C44-B273-A9703CAAFE35}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DEE3F30A-5B2E-4C44-B273-A9703CAAFE35}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DEE3F30A-5B2E-4C44-B273-A9703CAAFE35}.Release|Any CPU.Build.0 = Release|Any CPU + {736B6014-2F61-4692-8208-CB19665305F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {736B6014-2F61-4692-8208-CB19665305F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {736B6014-2F61-4692-8208-CB19665305F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {736B6014-2F61-4692-8208-CB19665305F0}.Release|Any CPU.Build.0 = Release|Any CPU + {0D73540F-FB12-4555-846F-3E45D93219D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D73540F-FB12-4555-846F-3E45D93219D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D73540F-FB12-4555-846F-3E45D93219D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D73540F-FB12-4555-846F-3E45D93219D7}.Release|Any CPU.Build.0 = Release|Any CPU + {67C87A2D-AF22-4C36-B99B-7E1A60B07B04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D3F9D760-D035-4816-8611-4884D3DD124B} = {B06067D2-6F7C-4281-A2AC-4E7DBD94C5DD} + {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} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5C35D5F5-EED1-4FA7-941C-02D71B34D9BA} + EndGlobalSection +EndGlobal diff --git a/tools/secret-rotation/Directory.Build.props b/tools/secret-rotation/Directory.Build.props new file mode 100644 index 00000000000..c873f1cbdc3 --- /dev/null +++ b/tools/secret-rotation/Directory.Build.props @@ -0,0 +1,12 @@ + + + net6.0 + enable + enable + false + true + true + + + + diff --git a/tools/secret-rotation/README.md b/tools/secret-rotation/README.md new file mode 100644 index 00000000000..095f71eb681 --- /dev/null +++ b/tools/secret-rotation/README.md @@ -0,0 +1,17 @@ +# Secret Rotation + +The secret rotation tool provides configuration driven orchestration of secret origination, propagation, revocation and metadata storage. + +If the tool's installed locally, it's invoked like: + +``` +dotnet tool run secrets --help +``` + +If the tool's installed globally, it's invoked like: + +``` +secrets --help +``` + +Additional documentation can be found in the [docs folder](docs/). \ No newline at end of file diff --git a/tools/secret-rotation/ci.yml b/tools/secret-rotation/ci.yml new file mode 100644 index 00000000000..c5803421c49 --- /dev/null +++ b/tools/secret-rotation/ci.yml @@ -0,0 +1,27 @@ +# NOTE: Please refer to https://aka.ms/azsdk/engsys/ci-yaml before editing this file. +trigger: + branches: + include: + - main + - feature/* + - release/* + - hotfix/* + paths: + include: + - tools/secret-rotation + +pr: + branches: + include: + - main + - feature/* + - release/* + - hotfix/* + paths: + include: + - tools/secret-rotation + +extends: + template: /eng/pipelines/templates/stages/archetype-sdk-tool-dotnet.yml + parameters: + ToolDirectory: tools/secret-rotation diff --git a/tools/secret-rotation/docs/configuration.md b/tools/secret-rotation/docs/configuration.md new file mode 100644 index 00000000000..45359e22279 --- /dev/null +++ b/tools/secret-rotation/docs/configuration.md @@ -0,0 +1,5 @@ +# Configuration + +Rotation plans use JSON configuration files, one file per plan. + +The configuration schema can be found in [/Azure.Sdk.Tools.SecretRotation.Configuration/secretrotation.schema.json](../Azure.Sdk.Tools.SecretRotation.Configuration/secretrotation.schema.json) \ No newline at end of file diff --git a/tools/secret-rotation/docs/rotation.md b/tools/secret-rotation/docs/rotation.md new file mode 100644 index 00000000000..a4f23aa4c9d --- /dev/null +++ b/tools/secret-rotation/docs/rotation.md @@ -0,0 +1,27 @@ +# Secret Rotation + +Rotation happens in 5 main phases: + +## Discovery + +The rotation plan's primary store is read to get the current value and metadata for a secret. +The expiration date is compared to the plan's expiration and rotation thresholds. +If the `--expired` option is specified and the plan's expiration date is not within the rotation or expiration thresholds, the plan will not be rotated. + +## Origination + +A new secret value is created by the plan's origin store. If the origin store doesn't provide an expiration date along with the new value, a default expiration date of `now() + expirationThreshold` is used. Along with the secret value and expiration date, the origin can include addition tags that may be useful in identifying and revoking a secret value. + +## Propagation + +The new secret value and expiration date are provided to the secondary stores. After propagation to secondary stores, the value is propagated to the Primary store indicating that rotation is complete. + +## Annotation + +Some secret stores can be both Origin and Primary in the same plan. For example, Key Vault Certificates can originate a new certificate and can store the certificate value with accompanying metadata. + +When a store serves as both Origin and Primary, the store must also support Annotation. Specifically, we expect that the new secret value can be marked as "Rotation Complete" after the Propagation phase, indicating that the rotation process completed successfully. + +## Revocation + +After rotating to a new secret value, the old value may need to be revoked. For example, revoking personal access tokens or deleting AAD app secrets. After rotation, the primary store will be queried for revokable rotation states. This will include any tags that were added during origination or propagation. The origin, primary and secondary stores are given the opportunity to perform appropriate revocation actions for each state. diff --git a/tools/secret-rotation/docs/stores.md b/tools/secret-rotation/docs/stores.md new file mode 100644 index 00000000000..87cb3810e1d --- /dev/null +++ b/tools/secret-rotation/docs/stores.md @@ -0,0 +1,53 @@ +# Secret Stores + +The services that create, store and use the secrets are called secret stores in the rotation tool. A store may act as Origin and create new secret values, as Primary and store the secret value along with rotation metadata like expiration date and revocation date, or it can act as a secondary store that gets updated with the new secret values on rotation. + +All of the stores inherit from the abstract class [SecretStore](../Azure.Sdk.Tools.SecretRotation.Core/SecretStore.cs). + +# Capabilities +The `SecretStore` class has methods categorized into 5 capabilities which are used during the rotation process: + +## CanRead + +Stores implementing `CanRead` must override the methods: + - `GetCurrentStateAsync` + - `GetRotationArtifactsAsync` + +## CanWrite +Stores implementing `CanWrite` must override the methods: + - `WriteSecretAsync` + +## CanOriginate +Stores implementing `CanOriginate` must override the methods: + - `OriginateValueAsync` + +## CanAnnotate +Stores implementing `CanAnnotate` must override the methods: + - `MarkRotationCompleteAsync` + +## CanRevoke +Stores implementing `CanRevoke` must override the methods: + - `GetRevocationActionAsync` + +# Roles + +The stores will fill 3 roles in the rotation process: `Origination`, `Primary storage` and `SecondaryStorage`. A stores implementation may support multiple roles depending on how the store is used in a rotation plan. + +For example, a Key Vault Certificate could be used as the origin for a new secret, could be used as the primary store for rotation metadata for a certificate created externally, or could be used as a secondary store in a plan that propagates certificates into secondary vaults. However, the RandomString store is useful as a secret origin, but it's incapable of storing rotation metadata or secret values. + +## Origination + +Stores that support secret origination must implement the `CanOriginate` capability. + +## Primary Storage + +Primary stores hold rotation metadata like rotation and expiration date, and would typically also store the secret value. + +Primary stores must implement the `CanRead` capability. If they're also acting as a secret's origin, e.g. a Key Vault certificate, should implement the `CanAnnotate` capability. If the store is not also acting as origin, it should implement the `CanWrite` capability. + +## Secondary Storage + +Stores that persist secret values must implement `CanWrite`. If the store should participate in expiration detection, it should also implement `CanRead`. + +Services that only need notification during or after a rotation should be implemented as secondary stores with their notification logic in the `WriteSecretAsync` method. +