diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1a5e3..251ad61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos ## Unreleased +## 2026.06.03.2207 + +- Build produced from commit 09c3d5c68bbc. +- **M9 — Bulk, Duplicate & Inheritance**: + - **Bulk parameter sets** added to `New-InfisicalSecret`, `Update-InfisicalSecret`, and `Remove-InfisicalSecret` accepting `-Secrets Hashtable[]`; client methods `CreateBatch`/`UpdateBatch`/`DeleteBatch` wrap `POST|PATCH|DELETE /api/v3/secrets/batch/raw`. + - **`Copy-InfisicalSecret`** cmdlet added, wrapping `POST /api/v4/secrets/duplicate` with source/destination environment + path parameters and per-attribute copy toggles. + - **Connection inheritance** centralized in `InfisicalCmdletBase` (`ResolveProjectId`/`ResolveEnvironment`/`ResolveSecretPath`/`ResolveApiVersion`/`ResolveOrganizationId`). Explicit parameters always win; missing values fall back to the active connection and emit a `-Verbose` line. + - Project/Environment/Folder/Tag and all secret cmdlets refactored to use the inheritance helpers; existing explicit-parameter behavior is preserved. + - `InfisicalBulkSecretConverter` accepts flexible key aliases (`SecretName`/`Name`/`Key`, `SecretValue`/`Value`, `SecretComment`/`Comment`, `Metadata`/`SecretMetadata`). + - Test count: 161 (up from 139). Added coverage for bulk DTO shapes, the converter, the duplicate request DTO, registry entries for the four new endpoints, and the resolution helpers. + +## Unreleased (carried forward) + +## 2026.06.03.2206 + +- Build produced from commit 09c3d5c68bbc. + +## Unreleased (carried forward) + ## 2026.06.03.2136 - Build produced from commit d9822aab7a4a. diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index 3e80f15..bd0557e 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PSInfisicalAPI.psm1' - ModuleVersion = '2026.06.03.2136' + ModuleVersion = '2026.06.03.2207' GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51' Author = 'Grace Solutions' CompanyName = 'Grace Solutions' @@ -17,6 +17,7 @@ 'New-InfisicalSecret', 'Update-InfisicalSecret', 'Remove-InfisicalSecret', + 'Copy-InfisicalSecret', 'ConvertTo-InfisicalSecretDictionary', 'Export-InfisicalSecrets', 'Get-InfisicalProjects', @@ -50,7 +51,7 @@ LicenseUri = 'https://www.gnu.org/licenses/agpl-3.0.html' ProjectUri = 'https://prod.git.gracesolution.info/gsadmin/PSInfisicalAPI' ReleaseNotes = 'See CHANGELOG.md in the project repository for release history.' - CommitHash = 'd9822aab7a4a' + CommitHash = '09c3d5c68bbc' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll index 72da4ed..ca605fc 100644 Binary files a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll and b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll differ diff --git a/build.ps1 b/build.ps1 index c1457a1..6a0066d 100644 --- a/build.ps1 +++ b/build.ps1 @@ -105,6 +105,7 @@ function Write-Manifest { 'New-InfisicalSecret', 'Update-InfisicalSecret', 'Remove-InfisicalSecret', + 'Copy-InfisicalSecret', 'ConvertTo-InfisicalSecretDictionary', 'Export-InfisicalSecrets', 'Get-InfisicalProjects', diff --git a/docs/DesignSpec.md b/docs/DesignSpec.md index 8b1a552..a7124fd 100644 --- a/docs/DesignSpec.md +++ b/docs/DesignSpec.md @@ -16,6 +16,7 @@ Get-InfisicalSecret New-InfisicalSecret Update-InfisicalSecret Remove-InfisicalSecret +Copy-InfisicalSecret ConvertTo-InfisicalSecretDictionary Export-InfisicalSecrets Get-InfisicalProjects @@ -224,6 +225,7 @@ Example shape: 'New-InfisicalSecret', 'Update-InfisicalSecret', 'Remove-InfisicalSecret', + 'Copy-InfisicalSecret', 'ConvertTo-InfisicalSecretDictionary', 'Export-InfisicalSecrets', 'Get-InfisicalProjects', diff --git a/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs b/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs new file mode 100644 index 0000000..7972862 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs @@ -0,0 +1,96 @@ +using System.Collections; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Secrets; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class BulkSecretConverterTests + { + [Fact] + public void ToCreateItems_Maps_Standard_Keys() + { + Hashtable entry = new Hashtable + { + { "SecretName", "API_KEY" }, + { "SecretValue", "abc" }, + { "SecretComment", "primary" }, + { "SkipMultilineEncoding", true }, + { "TagIds", new[] { "tag-1", "tag-2" } } + }; + + InfisicalBulkCreateSecretItem[] items = InfisicalBulkSecretConverter.ToCreateItems(new[] { entry }); + Assert.Single(items); + Assert.Equal("API_KEY", items[0].SecretName); + Assert.Equal("abc", items[0].SecretValue); + Assert.Equal("primary", items[0].SecretComment); + Assert.True(items[0].SkipMultilineEncoding); + Assert.Equal(new[] { "tag-1", "tag-2" }, items[0].TagIds); + } + + [Fact] + public void ToCreateItems_Accepts_Name_Alias_For_SecretName() + { + Hashtable entry = new Hashtable + { + { "Name", "API_KEY" }, + { "Value", "abc" } + }; + + InfisicalBulkCreateSecretItem[] items = InfisicalBulkSecretConverter.ToCreateItems(new[] { entry }); + Assert.Single(items); + Assert.Equal("API_KEY", items[0].SecretName); + Assert.Equal("abc", items[0].SecretValue); + } + + [Fact] + public void ToCreateItems_Without_SecretName_Throws() + { + Hashtable entry = new Hashtable { { "Value", "abc" } }; + + Assert.Throws(() => + InfisicalBulkSecretConverter.ToCreateItems(new[] { entry })); + } + + [Fact] + public void ToUpdateItems_Maps_NewSecretName() + { + Hashtable entry = new Hashtable + { + { "SecretName", "API_KEY" }, + { "NewSecretName", "RENAMED" }, + { "SecretValue", "new-value" } + }; + + InfisicalBulkUpdateSecretItem[] items = InfisicalBulkSecretConverter.ToUpdateItems(new[] { entry }); + Assert.Single(items); + Assert.Equal("API_KEY", items[0].SecretName); + Assert.Equal("RENAMED", items[0].NewSecretName); + Assert.Equal("new-value", items[0].SecretValue); + } + + [Fact] + public void ToCreateItems_Maps_Metadata_Dictionary() + { + Hashtable meta = new Hashtable { { "owner", "platform" }, { "tier", "1" } }; + Hashtable entry = new Hashtable + { + { "SecretName", "API_KEY" }, + { "SecretValue", "abc" }, + { "Metadata", meta } + }; + + InfisicalBulkCreateSecretItem[] items = InfisicalBulkSecretConverter.ToCreateItems(new[] { entry }); + Assert.NotNull(items[0].SecretMetadata); + Assert.Equal("platform", items[0].SecretMetadata["owner"]); + Assert.Equal("1", items[0].SecretMetadata["tier"]); + } + + [Fact] + public void ToCreateItems_Empty_Input_Returns_Empty() + { + InfisicalBulkCreateSecretItem[] items = InfisicalBulkSecretConverter.ToCreateItems(null); + Assert.Empty(items); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/BulkSecretDtoTests.cs b/src/PSInfisicalAPI.Tests/BulkSecretDtoTests.cs new file mode 100644 index 0000000..b4a4474 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/BulkSecretDtoTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class BulkSecretDtoTests + { + private static readonly System.Reflection.Assembly ModuleAssembly = + typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly; + + private static object MakeDto(string typeName) + { + System.Type t = ModuleAssembly.GetType(typeName, true); + return System.Activator.CreateInstance(t); + } + + [Fact] + public void BatchCreateItem_Serializes_With_Expected_Field_Names() + { + object item = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretBatchCreateItemDto"); + item.GetType().GetProperty("SecretKey").SetValue(item, "API_KEY"); + item.GetType().GetProperty("SecretValue").SetValue(item, "abc"); + + JObject json = JObject.Parse(JsonConvert.SerializeObject(item)); + Assert.Equal("API_KEY", (string)json["secretKey"]); + Assert.Equal("abc", (string)json["secretValue"]); + Assert.False(json.ContainsKey("skipMultilineEncoding")); + Assert.False(json.ContainsKey("tagIds")); + Assert.False(json.ContainsKey("secretMetadata")); + } + + [Fact] + public void BatchUpdateItem_Omits_Null_Optional_Fields() + { + object item = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretBatchUpdateItemDto"); + item.GetType().GetProperty("SecretKey").SetValue(item, "API_KEY"); + item.GetType().GetProperty("NewSecretName").SetValue(item, "RENAMED"); + + JObject json = JObject.Parse(JsonConvert.SerializeObject(item)); + Assert.Equal("API_KEY", (string)json["secretKey"]); + Assert.Equal("RENAMED", (string)json["newSecretName"]); + Assert.False(json.ContainsKey("secretValue")); + Assert.False(json.ContainsKey("secretComment")); + } + + [Fact] + public void BatchCreateRequest_Serializes_With_Expected_Envelope() + { + System.Type itemType = ModuleAssembly.GetType("PSInfisicalAPI.Secrets.InfisicalSecretBatchCreateItemDto", true); + object item = System.Activator.CreateInstance(itemType); + itemType.GetProperty("SecretKey").SetValue(item, "K1"); + itemType.GetProperty("SecretValue").SetValue(item, "V1"); + + System.Type listType = typeof(List<>).MakeGenericType(itemType); + object list = System.Activator.CreateInstance(listType); + listType.GetMethod("Add").Invoke(list, new object[] { item }); + + object dto = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretBatchCreateRequestDto"); + dto.GetType().GetProperty("WorkspaceId").SetValue(dto, "wks-1"); + dto.GetType().GetProperty("Environment").SetValue(dto, "prod"); + dto.GetType().GetProperty("SecretPath").SetValue(dto, "/db"); + dto.GetType().GetProperty("Secrets").SetValue(dto, list); + + JObject json = JObject.Parse(JsonConvert.SerializeObject(dto)); + Assert.Equal("wks-1", (string)json["workspaceId"]); + Assert.Equal("prod", (string)json["environment"]); + Assert.Equal("/db", (string)json["secretPath"]); + JArray secrets = (JArray)json["secrets"]; + Assert.Single(secrets); + Assert.Equal("K1", (string)secrets[0]["secretKey"]); + Assert.Equal("V1", (string)secrets[0]["secretValue"]); + } + + [Fact] + public void BatchDeleteRequest_Serializes_With_Secret_Keys() + { + System.Type itemType = ModuleAssembly.GetType("PSInfisicalAPI.Secrets.InfisicalSecretBatchDeleteItemDto", true); + object item = System.Activator.CreateInstance(itemType); + itemType.GetProperty("SecretKey").SetValue(item, "K1"); + + System.Type listType = typeof(List<>).MakeGenericType(itemType); + object list = System.Activator.CreateInstance(listType); + listType.GetMethod("Add").Invoke(list, new object[] { item }); + + object dto = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretBatchDeleteRequestDto"); + dto.GetType().GetProperty("WorkspaceId").SetValue(dto, "wks-1"); + dto.GetType().GetProperty("Environment").SetValue(dto, "prod"); + dto.GetType().GetProperty("Secrets").SetValue(dto, list); + + JObject json = JObject.Parse(JsonConvert.SerializeObject(dto)); + Assert.Equal("wks-1", (string)json["workspaceId"]); + JArray secrets = (JArray)json["secrets"]; + Assert.Single(secrets); + Assert.Equal("K1", (string)secrets[0]["secretKey"]); + } + + [Fact] + public void DuplicateRequest_Serializes_With_Expected_Field_Names() + { + object dto = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretDuplicateRequestDto"); + dto.GetType().GetProperty("ProjectId").SetValue(dto, "wks-1"); + dto.GetType().GetProperty("SourceEnvironment").SetValue(dto, "dev"); + dto.GetType().GetProperty("DestinationEnvironment").SetValue(dto, "prod"); + dto.GetType().GetProperty("SourceSecretPath").SetValue(dto, "/db"); + dto.GetType().GetProperty("DestinationSecretPath").SetValue(dto, "/db"); + dto.GetType().GetProperty("SecretIds").SetValue(dto, new[] { "id-1", "id-2" }); + dto.GetType().GetProperty("OverwriteExisting").SetValue(dto, true); + + JObject json = JObject.Parse(JsonConvert.SerializeObject(dto)); + Assert.Equal("wks-1", (string)json["projectId"]); + Assert.Equal("dev", (string)json["sourceEnvironment"]); + Assert.Equal("prod", (string)json["destinationEnvironment"]); + Assert.Equal("/db", (string)json["sourceSecretPath"]); + Assert.Equal("/db", (string)json["destinationSecretPath"]); + Assert.True((bool)json["overwriteExisting"]); + JArray ids = (JArray)json["secretIds"]; + Assert.Equal(2, ids.Count); + Assert.Equal("id-1", (string)ids[0]); + Assert.False(json.ContainsKey("attributesToCopy")); + } + + [Fact] + public void DuplicateAttributes_Omits_Null_Toggles() + { + object attrs = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretDuplicateAttributesDto"); + attrs.GetType().GetProperty("SecretValue").SetValue(attrs, true); + + JObject json = JObject.Parse(JsonConvert.SerializeObject(attrs)); + Assert.True((bool)json["secretValue"]); + Assert.False(json.ContainsKey("secretComment")); + Assert.False(json.ContainsKey("tags")); + Assert.False(json.ContainsKey("metadata")); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/CmdletBaseInheritanceTests.cs b/src/PSInfisicalAPI.Tests/CmdletBaseInheritanceTests.cs new file mode 100644 index 0000000..6ae42cd --- /dev/null +++ b/src/PSInfisicalAPI.Tests/CmdletBaseInheritanceTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Reflection; +using PSInfisicalAPI.Cmdlets; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Models; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class CmdletBaseInheritanceTests + { + private sealed class RecordingLogger : IInfisicalLogger + { + public List VerboseEntries { get; } = new List(); + + public void Information(string component, string message) { } + public void Verbose(string component, string message) { VerboseEntries.Add(message); } + public void Debug(string component, string message) { } + public void Warning(string component, string message) { } + public void Error(string component, string message) { } + } + + [Cmdlet(VerbsCommon.Get, "TestCmdlet")] + private sealed class TestCmdlet : InfisicalCmdletBase + { + public string CallResolveProjectId(InfisicalConnection connection, string explicitValue) + { + return ResolveProjectId(connection, explicitValue); + } + + public string CallResolveEnvironment(InfisicalConnection connection, string explicitValue) + { + return ResolveEnvironment(connection, explicitValue); + } + + public string CallResolveSecretPath(InfisicalConnection connection, string explicitValue) + { + return ResolveSecretPath(connection, explicitValue); + } + + public string CallResolveApiVersion(InfisicalConnection connection, string explicitValue) + { + return ResolveApiVersion(connection, explicitValue); + } + + public string CallResolveOrganizationId(InfisicalConnection connection, string explicitValue) + { + return ResolveOrganizationId(connection, explicitValue); + } + } + + private static TestCmdlet CreateCmdletWith(RecordingLogger logger) + { + TestCmdlet cmdlet = new TestCmdlet(); + FieldInfo field = typeof(InfisicalCmdletBase).GetField("_logger", BindingFlags.NonPublic | BindingFlags.Instance); + field.SetValue(cmdlet, logger); + return cmdlet; + } + + private static InfisicalConnection ConnectionWithDefaults() + { + return new InfisicalConnection + { + BaseUri = new Uri("https://app.example.com"), + ProjectId = "proj-conn", + Environment = "prod-conn", + DefaultSecretPath = "/db", + OrganizationId = "org-conn", + PinnedApiVersion = "v3" + }; + } + + [Fact] + public void Explicit_Value_Overrides_Connection_And_Does_Not_Log() + { + RecordingLogger logger = new RecordingLogger(); + TestCmdlet cmdlet = CreateCmdletWith(logger); + + string resolved = cmdlet.CallResolveProjectId(ConnectionWithDefaults(), "explicit-proj"); + Assert.Equal("explicit-proj", resolved); + Assert.Empty(logger.VerboseEntries); + } + + [Fact] + public void Missing_Value_Inherits_From_Connection_And_Logs() + { + RecordingLogger logger = new RecordingLogger(); + TestCmdlet cmdlet = CreateCmdletWith(logger); + + string resolved = cmdlet.CallResolveProjectId(ConnectionWithDefaults(), null); + Assert.Equal("proj-conn", resolved); + Assert.Single(logger.VerboseEntries); + Assert.Contains("Inherited ProjectId", logger.VerboseEntries[0]); + Assert.Contains("proj-conn", logger.VerboseEntries[0]); + } + + [Fact] + public void ResolveSecretPath_Defaults_To_Root_When_Connection_Has_No_Default() + { + RecordingLogger logger = new RecordingLogger(); + TestCmdlet cmdlet = CreateCmdletWith(logger); + + InfisicalConnection bareConnection = new InfisicalConnection { BaseUri = new Uri("https://app.example.com") }; + string resolved = cmdlet.CallResolveSecretPath(bareConnection, null); + Assert.Equal("/", resolved); + } + + [Fact] + public void ResolveSecretPath_Inherits_From_Connection_When_Set() + { + RecordingLogger logger = new RecordingLogger(); + TestCmdlet cmdlet = CreateCmdletWith(logger); + + string resolved = cmdlet.CallResolveSecretPath(ConnectionWithDefaults(), null); + Assert.Equal("/db", resolved); + Assert.Contains(logger.VerboseEntries, v => v.Contains("SecretPath") && v.Contains("/db")); + } + + [Fact] + public void ResolveApiVersion_Prefers_PinnedApiVersion_From_Connection() + { + RecordingLogger logger = new RecordingLogger(); + TestCmdlet cmdlet = CreateCmdletWith(logger); + + string resolved = cmdlet.CallResolveApiVersion(ConnectionWithDefaults(), null); + Assert.Equal("v3", resolved); + } + + [Fact] + public void ResolveEnvironment_And_ResolveOrganizationId_Inherit() + { + RecordingLogger logger = new RecordingLogger(); + TestCmdlet cmdlet = CreateCmdletWith(logger); + + Assert.Equal("prod-conn", cmdlet.CallResolveEnvironment(ConnectionWithDefaults(), null)); + Assert.Equal("org-conn", cmdlet.CallResolveOrganizationId(ConnectionWithDefaults(), null)); + Assert.Equal(2, logger.VerboseEntries.Count); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs b/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs index adeccf3..d931695 100644 --- a/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs +++ b/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs @@ -71,6 +71,10 @@ namespace PSInfisicalAPI.Tests [InlineData(InfisicalEndpointNames.LdapAuthLogin, "POST", "/api/v1/auth/ldap-auth/login")] [InlineData(InfisicalEndpointNames.AzureAuthLogin, "POST", "/api/v1/auth/azure-auth/login")] [InlineData(InfisicalEndpointNames.GcpIamAuthLogin, "POST", "/api/v1/auth/gcp-auth/login")] + [InlineData(InfisicalEndpointNames.BulkCreateSecret, "POST", "/api/v3/secrets/batch/raw")] + [InlineData(InfisicalEndpointNames.BulkUpdateSecret, "PATCH", "/api/v3/secrets/batch/raw")] + [InlineData(InfisicalEndpointNames.BulkDeleteSecret, "DELETE", "/api/v3/secrets/batch/raw")] + [InlineData(InfisicalEndpointNames.DuplicateSecret, "POST", "/api/v4/secrets/duplicate")] public void Registered_Endpoints_Have_Expected_Shape(string name, string method, string template) { InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(name); diff --git a/src/PSInfisicalAPI/Cmdlets/CopyInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/CopyInfisicalSecretCmdlet.cs new file mode 100644 index 0000000..ce4baea --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/CopyInfisicalSecretCmdlet.cs @@ -0,0 +1,75 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Secrets; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Copy, "InfisicalSecret", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalSecret))] + public sealed class CopyInfisicalSecretCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)] + [Alias("Id", "SecretIds")] + public string[] SecretId { get; set; } + + [Parameter(Mandatory = true)] + public string DestinationEnvironment { get; set; } + + [Parameter] public string DestinationSecretPath { get; set; } + [Parameter] public string SourceEnvironment { get; set; } + [Parameter] public string SourceSecretPath { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public string ApiVersion { get; set; } + [Parameter] public SwitchParameter OverwriteExisting { get; set; } + [Parameter] public SwitchParameter CopySecretValue { get; set; } + [Parameter] public SwitchParameter CopySecretComment { get; set; } + [Parameter] public SwitchParameter CopyTags { get; set; } + [Parameter] public SwitchParameter CopyMetadata { get; set; } + + protected override void ProcessRecord() + { + try + { + if (SecretId == null || SecretId.Length == 0) { return; } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + string resolvedSourceEnv = ResolveEnvironment(connection, SourceEnvironment); + string resolvedSourcePath = ResolveSecretPath(connection, SourceSecretPath); + string resolvedApiVersion = ResolveApiVersion(connection, ApiVersion); + + string target = string.Concat(SecretId.Length, " secret(s) -> ", DestinationEnvironment); + if (!ShouldProcess(target, "Duplicate Infisical secrets")) { return; } + + InfisicalDuplicateSecretsRequest request = new InfisicalDuplicateSecretsRequest + { + ProjectId = resolvedProjectId, + SourceEnvironment = resolvedSourceEnv, + DestinationEnvironment = DestinationEnvironment, + SourceSecretPath = resolvedSourcePath, + DestinationSecretPath = DestinationSecretPath, + SecretIds = SecretId, + ApiVersion = resolvedApiVersion, + OverwriteExisting = OverwriteExisting.IsPresent ? (bool?)true : null, + CopySecretValue = CopySecretValue.IsPresent ? (bool?)true : null, + CopySecretComment = CopySecretComment.IsPresent ? (bool?)true : null, + CopyTags = CopyTags.IsPresent ? (bool?)true : null, + CopyMetadata = CopyMetadata.IsPresent ? (bool?)true : null + }; + + InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger); + InfisicalSecret[] duplicated = client.Duplicate(connection, request); + if (duplicated != null) + { + foreach (InfisicalSecret secret in duplicated) { WriteObject(secret); } + } + } + catch (Exception exception) + { + ThrowTerminatingForException("CopyInfisicalSecretCmdlet", "DuplicateSecrets", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentCmdlet.cs index e7f3d59..728ec32 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentCmdlet.cs @@ -21,8 +21,9 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); - InfisicalEnvironment env = client.Retrieve(connection, ProjectId, EnvironmentSlugOrId); + InfisicalEnvironment env = client.Retrieve(connection, resolvedProjectId, EnvironmentSlugOrId); if (env != null) { WriteObject(env); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentsCmdlet.cs index 23879d9..2128cda 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentsCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentsCmdlet.cs @@ -17,8 +17,9 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); - InfisicalEnvironment[] envs = client.List(connection, ProjectId); + InfisicalEnvironment[] envs = client.List(connection, resolvedProjectId); foreach (InfisicalEnvironment env in envs) { WriteObject(env); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs index 30da2af..22ff5ee 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs @@ -23,8 +23,11 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + string resolvedEnvironment = ResolveEnvironment(connection, Environment); + string resolvedPath = ResolveSecretPath(connection, Path); InfisicalFolderClient client = new InfisicalFolderClient(HttpClient, Logger); - InfisicalFolder folder = client.Retrieve(connection, ProjectId, Environment, Path, FolderNameOrId); + InfisicalFolder folder = client.Retrieve(connection, resolvedProjectId, resolvedEnvironment, resolvedPath, FolderNameOrId); if (folder != null) { WriteObject(folder); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalFoldersCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFoldersCmdlet.cs index 7c64a2b..4c3b2a6 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalFoldersCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFoldersCmdlet.cs @@ -19,8 +19,11 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + string resolvedEnvironment = ResolveEnvironment(connection, Environment); + string resolvedPath = ResolveSecretPath(connection, Path); InfisicalFolderClient client = new InfisicalFolderClient(HttpClient, Logger); - InfisicalFolder[] folders = client.List(connection, ProjectId, Environment, Path); + InfisicalFolder[] folders = client.List(connection, resolvedProjectId, resolvedEnvironment, resolvedPath); foreach (InfisicalFolder folder in folders) { WriteObject(folder); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs index 979928d..93ec71e 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs @@ -10,7 +10,7 @@ namespace PSInfisicalAPI.Cmdlets [OutputType(typeof(InfisicalProject))] public sealed class GetInfisicalProjectCmdlet : InfisicalCmdletBase { - [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Parameter(ValueFromPipelineByPropertyName = true, Position = 0)] [Alias("Id")] public string ProjectId { get; set; } @@ -19,8 +19,9 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger); - InfisicalProject project = client.Retrieve(connection, ProjectId); + InfisicalProject project = client.Retrieve(connection, resolvedProjectId); if (project != null) { WriteObject(project); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs index eae5ea5..2493296 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs @@ -32,10 +32,10 @@ namespace PSInfisicalAPI.Cmdlets InfisicalRetrieveSecretQuery query = new InfisicalRetrieveSecretQuery { SecretName = SecretName, - ProjectId = ProjectId, - Environment = Environment, - SecretPath = SecretPath, - ApiVersion = ApiVersion, + ProjectId = ResolveProjectId(connection, ProjectId), + Environment = ResolveEnvironment(connection, Environment), + SecretPath = ResolveSecretPath(connection, SecretPath), + ApiVersion = ResolveApiVersion(connection, ApiVersion), Version = Version, Type = Type.ToString(), ViewSecretValue = ViewSecretValue.IsPresent, diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs index e3e60bb..599b8a9 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs @@ -32,10 +32,10 @@ namespace PSInfisicalAPI.Cmdlets InfisicalListSecretsQuery query = new InfisicalListSecretsQuery { - ProjectId = ProjectId, - Environment = Environment, - SecretPath = SecretPath, - ApiVersion = ApiVersion, + ProjectId = ResolveProjectId(connection, ProjectId), + Environment = ResolveEnvironment(connection, Environment), + SecretPath = ResolveSecretPath(connection, SecretPath), + ApiVersion = ResolveApiVersion(connection, ApiVersion), Recursive = Recursive.IsPresent, IncludeImports = IncludeImports.IsPresent, IncludePersonalOverrides = IncludePersonalOverrides.IsPresent, diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagCmdlet.cs index cc8d32e..8c7837f 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagCmdlet.cs @@ -21,8 +21,9 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); - InfisicalTag tag = client.Retrieve(connection, ProjectId, TagSlugOrId); + InfisicalTag tag = client.Retrieve(connection, resolvedProjectId, TagSlugOrId); if (tag != null) { WriteObject(tag); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagsCmdlet.cs index 8b10649..a4b736c 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagsCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagsCmdlet.cs @@ -17,8 +17,9 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); - InfisicalTag[] tags = client.List(connection, ProjectId); + InfisicalTag[] tags = client.List(connection, resolvedProjectId); foreach (InfisicalTag tag in tags) { WriteObject(tag); diff --git a/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs index fd4f21a..4a3c37b 100644 --- a/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs +++ b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs @@ -1,5 +1,6 @@ using System; using System.Management.Automation; +using PSInfisicalAPI.Connections; using PSInfisicalAPI.Errors; using PSInfisicalAPI.Http; using PSInfisicalAPI.Logging; @@ -44,5 +45,43 @@ namespace PSInfisicalAPI.Cmdlets ErrorRecord record = InfisicalErrorHandler.ToErrorRecord(exception, details); ThrowTerminatingError(record); } + + protected string ResolveProjectId(InfisicalConnection connection, string explicitValue) + { + return ResolveValue("ProjectId", explicitValue, connection != null ? connection.ProjectId : null, null); + } + + protected string ResolveEnvironment(InfisicalConnection connection, string explicitValue) + { + return ResolveValue("Environment", explicitValue, connection != null ? connection.Environment : null, null); + } + + protected string ResolveSecretPath(InfisicalConnection connection, string explicitValue) + { + return ResolveValue("SecretPath", explicitValue, connection != null ? connection.DefaultSecretPath : null, "/"); + } + + protected string ResolveApiVersion(InfisicalConnection connection, string explicitValue) + { + string fromConnection = connection != null ? (!string.IsNullOrEmpty(connection.PinnedApiVersion) ? connection.PinnedApiVersion : connection.ApiVersion) : null; + return ResolveValue("ApiVersion", explicitValue, fromConnection, null); + } + + protected string ResolveOrganizationId(InfisicalConnection connection, string explicitValue) + { + return ResolveValue("OrganizationId", explicitValue, connection != null ? connection.OrganizationId : null, null); + } + + private string ResolveValue(string parameterName, string explicitValue, string inheritedValue, string defaultValue) + { + if (!string.IsNullOrEmpty(explicitValue)) { return explicitValue; } + if (!string.IsNullOrEmpty(inheritedValue)) + { + Logger.Verbose(GetType().Name, string.Concat("Inherited ", parameterName, " '", inheritedValue, "' from connection.")); + return inheritedValue; + } + + return defaultValue; + } } } diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalEnvironmentCmdlet.cs index bcda139..6a00664 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalEnvironmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalEnvironmentCmdlet.cs @@ -25,8 +25,9 @@ namespace PSInfisicalAPI.Cmdlets } InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); - InfisicalEnvironment env = client.Create(connection, ProjectId, Name, Slug, Position); + InfisicalEnvironment env = client.Create(connection, resolvedProjectId, Name, Slug, Position); if (env != null) { WriteObject(env); diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs index ac1d044..f31ff1d 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs @@ -25,8 +25,11 @@ namespace PSInfisicalAPI.Cmdlets } InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + string resolvedEnvironment = ResolveEnvironment(connection, Environment); + string resolvedPath = ResolveSecretPath(connection, Path); InfisicalFolderClient client = new InfisicalFolderClient(HttpClient, Logger); - InfisicalFolder folder = client.Create(connection, ProjectId, Environment, Name, Path); + InfisicalFolder folder = client.Create(connection, resolvedProjectId, resolvedEnvironment, Name, resolvedPath); if (folder != null) { WriteObject(folder); diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs index 4dd0e82..2935f2e 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Management.Automation; using System.Security; using PSInfisicalAPI.Connections; @@ -12,7 +13,9 @@ namespace PSInfisicalAPI.Cmdlets [OutputType(typeof(InfisicalSecret))] public sealed class NewInfisicalSecretCmdlet : InfisicalCmdletBase { - [Parameter(Mandatory = true, Position = 0)] public string SecretName { get; set; } + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "PlainText")] + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "SecureString")] + public string SecretName { get; set; } [Parameter(Mandatory = true, Position = 1, ParameterSetName = "PlainText")] public string SecretValue { get; set; } @@ -20,6 +23,9 @@ namespace PSInfisicalAPI.Cmdlets [Parameter(Mandatory = true, Position = 1, ParameterSetName = "SecureString")] public SecureString SecureSecretValue { get; set; } + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Bulk", ValueFromPipeline = true)] + public Hashtable[] Secrets { get; set; } + [Parameter] public string SecretComment { get; set; } [Parameter] public string ProjectId { get; set; } [Parameter] public string Environment { get; set; } @@ -33,35 +39,62 @@ namespace PSInfisicalAPI.Cmdlets { try { - if (!ShouldProcess(SecretName, "Create Infisical secret")) + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + string resolvedEnvironment = ResolveEnvironment(connection, Environment); + string resolvedSecretPath = ResolveSecretPath(connection, SecretPath); + string resolvedApiVersion = ResolveApiVersion(connection, ApiVersion); + + if (string.Equals(ParameterSetName, "Bulk", StringComparison.Ordinal)) { + if (Secrets == null || Secrets.Length == 0) { return; } + string target = string.Concat(Secrets.Length, " secret(s)"); + if (!ShouldProcess(target, "Bulk-create Infisical secrets")) { return; } + + InfisicalBulkCreateSecretsRequest bulk = new InfisicalBulkCreateSecretsRequest + { + ProjectId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = resolvedSecretPath, + ApiVersion = resolvedApiVersion, + Secrets = InfisicalBulkSecretConverter.ToCreateItems(Secrets) + }; + + InfisicalSecretsClient bulkClient = new InfisicalSecretsClient(HttpClient, Logger); + InfisicalSecret[] created = bulkClient.CreateBatch(connection, bulk); + if (created != null) + { + foreach (InfisicalSecret secret in created) { WriteObject(secret); } + } + return; } + if (!ShouldProcess(SecretName, "Create Infisical secret")) { return; } + string plainValue = SecureSecretValue != null ? SecureStringUtility.UsePlainText(SecureSecretValue, p => p) : SecretValue; - InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); InfisicalCreateSecretRequest request = new InfisicalCreateSecretRequest { SecretName = SecretName, SecretValue = plainValue, SecretComment = SecretComment, - ProjectId = ProjectId, - Environment = Environment, - SecretPath = SecretPath, + ProjectId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = resolvedSecretPath, Type = Type.ToString(), - ApiVersion = ApiVersion, + ApiVersion = resolvedApiVersion, SkipMultilineEncoding = SkipMultilineEncoding.IsPresent ? (bool?)true : null, TagIds = TagIds }; InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger); - InfisicalSecret secret = client.Create(connection, request); - if (secret != null) + InfisicalSecret single = client.Create(connection, request); + if (single != null) { - WriteObject(secret); + WriteObject(single); } } catch (Exception exception) diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs index 21d99d0..bf26869 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs @@ -25,8 +25,9 @@ namespace PSInfisicalAPI.Cmdlets } InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); - InfisicalTag tag = client.Create(connection, ProjectId, Slug, Name, Color); + InfisicalTag tag = client.Create(connection, resolvedProjectId, Slug, Name, Color); if (tag != null) { WriteObject(tag); diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs index 54bd9ff..2716bc4 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs @@ -25,8 +25,9 @@ namespace PSInfisicalAPI.Cmdlets } InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); - client.Delete(connection, ProjectId, EnvironmentId); + client.Delete(connection, resolvedProjectId, EnvironmentId); if (PassThru.IsPresent) { diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs index 825bdf3..7dde5d7 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs @@ -27,8 +27,11 @@ namespace PSInfisicalAPI.Cmdlets } InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + string resolvedEnvironment = ResolveEnvironment(connection, Environment); + string resolvedPath = ResolveSecretPath(connection, Path); InfisicalFolderClient client = new InfisicalFolderClient(HttpClient, Logger); - client.Delete(connection, ProjectId, Environment, FolderId, Path); + client.Delete(connection, resolvedProjectId, resolvedEnvironment, FolderId, resolvedPath); if (PassThru.IsPresent) { diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs index 6cd89e1..fbab178 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs @@ -8,7 +8,7 @@ namespace PSInfisicalAPI.Cmdlets [Cmdlet(VerbsCommon.Remove, "InfisicalProject", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] public sealed class RemoveInfisicalProjectCmdlet : InfisicalCmdletBase { - [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Parameter(ValueFromPipelineByPropertyName = true, Position = 0)] [Alias("Id")] public string ProjectId { get; set; } @@ -18,18 +18,20 @@ namespace PSInfisicalAPI.Cmdlets { try { - if (!ShouldProcess(ProjectId, "Remove Infisical project")) + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + + if (!ShouldProcess(resolvedProjectId, "Remove Infisical project")) { return; } - InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger); - client.Delete(connection, ProjectId); + client.Delete(connection, resolvedProjectId); if (PassThru.IsPresent) { - WriteObject(ProjectId); + WriteObject(resolvedProjectId); } } catch (Exception exception) diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs index 3087596..d2154c7 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs @@ -6,12 +6,16 @@ using PSInfisicalAPI.Secrets; namespace PSInfisicalAPI.Cmdlets { - [Cmdlet(VerbsCommon.Remove, "InfisicalSecret", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + [Cmdlet(VerbsCommon.Remove, "InfisicalSecret", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High, DefaultParameterSetName = "Single")] public sealed class RemoveInfisicalSecretCmdlet : InfisicalCmdletBase { - [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0, ParameterSetName = "Single")] public string SecretName { get; set; } + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Bulk", ValueFromPipeline = true)] + [Alias("Names", "SecretKeys")] + public string[] SecretNames { get; set; } + [Parameter] public string ProjectId { get; set; } [Parameter] public string Environment { get; set; } [Parameter] public string SecretPath { get; set; } @@ -23,23 +27,51 @@ namespace PSInfisicalAPI.Cmdlets { try { - if (!ShouldProcess(SecretName, "Remove Infisical secret")) + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + string resolvedEnvironment = ResolveEnvironment(connection, Environment); + string resolvedSecretPath = ResolveSecretPath(connection, SecretPath); + string resolvedApiVersion = ResolveApiVersion(connection, ApiVersion); + + InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger); + + if (string.Equals(ParameterSetName, "Bulk", StringComparison.Ordinal)) { + if (SecretNames == null || SecretNames.Length == 0) { return; } + string target = string.Concat(SecretNames.Length, " secret(s)"); + if (!ShouldProcess(target, "Bulk-remove Infisical secrets")) { return; } + + InfisicalBulkDeleteSecretsRequest bulk = new InfisicalBulkDeleteSecretsRequest + { + ProjectId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = resolvedSecretPath, + ApiVersion = resolvedApiVersion, + SecretNames = SecretNames + }; + + client.DeleteBatch(connection, bulk); + + if (PassThru.IsPresent) + { + foreach (string name in SecretNames) { WriteObject(name); } + } + return; } - InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + if (!ShouldProcess(SecretName, "Remove Infisical secret")) { return; } + InfisicalDeleteSecretRequest request = new InfisicalDeleteSecretRequest { SecretName = SecretName, - ProjectId = ProjectId, - Environment = Environment, - SecretPath = SecretPath, + ProjectId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = resolvedSecretPath, Type = Type.ToString(), - ApiVersion = ApiVersion + ApiVersion = resolvedApiVersion }; - InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger); client.Delete(connection, request); if (PassThru.IsPresent) diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs index bb14432..96b3b7e 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs @@ -25,8 +25,9 @@ namespace PSInfisicalAPI.Cmdlets } InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); - client.Delete(connection, ProjectId, TagId); + client.Delete(connection, resolvedProjectId, TagId); if (PassThru.IsPresent) { diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs index ade169e..76de675 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs @@ -29,8 +29,9 @@ namespace PSInfisicalAPI.Cmdlets } InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); - InfisicalEnvironment env = client.Update(connection, ProjectId, EnvironmentId, Name, Slug, Position); + InfisicalEnvironment env = client.Update(connection, resolvedProjectId, EnvironmentId, Name, Slug, Position); if (env != null) { WriteObject(env); diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs index 891b398..bb5fe36 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs @@ -29,8 +29,11 @@ namespace PSInfisicalAPI.Cmdlets } InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + string resolvedEnvironment = ResolveEnvironment(connection, Environment); + string resolvedPath = ResolveSecretPath(connection, Path); InfisicalFolderClient client = new InfisicalFolderClient(HttpClient, Logger); - InfisicalFolder folder = client.Update(connection, ProjectId, Environment, FolderId, Name, Path); + InfisicalFolder folder = client.Update(connection, resolvedProjectId, resolvedEnvironment, FolderId, Name, resolvedPath); if (folder != null) { WriteObject(folder); diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs index 3a58b3f..a76cb6b 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs @@ -10,7 +10,7 @@ namespace PSInfisicalAPI.Cmdlets [OutputType(typeof(InfisicalProject))] public sealed class UpdateInfisicalProjectCmdlet : InfisicalCmdletBase { - [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Parameter(ValueFromPipelineByPropertyName = true, Position = 0)] [Alias("Id")] public string ProjectId { get; set; } @@ -22,14 +22,16 @@ namespace PSInfisicalAPI.Cmdlets { try { - if (!ShouldProcess(ProjectId, "Update Infisical project")) + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + + if (!ShouldProcess(resolvedProjectId, "Update Infisical project")) { return; } - InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger); - InfisicalProject project = client.Update(connection, ProjectId, Name, Description, AutoCapitalization); + InfisicalProject project = client.Update(connection, resolvedProjectId, Name, Description, AutoCapitalization); if (project != null) { WriteObject(project); diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs index 359025f..0bb6912 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Management.Automation; using System.Security; using PSInfisicalAPI.Connections; @@ -12,12 +13,16 @@ namespace PSInfisicalAPI.Cmdlets [OutputType(typeof(InfisicalSecret))] public sealed class UpdateInfisicalSecretCmdlet : InfisicalCmdletBase { - [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0, ParameterSetName = "PlainText")] + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0, ParameterSetName = "SecureString")] public string SecretName { get; set; } [Parameter(ParameterSetName = "PlainText")] public string SecretValue { get; set; } [Parameter(ParameterSetName = "SecureString")] public SecureString SecureSecretValue { get; set; } + [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Bulk", ValueFromPipeline = true)] + public Hashtable[] Secrets { get; set; } + [Parameter] public string NewSecretName { get; set; } [Parameter] public string SecretComment { get; set; } [Parameter] public string ProjectId { get; set; } @@ -32,36 +37,63 @@ namespace PSInfisicalAPI.Cmdlets { try { - if (!ShouldProcess(SecretName, "Update Infisical secret")) + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + string resolvedEnvironment = ResolveEnvironment(connection, Environment); + string resolvedSecretPath = ResolveSecretPath(connection, SecretPath); + string resolvedApiVersion = ResolveApiVersion(connection, ApiVersion); + + if (string.Equals(ParameterSetName, "Bulk", StringComparison.Ordinal)) { + if (Secrets == null || Secrets.Length == 0) { return; } + string target = string.Concat(Secrets.Length, " secret(s)"); + if (!ShouldProcess(target, "Bulk-update Infisical secrets")) { return; } + + InfisicalBulkUpdateSecretsRequest bulk = new InfisicalBulkUpdateSecretsRequest + { + ProjectId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = resolvedSecretPath, + ApiVersion = resolvedApiVersion, + Secrets = InfisicalBulkSecretConverter.ToUpdateItems(Secrets) + }; + + InfisicalSecretsClient bulkClient = new InfisicalSecretsClient(HttpClient, Logger); + InfisicalSecret[] updated = bulkClient.UpdateBatch(connection, bulk); + if (updated != null) + { + foreach (InfisicalSecret secret in updated) { WriteObject(secret); } + } + return; } + if (!ShouldProcess(SecretName, "Update Infisical secret")) { return; } + string plainValue = SecureSecretValue != null ? SecureStringUtility.UsePlainText(SecureSecretValue, p => p) : SecretValue; - InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); InfisicalUpdateSecretRequest request = new InfisicalUpdateSecretRequest { SecretName = SecretName, NewSecretName = NewSecretName, SecretValue = plainValue, SecretComment = SecretComment, - ProjectId = ProjectId, - Environment = Environment, - SecretPath = SecretPath, + ProjectId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = resolvedSecretPath, Type = Type.ToString(), - ApiVersion = ApiVersion, + ApiVersion = resolvedApiVersion, SkipMultilineEncoding = SkipMultilineEncoding.IsPresent ? (bool?)true : null, TagIds = TagIds }; InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger); - InfisicalSecret secret = client.Update(connection, request); - if (secret != null) + InfisicalSecret single = client.Update(connection, request); + if (single != null) { - WriteObject(secret); + WriteObject(single); } } catch (Exception exception) diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs index 9a6c45b..15aefe6 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs @@ -29,8 +29,9 @@ namespace PSInfisicalAPI.Cmdlets } InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); - InfisicalTag tag = client.Update(connection, ProjectId, TagId, Slug, Name, Color); + InfisicalTag tag = client.Update(connection, resolvedProjectId, TagId, Slug, Name, Color); if (tag != null) { WriteObject(tag); diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs index 83a8d0e..161c3a5 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs @@ -15,6 +15,10 @@ namespace PSInfisicalAPI.Endpoints public const string CreateSecret = "CreateSecret"; public const string UpdateSecret = "UpdateSecret"; public const string DeleteSecret = "DeleteSecret"; + public const string BulkCreateSecret = "BulkCreateSecret"; + public const string BulkUpdateSecret = "BulkUpdateSecret"; + public const string BulkDeleteSecret = "BulkDeleteSecret"; + public const string DuplicateSecret = "DuplicateSecret"; public const string ListProjects = "ListProjects"; public const string RetrieveProject = "RetrieveProject"; diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs index 0478a5c..332de5a 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs @@ -197,6 +197,54 @@ namespace PSInfisicalAPI.Endpoints RequiresAuthorization = true, ContainsSecretMaterialInResponse = true }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.BulkCreateSecret, + Resource = "Secrets", + Version = "v3", + Method = "POST", + Template = "/api/v3/secrets/batch/raw", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.BulkUpdateSecret, + Resource = "Secrets", + Version = "v3", + Method = "PATCH", + Template = "/api/v3/secrets/batch/raw", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.BulkDeleteSecret, + Resource = "Secrets", + Version = "v3", + Method = "DELETE", + Template = "/api/v3/secrets/batch/raw", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.DuplicateSecret, + Resource = "Secrets", + Version = "v4", + Method = "POST", + Template = "/api/v4/secrets/duplicate", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); } private static void RegisterProjects(Dictionary> map) diff --git a/src/PSInfisicalAPI/Secrets/InfisicalBulkSecretConverter.cs b/src/PSInfisicalAPI/Secrets/InfisicalBulkSecretConverter.cs new file mode 100644 index 0000000..ba4f37d --- /dev/null +++ b/src/PSInfisicalAPI/Secrets/InfisicalBulkSecretConverter.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Secrets +{ + public static class InfisicalBulkSecretConverter + { + public static InfisicalBulkCreateSecretItem[] ToCreateItems(IEnumerable input) + { + if (input == null) { return new InfisicalBulkCreateSecretItem[0]; } + + List list = new List(); + foreach (object element in input) + { + Hashtable table = AsHashtable(element); + InfisicalBulkCreateSecretItem item = new InfisicalBulkCreateSecretItem + { + SecretName = GetString(table, "SecretName", "Name", "Key", "SecretKey"), + SecretValue = GetSecretValue(table, "SecretValue", "Value"), + SecretComment = GetString(table, "SecretComment", "Comment"), + SkipMultilineEncoding = GetBool(table, "SkipMultilineEncoding"), + TagIds = GetStringArray(table, "TagIds"), + SecretMetadata = GetStringDictionary(table, "SecretMetadata", "Metadata") + }; + + if (string.IsNullOrEmpty(item.SecretName)) + { + throw new InfisicalConfigurationException("Each bulk-create entry must include 'SecretName' (or 'Name'/'Key')."); + } + + list.Add(item); + } + + return list.ToArray(); + } + + public static InfisicalBulkUpdateSecretItem[] ToUpdateItems(IEnumerable input) + { + if (input == null) { return new InfisicalBulkUpdateSecretItem[0]; } + + List list = new List(); + foreach (object element in input) + { + Hashtable table = AsHashtable(element); + InfisicalBulkUpdateSecretItem item = new InfisicalBulkUpdateSecretItem + { + SecretName = GetString(table, "SecretName", "Name", "Key", "SecretKey"), + NewSecretName = GetString(table, "NewSecretName", "NewName"), + SecretValue = GetSecretValue(table, "SecretValue", "Value"), + SecretComment = GetString(table, "SecretComment", "Comment"), + SkipMultilineEncoding = GetBool(table, "SkipMultilineEncoding"), + TagIds = GetStringArray(table, "TagIds"), + SecretMetadata = GetStringDictionary(table, "SecretMetadata", "Metadata") + }; + + if (string.IsNullOrEmpty(item.SecretName)) + { + throw new InfisicalConfigurationException("Each bulk-update entry must include 'SecretName' (or 'Name'/'Key')."); + } + + list.Add(item); + } + + return list.ToArray(); + } + + private static Hashtable AsHashtable(object element) + { + if (element is Hashtable hashtable) { return hashtable; } + if (element is IDictionary dictionary) + { + Hashtable converted = new Hashtable(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in dictionary) + { + if (entry.Key == null) { continue; } + converted[entry.Key.ToString()] = entry.Value; + } + + return converted; + } + + throw new InfisicalConfigurationException("Bulk secret entries must be Hashtable or IDictionary values."); + } + + private static string GetString(Hashtable table, params string[] keys) + { + foreach (string key in keys) + { + if (table.ContainsKey(key) && table[key] != null) + { + return table[key].ToString(); + } + } + + return null; + } + + private static string GetSecretValue(Hashtable table, params string[] keys) + { + foreach (string key in keys) + { + if (!table.ContainsKey(key)) { continue; } + object value = table[key]; + if (value == null) { return null; } + if (value is System.Security.SecureString secure) + { + return SecureStringUtility.UsePlainText(secure, plain => plain); + } + + return value.ToString(); + } + + return null; + } + + private static bool? GetBool(Hashtable table, string key) + { + if (!table.ContainsKey(key) || table[key] == null) { return null; } + object value = table[key]; + if (value is bool b) { return b; } + bool parsed; + return bool.TryParse(value.ToString(), out parsed) ? parsed : (bool?)null; + } + + private static string[] GetStringArray(Hashtable table, string key) + { + if (!table.ContainsKey(key) || table[key] == null) { return null; } + object value = table[key]; + if (value is string[] direct) { return direct; } + if (value is IEnumerable enumerable && !(value is string)) + { + List items = new List(); + foreach (object item in enumerable) { if (item != null) { items.Add(item.ToString()); } } + return items.ToArray(); + } + + return new[] { value.ToString() }; + } + + private static Dictionary GetStringDictionary(Hashtable table, params string[] keys) + { + foreach (string key in keys) + { + if (!table.ContainsKey(key) || table[key] == null) { continue; } + if (table[key] is IDictionary dictionary) + { + Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in dictionary) + { + if (entry.Key == null) { continue; } + result[entry.Key.ToString()] = entry.Value != null ? entry.Value.ToString() : null; + } + + return result; + } + } + + return null; + } + } +} diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs index 8d4ea3a..d4dd9cc 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs @@ -88,4 +88,75 @@ namespace PSInfisicalAPI.Secrets [JsonProperty("secretPath", NullValueHandling = NullValueHandling.Ignore)] public string SecretPath { get; set; } [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] public string Type { get; set; } } + + internal sealed class InfisicalSecretBatchCreateItemDto + { + [JsonProperty("secretKey")] public string SecretKey { get; set; } + [JsonProperty("secretValue")] public string SecretValue { get; set; } + [JsonProperty("secretComment", NullValueHandling = NullValueHandling.Ignore)] public string SecretComment { get; set; } + [JsonProperty("skipMultilineEncoding", NullValueHandling = NullValueHandling.Ignore)] public bool? SkipMultilineEncoding { get; set; } + [JsonProperty("tagIds", NullValueHandling = NullValueHandling.Ignore)] public string[] TagIds { get; set; } + [JsonProperty("secretMetadata", NullValueHandling = NullValueHandling.Ignore)] public List SecretMetadata { get; set; } + } + + internal sealed class InfisicalSecretBatchUpdateItemDto + { + [JsonProperty("secretKey")] public string SecretKey { get; set; } + [JsonProperty("newSecretName", NullValueHandling = NullValueHandling.Ignore)] public string NewSecretName { get; set; } + [JsonProperty("secretValue", NullValueHandling = NullValueHandling.Ignore)] public string SecretValue { get; set; } + [JsonProperty("secretComment", NullValueHandling = NullValueHandling.Ignore)] public string SecretComment { get; set; } + [JsonProperty("skipMultilineEncoding", NullValueHandling = NullValueHandling.Ignore)] public bool? SkipMultilineEncoding { get; set; } + [JsonProperty("tagIds", NullValueHandling = NullValueHandling.Ignore)] public string[] TagIds { get; set; } + [JsonProperty("secretMetadata", NullValueHandling = NullValueHandling.Ignore)] public List SecretMetadata { get; set; } + } + + internal sealed class InfisicalSecretBatchDeleteItemDto + { + [JsonProperty("secretKey")] public string SecretKey { get; set; } + } + + internal sealed class InfisicalSecretBatchCreateRequestDto + { + [JsonProperty("workspaceId")] public string WorkspaceId { get; set; } + [JsonProperty("environment")] public string Environment { get; set; } + [JsonProperty("secretPath", NullValueHandling = NullValueHandling.Ignore)] public string SecretPath { get; set; } + [JsonProperty("secrets")] public List Secrets { get; set; } + } + + internal sealed class InfisicalSecretBatchUpdateRequestDto + { + [JsonProperty("workspaceId")] public string WorkspaceId { get; set; } + [JsonProperty("environment")] public string Environment { get; set; } + [JsonProperty("secretPath", NullValueHandling = NullValueHandling.Ignore)] public string SecretPath { get; set; } + [JsonProperty("mode", NullValueHandling = NullValueHandling.Ignore)] public string Mode { get; set; } + [JsonProperty("secrets")] public List Secrets { get; set; } + } + + internal sealed class InfisicalSecretBatchDeleteRequestDto + { + [JsonProperty("workspaceId")] public string WorkspaceId { get; set; } + [JsonProperty("environment")] public string Environment { get; set; } + [JsonProperty("secretPath", NullValueHandling = NullValueHandling.Ignore)] public string SecretPath { get; set; } + [JsonProperty("secrets")] public List Secrets { get; set; } + } + + internal sealed class InfisicalSecretDuplicateAttributesDto + { + [JsonProperty("secretValue", NullValueHandling = NullValueHandling.Ignore)] public bool? SecretValue { get; set; } + [JsonProperty("secretComment", NullValueHandling = NullValueHandling.Ignore)] public bool? SecretComment { get; set; } + [JsonProperty("tags", NullValueHandling = NullValueHandling.Ignore)] public bool? Tags { get; set; } + [JsonProperty("metadata", NullValueHandling = NullValueHandling.Ignore)] public bool? Metadata { get; set; } + } + + internal sealed class InfisicalSecretDuplicateRequestDto + { + [JsonProperty("projectId")] public string ProjectId { get; set; } + [JsonProperty("sourceEnvironment")] public string SourceEnvironment { get; set; } + [JsonProperty("destinationEnvironment")] public string DestinationEnvironment { get; set; } + [JsonProperty("sourceSecretPath", NullValueHandling = NullValueHandling.Ignore)] public string SourceSecretPath { get; set; } + [JsonProperty("destinationSecretPath", NullValueHandling = NullValueHandling.Ignore)] public string DestinationSecretPath { get; set; } + [JsonProperty("secretIds")] public string[] SecretIds { get; set; } + [JsonProperty("overwriteExisting", NullValueHandling = NullValueHandling.Ignore)] public bool? OverwriteExisting { get; set; } + [JsonProperty("attributesToCopy", NullValueHandling = NullValueHandling.Ignore)] public InfisicalSecretDuplicateAttributesDto AttributesToCopy { get; set; } + } } diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs index a5e20d1..0a274d5 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs @@ -69,4 +69,69 @@ namespace PSInfisicalAPI.Secrets public string Type { get; set; } public string ApiVersion { get; set; } } + + public sealed class InfisicalBulkCreateSecretItem + { + public string SecretName { get; set; } + public string SecretValue { get; set; } + public string SecretComment { get; set; } + public bool? SkipMultilineEncoding { get; set; } + public string[] TagIds { get; set; } + public Dictionary SecretMetadata { get; set; } + } + + public sealed class InfisicalBulkUpdateSecretItem + { + public string SecretName { get; set; } + public string NewSecretName { get; set; } + public string SecretValue { get; set; } + public string SecretComment { get; set; } + public bool? SkipMultilineEncoding { get; set; } + public string[] TagIds { get; set; } + public Dictionary SecretMetadata { get; set; } + } + + public sealed class InfisicalBulkCreateSecretsRequest + { + public string ProjectId { get; set; } + public string Environment { get; set; } + public string SecretPath { get; set; } + public string ApiVersion { get; set; } + public InfisicalBulkCreateSecretItem[] Secrets { get; set; } + } + + public sealed class InfisicalBulkUpdateSecretsRequest + { + public string ProjectId { get; set; } + public string Environment { get; set; } + public string SecretPath { get; set; } + public string ApiVersion { get; set; } + public string Mode { get; set; } + public InfisicalBulkUpdateSecretItem[] Secrets { get; set; } + } + + public sealed class InfisicalBulkDeleteSecretsRequest + { + public string ProjectId { get; set; } + public string Environment { get; set; } + public string SecretPath { get; set; } + public string ApiVersion { get; set; } + public string[] SecretNames { get; set; } + } + + public sealed class InfisicalDuplicateSecretsRequest + { + public string ProjectId { get; set; } + public string SourceEnvironment { get; set; } + public string DestinationEnvironment { get; set; } + public string SourceSecretPath { get; set; } + public string DestinationSecretPath { get; set; } + public string[] SecretIds { get; set; } + public bool? OverwriteExisting { get; set; } + public bool? CopySecretValue { get; set; } + public bool? CopySecretComment { get; set; } + public bool? CopyTags { get; set; } + public bool? CopyMetadata { get; set; } + public string ApiVersion { get; set; } + } } diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs index 2f62246..9d7e93a 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs @@ -224,6 +224,227 @@ namespace PSInfisicalAPI.Secrets } } + public InfisicalSecret[] CreateBatch(InfisicalConnection connection, InfisicalBulkCreateSecretsRequest request) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (request == null) { throw new ArgumentNullException(nameof(request)); } + if (request.Secrets == null || request.Secrets.Length == 0) { throw new InfisicalConfigurationException("At least one secret is required."); } + + string resolvedProjectId = FirstNonEmpty(request.ProjectId, connection.ProjectId); + string resolvedEnvironment = FirstNonEmpty(request.Environment, connection.Environment); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(resolvedEnvironment)) { throw new InfisicalConfigurationException("Environment is required."); } + + List items = new List(request.Secrets.Length); + foreach (InfisicalBulkCreateSecretItem item in request.Secrets) + { + if (item == null) { continue; } + if (string.IsNullOrEmpty(item.SecretName)) { throw new InfisicalConfigurationException("Each bulk-create item requires SecretName."); } + items.Add(new InfisicalSecretBatchCreateItemDto + { + SecretKey = item.SecretName, + SecretValue = item.SecretValue ?? string.Empty, + SecretComment = item.SecretComment, + SkipMultilineEncoding = item.SkipMultilineEncoding, + TagIds = item.TagIds, + SecretMetadata = ToMetadataDtoList(item.SecretMetadata) + }); + } + + InfisicalSecretBatchCreateRequestDto dtoRequest = new InfisicalSecretBatchCreateRequestDto + { + WorkspaceId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = FirstNonEmpty(request.SecretPath, connection.DefaultSecretPath, "/"), + Secrets = items + }; + string body = _serializer.Serialize(dtoRequest); + + try + { + _logger.Information(Component, string.Concat("Attempting to bulk-create ", items.Count.ToString(CultureInfo.InvariantCulture), " Infisical secret(s). Please Wait...")); + InfisicalHttpResponse response = SendWithVersionFallback(connection, InfisicalEndpointNames.BulkCreateSecret, request.ApiVersion, "BulkCreateSecrets", null, null, body); + InfisicalSecretListResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalSecret[] mapped = InfisicalSecretMapper.MapMany(dto != null ? dto.Secrets : null); + _logger.Information(Component, "Infisical bulk secret creation was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical bulk secret creation failed."); + throw; + } + } + + public InfisicalSecret[] UpdateBatch(InfisicalConnection connection, InfisicalBulkUpdateSecretsRequest request) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (request == null) { throw new ArgumentNullException(nameof(request)); } + if (request.Secrets == null || request.Secrets.Length == 0) { throw new InfisicalConfigurationException("At least one secret is required."); } + + string resolvedProjectId = FirstNonEmpty(request.ProjectId, connection.ProjectId); + string resolvedEnvironment = FirstNonEmpty(request.Environment, connection.Environment); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(resolvedEnvironment)) { throw new InfisicalConfigurationException("Environment is required."); } + + List items = new List(request.Secrets.Length); + foreach (InfisicalBulkUpdateSecretItem item in request.Secrets) + { + if (item == null) { continue; } + if (string.IsNullOrEmpty(item.SecretName)) { throw new InfisicalConfigurationException("Each bulk-update item requires SecretName."); } + items.Add(new InfisicalSecretBatchUpdateItemDto + { + SecretKey = item.SecretName, + NewSecretName = item.NewSecretName, + SecretValue = item.SecretValue, + SecretComment = item.SecretComment, + SkipMultilineEncoding = item.SkipMultilineEncoding, + TagIds = item.TagIds, + SecretMetadata = ToMetadataDtoList(item.SecretMetadata) + }); + } + + InfisicalSecretBatchUpdateRequestDto dtoRequest = new InfisicalSecretBatchUpdateRequestDto + { + WorkspaceId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = FirstNonEmpty(request.SecretPath, connection.DefaultSecretPath, "/"), + Mode = request.Mode, + Secrets = items + }; + string body = _serializer.Serialize(dtoRequest); + + try + { + _logger.Information(Component, string.Concat("Attempting to bulk-update ", items.Count.ToString(CultureInfo.InvariantCulture), " Infisical secret(s). Please Wait...")); + InfisicalHttpResponse response = SendWithVersionFallback(connection, InfisicalEndpointNames.BulkUpdateSecret, request.ApiVersion, "BulkUpdateSecrets", null, null, body); + InfisicalSecretListResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalSecret[] mapped = InfisicalSecretMapper.MapMany(dto != null ? dto.Secrets : null); + _logger.Information(Component, "Infisical bulk secret update was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical bulk secret update failed."); + throw; + } + } + + public void DeleteBatch(InfisicalConnection connection, InfisicalBulkDeleteSecretsRequest request) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (request == null) { throw new ArgumentNullException(nameof(request)); } + if (request.SecretNames == null || request.SecretNames.Length == 0) { throw new InfisicalConfigurationException("At least one secret name is required."); } + + string resolvedProjectId = FirstNonEmpty(request.ProjectId, connection.ProjectId); + string resolvedEnvironment = FirstNonEmpty(request.Environment, connection.Environment); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(resolvedEnvironment)) { throw new InfisicalConfigurationException("Environment is required."); } + + List items = new List(request.SecretNames.Length); + foreach (string name in request.SecretNames) + { + if (string.IsNullOrEmpty(name)) { continue; } + items.Add(new InfisicalSecretBatchDeleteItemDto { SecretKey = name }); + } + + InfisicalSecretBatchDeleteRequestDto dtoRequest = new InfisicalSecretBatchDeleteRequestDto + { + WorkspaceId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = FirstNonEmpty(request.SecretPath, connection.DefaultSecretPath, "/"), + Secrets = items + }; + string body = _serializer.Serialize(dtoRequest); + + try + { + _logger.Information(Component, string.Concat("Attempting to bulk-delete ", items.Count.ToString(CultureInfo.InvariantCulture), " Infisical secret(s). Please Wait...")); + InfisicalHttpResponse response = SendWithVersionFallback(connection, InfisicalEndpointNames.BulkDeleteSecret, request.ApiVersion, "BulkDeleteSecrets", null, null, body); + response.Clear(); + _logger.Information(Component, "Infisical bulk secret deletion was successful."); + } + catch (Exception) + { + _logger.Error(Component, "Infisical bulk secret deletion failed."); + throw; + } + } + + public InfisicalSecret[] Duplicate(InfisicalConnection connection, InfisicalDuplicateSecretsRequest request) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (request == null) { throw new ArgumentNullException(nameof(request)); } + if (request.SecretIds == null || request.SecretIds.Length == 0) { throw new InfisicalConfigurationException("At least one SecretId is required."); } + + string resolvedProjectId = FirstNonEmpty(request.ProjectId, connection.ProjectId); + string resolvedSourceEnv = FirstNonEmpty(request.SourceEnvironment, connection.Environment); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(resolvedSourceEnv)) { throw new InfisicalConfigurationException("SourceEnvironment is required."); } + if (string.IsNullOrEmpty(request.DestinationEnvironment)) { throw new InfisicalConfigurationException("DestinationEnvironment is required."); } + + string resolvedSourcePath = FirstNonEmpty(request.SourceSecretPath, connection.DefaultSecretPath, "/"); + string resolvedDestPath = FirstNonEmpty(request.DestinationSecretPath, resolvedSourcePath); + + InfisicalSecretDuplicateAttributesDto attributes = null; + if (request.CopySecretValue.HasValue || request.CopySecretComment.HasValue || request.CopyTags.HasValue || request.CopyMetadata.HasValue) + { + attributes = new InfisicalSecretDuplicateAttributesDto + { + SecretValue = request.CopySecretValue, + SecretComment = request.CopySecretComment, + Tags = request.CopyTags, + Metadata = request.CopyMetadata + }; + } + + InfisicalSecretDuplicateRequestDto dtoRequest = new InfisicalSecretDuplicateRequestDto + { + ProjectId = resolvedProjectId, + SourceEnvironment = resolvedSourceEnv, + DestinationEnvironment = request.DestinationEnvironment, + SourceSecretPath = resolvedSourcePath, + DestinationSecretPath = resolvedDestPath, + SecretIds = request.SecretIds, + OverwriteExisting = request.OverwriteExisting, + AttributesToCopy = attributes + }; + string body = _serializer.Serialize(dtoRequest); + + try + { + _logger.Information(Component, string.Concat("Attempting to duplicate ", request.SecretIds.Length.ToString(CultureInfo.InvariantCulture), " Infisical secret(s). Please Wait...")); + InfisicalHttpResponse response = SendWithVersionFallback(connection, InfisicalEndpointNames.DuplicateSecret, request.ApiVersion, "DuplicateSecrets", null, null, body); + InfisicalSecretListResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalSecret[] mapped = InfisicalSecretMapper.MapMany(dto != null ? dto.Secrets : null); + _logger.Information(Component, "Infisical secret duplication was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical secret duplication failed."); + throw; + } + } + + private static List ToMetadataDtoList(Dictionary metadata) + { + if (metadata == null || metadata.Count == 0) { return null; } + List list = new List(metadata.Count); + foreach (KeyValuePair kvp in metadata) + { + list.Add(new InfisicalSecretMetadataDto { Key = kvp.Key, Value = kvp.Value }); + } + + return list; + } + public void Delete(InfisicalConnection connection, InfisicalDeleteSecretRequest request) { if (connection == null) { throw new ArgumentNullException(nameof(connection)); }