diff --git a/MountAws.UnitTests/SecretsManagerTests.cs b/MountAws.UnitTests/SecretsManagerTests.cs new file mode 100644 index 0000000..b32013d --- /dev/null +++ b/MountAws.UnitTests/SecretsManagerTests.cs @@ -0,0 +1,32 @@ +using System; +using AwesomeAssertions; +using MountAnything; +using MountAnything.Routing; +using MountAws.Services.SecretsManager; +using Xunit; + +namespace MountAws.UnitTests; + +public class SecretsManagerTests +{ + private readonly Router _router; + + public SecretsManagerTests() + { + var provider = new MountAwsProvider(); + _router = provider.CreateRouter(); + } + + [Theory] + [InlineData("myprofile/us-east-1/secretsmanager", typeof(SecretsManagerRootHandler))] + [InlineData("myprofile/us-east-1/secretsmanager/secrets", typeof(SecretsHandler))] + [InlineData("myprofile/us-east-1/secretsmanager/secrets/my-secret", typeof(SecretHandler))] + [InlineData("myprofile/us-east-1/secretsmanager/secrets/my-secret/password", typeof(SecretHandler))] + [InlineData("myprofile/us-east-1/secretsmanager/secrets/entsvc/aigenmodel/bria-attribution", typeof(SecretHandler))] + [InlineData("myprofile/us-east-1/secretsmanager/secrets/entsvc/aigenmodel", typeof(SecretHandler))] + public void SecretsManagerRoutesResolveToCorrectHandlers(string path, Type expectedHandlerType) + { + var resolver = _router.GetResolver(new ItemPath(path)); + resolver.HandlerType.Should().Be(expectedHandlerType); + } +} diff --git a/MountAws/MountAws.csproj b/MountAws/MountAws.csproj index 3d2608b..9680e2b 100644 --- a/MountAws/MountAws.csproj +++ b/MountAws/MountAws.csproj @@ -45,6 +45,7 @@ + diff --git a/MountAws/Services/Core/RegionHandler.cs b/MountAws/Services/Core/RegionHandler.cs index b7b37a8..d3d2aa8 100644 --- a/MountAws/Services/Core/RegionHandler.cs +++ b/MountAws/Services/Core/RegionHandler.cs @@ -11,6 +11,7 @@ using MountAws.Services.Lambda; using MountAws.Services.Route53; using MountAws.Services.S3; +using MountAws.Services.SecretsManager; using MountAws.Services.ServiceDiscovery; using MountAws.Services.Wafv2; @@ -49,6 +50,7 @@ protected override IEnumerable GetChildItemsImpl() yield return Services.Rds.RootHandler.CreateItem(Path); yield return Route53RootHandler.CreateItem(Path); yield return S3RootHandler.CreateItem(Path); + yield return SecretsManagerRootHandler.CreateItem(Path); yield return ServiceDiscoveryRootHandler.CreateItem(Path); yield return Wafv2RootHandler.CreateItem(Path); } diff --git a/MountAws/Services/SecretsManager/Formats.ps1xml b/MountAws/Services/SecretsManager/Formats.ps1xml new file mode 100644 index 0000000..8acbc3e --- /dev/null +++ b/MountAws/Services/SecretsManager/Formats.ps1xml @@ -0,0 +1,41 @@ + + + + SecretItem + + MountAws.Services.SecretsManager.SecretItem + + + + + + + + + + + + + + + + + + ItemName + + + ItemType + + + Description + + + LastChangedDate + + + + + + + + diff --git a/MountAws/Services/SecretsManager/Routes.cs b/MountAws/Services/SecretsManager/Routes.cs new file mode 100644 index 0000000..254fdb8 --- /dev/null +++ b/MountAws/Services/SecretsManager/Routes.cs @@ -0,0 +1,17 @@ +using MountAnything.Routing; + +namespace MountAws.Services.SecretsManager; + +public class Routes : IServiceRoutes +{ + public void AddServiceRoutes(Route regionRoute) + { + regionRoute.MapLiteral("secretsmanager", secretsManager => + { + secretsManager.MapLiteral("secrets", secrets => + { + secrets.MapRecursive(); + }); + }); + } +} diff --git a/MountAws/Services/SecretsManager/SecretFolderItem.cs b/MountAws/Services/SecretsManager/SecretFolderItem.cs new file mode 100644 index 0000000..8ef2b96 --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretFolderItem.cs @@ -0,0 +1,13 @@ +using System.Management.Automation; +using MountAnything; + +namespace MountAws.Services.SecretsManager; + +public class SecretFolderItem(ItemPath parentPath, ItemPath directoryPath) + : AwsItem(parentPath.Combine(directoryPath).Parent, new PSObject()) +{ + public override string ItemName { get; } = directoryPath.Name; + public override bool IsContainer => true; + protected override string TypeName => typeof(SecretItem).FullName!; + public override string ItemType => SecretsManagerItemTypes.Directory; +} diff --git a/MountAws/Services/SecretsManager/SecretHandler.cs b/MountAws/Services/SecretsManager/SecretHandler.cs new file mode 100644 index 0000000..c0acd6c --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretHandler.cs @@ -0,0 +1,134 @@ +using System.Management.Automation; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Amazon.SecretsManager; +using MountAnything; +using MountAnything.Content; + +namespace MountAws.Services.SecretsManager; + +public class SecretHandler( + ItemPath path, + IPathHandlerContext context, + IAmazonSecretsManager secretsManager, + SecretNavigator navigator, + SecretPath secretPath) + : PathHandler(path, context), IContentReaderHandler, IContentWriterHandler, ISetItemPropertiesHandler +{ + protected override IItem? GetItemImpl() + { + var childSecrets = secretsManager + .ListSecrets(secretPath.Path.FullName) + .ToArray(); + + if (childSecrets.Length == 1 && childSecrets[0].Name == secretPath.Path.FullName) + { + return new SecretItem(ParentPath, childSecrets[0]); + } + if (childSecrets.Any()) + { + return new SecretFolderItem(ParentPath, secretPath.Path); + } + + return null; + } + + protected override IEnumerable GetChildItemsImpl() + { + return GetItem() switch + { + SecretFolderItem => navigator.ListChildItems(Path, secretPath.Path), + _ => Enumerable.Empty() + }; + } + + public IStreamContentReader GetContentReader() + { + var secretString = GetSecretString() + ?? throw new InvalidOperationException("Secret does not contain a string value"); + return new StreamContentReader(new MemoryStream(Encoding.UTF8.GetBytes(secretString))); + } + + public IStreamContentWriter GetContentWriter() + { + return new StreamContentWriter(stream => + { + using var reader = new StreamReader(stream, Encoding.UTF8); + var secretString = reader.ReadToEnd(); + secretsManager.PutSecretValue(secretPath.Path.FullName, secretString); + }); + } + + public override IEnumerable GetItemProperties(HashSet propertyNames, Func pathResolver) + { + var secretString = GetSecretString(); + if (secretString == null || !TryParseJsonObject(secretString, out var properties)) + { + return base.GetItemProperties(propertyNames, pathResolver); + } + + var psObject = new PSObject(); + foreach (var property in properties) + { + psObject.Properties.Add(new PSNoteProperty(property.Key, property.Value)); + } + return psObject.AsItemProperties().WherePropertiesMatch(propertyNames); + } + + public void SetItemProperties(ICollection propertyValues) + { + var secretString = GetSecretString() + ?? throw new InvalidOperationException("Secret does not contain a string value"); + + var jsonNode = JsonNode.Parse(secretString) + ?? throw new InvalidOperationException("Secret value is not valid JSON"); + + if (jsonNode is not JsonObject jsonObject) + { + throw new InvalidOperationException("Secret value is not a JSON object"); + } + + foreach (var property in propertyValues) + { + jsonObject[property.Name] = JsonValue.Create(property.Value?.ToString()); + } + + secretsManager.PutSecretValue(secretPath.Path.FullName, jsonObject.ToJsonString()); + } + + private string? GetSecretString() + { + try + { + var response = secretsManager.GetSecretValue(secretPath.Path.FullName); + return response.SecretString; + } + catch + { + return null; + } + } + + private static bool TryParseJsonObject(string value, out Dictionary properties) + { + properties = new Dictionary(); + try + { + using var doc = JsonDocument.Parse(value); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return false; + + foreach (var property in doc.RootElement.EnumerateObject()) + { + properties[property.Name] = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString()! + : property.Value.GetRawText(); + } + return true; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/MountAws/Services/SecretsManager/SecretItem.cs b/MountAws/Services/SecretsManager/SecretItem.cs new file mode 100644 index 0000000..9a36512 --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretItem.cs @@ -0,0 +1,23 @@ +using System.Management.Automation; +using Amazon.SecretsManager.Model; +using MountAnything; + +namespace MountAws.Services.SecretsManager; + +public class SecretItem(ItemPath parentPath, SecretListEntry secret) : AwsItem(parentPath, new PSObject(secret)) +{ + public override string ItemName { get; } = new ItemPath(secret.Name).Name; + public override bool IsContainer => false; + protected override string TypeName => GetType().FullName!; + public override string ItemType => SecretsManagerItemTypes.Secret; + + [ItemProperty] + public string Name => ItemName; + [ItemProperty] + public string SecretName => secret.Name; + [ItemProperty] + public string Arn => secret.ARN; + + public override string? WebUrl => + UrlBuilder.CombineWith($"secretsmanager/secret?name={Uri.EscapeDataString(SecretName)}"); +} diff --git a/MountAws/Services/SecretsManager/SecretNavigator.cs b/MountAws/Services/SecretsManager/SecretNavigator.cs new file mode 100644 index 0000000..b5c876b --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretNavigator.cs @@ -0,0 +1,40 @@ +using Amazon.SecretsManager; +using Amazon.SecretsManager.Model; +using MountAnything; + +namespace MountAws.Services.SecretsManager; + +public class SecretNavigator : ItemNavigator +{ + private readonly IAmazonSecretsManager _secretsManager; + + public SecretNavigator(IAmazonSecretsManager secretsManager) + { + _secretsManager = secretsManager; + } + + protected override IItem CreateDirectoryItem(ItemPath parentPath, ItemPath directoryPath) + { + return new SecretFolderItem(parentPath, directoryPath); + } + + protected override IItem CreateItem(ItemPath parentPath, SecretListEntry model) + { + return new SecretItem(parentPath, model); + } + + protected override ItemPath GetPath(SecretListEntry model) + { + return new ItemPath(model.Name); + } + + protected override IEnumerable ListItems(ItemPath? pathPrefix) + { + if (pathPrefix == null || pathPrefix.IsRoot) + { + return _secretsManager.ListSecrets(); + } + + return _secretsManager.ListSecrets(pathPrefix.FullName); + } +} diff --git a/MountAws/Services/SecretsManager/SecretPath.cs b/MountAws/Services/SecretsManager/SecretPath.cs new file mode 100644 index 0000000..5756bf7 --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretPath.cs @@ -0,0 +1,8 @@ +using MountAnything; + +namespace MountAws.Services.SecretsManager; + +public class SecretPath : TypedItemPath +{ + public SecretPath(ItemPath path) : base(path) { } +} diff --git a/MountAws/Services/SecretsManager/SecretsHandler.cs b/MountAws/Services/SecretsManager/SecretsHandler.cs new file mode 100644 index 0000000..d591bbf --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretsHandler.cs @@ -0,0 +1,30 @@ +using MountAnything; +using MountAws.Services.Core; + +namespace MountAws.Services.SecretsManager; + +public class SecretsHandler : PathHandler +{ + private readonly SecretNavigator _navigator; + + public static IItem CreateItem(ItemPath parentPath) + { + return new GenericContainerItem(parentPath, "secrets", + "Navigate secrets as a virtual filesystem"); + } + + public SecretsHandler(ItemPath path, IPathHandlerContext context, SecretNavigator navigator) : base(path, context) + { + _navigator = navigator; + } + + protected override IItem? GetItemImpl() + { + return CreateItem(ParentPath); + } + + protected override IEnumerable GetChildItemsImpl() + { + return _navigator.ListChildItems(Path); + } +} diff --git a/MountAws/Services/SecretsManager/SecretsManagerApiExtensions.cs b/MountAws/Services/SecretsManager/SecretsManagerApiExtensions.cs new file mode 100644 index 0000000..c6760a1 --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretsManagerApiExtensions.cs @@ -0,0 +1,60 @@ +using Amazon.SecretsManager; +using Amazon.SecretsManager.Model; +using static MountAws.PagingHelper; + +namespace MountAws.Services.SecretsManager; + +public static class SecretsManagerApiExtensions +{ + public static IEnumerable ListSecrets(this IAmazonSecretsManager secretsManager) + { + return Paginate(nextToken => + { + var response = secretsManager.ListSecretsAsync(new ListSecretsRequest + { + NextToken = nextToken + }).GetAwaiter().GetResult(); + + return (response.SecretList, response.NextToken); + }); + } + + public static IEnumerable ListSecrets(this IAmazonSecretsManager secretsManager, string pathPrefix) + { + return Paginate(nextToken => + { + var response = secretsManager.ListSecretsAsync(new ListSecretsRequest + { + Filters = [ + new Filter + { + Key = FilterNameStringType.Name, + Values = [ + pathPrefix + ] + } + ], + NextToken = nextToken + }).GetAwaiter().GetResult(); + + return (response.SecretList, response.NextToken); + }); + } + + public static GetSecretValueResponse GetSecretValue(this IAmazonSecretsManager secretsManager, string secretId) + { + return secretsManager.GetSecretValueAsync(new GetSecretValueRequest + { + SecretId = secretId + }).GetAwaiter().GetResult(); + } + + public static void PutSecretValue(this IAmazonSecretsManager secretsManager, string secretId, string secretString) + { + secretsManager.PutSecretValueAsync(new PutSecretValueRequest + { + SecretId = secretId, + SecretString = secretString + }).GetAwaiter().GetResult(); + } +} diff --git a/MountAws/Services/SecretsManager/SecretsManagerItemTypes.cs b/MountAws/Services/SecretsManager/SecretsManagerItemTypes.cs new file mode 100644 index 0000000..ae5eb60 --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretsManagerItemTypes.cs @@ -0,0 +1,7 @@ +namespace MountAws.Services.SecretsManager; + +public static class SecretsManagerItemTypes +{ + public const string Directory = "Directory"; + public const string Secret = "Secret"; +} \ No newline at end of file diff --git a/MountAws/Services/SecretsManager/SecretsManagerRegistrar.cs b/MountAws/Services/SecretsManager/SecretsManagerRegistrar.cs new file mode 100644 index 0000000..0549b42 --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretsManagerRegistrar.cs @@ -0,0 +1,15 @@ +using Amazon; +using Amazon.SecretsManager; +using Amazon.Runtime; +using Autofac; + +namespace MountAws.Services.SecretsManager; + +public class SecretsManagerRegistrar : IServiceRegistrar +{ + public void Register(ContainerBuilder builder) + { + builder.RegisterType().As() + .UsingConstructor(typeof(AWSCredentials), typeof(RegionEndpoint)); + } +} diff --git a/MountAws/Services/SecretsManager/SecretsManagerRootHandler.cs b/MountAws/Services/SecretsManager/SecretsManagerRootHandler.cs new file mode 100644 index 0000000..c55174a --- /dev/null +++ b/MountAws/Services/SecretsManager/SecretsManagerRootHandler.cs @@ -0,0 +1,27 @@ +using MountAnything; +using MountAws.Services.Core; + +namespace MountAws.Services.SecretsManager; + +public class SecretsManagerRootHandler : PathHandler +{ + public static IItem CreateItem(ItemPath parentPath) + { + return new GenericContainerItem(parentPath, "secretsmanager", + "Navigate AWS Secrets Manager secrets in a virtual filesystem"); + } + + public SecretsManagerRootHandler(ItemPath path, IPathHandlerContext context) : base(path, context) + { + } + + protected override IItem? GetItemImpl() + { + return CreateItem(ParentPath); + } + + protected override IEnumerable GetChildItemsImpl() + { + yield return SecretsHandler.CreateItem(Path); + } +} diff --git a/README.md b/README.md index 23a7d74..af6869c 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ The following services are currently supported: * [RDS](docs/Services/RDS.md) * [Route53](docs/Services/Route53.md) * [S3](docs/Services/S3.md) + * [Secrets Manager](docs/Services/SecretsManager.md) * [Service Discovery (aka Cloud Map)](docs/Services/ServiceDiscovery.md) * [WAFv2](docs/Services/WAFv2.md) diff --git a/docs/Services/SecretsManager.md b/docs/Services/SecretsManager.md new file mode 100644 index 0000000..ca66789 --- /dev/null +++ b/docs/Services/SecretsManager.md @@ -0,0 +1,70 @@ +# Secrets Manager on MountAws + +## Path Hierarchy + +``` +-- secretsmanager + |-- secrets + |-- my-secret # A secret with a simple name + |-- my-app # A virtual folder (prefix of other secrets) + |-- prod # Another virtual folder + |-- database-credentials # A secret named "my-app/prod/database-credentials" +``` + +Secret names containing `/` are automatically organized into a virtual folder hierarchy, so you can browse them like a directory tree. + +## Browsing Secrets + +```powershell +# List all top-level secrets and folders +dir aws:/default/us-east-1/secretsmanager/secrets + +# Navigate into a folder +cd aws:/default/us-east-1/secretsmanager/secrets/my-app/prod +dir +``` + +## Reading Secret Values + +Use `Get-Content` to read the raw secret string value: + +```powershell +Get-Content aws:/default/us-east-1/secretsmanager/secrets/my-secret +``` + +For secrets that contain a JSON object, use `Get-ItemProperty` to read individual key/value pairs: + +```powershell +# Get all properties +Get-ItemProperty aws:/default/us-east-1/secretsmanager/secrets/my-app/prod/database-credentials + +# Get a specific property +Get-ItemProperty aws:/default/us-east-1/secretsmanager/secrets/my-app/prod/database-credentials -Name password +``` + +## Writing Secret Values + +Use `Set-Content` to write a new raw secret string value: + +```powershell +Set-Content aws:/default/us-east-1/secretsmanager/secrets/my-secret -Value "new-value" +``` + +For JSON secrets, use `Set-ItemProperty` to update individual keys without overwriting the entire secret: + +```powershell +Set-ItemProperty aws:/default/us-east-1/secretsmanager/secrets/my-app/prod/database-credentials -Name password -Value "new-password" +``` + +## Item Properties + +Each secret item contains all of the properties contained on a [SecretListEntry](https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_SecretListEntry.html). This includes the following properties: + +| Property | Description | +|---|---| +| Name | Last segment of the secret name | +| SecretName | Full secret name (e.g., `my-app/prod/database-credentials`) | +| Description | Secret description | +| Arn | Secret ARN | +| LastChangedDate | When the secret was last modified | +| CreatedDate | When the secret was created |