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 |