diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a66f20..a02d14a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,41 +6,74 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos ## Unreleased +## 2026.07.30.2151 + +- Build produced from commit 14c8c4f3845b. + +## Unreleased (carried forward) + +### Breaking + +- Operation failures are now reported as **non-terminating** errors, so `-ErrorAction` (and `$ErrorActionPreference`) decides the outcome: `Continue` writes the error and lets a pipeline keep processing, `SilentlyContinue` suppresses it while still populating `$Error` and `-ErrorVariable`, `Ignore` records nothing, and `Stop` is promoted by the engine into a terminating error. Previously every failure was terminating and ignored `-ErrorAction` entirely. +- **`try`/`catch` now requires `-ErrorAction Stop`** (or `$ErrorActionPreference = 'Stop'`). Existing `try`/`catch` blocks around these cmdlets must add it to keep catching. +- A failing pipeline item no longer aborts the batch; remaining input is still processed. +- `Start-InfisicalProcess` reports an unacceptable exit code as a non-terminating error for the same reason. + +### Fixed + +- Cmdlets no longer report "The pipeline has been stopped." as an error. `Select-Object -First`, and `Where-Object` feeding it, stop the upstream cmdlet by design; the shared error path in `InfisicalCmdletBase` now lets those pipeline-control exceptions propagate untouched instead of logging them and raising a terminating error. +- `Request-InfisicalCertificate -CommonName` accepts the RDN form (`CN=WEB01`) and reduces it to the bare value. Previously the prefix was passed through verbatim, producing a `CN=CN=WEB01` subject and a bogus `CN=WEB01` DNS SAN. +- `Request-InfisicalCertificate -DnsName` routes IP literals to `iPAddress` SAN entries. The documented usage splats the mixed output of `Get-InfisicalSANList` into `-DnsName`, which previously emitted IP addresses as malformed `dNSName` entries. +- `Request-InfisicalCertificate -CertificateAuthorityId` now sends the normalized common name to the signing endpoint instead of the raw parameter value. + +- Error-level diagnostics no longer occupy the warning stream. Every `Logger.Error` call site logs and then throws, so the failure already reaches the caller as an `ErrorRecord`; emitting the same failure as eight warning lines put it under `-WarningAction` instead of `-ErrorAction`. Those lines moved to the verbose stream, leaving one error per failure. `-ErrorVariable`, `$Error`, `2>` redirection, and `try`/`catch` all now govern module failures as expected. + +### Added + +- `Request-InfisicalCertificate` resolves and reports its issuance path before generating a keypair, naming the subscriber, CA, or profile on the verbose stream and in the `-WhatIf` target. +- The `-CertificateAuthorityId` path fails fast with actionable guidance when the CA has direct issuance disabled, instead of building a CSR and surfacing a bare `400` from the signing call. + +### Changed + +- A `400 Certificate template or subscriber is required for issuance` response from direct CA signing is restated with the cmdlet parameters that resolve it (`-PkiSubscriberSlug`, `-CertificateProfileId`, or enabling direct issuance on the CA). +- `Get-InfisicalCertificateAuthority` table output includes a `DirectIssue` column (`EnableDirectIssuance`), so CAs eligible for `-CertificateAuthorityId` are visible without formatting the full object. +- The README end-to-end PKI example issues through a PKI subscriber and documents how to choose among the three issuance parameter sets. + ## 2026.06.16.0217 - Build produced from commit 6318d06362ad. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.16.0215 - Build produced from commit 6318d06362ad. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.16.0213 - Build produced from commit 6318d06362ad. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.16.0207 - Build produced from commit 6318d06362ad. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.16.0156 - Build produced from commit 6318d06362ad. -## Unreleased (carried forward) +## Unreleased (carried forward) ## 2026.06.10.2018 - Build produced from commit daf1cdce6576. -## Unreleased (carried forward) +## Unreleased (carried forward) - Renamed prefix-related parameters across `ConvertTo-InfisicalSecretDictionary`, `Import-InfisicalSecret`, `Export-InfisicalSecrets`, and `Start-InfisicalProcess`: `-Prefix` is now `-SecretsPrefix` and `-ForcePrefix` is now `-ForceSecretsPrefix`. `Start-InfisicalProcess` also renames the pipeline parameter `-Secret` to `-Secrets`. The previous names remain available as parameter aliases (`Prefix`, `ForcePrefix`, `Secret`) for backward compatibility. diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml b/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml index 34418a1..1e2bdce 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.Format.ps1xml @@ -38,11 +38,12 @@ - 28 - 32 - 10 - 10 - 14 + 24 + 28 + 9 + 8 + 11 + 13 22 @@ -52,6 +53,7 @@ CommonName Type Status + EnableDirectIssuance KeyAlgorithm NotAfter diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index 8f802e3..795e8fe 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PSInfisicalAPI.psm1' - ModuleVersion = '2026.06.16.0217' + ModuleVersion = '2026.07.30.2151' GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51' Author = 'Grace Solutions' CompanyName = 'Grace Solutions' @@ -74,7 +74,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 = '6318d06362ad' + CommitHash = '14c8c4f3845b' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll index ef4f494..d5e7557 100644 Binary files a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll and b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll differ diff --git a/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml b/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml index cb37781..87b7aa6 100644 --- a/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml +++ b/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml @@ -1066,7 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters Notes - ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. + ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others require a subscriber or certificate profile instead. @@ -1281,6 +1281,8 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi Notes Default -PrivateKeyProtection is 'LocalOnly': the leaf is loaded into memory without persisting the private key and PrivateKeyPem is scrubbed from the emitted result unless -PrivateKeyPath or an explicit -KeyStorageFlags binding overrides it. The reuse path completes its chain from the Infisical bundle when local stores are incomplete; pass -LocalChainOnly to suppress that fetch entirely. + -CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -PkiSubscriberSlug or -CertificateProfileId, or enable direct issuance on the CA. + -CommonName takes the bare value ('web01.contoso.com'), not an RDN; a leading 'CN=' is stripped because the CSR builder adds the prefix itself. -DnsName accepts the mixed output of Get-InfisicalSANList: IP literals in that list are emitted as iPAddress SAN entries rather than dNSName entries. diff --git a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml index cb37781..87b7aa6 100644 --- a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml +++ b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml @@ -1066,7 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters Notes - ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. + ByID retrieval currently always resolves against the internal CA endpoint. CA Ids returned here are the values to pass on -CertificateAuthorityId to Request-InfisicalCertificate. The Type property distinguishes 'internal' from 'acme' when -Kind Any is used. Only CAs whose EnableDirectIssuance property is True can sign a CSR through -CertificateAuthorityId; the others require a subscriber or certificate profile instead. @@ -1281,6 +1281,8 @@ $GetInfisicalCertificatePolicyResult = Get-InfisicalCertificatePolicy @GetInfisi Notes Default -PrivateKeyProtection is 'LocalOnly': the leaf is loaded into memory without persisting the private key and PrivateKeyPem is scrubbed from the emitted result unless -PrivateKeyPath or an explicit -KeyStorageFlags binding overrides it. The reuse path completes its chain from the Infisical bundle when local stores are incomplete; pass -LocalChainOnly to suppress that fetch entirely. + -CertificateAuthorityId only works against a CA that permits direct issuance (Get-InfisicalCertificateAuthority reports this as EnableDirectIssuance). The cmdlet resolves the issuer and validates this before generating a keypair, naming the subscriber, CA, or profile it will use on the verbose stream and in the -WhatIf target. There is no -CertificateTemplateId parameter because Infisical's REST API exposes no template-based issuance route; when the API asks for 'a certificate template or subscriber', supply -PkiSubscriberSlug or -CertificateProfileId, or enable direct issuance on the CA. + -CommonName takes the bare value ('web01.contoso.com'), not an RDN; a leading 'CN=' is stripped because the CSR builder adds the prefix itself. -DnsName accepts the mixed output of Get-InfisicalSANList: IP literals in that list are emitted as iPAddress SAN entries rather than dNSName entries. diff --git a/Module/PSInfisicalAPI/en-US/about_PSInfisicalAPI.help.txt b/Module/PSInfisicalAPI/en-US/about_PSInfisicalAPI.help.txt index ac9ffea..d86c500 100644 --- a/Module/PSInfisicalAPI/en-US/about_PSInfisicalAPI.help.txt +++ b/Module/PSInfisicalAPI/en-US/about_PSInfisicalAPI.help.txt @@ -84,6 +84,39 @@ EXAMPLES Get-InfisicalSecrets | Export-InfisicalSecrets -Path .\secrets.env -Format Env +ERROR HANDLING AND STREAMS + Every cmdlet derives from PSCmdlet, so the common parameters are bound: + -Verbose, -Debug, -ErrorAction, -ErrorVariable, -WarningAction, + -WarningVariable, -InformationAction, -InformationVariable, -OutVariable, + and -PipelineVariable, plus -WhatIf/-Confirm where ShouldProcess applies. + + Output is separated by stream so those parameters mean what they say: + + Error The failure itself, once, as a non-terminating ErrorRecord. + Warning Advisories that are not failures. + Verbose Request/response trace and the trail leading up to a failure. + Debug Low-level detail. + + Operation failures are NON-TERMINATING, so -ErrorAction decides the + outcome: + + Continue (default) Error is written; a pipeline keeps processing. + SilentlyContinue Nothing printed; still in $Error/-ErrorVariable. + Ignore Nothing printed and nothing recorded. + Stop Promoted to terminating; try/catch catches it. + + To catch a failure you must ask for it: + + try { + Request-InfisicalCertificate @Parameters -ErrorAction Stop + } catch [PSInfisicalAPI.Errors.InfisicalApiException] { + "HTTP $($_.Exception.StatusCode): $($_.Exception.ApiErrorMessage)" + } + + The ErrorRecord carries the API detail, so log scraping is unnecessary: + $Error[0].Exception exposes StatusCode, ApiErrorCode, ApiErrorMessage, and + ApiRequestId on InfisicalApiException. + SECURITY NOTES - SecureString is used for ClientSecret, AccessToken, and any secret payloads returned by the API. diff --git a/README.md b/README.md index ba2e9b3..6a1abd3 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Disconnect-Infisical ## End-to-end: request and install a chained certificate -Connects, selects a project by name, sources SANs from `Get-InfisicalSANList`, picks the first available internal CA, requests a certificate, installs it (and its chain) into the current-user store, and disconnects. Each call uses a splatted `OrderedDictionary` constructed with `OrdinalIgnoreCase` so parameter names round-trip case-insensitively. +Connects, selects a `cert-manager` project, sources SANs from `Get-InfisicalSANList`, requests a certificate through a PKI subscriber, installs it (and its chain) into the current-user store, and disconnects. Each call uses a splatted `OrderedDictionary` constructed with `OrdinalIgnoreCase` so parameter names round-trip case-insensitively. ```powershell $ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) @@ -159,25 +159,159 @@ $ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specializ $Connection = Connect-Infisical @ConnectInfisicalParameters -$Project = Get-InfisicalProject | Where-Object {($_.Name -eq 'Platform')} | Select-Object -First 1 -$Ca = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) | Select-Object -First 1 -$SanList = Get-InfisicalSANList +$Project = Get-InfisicalProject -Type cert-manager | Where-Object {($_.Name -eq 'Platform')} | Select-Object -First 1 +$Subscriber = Get-InfisicalPkiSubscriber -ProjectId ($Project.Id) | Select-Object -First 1 +$SanList = Get-InfisicalSANList $RequestInfisicalCertificateParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) - $RequestInfisicalCertificateParameters.ProjectId = $Project.Id - $RequestInfisicalCertificateParameters.CertificateAuthorityId = $Ca.Id - $RequestInfisicalCertificateParameters.CommonName = "CN=$($Env:ComputerName.ToUpper())" - $RequestInfisicalCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]' + $RequestInfisicalCertificateParameters.ProjectId = $Project.Id + $RequestInfisicalCertificateParameters.PkiSubscriberSlug = $Subscriber.Name + $RequestInfisicalCertificateParameters.CommonName = $Env:ComputerName.ToUpper() + $RequestInfisicalCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]' $RequestInfisicalCertificateParameters.DnsName.AddRange($SanList) $RequestInfisicalCertificateParameters.DnsName.Add('myrecord.mydomain.com') - $RequestInfisicalCertificateParameters.Ttl = '90d' - $RequestInfisicalCertificateParameters.Install = $True - $RequestInfisicalCertificateParameters.InstallChain = $True - $Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters + $RequestInfisicalCertificateParameters.Install = $True + $RequestInfisicalCertificateParameters.InstallChain = $True + $RequestInfisicalCertificateParameters.Verbose = $True + +$Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters $Null = Disconnect-Infisical -Verbose ``` +### Choosing an issuance path + +`Request-InfisicalCertificate` has three mutually exclusive issuance parameter sets. Which one works depends on how the project is configured in Infisical: + +| Parameter | Use when | +| -------------------------- | ------------------------------------------------------------------------------------------ | +| `-PkiSubscriberSlug` | The project defines PKI subscribers (`Get-InfisicalPkiSubscriber`). Preferred for most setups. | +| `-CertificateProfileId` | The project issues through certificate profiles (`Get-InfisicalCertificateProfile`). | +| `-CertificateAuthorityId` | Signing straight against a CA. Requires **direct issuance** to be enabled on that CA. | + +There is no `-CertificateTemplateId` parameter. Infisical's REST API exposes no template-based issuance route — templates are consumed internally by EST and subscribers — so when the API says *"Certificate template or subscriber is required for issuance"*, the reachable answers are a subscriber, a profile, or enabling direct issuance. + +The cmdlet resolves and reports the issuer before generating a keypair, so `-Verbose` tells you exactly what will sign the request: + +```text +VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing via PKI subscriber 'web-tier' in project '2122628e-...'. +VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing directly via certificate authority 'intermediate-ca' (bf661d78-...); direct issuance is enabled. +``` + +`-WhatIf` names the same issuer without issuing anything: + +```powershell +Request-InfisicalCertificate @RequestInfisicalCertificateParameters -WhatIf +# What if: Performing the operation "Request new certificate" on target +# "PKI subscriber 'web-tier' for CN=WEB01". +``` + +#### Discovering subscribers + +A subscriber is a named enrollment identity that pins the CA, TTL, key usages, and SAN policy, so the request carries only a CSR. List what a project offers: + +```powershell +Get-InfisicalPkiSubscriber -ProjectId ($Project.Id) | + Format-Table Name, CommonName, Status, Ttl, CaId +``` + +Pass the subscriber's `Name` to `-PkiSubscriberSlug`. Because the subscriber owns the lifetime and usage policy, `-Ttl`, `-KeyUsage`, and `-ExtendedKeyUsage` are not accepted on this parameter set — set them on the subscriber in Infisical instead. + +If the project has no subscribers, either create one (**Certificate Management > Subscribers > Add Subscriber**) or use one of the other two paths. + +#### Enabling direct issuance on a CA + +Direct issuance lets a CA sign a bare CSR with no subscriber or template in front of it. It is a per-CA setting, and Infisical rejects the request with `400 Certificate template or subscriber is required for issuance` when it is off. This module now catches that before building a CSR: + +```text +Request-InfisicalCertificate : Certificate authority 'intermediate-ca' (bf661d78-...) has direct issuance +disabled, so it cannot sign a CSR on its own. Either enable direct issuance on the CA in Infisical +(Certificate Authorities > the CA > Enable Direct Issuance), or issue through a subscriber or profile +instead: Request-InfisicalCertificate -PkiSubscriberSlug ... or -CertificateProfileId ... +``` + +To turn it on, in the Infisical UI open the `cert-manager` project, go to **Certificate Authorities**, select the CA, and enable **Direct Issuance** in its settings. The setting is surfaced by this module as `EnableDirectIssuance`, so you can confirm it and pick an eligible CA in one step: + +```powershell +Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal | + Format-Table Name, CommonName, Status, EnableDirectIssuance + +$Ca = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal | + Where-Object {($_.EnableDirectIssuance -eq $True)} | + Select-Object -First 1 + +$RequestInfisicalCertificateParameters.CertificateAuthorityId = $Ca.Id +$RequestInfisicalCertificateParameters.Ttl = '90d' # required by the CA path +``` + +Prefer a subscriber or profile for routine enrollment: direct issuance bypasses the naming and key-usage constraints those layers enforce. Reserve it for bootstrap or break-glass cases. + +### Subject and SAN handling + +- `-CommonName` takes the bare value (`WEB01.contoso.com`), not an RDN. `CN=WEB01` is accepted and normalized, since the CSR builder adds the `CN=` prefix itself. +- `Get-InfisicalSANList` returns DNS names *and* IP addresses in one list. Passing the whole list to `-DnsName` is fine: IP literals are detected and emitted as `iPAddress` SAN entries rather than malformed `dNSName` entries. +- `-Ttl` (or `-NotAfter`) applies to the `-CertificateAuthorityId` and `-CertificateProfileId` paths. Subscriber-issued certificates take their lifetime from the subscriber definition. + +## Diagnostics and error handling + +Every cmdlet derives from `PSCmdlet`, so the full set of common parameters is bound: `-Verbose`, `-Debug`, `-ErrorAction`, `-ErrorVariable`, `-WarningAction`, `-WarningVariable`, `-InformationAction`, `-InformationVariable`, `-OutVariable`, `-PipelineVariable`, and `-WhatIf`/`-Confirm` on the cmdlets that declare `SupportsShouldProcess`. + +Output is routed by stream so those parameters mean what they say: + +| Stream | Carries | Controlled by | +| ----------- | ----------------------------------------------------------------------- | ------------------------------------ | +| Error | The failure itself, once, as a non-terminating `ErrorRecord` | `-ErrorAction`, `-ErrorVariable`, `2>` | +| Warning | Genuine advisories that are not failures (e.g. issuance returned no certificate) | `-WarningAction`, `-WarningVariable` | +| Verbose | Request/response trace and the diagnostic trail leading up to a failure | `-Verbose` | +| Debug | Low-level detail | `-Debug` | + +A failed call surfaces exactly one error. The `[Error]`-tagged diagnostic lines that precede it are on the verbose stream, so they appear only under `-Verbose` and never compete with the `ErrorRecord`: + +```powershell +# One error, no warning noise. +Request-InfisicalCertificate @Parameters -ErrorVariable Failure -ErrorAction SilentlyContinue + +# The ErrorRecord carries the API detail; no log scraping required. +$Failure[0].Exception.StatusCode # 400 +$Failure[0].Exception.ApiErrorCode # BadRequest +$Failure[0].Exception.ApiErrorMessage # Certificate template or subscriber is required for issuance +$Failure[0].Exception.ApiRequestId # req-SSPFN1gc2zHvkV +``` + +### `-ErrorAction` decides the outcome + +Operation failures are reported as **non-terminating** errors, so `-ErrorAction` (or `$ErrorActionPreference`) governs what happens, exactly as it does for built-in cmdlets: + +| `-ErrorAction` | Behavior | +| ------------------ | ------------------------------------------------------------------------- | +| `Continue` (default) | Error is written; a pipeline keeps processing its remaining input | +| `SilentlyContinue` | Nothing is printed; the error is still in `$Error` and `-ErrorVariable` | +| `Ignore` | Nothing is printed and nothing is recorded in `$Error` | +| `Stop` | Promoted to a terminating error that `try`/`catch` catches | +| `Inquire` | Prompts | + +A failing item does not abort the batch: + +```powershell +'web01', 'does-not-exist', 'web02' | + ForEach-Object { Get-InfisicalPkiSubscriber -ProjectId $ProjectId -Name $_ -ErrorAction SilentlyContinue } +# emits web01 and web02; the failure is available in $Error +``` + +**To catch failures you must ask for it** with `-ErrorAction Stop` or `$ErrorActionPreference = 'Stop'`: + +```powershell +try { + $Certificate = Request-InfisicalCertificate @Parameters -ErrorAction Stop +} catch [PSInfisicalAPI.Errors.InfisicalApiException] { + Write-Warning "Issuance failed with HTTP $($_.Exception.StatusCode): $($_.Exception.ApiErrorMessage)" +} +``` + +> **Breaking change.** Failures were previously terminating, so `try`/`catch` caught them without `-ErrorAction Stop`. Existing `try`/`catch` blocks need `-ErrorAction Stop` added (or `$ErrorActionPreference = 'Stop'` set) to keep catching. + +Exception types are `InfisicalApiException`, `InfisicalAuthenticationException`, `InfisicalHttpException`, `InfisicalSerializationException`, `InfisicalConfigurationException`, `InfisicalExportException`, and `InfisicalImportException`, all deriving from `InfisicalException`. + ## Automatic environment-variable discovery When `Connect-Infisical` is invoked with one or more parameters missing (or set to whitespace/empty), the cmdlet searches environment variables and uses the first value it finds. This makes invocation as simple as `Connect-Infisical` when variables are set up in advance. diff --git a/src/PSInfisicalAPI.Tests/CsrAndRequestCmdletTests.cs b/src/PSInfisicalAPI.Tests/CsrAndRequestCmdletTests.cs index 0684050..debd2c3 100644 --- a/src/PSInfisicalAPI.Tests/CsrAndRequestCmdletTests.cs +++ b/src/PSInfisicalAPI.Tests/CsrAndRequestCmdletTests.cs @@ -114,6 +114,170 @@ namespace PSInfisicalAPI.Tests Assert.Equal("DE", countryProp.GetValue(result)); } + [Theory] + [InlineData("CN=WEB01", "WEB01")] + [InlineData("cn=web01.contoso.local", "web01.contoso.local")] + [InlineData("CN=WEB01,OU=IT,O=Contoso", "WEB01")] + [InlineData(" CN=WEB01 ", "WEB01")] + [InlineData("WEB01.contoso.local", "WEB01.contoso.local")] + [InlineData(null, null)] + public void MergeSubject_Normalizes_Rdn_Style_CommonName(string supplied, string expected) + { + Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true); + MethodInfo merge = helperType.GetMethod("MergeSubject", BindingFlags.Public | BindingFlags.Static); + + object result = merge.Invoke(null, new object[] { null, supplied, null, null, null, null, null, null }); + + Assert.Equal(expected, result.GetType().GetProperty("CommonName").GetValue(result)); + } + + [Fact] + public void MergeSubject_Normalizes_CommonName_Supplied_Through_Subject_Hashtable() + { + Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true); + MethodInfo merge = helperType.GetMethod("MergeSubject", BindingFlags.Public | BindingFlags.Static); + + Hashtable subject = new Hashtable { { "CN", "CN=WEB01" } }; + object result = merge.Invoke(null, new object[] { subject, null, null, null, null, null, null, null }); + + Assert.Equal("WEB01", result.GetType().GetProperty("CommonName").GetValue(result)); + } + + [Fact] + public void BuildDnsNames_Routes_Ip_Literals_From_DnsName_To_IpAddress_Sans() + { + // Get-InfisicalSANList emits host names and IP addresses in one list, and the documented usage + // splats that whole list into -DnsName. + PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet + { + DnsName = new[] { "WEB01", "172.16.32.24", "WEB01.contoso.local", "127.0.0.1", "::1" } + }; + + List ipAddresses = new List(); + List dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "WEB01" }, ipAddresses); + + Assert.Equal(new[] { "WEB01", "WEB01.contoso.local" }, dnsNames); + Assert.Equal(new[] { "172.16.32.24", "127.0.0.1", "::1" }, ipAddresses); + } + + [Fact] + public void BuildDnsNames_Merges_Explicit_IpAddress_Parameter_And_Deduplicates() + { + PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet + { + DnsName = new[] { "WEB01", "10.0.0.5" }, + IpAddress = new[] { "10.0.0.5", "10.0.0.6" } + }; + + List ipAddresses = new List(); + List dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "WEB01" }, ipAddresses); + + Assert.Equal(new[] { "WEB01" }, dnsNames); + Assert.Equal(new[] { "10.0.0.5", "10.0.0.6" }, ipAddresses); + } + + [Fact] + public void BuildDnsNames_Mirrors_Ip_CommonName_Into_IpAddress_Sans_Not_Dns() + { + PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet + { + DnsName = new[] { "WEB01.contoso.local" } + }; + + List ipAddresses = new List(); + List dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "10.0.0.5" }, ipAddresses); + + Assert.Equal(new[] { "WEB01.contoso.local" }, dnsNames); + Assert.Equal(new[] { "10.0.0.5" }, ipAddresses); + } + + [Fact] + public void BuildDnsNames_Ip_Only_Request_Does_Not_Pick_Up_Local_Fqdn() + { + PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet + { + IpAddress = new[] { "10.0.0.5" } + }; + + List ipAddresses = new List(); + List dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "10.0.0.5" }, ipAddresses); + + Assert.Empty(dnsNames); + Assert.Equal(new[] { "10.0.0.5" }, ipAddresses); + } + + private static List InvokeBuildDnsNames(PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet, InfisicalCsrSubject subject, List ipAddresses) + { + MethodInfo build = cmdlet.GetType().GetMethod("BuildDnsNames", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(build); + return (List)build.Invoke(cmdlet, new object[] { subject, ipAddresses }); + } + + [Fact] + public void DirectIssuance_Guidance_Names_The_Parameters_That_Resolve_It() + { + // Infisical exposes no certificate-template issuance route over REST, so the actionable alternatives + // are enabling direct issuance on the CA, a subscriber, or a profile. + PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet + { + CertificateAuthorityId = "ca-1234", + ProjectId = "proj-5678" + }; + + MethodInfo build = cmdlet.GetType().GetMethod("BuildDirectIssuanceGuidance", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(build); + + string guidance = (string)build.Invoke(cmdlet, new object[] { null }); + + Assert.Contains("ca-1234", guidance); + Assert.Contains("proj-5678", guidance); + Assert.Contains("-PkiSubscriberSlug", guidance); + Assert.Contains("-CertificateProfileId", guidance); + Assert.Contains("Get-InfisicalPkiSubscriber", guidance); + Assert.Contains("Direct Issuance", guidance); + } + + [Fact] + public void DirectIssuance_Guidance_Prefers_The_Ca_Name_When_Known() + { + PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet + { + CertificateAuthorityId = "ca-1234", + ProjectId = "proj-5678" + }; + + PSInfisicalAPI.Models.InfisicalCertificateAuthority ca = new PSInfisicalAPI.Models.InfisicalCertificateAuthority + { + Id = "ca-1234", + Name = "intermediate-ca", + EnableDirectIssuance = false + }; + + MethodInfo build = cmdlet.GetType().GetMethod("BuildDirectIssuanceGuidance", BindingFlags.NonPublic | BindingFlags.Instance); + string guidance = (string)build.Invoke(cmdlet, new object[] { ca }); + + Assert.Contains("intermediate-ca", guidance); + Assert.Contains("ca-1234", guidance); + } + + [Theory] + [InlineData("WriteErrorForException")] + [InlineData("ThrowTerminatingForException")] + public void Failure_Handlers_Rethrow_Pipeline_Stops_Untouched(string handlerName) + { + // Select-Object -First makes WriteObject throw a PipelineStoppedException-derived type. Reporting it + // as an error surfaces spurious "The pipeline has been stopped." warnings on normal early exits. + PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet(); + MethodInfo method = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).GetMethod(handlerName, BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(method); + + PipelineStoppedException stop = new PipelineStoppedException(); + TargetInvocationException wrapper = Assert.Throws( + () => method.Invoke(cmdlet, new object[] { "TestComponent", "TestOperation", stop })); + + Assert.Same(stop, wrapper.InnerException); + } + [Fact] public void SignCertificateBySubscriber_Uses_Pki_Subscribers_Template() { diff --git a/src/PSInfisicalAPI.Tests/LoggerStreamRoutingTests.cs b/src/PSInfisicalAPI.Tests/LoggerStreamRoutingTests.cs new file mode 100644 index 0000000..2c01d6f --- /dev/null +++ b/src/PSInfisicalAPI.Tests/LoggerStreamRoutingTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using PSInfisicalAPI.Logging; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + /// + /// The test project references PowerShellStandard.Library, which cannot host a runspace, so the logger's + /// stream choice is asserted structurally: which Cmdlet.Write* method each level compiles down to. + /// + public class LoggerStreamRoutingTests + { + [Theory] + [InlineData("Error", "WriteVerbose")] + [InlineData("Warning", "WriteWarning")] + [InlineData("Information", "WriteVerbose")] + [InlineData("Verbose", "WriteVerbose")] + [InlineData("Debug", "WriteDebug")] + public void PSCmdletLogger_Routes_Level_To_Expected_Stream(string levelMethod, string expectedWriteMethod) + { + MethodInfo method = typeof(PSCmdletLogger).GetMethod(levelMethod, BindingFlags.Public | BindingFlags.Instance); + Assert.NotNull(method); + + List called = GetCalledMethodNames(method); + Assert.Contains(expectedWriteMethod, called); + } + + [Fact] + public void PSCmdletLogger_Error_Does_Not_Write_To_Warning_Stream() + { + // Every Logger.Error call site in this module logs and then throws, so the failure already reaches the + // caller as an ErrorRecord. Duplicating it on the warning stream put failures under -WarningAction + // instead of -ErrorAction and buried the real error under eight lines of noise. + MethodInfo error = typeof(PSCmdletLogger).GetMethod("Error", BindingFlags.Public | BindingFlags.Instance); + List called = GetCalledMethodNames(error); + + Assert.DoesNotContain("WriteWarning", called); + Assert.DoesNotContain("WriteError", called); + } + + [Fact] + public void No_Cmdlet_Reports_Operation_Failures_As_Terminating_Errors() + { + // Operation failures go through WriteErrorForException so -ErrorAction decides the outcome. + // ThrowTerminatingForException remains available for aborts that ignore -ErrorAction, but no cmdlet + // should be using it for ordinary failures; this pins the convention against drift. + Assembly assembly = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).Assembly; + List offenders = new List(); + int inspected = 0; + + foreach (Type type in assembly.GetTypes()) + { + if (!typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).IsAssignableFrom(type)) { continue; } + if (type == typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase)) { continue; } + + inspected++; + foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + if (method.GetMethodBody() == null) { continue; } + if (GetCalledMethodNames(method).Contains("ThrowTerminatingForException")) + { + offenders.Add(string.Concat(type.Name, ".", method.Name)); + } + } + } + + Assert.True(inspected > 40, string.Concat("Expected to inspect the cmdlet set, saw ", inspected.ToString())); + Assert.Empty(offenders); + } + + [Fact] + public void Cmdlets_Route_Failures_Through_WriteErrorForException() + { + Assembly assembly = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).Assembly; + Type cmdletType = assembly.GetType("PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet", true); + MethodInfo processRecord = cmdletType.GetMethod("ProcessRecord", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + Assert.NotNull(processRecord); + + Assert.Contains("WriteErrorForException", GetCalledMethodNames(processRecord)); + } + + private static List GetCalledMethodNames(MethodInfo method) + { + List names = new List(); + MethodBody body = method.GetMethodBody(); + Assert.NotNull(body); + + byte[] il = body.GetILAsByteArray(); + Assert.NotNull(il); + + const byte Call = 0x28; + const byte CallVirt = 0x6F; + + for (int i = 0; i + 4 < il.Length; i++) + { + if (il[i] != Call && il[i] != CallVirt) { continue; } + + int token = BitConverter.ToInt32(il, i + 1); + try + { + MethodBase resolved = method.Module.ResolveMethod(token); + if (resolved != null) { names.Add(resolved.Name); } + } + catch (ArgumentException) + { + // Byte sequence was operand data rather than an opcode; ignore. + } + } + + return names; + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs index dd80ace..553e941 100644 --- a/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ConnectInfisicalCmdlet.cs @@ -205,7 +205,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException(Component, "Connect", exception); + WriteErrorForException(Component, "Connect", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalCertificateCmdlet.cs index a7ee2e8..059ea1b 100644 --- a/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalCertificateCmdlet.cs @@ -50,7 +50,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("ConvertToInfisicalCertificateCmdlet", "ConvertToCertificate", exception); + WriteErrorForException("ConvertToInfisicalCertificateCmdlet", "ConvertToCertificate", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs index fa96447..037a070 100644 --- a/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ConvertToInfisicalSecretDictionaryCmdlet.cs @@ -66,7 +66,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("ConvertToInfisicalSecretDictionaryCmdlet", "ConvertToDictionary", exception); + WriteErrorForException("ConvertToInfisicalSecretDictionaryCmdlet", "ConvertToDictionary", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/CopyInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/CopyInfisicalSecretCmdlet.cs index ea85a76..c8ba03a 100644 --- a/src/PSInfisicalAPI/Cmdlets/CopyInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/CopyInfisicalSecretCmdlet.cs @@ -65,7 +65,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("CopyInfisicalSecretCmdlet", "DuplicateSecrets", exception); + WriteErrorForException("CopyInfisicalSecretCmdlet", "DuplicateSecrets", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/DisconnectInfisicalCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/DisconnectInfisicalCmdlet.cs index 404f705..a57f23d 100644 --- a/src/PSInfisicalAPI/Cmdlets/DisconnectInfisicalCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/DisconnectInfisicalCmdlet.cs @@ -27,7 +27,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("DisconnectInfisicalCmdlet", "Disconnect", exception); + WriteErrorForException("DisconnectInfisicalCmdlet", "Disconnect", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalCertificateCmdlet.cs index 5e1d40c..c0dd97d 100644 --- a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalCertificateCmdlet.cs @@ -85,7 +85,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("ExportInfisicalCertificateCmdlet", "ExportCertificate", exception); + WriteErrorForException("ExportInfisicalCertificateCmdlet", "ExportCertificate", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalScepMdmProfileCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalScepMdmProfileCmdlet.cs index 3daef68..72150ad 100644 --- a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalScepMdmProfileCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalScepMdmProfileCmdlet.cs @@ -58,7 +58,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException(Component, "ExportScepMdmProfile", exception); + WriteErrorForException(Component, "ExportScepMdmProfile", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs index af61551..641026d 100644 --- a/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ExportInfisicalSecretsCmdlet.cs @@ -92,7 +92,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("ExportInfisicalSecretsCmdlet", string.Concat("Export-", Format.ToString()), exception); + WriteErrorForException("ExportInfisicalSecretsCmdlet", string.Concat("Export-", Format.ToString()), exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationCmdlet.cs index cfa4f95..db11a1b 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationCmdlet.cs @@ -54,7 +54,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalCertificateApplicationCmdlet", "GetCertificateApplication", exception); + WriteErrorForException("GetInfisicalCertificateApplicationCmdlet", "GetCertificateApplication", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationEnrollmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationEnrollmentCmdlet.cs index 6001970..c8d3db7 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationEnrollmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationEnrollmentCmdlet.cs @@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalCertificateApplicationEnrollmentCmdlet", "GetCertificateApplicationEnrollment", exception); + WriteErrorForException("GetInfisicalCertificateApplicationEnrollmentCmdlet", "GetCertificateApplicationEnrollment", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs index b5435e7..6b161c0 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs @@ -60,7 +60,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalCertificateAuthorityCmdlet", "GetCertificateAuthority", exception); + WriteErrorForException("GetInfisicalCertificateAuthorityCmdlet", "GetCertificateAuthority", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateCmdlet.cs index 7109748..4ef7e24 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateCmdlet.cs @@ -134,7 +134,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalCertificateCmdlet", "GetCertificate", exception); + WriteErrorForException("GetInfisicalCertificateCmdlet", "GetCertificate", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificatePolicyCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificatePolicyCmdlet.cs index 840b9c8..1046afe 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificatePolicyCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificatePolicyCmdlet.cs @@ -47,7 +47,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalCertificatePolicyCmdlet", "GetCertificatePolicy", exception); + WriteErrorForException("GetInfisicalCertificatePolicyCmdlet", "GetCertificatePolicy", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateProfileCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateProfileCmdlet.cs index 737d3ee..6e14bf7 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateProfileCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateProfileCmdlet.cs @@ -50,7 +50,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalCertificateProfileCmdlet", "GetCertificateProfile", exception); + WriteErrorForException("GetInfisicalCertificateProfileCmdlet", "GetCertificateProfile", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentCmdlet.cs index 01efdf6..8f1cd28 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalEnvironmentCmdlet.cs @@ -43,7 +43,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalEnvironmentCmdlet", "GetEnvironment", exception); + WriteErrorForException("GetInfisicalEnvironmentCmdlet", "GetEnvironment", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs index 2ae3ce8..8e4aa4f 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalFolderCmdlet.cs @@ -45,7 +45,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalFolderCmdlet", "GetFolder", exception); + WriteErrorForException("GetInfisicalFolderCmdlet", "GetFolder", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalOrganizationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalOrganizationCmdlet.cs index 5e891b6..cdfaabb 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalOrganizationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalOrganizationCmdlet.cs @@ -41,7 +41,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalOrganizationCmdlet", "GetOrganization", exception); + WriteErrorForException("GetInfisicalOrganizationCmdlet", "GetOrganization", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalPkiSubscriberCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalPkiSubscriberCmdlet.cs index 35b4c39..5a1a37a 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalPkiSubscriberCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalPkiSubscriberCmdlet.cs @@ -43,7 +43,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalPkiSubscriberCmdlet", "GetPkiSubscriber", exception); + WriteErrorForException("GetInfisicalPkiSubscriberCmdlet", "GetPkiSubscriber", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs index a239a79..05f5da4 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalProjectCmdlet.cs @@ -47,7 +47,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalProjectCmdlet", "GetProject", exception); + WriteErrorForException("GetInfisicalProjectCmdlet", "GetProject", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSANListCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSANListCmdlet.cs index 998fca8..460c368 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSANListCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSANListCmdlet.cs @@ -82,7 +82,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException(Component, "GetSANList", exception); + WriteErrorForException(Component, "GetSANList", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalScepMdmProfileCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalScepMdmProfileCmdlet.cs index 59336e0..19e08ad 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalScepMdmProfileCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalScepMdmProfileCmdlet.cs @@ -86,7 +86,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException(Component, "GetScepMdmProfile", exception); + WriteErrorForException(Component, "GetScepMdmProfile", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs index 972ded1..d7cc80d 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSecretCmdlet.cs @@ -93,7 +93,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalSecretCmdlet", "GetSecret", exception); + WriteErrorForException("GetInfisicalSecretCmdlet", "GetSecret", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSubOrganizationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSubOrganizationCmdlet.cs index ab8ff47..d23d8af 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalSubOrganizationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalSubOrganizationCmdlet.cs @@ -53,7 +53,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalSubOrganizationCmdlet", "GetSubOrganization", exception); + WriteErrorForException("GetInfisicalSubOrganizationCmdlet", "GetSubOrganization", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagCmdlet.cs index 06d9dce..3905af6 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalTagCmdlet.cs @@ -43,7 +43,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("GetInfisicalTagCmdlet", "GetTag", exception); + WriteErrorForException("GetInfisicalTagCmdlet", "GetTag", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/ImportInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/ImportInfisicalSecretCmdlet.cs index 4eaa520..c6852ba 100644 --- a/src/PSInfisicalAPI/Cmdlets/ImportInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/ImportInfisicalSecretCmdlet.cs @@ -66,7 +66,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("ImportInfisicalSecretCmdlet", "ImportSecret", exception); + WriteErrorForException("ImportInfisicalSecretCmdlet", "ImportSecret", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs index bdc69ce..bcb6735 100644 --- a/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs +++ b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs @@ -1,5 +1,6 @@ using System; using System.Management.Automation; +using System.Runtime.ExceptionServices; using PSInfisicalAPI.Connections; using PSInfisicalAPI.Errors; using PSInfisicalAPI.Http; @@ -44,12 +45,54 @@ namespace PSInfisicalAPI.Cmdlets return current != null && current.SkipCertificateCheck; } + /// + /// Reports an operation failure as a non-terminating error, which is what lets -ErrorAction decide the + /// outcome: Continue prints and carries on, SilentlyContinue and Ignore suppress, Inquire prompts, and + /// Stop is promoted by the engine into a terminating error that try/catch sees. Scripts that want to + /// catch these must ask for it with -ErrorAction Stop or $ErrorActionPreference = 'Stop'. + /// + protected void WriteErrorForException(string component, string operation, Exception exception) + { + ErrorRecord record = BuildFailureRecord(component, operation, exception); + WriteError(record); + } + + /// + /// Reports a failure the cmdlet cannot continue past regardless of -ErrorAction. Reserved for aborts that + /// are not per-item failures; ordinary operation failures belong on . + /// protected void ThrowTerminatingForException(string component, string operation, Exception exception) { + ErrorRecord record = BuildFailureRecord(component, operation, exception); + ThrowTerminatingError(record); + } + + private ErrorRecord BuildFailureRecord(string component, string operation, Exception exception) + { + if (IsPipelineControlException(exception)) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + InfisicalErrorDetails details = InfisicalErrorHandler.BuildDetails(component, operation, exception); InfisicalErrorHandler.LogFailure(Logger, details); - ErrorRecord record = InfisicalErrorHandler.ToErrorRecord(exception, details); - ThrowTerminatingError(record); + return InfisicalErrorHandler.ToErrorRecord(exception, details); + } + + /// + /// Identifies exceptions the PowerShell engine uses to unwind a pipeline rather than to report a fault. + /// Downstream cmdlets that stop early (Select-Object -First, Where-Object feeding such a + /// cmdlet, Ctrl+C) make throw one of + /// these. Reporting them as errors turns a normal early exit into spurious "The pipeline has been stopped." + /// output, so they must propagate untouched. + /// + protected static bool IsPipelineControlException(Exception exception) + { + // StopUpstreamCommandsException (internal, thrown by Select-Object -First) derives from + // PipelineStoppedException, so the base type covers it. + return exception is PipelineStoppedException + || exception is PipelineClosedException + || exception is HaltCommandException; } protected string ResolveApiVersion(InfisicalConnection connection, string explicitValue) diff --git a/src/PSInfisicalAPI/Cmdlets/InstallInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/InstallInfisicalCertificateCmdlet.cs index 03b635a..b9dffe0 100644 --- a/src/PSInfisicalAPI/Cmdlets/InstallInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/InstallInfisicalCertificateCmdlet.cs @@ -56,7 +56,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("InstallInfisicalCertificateCmdlet", "InstallCertificate", exception); + WriteErrorForException("InstallInfisicalCertificateCmdlet", "InstallCertificate", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalEnvironmentCmdlet.cs index ad11bef..b0b919b 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalEnvironmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalEnvironmentCmdlet.cs @@ -34,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("NewInfisicalEnvironmentCmdlet", "CreateEnvironment", exception); + WriteErrorForException("NewInfisicalEnvironmentCmdlet", "CreateEnvironment", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs index 46b6f97..67c2aeb 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalFolderCmdlet.cs @@ -34,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("NewInfisicalFolderCmdlet", "CreateFolder", exception); + WriteErrorForException("NewInfisicalFolderCmdlet", "CreateFolder", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalOrganizationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalOrganizationCmdlet.cs index 2d67d01..5e45df3 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalOrganizationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalOrganizationCmdlet.cs @@ -32,7 +32,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("NewInfisicalOrganizationCmdlet", "CreateOrganization", exception); + WriteErrorForException("NewInfisicalOrganizationCmdlet", "CreateOrganization", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalProjectCmdlet.cs index 73cd932..6a6f2b7 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalProjectCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalProjectCmdlet.cs @@ -40,7 +40,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("NewInfisicalProjectCmdlet", "CreateProject", exception); + WriteErrorForException("NewInfisicalProjectCmdlet", "CreateProject", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalScepDynamicChallengeCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalScepDynamicChallengeCmdlet.cs index abaee4e..6954948 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalScepDynamicChallengeCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalScepDynamicChallengeCmdlet.cs @@ -42,7 +42,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("NewInfisicalScepDynamicChallengeCmdlet", "GenerateScepDynamicChallenge", exception); + WriteErrorForException("NewInfisicalScepDynamicChallengeCmdlet", "GenerateScepDynamicChallenge", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs index 604de84..eccd520 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSecretCmdlet.cs @@ -99,7 +99,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("NewInfisicalSecretCmdlet", "CreateSecret", exception); + WriteErrorForException("NewInfisicalSecretCmdlet", "CreateSecret", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalSubOrganizationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSubOrganizationCmdlet.cs index f01c580..80fa582 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalSubOrganizationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalSubOrganizationCmdlet.cs @@ -32,7 +32,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("NewInfisicalSubOrganizationCmdlet", "CreateSubOrganization", exception); + WriteErrorForException("NewInfisicalSubOrganizationCmdlet", "CreateSubOrganization", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs index 3014fcb..c4b8746 100644 --- a/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/NewInfisicalTagCmdlet.cs @@ -34,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("NewInfisicalTagCmdlet", "CreateTag", exception); + WriteErrorForException("NewInfisicalTagCmdlet", "CreateTag", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs index 48b3c24..23e3231 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalEnvironmentCmdlet.cs @@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("RemoveInfisicalEnvironmentCmdlet", "DeleteEnvironment", exception); + WriteErrorForException("RemoveInfisicalEnvironmentCmdlet", "DeleteEnvironment", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs index 597defc..e189446 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalFolderCmdlet.cs @@ -37,7 +37,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("RemoveInfisicalFolderCmdlet", "DeleteFolder", exception); + WriteErrorForException("RemoveInfisicalFolderCmdlet", "DeleteFolder", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalOrganizationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalOrganizationCmdlet.cs index 5adbd6e..ab51732 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalOrganizationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalOrganizationCmdlet.cs @@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("RemoveInfisicalOrganizationCmdlet", "DeleteOrganization", exception); + WriteErrorForException("RemoveInfisicalOrganizationCmdlet", "DeleteOrganization", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs index 86bf47a..9341265 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalProjectCmdlet.cs @@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("RemoveInfisicalProjectCmdlet", "DeleteProject", exception); + WriteErrorForException("RemoveInfisicalProjectCmdlet", "DeleteProject", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs index bccec64..52892eb 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSecretCmdlet.cs @@ -79,7 +79,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("RemoveInfisicalSecretCmdlet", "DeleteSecret", exception); + WriteErrorForException("RemoveInfisicalSecretCmdlet", "DeleteSecret", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSubOrganizationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSubOrganizationCmdlet.cs index 28ae190..fa86350 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSubOrganizationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalSubOrganizationCmdlet.cs @@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("RemoveInfisicalSubOrganizationCmdlet", "DeleteSubOrganization", exception); + WriteErrorForException("RemoveInfisicalSubOrganizationCmdlet", "DeleteSubOrganization", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs index 9280a22..87729a4 100644 --- a/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RemoveInfisicalTagCmdlet.cs @@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("RemoveInfisicalTagCmdlet", "DeleteTag", exception); + WriteErrorForException("RemoveInfisicalTagCmdlet", "DeleteTag", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs index 2a761de..cde6140 100644 --- a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Management.Automation; using System.Security.Cryptography.X509Certificates; using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Errors; using PSInfisicalAPI.Models; using PSInfisicalAPI.Pki; @@ -79,7 +80,8 @@ namespace PSInfisicalAPI.Cmdlets InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); InfisicalCsrSubject csrSubject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress); - List dnsNames = BuildDnsNames(csrSubject); + List ipAddresses = new List(); + List dnsNames = BuildDnsNames(csrSubject, ipAddresses); if (string.IsNullOrEmpty(csrSubject.CommonName) && dnsNames.Count > 0) { csrSubject.CommonName = dnsNames[0]; } if (string.IsNullOrEmpty(csrSubject.CommonName)) { throw new InvalidOperationException("Subject CommonName could not be determined and no DnsName was provided."); } @@ -104,6 +106,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception bundleException) { + if (IsPipelineControlException(bundleException)) { throw; } Logger.Verbose(Component, string.Concat("Infisical bundle fetch for reuse path failed (continuing with local-only chain): ", bundleException.Message)); } } @@ -112,12 +115,14 @@ namespace PSInfisicalAPI.Cmdlets return; } - string target = string.Concat("PKI subscriber '", PkiSubscriberSlug ?? "(n/a)", "', CA '", CertificateAuthorityId ?? "(n/a)", "', or profile '", CertificateProfileId ?? "(n/a)", "' for CN=", csrSubject.CommonName); + string issuer = ResolveIssuancePath(client, connection); + + string target = string.Concat(issuer, " for CN=", csrSubject.CommonName); if (!ShouldProcess(target, "Request new certificate")) { return; } InfisicalCsrOptions csrOptions = new InfisicalCsrOptions { KeyAlgorithm = KeyAlgorithm, RsaKeySize = KeySize, EcCurve = Curve }; - InfisicalCsrResult csr = InfisicalCsrBuilder.Build(csrSubject, dnsNames, IpAddress, csrOptions); - InfisicalSignedCertificate signed = SignCertificate(client, connection, ProjectId, csr.CsrPem); + InfisicalCsrResult csr = InfisicalCsrBuilder.Build(csrSubject, dnsNames, ipAddresses, csrOptions); + InfisicalSignedCertificate signed = SignCertificate(client, connection, ProjectId, csr.CsrPem, csrSubject); signed.PrivateKeyPem = csr.PrivateKeyPem; if (string.IsNullOrEmpty(signed.CertificatePem)) @@ -160,24 +165,123 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException(Component, "RequestCertificate", exception); + WriteErrorForException(Component, "RequestCertificate", exception); } } - private List BuildDnsNames(InfisicalCsrSubject subject) + /// + /// States which issuer will sign this request, and rejects an unusable one before a keypair is generated. + /// Direct CA signing is only permitted when the CA has direct issuance enabled; without this check the + /// cmdlet builds a CSR and learns that from a 400 at the very end. + /// + private string ResolveIssuancePath(InfisicalPkiClient client, InfisicalConnection connection) + { + if (string.Equals(ParameterSetName, "BySubscriber", StringComparison.Ordinal)) + { + Logger.Information(Component, string.Concat("Issuing via PKI subscriber '", PkiSubscriberSlug, "' in project '", ProjectId, "'.")); + return string.Concat("PKI subscriber '", PkiSubscriberSlug, "'"); + } + + if (string.Equals(ParameterSetName, "ByProfile", StringComparison.Ordinal)) + { + Logger.Information(Component, string.Concat("Issuing via certificate profile '", CertificateProfileId, "' in project '", ProjectId, "'.")); + return string.Concat("certificate profile '", CertificateProfileId, "'"); + } + + InfisicalCertificateAuthority ca = null; + try + { + ca = client.GetInternalCertificateAuthority(connection, CertificateAuthorityId, ProjectId); + } + catch (Exception lookupException) + { + if (IsPipelineControlException(lookupException)) { throw; } + + // A caller may be able to sign without permission to read the CA record. Defer to the API. + Logger.Verbose(Component, string.Concat("Could not read certificate authority '", CertificateAuthorityId, "' for preflight (continuing): ", lookupException.Message)); + return string.Concat("certificate authority '", CertificateAuthorityId, "'"); + } + + if (ca != null && ca.EnableDirectIssuance.HasValue && !ca.EnableDirectIssuance.Value) + { + throw new InfisicalConfigurationException(BuildDirectIssuanceGuidance(ca)); + } + + string caLabel = ca != null ? (ca.Name ?? ca.FriendlyName ?? CertificateAuthorityId) : CertificateAuthorityId; + Logger.Information(Component, string.Concat("Issuing directly via certificate authority '", caLabel, "' (", CertificateAuthorityId, "); direct issuance is enabled.")); + return string.Concat("certificate authority '", caLabel, "'"); + } + + /// + /// Restates the direct-issuance restriction in terms of the parameters that resolve it. Infisical's REST + /// API exposes no certificate-template issuance route, so the alternatives are a subscriber or a profile. + /// + private string BuildDirectIssuanceGuidance(InfisicalCertificateAuthority ca) + { + string caLabel = ca != null ? (ca.Name ?? ca.FriendlyName ?? CertificateAuthorityId) : CertificateAuthorityId; + return string.Concat( + "Certificate authority '", caLabel, "' (", CertificateAuthorityId, ") has direct issuance disabled, so it cannot sign a CSR on its own. ", + "Either enable direct issuance on the CA in Infisical (Certificate Authorities > the CA > Enable Direct Issuance), ", + "or issue through a subscriber or profile instead: Request-InfisicalCertificate -PkiSubscriberSlug (see Get-InfisicalPkiSubscriber -ProjectId '", ProjectId ?? "", "') ", + "or -CertificateProfileId (see Get-InfisicalCertificateProfile)."); + } + + /// + /// Splits the requested SAN values into DNS names and IP addresses. Get-InfisicalSANList emits both kinds + /// in one list, so IP literals arriving through -DnsName are routed to the IP SAN bucket rather than + /// emitted as malformed dNSName entries. + /// + private List BuildDnsNames(InfisicalCsrSubject subject, List ipAddresses) { List result = new List(); - if (DnsName != null) { foreach (string dns in DnsName) { if (!string.IsNullOrEmpty(dns)) { result.Add(dns); } } } - if (result.Count == 0) + AddSanCandidates(DnsName, result, ipAddresses); + AddSanCandidates(IpAddress, null, ipAddresses); + + // Fall back to the local FQDN only when no SAN of either kind was requested; an explicit IP-only + // request must not silently pick up this machine's name. + if (result.Count == 0 && ipAddresses.Count == 0) { string fqdn = InfisicalCertificateRequestHelpers.ResolveLocalFqdn(); if (!string.IsNullOrEmpty(fqdn)) { result.Add(fqdn); } } - if (!string.IsNullOrEmpty(subject.CommonName) && !result.Contains(subject.CommonName)) { result.Insert(0, subject.CommonName); } + // The common name is mirrored into the SAN list because most validators ignore a CN that has no + // matching SAN entry. An IP common name belongs in the iPAddress bucket, not the dNSName one. + if (!string.IsNullOrEmpty(subject.CommonName)) + { + if (IsIpLiteral(subject.CommonName)) + { + if (!ipAddresses.Contains(subject.CommonName)) { ipAddresses.Insert(0, subject.CommonName); } + } + else if (!result.Contains(subject.CommonName)) + { + result.Insert(0, subject.CommonName); + } + } + return result; } + private static void AddSanCandidates(IEnumerable candidates, List dnsNames, List ipAddresses) + { + if (candidates == null) { return; } + foreach (string candidate in candidates) + { + if (string.IsNullOrEmpty(candidate)) { continue; } + string value = candidate.Trim(); + if (value.Length == 0) { continue; } + + List bucket = IsIpLiteral(value) ? ipAddresses : dnsNames; + if (bucket != null && !bucket.Contains(value)) { bucket.Add(value); } + } + } + + private static bool IsIpLiteral(string value) + { + System.Net.IPAddress parsed; + return System.Net.IPAddress.TryParse(value, out parsed); + } + private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName) { List candidateSerials = new List(); @@ -192,6 +296,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception searchException) { + if (IsPipelineControlException(searchException)) { throw; } Logger.Verbose(Component, string.Concat("Infisical search for idempotency check failed: ", searchException.Message)); } @@ -208,7 +313,7 @@ namespace PSInfisicalAPI.Cmdlets return InfisicalCertificateRequestHelpers.ResolveKeyStorageFlags(PrivateKeyProtection, PersistKey.IsPresent, MachineKey.IsPresent); } - private InfisicalSignedCertificate SignCertificate(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string csrPem) + private InfisicalSignedCertificate SignCertificate(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string csrPem, InfisicalCsrSubject subject) { if (string.Equals(ParameterSetName, "BySubscriber", StringComparison.Ordinal)) { @@ -217,11 +322,49 @@ namespace PSInfisicalAPI.Cmdlets if (string.Equals(ParameterSetName, "ByProfile", StringComparison.Ordinal)) { - InfisicalCsrSubject subject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress); return client.IssueCertificateByProfile(connection, CertificateProfileId, csrPem, subject.CommonName, subject.Organization, subject.OrganizationalUnit, subject.Country, subject.State, subject.Locality, Ttl, NotBefore, NotAfter, KeyUsage, ExtendedKeyUsage); } - return client.SignCertificateByCa(connection, CertificateAuthorityId, csrPem, CommonName, null, Ttl, NotBefore, NotAfter, FriendlyName, PkiCollectionId, KeyUsage, ExtendedKeyUsage); + try + { + return client.SignCertificateByCa(connection, CertificateAuthorityId, csrPem, subject.CommonName, null, Ttl, NotBefore, NotAfter, FriendlyName, PkiCollectionId, KeyUsage, ExtendedKeyUsage); + } + catch (InfisicalApiException apiException) + { + throw EnrichDirectIssuanceFailure(apiException); + } + } + + /// + /// Backstop for when the preflight in could not read the CA record and + /// the API rejects the signing request instead. The raw 400 does not say which cmdlet parameter to reach + /// for, so restate it in the module's own terms. + /// + private InfisicalApiException EnrichDirectIssuanceFailure(InfisicalApiException apiException) + { + if (apiException == null || apiException.StatusCode != 400) { return apiException; } + + string apiMessage = apiException.ApiErrorMessage ?? apiException.Message ?? string.Empty; + if (apiMessage.IndexOf("template or subscriber", StringComparison.OrdinalIgnoreCase) < 0) + { + return apiException; + } + + string guidance = string.Concat(BuildDirectIssuanceGuidance(null), " Original API error: ", apiMessage); + + return new InfisicalApiException(guidance, apiException) + { + StatusCode = apiException.StatusCode, + ReasonPhrase = apiException.ReasonPhrase, + ApiErrorCode = apiException.ApiErrorCode, + ApiErrorMessage = apiException.ApiErrorMessage, + ApiRequestId = apiException.ApiRequestId, + SanitizedBody = apiException.SanitizedBody, + EndpointName = apiException.EndpointName, + RequestMethod = apiException.RequestMethod, + Component = apiException.Component, + Operation = apiException.Operation + }; } } } diff --git a/src/PSInfisicalAPI/Cmdlets/StartInfisicalProcessCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/StartInfisicalProcessCmdlet.cs index 2165517..e3553c5 100644 --- a/src/PSInfisicalAPI/Cmdlets/StartInfisicalProcessCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/StartInfisicalProcessCmdlet.cs @@ -155,13 +155,13 @@ namespace PSInfisicalAPI.Cmdlets string message = string.Concat("Process '", FilePath, "' exited with code ", result.ExitCode.HasValue ? result.ExitCode.Value.ToString() : "", " which is not in the acceptable exit code list."); InvalidOperationException exception = new InvalidOperationException(message); ErrorRecord error = new ErrorRecord(exception, "StartInfisicalProcess.UnacceptableExitCode", ErrorCategory.InvalidResult, result); - ThrowTerminatingError(error); + WriteError(error); } } catch (PipelineStoppedException) { throw; } catch (Exception exception) { - ThrowTerminatingForException(Component, "StartProcess", exception); + WriteErrorForException(Component, "StartProcess", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/UninstallInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UninstallInfisicalCertificateCmdlet.cs index 9ea61cb..0089ee4 100644 --- a/src/PSInfisicalAPI/Cmdlets/UninstallInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UninstallInfisicalCertificateCmdlet.cs @@ -74,7 +74,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("UninstallInfisicalCertificateCmdlet", "UninstallCertificate", exception); + WriteErrorForException("UninstallInfisicalCertificateCmdlet", "UninstallCertificate", exception); } } diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs index d042e01..bed08cf 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalEnvironmentCmdlet.cs @@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("UpdateInfisicalEnvironmentCmdlet", "UpdateEnvironment", exception); + WriteErrorForException("UpdateInfisicalEnvironmentCmdlet", "UpdateEnvironment", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs index 9305ae2..61f62ff 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalFolderCmdlet.cs @@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("UpdateInfisicalFolderCmdlet", "UpdateFolder", exception); + WriteErrorForException("UpdateInfisicalFolderCmdlet", "UpdateFolder", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalOrganizationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalOrganizationCmdlet.cs index c8aeb60..cbc5559 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalOrganizationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalOrganizationCmdlet.cs @@ -37,7 +37,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("UpdateInfisicalOrganizationCmdlet", "UpdateOrganization", exception); + WriteErrorForException("UpdateInfisicalOrganizationCmdlet", "UpdateOrganization", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs index 3d929d0..5d53627 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalProjectCmdlet.cs @@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("UpdateInfisicalProjectCmdlet", "UpdateProject", exception); + WriteErrorForException("UpdateInfisicalProjectCmdlet", "UpdateProject", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs index 1586e10..1e35f32 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSecretCmdlet.cs @@ -98,7 +98,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("UpdateInfisicalSecretCmdlet", "UpdateSecret", exception); + WriteErrorForException("UpdateInfisicalSecretCmdlet", "UpdateSecret", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSubOrganizationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSubOrganizationCmdlet.cs index 3c463aa..32f33a2 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSubOrganizationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalSubOrganizationCmdlet.cs @@ -37,7 +37,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("UpdateInfisicalSubOrganizationCmdlet", "UpdateSubOrganization", exception); + WriteErrorForException("UpdateInfisicalSubOrganizationCmdlet", "UpdateSubOrganization", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs index 7b5f01c..462caf6 100644 --- a/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/UpdateInfisicalTagCmdlet.cs @@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException("UpdateInfisicalTagCmdlet", "UpdateTag", exception); + WriteErrorForException("UpdateInfisicalTagCmdlet", "UpdateTag", exception); } } } diff --git a/src/PSInfisicalAPI/Cmdlets/WriteInfisicalScepMdmProfileToWmiCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/WriteInfisicalScepMdmProfileToWmiCmdlet.cs index 87bbbdb..6b6e0aa 100644 --- a/src/PSInfisicalAPI/Cmdlets/WriteInfisicalScepMdmProfileToWmiCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/WriteInfisicalScepMdmProfileToWmiCmdlet.cs @@ -62,7 +62,7 @@ namespace PSInfisicalAPI.Cmdlets } catch (Exception exception) { - ThrowTerminatingForException(Component, "WriteScepMdmProfileToWmi", exception); + WriteErrorForException(Component, "WriteScepMdmProfileToWmi", exception); } } diff --git a/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs b/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs index 178d189..8f4410a 100644 --- a/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs +++ b/src/PSInfisicalAPI/Logging/PSCmdletLogger.cs @@ -36,10 +36,17 @@ namespace PSInfisicalAPI.Logging _cmdlet.WriteWarning(line); } + /// + /// Error-level lines are diagnostic breadcrumbs: every call site in this module logs one and then throws, + /// so the failure itself always reaches the caller as an ErrorRecord carrying the same detail. Emitting + /// them on the warning stream duplicated that failure eight lines deep and put it under -WarningAction + /// instead of -ErrorAction. They belong on the verbose stream, where -Verbose opts into the trail and the + /// ErrorRecord remains the single authority on what failed. + /// public void Error(string component, string message) { string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Error, component, message); - _cmdlet.WriteWarning(line); + _cmdlet.WriteVerbose(line); } } } diff --git a/src/PSInfisicalAPI/Pki/InfisicalCertificateRequestHelpers.cs b/src/PSInfisicalAPI/Pki/InfisicalCertificateRequestHelpers.cs index 4395c75..6fea182 100644 --- a/src/PSInfisicalAPI/Pki/InfisicalCertificateRequestHelpers.cs +++ b/src/PSInfisicalAPI/Pki/InfisicalCertificateRequestHelpers.cs @@ -32,9 +32,29 @@ namespace PSInfisicalAPI.Pki if (!string.IsNullOrEmpty(organizationalUnit)) { result.OrganizationalUnit = organizationalUnit; } if (!string.IsNullOrEmpty(emailAddress)) { result.EmailAddress = emailAddress; } + result.CommonName = NormalizeCommonName(result.CommonName); return result; } + /// + /// Reduces a caller-supplied common name to the bare CN value. Callers commonly pass the RDN form + /// ("CN=HOST") or a full DN ("CN=HOST,OU=IT"); using either verbatim produces a doubled "CN=CN=HOST" + /// subject and a bogus "CN=HOST" DNS SAN, since the CSR builder adds the CN= prefix itself. + /// + public static string NormalizeCommonName(string commonName) + { + if (string.IsNullOrEmpty(commonName)) { return commonName; } + + string value = commonName.Trim(); + if (!value.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) { return value; } + + value = value.Substring(3); + int separator = value.IndexOf(','); + if (separator >= 0) { value = value.Substring(0, separator); } + + return value.Trim(); + } + public static string ResolveLocalFqdn() { try