diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e359d..187bd42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos ## Unreleased +## 2026.06.03.0057 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + +## 2026.06.03.0056 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + +## 2026.06.03.0055 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + +## 2026.06.03.0047 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + +## 2026.06.03.0046 + +- Build produced from commit 7e5209190ac2. + +## Unreleased (carried forward) + ## 2026.06.03.0032 - Build produced from commit c86676010532. diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index 706ba22..e313a14 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PSInfisicalAPI.psm1' - ModuleVersion = '2026.06.03.0032' + ModuleVersion = '2026.06.03.0057' GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51' Author = 'Grace Solutions' CompanyName = 'Grace Solutions' @@ -27,7 +27,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 = 'c86676010532' + CommitHash = '7e5209190ac2' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll index acef9b6..6f0a94e 100644 Binary files a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll and b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll differ diff --git a/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs index 478ee7b..f5e6bdd 100644 --- a/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs @@ -89,10 +89,13 @@ namespace PSInfisicalAPI.Cmdlets throw new InfisicalAuthenticationException("Authentication did not produce an access token."); } + bool apiVersionExplicitlyBound = MyInvocation.BoundParameters.ContainsKey("ApiVersion"); + InfisicalConnection connection = new InfisicalConnection { BaseUri = BaseUri, ApiVersion = ApiVersion, + PinnedApiVersion = apiVersionExplicitlyBound ? ApiVersion : null, AuthType = authType, OrganizationId = OrganizationId, ProjectId = ProjectId, diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs index 6bb770a..6db8d78 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs @@ -16,6 +16,7 @@ namespace PSInfisicalAPI.Cmdlets [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 int? Version { get; set; } [Parameter] public InfisicalSecretType Type { get; set; } = InfisicalSecretType.Shared; [Parameter] public SwitchParameter ViewSecretValue { get; set; } @@ -34,6 +35,7 @@ namespace PSInfisicalAPI.Cmdlets ProjectId = ProjectId, Environment = Environment, SecretPath = SecretPath, + ApiVersion = ApiVersion, Version = Version, Type = Type.ToString(), ViewSecretValue = ViewSecretValue.IsPresent, diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs index 9695aa1..938b5f4 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretsCmdlet.cs @@ -15,6 +15,7 @@ namespace PSInfisicalAPI.Cmdlets [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 SwitchParameter Recursive { get; set; } [Parameter] public SwitchParameter IncludeImports { get; set; } [Parameter] public SwitchParameter IncludePersonalOverrides { get; set; } @@ -34,6 +35,7 @@ namespace PSInfisicalAPI.Cmdlets ProjectId = ProjectId, Environment = Environment, SecretPath = SecretPath, + ApiVersion = ApiVersion, Recursive = Recursive.IsPresent, IncludeImports = IncludeImports.IsPresent, IncludePersonalOverrides = IncludePersonalOverrides.IsPresent, diff --git a/src/PSInfisicalAPI/Connections/InfisicalConnection.cs b/src/PSInfisicalAPI/Connections/InfisicalConnection.cs index 7fcfad8..ef4d04c 100644 --- a/src/PSInfisicalAPI/Connections/InfisicalConnection.cs +++ b/src/PSInfisicalAPI/Connections/InfisicalConnection.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Security; using PSInfisicalAPI.Models; @@ -8,6 +9,7 @@ namespace PSInfisicalAPI.Connections { public Uri BaseUri { get; set; } public string ApiVersion { get; set; } + public string PinnedApiVersion { get; set; } public InfisicalAuthType AuthType { get; set; } public string OrganizationId { get; set; } public string ProjectId { get; set; } @@ -17,6 +19,8 @@ namespace PSInfisicalAPI.Connections public DateTimeOffset? ExpiresAtUtc { get; set; } public bool IsConnected { get; set; } + public Dictionary ResolvedEndpointVersions { get; } = new Dictionary(StringComparer.Ordinal); + internal SecureString AccessToken { get; set; } public override string ToString() diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs index 9e3dfe7..997d14f 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs @@ -5,67 +5,88 @@ namespace PSInfisicalAPI.Endpoints { public static class InfisicalEndpointRegistry { - private static readonly Dictionary Definitions = - new Dictionary + private static readonly Dictionary> Candidates = + new Dictionary> { { InfisicalEndpointNames.UniversalAuthLogin, - new InfisicalEndpointDefinition + new List { - Name = InfisicalEndpointNames.UniversalAuthLogin, - Resource = "Authentication", - Version = "v1", - Method = "POST", - Template = "/api/v1/auth/universal-auth/login", - RequiresAuthorization = false, - ContainsSecretMaterialInRequest = true, - ContainsSecretMaterialInResponse = true + 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 InfisicalEndpointDefinition + new List { - 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 = "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 InfisicalEndpointDefinition + new List { - 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 = "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 + } } } }; public static InfisicalEndpointDefinition Get(string name) { - if (string.IsNullOrEmpty(name)) - { - throw new InfisicalConfigurationException("Endpoint name must be provided."); - } - - InfisicalEndpointDefinition definition; - if (!Definitions.TryGetValue(name, out definition)) - { - throw new InfisicalConfigurationException(string.Concat("Unknown endpoint name: ", name)); - } - - return definition; + List list = GetCandidatesInternal(name); + return list[0]; } public static bool TryGet(string name, out InfisicalEndpointDefinition definition) @@ -76,12 +97,50 @@ namespace PSInfisicalAPI.Endpoints return false; } - return Definitions.TryGetValue(name, out definition); + List list; + if (!Candidates.TryGetValue(name, out list) || list == null || list.Count == 0) + { + definition = null; + return false; + } + + definition = list[0]; + return true; + } + + public static IReadOnlyList GetCandidates(string name) + { + return GetCandidatesInternal(name); } public static IEnumerable All() { - return Definitions.Values; + List result = new List(); + foreach (List list in Candidates.Values) + { + foreach (InfisicalEndpointDefinition definition in list) + { + result.Add(definition); + } + } + + return result; + } + + private static List GetCandidatesInternal(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new InfisicalConfigurationException("Endpoint name must be provided."); + } + + List list; + if (!Candidates.TryGetValue(name, out list) || list == null || list.Count == 0) + { + throw new InfisicalConfigurationException(string.Concat("Unknown endpoint name: ", name)); + } + + return list; } } } diff --git a/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs b/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs index 69b6d30..178d189 100644 --- a/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs +++ b/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs @@ -39,13 +39,7 @@ namespace PSInfisicalAPI.Logging public void Error(string component, string message) { string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Error, component, message); - ErrorRecord record = new ErrorRecord( - new InvalidOperationException(message ?? string.Empty), - "PSInfisicalAPI.Error", - ErrorCategory.NotSpecified, - component); - record.ErrorDetails = new ErrorDetails(line); - _cmdlet.WriteError(record); + _cmdlet.WriteWarning(line); } } } diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs index fd3561a..8ede056 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretQuery.cs @@ -7,6 +7,7 @@ namespace PSInfisicalAPI.Secrets public string ProjectId { get; set; } public string Environment { get; set; } public string SecretPath { get; set; } + public string ApiVersion { get; set; } public bool Recursive { get; set; } public bool? IncludeImports { get; set; } public bool IncludePersonalOverrides { get; set; } @@ -22,6 +23,7 @@ namespace PSInfisicalAPI.Secrets public string ProjectId { get; set; } public string Environment { get; set; } public string SecretPath { get; set; } + public string ApiVersion { get; set; } public int? Version { get; set; } public string Type { get; set; } public bool? ViewSecretValue { get; set; } diff --git a/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs b/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs index 8013cc5..aa9d7a7 100644 --- a/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs +++ b/src/PSInfisicalAPI/Secrets/InfisicalSecretsClient.cs @@ -32,10 +32,11 @@ namespace PSInfisicalAPI.Secrets if (connection == null) { throw new ArgumentNullException(nameof(connection)); } if (query == null) { throw new ArgumentNullException(nameof(query)); } - InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.ListSecrets); + string resolvedProjectId = FirstNonEmpty(query.ProjectId, connection.ProjectId); List> queryParameters = new List>(); - AddIfNotNull(queryParameters, "projectId", FirstNonEmpty(query.ProjectId, connection.ProjectId)); + AddIfNotNull(queryParameters, "workspaceId", resolvedProjectId); + AddIfNotNull(queryParameters, "projectId", resolvedProjectId); AddIfNotNull(queryParameters, "environment", FirstNonEmpty(query.Environment, connection.Environment)); AddIfNotNull(queryParameters, "secretPath", FirstNonEmpty(query.SecretPath, connection.DefaultSecretPath, "/")); queryParameters.Add(new KeyValuePair("recursive", query.Recursive ? "true" : "false")); @@ -60,13 +61,18 @@ namespace PSInfisicalAPI.Secrets } } - Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, null, queryParameters); - InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, "RetrieveSecrets", uri, null); - try { _logger.Information(Component, "Attempting to retrieve Infisical secrets. Please Wait..."); - EnsureSuccess(response, definition); + + InfisicalHttpResponse response = SendWithVersionFallback( + connection, + InfisicalEndpointNames.ListSecrets, + query.ApiVersion, + "RetrieveSecrets", + null, + queryParameters, + null); InfisicalSecretListResponseDto dto = _serializer.Deserialize(response.Body); response.Clear(); @@ -88,12 +94,13 @@ namespace PSInfisicalAPI.Secrets if (query == null) { throw new ArgumentNullException(nameof(query)); } if (string.IsNullOrEmpty(query.SecretName)) { throw new InfisicalConfigurationException("SecretName is required."); } - InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret); - Dictionary pathParameters = new Dictionary { { "secretName", query.SecretName } }; + string resolvedProjectId = FirstNonEmpty(query.ProjectId, connection.ProjectId); + List> queryParameters = new List>(); - AddIfNotNull(queryParameters, "projectId", FirstNonEmpty(query.ProjectId, connection.ProjectId)); + AddIfNotNull(queryParameters, "workspaceId", resolvedProjectId); + AddIfNotNull(queryParameters, "projectId", resolvedProjectId); AddIfNotNull(queryParameters, "environment", FirstNonEmpty(query.Environment, connection.Environment)); AddIfNotNull(queryParameters, "secretPath", FirstNonEmpty(query.SecretPath, connection.DefaultSecretPath, "/")); AddIfNotNull(queryParameters, "type", string.IsNullOrEmpty(query.Type) ? "shared" : query.Type.ToLowerInvariant()); @@ -102,13 +109,18 @@ namespace PSInfisicalAPI.Secrets if (query.ExpandSecretReferences.HasValue) { queryParameters.Add(new KeyValuePair("expandSecretReferences", query.ExpandSecretReferences.Value ? "true" : "false")); } if (query.IncludeImports.HasValue) { queryParameters.Add(new KeyValuePair("includeImports", query.IncludeImports.Value ? "true" : "false")); } - Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, pathParameters, queryParameters); - InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, "RetrieveSecret", uri, null); - try { _logger.Information(Component, string.Concat("Attempting to retrieve Infisical secret '", query.SecretName, "'. Please Wait...")); - EnsureSuccess(response, definition); + + InfisicalHttpResponse response = SendWithVersionFallback( + connection, + InfisicalEndpointNames.RetrieveSecret, + query.ApiVersion, + "RetrieveSecret", + pathParameters, + queryParameters, + null); InfisicalSecretSingleResponseDto dto = _serializer.Deserialize(response.Body); response.Clear(); @@ -124,6 +136,160 @@ namespace PSInfisicalAPI.Secrets } } + private InfisicalHttpResponse SendWithVersionFallback( + InfisicalConnection connection, + string endpointName, + string perCallApiVersion, + string operationName, + Dictionary pathParameters, + List> queryParameters, + string body) + { + IReadOnlyList allCandidates = InfisicalEndpointRegistry.GetCandidates(endpointName); + + string pinned = FirstNonEmpty(perCallApiVersion, connection.PinnedApiVersion); + string cached; + connection.ResolvedEndpointVersions.TryGetValue(endpointName, out cached); + + List candidates = OrderCandidates(allCandidates, pinned, cached); + + if (candidates.Count == 0) + { + throw new InfisicalConfigurationException(string.Concat( + "No matching endpoint candidate for '", endpointName, + "' with ApiVersion='", pinned ?? string.Empty, "'.")); + } + + InfisicalApiException lastException = null; + + for (int index = 0; index < candidates.Count; index++) + { + InfisicalEndpointDefinition definition = candidates[index]; + Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, pathParameters, queryParameters); + InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, operationName, uri, body); + + if (response.StatusCode >= 200 && response.StatusCode < 300) + { + connection.ResolvedEndpointVersions[endpointName] = definition.Version; + return response; + } + + InfisicalApiException exception = BuildApiException(response, definition); + response.Clear(); + + bool hasMoreCandidates = (index + 1) < candidates.Count; + bool pinnedHere = !string.IsNullOrEmpty(pinned); + + if (!pinnedHere && hasMoreCandidates && IsVersionMismatch(exception)) + { + _logger.Warning(Component, string.Concat( + "Endpoint '", endpointName, "' version '", definition.Version, + "' rejected by server (", exception.StatusCode.ToString(CultureInfo.InvariantCulture), + "); falling back to next candidate.")); + lastException = exception; + continue; + } + + throw exception; + } + + throw lastException ?? new InfisicalApiException(string.Concat( + "All API version candidates exhausted for '", endpointName, "'.")); + } + + private static List OrderCandidates( + IReadOnlyList allCandidates, + string pinned, + string cached) + { + List ordered = new List(); + + if (!string.IsNullOrEmpty(pinned)) + { + foreach (InfisicalEndpointDefinition candidate in allCandidates) + { + if (string.Equals(candidate.Version, pinned, StringComparison.OrdinalIgnoreCase)) + { + ordered.Add(candidate); + } + } + + return ordered; + } + + if (!string.IsNullOrEmpty(cached)) + { + foreach (InfisicalEndpointDefinition candidate in allCandidates) + { + if (string.Equals(candidate.Version, cached, StringComparison.OrdinalIgnoreCase)) + { + ordered.Add(candidate); + break; + } + } + + foreach (InfisicalEndpointDefinition candidate in allCandidates) + { + if (!string.Equals(candidate.Version, cached, StringComparison.OrdinalIgnoreCase)) + { + ordered.Add(candidate); + } + } + + return ordered; + } + + foreach (InfisicalEndpointDefinition candidate in allCandidates) + { + ordered.Add(candidate); + } + + return ordered; + } + + private static bool IsVersionMismatch(InfisicalApiException exception) + { + string body = exception.SanitizedBody; + bool hasInfisicalErrorEnvelope = !string.IsNullOrEmpty(body) + && body.IndexOf("\"reqId\"", StringComparison.OrdinalIgnoreCase) >= 0 + && body.IndexOf("\"error\"", StringComparison.OrdinalIgnoreCase) >= 0; + + if (exception.StatusCode == 405) + { + return true; + } + + if (exception.StatusCode == 404 && !hasInfisicalErrorEnvelope) + { + return true; + } + + if (exception.StatusCode == 400 && !string.IsNullOrEmpty(body)) + { + if (body.IndexOf("projectSlug", StringComparison.OrdinalIgnoreCase) >= 0 || + body.IndexOf("workspaceId", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + } + + return false; + } + + 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; + } + private InfisicalHttpResponse ExecuteAuthorized(InfisicalConnection connection, InfisicalEndpointDefinition definition, string operationName, Uri uri, string body) { Dictionary headers = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -157,23 +323,6 @@ namespace PSInfisicalAPI.Secrets return _httpClient.Send(request); } - private static void EnsureSuccess(InfisicalHttpResponse response, InfisicalEndpointDefinition definition) - { - if (response.StatusCode >= 200 && response.StatusCode < 300) - { - return; - } - - 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 = definition.ContainsSecretMaterialInResponse ? "[REDACTED]" : response.Body; - response.Clear(); - throw exception; - } - private static void AddIfNotNull(List> list, string key, string value) { if (!string.IsNullOrEmpty(value))