diff --git a/CHANGELOG.md b/CHANGELOG.md index 7182481..080548b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ 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. + +## Unreleased (carried forward) + +- **Bulk v4 batch routes**: Endpoint registry now registers `POST|PATCH|DELETE /api/v4/secrets/batch` as the preferred candidates for `BulkCreateSecret`/`BulkUpdateSecret`/`BulkDeleteSecret`; the existing v3 raw routes (`/api/v3/secrets/batch/raw`) remain as automatic fallback. Batch request DTOs serialize both `projectId` (required by v4) and `workspaceId` (accepted by v3) when populated. +- **Strongly-typed bulk input**: `-Secrets` on `New-InfisicalSecret` and `Update-InfisicalSecret` is now `IDictionary[]` instead of `Hashtable[]`. `InfisicalBulkSecretConverter` accepts `IEnumerable>` and parses `TagIds` from a comma-separated string. Nested `Metadata`/`SecretMetadata` dictionaries are no longer accepted in the bulk hashtable surface (set `SecretMetadata` programmatically on `InfisicalBulkCreateSecretItem`/`InfisicalBulkUpdateSecretItem` if needed). + +## 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. +- **Resource CRUD expansion**: Added full Get/New/Update/Remove cmdlet families for Projects, Environments, Folders, and Tags (20 new cmdlets): + - Projects: `Get-InfisicalProjects`, `Get-InfisicalProject`, `New-InfisicalProject`, `Update-InfisicalProject`, `Remove-InfisicalProject`. + - Environments: `Get-InfisicalEnvironments`, `Get-InfisicalEnvironment`, `New-InfisicalEnvironment`, `Update-InfisicalEnvironment`, `Remove-InfisicalEnvironment`. + - Folders: `Get-InfisicalFolders`, `Get-InfisicalFolder`, `New-InfisicalFolder`, `Update-InfisicalFolder`, `Remove-InfisicalFolder`. + - Tags: `Get-InfisicalTags`, `Get-InfisicalTag`, `New-InfisicalTag`, `Update-InfisicalTag`, `Remove-InfisicalTag`. +- **Secret mutation cmdlets**: Added `New-InfisicalSecret`, `Update-InfisicalSecret`, and `Remove-InfisicalSecret`; extended `InfisicalSecretsClient` with corresponding create/update/delete operations. +- **Additional auth providers**: `Connect-Infisical` now supports JWT (`-Jwt -IdentityId`), OIDC (`-Jwt -IdentityId`), LDAP (`-Username -Password`), Azure (`-Jwt -IdentityId`), and GCP IAM (`-Jwt -IdentityId`) via dedicated parameter sets. Common identity-login flow is centralized in `IdentityLoginExecutor`. +- Endpoint registry expanded with login routes (`/api/v1/auth/{jwt|oidc|ldap|azure|gcp}-auth/login`) and CRUD routes for projects (v2), environments, folders, tags, and secret mutations. +- Test suite expanded to 139 passing tests, including mapper round-trips for projects/environments/folders/tags, secret mutation DTO shapes, and request-body validation for each new auth provider. + ## 2026.06.03.0131 - Build produced from commit 7be0b7b42008. diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index cd3acdd..57b8442 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PSInfisicalAPI.psm1' - ModuleVersion = '2026.06.03.0131' + ModuleVersion = '2026.06.04.0020' GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51' Author = 'Grace Solutions' CompanyName = 'Grace Solutions' @@ -14,8 +14,32 @@ 'Disconnect-Infisical', 'Get-InfisicalSecrets', 'Get-InfisicalSecret', + 'New-InfisicalSecret', + 'Update-InfisicalSecret', + 'Remove-InfisicalSecret', + 'Copy-InfisicalSecret', 'ConvertTo-InfisicalSecretDictionary', - 'Export-InfisicalSecrets' + 'Export-InfisicalSecrets', + 'Get-InfisicalProjects', + 'Get-InfisicalProject', + 'New-InfisicalProject', + 'Update-InfisicalProject', + 'Remove-InfisicalProject', + 'Get-InfisicalEnvironments', + 'Get-InfisicalEnvironment', + 'New-InfisicalEnvironment', + 'Update-InfisicalEnvironment', + 'Remove-InfisicalEnvironment', + 'Get-InfisicalFolders', + 'Get-InfisicalFolder', + 'New-InfisicalFolder', + 'Update-InfisicalFolder', + 'Remove-InfisicalFolder', + 'Get-InfisicalTags', + 'Get-InfisicalTag', + 'New-InfisicalTag', + 'Update-InfisicalTag', + 'Remove-InfisicalTag' ) AliasesToExport = @() VariablesToExport = @() @@ -27,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 = '7be0b7b42008' + CommitHash = '211fbcf34dbb' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll index 0ba2ef8..d3eae94 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 ef7a856..6a0066d 100644 --- a/build.ps1 +++ b/build.ps1 @@ -102,8 +102,32 @@ function Write-Manifest { 'Disconnect-Infisical', 'Get-InfisicalSecrets', 'Get-InfisicalSecret', + 'New-InfisicalSecret', + 'Update-InfisicalSecret', + 'Remove-InfisicalSecret', + 'Copy-InfisicalSecret', 'ConvertTo-InfisicalSecretDictionary', - 'Export-InfisicalSecrets' + 'Export-InfisicalSecrets', + 'Get-InfisicalProjects', + 'Get-InfisicalProject', + 'New-InfisicalProject', + 'Update-InfisicalProject', + 'Remove-InfisicalProject', + 'Get-InfisicalEnvironments', + 'Get-InfisicalEnvironment', + 'New-InfisicalEnvironment', + 'Update-InfisicalEnvironment', + 'Remove-InfisicalEnvironment', + 'Get-InfisicalFolders', + 'Get-InfisicalFolder', + 'New-InfisicalFolder', + 'Update-InfisicalFolder', + 'Remove-InfisicalFolder', + 'Get-InfisicalTags', + 'Get-InfisicalTag', + 'New-InfisicalTag', + 'Update-InfisicalTag', + 'Remove-InfisicalTag' ) AliasesToExport = @() VariablesToExport = @() @@ -163,7 +187,7 @@ if (`$null -eq `$manifest) { Import-Module -Name '$($ModuleDirectory.FullName)' -Force -`$cmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecrets','Get-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets') +`$cmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecrets','Get-InfisicalSecret','New-InfisicalSecret','Update-InfisicalSecret','Remove-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets','Get-InfisicalProjects','Get-InfisicalProject','New-InfisicalProject','Update-InfisicalProject','Remove-InfisicalProject','Get-InfisicalEnvironments','Get-InfisicalEnvironment','New-InfisicalEnvironment','Update-InfisicalEnvironment','Remove-InfisicalEnvironment','Get-InfisicalFolders','Get-InfisicalFolder','New-InfisicalFolder','Update-InfisicalFolder','Remove-InfisicalFolder','Get-InfisicalTags','Get-InfisicalTag','New-InfisicalTag','Update-InfisicalTag','Remove-InfisicalTag') foreach (`$c in `$cmds) { if (-not (Get-Command -Name `$c -Module PSInfisicalAPI -ErrorAction SilentlyContinue)) { throw "Cmdlet not found: `$c" diff --git a/docs/DesignSpec.md b/docs/DesignSpec.md index 030f81d..a7124fd 100644 --- a/docs/DesignSpec.md +++ b/docs/DesignSpec.md @@ -6,15 +6,39 @@ The goal is to establish a strong, reusable, secure framework first, then initially implement secret retrieval and export workflows. -Initial public cmdlets: +Public cmdlets: ```powershell Connect-Infisical Disconnect-Infisical Get-InfisicalSecrets Get-InfisicalSecret +New-InfisicalSecret +Update-InfisicalSecret +Remove-InfisicalSecret +Copy-InfisicalSecret ConvertTo-InfisicalSecretDictionary Export-InfisicalSecrets +Get-InfisicalProjects +Get-InfisicalProject +New-InfisicalProject +Update-InfisicalProject +Remove-InfisicalProject +Get-InfisicalEnvironments +Get-InfisicalEnvironment +New-InfisicalEnvironment +Update-InfisicalEnvironment +Remove-InfisicalEnvironment +Get-InfisicalFolders +Get-InfisicalFolder +New-InfisicalFolder +Update-InfisicalFolder +Remove-InfisicalFolder +Get-InfisicalTags +Get-InfisicalTag +New-InfisicalTag +Update-InfisicalTag +Remove-InfisicalTag ``` Infisical’s public API is REST-based and provides programmatic access for managing secrets and related resources. Current Infisical documentation shows the list-secrets endpoint under `/api/v4/secrets`, the single-secret retrieval endpoint under `/api/v4/secrets/{secretName}`, and Universal Auth login under `/api/v1/auth/universal-auth/login`. The implementation must centralize API endpoint definitions because Infisical uses different API versions across resource families. ([Infisical Blog][1]) @@ -198,8 +222,32 @@ Example shape: 'Disconnect-Infisical', 'Get-InfisicalSecrets', 'Get-InfisicalSecret', + 'New-InfisicalSecret', + 'Update-InfisicalSecret', + 'Remove-InfisicalSecret', + 'Copy-InfisicalSecret', 'ConvertTo-InfisicalSecretDictionary', - 'Export-InfisicalSecrets' + 'Export-InfisicalSecrets', + 'Get-InfisicalProjects', + 'Get-InfisicalProject', + 'New-InfisicalProject', + 'Update-InfisicalProject', + 'Remove-InfisicalProject', + 'Get-InfisicalEnvironments', + 'Get-InfisicalEnvironment', + 'New-InfisicalEnvironment', + 'Update-InfisicalEnvironment', + 'Remove-InfisicalEnvironment', + 'Get-InfisicalFolders', + 'Get-InfisicalFolder', + 'New-InfisicalFolder', + 'Update-InfisicalFolder', + 'Remove-InfisicalFolder', + 'Get-InfisicalTags', + 'Get-InfisicalTag', + 'New-InfisicalTag', + 'Update-InfisicalTag', + 'Remove-InfisicalTag' ) AliasesToExport = @() PrivateData = @{ @@ -698,29 +746,29 @@ Internal implementation must still use proper typed path handling. # 12. Authentication Design -## 12.1 Supported Initial Auth Types +## 12.1 Supported Auth Types -Initial implementation: +Currently implemented: ```text Universal Auth Token Auth +JWT Auth +OIDC Auth +LDAP Auth +Azure Auth +GCP IAM Auth ``` -Infisical documents identity authentication modes such as Universal Auth and Token Auth for API access, and API interaction requires an access token. ([Infisical Blog][3]) +Each implemented provider is exposed as a dedicated `Connect-Infisical` parameter set. Identity-based providers (JWT, OIDC, Azure, GCP IAM) share a common login flow via `IdentityLoginExecutor` and POST to `/api/v1/auth/{provider}-auth/login`. Infisical documents identity authentication modes such as Universal Auth and Token Auth for API access, and API interaction requires an access token. ([Infisical Blog][3]) ## 12.2 Future Auth Types Design must allow future support for: ```text -AWS Auth -Azure Auth -GCP Auth +AWS IAM Auth Kubernetes Auth -OIDC Auth -JWT Auth -LDAP Auth TLS Certificate Auth Alibaba Cloud Auth OCI Auth diff --git a/src/PSInfisicalAPI.Tests/AuthProviderTests.cs b/src/PSInfisicalAPI.Tests/AuthProviderTests.cs new file mode 100644 index 0000000..51c2b13 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/AuthProviderTests.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using PSInfisicalAPI.Authentication; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Security; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class AuthProviderTests + { + private sealed class CapturingHttpClient : IInfisicalHttpClient + { + public InfisicalHttpRequest CapturedRequest { get; private set; } + public string ResponseBody { get; set; } = "{\"accessToken\":\"abc.def.ghi\",\"expiresIn\":3600,\"tokenType\":\"Bearer\"}"; + + public InfisicalHttpResponse Send(InfisicalHttpRequest request) + { + CapturedRequest = request; + return new InfisicalHttpResponse + { + StatusCode = 200, + Body = ResponseBody, + Headers = new Dictionary() + }; + } + } + + private static InfisicalAuthenticationRequest BaseRequest() + { + return new InfisicalAuthenticationRequest + { + BaseUri = new Uri("https://example.invalid"), + ApiVersion = "v1" + }; + } + + [Fact] + public void JwtAuthProvider_Posts_IdentityId_And_Jwt() + { + CapturingHttpClient http = new CapturingHttpClient(); + InfisicalAuthenticationRequest request = BaseRequest(); + request.IdentityId = "identity-1"; + request.Jwt = SecureStringUtility.ToReadOnlySecureString("token.value"); + + InfisicalAuthenticationResult result = new JwtAuthProvider().Authenticate(request, http, null); + + Assert.NotNull(result); + Assert.NotNull(http.CapturedRequest); + Assert.Equal("POST", http.CapturedRequest.Method); + Assert.EndsWith("/api/v1/auth/jwt-auth/login", http.CapturedRequest.Uri.AbsolutePath); + JObject body = JObject.Parse(http.CapturedRequest.Body); + Assert.Equal("identity-1", (string)body["identityId"]); + Assert.Equal("token.value", (string)body["jwt"]); + } + + [Fact] + public void OidcAuthProvider_Posts_IdentityId_And_Jwt_To_Oidc_Endpoint() + { + CapturingHttpClient http = new CapturingHttpClient(); + InfisicalAuthenticationRequest request = BaseRequest(); + request.IdentityId = "identity-2"; + request.Jwt = SecureStringUtility.ToReadOnlySecureString("oidc.token"); + + new OidcAuthProvider().Authenticate(request, http, null); + + Assert.EndsWith("/api/v1/auth/oidc-auth/login", http.CapturedRequest.Uri.AbsolutePath); + JObject body = JObject.Parse(http.CapturedRequest.Body); + Assert.Equal("identity-2", (string)body["identityId"]); + Assert.Equal("oidc.token", (string)body["jwt"]); + } + + [Fact] + public void LdapAuthProvider_Posts_Username_And_Password_To_Ldap_Endpoint() + { + CapturingHttpClient http = new CapturingHttpClient(); + InfisicalAuthenticationRequest request = BaseRequest(); + request.Username = "svc.account"; + request.Password = SecureStringUtility.ToReadOnlySecureString("P@ssw0rd!"); + + new LdapAuthProvider().Authenticate(request, http, null); + + Assert.EndsWith("/api/v1/auth/ldap-auth/login", http.CapturedRequest.Uri.AbsolutePath); + JObject body = JObject.Parse(http.CapturedRequest.Body); + Assert.Equal("svc.account", (string)body["username"]); + Assert.Equal("P@ssw0rd!", (string)body["password"]); + Assert.False(body.ContainsKey("identityId")); + } + + [Fact] + public void LdapAuthProvider_Includes_IdentityId_When_Supplied() + { + CapturingHttpClient http = new CapturingHttpClient(); + InfisicalAuthenticationRequest request = BaseRequest(); + request.Username = "u"; + request.Password = SecureStringUtility.ToReadOnlySecureString("p"); + request.IdentityId = "id-ldap"; + + new LdapAuthProvider().Authenticate(request, http, null); + + JObject body = JObject.Parse(http.CapturedRequest.Body); + Assert.Equal("id-ldap", (string)body["identityId"]); + } + + [Fact] + public void AzureAuthProvider_Posts_IdentityId_And_Jwt_To_Azure_Endpoint() + { + CapturingHttpClient http = new CapturingHttpClient(); + InfisicalAuthenticationRequest request = BaseRequest(); + request.IdentityId = "identity-az"; + request.Jwt = SecureStringUtility.ToReadOnlySecureString("az.token"); + + new AzureAuthProvider().Authenticate(request, http, null); + + Assert.EndsWith("/api/v1/auth/azure-auth/login", http.CapturedRequest.Uri.AbsolutePath); + JObject body = JObject.Parse(http.CapturedRequest.Body); + Assert.Equal("identity-az", (string)body["identityId"]); + Assert.Equal("az.token", (string)body["jwt"]); + } + + [Fact] + public void GcpIamAuthProvider_Posts_IdentityId_And_Jwt_To_Gcp_Endpoint() + { + CapturingHttpClient http = new CapturingHttpClient(); + InfisicalAuthenticationRequest request = BaseRequest(); + request.IdentityId = "identity-gcp"; + request.Jwt = SecureStringUtility.ToReadOnlySecureString("gcp.token"); + + new GcpIamAuthProvider().Authenticate(request, http, null); + + Assert.EndsWith("/api/v1/auth/gcp-auth/login", http.CapturedRequest.Uri.AbsolutePath); + JObject body = JObject.Parse(http.CapturedRequest.Body); + Assert.Equal("identity-gcp", (string)body["identityId"]); + Assert.Equal("gcp.token", (string)body["jwt"]); + } + + [Fact] + public void JwtAuthProvider_Throws_When_IdentityId_Missing() + { + InfisicalAuthenticationRequest request = BaseRequest(); + request.Jwt = SecureStringUtility.ToReadOnlySecureString("x"); + Assert.Throws(() => + new JwtAuthProvider().Authenticate(request, new CapturingHttpClient(), null)); + } + + [Fact] + public void LdapAuthProvider_Throws_When_Password_Missing() + { + InfisicalAuthenticationRequest request = BaseRequest(); + request.Username = "u"; + Assert.Throws(() => + new LdapAuthProvider().Authenticate(request, new CapturingHttpClient(), null)); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs b/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs new file mode 100644 index 0000000..c679d20 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/BulkSecretConverterTests.cs @@ -0,0 +1,209 @@ +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; + +namespace PSInfisicalAPI.Tests +{ + public class BulkSecretConverterTests + { + [Fact] + public void ToCreateItems_Maps_Standard_Keys() + { + Dictionary entry = new Dictionary + { + { "SecretName", "API_KEY" }, + { "SecretValue", "abc" }, + { "SecretComment", "primary" }, + { "SkipMultilineEncoding", "true" }, + { "TagIds", "tag-1,tag-2" } + }; + + InfisicalBulkCreateSecretItem[] items = InfisicalBulkSecretConverter.ToCreateItems(new IDictionary[] { 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() + { + Dictionary entry = new Dictionary + { + { "Name", "API_KEY" }, + { "Value", "abc" } + }; + + InfisicalBulkCreateSecretItem[] items = InfisicalBulkSecretConverter.ToCreateItems(new IDictionary[] { entry }); + Assert.Single(items); + Assert.Equal("API_KEY", items[0].SecretName); + Assert.Equal("abc", items[0].SecretValue); + } + + [Fact] + public void ToCreateItems_Without_SecretName_Throws() + { + Dictionary entry = new Dictionary { { "Value", "abc" } }; + + Assert.Throws(() => + InfisicalBulkSecretConverter.ToCreateItems(new IDictionary[] { entry })); + } + + [Fact] + public void ToUpdateItems_Maps_NewSecretName() + { + Dictionary entry = new Dictionary + { + { "SecretName", "API_KEY" }, + { "NewSecretName", "RENAMED" }, + { "SecretValue", "new-value" } + }; + + InfisicalBulkUpdateSecretItem[] items = InfisicalBulkSecretConverter.ToUpdateItems(new IDictionary[] { 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_Trims_TagId_Whitespace_And_Skips_Empty() + { + Dictionary entry = new Dictionary + { + { "SecretName", "API_KEY" }, + { "SecretValue", "abc" }, + { "TagIds", " tag-1 , , tag-2 " } + }; + + InfisicalBulkCreateSecretItem[] items = InfisicalBulkSecretConverter.ToCreateItems(new IDictionary[] { entry }); + Assert.Equal(new[] { "tag-1", "tag-2" }, items[0].TagIds); + } + + [Fact] + public void ToCreateItems_Empty_Input_Returns_Empty() + { + InfisicalBulkCreateSecretItem[] items = InfisicalBulkSecretConverter.ToCreateItems(null); + 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.Tests/BulkSecretDtoTests.cs b/src/PSInfisicalAPI.Tests/BulkSecretDtoTests.cs new file mode 100644 index 0000000..60af355 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/BulkSecretDtoTests.cs @@ -0,0 +1,166 @@ +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("ProjectId").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("wks-1", (string)json["projectId"]); + 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 BatchCreateRequest_Omits_Null_WorkspaceId_When_Only_ProjectId_Set() + { + object dto = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretBatchCreateRequestDto"); + dto.GetType().GetProperty("ProjectId").SetValue(dto, "wks-1"); + dto.GetType().GetProperty("Environment").SetValue(dto, "prod"); + + JObject json = JObject.Parse(JsonConvert.SerializeObject(dto)); + Assert.False(json.ContainsKey("workspaceId")); + Assert.Equal("wks-1", (string)json["projectId"]); + } + + [Fact] + public void BatchUpdateRequest_Includes_ProjectId_Alongside_WorkspaceId() + { + object dto = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretBatchUpdateRequestDto"); + dto.GetType().GetProperty("WorkspaceId").SetValue(dto, "wks-1"); + dto.GetType().GetProperty("ProjectId").SetValue(dto, "wks-1"); + dto.GetType().GetProperty("Environment").SetValue(dto, "prod"); + + JObject json = JObject.Parse(JsonConvert.SerializeObject(dto)); + Assert.Equal("wks-1", (string)json["workspaceId"]); + Assert.Equal("wks-1", (string)json["projectId"]); + } + + [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("ProjectId").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"]); + Assert.Equal("wks-1", (string)json["projectId"]); + 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 ccd6d61..190a3ad 100644 --- a/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs +++ b/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs @@ -45,5 +45,55 @@ namespace PSInfisicalAPI.Tests { Assert.Throws(() => InfisicalEndpointRegistry.Get("NotARealEndpoint")); } + + [Theory] + [InlineData(InfisicalEndpointNames.CreateSecret, "POST", "/api/v3/secrets/raw/{secretName}")] + [InlineData(InfisicalEndpointNames.UpdateSecret, "PATCH", "/api/v3/secrets/raw/{secretName}")] + [InlineData(InfisicalEndpointNames.DeleteSecret, "DELETE", "/api/v3/secrets/raw/{secretName}")] + [InlineData(InfisicalEndpointNames.ListProjects, "GET", "/api/v1/workspace")] + [InlineData(InfisicalEndpointNames.RetrieveProject, "GET", "/api/v1/workspace/{projectId}")] + [InlineData(InfisicalEndpointNames.CreateProject, "POST", "/api/v2/workspace")] + [InlineData(InfisicalEndpointNames.UpdateProject, "PATCH", "/api/v1/workspace/{projectId}")] + [InlineData(InfisicalEndpointNames.DeleteProject, "DELETE", "/api/v1/workspace/{projectId}")] + [InlineData(InfisicalEndpointNames.CreateEnvironment, "POST", "/api/v1/workspace/{projectId}/environments")] + [InlineData(InfisicalEndpointNames.UpdateEnvironment, "PATCH", "/api/v1/workspace/{projectId}/environments/{environmentId}")] + [InlineData(InfisicalEndpointNames.DeleteEnvironment, "DELETE", "/api/v1/workspace/{projectId}/environments/{environmentId}")] + [InlineData(InfisicalEndpointNames.ListFolders, "GET", "/api/v1/folders")] + [InlineData(InfisicalEndpointNames.CreateFolder, "POST", "/api/v1/folders")] + [InlineData(InfisicalEndpointNames.UpdateFolder, "PATCH", "/api/v1/folders/{folderId}")] + [InlineData(InfisicalEndpointNames.DeleteFolder, "DELETE", "/api/v1/folders/{folderId}")] + [InlineData(InfisicalEndpointNames.ListTags, "GET", "/api/v1/workspace/{projectId}/tags")] + [InlineData(InfisicalEndpointNames.CreateTag, "POST", "/api/v1/workspace/{projectId}/tags")] + [InlineData(InfisicalEndpointNames.UpdateTag, "PATCH", "/api/v1/workspace/{projectId}/tags/{tagId}")] + [InlineData(InfisicalEndpointNames.DeleteTag, "DELETE", "/api/v1/workspace/{projectId}/tags/{tagId}")] + [InlineData(InfisicalEndpointNames.JwtAuthLogin, "POST", "/api/v1/auth/jwt-auth/login")] + [InlineData(InfisicalEndpointNames.OidcAuthLogin, "POST", "/api/v1/auth/oidc-auth/login")] + [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/v4/secrets/batch")] + [InlineData(InfisicalEndpointNames.BulkUpdateSecret, "PATCH", "/api/v4/secrets/batch")] + [InlineData(InfisicalEndpointNames.BulkDeleteSecret, "DELETE", "/api/v4/secrets/batch")] + [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); + Assert.Equal(method, definition.Method); + Assert.Equal(template, definition.Template); + } + + [Theory] + [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")] + public void Bulk_Endpoints_Retain_V3_Fallback_Candidate(string name, string method, string template) + { + System.Collections.Generic.IReadOnlyList candidates = InfisicalEndpointRegistry.GetCandidates(name); + Assert.Equal(2, candidates.Count); + Assert.Equal("v4", candidates[0].Version); + Assert.Equal("v3", candidates[1].Version); + Assert.Equal(method, candidates[1].Method); + Assert.Equal(template, candidates[1].Template); + } } } diff --git a/src/PSInfisicalAPI.Tests/EnvironmentMapperTests.cs b/src/PSInfisicalAPI.Tests/EnvironmentMapperTests.cs new file mode 100644 index 0000000..302daae --- /dev/null +++ b/src/PSInfisicalAPI.Tests/EnvironmentMapperTests.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using PSInfisicalAPI.Models; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class EnvironmentMapperTests + { + private static readonly System.Type MapperType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly + .GetType("PSInfisicalAPI.Environments.InfisicalEnvironmentMapper", true); + + private static readonly System.Type DtoType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly + .GetType("PSInfisicalAPI.Environments.InfisicalEnvironmentResponseDto", true); + + private static InfisicalEnvironment InvokeMap(object dto, string fallbackProjectId) + { + MethodInfo map = MapperType.GetMethod("Map", BindingFlags.Public | BindingFlags.Static); + return (InfisicalEnvironment)map.Invoke(null, new object[] { dto, fallbackProjectId }); + } + + [Fact] + public void Map_Null_Returns_Null() + { + Assert.Null(InvokeMap(null, "proj-x")); + } + + [Fact] + public void Map_Populates_Fields_With_Explicit_ProjectId() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "env-001"); + DtoType.GetProperty("Name").SetValue(dto, "Production"); + DtoType.GetProperty("Slug").SetValue(dto, "prod"); + DtoType.GetProperty("Position").SetValue(dto, 1); + DtoType.GetProperty("ProjectId").SetValue(dto, "proj-001"); + + InfisicalEnvironment env = InvokeMap(dto, "fallback-proj"); + + Assert.Equal("env-001", env.Id); + Assert.Equal("Production", env.Name); + Assert.Equal("prod", env.Slug); + Assert.Equal(1, env.Position); + Assert.Equal("proj-001", env.ProjectId); + } + + [Fact] + public void Map_Uses_WorkspaceId_When_ProjectId_Empty() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "env-002"); + DtoType.GetProperty("WorkspaceId").SetValue(dto, "wks-002"); + + InfisicalEnvironment env = InvokeMap(dto, "fallback-proj"); + Assert.Equal("wks-002", env.ProjectId); + } + + [Fact] + public void Map_Uses_Fallback_When_No_ProjectId_Or_WorkspaceId() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "env-003"); + + InfisicalEnvironment env = InvokeMap(dto, "fallback-proj"); + Assert.Equal("fallback-proj", env.ProjectId); + } + + [Fact] + public void Map_Falls_Back_To_InternalId_For_Id() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("InternalId").SetValue(dto, "internal-env"); + + InfisicalEnvironment env = InvokeMap(dto, "p"); + Assert.Equal("internal-env", env.Id); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/FolderMapperTests.cs b/src/PSInfisicalAPI.Tests/FolderMapperTests.cs new file mode 100644 index 0000000..f4427a7 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/FolderMapperTests.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using PSInfisicalAPI.Models; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class FolderMapperTests + { + private static readonly System.Type MapperType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly + .GetType("PSInfisicalAPI.Folders.InfisicalFolderMapper", true); + + private static readonly System.Type DtoType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly + .GetType("PSInfisicalAPI.Folders.InfisicalFolderResponseDto", true); + + private static InfisicalFolder InvokeMap(object dto, string fallbackProjectId, string fallbackEnvironment) + { + MethodInfo map = MapperType.GetMethod("Map", BindingFlags.Public | BindingFlags.Static); + return (InfisicalFolder)map.Invoke(null, new object[] { dto, fallbackProjectId, fallbackEnvironment }); + } + + [Fact] + public void Map_Null_Returns_Null() + { + Assert.Null(InvokeMap(null, "proj-x", "dev")); + } + + [Fact] + public void Map_Populates_Fields_With_Explicit_ProjectId() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "fld-001"); + DtoType.GetProperty("Name").SetValue(dto, "config"); + DtoType.GetProperty("Path").SetValue(dto, "/app/config"); + DtoType.GetProperty("ParentId").SetValue(dto, "fld-root"); + DtoType.GetProperty("Environment").SetValue(dto, "prod"); + DtoType.GetProperty("ProjectId").SetValue(dto, "proj-001"); + + InfisicalFolder folder = InvokeMap(dto, "fallback-proj", "fallback-env"); + + Assert.Equal("fld-001", folder.Id); + Assert.Equal("config", folder.Name); + Assert.Equal("/app/config", folder.Path); + Assert.Equal("fld-root", folder.ParentId); + Assert.Equal("prod", folder.Environment); + Assert.Equal("proj-001", folder.ProjectId); + } + + [Fact] + public void Map_Uses_WorkspaceId_When_ProjectId_Empty() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "fld-002"); + DtoType.GetProperty("WorkspaceId").SetValue(dto, "wks-002"); + + InfisicalFolder folder = InvokeMap(dto, "fallback-proj", "fallback-env"); + Assert.Equal("wks-002", folder.ProjectId); + } + + [Fact] + public void Map_Uses_Fallback_When_No_ProjectId_Or_Environment() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "fld-003"); + + InfisicalFolder folder = InvokeMap(dto, "fallback-proj", "fallback-env"); + Assert.Equal("fallback-proj", folder.ProjectId); + Assert.Equal("fallback-env", folder.Environment); + } + + [Fact] + public void Map_Falls_Back_To_InternalId_For_Id() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("InternalId").SetValue(dto, "internal-fld"); + + InfisicalFolder folder = InvokeMap(dto, "p", "e"); + Assert.Equal("internal-fld", folder.Id); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj b/src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj index 80f740d..c602801 100644 --- a/src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj +++ b/src/PSInfisicalAPI.Tests/PSInfisicalAPI.Tests.csproj @@ -2,6 +2,7 @@ net8.0 + LatestMajor 9.0 false PSInfisicalAPI.Tests diff --git a/src/PSInfisicalAPI.Tests/ProjectMapperTests.cs b/src/PSInfisicalAPI.Tests/ProjectMapperTests.cs new file mode 100644 index 0000000..5c02718 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/ProjectMapperTests.cs @@ -0,0 +1,108 @@ +using System.Collections; +using System.Reflection; +using PSInfisicalAPI.Models; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class ProjectMapperTests + { + private static readonly System.Type MapperType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly + .GetType("PSInfisicalAPI.Projects.InfisicalProjectMapper", true); + + private static readonly System.Type DtoType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly + .GetType("PSInfisicalAPI.Projects.InfisicalProjectResponseDto", true); + + private static readonly System.Type EnvDtoType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly + .GetType("PSInfisicalAPI.Projects.InfisicalProjectEnvironmentDto", true); + + private static InfisicalProject InvokeMap(object dto) + { + MethodInfo map = MapperType.GetMethod("Map", BindingFlags.Public | BindingFlags.Static); + return (InfisicalProject)map.Invoke(null, new[] { dto }); + } + + [Fact] + public void Map_Null_Dto_Returns_Null() + { + Assert.Null(InvokeMap(null)); + } + + [Fact] + public void Map_Populates_Core_Fields() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "proj-001"); + DtoType.GetProperty("Name").SetValue(dto, "DevOps"); + DtoType.GetProperty("Slug").SetValue(dto, "devops"); + DtoType.GetProperty("Description").SetValue(dto, "Internal DevOps project"); + DtoType.GetProperty("Organization").SetValue(dto, "org-abc"); + DtoType.GetProperty("Type").SetValue(dto, "secret-manager"); + DtoType.GetProperty("AutoCapitalization").SetValue(dto, true); + DtoType.GetProperty("CreatedAt").SetValue(dto, "2026-01-15T12:34:56Z"); + DtoType.GetProperty("UpdatedAt").SetValue(dto, "2026-02-20T09:00:00Z"); + + InfisicalProject project = InvokeMap(dto); + + Assert.Equal("proj-001", project.Id); + Assert.Equal("DevOps", project.Name); + Assert.Equal("devops", project.Slug); + Assert.Equal("Internal DevOps project", project.Description); + Assert.Equal("org-abc", project.OrganizationId); + Assert.Equal("secret-manager", project.Type); + Assert.True(project.AutoCapitalization); + Assert.NotNull(project.CreatedAtUtc); + Assert.NotNull(project.UpdatedAtUtc); + Assert.Empty(project.EnvironmentSlugs); + } + + [Fact] + public void Map_Falls_Back_To_InternalId_And_OrgId() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("InternalId").SetValue(dto, "internal-id-1"); + DtoType.GetProperty("OrgId").SetValue(dto, "org-fallback"); + + InfisicalProject project = InvokeMap(dto); + + Assert.Equal("internal-id-1", project.Id); + Assert.Equal("org-fallback", project.OrganizationId); + } + + [Fact] + public void Map_Extracts_EnvironmentSlugs() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "proj-002"); + + System.Type listType = typeof(System.Collections.Generic.List<>).MakeGenericType(EnvDtoType); + IList envs = (IList)System.Activator.CreateInstance(listType); + + object env1 = System.Activator.CreateInstance(EnvDtoType); + EnvDtoType.GetProperty("Slug").SetValue(env1, "dev"); + EnvDtoType.GetProperty("Name").SetValue(env1, "Development"); + envs.Add(env1); + + object env2 = System.Activator.CreateInstance(EnvDtoType); + EnvDtoType.GetProperty("Slug").SetValue(env2, "prod"); + envs.Add(env2); + + DtoType.GetProperty("Environments").SetValue(dto, envs); + + InfisicalProject project = InvokeMap(dto); + + Assert.Equal(2, project.EnvironmentSlugs.Length); + Assert.Contains("dev", project.EnvironmentSlugs); + Assert.Contains("prod", project.EnvironmentSlugs); + } + + [Fact] + public void MapMany_Null_Returns_Empty() + { + MethodInfo mapMany = MapperType.GetMethod("MapMany", BindingFlags.Public | BindingFlags.Static); + InfisicalProject[] result = (InfisicalProject[])mapMany.Invoke(null, new object[] { null }); + Assert.NotNull(result); + Assert.Empty(result); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/SecretMutationDtoTests.cs b/src/PSInfisicalAPI.Tests/SecretMutationDtoTests.cs new file mode 100644 index 0000000..70d0700 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/SecretMutationDtoTests.cs @@ -0,0 +1,73 @@ +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class SecretMutationDtoTests + { + 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 CreateRequestDto_Serializes_With_Expected_Field_Names() + { + object dto = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretCreateRequestDto"); + 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("Type").SetValue(dto, "shared"); + dto.GetType().GetProperty("SecretValue").SetValue(dto, "p@ss"); + dto.GetType().GetProperty("SecretComment").SetValue(dto, "comment"); + + 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"]); + Assert.Equal("shared", (string)json["type"]); + Assert.Equal("p@ss", (string)json["secretValue"]); + Assert.Equal("comment", (string)json["secretComment"]); + Assert.False(json.ContainsKey("skipMultilineEncoding")); + Assert.False(json.ContainsKey("tagIds")); + } + + [Fact] + public void UpdateRequestDto_Omits_Null_Optional_Fields() + { + object dto = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretUpdateRequestDto"); + dto.GetType().GetProperty("WorkspaceId").SetValue(dto, "wks-1"); + dto.GetType().GetProperty("Environment").SetValue(dto, "prod"); + dto.GetType().GetProperty("NewSecretName").SetValue(dto, "renamed"); + + JObject json = JObject.Parse(JsonConvert.SerializeObject(dto)); + Assert.Equal("renamed", (string)json["newSecretName"]); + Assert.False(json.ContainsKey("secretValue")); + Assert.False(json.ContainsKey("secretComment")); + Assert.False(json.ContainsKey("type")); + Assert.False(json.ContainsKey("secretPath")); + } + + [Fact] + public void DeleteRequestDto_Serializes_With_Expected_Field_Names() + { + object dto = MakeDto("PSInfisicalAPI.Secrets.InfisicalSecretDeleteRequestDto"); + 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("Type").SetValue(dto, "shared"); + + 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"]); + Assert.Equal("shared", (string)json["type"]); + } + } +} diff --git a/src/PSInfisicalAPI.Tests/TagMapperTests.cs b/src/PSInfisicalAPI.Tests/TagMapperTests.cs new file mode 100644 index 0000000..12af553 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/TagMapperTests.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using PSInfisicalAPI.Models; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + public class TagMapperTests + { + private static readonly System.Type MapperType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly + .GetType("PSInfisicalAPI.Tags.InfisicalTagMapper", true); + + private static readonly System.Type DtoType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly + .GetType("PSInfisicalAPI.Tags.InfisicalTagResponseDto", true); + + private static InfisicalTag InvokeMap(object dto, string fallbackProjectId) + { + MethodInfo map = MapperType.GetMethod("Map", BindingFlags.Public | BindingFlags.Static); + return (InfisicalTag)map.Invoke(null, new object[] { dto, fallbackProjectId }); + } + + [Fact] + public void Map_Null_Returns_Null() + { + Assert.Null(InvokeMap(null, "proj-x")); + } + + [Fact] + public void Map_Populates_Fields_With_Explicit_ProjectId() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "tag-001"); + DtoType.GetProperty("Slug").SetValue(dto, "critical"); + DtoType.GetProperty("Name").SetValue(dto, "Critical"); + DtoType.GetProperty("Color").SetValue(dto, "#FF0000"); + DtoType.GetProperty("ProjectId").SetValue(dto, "proj-001"); + + InfisicalTag tag = InvokeMap(dto, "fallback-proj"); + + Assert.Equal("tag-001", tag.Id); + Assert.Equal("critical", tag.Slug); + Assert.Equal("Critical", tag.Name); + Assert.Equal("#FF0000", tag.Color); + Assert.Equal("proj-001", tag.ProjectId); + } + + [Fact] + public void Map_Uses_WorkspaceId_When_ProjectId_Empty() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "tag-002"); + DtoType.GetProperty("WorkspaceId").SetValue(dto, "wks-002"); + + InfisicalTag tag = InvokeMap(dto, "fallback-proj"); + Assert.Equal("wks-002", tag.ProjectId); + } + + [Fact] + public void Map_Uses_Fallback_When_No_ProjectId_Or_WorkspaceId() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("Id").SetValue(dto, "tag-003"); + + InfisicalTag tag = InvokeMap(dto, "fallback-proj"); + Assert.Equal("fallback-proj", tag.ProjectId); + } + + [Fact] + public void Map_Falls_Back_To_InternalId_For_Id() + { + object dto = System.Activator.CreateInstance(DtoType); + DtoType.GetProperty("InternalId").SetValue(dto, "internal-tag"); + + InfisicalTag tag = InvokeMap(dto, "p"); + Assert.Equal("internal-tag", tag.Id); + } + } +} diff --git a/src/PSInfisicalAPI/Authentication/AzureAuthProvider.cs b/src/PSInfisicalAPI/Authentication/AzureAuthProvider.cs new file mode 100644 index 0000000..9d9b53a --- /dev/null +++ b/src/PSInfisicalAPI/Authentication/AzureAuthProvider.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Authentication +{ + public sealed class AzureAuthProvider : IInfisicalAuthProvider + { + private const string Component = "AzureAuthProvider"; + + public string Name { get { return "AzureAuth"; } } + + public InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (request == null || string.IsNullOrEmpty(request.IdentityId)) + { + throw new InfisicalAuthenticationException("IdentityId is required for Azure Auth."); + } + + if (request.Jwt == null || request.Jwt.Length == 0) + { + throw new InfisicalAuthenticationException("Jwt is required for Azure Auth."); + } + + return IdentityLoginExecutor.Execute(InfisicalEndpointNames.AzureAuthLogin, Component, request, httpClient, logger, serializer => + { + return SecureStringUtility.UsePlainText(request.Jwt, plainJwt => + { + Dictionary bodyObject = new Dictionary + { + { "identityId", request.IdentityId }, + { "jwt", plainJwt ?? string.Empty } + }; + + return serializer.Serialize(bodyObject); + }); + }); + } + } +} diff --git a/src/PSInfisicalAPI/Authentication/GcpIamAuthProvider.cs b/src/PSInfisicalAPI/Authentication/GcpIamAuthProvider.cs new file mode 100644 index 0000000..3423ea6 --- /dev/null +++ b/src/PSInfisicalAPI/Authentication/GcpIamAuthProvider.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Authentication +{ + public sealed class GcpIamAuthProvider : IInfisicalAuthProvider + { + private const string Component = "GcpIamAuthProvider"; + + public string Name { get { return "GcpIamAuth"; } } + + public InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (request == null || string.IsNullOrEmpty(request.IdentityId)) + { + throw new InfisicalAuthenticationException("IdentityId is required for GCP IAM Auth."); + } + + if (request.Jwt == null || request.Jwt.Length == 0) + { + throw new InfisicalAuthenticationException("Jwt is required for GCP IAM Auth."); + } + + return IdentityLoginExecutor.Execute(InfisicalEndpointNames.GcpIamAuthLogin, Component, request, httpClient, logger, serializer => + { + return SecureStringUtility.UsePlainText(request.Jwt, plainJwt => + { + Dictionary bodyObject = new Dictionary + { + { "identityId", request.IdentityId }, + { "jwt", plainJwt ?? string.Empty } + }; + + return serializer.Serialize(bodyObject); + }); + }); + } + } +} diff --git a/src/PSInfisicalAPI/Authentication/IdentityLoginExecutor.cs b/src/PSInfisicalAPI/Authentication/IdentityLoginExecutor.cs new file mode 100644 index 0000000..1e3baf0 --- /dev/null +++ b/src/PSInfisicalAPI/Authentication/IdentityLoginExecutor.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Security; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Security; +using PSInfisicalAPI.Serialization; + +namespace PSInfisicalAPI.Authentication +{ + internal static class IdentityLoginExecutor + { + internal static InfisicalAuthenticationResult Execute( + string endpointName, + string component, + InfisicalAuthenticationRequest request, + IInfisicalHttpClient httpClient, + IInfisicalLogger logger, + Func bodyFactory) + { + if (request == null) { throw new ArgumentNullException(nameof(request)); } + if (httpClient == null) { throw new ArgumentNullException(nameof(httpClient)); } + if (bodyFactory == null) { throw new ArgumentNullException(nameof(bodyFactory)); } + + IInfisicalLogger log = logger ?? NullInfisicalLogger.Instance; + log.Information(component, "Attempting to authenticate to Infisical. Please Wait..."); + + InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(endpointName); + Uri uri = InfisicalUriBuilder.Build(request.BaseUri, definition, null, null); + JsonInfisicalSerializer serializer = new JsonInfisicalSerializer(); + string body = bodyFactory(serializer); + + InfisicalHttpRequest httpRequest = new InfisicalHttpRequest + { + OperationName = "Authenticate", + EndpointName = definition.Name, + Method = definition.Method, + Uri = uri, + Body = body, + ContentType = "application/json", + ContainsSecretMaterialInRequest = definition.ContainsSecretMaterialInRequest, + ContainsSecretMaterialInResponse = definition.ContainsSecretMaterialInResponse, + Headers = new Dictionary { { "Accept", "application/json" } } + }; + + InfisicalHttpResponse response = httpClient.Send(httpRequest); + + try + { + if (response.StatusCode < 200 || response.StatusCode >= 300) + { + log.Error(component, "Infisical authentication failed."); + throw new InfisicalAuthenticationException(string.Concat(component, " login returned status ", response.StatusCode.ToString(CultureInfo.InvariantCulture), ".")); + } + + IdentityLoginResponse parsed = serializer.Deserialize(response.Body); + if (parsed == null || string.IsNullOrEmpty(parsed.AccessToken)) + { + throw new InfisicalAuthenticationException(string.Concat(component, " login response did not contain an access token.")); + } + + SecureString accessToken = SecureStringUtility.ToReadOnlySecureString(parsed.AccessToken); + + DateTimeOffset? expiresAt = null; + if (parsed.ExpiresIn > 0) + { + expiresAt = DateTimeOffset.UtcNow.AddSeconds(parsed.ExpiresIn); + } + + parsed.AccessToken = null; + + log.Information(component, "Infisical authentication was successful."); + return new InfisicalAuthenticationResult + { + AccessToken = accessToken, + TokenType = string.IsNullOrEmpty(parsed.TokenType) ? "Bearer" : parsed.TokenType, + ExpiresAtUtc = expiresAt + }; + } + finally + { + response.Clear(); + } + } + + private sealed class IdentityLoginResponse + { + [Newtonsoft.Json.JsonProperty("accessToken")] + public string AccessToken { get; set; } + + [Newtonsoft.Json.JsonProperty("expiresIn")] + public int ExpiresIn { get; set; } + + [Newtonsoft.Json.JsonProperty("tokenType")] + public string TokenType { get; set; } + } + } +} diff --git a/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationRequest.cs b/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationRequest.cs index f2c5be8..6fecb09 100644 --- a/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationRequest.cs +++ b/src/PSInfisicalAPI/Authentication/InfisicalAuthenticationRequest.cs @@ -10,5 +10,10 @@ namespace PSInfisicalAPI.Authentication public string ClientId { get; set; } public SecureString ClientSecret { get; set; } public SecureString PreSuppliedAccessToken { get; set; } + + public string IdentityId { get; set; } + public SecureString Jwt { get; set; } + public string Username { get; set; } + public SecureString Password { get; set; } } } diff --git a/src/PSInfisicalAPI/Authentication/JwtAuthProvider.cs b/src/PSInfisicalAPI/Authentication/JwtAuthProvider.cs new file mode 100644 index 0000000..e9ee4e8 --- /dev/null +++ b/src/PSInfisicalAPI/Authentication/JwtAuthProvider.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Authentication +{ + public sealed class JwtAuthProvider : IInfisicalAuthProvider + { + private const string Component = "JwtAuthProvider"; + + public string Name { get { return "JwtAuth"; } } + + public InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (request == null || string.IsNullOrEmpty(request.IdentityId)) + { + throw new InfisicalAuthenticationException("IdentityId is required for JWT Auth."); + } + + if (request.Jwt == null || request.Jwt.Length == 0) + { + throw new InfisicalAuthenticationException("Jwt is required for JWT Auth."); + } + + return IdentityLoginExecutor.Execute(InfisicalEndpointNames.JwtAuthLogin, Component, request, httpClient, logger, serializer => + { + return SecureStringUtility.UsePlainText(request.Jwt, plainJwt => + { + Dictionary bodyObject = new Dictionary + { + { "identityId", request.IdentityId }, + { "jwt", plainJwt ?? string.Empty } + }; + + return serializer.Serialize(bodyObject); + }); + }); + } + } +} diff --git a/src/PSInfisicalAPI/Authentication/LdapAuthProvider.cs b/src/PSInfisicalAPI/Authentication/LdapAuthProvider.cs new file mode 100644 index 0000000..d670dd3 --- /dev/null +++ b/src/PSInfisicalAPI/Authentication/LdapAuthProvider.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Authentication +{ + public sealed class LdapAuthProvider : IInfisicalAuthProvider + { + private const string Component = "LdapAuthProvider"; + + public string Name { get { return "LdapAuth"; } } + + public InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (request == null || string.IsNullOrEmpty(request.Username)) + { + throw new InfisicalAuthenticationException("Username is required for LDAP Auth."); + } + + if (request.Password == null || request.Password.Length == 0) + { + throw new InfisicalAuthenticationException("Password is required for LDAP Auth."); + } + + return IdentityLoginExecutor.Execute(InfisicalEndpointNames.LdapAuthLogin, Component, request, httpClient, logger, serializer => + { + return SecureStringUtility.UsePlainText(request.Password, plainPassword => + { + Dictionary bodyObject = new Dictionary + { + { "username", request.Username }, + { "password", plainPassword ?? string.Empty } + }; + + if (!string.IsNullOrEmpty(request.IdentityId)) + { + bodyObject["identityId"] = request.IdentityId; + } + + return serializer.Serialize(bodyObject); + }); + }); + } + } +} diff --git a/src/PSInfisicalAPI/Authentication/OidcAuthProvider.cs b/src/PSInfisicalAPI/Authentication/OidcAuthProvider.cs new file mode 100644 index 0000000..5361010 --- /dev/null +++ b/src/PSInfisicalAPI/Authentication/OidcAuthProvider.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Authentication +{ + public sealed class OidcAuthProvider : IInfisicalAuthProvider + { + private const string Component = "OidcAuthProvider"; + + public string Name { get { return "OidcAuth"; } } + + public InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (request == null || string.IsNullOrEmpty(request.IdentityId)) + { + throw new InfisicalAuthenticationException("IdentityId is required for OIDC Auth."); + } + + if (request.Jwt == null || request.Jwt.Length == 0) + { + throw new InfisicalAuthenticationException("Jwt is required for OIDC Auth."); + } + + return IdentityLoginExecutor.Execute(InfisicalEndpointNames.OidcAuthLogin, Component, request, httpClient, logger, serializer => + { + return SecureStringUtility.UsePlainText(request.Jwt, plainJwt => + { + Dictionary bodyObject = new Dictionary + { + { "identityId", request.IdentityId }, + { "jwt", plainJwt ?? string.Empty } + }; + + return serializer.Serialize(bodyObject); + }); + }); + } + } +} 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/ConnectInfisicalCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs index f5e6bdd..08834df 100644 --- a/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs @@ -15,6 +15,11 @@ namespace PSInfisicalAPI.Cmdlets { private const string ParameterSetUniversalAuth = "UniversalAuth"; private const string ParameterSetToken = "Token"; + private const string ParameterSetJwt = "JwtAuth"; + private const string ParameterSetOidc = "OidcAuth"; + private const string ParameterSetLdap = "LdapAuth"; + private const string ParameterSetAzure = "AzureAuth"; + private const string ParameterSetGcpIam = "GcpIamAuth"; private const string Component = "ConnectInfisicalCmdlet"; [Parameter] @@ -38,6 +43,25 @@ namespace PSInfisicalAPI.Cmdlets [Parameter(ParameterSetName = ParameterSetToken)] public SecureString AccessToken { get; set; } + [Parameter(Mandatory = true, ParameterSetName = ParameterSetJwt)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSetOidc)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSetAzure)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSetGcpIam)] + [Parameter(ParameterSetName = ParameterSetLdap)] + public string IdentityId { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetJwt)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSetOidc)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSetAzure)] + [Parameter(Mandatory = true, ParameterSetName = ParameterSetGcpIam)] + public SecureString Jwt { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetLdap)] + public string Username { get; set; } + + [Parameter(Mandatory = true, ParameterSetName = ParameterSetLdap)] + public SecureString Password { get; set; } + [Parameter] public string SecretPath { get; set; } = "/"; @@ -58,28 +82,91 @@ namespace PSInfisicalAPI.Cmdlets InfisicalAuthenticationRequest request; InfisicalAuthType authType; - if (string.Equals(ParameterSetName, ParameterSetToken, StringComparison.Ordinal)) + switch (ParameterSetName) { - provider = new TokenAuthProvider(); - authType = InfisicalAuthType.Token; - request = new InfisicalAuthenticationRequest - { - BaseUri = BaseUri, - ApiVersion = ApiVersion, - PreSuppliedAccessToken = AccessToken - }; - } - else - { - provider = new UniversalAuthProvider(); - authType = InfisicalAuthType.UniversalAuth; - request = new InfisicalAuthenticationRequest - { - BaseUri = BaseUri, - ApiVersion = ApiVersion, - ClientId = ClientId, - ClientSecret = ClientSecret - }; + case ParameterSetToken: + provider = new TokenAuthProvider(); + authType = InfisicalAuthType.Token; + request = new InfisicalAuthenticationRequest + { + BaseUri = BaseUri, + ApiVersion = ApiVersion, + PreSuppliedAccessToken = AccessToken + }; + break; + + case ParameterSetJwt: + provider = new JwtAuthProvider(); + authType = InfisicalAuthType.Jwt; + request = new InfisicalAuthenticationRequest + { + BaseUri = BaseUri, + ApiVersion = ApiVersion, + IdentityId = IdentityId, + Jwt = Jwt + }; + break; + + case ParameterSetOidc: + provider = new OidcAuthProvider(); + authType = InfisicalAuthType.Oidc; + request = new InfisicalAuthenticationRequest + { + BaseUri = BaseUri, + ApiVersion = ApiVersion, + IdentityId = IdentityId, + Jwt = Jwt + }; + break; + + case ParameterSetLdap: + provider = new LdapAuthProvider(); + authType = InfisicalAuthType.Ldap; + request = new InfisicalAuthenticationRequest + { + BaseUri = BaseUri, + ApiVersion = ApiVersion, + IdentityId = IdentityId, + Username = Username, + Password = Password + }; + break; + + case ParameterSetAzure: + provider = new AzureAuthProvider(); + authType = InfisicalAuthType.Azure; + request = new InfisicalAuthenticationRequest + { + BaseUri = BaseUri, + ApiVersion = ApiVersion, + IdentityId = IdentityId, + Jwt = Jwt + }; + break; + + case ParameterSetGcpIam: + provider = new GcpIamAuthProvider(); + authType = InfisicalAuthType.GcpIam; + request = new InfisicalAuthenticationRequest + { + BaseUri = BaseUri, + ApiVersion = ApiVersion, + IdentityId = IdentityId, + Jwt = Jwt + }; + break; + + default: + provider = new UniversalAuthProvider(); + authType = InfisicalAuthType.UniversalAuth; + request = new InfisicalAuthenticationRequest + { + BaseUri = BaseUri, + ApiVersion = ApiVersion, + ClientId = ClientId, + ClientSecret = ClientSecret + }; + break; } InfisicalAuthenticationResult authResult = provider.Authenticate(request, HttpClient, Logger); @@ -123,6 +210,7 @@ namespace PSInfisicalAPI.Cmdlets private void ResolveMissingParametersFromEnvironment() { bool tokenSet = string.Equals(ParameterSetName, ParameterSetToken, StringComparison.Ordinal); + bool universalSet = string.Equals(ParameterSetName, ParameterSetUniversalAuth, StringComparison.Ordinal); bool needsScan = BaseUri == null || @@ -130,8 +218,8 @@ namespace PSInfisicalAPI.Cmdlets string.IsNullOrWhiteSpace(ProjectId) || string.IsNullOrWhiteSpace(Environment) || (tokenSet && (AccessToken == null || AccessToken.Length == 0)) || - (!tokenSet && string.IsNullOrWhiteSpace(ClientId)) || - (!tokenSet && (ClientSecret == null || ClientSecret.Length == 0)); + (universalSet && string.IsNullOrWhiteSpace(ClientId)) || + (universalSet && (ClientSecret == null || ClientSecret.Length == 0)); if (!needsScan) { @@ -161,7 +249,7 @@ namespace PSInfisicalAPI.Cmdlets { AccessToken = InfisicalEnvironmentResolver.ResolveSecureString("AccessToken", InfisicalEnvironmentResolver.AccessTokenPatterns, AccessToken, Logger); } - else + else if (universalSet) { ClientId = InfisicalEnvironmentResolver.ResolveString("ClientId", InfisicalEnvironmentResolver.ClientIdPatterns, ClientId, Logger); ClientSecret = InfisicalEnvironmentResolver.ResolveSecureString("ClientSecret", InfisicalEnvironmentResolver.ClientSecretPatterns, ClientSecret, Logger); @@ -199,7 +287,7 @@ namespace PSInfisicalAPI.Cmdlets { if (AccessToken == null || AccessToken.Length == 0) { missing.Add("AccessToken"); } } - else + else if (string.Equals(ParameterSetName, ParameterSetUniversalAuth, StringComparison.Ordinal)) { if (string.IsNullOrWhiteSpace(ClientId)) { missing.Add("ClientId"); } if (ClientSecret == null || ClientSecret.Length == 0) { missing.Add("ClientSecret"); } 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 new file mode 100644 index 0000000..728ec32 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentCmdlet.cs @@ -0,0 +1,38 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Environments; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "InfisicalEnvironment")] + [OutputType(typeof(InfisicalEnvironment))] + public sealed class GetInfisicalEnvironmentCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Slug", "Id", "Environment")] + public string EnvironmentSlugOrId { get; set; } + + [Parameter] public string ProjectId { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); + InfisicalEnvironment env = client.Retrieve(connection, resolvedProjectId, EnvironmentSlugOrId); + if (env != null) + { + WriteObject(env); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("GetInfisicalEnvironmentCmdlet", "RetrieveEnvironment", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentsCmdlet.cs new file mode 100644 index 0000000..2128cda --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentsCmdlet.cs @@ -0,0 +1,34 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Environments; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "InfisicalEnvironments")] + [OutputType(typeof(InfisicalEnvironment))] + public sealed class GetInfisicalEnvironmentsCmdlet : InfisicalCmdletBase + { + [Parameter] public string ProjectId { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); + InfisicalEnvironment[] envs = client.List(connection, resolvedProjectId); + foreach (InfisicalEnvironment env in envs) + { + WriteObject(env); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("GetInfisicalEnvironmentsCmdlet", "ListEnvironments", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs new file mode 100644 index 0000000..22ff5ee --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs @@ -0,0 +1,42 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Folders; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "InfisicalFolder")] + [OutputType(typeof(InfisicalFolder))] + public sealed class GetInfisicalFolderCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Name", "Id")] + public string FolderNameOrId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Environment { get; set; } + [Parameter] public string Path { get; set; } + + protected override void ProcessRecord() + { + 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, resolvedProjectId, resolvedEnvironment, resolvedPath, FolderNameOrId); + if (folder != null) + { + WriteObject(folder); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("GetInfisicalFolderCmdlet", "RetrieveFolder", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalFoldersCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFoldersCmdlet.cs new file mode 100644 index 0000000..4c3b2a6 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFoldersCmdlet.cs @@ -0,0 +1,38 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Folders; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "InfisicalFolders")] + [OutputType(typeof(InfisicalFolder))] + public sealed class GetInfisicalFoldersCmdlet : InfisicalCmdletBase + { + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Environment { get; set; } + [Parameter] public string Path { get; set; } + + protected override void ProcessRecord() + { + 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, resolvedProjectId, resolvedEnvironment, resolvedPath); + foreach (InfisicalFolder folder in folders) + { + WriteObject(folder); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("GetInfisicalFoldersCmdlet", "ListFolders", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs new file mode 100644 index 0000000..93ec71e --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs @@ -0,0 +1,36 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Projects; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "InfisicalProject")] + [OutputType(typeof(InfisicalProject))] + public sealed class GetInfisicalProjectCmdlet : InfisicalCmdletBase + { + [Parameter(ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Id")] + public string ProjectId { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger); + InfisicalProject project = client.Retrieve(connection, resolvedProjectId); + if (project != null) + { + WriteObject(project); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("GetInfisicalProjectCmdlet", "RetrieveProject", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectsCmdlet.cs new file mode 100644 index 0000000..fa6150a --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectsCmdlet.cs @@ -0,0 +1,32 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Projects; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "InfisicalProjects")] + [OutputType(typeof(InfisicalProject))] + public sealed class GetInfisicalProjectsCmdlet : InfisicalCmdletBase + { + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger); + InfisicalProject[] projects = client.List(connection); + + foreach (InfisicalProject project in projects) + { + WriteObject(project); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("GetInfisicalProjectsCmdlet", "ListProjects", exception); + } + } + } +} 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 new file mode 100644 index 0000000..8c7837f --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagCmdlet.cs @@ -0,0 +1,38 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Tags; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "InfisicalTag")] + [OutputType(typeof(InfisicalTag))] + public sealed class GetInfisicalTagCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Slug", "Id")] + public string TagSlugOrId { get; set; } + + [Parameter] public string ProjectId { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); + InfisicalTag tag = client.Retrieve(connection, resolvedProjectId, TagSlugOrId); + if (tag != null) + { + WriteObject(tag); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("GetInfisicalTagCmdlet", "RetrieveTag", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagsCmdlet.cs new file mode 100644 index 0000000..a4b736c --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagsCmdlet.cs @@ -0,0 +1,34 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Tags; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Get, "InfisicalTags")] + [OutputType(typeof(InfisicalTag))] + public sealed class GetInfisicalTagsCmdlet : InfisicalCmdletBase + { + [Parameter] public string ProjectId { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); + InfisicalTag[] tags = client.List(connection, resolvedProjectId); + foreach (InfisicalTag tag in tags) + { + WriteObject(tag); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("GetInfisicalTagsCmdlet", "ListTags", exception); + } + } + } +} 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 new file mode 100644 index 0000000..6a00664 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalEnvironmentCmdlet.cs @@ -0,0 +1,42 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Environments; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.New, "InfisicalEnvironment", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalEnvironment))] + public sealed class NewInfisicalEnvironmentCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, Position = 0)] public string Name { get; set; } + [Parameter(Mandatory = true, Position = 1)] public string Slug { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public int? Position { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(Slug, "Create Infisical environment")) + { + return; + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); + InfisicalEnvironment env = client.Create(connection, resolvedProjectId, Name, Slug, Position); + if (env != null) + { + WriteObject(env); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("NewInfisicalEnvironmentCmdlet", "CreateEnvironment", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs new file mode 100644 index 0000000..f31ff1d --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs @@ -0,0 +1,44 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Folders; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.New, "InfisicalFolder", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalFolder))] + public sealed class NewInfisicalFolderCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, Position = 0)] public string Name { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Environment { get; set; } + [Parameter] public string Path { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(Name, "Create Infisical folder")) + { + return; + } + + 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, resolvedProjectId, resolvedEnvironment, Name, resolvedPath); + if (folder != null) + { + WriteObject(folder); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("NewInfisicalFolderCmdlet", "CreateFolder", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalProjectCmdlet.cs new file mode 100644 index 0000000..73cd932 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalProjectCmdlet.cs @@ -0,0 +1,47 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Projects; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.New, "InfisicalProject", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalProject))] + public sealed class NewInfisicalProjectCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, Position = 0)] + [Alias("Name")] + public string ProjectName { get; set; } + + [Parameter] public string Slug { get; set; } + [Parameter] public string Description { get; set; } + [Parameter] public string Type { get; set; } + [Parameter] public string OrganizationId { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(ProjectName, "Create Infisical project")) + { + return; + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedOrgId = !string.IsNullOrEmpty(OrganizationId) ? OrganizationId : connection.OrganizationId; + + InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger); + InfisicalProject project = client.Create(connection, ProjectName, Slug, Description, Type, resolvedOrgId); + if (project != null) + { + WriteObject(project); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("NewInfisicalProjectCmdlet", "CreateProject", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs new file mode 100644 index 0000000..2d8962a --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Security; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Secrets; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.New, "InfisicalSecret", SupportsShouldProcess = true, DefaultParameterSetName = "PlainText")] + [OutputType(typeof(InfisicalSecret))] + public sealed class NewInfisicalSecretCmdlet : InfisicalCmdletBase + { + [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; } + + [Parameter(Mandatory = true, Position = 1, 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 SecretComment { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Environment { get; set; } + [Parameter] public string SecretPath { get; set; } + [Parameter] public string ApiVersion { get; set; } + [Parameter] public InfisicalSecretType Type { get; set; } = InfisicalSecretType.Shared; + [Parameter] public SwitchParameter SkipMultilineEncoding { get; set; } + [Parameter] public string[] TagIds { get; set; } + + protected override void ProcessRecord() + { + try + { + 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; + + InfisicalCreateSecretRequest request = new InfisicalCreateSecretRequest + { + SecretName = SecretName, + SecretValue = plainValue, + SecretComment = SecretComment, + ProjectId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = resolvedSecretPath, + Type = Type.ToString(), + ApiVersion = resolvedApiVersion, + SkipMultilineEncoding = SkipMultilineEncoding.IsPresent ? (bool?)true : null, + TagIds = TagIds + }; + + InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger); + InfisicalSecret single = client.Create(connection, request); + if (single != null) + { + WriteObject(single); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("NewInfisicalSecretCmdlet", "CreateSecret", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs new file mode 100644 index 0000000..bf26869 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs @@ -0,0 +1,42 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Tags; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.New, "InfisicalTag", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalTag))] + public sealed class NewInfisicalTagCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, Position = 0)] public string Slug { get; set; } + [Parameter] public string Name { get; set; } + [Parameter] public string Color { get; set; } + [Parameter] public string ProjectId { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(Slug, "Create Infisical tag")) + { + return; + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); + InfisicalTag tag = client.Create(connection, resolvedProjectId, Slug, Name, Color); + if (tag != null) + { + WriteObject(tag); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("NewInfisicalTagCmdlet", "CreateTag", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs new file mode 100644 index 0000000..2716bc4 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs @@ -0,0 +1,43 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Environments; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Remove, "InfisicalEnvironment", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public sealed class RemoveInfisicalEnvironmentCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Id")] + public string EnvironmentId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(EnvironmentId, "Remove Infisical environment")) + { + return; + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); + client.Delete(connection, resolvedProjectId, EnvironmentId); + + if (PassThru.IsPresent) + { + WriteObject(EnvironmentId); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("RemoveInfisicalEnvironmentCmdlet", "DeleteEnvironment", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs new file mode 100644 index 0000000..7dde5d7 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs @@ -0,0 +1,47 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Folders; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Remove, "InfisicalFolder", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public sealed class RemoveInfisicalFolderCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Id")] + public string FolderId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Environment { get; set; } + [Parameter] public string Path { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(FolderId, "Remove Infisical folder")) + { + return; + } + + 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, resolvedProjectId, resolvedEnvironment, FolderId, resolvedPath); + + if (PassThru.IsPresent) + { + WriteObject(FolderId); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("RemoveInfisicalFolderCmdlet", "DeleteFolder", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs new file mode 100644 index 0000000..fbab178 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs @@ -0,0 +1,43 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Projects; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Remove, "InfisicalProject", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public sealed class RemoveInfisicalProjectCmdlet : InfisicalCmdletBase + { + [Parameter(ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Id")] + public string ProjectId { get; set; } + + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + + if (!ShouldProcess(resolvedProjectId, "Remove Infisical project")) + { + return; + } + + InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger); + client.Delete(connection, resolvedProjectId); + + if (PassThru.IsPresent) + { + WriteObject(resolvedProjectId); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("RemoveInfisicalProjectCmdlet", "DeleteProject", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs new file mode 100644 index 0000000..d2154c7 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs @@ -0,0 +1,88 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Secrets; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Remove, "InfisicalSecret", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High, DefaultParameterSetName = "Single")] + public sealed class RemoveInfisicalSecretCmdlet : InfisicalCmdletBase + { + [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; } + [Parameter] public string ApiVersion { get; set; } + [Parameter] public InfisicalSecretType Type { get; set; } = InfisicalSecretType.Shared; + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + 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; + } + + if (!ShouldProcess(SecretName, "Remove Infisical secret")) { return; } + + InfisicalDeleteSecretRequest request = new InfisicalDeleteSecretRequest + { + SecretName = SecretName, + ProjectId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = resolvedSecretPath, + Type = Type.ToString(), + ApiVersion = resolvedApiVersion + }; + + client.Delete(connection, request); + + if (PassThru.IsPresent) + { + WriteObject(SecretName); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("RemoveInfisicalSecretCmdlet", "DeleteSecret", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs new file mode 100644 index 0000000..96b3b7e --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs @@ -0,0 +1,43 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Tags; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsCommon.Remove, "InfisicalTag", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public sealed class RemoveInfisicalTagCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Id")] + public string TagId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(TagId, "Remove Infisical tag")) + { + return; + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); + client.Delete(connection, resolvedProjectId, TagId); + + if (PassThru.IsPresent) + { + WriteObject(TagId); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("RemoveInfisicalTagCmdlet", "DeleteTag", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs new file mode 100644 index 0000000..76de675 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs @@ -0,0 +1,46 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Environments; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsData.Update, "InfisicalEnvironment", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalEnvironment))] + public sealed class UpdateInfisicalEnvironmentCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Id")] + public string EnvironmentId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Name { get; set; } + [Parameter] public string Slug { get; set; } + [Parameter] public int? Position { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(EnvironmentId, "Update Infisical environment")) + { + return; + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalEnvironmentClient client = new InfisicalEnvironmentClient(HttpClient, Logger); + InfisicalEnvironment env = client.Update(connection, resolvedProjectId, EnvironmentId, Name, Slug, Position); + if (env != null) + { + WriteObject(env); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("UpdateInfisicalEnvironmentCmdlet", "UpdateEnvironment", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs new file mode 100644 index 0000000..bb5fe36 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs @@ -0,0 +1,48 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Folders; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsData.Update, "InfisicalFolder", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalFolder))] + public sealed class UpdateInfisicalFolderCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Id")] + public string FolderId { get; set; } + + [Parameter(Mandatory = true, Position = 1)] public string Name { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Environment { get; set; } + [Parameter] public string Path { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(FolderId, "Update Infisical folder")) + { + return; + } + + 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, resolvedProjectId, resolvedEnvironment, FolderId, Name, resolvedPath); + if (folder != null) + { + WriteObject(folder); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("UpdateInfisicalFolderCmdlet", "UpdateFolder", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs new file mode 100644 index 0000000..a76cb6b --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs @@ -0,0 +1,46 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Projects; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsData.Update, "InfisicalProject", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalProject))] + public sealed class UpdateInfisicalProjectCmdlet : InfisicalCmdletBase + { + [Parameter(ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Id")] + public string ProjectId { get; set; } + + [Parameter] public string Name { get; set; } + [Parameter] public string Description { get; set; } + [Parameter] public bool? AutoCapitalization { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + + if (!ShouldProcess(resolvedProjectId, "Update Infisical project")) + { + return; + } + + InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger); + InfisicalProject project = client.Update(connection, resolvedProjectId, Name, Description, AutoCapitalization); + if (project != null) + { + WriteObject(project); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("UpdateInfisicalProjectCmdlet", "UpdateProject", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs new file mode 100644 index 0000000..0449af7 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Security; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Secrets; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsData.Update, "InfisicalSecret", SupportsShouldProcess = true, DefaultParameterSetName = "PlainText")] + [OutputType(typeof(InfisicalSecret))] + public sealed class UpdateInfisicalSecretCmdlet : InfisicalCmdletBase + { + [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)] + [BulkSecretsTransformation] + public IDictionary[] Secrets { get; set; } + + [Parameter] public string NewSecretName { get; set; } + [Parameter] public string SecretComment { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Environment { get; set; } + [Parameter] public string SecretPath { get; set; } + [Parameter] public string ApiVersion { get; set; } + [Parameter] public InfisicalSecretType Type { get; set; } = InfisicalSecretType.Shared; + [Parameter] public SwitchParameter SkipMultilineEncoding { get; set; } + [Parameter] public string[] TagIds { get; set; } + + protected override void ProcessRecord() + { + try + { + 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; + + InfisicalUpdateSecretRequest request = new InfisicalUpdateSecretRequest + { + SecretName = SecretName, + NewSecretName = NewSecretName, + SecretValue = plainValue, + SecretComment = SecretComment, + ProjectId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = resolvedSecretPath, + Type = Type.ToString(), + ApiVersion = resolvedApiVersion, + SkipMultilineEncoding = SkipMultilineEncoding.IsPresent ? (bool?)true : null, + TagIds = TagIds + }; + + InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger); + InfisicalSecret single = client.Update(connection, request); + if (single != null) + { + WriteObject(single); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("UpdateInfisicalSecretCmdlet", "UpdateSecret", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs new file mode 100644 index 0000000..15aefe6 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs @@ -0,0 +1,46 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Tags; + +namespace PSInfisicalAPI.Cmdlets +{ + [Cmdlet(VerbsData.Update, "InfisicalTag", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalTag))] + public sealed class UpdateInfisicalTagCmdlet : InfisicalCmdletBase + { + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)] + [Alias("Id")] + public string TagId { get; set; } + + [Parameter] public string Slug { get; set; } + [Parameter] public string Name { get; set; } + [Parameter] public string Color { get; set; } + [Parameter] public string ProjectId { get; set; } + + protected override void ProcessRecord() + { + try + { + if (!ShouldProcess(TagId, "Update Infisical tag")) + { + return; + } + + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + string resolvedProjectId = ResolveProjectId(connection, ProjectId); + InfisicalTagClient client = new InfisicalTagClient(HttpClient, Logger); + InfisicalTag tag = client.Update(connection, resolvedProjectId, TagId, Slug, Name, Color); + if (tag != null) + { + WriteObject(tag); + } + } + catch (Exception exception) + { + ThrowTerminatingForException("UpdateInfisicalTagCmdlet", "UpdateTag", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs index 1bea19d..161c3a5 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs @@ -3,7 +3,45 @@ namespace PSInfisicalAPI.Endpoints public static class InfisicalEndpointNames { public const string UniversalAuthLogin = "UniversalAuthLogin"; + public const string TokenAuthLogin = "TokenAuthLogin"; + public const string JwtAuthLogin = "JwtAuthLogin"; + public const string OidcAuthLogin = "OidcAuthLogin"; + public const string LdapAuthLogin = "LdapAuthLogin"; + public const string AzureAuthLogin = "AzureAuthLogin"; + public const string GcpIamAuthLogin = "GcpIamAuthLogin"; + public const string ListSecrets = "ListSecrets"; public const string RetrieveSecret = "RetrieveSecret"; + 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"; + public const string CreateProject = "CreateProject"; + public const string UpdateProject = "UpdateProject"; + public const string DeleteProject = "DeleteProject"; + + public const string ListEnvironments = "ListEnvironments"; + public const string RetrieveEnvironment = "RetrieveEnvironment"; + public const string CreateEnvironment = "CreateEnvironment"; + public const string UpdateEnvironment = "UpdateEnvironment"; + public const string DeleteEnvironment = "DeleteEnvironment"; + + public const string ListFolders = "ListFolders"; + public const string RetrieveFolder = "RetrieveFolder"; + public const string CreateFolder = "CreateFolder"; + public const string UpdateFolder = "UpdateFolder"; + public const string DeleteFolder = "DeleteFolder"; + + public const string ListTags = "ListTags"; + public const string RetrieveTag = "RetrieveTag"; + public const string CreateTag = "CreateTag"; + public const string UpdateTag = "UpdateTag"; + public const string DeleteTag = "DeleteTag"; } } diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs index 997d14f..4aceb25 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs @@ -5,83 +5,495 @@ namespace PSInfisicalAPI.Endpoints { public static class InfisicalEndpointRegistry { - private static readonly Dictionary> Candidates = - new Dictionary> + private static readonly Dictionary> Candidates; + + static InfisicalEndpointRegistry() + { + Candidates = new Dictionary>(); + RegisterAuthentication(Candidates); + RegisterSecrets(Candidates); + RegisterProjects(Candidates); + RegisterEnvironments(Candidates); + RegisterFolders(Candidates); + RegisterTags(Candidates); + } + + private static void Add(Dictionary> map, InfisicalEndpointDefinition definition) + { + List list; + if (!map.TryGetValue(definition.Name, out list)) { - { - InfisicalEndpointNames.UniversalAuthLogin, - new List - { - new InfisicalEndpointDefinition - { - Name = InfisicalEndpointNames.UniversalAuthLogin, - Resource = "Authentication", - Version = "v1", - Method = "POST", - Template = "/api/v1/auth/universal-auth/login", - RequiresAuthorization = false, - ContainsSecretMaterialInRequest = true, - ContainsSecretMaterialInResponse = true - } - } - }, - { - InfisicalEndpointNames.ListSecrets, - new List - { - new InfisicalEndpointDefinition - { - Name = InfisicalEndpointNames.ListSecrets, - Resource = "Secrets", - Version = "v4", - Method = "GET", - Template = "/api/v4/secrets", - RequiresAuthorization = true, - ContainsSecretMaterialInRequest = false, - ContainsSecretMaterialInResponse = true - }, - new InfisicalEndpointDefinition - { - Name = InfisicalEndpointNames.ListSecrets, - Resource = "Secrets", - Version = "v3", - Method = "GET", - Template = "/api/v3/secrets/raw", - RequiresAuthorization = true, - ContainsSecretMaterialInRequest = false, - ContainsSecretMaterialInResponse = true - } - } - }, - { - InfisicalEndpointNames.RetrieveSecret, - new List - { - new InfisicalEndpointDefinition - { - Name = InfisicalEndpointNames.RetrieveSecret, - Resource = "Secrets", - Version = "v4", - Method = "GET", - Template = "/api/v4/secrets/{secretName}", - RequiresAuthorization = true, - ContainsSecretMaterialInRequest = false, - ContainsSecretMaterialInResponse = true - }, - new InfisicalEndpointDefinition - { - Name = InfisicalEndpointNames.RetrieveSecret, - Resource = "Secrets", - Version = "v3", - Method = "GET", - Template = "/api/v3/secrets/raw/{secretName}", - RequiresAuthorization = true, - ContainsSecretMaterialInRequest = false, - ContainsSecretMaterialInResponse = true - } - } - } - }; + list = new List(); + map[definition.Name] = list; + } + + list.Add(definition); + } + + private static void RegisterAuthentication(Dictionary> map) + { + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.UniversalAuthLogin, + Resource = "Authentication", + Version = "v1", + Method = "POST", + Template = "/api/v1/auth/universal-auth/login", + RequiresAuthorization = false, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.TokenAuthLogin, + Resource = "Authentication", + Version = "v1", + Method = "POST", + Template = "/api/v1/auth/token-auth/login", + RequiresAuthorization = false, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.JwtAuthLogin, + Resource = "Authentication", + Version = "v1", + Method = "POST", + Template = "/api/v1/auth/jwt-auth/login", + RequiresAuthorization = false, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.OidcAuthLogin, + Resource = "Authentication", + Version = "v1", + Method = "POST", + Template = "/api/v1/auth/oidc-auth/login", + RequiresAuthorization = false, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.LdapAuthLogin, + Resource = "Authentication", + Version = "v1", + Method = "POST", + Template = "/api/v1/auth/ldap-auth/login", + RequiresAuthorization = false, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.AzureAuthLogin, + Resource = "Authentication", + Version = "v1", + Method = "POST", + Template = "/api/v1/auth/azure-auth/login", + RequiresAuthorization = false, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.GcpIamAuthLogin, + Resource = "Authentication", + Version = "v1", + Method = "POST", + Template = "/api/v1/auth/gcp-auth/login", + RequiresAuthorization = false, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + } + + private static void RegisterSecrets(Dictionary> map) + { + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListSecrets, + Resource = "Secrets", + Version = "v4", + Method = "GET", + Template = "/api/v4/secrets", + RequiresAuthorization = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListSecrets, + Resource = "Secrets", + Version = "v3", + Method = "GET", + Template = "/api/v3/secrets/raw", + RequiresAuthorization = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveSecret, + Resource = "Secrets", + Version = "v4", + Method = "GET", + Template = "/api/v4/secrets/{secretName}", + RequiresAuthorization = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveSecret, + Resource = "Secrets", + Version = "v3", + Method = "GET", + Template = "/api/v3/secrets/raw/{secretName}", + RequiresAuthorization = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.CreateSecret, + Resource = "Secrets", + Version = "v3", + Method = "POST", + Template = "/api/v3/secrets/raw/{secretName}", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.UpdateSecret, + Resource = "Secrets", + Version = "v3", + Method = "PATCH", + Template = "/api/v3/secrets/raw/{secretName}", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.DeleteSecret, + Resource = "Secrets", + Version = "v3", + Method = "DELETE", + Template = "/api/v3/secrets/raw/{secretName}", + RequiresAuthorization = true, + ContainsSecretMaterialInResponse = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.BulkCreateSecret, + Resource = "Secrets", + Version = "v4", + Method = "POST", + Template = "/api/v4/secrets/batch", + RequiresAuthorization = true, + ContainsSecretMaterialInRequest = 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 = "v4", + Method = "PATCH", + Template = "/api/v4/secrets/batch", + 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 = "v4", + Method = "DELETE", + Template = "/api/v4/secrets/batch", + 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) + { + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListProjects, + Resource = "Projects", + Version = "v1", + Method = "GET", + Template = "/api/v1/workspace", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveProject, + Resource = "Projects", + Version = "v1", + Method = "GET", + Template = "/api/v1/workspace/{projectId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.CreateProject, + Resource = "Projects", + Version = "v2", + Method = "POST", + Template = "/api/v2/workspace", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.UpdateProject, + Resource = "Projects", + Version = "v1", + Method = "PATCH", + Template = "/api/v1/workspace/{projectId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.DeleteProject, + Resource = "Projects", + Version = "v1", + Method = "DELETE", + Template = "/api/v1/workspace/{projectId}", + RequiresAuthorization = true + }); + } + + private static void RegisterEnvironments(Dictionary> map) + { + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListEnvironments, + Resource = "Environments", + Version = "v1", + Method = "GET", + Template = "/api/v1/workspace/{projectId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveEnvironment, + Resource = "Environments", + Version = "v1", + Method = "GET", + Template = "/api/v1/workspace/{projectId}/environments/{environmentId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.CreateEnvironment, + Resource = "Environments", + Version = "v1", + Method = "POST", + Template = "/api/v1/workspace/{projectId}/environments", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.UpdateEnvironment, + Resource = "Environments", + Version = "v1", + Method = "PATCH", + Template = "/api/v1/workspace/{projectId}/environments/{environmentId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.DeleteEnvironment, + Resource = "Environments", + Version = "v1", + Method = "DELETE", + Template = "/api/v1/workspace/{projectId}/environments/{environmentId}", + RequiresAuthorization = true + }); + } + + private static void RegisterFolders(Dictionary> map) + { + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListFolders, + Resource = "Folders", + Version = "v1", + Method = "GET", + Template = "/api/v1/folders", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveFolder, + Resource = "Folders", + Version = "v1", + Method = "GET", + Template = "/api/v1/folders/{folderId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.CreateFolder, + Resource = "Folders", + Version = "v1", + Method = "POST", + Template = "/api/v1/folders", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.UpdateFolder, + Resource = "Folders", + Version = "v1", + Method = "PATCH", + Template = "/api/v1/folders/{folderId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.DeleteFolder, + Resource = "Folders", + Version = "v1", + Method = "DELETE", + Template = "/api/v1/folders/{folderId}", + RequiresAuthorization = true + }); + } + + private static void RegisterTags(Dictionary> map) + { + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListTags, + Resource = "Tags", + Version = "v1", + Method = "GET", + Template = "/api/v1/workspace/{projectId}/tags", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.RetrieveTag, + Resource = "Tags", + Version = "v1", + Method = "GET", + Template = "/api/v1/workspace/{projectId}/tags/{tagId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.CreateTag, + Resource = "Tags", + Version = "v1", + Method = "POST", + Template = "/api/v1/workspace/{projectId}/tags", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.UpdateTag, + Resource = "Tags", + Version = "v1", + Method = "PATCH", + Template = "/api/v1/workspace/{projectId}/tags/{tagId}", + RequiresAuthorization = true + }); + + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.DeleteTag, + Resource = "Tags", + Version = "v1", + Method = "DELETE", + Template = "/api/v1/workspace/{projectId}/tags/{tagId}", + RequiresAuthorization = true + }); + } public static InfisicalEndpointDefinition Get(string name) { diff --git a/src/PSInfisicalAPI/Environments/InfisicalEnvironmentClient.cs b/src/PSInfisicalAPI/Environments/InfisicalEnvironmentClient.cs new file mode 100644 index 0000000..3a917bc --- /dev/null +++ b/src/PSInfisicalAPI/Environments/InfisicalEnvironmentClient.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Serialization; + +namespace PSInfisicalAPI.Environments +{ + public sealed class InfisicalEnvironmentClient + { + private const string Component = "EnvironmentClient"; + + private readonly IInfisicalLogger _logger; + private readonly JsonInfisicalSerializer _serializer; + private readonly InfisicalApiInvoker _invoker; + + public InfisicalEnvironmentClient(IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (httpClient == null) { throw new ArgumentNullException(nameof(httpClient)); } + _logger = logger ?? NullInfisicalLogger.Instance; + _serializer = new JsonInfisicalSerializer(); + _invoker = new InfisicalApiInvoker(httpClient); + } + + public InfisicalEnvironment[] List(InfisicalConnection connection, string projectId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", resolvedProjectId } }; + + try + { + _logger.Information(Component, "Attempting to list Infisical environments. Please Wait..."); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.ListEnvironments, "ListEnvironments", pathParameters, null, null); + InfisicalEnvironmentWorkspaceWrapperDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalEnvironmentWorkspaceDto workspace = dto != null ? (dto.Workspace ?? dto.Project) : null; + List envs = workspace != null ? workspace.Environments : null; + InfisicalEnvironment[] mapped = InfisicalEnvironmentMapper.MapMany(envs, resolvedProjectId); + _logger.Information(Component, "Infisical environment list retrieval was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical environment list retrieval failed."); + throw; + } + } + + public InfisicalEnvironment Retrieve(InfisicalConnection connection, string projectId, string environmentSlugOrId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(environmentSlugOrId)) { throw new InfisicalConfigurationException("Environment is required."); } + + InfisicalEnvironment[] all = List(connection, resolvedProjectId); + foreach (InfisicalEnvironment env in all) + { + if (string.Equals(env.Id, environmentSlugOrId, StringComparison.OrdinalIgnoreCase) || + string.Equals(env.Slug, environmentSlugOrId, StringComparison.OrdinalIgnoreCase)) + { + return env; + } + } + + return null; + } + + public InfisicalEnvironment Create(InfisicalConnection connection, string projectId, string name, string slug, int? position) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(name)) { throw new InfisicalConfigurationException("Name is required."); } + if (string.IsNullOrEmpty(slug)) { throw new InfisicalConfigurationException("Slug is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", resolvedProjectId } }; + InfisicalEnvironmentCreateRequestDto request = new InfisicalEnvironmentCreateRequestDto { Name = name, Slug = slug, Position = position }; + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, string.Concat("Attempting to create Infisical environment '", slug, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.CreateEnvironment, "CreateEnvironment", pathParameters, null, body); + InfisicalEnvironmentSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalEnvironment mapped = InfisicalEnvironmentMapper.Map(dto != null ? dto.Environment : null, resolvedProjectId); + _logger.Information(Component, "Infisical environment creation was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical environment creation failed."); + throw; + } + } + + public InfisicalEnvironment Update(InfisicalConnection connection, string projectId, string environmentId, string name, string slug, int? position) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(environmentId)) { throw new InfisicalConfigurationException("EnvironmentId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", resolvedProjectId }, { "environmentId", environmentId } }; + InfisicalEnvironmentUpdateRequestDto request = new InfisicalEnvironmentUpdateRequestDto { Name = name, Slug = slug, Position = position }; + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, string.Concat("Attempting to update Infisical environment '", environmentId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.UpdateEnvironment, "UpdateEnvironment", pathParameters, null, body); + InfisicalEnvironmentSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalEnvironment mapped = InfisicalEnvironmentMapper.Map(dto != null ? dto.Environment : null, resolvedProjectId); + _logger.Information(Component, "Infisical environment update was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical environment update failed."); + throw; + } + } + + public void Delete(InfisicalConnection connection, string projectId, string environmentId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(environmentId)) { throw new InfisicalConfigurationException("EnvironmentId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", resolvedProjectId }, { "environmentId", environmentId } }; + + try + { + _logger.Information(Component, string.Concat("Attempting to delete Infisical environment '", environmentId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.DeleteEnvironment, "DeleteEnvironment", pathParameters, null, null); + response.Clear(); + _logger.Information(Component, "Infisical environment deletion was successful."); + } + catch (Exception) + { + _logger.Error(Component, "Infisical environment deletion failed."); + throw; + } + } + + private static string FirstNonEmpty(params string[] values) + { + if (values == null) { return null; } + foreach (string value in values) { if (!string.IsNullOrEmpty(value)) { return value; } } + return null; + } + } +} diff --git a/src/PSInfisicalAPI/Environments/InfisicalEnvironmentDtos.cs b/src/PSInfisicalAPI/Environments/InfisicalEnvironmentDtos.cs new file mode 100644 index 0000000..de52268 --- /dev/null +++ b/src/PSInfisicalAPI/Environments/InfisicalEnvironmentDtos.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace PSInfisicalAPI.Environments +{ + internal sealed class InfisicalEnvironmentResponseDto + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("_id")] public string InternalId { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("slug")] public string Slug { get; set; } + [JsonProperty("position")] public int? Position { get; set; } + [JsonProperty("projectId")] public string ProjectId { get; set; } + [JsonProperty("workspaceId")] public string WorkspaceId { get; set; } + } + + internal sealed class InfisicalEnvironmentSingleResponseDto + { + [JsonProperty("environment")] public InfisicalEnvironmentResponseDto Environment { get; set; } + } + + internal sealed class InfisicalEnvironmentWorkspaceWrapperDto + { + [JsonProperty("workspace")] public InfisicalEnvironmentWorkspaceDto Workspace { get; set; } + [JsonProperty("project")] public InfisicalEnvironmentWorkspaceDto Project { get; set; } + } + + internal sealed class InfisicalEnvironmentWorkspaceDto + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("environments")] public List Environments { get; set; } + } + + internal sealed class InfisicalEnvironmentCreateRequestDto + { + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("slug")] public string Slug { get; set; } + [JsonProperty("position", NullValueHandling = NullValueHandling.Ignore)] public int? Position { get; set; } + } + + internal sealed class InfisicalEnvironmentUpdateRequestDto + { + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } + [JsonProperty("slug", NullValueHandling = NullValueHandling.Ignore)] public string Slug { get; set; } + [JsonProperty("position", NullValueHandling = NullValueHandling.Ignore)] public int? Position { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Environments/InfisicalEnvironmentMapper.cs b/src/PSInfisicalAPI/Environments/InfisicalEnvironmentMapper.cs new file mode 100644 index 0000000..03e9200 --- /dev/null +++ b/src/PSInfisicalAPI/Environments/InfisicalEnvironmentMapper.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Environments +{ + internal static class InfisicalEnvironmentMapper + { + public static InfisicalEnvironment Map(InfisicalEnvironmentResponseDto dto, string fallbackProjectId) + { + if (dto == null) + { + return null; + } + + string projectId = !string.IsNullOrEmpty(dto.ProjectId) + ? dto.ProjectId + : (!string.IsNullOrEmpty(dto.WorkspaceId) ? dto.WorkspaceId : fallbackProjectId); + + return new InfisicalEnvironment + { + Id = !string.IsNullOrEmpty(dto.Id) ? dto.Id : dto.InternalId, + Name = dto.Name, + Slug = dto.Slug, + Position = dto.Position, + ProjectId = projectId + }; + } + + public static InfisicalEnvironment[] MapMany(IEnumerable items, string fallbackProjectId) + { + if (items == null) + { + return Array.Empty(); + } + + List results = new List(); + foreach (InfisicalEnvironmentResponseDto dto in items) + { + InfisicalEnvironment mapped = Map(dto, fallbackProjectId); + if (mapped != null) + { + results.Add(mapped); + } + } + + return results.ToArray(); + } + } +} diff --git a/src/PSInfisicalAPI/Folders/InfisicalFolderClient.cs b/src/PSInfisicalAPI/Folders/InfisicalFolderClient.cs new file mode 100644 index 0000000..4722add --- /dev/null +++ b/src/PSInfisicalAPI/Folders/InfisicalFolderClient.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Serialization; + +namespace PSInfisicalAPI.Folders +{ + public sealed class InfisicalFolderClient + { + private const string Component = "FolderClient"; + + private readonly IInfisicalLogger _logger; + private readonly JsonInfisicalSerializer _serializer; + private readonly InfisicalApiInvoker _invoker; + + public InfisicalFolderClient(IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (httpClient == null) { throw new ArgumentNullException(nameof(httpClient)); } + _logger = logger ?? NullInfisicalLogger.Instance; + _serializer = new JsonInfisicalSerializer(); + _invoker = new InfisicalApiInvoker(httpClient); + } + + public InfisicalFolder[] List(InfisicalConnection connection, string projectId, string environment, string path) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + string resolvedEnvironment = FirstNonEmpty(environment, connection.Environment); + string resolvedPath = FirstNonEmpty(path, connection.DefaultSecretPath, "/"); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(resolvedEnvironment)) { throw new InfisicalConfigurationException("Environment is required."); } + + List> queryParameters = new List> + { + new KeyValuePair("workspaceId", resolvedProjectId), + new KeyValuePair("environment", resolvedEnvironment), + new KeyValuePair("path", resolvedPath) + }; + + try + { + _logger.Information(Component, "Attempting to list Infisical folders. Please Wait..."); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.ListFolders, "ListFolders", null, queryParameters, null); + InfisicalFolderListResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalFolder[] mapped = InfisicalFolderMapper.MapMany(dto != null ? dto.Folders : null, resolvedProjectId, resolvedEnvironment); + _logger.Information(Component, "Infisical folder list retrieval was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical folder list retrieval failed."); + throw; + } + } + + public InfisicalFolder Retrieve(InfisicalConnection connection, string projectId, string environment, string path, string folderNameOrId) + { + if (string.IsNullOrEmpty(folderNameOrId)) { throw new InfisicalConfigurationException("Folder name or id is required."); } + + InfisicalFolder[] all = List(connection, projectId, environment, path); + foreach (InfisicalFolder folder in all) + { + if (string.Equals(folder.Id, folderNameOrId, StringComparison.OrdinalIgnoreCase) || + string.Equals(folder.Name, folderNameOrId, StringComparison.OrdinalIgnoreCase)) + { + return folder; + } + } + + return null; + } + + public InfisicalFolder Create(InfisicalConnection connection, string projectId, string environment, string name, string path) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + string resolvedEnvironment = FirstNonEmpty(environment, connection.Environment); + string resolvedPath = FirstNonEmpty(path, connection.DefaultSecretPath, "/"); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(resolvedEnvironment)) { throw new InfisicalConfigurationException("Environment is required."); } + if (string.IsNullOrEmpty(name)) { throw new InfisicalConfigurationException("Name is required."); } + + InfisicalFolderCreateRequestDto request = new InfisicalFolderCreateRequestDto + { + WorkspaceId = resolvedProjectId, + Environment = resolvedEnvironment, + Name = name, + Path = resolvedPath + }; + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, string.Concat("Attempting to create Infisical folder '", name, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.CreateFolder, "CreateFolder", null, null, body); + InfisicalFolderSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalFolder mapped = InfisicalFolderMapper.Map(dto != null ? dto.Folder : null, resolvedProjectId, resolvedEnvironment); + _logger.Information(Component, "Infisical folder creation was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical folder creation failed."); + throw; + } + } + + public InfisicalFolder Update(InfisicalConnection connection, string projectId, string environment, string folderId, string name, string path) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + string resolvedEnvironment = FirstNonEmpty(environment, connection.Environment); + string resolvedPath = FirstNonEmpty(path, connection.DefaultSecretPath, "/"); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(resolvedEnvironment)) { throw new InfisicalConfigurationException("Environment is required."); } + if (string.IsNullOrEmpty(folderId)) { throw new InfisicalConfigurationException("FolderId is required."); } + if (string.IsNullOrEmpty(name)) { throw new InfisicalConfigurationException("Name is required."); } + + Dictionary pathParameters = new Dictionary { { "folderId", folderId } }; + InfisicalFolderUpdateRequestDto request = new InfisicalFolderUpdateRequestDto + { + WorkspaceId = resolvedProjectId, + Environment = resolvedEnvironment, + Name = name, + Path = resolvedPath + }; + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, string.Concat("Attempting to update Infisical folder '", folderId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.UpdateFolder, "UpdateFolder", pathParameters, null, body); + InfisicalFolderSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalFolder mapped = InfisicalFolderMapper.Map(dto != null ? dto.Folder : null, resolvedProjectId, resolvedEnvironment); + _logger.Information(Component, "Infisical folder update was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical folder update failed."); + throw; + } + } + + public void Delete(InfisicalConnection connection, string projectId, string environment, string folderId, string path) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + string resolvedEnvironment = FirstNonEmpty(environment, connection.Environment); + string resolvedPath = FirstNonEmpty(path, connection.DefaultSecretPath, "/"); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(resolvedEnvironment)) { throw new InfisicalConfigurationException("Environment is required."); } + if (string.IsNullOrEmpty(folderId)) { throw new InfisicalConfigurationException("FolderId is required."); } + + Dictionary pathParameters = new Dictionary { { "folderId", folderId } }; + List> queryParameters = new List> + { + new KeyValuePair("workspaceId", resolvedProjectId), + new KeyValuePair("environment", resolvedEnvironment), + new KeyValuePair("path", resolvedPath) + }; + + try + { + _logger.Information(Component, string.Concat("Attempting to delete Infisical folder '", folderId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.DeleteFolder, "DeleteFolder", pathParameters, queryParameters, null); + response.Clear(); + _logger.Information(Component, "Infisical folder deletion was successful."); + } + catch (Exception) + { + _logger.Error(Component, "Infisical folder deletion failed."); + throw; + } + } + + private static string FirstNonEmpty(params string[] values) + { + if (values == null) { return null; } + foreach (string value in values) { if (!string.IsNullOrEmpty(value)) { return value; } } + return null; + } + } +} diff --git a/src/PSInfisicalAPI/Folders/InfisicalFolderDtos.cs b/src/PSInfisicalAPI/Folders/InfisicalFolderDtos.cs new file mode 100644 index 0000000..5c29e2a --- /dev/null +++ b/src/PSInfisicalAPI/Folders/InfisicalFolderDtos.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace PSInfisicalAPI.Folders +{ + internal sealed class InfisicalFolderResponseDto + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("_id")] public string InternalId { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("path")] public string Path { get; set; } + [JsonProperty("parentId")] public string ParentId { get; set; } + [JsonProperty("envId")] public string EnvId { get; set; } + [JsonProperty("environment")] public string Environment { get; set; } + [JsonProperty("projectId")] public string ProjectId { get; set; } + [JsonProperty("workspaceId")] public string WorkspaceId { get; set; } + [JsonProperty("createdAt")] public string CreatedAt { get; set; } + [JsonProperty("updatedAt")] public string UpdatedAt { get; set; } + } + + internal sealed class InfisicalFolderListResponseDto + { + [JsonProperty("folders")] public List Folders { get; set; } + } + + internal sealed class InfisicalFolderSingleResponseDto + { + [JsonProperty("folder")] public InfisicalFolderResponseDto Folder { get; set; } + } + + internal sealed class InfisicalFolderCreateRequestDto + { + [JsonProperty("workspaceId")] public string WorkspaceId { get; set; } + [JsonProperty("environment")] public string Environment { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] public string Path { get; set; } + [JsonProperty("directory", NullValueHandling = NullValueHandling.Ignore)] public string Directory { get; set; } + } + + internal sealed class InfisicalFolderUpdateRequestDto + { + [JsonProperty("workspaceId")] public string WorkspaceId { get; set; } + [JsonProperty("environment")] public string Environment { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] public string Path { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Folders/InfisicalFolderMapper.cs b/src/PSInfisicalAPI/Folders/InfisicalFolderMapper.cs new file mode 100644 index 0000000..9fa8b79 --- /dev/null +++ b/src/PSInfisicalAPI/Folders/InfisicalFolderMapper.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Folders +{ + internal static class InfisicalFolderMapper + { + public static InfisicalFolder Map(InfisicalFolderResponseDto dto, string fallbackProjectId, string fallbackEnvironment) + { + if (dto == null) + { + return null; + } + + string projectId = !string.IsNullOrEmpty(dto.ProjectId) + ? dto.ProjectId + : (!string.IsNullOrEmpty(dto.WorkspaceId) ? dto.WorkspaceId : fallbackProjectId); + + string environment = !string.IsNullOrEmpty(dto.Environment) ? dto.Environment : fallbackEnvironment; + + return new InfisicalFolder + { + Id = !string.IsNullOrEmpty(dto.Id) ? dto.Id : dto.InternalId, + Name = dto.Name, + Path = dto.Path, + ParentId = dto.ParentId, + Environment = environment, + ProjectId = projectId, + CreatedAtUtc = ParseTimestamp(dto.CreatedAt), + UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt) + }; + } + + public static InfisicalFolder[] MapMany(IEnumerable items, string fallbackProjectId, string fallbackEnvironment) + { + if (items == null) + { + return Array.Empty(); + } + + List results = new List(); + foreach (InfisicalFolderResponseDto dto in items) + { + InfisicalFolder mapped = Map(dto, fallbackProjectId, fallbackEnvironment); + if (mapped != null) + { + results.Add(mapped); + } + } + + return results.ToArray(); + } + + private static DateTimeOffset? ParseTimestamp(string value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + DateTimeOffset parsed; + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out parsed)) + { + return parsed; + } + + return null; + } + } +} diff --git a/src/PSInfisicalAPI/Http/InfisicalApiInvoker.cs b/src/PSInfisicalAPI/Http/InfisicalApiInvoker.cs new file mode 100644 index 0000000..d6e2872 --- /dev/null +++ b/src/PSInfisicalAPI/Http/InfisicalApiInvoker.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Security; + +namespace PSInfisicalAPI.Http +{ + internal sealed class InfisicalApiInvoker + { + private readonly IInfisicalHttpClient _httpClient; + + public InfisicalApiInvoker(IInfisicalHttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public InfisicalHttpResponse Invoke( + InfisicalConnection connection, + string endpointName, + string operationName, + IDictionary pathParameters, + IEnumerable> queryParameters, + string body) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(endpointName)) { throw new ArgumentNullException(nameof(endpointName)); } + + InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(endpointName); + Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, pathParameters, queryParameters); + + InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, operationName, uri, body); + + if (response.StatusCode >= 200 && response.StatusCode < 300) + { + return response; + } + + InfisicalApiException exception = BuildApiException(response, definition); + response.Clear(); + throw exception; + } + + private InfisicalHttpResponse ExecuteAuthorized( + InfisicalConnection connection, + InfisicalEndpointDefinition definition, + string operationName, + Uri uri, + string body) + { + Dictionary headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + headers["Accept"] = "application/json"; + + if (!string.IsNullOrEmpty(body)) + { + headers["Content-Type"] = "application/json"; + } + + if (definition.RequiresAuthorization) + { + if (connection.AccessToken == null) + { + throw new InfisicalAuthenticationException("Connection does not contain an access token."); + } + + SecureStringUtility.UsePlainText(connection.AccessToken, plainToken => + { + headers["Authorization"] = string.Concat("Bearer ", plainToken ?? string.Empty); + }); + } + + InfisicalHttpRequest request = new InfisicalHttpRequest + { + OperationName = operationName, + EndpointName = definition.Name, + Method = definition.Method, + Uri = uri, + Headers = headers, + Body = body, + ContainsSecretMaterialInRequest = definition.ContainsSecretMaterialInRequest, + ContainsSecretMaterialInResponse = definition.ContainsSecretMaterialInResponse + }; + + return _httpClient.Send(request); + } + + private static InfisicalApiException BuildApiException(InfisicalHttpResponse response, InfisicalEndpointDefinition definition) + { + InfisicalApiException exception = new InfisicalApiException(string.Concat( + "Infisical API returned ", + response.StatusCode.ToString(CultureInfo.InvariantCulture), + " (", response.ReasonPhrase ?? string.Empty, ").")); + exception.StatusCode = response.StatusCode; + exception.ReasonPhrase = response.ReasonPhrase; + exception.EndpointName = definition.Name; + exception.RequestMethod = definition.Method; + exception.SanitizedBody = response.Body; + return exception; + } + } +} diff --git a/src/PSInfisicalAPI/Models/InfisicalAuthType.cs b/src/PSInfisicalAPI/Models/InfisicalAuthType.cs index 10e8496..d0729f6 100644 --- a/src/PSInfisicalAPI/Models/InfisicalAuthType.cs +++ b/src/PSInfisicalAPI/Models/InfisicalAuthType.cs @@ -3,6 +3,11 @@ namespace PSInfisicalAPI.Models public enum InfisicalAuthType { UniversalAuth, - Token + Token, + Jwt, + Oidc, + Ldap, + Azure, + GcpIam } } diff --git a/src/PSInfisicalAPI/Models/InfisicalEnvironment.cs b/src/PSInfisicalAPI/Models/InfisicalEnvironment.cs new file mode 100644 index 0000000..d92f035 --- /dev/null +++ b/src/PSInfisicalAPI/Models/InfisicalEnvironment.cs @@ -0,0 +1,16 @@ +namespace PSInfisicalAPI.Models +{ + public sealed class InfisicalEnvironment + { + public string Id { get; set; } + public string Name { get; set; } + public string Slug { get; set; } + public int? Position { get; set; } + public string ProjectId { get; set; } + + public override string ToString() + { + return string.IsNullOrEmpty(Slug) ? (Name ?? Id) : Slug; + } + } +} diff --git a/src/PSInfisicalAPI/Models/InfisicalFolder.cs b/src/PSInfisicalAPI/Models/InfisicalFolder.cs new file mode 100644 index 0000000..17e4ca3 --- /dev/null +++ b/src/PSInfisicalAPI/Models/InfisicalFolder.cs @@ -0,0 +1,21 @@ +using System; + +namespace PSInfisicalAPI.Models +{ + public sealed class InfisicalFolder + { + public string Id { get; set; } + public string Name { get; set; } + public string Path { get; set; } + public string ParentId { get; set; } + public string Environment { get; set; } + public string ProjectId { get; set; } + public DateTimeOffset? CreatedAtUtc { get; set; } + public DateTimeOffset? UpdatedAtUtc { get; set; } + + public override string ToString() + { + return string.IsNullOrEmpty(Path) ? (Name ?? Id) : Path; + } + } +} diff --git a/src/PSInfisicalAPI/Models/InfisicalProject.cs b/src/PSInfisicalAPI/Models/InfisicalProject.cs new file mode 100644 index 0000000..c4ddc2d --- /dev/null +++ b/src/PSInfisicalAPI/Models/InfisicalProject.cs @@ -0,0 +1,23 @@ +using System; + +namespace PSInfisicalAPI.Models +{ + public sealed class InfisicalProject + { + public string Id { get; set; } + public string Name { get; set; } + public string Slug { get; set; } + public string Description { get; set; } + public string OrganizationId { get; set; } + public string Type { get; set; } + public bool AutoCapitalization { get; set; } + public string[] EnvironmentSlugs { get; set; } + public DateTimeOffset? CreatedAtUtc { get; set; } + public DateTimeOffset? UpdatedAtUtc { get; set; } + + public override string ToString() + { + return string.IsNullOrEmpty(Slug) ? (Name ?? Id) : Slug; + } + } +} diff --git a/src/PSInfisicalAPI/Models/InfisicalTag.cs b/src/PSInfisicalAPI/Models/InfisicalTag.cs new file mode 100644 index 0000000..4eec34c --- /dev/null +++ b/src/PSInfisicalAPI/Models/InfisicalTag.cs @@ -0,0 +1,20 @@ +using System; + +namespace PSInfisicalAPI.Models +{ + public sealed class InfisicalTag + { + public string Id { get; set; } + public string Slug { get; set; } + public string Name { get; set; } + public string Color { get; set; } + public string ProjectId { get; set; } + public DateTimeOffset? CreatedAtUtc { get; set; } + public DateTimeOffset? UpdatedAtUtc { get; set; } + + public override string ToString() + { + return Slug ?? Name ?? Id; + } + } +} diff --git a/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs b/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs new file mode 100644 index 0000000..a0c5e28 --- /dev/null +++ b/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Serialization; + +namespace PSInfisicalAPI.Projects +{ + public sealed class InfisicalProjectClient + { + private const string Component = "ProjectClient"; + + private readonly IInfisicalLogger _logger; + private readonly JsonInfisicalSerializer _serializer; + private readonly InfisicalApiInvoker _invoker; + + public InfisicalProjectClient(IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (httpClient == null) { throw new ArgumentNullException(nameof(httpClient)); } + _logger = logger ?? NullInfisicalLogger.Instance; + _serializer = new JsonInfisicalSerializer(); + _invoker = new InfisicalApiInvoker(httpClient); + } + + public InfisicalProject[] List(InfisicalConnection connection) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + + try + { + _logger.Information(Component, "Attempting to list Infisical projects. Please Wait..."); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.ListProjects, "ListProjects", null, null, null); + InfisicalProjectListResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + List source = (dto != null && dto.Workspaces != null) ? dto.Workspaces : (dto != null ? dto.Projects : null); + InfisicalProject[] mapped = InfisicalProjectMapper.MapMany(source); + _logger.Information(Component, "Infisical project list retrieval was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical project list retrieval failed."); + throw; + } + } + + public InfisicalProject Retrieve(InfisicalConnection connection, string projectId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(projectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", projectId } }; + + try + { + _logger.Information(Component, string.Concat("Attempting to retrieve Infisical project '", projectId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.RetrieveProject, "RetrieveProject", pathParameters, null, null); + InfisicalProjectSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalProjectResponseDto inner = dto != null ? (dto.Workspace ?? dto.Project) : null; + InfisicalProject mapped = InfisicalProjectMapper.Map(inner); + _logger.Information(Component, "Infisical project retrieval was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical project retrieval failed."); + throw; + } + } + + public InfisicalProject Create(InfisicalConnection connection, string projectName, string slug, string description, string type, string organizationId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(projectName)) { throw new InfisicalConfigurationException("ProjectName is required."); } + + InfisicalProjectCreateRequestDto request = new InfisicalProjectCreateRequestDto + { + ProjectName = projectName, + Slug = slug, + ProjectDescription = description, + Type = type, + OrganizationId = organizationId + }; + + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, string.Concat("Attempting to create Infisical project '", projectName, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.CreateProject, "CreateProject", null, null, body); + InfisicalProjectSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalProjectResponseDto inner = dto != null ? (dto.Project ?? dto.Workspace) : null; + InfisicalProject mapped = InfisicalProjectMapper.Map(inner); + _logger.Information(Component, "Infisical project creation was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical project creation failed."); + throw; + } + } + + public InfisicalProject Update(InfisicalConnection connection, string projectId, string name, string description, bool? autoCapitalization) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(projectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", projectId } }; + InfisicalProjectUpdateRequestDto request = new InfisicalProjectUpdateRequestDto { Name = name, Description = description, AutoCapitalization = autoCapitalization }; + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, string.Concat("Attempting to update Infisical project '", projectId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.UpdateProject, "UpdateProject", pathParameters, null, body); + InfisicalProjectSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalProjectResponseDto inner = dto != null ? (dto.Workspace ?? dto.Project) : null; + InfisicalProject mapped = InfisicalProjectMapper.Map(inner); + _logger.Information(Component, "Infisical project update was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical project update failed."); + throw; + } + } + + public void Delete(InfisicalConnection connection, string projectId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(projectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", projectId } }; + + try + { + _logger.Information(Component, string.Concat("Attempting to delete Infisical project '", projectId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.DeleteProject, "DeleteProject", pathParameters, null, null); + response.Clear(); + _logger.Information(Component, "Infisical project deletion was successful."); + } + catch (Exception) + { + _logger.Error(Component, "Infisical project deletion failed."); + throw; + } + } + } +} diff --git a/src/PSInfisicalAPI/Projects/InfisicalProjectDtos.cs b/src/PSInfisicalAPI/Projects/InfisicalProjectDtos.cs new file mode 100644 index 0000000..f6f6757 --- /dev/null +++ b/src/PSInfisicalAPI/Projects/InfisicalProjectDtos.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace PSInfisicalAPI.Projects +{ + internal sealed class InfisicalProjectResponseDto + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("_id")] public string InternalId { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("slug")] public string Slug { get; set; } + [JsonProperty("description")] public string Description { get; set; } + [JsonProperty("organization")] public string Organization { get; set; } + [JsonProperty("orgId")] public string OrgId { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("autoCapitalization")] public bool AutoCapitalization { get; set; } + [JsonProperty("createdAt")] public string CreatedAt { get; set; } + [JsonProperty("updatedAt")] public string UpdatedAt { get; set; } + [JsonProperty("environments")] public List Environments { get; set; } + } + + internal sealed class InfisicalProjectEnvironmentDto + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("slug")] public string Slug { get; set; } + } + + internal sealed class InfisicalProjectListResponseDto + { + [JsonProperty("workspaces")] public List Workspaces { get; set; } + [JsonProperty("projects")] public List Projects { get; set; } + } + + internal sealed class InfisicalProjectSingleResponseDto + { + [JsonProperty("workspace")] public InfisicalProjectResponseDto Workspace { get; set; } + [JsonProperty("project")] public InfisicalProjectResponseDto Project { get; set; } + } + + internal sealed class InfisicalProjectCreateRequestDto + { + [JsonProperty("projectName")] public string ProjectName { get; set; } + [JsonProperty("slug", NullValueHandling = NullValueHandling.Ignore)] public string Slug { get; set; } + [JsonProperty("projectDescription", NullValueHandling = NullValueHandling.Ignore)] public string ProjectDescription { get; set; } + [JsonProperty("type", NullValueHandling = NullValueHandling.Ignore)] public string Type { get; set; } + [JsonProperty("organizationId", NullValueHandling = NullValueHandling.Ignore)] public string OrganizationId { get; set; } + } + + internal sealed class InfisicalProjectUpdateRequestDto + { + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] public string Description { get; set; } + [JsonProperty("autoCapitalization", NullValueHandling = NullValueHandling.Ignore)] public bool? AutoCapitalization { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Projects/InfisicalProjectMapper.cs b/src/PSInfisicalAPI/Projects/InfisicalProjectMapper.cs new file mode 100644 index 0000000..3821b1b --- /dev/null +++ b/src/PSInfisicalAPI/Projects/InfisicalProjectMapper.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Projects +{ + internal static class InfisicalProjectMapper + { + public static InfisicalProject Map(InfisicalProjectResponseDto dto) + { + if (dto == null) + { + return null; + } + + InfisicalProject project = new InfisicalProject + { + Id = !string.IsNullOrEmpty(dto.Id) ? dto.Id : dto.InternalId, + Name = dto.Name, + Slug = dto.Slug, + Description = dto.Description, + OrganizationId = !string.IsNullOrEmpty(dto.Organization) ? dto.Organization : dto.OrgId, + Type = dto.Type, + AutoCapitalization = dto.AutoCapitalization, + EnvironmentSlugs = MapEnvironmentSlugs(dto.Environments), + CreatedAtUtc = ParseTimestamp(dto.CreatedAt), + UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt) + }; + + return project; + } + + public static InfisicalProject[] MapMany(IEnumerable items) + { + if (items == null) + { + return Array.Empty(); + } + + List results = new List(); + foreach (InfisicalProjectResponseDto dto in items) + { + InfisicalProject mapped = Map(dto); + if (mapped != null) + { + results.Add(mapped); + } + } + + return results.ToArray(); + } + + private static string[] MapEnvironmentSlugs(List environments) + { + if (environments == null || environments.Count == 0) + { + return Array.Empty(); + } + + List slugs = new List(environments.Count); + foreach (InfisicalProjectEnvironmentDto env in environments) + { + if (env != null && !string.IsNullOrEmpty(env.Slug)) + { + slugs.Add(env.Slug); + } + } + + return slugs.ToArray(); + } + + private static DateTimeOffset? ParseTimestamp(string value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + DateTimeOffset parsed; + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out parsed)) + { + return parsed; + } + + return null; + } + } +} diff --git a/src/PSInfisicalAPI/Secrets/InfisicalBulkSecretConverter.cs b/src/PSInfisicalAPI/Secrets/InfisicalBulkSecretConverter.cs new file mode 100644 index 0000000..8b7aab5 --- /dev/null +++ b/src/PSInfisicalAPI/Secrets/InfisicalBulkSecretConverter.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using PSInfisicalAPI.Errors; + +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 (IDictionary entry in input) + { + if (entry == null) { continue; } + IDictionary table = Normalize(entry); + InfisicalBulkCreateSecretItem item = new InfisicalBulkCreateSecretItem + { + SecretName = GetString(table, "SecretName", "Name", "Key", "SecretKey"), + SecretValue = GetString(table, "SecretValue", "Value"), + SecretComment = GetString(table, "SecretComment", "Comment"), + SkipMultilineEncoding = GetBool(table, "SkipMultilineEncoding"), + TagIds = GetStringArray(table, "TagIds") + }; + + 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 (IDictionary entry in input) + { + if (entry == null) { continue; } + IDictionary table = Normalize(entry); + InfisicalBulkUpdateSecretItem item = new InfisicalBulkUpdateSecretItem + { + SecretName = GetString(table, "SecretName", "Name", "Key", "SecretKey"), + NewSecretName = GetString(table, "NewSecretName", "NewName"), + SecretValue = GetString(table, "SecretValue", "Value"), + SecretComment = GetString(table, "SecretComment", "Comment"), + SkipMultilineEncoding = GetBool(table, "SkipMultilineEncoding"), + TagIds = GetStringArray(table, "TagIds") + }; + + 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 IDictionary Normalize(IDictionary source) + { + Dictionary normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair kvp in source) + { + if (string.IsNullOrEmpty(kvp.Key)) { continue; } + normalized[kvp.Key] = kvp.Value; + } + + return normalized; + } + + private static string GetString(IDictionary table, params string[] keys) + { + foreach (string key in keys) + { + string value; + if (table.TryGetValue(key, out value) && !string.IsNullOrEmpty(value)) + { + return value; + } + } + + return null; + } + + private static bool? GetBool(IDictionary table, string key) + { + string value; + if (!table.TryGetValue(key, out value) || string.IsNullOrEmpty(value)) { return null; } + bool parsed; + return bool.TryParse(value, out parsed) ? parsed : (bool?)null; + } + + private static string[] GetStringArray(IDictionary table, string key) + { + string value; + if (!table.TryGetValue(key, out value) || string.IsNullOrEmpty(value)) { return null; } + string[] parts = value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + List trimmed = new List(parts.Length); + foreach (string part in parts) + { + string item = part.Trim(); + if (!string.IsNullOrEmpty(item)) { trimmed.Add(item); } + } + + return trimmed.Count == 0 ? null : trimmed.ToArray(); + } + } +} diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs index 83f8eaf..7f4caab 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretDtos.cs @@ -55,4 +55,111 @@ namespace PSInfisicalAPI.Secrets { [JsonProperty("secret")] public InfisicalSecretResponseDto Secret { get; set; } } + + internal sealed class InfisicalSecretCreateRequestDto + { + [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("type", NullValueHandling = NullValueHandling.Ignore)] public string Type { 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; } + } + + internal sealed class InfisicalSecretUpdateRequestDto + { + [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("type", NullValueHandling = NullValueHandling.Ignore)] public string Type { get; set; } + [JsonProperty("secretValue", NullValueHandling = NullValueHandling.Ignore)] public string SecretValue { get; set; } + [JsonProperty("secretComment", NullValueHandling = NullValueHandling.Ignore)] public string SecretComment { get; set; } + [JsonProperty("newSecretName", NullValueHandling = NullValueHandling.Ignore)] public string NewSecretName { get; set; } + [JsonProperty("skipMultilineEncoding", NullValueHandling = NullValueHandling.Ignore)] public bool? SkipMultilineEncoding { get; set; } + [JsonProperty("tagIds", NullValueHandling = NullValueHandling.Ignore)] public string[] TagIds { get; set; } + } + + internal sealed class InfisicalSecretDeleteRequestDto + { + [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("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", NullValueHandling = NullValueHandling.Ignore)] public string WorkspaceId { get; set; } + [JsonProperty("projectId", NullValueHandling = NullValueHandling.Ignore)] public string ProjectId { 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", NullValueHandling = NullValueHandling.Ignore)] public string WorkspaceId { get; set; } + [JsonProperty("projectId", NullValueHandling = NullValueHandling.Ignore)] public string ProjectId { 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", NullValueHandling = NullValueHandling.Ignore)] public string WorkspaceId { get; set; } + [JsonProperty("projectId", NullValueHandling = NullValueHandling.Ignore)] public string ProjectId { 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 8ede056..0a274d5 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs @@ -30,4 +30,108 @@ namespace PSInfisicalAPI.Secrets public bool? ExpandSecretReferences { get; set; } public bool? IncludeImports { get; set; } } + + public sealed class InfisicalCreateSecretRequest + { + public string SecretName { get; set; } + public string SecretValue { get; set; } + public string SecretComment { get; set; } + public string ProjectId { get; set; } + public string Environment { get; set; } + public string SecretPath { get; set; } + public string Type { get; set; } + public string ApiVersion { get; set; } + public bool? SkipMultilineEncoding { get; set; } + public string[] TagIds { get; set; } + } + + public sealed class InfisicalUpdateSecretRequest + { + public string SecretName { get; set; } + public string NewSecretName { get; set; } + public string SecretValue { get; set; } + public string SecretComment { get; set; } + public string ProjectId { get; set; } + public string Environment { get; set; } + public string SecretPath { get; set; } + public string Type { get; set; } + public string ApiVersion { get; set; } + public bool? SkipMultilineEncoding { get; set; } + public string[] TagIds { get; set; } + } + + public sealed class InfisicalDeleteSecretRequest + { + public string SecretName { get; set; } + public string ProjectId { get; set; } + public string Environment { get; set; } + public string SecretPath { get; set; } + 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 aa9d7a7..8e4161b 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs @@ -136,6 +136,353 @@ namespace PSInfisicalAPI.Secrets } } + public InfisicalSecret Create(InfisicalConnection connection, InfisicalCreateSecretRequest request) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (request == null) { throw new ArgumentNullException(nameof(request)); } + if (string.IsNullOrEmpty(request.SecretName)) { throw new InfisicalConfigurationException("SecretName is required."); } + if (request.SecretValue == null) { throw new InfisicalConfigurationException("SecretValue 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."); } + + Dictionary pathParameters = new Dictionary { { "secretName", request.SecretName } }; + InfisicalSecretCreateRequestDto dtoRequest = new InfisicalSecretCreateRequestDto + { + WorkspaceId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = FirstNonEmpty(request.SecretPath, connection.DefaultSecretPath, "/"), + Type = string.IsNullOrEmpty(request.Type) ? "shared" : request.Type.ToLowerInvariant(), + SecretValue = request.SecretValue, + SecretComment = request.SecretComment, + SkipMultilineEncoding = request.SkipMultilineEncoding, + TagIds = request.TagIds + }; + string body = _serializer.Serialize(dtoRequest); + + try + { + _logger.Information(Component, string.Concat("Attempting to create Infisical secret '", request.SecretName, "'. Please Wait...")); + InfisicalHttpResponse response = SendWithVersionFallback(connection, InfisicalEndpointNames.CreateSecret, request.ApiVersion, "CreateSecret", pathParameters, null, body); + InfisicalSecretSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalSecret mapped = InfisicalSecretMapper.Map(dto != null ? dto.Secret : null); + _logger.Information(Component, "Infisical secret creation was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical secret creation failed."); + throw; + } + } + + public InfisicalSecret Update(InfisicalConnection connection, InfisicalUpdateSecretRequest request) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (request == null) { throw new ArgumentNullException(nameof(request)); } + if (string.IsNullOrEmpty(request.SecretName)) { throw new InfisicalConfigurationException("SecretName 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."); } + + Dictionary pathParameters = new Dictionary { { "secretName", request.SecretName } }; + InfisicalSecretUpdateRequestDto dtoRequest = new InfisicalSecretUpdateRequestDto + { + WorkspaceId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = FirstNonEmpty(request.SecretPath, connection.DefaultSecretPath, "/"), + Type = string.IsNullOrEmpty(request.Type) ? "shared" : request.Type.ToLowerInvariant(), + SecretValue = request.SecretValue, + SecretComment = request.SecretComment, + NewSecretName = request.NewSecretName, + SkipMultilineEncoding = request.SkipMultilineEncoding, + TagIds = request.TagIds + }; + string body = _serializer.Serialize(dtoRequest); + + try + { + _logger.Information(Component, string.Concat("Attempting to update Infisical secret '", request.SecretName, "'. Please Wait...")); + InfisicalHttpResponse response = SendWithVersionFallback(connection, InfisicalEndpointNames.UpdateSecret, request.ApiVersion, "UpdateSecret", pathParameters, null, body); + InfisicalSecretSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalSecret mapped = InfisicalSecretMapper.Map(dto != null ? dto.Secret : null); + _logger.Information(Component, "Infisical secret update was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical secret update failed."); + throw; + } + } + + 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, + ProjectId = 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, + ProjectId = 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, + ProjectId = 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)); } + if (request == null) { throw new ArgumentNullException(nameof(request)); } + if (string.IsNullOrEmpty(request.SecretName)) { throw new InfisicalConfigurationException("SecretName 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."); } + + Dictionary pathParameters = new Dictionary { { "secretName", request.SecretName } }; + InfisicalSecretDeleteRequestDto dtoRequest = new InfisicalSecretDeleteRequestDto + { + WorkspaceId = resolvedProjectId, + Environment = resolvedEnvironment, + SecretPath = FirstNonEmpty(request.SecretPath, connection.DefaultSecretPath, "/"), + Type = string.IsNullOrEmpty(request.Type) ? "shared" : request.Type.ToLowerInvariant() + }; + string body = _serializer.Serialize(dtoRequest); + + try + { + _logger.Information(Component, string.Concat("Attempting to delete Infisical secret '", request.SecretName, "'. Please Wait...")); + InfisicalHttpResponse response = SendWithVersionFallback(connection, InfisicalEndpointNames.DeleteSecret, request.ApiVersion, "DeleteSecret", pathParameters, null, body); + response.Clear(); + _logger.Information(Component, "Infisical secret deletion was successful."); + } + catch (Exception) + { + _logger.Error(Component, "Infisical secret deletion failed."); + throw; + } + } + private InfisicalHttpResponse SendWithVersionFallback( InfisicalConnection connection, string endpointName, diff --git a/src/PSInfisicalAPI/Tags/InfisicalTagClient.cs b/src/PSInfisicalAPI/Tags/InfisicalTagClient.cs new file mode 100644 index 0000000..9430ae2 --- /dev/null +++ b/src/PSInfisicalAPI/Tags/InfisicalTagClient.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Serialization; + +namespace PSInfisicalAPI.Tags +{ + public sealed class InfisicalTagClient + { + private const string Component = "TagClient"; + + private readonly IInfisicalLogger _logger; + private readonly JsonInfisicalSerializer _serializer; + private readonly InfisicalApiInvoker _invoker; + + public InfisicalTagClient(IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (httpClient == null) { throw new ArgumentNullException(nameof(httpClient)); } + _logger = logger ?? NullInfisicalLogger.Instance; + _serializer = new JsonInfisicalSerializer(); + _invoker = new InfisicalApiInvoker(httpClient); + } + + public InfisicalTag[] List(InfisicalConnection connection, string projectId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", resolvedProjectId } }; + + try + { + _logger.Information(Component, "Attempting to list Infisical tags. Please Wait..."); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.ListTags, "ListTags", pathParameters, null, null); + InfisicalTagListResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + List source = dto != null ? (dto.WorkspaceTags ?? dto.Tags) : null; + InfisicalTag[] mapped = InfisicalTagMapper.MapMany(source, resolvedProjectId); + _logger.Information(Component, "Infisical tag list retrieval was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical tag list retrieval failed."); + throw; + } + } + + public InfisicalTag Retrieve(InfisicalConnection connection, string projectId, string tagSlugOrId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(tagSlugOrId)) { throw new InfisicalConfigurationException("Tag slug or id is required."); } + + InfisicalTag[] all = List(connection, resolvedProjectId); + foreach (InfisicalTag tag in all) + { + if (string.Equals(tag.Id, tagSlugOrId, StringComparison.OrdinalIgnoreCase) || + string.Equals(tag.Slug, tagSlugOrId, StringComparison.OrdinalIgnoreCase)) + { + return tag; + } + } + + return null; + } + + public InfisicalTag Create(InfisicalConnection connection, string projectId, string slug, string name, string color) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(slug)) { throw new InfisicalConfigurationException("Slug is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", resolvedProjectId } }; + InfisicalTagCreateRequestDto request = new InfisicalTagCreateRequestDto { Slug = slug, Name = name, Color = color }; + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, string.Concat("Attempting to create Infisical tag '", slug, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.CreateTag, "CreateTag", pathParameters, null, body); + InfisicalTagSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalTagResponseDto inner = dto != null ? (dto.WorkspaceTag ?? dto.Tag) : null; + InfisicalTag mapped = InfisicalTagMapper.Map(inner, resolvedProjectId); + _logger.Information(Component, "Infisical tag creation was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical tag creation failed."); + throw; + } + } + + public InfisicalTag Update(InfisicalConnection connection, string projectId, string tagId, string slug, string name, string color) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(tagId)) { throw new InfisicalConfigurationException("TagId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", resolvedProjectId }, { "tagId", tagId } }; + InfisicalTagUpdateRequestDto request = new InfisicalTagUpdateRequestDto { Slug = slug, Name = name, Color = color }; + string body = _serializer.Serialize(request); + + try + { + _logger.Information(Component, string.Concat("Attempting to update Infisical tag '", tagId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.UpdateTag, "UpdateTag", pathParameters, null, body); + InfisicalTagSingleResponseDto dto = _serializer.Deserialize(response.Body); + response.Clear(); + + InfisicalTagResponseDto inner = dto != null ? (dto.WorkspaceTag ?? dto.Tag) : null; + InfisicalTag mapped = InfisicalTagMapper.Map(inner, resolvedProjectId); + _logger.Information(Component, "Infisical tag update was successful."); + return mapped; + } + catch (Exception) + { + _logger.Error(Component, "Infisical tag update failed."); + throw; + } + } + + public void Delete(InfisicalConnection connection, string projectId, string tagId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + string resolvedProjectId = FirstNonEmpty(projectId, connection.ProjectId); + if (string.IsNullOrEmpty(resolvedProjectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + if (string.IsNullOrEmpty(tagId)) { throw new InfisicalConfigurationException("TagId is required."); } + + Dictionary pathParameters = new Dictionary { { "projectId", resolvedProjectId }, { "tagId", tagId } }; + + try + { + _logger.Information(Component, string.Concat("Attempting to delete Infisical tag '", tagId, "'. Please Wait...")); + InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.DeleteTag, "DeleteTag", pathParameters, null, null); + response.Clear(); + _logger.Information(Component, "Infisical tag deletion was successful."); + } + catch (Exception) + { + _logger.Error(Component, "Infisical tag deletion failed."); + throw; + } + } + + private static string FirstNonEmpty(params string[] values) + { + if (values == null) { return null; } + foreach (string value in values) { if (!string.IsNullOrEmpty(value)) { return value; } } + return null; + } + } +} diff --git a/src/PSInfisicalAPI/Tags/InfisicalTagDtos.cs b/src/PSInfisicalAPI/Tags/InfisicalTagDtos.cs new file mode 100644 index 0000000..622c80a --- /dev/null +++ b/src/PSInfisicalAPI/Tags/InfisicalTagDtos.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace PSInfisicalAPI.Tags +{ + internal sealed class InfisicalTagResponseDto + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("_id")] public string InternalId { get; set; } + [JsonProperty("slug")] public string Slug { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("color")] public string Color { get; set; } + [JsonProperty("projectId")] public string ProjectId { get; set; } + [JsonProperty("workspaceId")] public string WorkspaceId { get; set; } + [JsonProperty("createdAt")] public string CreatedAt { get; set; } + [JsonProperty("updatedAt")] public string UpdatedAt { get; set; } + } + + internal sealed class InfisicalTagListResponseDto + { + [JsonProperty("workspaceTags")] public List WorkspaceTags { get; set; } + [JsonProperty("tags")] public List Tags { get; set; } + } + + internal sealed class InfisicalTagSingleResponseDto + { + [JsonProperty("workspaceTag")] public InfisicalTagResponseDto WorkspaceTag { get; set; } + [JsonProperty("tag")] public InfisicalTagResponseDto Tag { get; set; } + } + + internal sealed class InfisicalTagCreateRequestDto + { + [JsonProperty("slug")] public string Slug { get; set; } + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } + [JsonProperty("color", NullValueHandling = NullValueHandling.Ignore)] public string Color { get; set; } + } + + internal sealed class InfisicalTagUpdateRequestDto + { + [JsonProperty("slug", NullValueHandling = NullValueHandling.Ignore)] public string Slug { get; set; } + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } + [JsonProperty("color", NullValueHandling = NullValueHandling.Ignore)] public string Color { get; set; } + } +} diff --git a/src/PSInfisicalAPI/Tags/InfisicalTagMapper.cs b/src/PSInfisicalAPI/Tags/InfisicalTagMapper.cs new file mode 100644 index 0000000..04ac855 --- /dev/null +++ b/src/PSInfisicalAPI/Tags/InfisicalTagMapper.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using PSInfisicalAPI.Models; + +namespace PSInfisicalAPI.Tags +{ + internal static class InfisicalTagMapper + { + public static InfisicalTag Map(InfisicalTagResponseDto dto, string fallbackProjectId) + { + if (dto == null) + { + return null; + } + + string projectId = !string.IsNullOrEmpty(dto.ProjectId) + ? dto.ProjectId + : (!string.IsNullOrEmpty(dto.WorkspaceId) ? dto.WorkspaceId : fallbackProjectId); + + return new InfisicalTag + { + Id = !string.IsNullOrEmpty(dto.Id) ? dto.Id : dto.InternalId, + Slug = dto.Slug, + Name = dto.Name, + Color = dto.Color, + ProjectId = projectId, + CreatedAtUtc = ParseTimestamp(dto.CreatedAt), + UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt) + }; + } + + public static InfisicalTag[] MapMany(IEnumerable items, string fallbackProjectId) + { + if (items == null) + { + return Array.Empty(); + } + + List results = new List(); + foreach (InfisicalTagResponseDto dto in items) + { + InfisicalTag mapped = Map(dto, fallbackProjectId); + if (mapped != null) + { + results.Add(mapped); + } + } + + return results.ToArray(); + } + + private static DateTimeOffset? ParseTimestamp(string value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + DateTimeOffset parsed; + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out parsed)) + { + return parsed; + } + + return null; + } + } +}