diff --git a/CHANGELOG.md b/CHANGELOG.md index c63b5cb..080548b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos ## Unreleased +## 2026.06.04.0020 + +- Build produced from commit 211fbcf34dbb. + +## Unreleased (carried forward) + ## 2026.06.04.0005 - Build produced from commit e0a6ef02df3e. @@ -18,7 +24,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos ## 2026.06.03.2207 - Build produced from commit 09c3d5c68bbc. -- **M9 — Bulk, Duplicate & Inheritance**: +- **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. diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index 2d0e294..57b8442 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PSInfisicalAPI.psm1' - ModuleVersion = '2026.06.04.0005' + ModuleVersion = '2026.06.04.0020' GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51' Author = 'Grace Solutions' CompanyName = 'Grace Solutions' @@ -51,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 = 'e0a6ef02df3e' + CommitHash = '211fbcf34dbb' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll index c2228bd..d3eae94 100644 Binary files a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll and b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll differ diff --git a/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs b/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs index 81f3870..c679d20 100644 --- a/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs +++ b/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs @@ -1,4 +1,8 @@ +using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; +using System.Management.Automation; +using PSInfisicalAPI.Cmdlets; using PSInfisicalAPI.Errors; using PSInfisicalAPI.Secrets; using Xunit; @@ -90,4 +94,116 @@ namespace PSInfisicalAPI.Tests Assert.Empty(items); } } + + public class BulkSecretsTransformationAttributeTests + { + private static IDictionary[] Transform(object input) + { + BulkSecretsTransformationAttribute attribute = new BulkSecretsTransformationAttribute(); + object result = attribute.Transform(null, input); + return (IDictionary[])result; + } + + [Fact] + public void Transform_Single_Hashtable_Wraps_Into_Array() + { + Hashtable input = new Hashtable { { "SecretName", "API_KEY" }, { "SecretValue", "abc" } }; + + IDictionary[] result = Transform(input); + + Assert.Single(result); + Assert.Equal("API_KEY", result[0]["SecretName"]); + Assert.Equal("abc", result[0]["SecretValue"]); + } + + [Fact] + public void Transform_Hashtable_Array_Preserves_Order() + { + Hashtable a = new Hashtable { { "SecretName", "A" }, { "SecretValue", "1" } }; + Hashtable b = new Hashtable { { "SecretName", "B" }, { "SecretValue", "2" } }; + + IDictionary[] result = Transform(new object[] { a, b }); + + Assert.Equal(2, result.Length); + Assert.Equal("A", result[0]["SecretName"]); + Assert.Equal("B", result[1]["SecretName"]); + } + + [Fact] + public void Transform_OrderedDictionary_Converts_To_StringString() + { + OrderedDictionary input = new OrderedDictionary(); + input.Add("SecretName", "API_KEY"); + input.Add("SkipMultilineEncoding", true); + + IDictionary[] result = Transform(input); + + Assert.Single(result); + Assert.Equal("API_KEY", result[0]["SecretName"]); + Assert.Equal("true", result[0]["SkipMultilineEncoding"]); + } + + [Fact] + public void Transform_Array_Values_Are_Joined_Comma_Separated() + { + Hashtable input = new Hashtable + { + { "SecretName", "API_KEY" }, + { "TagIds", new[] { "tag-1", "tag-2" } } + }; + + IDictionary[] result = Transform(input); + + Assert.Equal("tag-1,tag-2", result[0]["TagIds"]); + } + + [Fact] + public void Transform_Already_Typed_Array_Passes_Through() + { + IDictionary[] input = new IDictionary[] + { + new Dictionary { { "SecretName", "A" }, { "SecretValue", "1" } } + }; + + IDictionary[] result = Transform(input); + + Assert.Same(input, result); + } + + [Fact] + public void Transform_Null_Input_Returns_Null() + { + BulkSecretsTransformationAttribute attribute = new BulkSecretsTransformationAttribute(); + Assert.Null(attribute.Transform(null, null)); + } + + [Fact] + public void Transform_Invalid_Element_Throws_ArgumentTransformationMetadataException() + { + BulkSecretsTransformationAttribute attribute = new BulkSecretsTransformationAttribute(); + Assert.Throws(() => + attribute.Transform(null, new object[] { "not-a-dictionary" })); + } + + [Fact] + public void Transform_End_To_End_With_Converter_Produces_Bulk_Items() + { + Hashtable entry = new Hashtable + { + { "SecretName", "API_KEY" }, + { "SecretValue", "abc" }, + { "TagIds", new[] { "tag-1", "tag-2" } }, + { "SkipMultilineEncoding", true } + }; + + IDictionary[] transformed = Transform(new object[] { entry }); + InfisicalBulkCreateSecretItem[] items = InfisicalBulkSecretConverter.ToCreateItems(transformed); + + Assert.Single(items); + Assert.Equal("API_KEY", items[0].SecretName); + Assert.Equal("abc", items[0].SecretValue); + Assert.Equal(new[] { "tag-1", "tag-2" }, items[0].TagIds); + Assert.True(items[0].SkipMultilineEncoding); + } + } } diff --git a/src/PSInfisicalAPI/Cmdlets/BulkSecretsTransformationAttribute.cs b/src/PSInfisicalAPI/Cmdlets/BulkSecretsTransformationAttribute.cs new file mode 100644 index 0000000..de7e289 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/BulkSecretsTransformationAttribute.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Management.Automation; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Cmdlets +{ + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] + public sealed class BulkSecretsTransformationAttribute : ArgumentTransformationAttribute + { + public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) + { + if (inputData == null) { return null; } + + object unwrapped = Unwrap(inputData); + + if (unwrapped is IDictionary[] strongArray) { return strongArray; } + + if (unwrapped is IDictionary singleDict) + { + return new IDictionary[] { Convert(singleDict) }; + } + + if (unwrapped is IEnumerable enumerable && !(unwrapped is string)) + { + List> result = new List>(); + foreach (object element in enumerable) + { + if (element == null) { continue; } + object e = Unwrap(element); + if (e is IDictionary dict) + { + result.Add(Convert(dict)); + continue; + } + + throw new ArgumentTransformationMetadataException( + "Each element of -Secrets must be a dictionary (Hashtable, OrderedDictionary, Dictionary, etc.)."); + } + + return result.ToArray(); + } + + throw new ArgumentTransformationMetadataException( + "-Secrets must be a dictionary or an array of dictionaries."); + } + + private static object Unwrap(object value) + { + PSObject pso = value as PSObject; + return pso != null ? pso.BaseObject : value; + } + + private static IDictionary Convert(IDictionary source) + { + Dictionary dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in source) + { + if (entry.Key == null) { continue; } + string key = entry.Key.ToString(); + dict[key] = Stringify(entry.Value); + } + + return dict; + } + + private static string Stringify(object value) + { + object v = Unwrap(value); + if (v == null) { return null; } + if (v is string s) { return s; } + if (v is bool b) { return b ? "true" : "false"; } + if (v is System.Security.SecureString secure) + { + return SecureStringUtility.UsePlainText(secure, plain => plain); + } + + if (v is IEnumerable enumerable) + { + List parts = new List(); + foreach (object item in enumerable) + { + if (item == null) { continue; } + parts.Add(Unwrap(item).ToString()); + } + + return string.Join(",", parts); + } + + return v.ToString(); + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs index f853073..2d8962a 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs @@ -24,6 +24,7 @@ namespace PSInfisicalAPI.Cmdlets public SecureString SecureSecretValue { get; set; } [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Bulk", ValueFromPipeline = true)] + [BulkSecretsTransformation] public IDictionary[] Secrets { get; set; } [Parameter] public string SecretComment { get; set; } diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs index 9ebd1f6..0449af7 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs @@ -21,6 +21,7 @@ namespace PSInfisicalAPI.Cmdlets [Parameter(ParameterSetName = "SecureString")] public SecureString SecureSecretValue { get; set; } [Parameter(Mandatory = true, Position = 0, ParameterSetName = "Bulk", ValueFromPipeline = true)] + [BulkSecretsTransformation] public IDictionary[] Secrets { get; set; } [Parameter] public string NewSecretName { get; set; }