Skip to content

Commit

Permalink
Use --tags and --name for rotation config filtering (#5577)
Browse files Browse the repository at this point in the history
* Add --tags and --name options to secret rotation
* Add json schema for plan configuration files
  • Loading branch information
hallipr authored Mar 3, 2023
1 parent 3c001ca commit a370b11
Show file tree
Hide file tree
Showing 21 changed files with 378 additions and 123 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@
"description": "Time span indicating when the old secret values should be revoked after rotation",
"type": "string"
},
"tags": {
"description": "Plan tags",
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
},
"stores": {
"description": "The stores participating in the rotation plan",
"type": "array",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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 ListCommand : RotationCommandBase
{
public ListCommand() : base("list", "List secret rotation plans")
{
}

protected override Task HandleCommandAsync(ILogger logger, RotationConfiguration rotationConfiguration,
InvocationContext invocationContext)
{
foreach (PlanConfiguration plan in rotationConfiguration.PlanConfigurations)
{
Console.WriteLine($"name: {plan.Name} - tags: {string.Join(", ", plan.Tags)}");
}

return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -1,68 +1,34 @@
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<bool> allOption = new(new[] { "--all", "-a" }, "Rotate all secrets");
private readonly Option<string[]> secretsOption = new(new[] { "--secrets", "-s" }, "Rotate only the specified secrets");
private readonly Option<bool> expiringOption = new(new[] { "--expiring", "-e" }, "Only rotate expiring secrets");
private readonly Option<bool> 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<RotationPlan> plans;

if (all)
{
plans = rotationConfiguration.GetAllRotationPlans(logger, timeProvider);
}
else
{
string[] secretNames = invocationContext.ParseResult.GetValueForOption(this.secretsOption)!;

plans = rotationConfiguration.GetRotationPlans(logger, secretNames, timeProvider);
}
IEnumerable<RotationPlan> plans = rotationConfiguration.GetAllRotationPlans(logger, 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.";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,34 @@ namespace Azure.Sdk.Tools.SecretRotation.Cli.Commands;

public abstract class RotationCommandBase : Command
{
private readonly Option<string> configOption = new(new[] { "--config", "-c" }, "Configuration path")
private readonly Option<string[]> nameOption = new(new[] { "--name", "-n" })
{
IsRequired = true,
Arity = ArgumentArity.ExactlyOne
Arity = ArgumentArity.ZeroOrMore,
Description = "Name of the plan to rotate.",
};

private readonly Option<bool> verboseOption = new(new[] { "--verbose", "-v" }, "Verbose output");
private readonly Option<string[]> tagsOption = new(new[] { "--tags", "-t" })
{
IsRequired = false,
Description = "Tags to filter the plans to rotate.",
};

private readonly Option<string> configOption = new(new[] { "--config", "-c" })
{
IsRequired = false,
Description = "Configuration root path. Defaults to current working directory.",
};

private readonly Option<bool> verboseOption = new(new[] { "--verbose", "-v" })
{
IsRequired = false,
Description = "Verbose output",
};

protected RotationCommandBase(string name, string description) : base(name, description)
{
AddOption(this.nameOption);
AddOption(this.tagsOption);
AddOption(this.configOption);
AddOption(this.verboseOption);
this.SetHandler(ParseAndHandleCommandAsync);
Expand All @@ -35,9 +53,17 @@ protected abstract Task HandleCommandAsync(ILogger logger, RotationConfiguration

private async Task ParseAndHandleCommandAsync(InvocationContext invocationContext)
{
string configPath = invocationContext.ParseResult.GetValueForOption(this.configOption)!;
string[] names = invocationContext.ParseResult.GetValueForOption(this.nameOption)
?? Array.Empty<string>();

string[] tags = invocationContext.ParseResult.GetValueForOption(this.tagsOption)
?? Array.Empty<string>();

bool verbose = invocationContext.ParseResult.GetValueForOption(this.verboseOption);

string configPath = invocationContext.ParseResult.GetValueForOption(this.configOption)
?? Environment.CurrentDirectory;

LogLevel logLevel = verbose ? LogLevel.Trace : LogLevel.Information;

ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder
Expand All @@ -56,7 +82,7 @@ private async Task ParseAndHandleCommandAsync(InvocationContext invocationContex
GetDefaultSecretStoreFactories(tokenCredential, logger);

// TODO: Pass a logger to RotationConfiguration so it can verbose log when reading from files.
RotationConfiguration rotationConfiguration = RotationConfiguration.From(configPath, secretStoreFactories);
RotationConfiguration rotationConfiguration = RotationConfiguration.From(names, tags, configPath, secretStoreFactories);

await HandleCommandAsync(logger, rotationConfiguration, invocationContext);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.CommandLine;
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
Expand All @@ -14,6 +14,7 @@ public class Program
public static async Task<int> Main(string[] args)
{
var rootCommand = new RootCommand("Secrets rotation tool");
rootCommand.AddCommand(new ListCommand());
rootCommand.AddCommand(new StatusCommand());
rootCommand.AddCommand(new RotateCommand());

Expand All @@ -29,6 +30,7 @@ private static void HandleExceptions(Exception exception, InvocationContext invo
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(BuildErrorMessage(exception).TrimEnd());
Console.ResetColor();
invocationContext.ExitCode = 1;
}

private static string BuildErrorMessage(Exception exception)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,55 @@
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

namespace Azure.Sdk.Tools.SecretRotation.Configuration;

public class PlanConfiguration
{
[JsonPropertyName("name")]
public string? Name { get; set; }
private static readonly JsonSerializerOptions jsonOptions = new()
{
AllowTrailingCommas = true,
Converters =
{
new JsonStringEnumConverter()
},
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
};

private static readonly Regex schemaPattern = new (@"https\://raw\.githubusercontent\.com/azure/azure-sdk-tools/(?<branch>.+?)/schemas/secretrotation/(?<version>.+?)/schema\.json", RegexOptions.IgnoreCase);

public string Name { get; set; } = string.Empty;

[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<StoreConfiguration>();

public static PlanConfiguration FromFile(string path)
public string[] Tags { get; set; } = Array.Empty<string>();

public static bool TryLoadFromFile(string path, out PlanConfiguration? configuration)
{
string fileContents = GetFileContents(path);

PlanConfiguration configuration = ParseConfiguration(path, fileContents);
configuration = ParseConfiguration(path, fileContents);

if (configuration == null)
{
return false;
}

if (string.IsNullOrEmpty(configuration.Name))
{
configuration.Name = Path.GetFileNameWithoutExtension(path);
}

return configuration;
return true;
}

private static string GetFileContents(string path)
Expand All @@ -47,11 +65,29 @@ private static string GetFileContents(string path)
}
}

private static PlanConfiguration ParseConfiguration(string filePath, string fileContents)
private static PlanConfiguration? ParseConfiguration(string filePath, string fileContents)
{
try
{
PlanConfiguration planConfiguration = JsonSerializer.Deserialize<PlanConfiguration>(fileContents)
JsonDocument document = JsonDocument.Parse(fileContents, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip });

if (document.RootElement.ValueKind != JsonValueKind.Object
|| !document.RootElement.TryGetProperty("$schema", out JsonElement schemaElement)
|| schemaElement.ValueKind != JsonValueKind.String)
{
return null;
}

string schema = schemaElement.ToString();
Match schemaMatch = schemaPattern.Match(schema);
if (!schemaMatch.Success)
{
// We expect to recognize the url in the $schema property. This allows us to ignore json files in the
// config path that aren't intended to be plan configurations.
return null;
}

PlanConfiguration planConfiguration = document.Deserialize<PlanConfiguration>(jsonOptions)
?? throw new RotationConfigurationException($"Error reading configuration file '{filePath}'. Configuration deserialized to null.");

return planConfiguration;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,60 +18,52 @@ private RotationConfiguration(

public ReadOnlyCollection<PlanConfiguration> PlanConfigurations { get; }

public static RotationConfiguration From(string path,
public static RotationConfiguration From(IList<string> names,
IList<string> tags,
string directoryPath,
IDictionary<string, Func<StoreConfiguration, SecretStore>> storeFactories)
{
List<PlanConfiguration> planConfigurations = new();
IEnumerable<string> jsonFiles;

if (Directory.Exists(path))
try
{
planConfigurations.AddRange(Directory.EnumerateFiles(path, "*.json", SearchOption.TopDirectoryOnly)
.Select(PlanConfiguration.FromFile));
jsonFiles = Directory
.EnumerateFiles(directoryPath, "*.json", SearchOption.AllDirectories);
}
else
catch (Exception ex)
{
planConfigurations.Add(PlanConfiguration.FromFile(path));
throw new RotationConfigurationException(
$"Error loading files in directory '{directoryPath}'.",
ex);
}

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);
IEnumerable<PlanConfiguration> planConfigurations = jsonFiles
.Select(file => PlanConfiguration.TryLoadFromFile(file, out var plan) ? plan : null)
.Where(x => x != null)
.Select(x => x!);

return planConfiguration != null
? ResolveRotationPlan(planConfiguration, logger, timeProvider)
: null;
}
if (names.Any())
{
planConfigurations = planConfigurations
.Where(plan => names.Contains(plan.Name, StringComparer.OrdinalIgnoreCase));
}

public IEnumerable<RotationPlan> GetRotationPlans(ILogger logger, IEnumerable<string> secretNames,
TimeProvider timeProvider)
{
var namedPlans = secretNames
.Select(secretName => new
{
SecretName = secretName,
RotationPlan = GetRotationPlan(secretName, logger, timeProvider),
})
.ToArray();
if (tags.Any())
{
planConfigurations = planConfigurations
.Where(plan => tags.All(tag => plan.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)));
}

string[] invalidNames = namedPlans
.Where(x => x.RotationPlan == null)
.Select(x => x.SecretName)
.ToArray();
planConfigurations = planConfigurations.ToArray();

if (invalidNames.Any())
if (!planConfigurations.Any())
{
throw new RotationConfigurationException($"Unknown rotation plan names: '{string.Join("', '", invalidNames)}'");
throw new RotationConfigurationException("Unable to locate any configuration files matching the provided names and tags");
}
return namedPlans
.Select(x => x.RotationPlan!)
.ToArray();

var configuration = new RotationConfiguration(storeFactories, planConfigurations);

return configuration;
}

public IEnumerable<RotationPlan> GetAllRotationPlans(ILogger logger, TimeProvider timeProvider)
Expand Down Expand Up @@ -143,7 +135,7 @@ private IList<SecretStore> GetSecondaryStores(PlanConfiguration planConfiguratio
(StoreConfiguration Configuration, int Index)[] secondaryStoreConfigurations = planConfiguration
.StoreConfigurations
.Select((configuration, index) => (Configuration: configuration, Index: index))
.Where(x => !x.Configuration.IsPrimary && !x.Configuration.IsOrigin)
.Where(x => x.Configuration is { IsPrimary: false, IsOrigin: false })
.ToArray();

foreach ((StoreConfiguration storeConfiguration, int index) in secondaryStoreConfigurations)
Expand Down
Loading

0 comments on commit a370b11

Please sign in to comment.