diff --git a/.gitea/workflows/publish-psgallery.yml b/.gitea/workflows/publish-psgallery.yml index 39447ec..ff816c5 100644 --- a/.gitea/workflows/publish-psgallery.yml +++ b/.gitea/workflows/publish-psgallery.yml @@ -129,6 +129,7 @@ jobs: COMMIT_SHA: ${{ github.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} SERVER_URL: ${{ github.server_url }} RUN_ID: ${{ github.run_id }} @@ -175,7 +176,17 @@ jobs: Write-Host " CHANGELOG section length: $($changelogSection.Length) chars" Write-Host "==> [4/8] Building release body" - $changelogText = if ($changelogSection) { $changelogSection } else { '_No CHANGELOG section found for this version._' } + + # The merged pull request description is the account written for humans, so it leads when present. + # Trailing co-author and generation trailers are dropped; they belong on the commit, not the release. + $prBody = '' + if (-not [string]::IsNullOrWhiteSpace($env:PR_BODY)) { + $prBody = ($env:PR_BODY -replace '(?m)^\s*(Co-Authored-By|Co-authored-by):.*$', '') + $prBody = ($prBody -replace '(?m)^\s*(Generated with|🤖 Generated with).*$', '') + $prBody = $prBody.Trim() + } + Write-Host " PR description length: $($prBody.Length) chars" + $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine("**PSInfisicalAPI $($env:VERSION)**") [void]$sb.AppendLine('') @@ -188,8 +199,33 @@ jobs: [void]$sb.AppendLine("| Merged PR | [#$($env:PR_NUMBER) $($env:PR_TITLE)]($prUrl) by @$($env:PR_AUTHOR) |") [void]$sb.AppendLine("| Workflow run | [$($env:RUN_ID)]($runUrl) |") [void]$sb.AppendLine('') - [void]$sb.AppendLine('## Changes') - [void]$sb.AppendLine($changelogText) + + if ($prBody) { + # The description carries its own headings, so it is emitted without a wrapper. + [void]$sb.AppendLine($prBody) + + # Folded so the release leads with the narrative but still records the changelog entry. + if ($changelogSection) { + [void]$sb.AppendLine('') + [void]$sb.AppendLine('
') + [void]$sb.AppendLine("CHANGELOG entry for $($env:VERSION)") + [void]$sb.AppendLine('') + [void]$sb.AppendLine($changelogSection) + [void]$sb.AppendLine('') + [void]$sb.AppendLine('
') + } + } + elseif ($changelogSection) { + [void]$sb.AppendLine('## Changes') + [void]$sb.AppendLine('') + [void]$sb.AppendLine($changelogSection) + } + else { + [void]$sb.AppendLine('## Changes') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('_No pull request description or CHANGELOG section found for this version._') + } + [void]$sb.AppendLine('') [void]$sb.AppendLine('## Install') [void]$sb.AppendLine('```powershell') diff --git a/.github/workflows/publish-psgallery.yml b/.github/workflows/publish-psgallery.yml index 4a211ed..2ec2a5b 100644 --- a/.github/workflows/publish-psgallery.yml +++ b/.github/workflows/publish-psgallery.yml @@ -131,6 +131,7 @@ jobs: COMMIT_SHA: ${{ github.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} SERVER_URL: ${{ github.server_url }} RUN_ID: ${{ github.run_id }} @@ -177,7 +178,17 @@ jobs: Write-Host " CHANGELOG section length: $($changelogSection.Length) chars" Write-Host "==> [4/8] Building release body" - $changelogText = if ($changelogSection) { $changelogSection } else { '_No CHANGELOG section found for this version._' } + + # The merged pull request description is the account written for humans, so it leads when present. + # Trailing co-author and generation trailers are dropped; they belong on the commit, not the release. + $prBody = '' + if (-not [string]::IsNullOrWhiteSpace($env:PR_BODY)) { + $prBody = ($env:PR_BODY -replace '(?m)^\s*(Co-Authored-By|Co-authored-by):.*$', '') + $prBody = ($prBody -replace '(?m)^\s*(Generated with|🤖 Generated with).*$', '') + $prBody = $prBody.Trim() + } + Write-Host " PR description length: $($prBody.Length) chars" + $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine("**PSInfisicalAPI $($env:VERSION)**") [void]$sb.AppendLine('') @@ -190,8 +201,33 @@ jobs: [void]$sb.AppendLine("| Merged PR | [#$($env:PR_NUMBER) $($env:PR_TITLE)]($prUrl) by @$($env:PR_AUTHOR) |") [void]$sb.AppendLine("| Workflow run | [$($env:RUN_ID)]($runUrl) |") [void]$sb.AppendLine('') - [void]$sb.AppendLine('## Changes') - [void]$sb.AppendLine($changelogText) + + if ($prBody) { + # The description carries its own headings, so it is emitted without a wrapper. + [void]$sb.AppendLine($prBody) + + # Folded so the release leads with the narrative but still records the changelog entry. + if ($changelogSection) { + [void]$sb.AppendLine('') + [void]$sb.AppendLine('
') + [void]$sb.AppendLine("CHANGELOG entry for $($env:VERSION)") + [void]$sb.AppendLine('') + [void]$sb.AppendLine($changelogSection) + [void]$sb.AppendLine('') + [void]$sb.AppendLine('
') + } + } + elseif ($changelogSection) { + [void]$sb.AppendLine('## Changes') + [void]$sb.AppendLine('') + [void]$sb.AppendLine($changelogSection) + } + else { + [void]$sb.AppendLine('## Changes') + [void]$sb.AppendLine('') + [void]$sb.AppendLine('_No pull request description or CHANGELOG section found for this version._') + } + [void]$sb.AppendLine('') [void]$sb.AppendLine('## Install') [void]$sb.AppendLine('```powershell') diff --git a/.gitignore b/.gitignore index 707e68d..dac35c8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,6 @@ TestResults/ *.trx *.coverage -## Local helper scripts (not part of the module) +## Local helper scripts (not part of the module). Case-insensitive on Windows, so this also matches +## Scripts/ - shipped tooling lives in Tools/ instead. scripts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 349385b..bbfeae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,35 +6,46 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos ## Unreleased -## 2026.07.31.0045 +## 2026.08.01.0245 -- Build produced from commit d47a5af6b3a6. +### Added (environment seeding) -## Unreleased (carried forward) +- The seeding script configures enrollment protocols, seeding API on every profile, SCEP with a dynamic challenge on the RSA profile, and ACME on the EC server/client profile. Enabling one is what makes Infisical mint its endpoint, so the run reads back and prints the SCEP, challenge, and ACME directory URLs, and returns them under `Enrollment`. +- Protocols are enabled on the **application-to-profile link**, not on the profile. A profile's own `enrollmentType` is a single value, but the link carries an independent configuration per protocol, which is what the console lists under an application — so one profile can answer several at once. The routes take `PUT`; a `POST` returns 404. +- SCEP defaults to a dynamic challenge, so there is no shared secret to distribute or rotate. `-ScepChallengePassword` supplies one for `ChallengeType = 'static'`, and when that is used without a value the run generates one and prints it once, because Infisical will not hand it back afterwards. +- Auto-renew is on by default for every API profile. It is an API-level setting; neither SCEP nor ACME has an equivalent, because both protocols have the client drive renewal itself. -## 2026.07.30.2350 +### Fixed (environment seeding) -- Build produced from commit 67cf0abac2dd. +- Seeded certificate policies now carry subject, SAN, and signature-algorithm constraints, and profiles carry defaults. Infisical refuses any attribute with no policy entry ("no subject policies defined"), refuses a policy defining no signature algorithm, and refuses a request missing a usage the policy marks required — `Request-InfisicalCertificate` sends no key usages of its own, so a profile default has to supply them. The previous configuration omitted all of this and could not issue. +- API responses are read through accessors that tolerate an absent property or a differently shaped payload. The cert-manager routes return bare payloads — a naked array for a list, the resource itself for a create — where the secrets and pki routes wrap theirs in a named property, and under `Set-StrictMode` each mismatch was a terminating error. The application-to-profile listing returns join rows keyed by `profileId` rather than profile objects with an `id`. +- The certificate-authority loop assigned a local `$configuration`, which shadowed the script-level `$Configuration` block because PowerShell variable names are case-insensitive; every authority after the first then failed to resolve its organization details. The `$profile` loop variables were renamed for the same reason — `$profile` is an automatic variable. +- The documented enumeration values dropped `domain_component`, `upn`, and `any_purpose`, none of which the API accepts. -## Unreleased (carried forward) +### Fixed (build) -## 2026.07.30.2344 +- `CHANGELOG.md` no longer accumulates duplicate `## Unreleased (carried forward)` headings. The pre-`62131e7` promotion step appended a suffix on every build instead of consuming the section, stranding release notes under 103 such headings — one had accumulated the suffix seven times — while the version sections the release workflow extracts held only their build line. Existing notes were consolidated and duplicate version sections collapsed, taking the file from 1441 lines to 570 with no content lost. -- Build produced from commit dadba2f4c890. +### Changed (project scoping, follow-up) -## Unreleased (carried forward) +- An organization with **no** Certificate Manager project is no longer an error. Resolution returns nothing and the PKI `Get-*` cmdlets emit no output, with `-Verbose` explaining that none was found and suggesting one be created. Nothing to list is an empty result, not a failure. +- Several Certificate Manager projects in one organization is **no longer an error**. Infisical designates one as the organization's active project and serves certificate applications only from it, so resolution now picks that one; when none is designated the first is used and the verbose line says so. +- `InfisicalOrganization.DefaultCertManagerProjectId` exposes the organization's active Certificate Manager project. +- `Get-InfisicalProject` now calls `/api/v1/projects`, keeping the previously used `/api/v1/workspace` as a fallback candidate — that route mounts Infisical's deprecated project router. -## 2026.07.30.2305 +### Changed (project scoping) -- Build produced from commit f65124fd9911. +- **`-ProjectId` is now optional on every PKI cmdlet** (`Get-InfisicalCertificateApplication`, `-ApplicationEnrollment`, `-Authority`, `-Certificate`, `-CertificatePolicy`, `-CertificateProfile`, `Get-InfisicalPkiSubscriber`, `Request-InfisicalCertificate`). The Infisical console never asks which Certificate Manager project to use because its resolver selects the single cert-manager project when an organization has exactly one; the module was stricter than the service it wraps. Omitting `-ProjectId` applies the same rule and reports the resolved project on the verbose stream, and an organization with several produces an error listing them. +- Resolution is client-side because several PKI endpoints carry the project in the URL path (`/api/v1/projects/{projectId}/pki-subscribers`, `/certificates/search`) and cannot defer to the server's resolver. +- `Get-InfisicalCertificate -SerialNumber` no longer resolves a project, since addressing a certificate by serial does not need one. -## Unreleased (carried forward) +### Added (application scoping) -## 2026.07.30.2259 +- `Get-InfisicalCertificateProfile -ApplicationId` and `-CaId` filter a listing the way the console groups profiles. `Get-InfisicalCertificate` already accepted `-ApplicationId`. -- Build produced from commit f56fd15b3864. +### Fixed (documentation) -## Unreleased (carried forward) +- The README Quick Start did not run: it passed `-ProjectId` and `-Environment` to `Connect-Infisical`, which has neither, failing with "A parameter cannot be found that matches parameter name 'ProjectId'". Project, environment, and secret path are per-call parameters. ### Added (certificate metadata) @@ -45,9 +56,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - `InfisicalCertificate.Metadata` and `InfisicalCertificateResult.Metadata` expose a certificate's metadata as a case-insensitive dictionary. - A metadata failure is reported as a warning rather than failing an issuance that otherwise succeeded, since by that point the certificate exists and may already be installed. +### Added (PKI configuration management) + +- Full create, update, and delete for the objects a Certificate Manager project is built from — 15 cmdlets across certificate authorities, policies, profiles, applications, and PKI subscribers. The module was previously read-only for PKI configuration, which is why environment setup meant raw REST. +- `New-InfisicalCertificateAuthority -Type Intermediate` returns an authority that can issue. Infisical creates a subordinate pending a certificate and exposes no call that completes it, so the cmdlet reads the CSR, signs it with `-ParentCaId`, and imports the signed certificate and chain. +- Constraint dictionaries accept `Allowed`/`Required`/`Denied` in any casing and emit the lower-case form the API requires; an empty collection is omitted rather than sent, since `"allowed": []` means "allow nothing" rather than "unconstrained". +- `-EnrollmentConfig` on the profile cmdlets routes to the block matching `-EnrollmentType`, so EST, ACME, and SCEP settings arrive through one parameter. +- `-ProjectId` is optional on all 15, resolving the same way the read cmdlets do. Every one supports `-WhatIf`, and `Remove-*` default to high confirm impact. + +### Fixed (build) + +- `Update-Changelog` inserted the version heading above the release notes, so the section the release workflow extracts contained only the build line while the notes stayed under "Unreleased" — every release published an empty changelog. Notes are now promoted into the version section, with a fresh empty "Unreleased" left above. + +### Changed (routing) + +- `SignCertificateByCa` prefers `/api/v1/cert-manager` over the older `/api/v1/pki` route. `RetrieveCertificate` and `GetCertificateBundle` deliberately keep the older route first: it resolves by serial number, which is what callers supply, whereas the cert-manager route takes a certificate id. Both remain registered so either identifier resolves. + ### Added (tooling) -- `Scripts/Initialize-InfisicalCertManagerEnvironment.ps1` seeds a Certificate Manager project, CA hierarchy, certificate policies, and API enrollment profiles from one declarative configuration block, taking only a base URI, client id, and client secret. Idempotent and `-WhatIf`-aware. Seeds an RSA hierarchy for SCCM/MECM server and client authentication, and an ECDSA P-384 hierarchy for server/client authentication and code signing. +- `Tools/Initialize-InfisicalCertManagerEnvironment.ps1` seeds a Certificate Manager project, CA hierarchy, certificate policies, API enrollment profiles, and a certificate application from one declarative configuration block, taking only a base URI, client id, and client secret. Idempotent and `-WhatIf`-aware. Seeds an RSA hierarchy for SCCM/MECM server and client authentication, and an ECDSA P-384 hierarchy for server/client authentication and code signing. +- The script **adopts an organization's existing Certificate Manager project** rather than creating one when the configured slug does not match. Creating a second would have succeeded and then been unable to hold applications, since they are served only from the organization's active project. +- It creates a certificate application and attaches the seeded profiles to it, so the result is navigable by `Get-InfisicalCertificateProfile -ApplicationId`. + +### Fixed (documentation) + +- The environment-variable discovery table listed `ProjectId`, `Environment`, and `SecretPath` as discoverable `Connect-Infisical` parameters. The resolver defines patterns for `BaseUri`, `OrganizationId`, `ClientId`, `ClientSecret`, `AccessToken`, and `ApiVersion` only; the other three are per-call parameters and were never resolved. ### Fixed (documentation) @@ -75,18 +108,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - Reuse detection searches the same store location the install will write to, instead of always searching `CurrentUser`. - Elevation detection moved to `InfisicalCmdletBase` and is shared with `Write-InfisicalScepMdmProfileToWmi`. -## 2026.07.30.2239 - -- Build produced from commit f62b3e90b1b1. - -## Unreleased (carried forward) - -## 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. @@ -114,76 +135,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - `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) - -## 2026.06.16.0215 - -- Build produced from commit 6318d06362ad. - -## Unreleased (carried forward) - -## 2026.06.16.0213 - -- Build produced from commit 6318d06362ad. - -## Unreleased (carried forward) - -## 2026.06.16.0207 - -- Build produced from commit 6318d06362ad. - -## Unreleased (carried forward) - -## 2026.06.16.0156 - -- Build produced from commit 6318d06362ad. - -## Unreleased (carried forward) - -## 2026.06.10.2018 - -- Build produced from commit daf1cdce6576. - -## 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. -## 2026.06.07.1435 - -- Build produced from commit 97193d46f2ff. - -## Unreleased (carried forward) - -## 2026.06.07.1426 - -- Build produced from commit b5575222eb36. - -## Unreleased (carried forward) - - Added `-ForcePrefix` switch to `ConvertTo-InfisicalSecretDictionary`, `Import-InfisicalSecret`, `Export-InfisicalSecrets`, and `Start-InfisicalProcess`. When `-Prefix` is supplied, names that already start with the prefix (case-insensitive) are now left as-is to prevent double-prefixing (e.g. `MYAPP_API_KEY` stays `MYAPP_API_KEY` when `-Prefix 'MYAPP_'` is supplied). Pass `-ForcePrefix` to restore unconditional prepending. Centralized in a new `PSInfisicalAPI.Common.InfisicalPrefix.Apply` helper. -## 2026.06.07.1421 - -- Build produced from commit b5575222eb36. - -## Unreleased (carried forward) - -## 2026.06.07.1350 - -- Build produced from commit 1aa51b8cbf9c. - -## Unreleased (carried forward) - -## 2026.06.07.0017 - -- Build produced from commit 77cb03ec9845. - -## Unreleased (carried forward) - - Added Organization CRUD cmdlets: `Get-InfisicalOrganization`, `New-InfisicalOrganization`, `Update-InfisicalOrganization`, `Remove-InfisicalOrganization`. `Get` lists every organization the active session can see (List parameter set, default) and returns a single record when `-OrganizationId` is supplied (Single parameter set). `New`/`Update`/`Remove` honor `-WhatIf`/`-Confirm`; `Remove` defaults to High `ConfirmImpact` and supports `-PassThru`. No project context required. Backed by new `InfisicalOrganization` model, DTO, mapper, and client wired into `InfisicalEndpointRegistry` (`ListOrganizations`, `RetrieveOrganization`, `CreateOrganization`, `UpdateOrganization`, `DeleteOrganization`). - Added Sub-Organization CRUD cmdlets: `Get-InfisicalSubOrganization`, `New-InfisicalSubOrganization`, `Update-InfisicalSubOrganization`, `Remove-InfisicalSubOrganization`, targeting the `/api/v1/sub-organizations` Beta endpoints. `Get` lists by default and accepts optional `-Limit`, `-Offset`, `-Search`, `-OrderBy`, `-OrderDirection`, and `-IsAccessible` query parameters; supplying `-SubOrganizationId` returns a single record. `New`/`Update`/`Remove` honor `-WhatIf`/`-Confirm`; `Remove` defaults to High `ConfirmImpact` and supports `-PassThru`. No project context required. Backed by new `InfisicalSubOrganization` model, DTO, mapper, and client wired into `InfisicalEndpointRegistry` (`ListSubOrganizations`, `RetrieveSubOrganization`, `CreateSubOrganization`, `UpdateSubOrganization`, `DeleteSubOrganization`). - Added `Get-InfisicalSANList` cmdlet: emits a deduplicated SAN candidate set containing the local device name, the device name suffixed with each non-empty DNS suffix found across operational adapters and the system primary domain, every IPv4 unicast address falling within RFC 1918 (10/8, 172.16/12, 192.168/16) or CGNAT (100.64/10), and the IPv4/IPv6 loopback addresses (127.0.0.1, ::1). Intended to feed `Request-InfisicalCertificate -DnsName` directly. @@ -191,239 +146,47 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - `Get-InfisicalSANList`: output is a single strongly-typed `System.String[]` array emitted non-enumerated (`OutputType(string[])`), so variable assignment yields `string[]` rather than `object[]`. This lets `[System.Collections.Generic.List[string]]::AddRange()` consume the result directly and lets the array bind straight to `string[]` parameters such as `Request-InfisicalCertificate -DnsName`. - `build.ps1` `CmdletsToExport` and `Test-ModuleImports` expected list now contain 51 cmdlets. `docs/DesignSpec.md` updated with `§16.7` (Organizations) and `§16.8` (Sub-Organizations); full MAML help added for all 9 new cmdlets in `Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml`. -## 2026.06.06.2229 - -- Build produced from commit 207e7429e448. - -## Unreleased (carried forward) - - `Start-InfisicalProcess`: switched stdout/stderr capture to event-based `OutputDataReceived`/`ErrorDataReceived` with `BeginOutputReadLine`/`BeginErrorReadLine` (removed `Task`/`ReadToEndAsync`/`GetAwaiter().GetResult()` to eliminate PowerShell `SynchronizationContext` deadlock risk). Restored the original `do { log; sleep } while (!HasExited)` polling pattern using `Thread.Sleep(pollInterval)` so verbose "has been running for X" / "Checking again in Y" messages fire at the configured cadence even when no `-ExecutionTimeout` is supplied. - `Start-InfisicalProcess`: TimeSpan values in verbose logs and on the result now use a friendly format ("`7 seconds, and 364 milliseconds`", "`1 minute, and 30 seconds`", "`N/A`" when zero) matching the legacy `Start-ProcessWithOutput` `GetTimeSpanMessage` scriptblock. Added `DurationFriendly` property to `InfisicalProcessResult` and a "The command execution took X" verbose line at completion. -## 2026.06.06.2227 - -- Build produced from commit d3c7b83da717. - -## Unreleased (carried forward) - -## 2026.06.06.2221 - -- Build produced from commit d3c7b83da717. - -## Unreleased (carried forward) - -## 2026.06.06.2207 - -- Build produced from commit d3c7b83da717. - -## Unreleased (carried forward) - -## 2026.06.06.2206 - -- Build produced from commit d3c7b83da717. - -## Unreleased (carried forward) - -## 2026.06.06.2155 - -- Build produced from commit d3c7b83da717. - -## Unreleased (carried forward) - - Added `Start-InfisicalProcess` cmdlet: launches a child process with `InfisicalSecret` objects (pipeline or `-Secret`) decrypted directly into `ProcessStartInfo.Environment`, with optional `-Prefix`, additional `-EnvironmentVariables`, stdout/stderr capture, `-AcceptableExitCodeList` validation, `-ParsingExpression` regex parsing, `-ExecutionTimeout`/`-ExecutionTimeoutInterval` polling, `-NoWait`, `-WindowStyle`/`-CreateNoWindow` parameter sets, `-Priority`, `-StandardInputObjectList`, `-SecureArgumentList`, `-LogOutput`, `-ContinueOnError`, and `ShouldProcess` support. Secret plaintext is never written to user or machine scope. `build.ps1` `CmdletsToExport` and `Test-ModuleImports` expected list now contain 42 cmdlets. -## 2026.06.06.2138 - -- Build produced from commit 318db7048017. - -## Unreleased (carried forward) - -## 2026.06.05.2040 - -- Build produced from commit 1270c9099cae. - -## Unreleased (carried forward) - -## 2026.06.05.0240 - -- Build produced from commit b438abf18f18. - -## Unreleased (carried forward) - -## 2026.06.05.0215 - -- Build produced from commit 82f99ea7d4a4. - -## Unreleased (carried forward) - -## 2026.06.05.0205 - -- Build produced from commit 86968c18cb15. - -## Unreleased (carried forward) - -## 2026.06.05.0117 - -- Build produced from commit cffda99591c9. - -## Unreleased (carried forward) - -## 2026.06.05.0015 - -- Build produced from commit fb27ab8a8503. - -## Unreleased (carried forward) - - Fixed `ParameterNameConflictsWithAlias` registration error on `Get-InfisicalCertificateApplication`, `Get-InfisicalCertificateApplicationEnrollment`, and `New-InfisicalScepDynamicChallenge`. The cmdlets each declared an `[Alias]` entry that matched the parameter's own name, which PowerShell rejects at bind time and made the cmdlets unusable. -## 2026.06.04.2335 - -- Build produced from commit 3c39a99b9a4c. - -## Unreleased (carried forward) - -## 2026.06.04.2305 - -- Build produced from commit 485ee8a7dd6a. - -## Unreleased (carried forward) - - `Get-InfisicalCertificateApplication` added with `List` (default), `ById`, and `ByName` parameter sets. Binds to `/api/v1/cert-manager/applications` (list) and `/api/v1/cert-manager/applications/{applicationId}` / `/by-name/{name}` for single retrieval. Requests carry the `x-infisical-project-id` header so the certificate-manager scope resolves correctly. New `InfisicalCertificateApplication` model surfaces id, project, name, description, and counts. - `Get-InfisicalCertificateApplicationEnrollment` added. Returns the API/EST/ACME/SCEP enrollment configuration for an application/profile pair (`GET /api/v1/cert-manager/applications/{applicationId}/profiles/{profileId}/enrollment`). The new `InfisicalCertificateApplicationEnrollment` model includes sub-blocks for each enrollment protocol; the SCEP block computes a SHA-1 `RaCertificateThumbprint` from the RA certificate PEM so it can be fed directly into MDM payloads. - `New-InfisicalScepDynamicChallenge` added. Wraps `POST /scep/applications/{applicationId}/profiles/{profileId}/challenge` and returns the minted challenge as a `SecureString` (default) or string (`-AsPlainText`). The endpoint is gated by the dynamic-challenge feature on the target Infisical instance and by the calling identity's permission on `certificate-application-enrollment`. - `Get-InfisicalScepMdmProfile` reworked into three parameter sets. `FromEnrollment` (new default) consumes an `InfisicalCertificateApplicationEnrollment` and auto-resolves `ServerUrl` from `scep.scepEndpointUrl`, `CAThumbprint` from the RA certificate, and the SCEP challenge (auto-minting when `challengeType=dynamic` and `-Challenge` is not supplied). `FromProfile` keeps the legacy projection from an `InfisicalCertificateProfile`, now requires `-ApplicationId`, and the default server URL is built against `/scep/applications/{appId}/profiles/{profileId}/pkiclient.exe`. `Manual` requires explicit `-ServerUrl`, `-Challenge`, and `-UniqueId`. - `InfisicalApiInvoker` accepts an optional `extraHeaders` argument so callers can attach the `x-infisical-project-id` header and override `Accept` for plain-text responses (used by the new SCEP challenge endpoint). -## 2026.06.04.2147 - -- Build produced from commit 183fb48c32ce. - -## Unreleased (carried forward) - - `Get-InfisicalScepMdmProfile` added. Projects an `InfisicalCertificateProfile` (pipeline-bound) into a new `InfisicalScepMdmProfile` model that mirrors the Windows `ClientCertificateInstall/SCEP` CSP node set. `-ServerUrl` defaults to `{baseUri}/scep/{profileId}/pkiclient.exe` derived from the active connection (the `pkiclient.exe` suffix is the RFC 8894 / Cisco SCEP client compatibility holdover, not a server-side executable). `-UniqueId` defaults to a sanitized slug. `-Challenge` is a `SecureString` decrypted only when materializing the model. `KeyAlgorithm` and `EkuMapping` are inherited from the source profile defaults unless overridden. - `Export-InfisicalScepMdmProfile` added. Serializes the model via `InfisicalScepMdmProfile.ToSyncMl()` (XDocument build, XmlWriter emit, XmlReader round-trip validation) and writes the result to `-Path` as UTF-8 without BOM. Auto-creates the target directory, honors `-WhatIf`/`-Confirm`, and follows the project rule for `-Force`: if the destination exists without `-Force`, the cmdlet logs a warning and returns instead of throwing. `-PassThru` emits the resulting `FileInfo`. - `Write-InfisicalScepMdmProfileToWmi` added. Submits the same model to the local MDM Bridge WMI provider by invoking `New-CimInstance -Namespace root/cimv2/mdm/dmmap -ClassName MDM_ClientCertificateInstall_SCEP02 -Property ` through the host runspace (no new package references). Guards: throws `PlatformNotSupportedException` off Windows; device-scope enrollment requires an elevated session unless `-SkipElevationCheck` is passed; supports `-WhatIf`/`-Confirm`; `-PassThru` emits the returned CIM instance. Override `-ClassName` when targeting a different SCEP CSP version on the host. -## 2026.06.04.2112 - -- Build produced from commit 3754de74f6c8. - -## Unreleased (carried forward) - - Infisical API error responses are now parsed to surface the server-side `message`, `error`, and `reqId` fields. The 4xx/5xx exception message includes the human-readable explanation (e.g. "The project is of type secret-manager") instead of an opaque `Infisical API returned 400 (Bad Request)`. The `InfisicalApiException` gains `ApiErrorMessage` and `ApiRequestId` properties; `InfisicalErrorDetails` carries the same fields so PowerShell error records and logger output expose them. - `Get-InfisicalCertificateProfile` added with `List` (default) and `ById` parameter sets. List binds to `GET /api/v1/cert-manager/certificate-profiles` (optional `-Limit`, `-Offset`, `-IncludeConfigs`); ById binds to `GET /api/v1/cert-manager/certificate-profiles/{certificateProfileId}`. New `InfisicalCertificateProfile` model surfaces ca/policy ids, slug, enrollment type, per-profile defaults (ttl, key/extended key usages), and the embedded CA/policy/apiConfig summaries. - `Get-InfisicalCertificatePolicy` added with `List` (default) and `ById` parameter sets. List binds to `GET /api/v1/cert-manager/certificate-policies` (optional `-Limit`, `-Offset`); ById binds to `GET /api/v1/cert-manager/certificate-policies/{certificatePolicyId}`. New `InfisicalCertificatePolicy` model surfaces subject, SANs, key usages, extended key usages, algorithms, and validity. Polymorphic string-or-array fields (`allowed`, `required`, `keyAlgorithm`) are normalized to arrays; `sans` is normalized whether the API returns an object or an array. - `Get-InfisicalCertificateAuthority` gains a `-Kind` parameter on the List parameter set with values `Internal` (default, preserves prior behavior against `/api/v1/cert-manager/ca/internal`), `Any` (binds to the generic `/api/v1/cert-manager/ca` endpoint which returns both internal and ACME CAs), and `Acme` (uses the generic endpoint and client-side filters to ACME issuers only). ById retrieval is unchanged and still resolves against the internal CA endpoint. - `Request-InfisicalCertificate` gains a `ByProfile` parameter set bound by the new `-CertificateProfileId` parameter (alias `ProfileId`). The cmdlet generates a local keypair and CSR as usual, then POSTs to `/api/v1/cert-manager/certificates` with the profile id, the CSR, and a subject/attribute envelope (commonName, organization, organizationalUnit, country, state, locality, ttl, notBefore, notAfter, keyUsages, extendedKeyUsages). The wrapped response (`{certificate:{certificate,certificateChain,issuingCaCertificate,serialNumber,certificateId,privateKey}, certificateRequestId, status, message}`) is unwrapped into the existing `InfisicalSignedCertificate` shape so the install / reuse / chain-completion paths continue to work unchanged. Issuance that returns without a certificate body (e.g. status `pending_approval` or `pending_validation`) is logged as a warning and the cmdlet emits a status-only `InfisicalCertificateResult` (new `Status`, `StatusMessage`, `CertificateRequestId` properties) instead of throwing; install / chain / private-key-write steps are skipped in that case. Whether issuance is immediate or pending is dictated by the certificate policy bound to the profile (auto-approve vs. manual review and any required validation). -## 2026.06.04.1920 - -- Build produced from commit 0f8f44afdb38. - -## Unreleased (carried forward) - - `build.ps1` gains a `-CommitArtifacts` switch that, after a successful build, stages and commits only the build outputs (`Module/PSInfisicalAPI/bin/**`, `Module/PSInfisicalAPI/PSInfisicalAPI.psd1`, and the auto-inserted `CHANGELOG.md` build stamp) with a message that references the source commit whose hash is now embedded in `BuildCommitHash`. The switch is mutually exclusive with the older broader `-CommitOnSuccess` (which still uses `git add -A`). README extended with a "Committing source and build artifacts in lockstep" section describing the recommended two-commit workflow. -## 2026.06.04.1917 - -- Build produced from commit a34db831d8bf. - -## Unreleased (carried forward) - -## 2026.06.04.1915 - -- Build produced from commit 2489b7adca98. - -## Unreleased (carried forward) - -## 2026.06.04.1911 - -- Build produced from commit 51bf819c37e5. - -## Unreleased (carried forward) - -## 2026.06.04.1906 - -- Build produced from commit 51bf819c37e5. - -## Unreleased (carried forward) - - **BREAKING**: Removed the plural-noun discovery cmdlets `Get-InfisicalProjects`, `Get-InfisicalEnvironments`, `Get-InfisicalFolders`, `Get-InfisicalTags`, `Get-InfisicalSecrets`, and `Get-InfisicalCertificates`. Their behavior is now folded into the corresponding singular cmdlets via a `List` (default) / single-record parameter set pair, matching the existing `Get-InfisicalCertificateAuthority` precedent. Callers should drop the trailing `s`; invocation without the identity parameter (`-ProjectId`, `-EnvironmentSlugOrId`, `-FolderNameOrId`, `-TagSlugOrId`, `-SecretName`, `-SerialNumber`) now returns the list, and supplying the identity parameter returns the single record. No back-compat aliases were added. - Added `Get-InfisicalPkiSubscriber` with `List` (default) and `ByName` parameter sets, backed by new `InfisicalPkiClient.ListPkiSubscribers` and `GetPkiSubscriber` methods, an `InfisicalPkiSubscriber` model, and corresponding DTOs/mapper. Use the emitted `Name` (slug) on `Request-InfisicalCertificate -PkiSubscriberSlug`. - **Bug fix**: `Request-InfisicalCertificate -PkiSubscriberSlug ...` was returning 404 because the registry's `SignCertificateBySubscriber` endpoint pointed at `/api/v1/pki/pki-subscribers/{subscriberName}/sign-certificate` and `/api/v1/cert-manager/pki-subscribers/...`. Per Infisical's `v1/index.ts`, the subscriber router is mounted at `/pki/subscribers`, so the single correct path is `/api/v1/pki/subscribers/{subscriberName}/sign-certificate`. The redundant `cert-manager` template was removed; the PKI endpoint registry tests were updated to match. - Updated MAML help in `Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml`: the six consolidated cmdlets and the new `Get-InfisicalPkiSubscriber` each ship three examples — two straight-line invocations (one per parameter set) plus one `OrderedDictionary` splat example. All in-text references to the removed plural cmdlets across other cmdlets' examples were updated to the singular form. - `build.ps1`: `CmdletsToExport` and the `Test-ModuleImports` expected cmdlet list were updated to drop the six plural cmdlets and add `Get-InfisicalPkiSubscriber` (total: 34 exported cmdlets). -## 2026.06.04.1825 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - -## 2026.06.04.1820 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - - `Install-InfisicalCertificate` now routes chain certificates by self-signed status instead of dumping every chain entry into the Intermediate Certification Authorities store. Self-signed roots are installed into `StoreName.Root` (Trusted Root Certification Authorities) and non-self-signed intermediates are installed into `StoreName.CertificateAuthority` (Intermediate Certification Authorities). The leaf continues to use the user-specified `-StoreName`/`-StoreLocation` (default `My`/`CurrentUser`). `Request-InfisicalCertificate` already routed chain certs correctly; the same routing helper is now shared by both cmdlets. - `InfisicalCertificateRequestHelpers` exposes a new public `GetChainCertificateTargetStore(X509Certificate2)` classifier and a new `InstallChain(IEnumerable, StoreLocation, bool, IInfisicalLogger, string)` overload. The existing `InstallChain(InfisicalSignedCertificate, ...)` overload now delegates to the new collection-based overload, so PKI chain-installation routing is centralized in one place. -## 2026.06.04.1810 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - - Authored MAML help (`Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml`) covering all 39 exported cmdlets. Every entry includes a synopsis, description, notes section, and two examples: a one-liner and an `OrderedDictionary` splat (with `OrdinalIgnoreCase`) that includes preceding `Get-` resolver commands wherever IDs or slugs are required. - `build.ps1` now stages the cmdlet help XML next to the deployed binary. After the publish step, every culture directory under `Module/PSInfisicalAPI/` (matching `xx` or `xx-XX`) that contains `PSInfisicalAPI.dll-Help.xml` is mirrored into `bin//`. The script hard-fails if `bin/en-US/PSInfisicalAPI.dll-Help.xml` is missing or contains zero `` entries. - `Test-ModuleImports` in `build.ps1` now dynamically enumerates exported cmdlets via `Get-Command -Module PSInfisicalAPI -CommandType Cmdlet`, cross-checks the result against an expected list of 39 cmdlet names (including the previously-missing `Copy-InfisicalSecret`), and for each cmdlet asserts that `Get-Help -Full` returns a non-empty synopsis (rejecting PowerShell's auto-generated cmdlet-name fallback), a non-empty description, and that `Get-Help -Examples` returns at least one example node whose `` block is non-empty. -## 2026.06.04.1808 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - -## 2026.06.04.1658 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - - `Request-InfisicalCertificate` reuse path now falls back to the Infisical certificate-bundle endpoint when the local trust stores do not contain the issuing intermediates or root. The cmdlet builds the local chain first; if the result has no intermediates and no root, it fetches `GetCertificateBundle(serialNumber)` and rebuilds the result with the bundle's chain PEM merged in. A new `-LocalChainOnly` switch opts out of the bundle fetch for strict offline behavior. Bundle-fetch failures are logged at verbose level and the cmdlet returns the local-only result. - `InfisicalCertificateRequestHelpers.BuildResultFromExistingLocal` adds a second overload that accepts an `InfisicalCertificateBundle`; when supplied, chain certs from the bundle are deduplicated by thumbprint and merged with the locally-resolved chain before classification. -## 2026.06.04.1652 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - -## 2026.06.04.1651 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - -## 2026.06.04.1634 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - -## 2026.06.04.1631 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - -## 2026.06.04.1622 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - - **PKI contract fixes and cmdlet expansion**: - `InfisicalPkiClient` no longer auto-injects `connection.ProjectId` into PKI CA list/retrieve calls; only the caller's explicit `-ProjectId` is forwarded so that cert-manager primary routes (which do not accept the query parameter) succeed. - List/single CA and single certificate response parsing now tolerate raw arrays, wrapper objects (`{certificate: {...}}`, `{certificates: [...]}`), and nested `configuration` blocks. `InfisicalCaMapper` reads CA detail fields from `configuration` first, falling back to top-level. @@ -435,43 +198,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - **Multi-algorithm CSR support** on `Request-InfisicalCertificate` via split parameters: `-KeyAlgorithm` (`Rsa`/`Ecdsa`/`Ed25519`, default `Rsa`), `-KeySize` (`2048`/`3072`/`4096`, default `2048`, applies to RSA only), `-Curve` (`P256`/`P384`, default `P256`, applies to ECDSA only). Signature algorithms are picked automatically: SHA256WITHRSA for RSA, SHA256WITHECDSA / SHA384WITHECDSA for ECDSA P-256/P-384, and Ed25519 (pure-EdDSA) for Ed25519. The underlying `InfisicalCsrBuilder.Build(subject, dns, ip, options)` API was updated to take an `InfisicalCsrOptions` object in place of the prior `keySize` int. - **Sign-certificate endpoint registrations**: `SignCertificateBySubscriber` and `SignCertificateByCa` registered with both `/api/v1/pki/...` and `/api/v1/cert-manager/...` candidate paths and marked `ContainsSecretMaterialInResponse = true`. -## 2026.06.04.1554 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - -## 2026.06.04.1512 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - -## 2026.06.04.1508 - -- Build produced from commit 19615363e356. - -## Unreleased (carried forward) - - **CI — Gitea artifact upload fix**: Replaced `actions/upload-artifact@v4` and `actions/download-artifact@v4` with the Gitea-compatible forks `christopherhx/gitea-upload-artifact@v4` and `christopherhx/gitea-download-artifact@v4` in `.gitea/workflows/publish-psgallery.yml`. The upstream v4 actions abort on Gitea because Gitea is detected as GHES, which the upstream v4 actions do not support (see [go-gitea/gitea#28853](https://github.com/go-gitea/gitea/issues/28853)). -## 2026.06.04.0123 - -- Build produced from commit 2cbd5c2008f5. - -## Unreleased (carried forward) - - **M10 polish — formatting, type metadata, and PKI route aliases**: - Added default table views and `DefaultDisplayPropertySet` entries for `InfisicalCertificateAuthority`, `InfisicalCertificate`, and `InfisicalCertificateBundle` in the module `Format.ps1xml` / `Types.ps1xml`. - Realigned PKI endpoint registry to current Infisical paths: `ListInternalCertificateAuthorities` and `RetrieveInternalCertificateAuthority` now use `/api/v1/cert-manager/ca/internal[/{caId}]` as primary, with legacy `/api/v1/pki/ca/internal[/{caId}]` retained as a fallback alias. `GetCertificateBundle` and `RetrieveCertificate` similarly carry `cert-manager` fallback aliases. - `InfisicalApiInvoker.InvokeWithCandidateFallback` walks the candidate list and falls back on `404`/`405`, used by `InfisicalPkiClient` so older self-hosted Infisical instances are tolerated transparently. -## 2026.06.04.0114 - -- Build produced from commit 2cbd5c2008f5. - -## Unreleased (carried forward) - - **M10 — PKI Internal CAs, Certificates & Windows Store integration**: - **`Get-InfisicalCertificateAuthority`** lists internal certificate authorities for the current project, or returns a single CA with `-CaId`. - **`Search-InfisicalCertificate`** wraps `POST /api/v1/projects/{projectId}/certificates/search` with rich filters (`-CommonName`, `-FriendlyName`, `-Search`, `-Status`, `-CaId`, `-ProfileId`, `-ApplicationId`, `-EnrollmentType`, `-KeyAlgorithm`, `-SignatureAlgorithm`, `-Source`, `-NotAfterFrom/To`, `-NotBeforeFrom/To`, `-SortBy/-SortOrder`, `-Limit/-Offset`). Auto-paginates unless `-NoAutoPage` is set. @@ -481,24 +214,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - **BouncyCastle dependency**: Added `BouncyCastle.Cryptography` to bridge PEM/PKCS#8 parsing on .NET Standard 2.0 / Windows PowerShell 5.1 (where `X509Certificate2.CreateFromPem` and `RSA.ImportFromPem` are unavailable). The shared `PemCertificateBuilder` assembles cert + chain + key into an in-memory PKCS#12 blob and imports it back into `X509Certificate2`. The DLL ships in the published module bin directory. - PKI endpoint registry entries for `ListInternalCertificateAuthorities` (`GET /api/v1/pki/ca/internal`), `RetrieveInternalCertificateAuthority` (`GET /api/v1/pki/ca/internal/{caId}`), `SearchCertificates` (`POST /api/v1/projects/{projectId}/certificates/search`), `RetrieveCertificate`, and `GetCertificateBundle` (`GET /api/v1/pki/certificates/{serialNumber}/bundle`). -## 2026.06.04.0020 - -- Build produced from commit 211fbcf34dbb. - -## Unreleased (carried forward) - -## 2026.06.04.0005 - -- Build produced from commit e0a6ef02df3e. - -## Unreleased (carried forward) - - **Bulk v4 batch routes**: Endpoint registry now registers `POST|PATCH|DELETE /api/v4/secrets/batch` as the preferred candidates for `BulkCreateSecret`/`BulkUpdateSecret`/`BulkDeleteSecret`; the existing v3 raw routes (`/api/v3/secrets/batch/raw`) remain as automatic fallback. Batch request DTOs serialize both `projectId` (required by v4) and `workspaceId` (accepted by v3) when populated. - **Strongly-typed bulk input**: `-Secrets` on `New-InfisicalSecret` and `Update-InfisicalSecret` is now `IDictionary[]` instead of `Hashtable[]`. `InfisicalBulkSecretConverter` accepts `IEnumerable>` and parses `TagIds` from a comma-separated string. Nested `Metadata`/`SecretMetadata` dictionaries are no longer accepted in the bulk hashtable surface (set `SecretMetadata` programmatically on `InfisicalBulkCreateSecretItem`/`InfisicalBulkUpdateSecretItem` if needed). -## 2026.06.03.2207 - -- Build produced from commit 09c3d5c68bbc. - **M9 — Bulk, Duplicate & Inheritance**: - **Bulk parameter sets** added to `New-InfisicalSecret`, `Update-InfisicalSecret`, and `Remove-InfisicalSecret` accepting `-Secrets Hashtable[]`; client methods `CreateBatch`/`UpdateBatch`/`DeleteBatch` wrap `POST|PATCH|DELETE /api/v3/secrets/batch/raw`. - **`Copy-InfisicalSecret`** cmdlet added, wrapping `POST /api/v4/secrets/duplicate` with source/destination environment + path parameters and per-attribute copy toggles. @@ -507,17 +225,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - `InfisicalBulkSecretConverter` accepts flexible key aliases (`SecretName`/`Name`/`Key`, `SecretValue`/`Value`, `SecretComment`/`Comment`, `Metadata`/`SecretMetadata`). - Test count: 161 (up from 139). Added coverage for bulk DTO shapes, the converter, the duplicate request DTO, registry entries for the four new endpoints, and the resolution helpers. -## Unreleased (carried forward) - -## 2026.06.03.2206 - -- Build produced from commit 09c3d5c68bbc. - -## Unreleased (carried forward) - -## 2026.06.03.2136 - -- Build produced from commit d9822aab7a4a. - **Resource CRUD expansion**: Added full Get/New/Update/Remove cmdlet families for Projects, Environments, Folders, and Tags (20 new cmdlets): - Projects: `Get-InfisicalProjects`, `Get-InfisicalProject`, `New-InfisicalProject`, `Update-InfisicalProject`, `Remove-InfisicalProject`. - Environments: `Get-InfisicalEnvironments`, `Get-InfisicalEnvironment`, `New-InfisicalEnvironment`, `Update-InfisicalEnvironment`, `Remove-InfisicalEnvironment`. @@ -528,820 +235,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - Endpoint registry expanded with login routes (`/api/v1/auth/{jwt|oidc|ldap|azure|gcp}-auth/login`) and CRUD routes for projects (v2), environments, folders, tags, and secret mutations. - Test suite expanded to 139 passing tests, including mapper round-trips for projects/environments/folders/tags, secret mutation DTO shapes, and request-body validation for each new auth provider. -## 2026.06.03.0131 - -- Build produced from commit 7be0b7b42008. - **Behavior change**: `Get-InfisicalSecrets` and `Get-InfisicalSecret` now default `-ViewSecretValue` to `$true`. Real secret values are returned by default. To request the redacted/hidden response, pass `-ViewSecretValue:$false`. - `InfisicalSecretMapper` now treats the server-side `` placeholder as a hidden marker rather than a value: when `secretValueHidden=true` (or the placeholder string is detected) `SecretValue` is set to `null` instead of stuffing the literal into a `SecureString`. This prevents downstream consumers (auth, exports, dictionary conversion) from silently using `` as if it were a real secret. -## Unreleased (carried forward) - -## 2026.06.03.0113 - -- Build produced from commit 09c577ebd0fd. - Added `InfisicalSecret.GetPlainTextValue()` for direct plain-text access to secret material from PowerShell without needing `Marshal.SecureStringToBSTR`. - Added `-AsPlainText` switch to `ConvertTo-InfisicalSecretDictionary`; when present the cmdlet emits `Dictionary` instead of the default `Dictionary`. -## Unreleased (carried forward) - -## 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. - -## Unreleased (carried forward) - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1724 - -- Build produced from commit 5801b4774af5. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1648 - -- Build produced from commit 430e3a00c921. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1724 - -- Build produced from commit 5801b4774af5. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1638 - -- Build produced from commit 3c47d6ff30ec. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1724 - -- Build produced from commit 5801b4774af5. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1648 - -- Build produced from commit 430e3a00c921. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1724 - -- Build produced from commit 5801b4774af5. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1611 - -- Build produced from commit 3c47d6ff30ec. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1724 - -- Build produced from commit 5801b4774af5. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1648 - -- Build produced from commit 430e3a00c921. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1724 - -- Build produced from commit 5801b4774af5. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1638 - -- Build produced from commit 3c47d6ff30ec. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1724 - -- Build produced from commit 5801b4774af5. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1648 - -- Build produced from commit 430e3a00c921. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) - -## 2026.06.02.1724 - -- Build produced from commit 5801b4774af5. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) - -## 2026.06.02.1737 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) - -## 2026.06.02.1902 - -- Build produced from commit fa65c18bc171. - -## Unreleased - -## 2026.06.02.1907 - -- Build produced from commit fa65c18bc171. - -## Unreleased (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) (carried forward) - ### Added - Initial repository skeleton, C# `netstandard2.0` project, and PowerShell module layout. @@ -1352,3 +251,342 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loos - Cmdlets: `Connect-Infisical`, `Disconnect-Infisical`, `Get-InfisicalSecrets`, `Get-InfisicalSecret`, `ConvertTo-InfisicalSecretDictionary`, `Export-InfisicalSecrets`. - Build script (`build.ps1`) generating manifest, copying binaries, creating release folders, and supporting unit/integration tests. - xUnit test project with unit tests and opt-in integration tests. + +_Build produced from commit b27fe6f002f4._ + +## 2026.08.01.0242 + +_Build produced from commit b27fe6f002f4._ + +## 2026.08.01.0140 + +_Build produced from commit 74d22a941c33._ + +## 2026.08.01.0123 + +_Build produced from commit f2a1492b66de._ + +## 2026.07.31.2140 + +_Build produced from commit 62131e750109._ + +## 2026.07.31.2136 + +_Build produced from commit 62131e750109._ + +## 2026.07.31.2019 + +_Build produced from commit e7674af1617c._ + +## 2026.07.31.2006 + +_Build produced from commit 276958e3a83a._ + +## 2026.07.31.1958 + +_Build produced from commit 633f40c1fa54._ + +## 2026.07.31.1924 + +_Build produced from commit 93b0cc1924ec._ + +## 2026.07.31.0045 + +_Build produced from commit d47a5af6b3a6._ + +## 2026.07.30.2350 + +_Build produced from commit 67cf0abac2dd._ + +## 2026.07.30.2344 + +_Build produced from commit dadba2f4c890._ + +## 2026.07.30.2305 + +_Build produced from commit f65124fd9911._ + +## 2026.07.30.2259 + +_Build produced from commit f56fd15b3864._ + +## 2026.07.30.2239 + +_Build produced from commit f62b3e90b1b1._ + +## 2026.07.30.2151 + +_Build produced from commit 14c8c4f3845b._ + +## 2026.06.16.0217 + +_Build produced from commit 6318d06362ad._ + +## 2026.06.16.0215 + +_Build produced from commit 6318d06362ad._ + +## 2026.06.16.0213 + +_Build produced from commit 6318d06362ad._ + +## 2026.06.16.0207 + +_Build produced from commit 6318d06362ad._ + +## 2026.06.16.0156 + +_Build produced from commit 6318d06362ad._ + +## 2026.06.10.2018 + +_Build produced from commit daf1cdce6576._ + +## 2026.06.07.1435 + +_Build produced from commit 97193d46f2ff._ + +## 2026.06.07.1426 + +_Build produced from commit b5575222eb36._ + +## 2026.06.07.1421 + +_Build produced from commit b5575222eb36._ + +## 2026.06.07.1350 + +_Build produced from commit 1aa51b8cbf9c._ + +## 2026.06.07.0017 + +_Build produced from commit 77cb03ec9845._ + +## 2026.06.06.2229 + +_Build produced from commit 207e7429e448._ + +## 2026.06.06.2227 + +_Build produced from commit d3c7b83da717._ + +## 2026.06.06.2221 + +_Build produced from commit d3c7b83da717._ + +## 2026.06.06.2207 + +_Build produced from commit d3c7b83da717._ + +## 2026.06.06.2206 + +_Build produced from commit d3c7b83da717._ + +## 2026.06.06.2155 + +_Build produced from commit d3c7b83da717._ + +## 2026.06.06.2138 + +_Build produced from commit 318db7048017._ + +## 2026.06.05.2040 + +_Build produced from commit 1270c9099cae._ + +## 2026.06.05.0240 + +_Build produced from commit b438abf18f18._ + +## 2026.06.05.0215 + +_Build produced from commit 82f99ea7d4a4._ + +## 2026.06.05.0205 + +_Build produced from commit 86968c18cb15._ + +## 2026.06.05.0117 + +_Build produced from commit cffda99591c9._ + +## 2026.06.05.0015 + +_Build produced from commit fb27ab8a8503._ + +## 2026.06.04.2335 + +_Build produced from commit 3c39a99b9a4c._ + +## 2026.06.04.2305 + +_Build produced from commit 485ee8a7dd6a._ + +## 2026.06.04.2147 + +_Build produced from commit 183fb48c32ce._ + +## 2026.06.04.2112 + +_Build produced from commit 3754de74f6c8._ + +## 2026.06.04.1920 + +_Build produced from commit 0f8f44afdb38._ + +## 2026.06.04.1917 + +_Build produced from commit a34db831d8bf._ + +## 2026.06.04.1915 + +_Build produced from commit 2489b7adca98._ + +## 2026.06.04.1911 + +_Build produced from commit 51bf819c37e5._ + +## 2026.06.04.1906 + +_Build produced from commit 51bf819c37e5._ + +## 2026.06.04.1825 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1820 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1810 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1808 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1658 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1652 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1651 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1634 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1631 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1622 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1554 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1512 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.1508 + +_Build produced from commit 19615363e356._ + +## 2026.06.04.0123 + +_Build produced from commit 2cbd5c2008f5._ + +## 2026.06.04.0114 + +_Build produced from commit 2cbd5c2008f5._ + +## 2026.06.04.0020 + +_Build produced from commit 211fbcf34dbb._ + +## 2026.06.04.0005 + +_Build produced from commit e0a6ef02df3e._ + +## 2026.06.03.2207 + +_Build produced from commit 09c3d5c68bbc._ + +## 2026.06.03.2206 + +_Build produced from commit 09c3d5c68bbc._ + +## 2026.06.03.2136 + +_Build produced from commit d9822aab7a4a._ + +## 2026.06.03.0131 + +_Build produced from commit 7be0b7b42008._ + +## 2026.06.03.0113 + +_Build produced from commit 09c577ebd0fd._ + +## 2026.06.03.0057 + +_Build produced from commit 7e5209190ac2._ + +## 2026.06.03.0056 + +_Build produced from commit 7e5209190ac2._ + +## 2026.06.03.0055 + +_Build produced from commit 7e5209190ac2._ + +## 2026.06.03.0047 + +_Build produced from commit 7e5209190ac2._ + +## 2026.06.03.0046 + +_Build produced from commit 7e5209190ac2._ + +## 2026.06.03.0032 + +_Build produced from commit c86676010532._ + +## 2026.06.02.1907 + +_Build produced from commit fa65c18bc171._ + +## 2026.06.02.1902 + +_Build produced from commit fa65c18bc171._ + +## 2026.06.02.1737 + +_Build produced from commit fa65c18bc171._ + +## 2026.06.02.1724 + +_Build produced from commit 5801b4774af5._ + +## 2026.06.02.1648 + +_Build produced from commit 430e3a00c921._ + +## 2026.06.02.1638 + +_Build produced from commit 3c47d6ff30ec._ + +## 2026.06.02.1611 + +_Build produced from commit 3c47d6ff30ec._ + diff --git a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 index 7cddb2a..755c053 100644 --- a/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 +++ b/Module/PSInfisicalAPI/PSInfisicalAPI.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'PSInfisicalAPI.psm1' - ModuleVersion = '2026.07.31.0045' + ModuleVersion = '2026.08.01.0245' GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51' Author = 'Grace Solutions' CompanyName = 'Grace Solutions' @@ -62,7 +62,22 @@ 'Write-InfisicalScepMdmProfileToWmi', 'Start-InfisicalProcess', 'Get-InfisicalEnvironmentVariable', - 'Get-InfisicalSANList' + 'Get-InfisicalSANList', + 'New-InfisicalCertificateAuthority', + 'Set-InfisicalCertificateAuthority', + 'Remove-InfisicalCertificateAuthority', + 'New-InfisicalCertificatePolicy', + 'Set-InfisicalCertificatePolicy', + 'Remove-InfisicalCertificatePolicy', + 'New-InfisicalCertificateProfile', + 'Set-InfisicalCertificateProfile', + 'Remove-InfisicalCertificateProfile', + 'New-InfisicalCertificateApplication', + 'Set-InfisicalCertificateApplication', + 'Remove-InfisicalCertificateApplication', + 'New-InfisicalPkiSubscriber', + 'Set-InfisicalPkiSubscriber', + 'Remove-InfisicalPkiSubscriber' ) AliasesToExport = @() VariablesToExport = @() @@ -74,7 +89,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 = 'd47a5af6b3a6' + CommitHash = 'b27fe6f002f4' } } } \ No newline at end of file diff --git a/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll b/Module/PSInfisicalAPI/bin/PSInfisicalAPI.dll index 5664731..0dc79cb 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 16d82b8..6a2776c 100644 --- a/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml +++ b/Module/PSInfisicalAPI/bin/en-US/PSInfisicalAPI.dll-Help.xml @@ -1066,6 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters Notes + -ProjectId is optional. The Infisical console never asks which Certificate Manager project to use, because its resolver selects the single cert-manager project when an organization has exactly one; omitting -ProjectId applies the same rule and reports the resolved project on the verbose stream. Pass it explicitly when an organization has more than one, in which case the error lists the candidates. Note that a project contains applications: Get-InfisicalProject -Type cert-manager returns the project, while Get-InfisicalCertificateApplication returns the applications inside it. 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 must issue through Request-InfisicalCertificate -CertificateProfileId, which bypasses that check. EnableDirectIssuance is fixed at CA creation and appears in no Infisical update schema, so it cannot be toggled afterwards; a CA migrated from the older requireTemplateForIssuance column reads False permanently. @@ -2103,4 +2104,439 @@ $Sans = Get-InfisicalSANList @GetInfisicalSANListParameters + + + New-InfisicalCertificateAuthority + Creates an internal Infisical certificate authority, signing a subordinate with its parent. + New + InfisicalCertificateAuthority + + + Creates a root or intermediate internal certificate authority in a Certificate Manager project. A root is self-signed on creation. Infisical creates an intermediate pending a certificate and exposes no single call that completes it, so this cmdlet performs the remaining sequence itself: it reads the certificate signing request, signs it with the authority named by -ParentCaId, and imports the signed certificate and chain back, returning an authority that is ready to issue. -NotAfter defaults to ten years for a root and five for an intermediate; -MaxPathLength defaults to 1 for a root and 0 otherwise. -ProjectId is optional and resolves to the organization's Certificate Manager project. + + + Notes + + Certificate authorities created through the API always have direct issuance disabled, because Infisical's creation service sets it explicitly and exposes no way to change it afterwards. Issue through a certificate profile, which does not consult that flag. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + $Root = New-InfisicalCertificateAuthority -Name 'root-ca' -Type Root -CommonName 'Contoso Root Certificate Authority' -Organization 'Contoso' -Country 'US' + Creates a self-signed root valid for ten years. + + + EXAMPLE 2 + New-InfisicalCertificateAuthority -Name 'issuing-ca' -Type Intermediate -ParentCaId $Root.Id -CommonName 'Contoso Issuing Certificate Authority' -KeyAlgorithm 'EC_secp384r1' + Creates a subordinate, signs it with the root, and imports the signed certificate so it can issue immediately. + + + + + + Set-InfisicalCertificateAuthority + Renames an internal Infisical certificate authority or changes its status. + Set + InfisicalCertificateAuthority + + + Updates the name or status of an internal certificate authority. Infisical's update schema accepts only these two fields; subject, key algorithm, and validity are fixed when the authority is created. Supply -PassThru to emit the updated record. + + + Notes + + Disabling an authority stops it issuing without deleting it or the certificates it has already signed. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalCertificateAuthority -CaId $Ca.Id -Status disabled + Stops the authority issuing new certificates. + + + EXAMPLE 2 + Set-InfisicalCertificateAuthority -CaId $Ca.Id -Name 'retired-issuing-ca' -PassThru + Renames the authority and emits the updated record. + + + + + + Remove-InfisicalCertificateAuthority + Deletes an internal Infisical certificate authority. + Remove + InfisicalCertificateAuthority + + + Deletes an internal certificate authority from a Certificate Manager project. -PassThru emits the removed identifier for logging. + + + Notes + + Destructive. Certificates already issued by the authority stop chaining to a known issuer once it is gone, and any subordinate beneath it is orphaned. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalCertificateAuthority -CaId $Ca.Id -Confirm:$False + Deletes the authority without prompting. + + + EXAMPLE 2 + Get-InfisicalCertificateAuthority -Kind Internal | Where-Object {($_.Status -eq 'disabled')} | Remove-InfisicalCertificateAuthority + Removes every disabled authority, prompting for each. + + + + + + New-InfisicalCertificatePolicy + Creates an Infisical certificate policy that constrains what a profile may issue. + New + InfisicalCertificatePolicy + + + Creates a certificate policy: the constraints a certificate profile issues within. Subject attributes, subject alternative names, key usages, and extended key usages are each expressed as allowed, required, and denied sets, supplied as dictionaries so the nested shape stays readable. -MaxValidity caps certificate lifetime, -KeyAlgorithm and -SignatureAlgorithm restrict the cryptography. A constraint that is not supplied leaves that dimension unconstrained, which is what fleet enrollment needs so each machine can present its own name. + + + Notes + + Constraint values use Infisical's snake_case names: digital_signature, key_encipherment, server_auth, client_auth, code_signing, common_name, dns_name, ip_address. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + New-InfisicalCertificatePolicy -Name 'server-auth' -MaxValidity '90d' -KeyAlgorithm 'RSA_2048','EC_secp384r1' -KeyUsage @{ Required = @('digital_signature','key_encipherment') } -ExtendedKeyUsage @{ Required = @('server_auth','client_auth') } + Creates a policy for server and client authentication, leaving subject and SANs unconstrained. + + + EXAMPLE 2 + New-InfisicalCertificatePolicy -Name 'code-signing' -MaxValidity '365d' -ExtendedKeyUsage @{ Required = @('code_signing'); Denied = @('server_auth','client_auth') } -SubjectAlternativeName @(@{ Type = 'dns_name'; Allowed = @('*.contoso.com') }) + Creates a code signing policy that forbids TLS usage and restricts DNS names to one suffix. + + + + + + Set-InfisicalCertificatePolicy + Updates an Infisical certificate policy. + Set + InfisicalCertificatePolicy + + + Updates a certificate policy. Only the constraints supplied on the command line are sent; anything omitted keeps its stored value. Supply -PassThru to emit the updated policy. + + + Notes + + Changing a policy affects every profile bound to it, and therefore every future certificate those profiles issue. Certificates already issued are unaffected. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalCertificatePolicy -PolicyId $Policy.Id -MaxValidity '30d' + Shortens the maximum lifetime, leaving every other constraint as it was. + + + EXAMPLE 2 + Set-InfisicalCertificatePolicy -PolicyId $Policy.Id -ExtendedKeyUsage @{ Required = @('server_auth') } -PassThru + Narrows the extended key usage and emits the updated policy. + + + + + + Remove-InfisicalCertificatePolicy + Deletes an Infisical certificate policy. + Remove + InfisicalCertificatePolicy + + + Deletes a certificate policy from a Certificate Manager project. -PassThru emits the removed identifier for logging. + + + Notes + + Destructive. A profile bound to the policy cannot issue once it is gone, so remove or repoint dependent profiles first. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalCertificatePolicy -PolicyId $Policy.Id -Confirm:$False + Deletes the policy without prompting. + + + EXAMPLE 2 + Get-InfisicalCertificatePolicy | Where-Object {($_.Name -like 'test-*')} | Remove-InfisicalCertificatePolicy + Removes every policy whose name begins with test-, prompting for each. + + + + + + New-InfisicalCertificateProfile + Creates an Infisical certificate profile that binds an issuing authority to a policy. + New + InfisicalCertificateProfile + + + Creates a certificate profile: the object Request-InfisicalCertificate -CertificateProfileId issues against. A profile binds an issuing certificate authority to a certificate policy and exposes it for one enrollment type. -Slug accepts lowercase letters, numbers, and hyphens. -EnrollmentConfig carries the settings for the chosen -EnrollmentType, so EST, ACME, and SCEP settings all arrive through one parameter; for the default api type, -AutoRenew and -RenewBeforeDays are folded into it. + + + Notes + + Profile issuance is the only path that does not consult the issuing authority's direct-issuance flag, so a profile issues successfully against an authority whose EnableDirectIssuance is False. Unlike a PKI subscriber, a profile accepts a per-request common name, which is what makes it suitable for fleet enrollment. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + New-InfisicalCertificateProfile -Slug 'server-auth' -CertificatePolicyId $Policy.Id -CaId $Ca.Id + Creates an API enrollment profile bound to a policy and issuing authority. + + + EXAMPLE 2 + New-InfisicalCertificateProfile -Slug 'workload' -CertificatePolicyId $Policy.Id -CaId $Ca.Id -AutoRenew -RenewBeforeDays 14 -Defaults @{ ttlDays = 90 } + Creates a profile that renews issued certificates fourteen days before expiry and defaults to a ninety day lifetime. + + + + + + Set-InfisicalCertificateProfile + Updates an Infisical certificate profile. + Set + InfisicalCertificateProfile + + + Updates a certificate profile. Only the values supplied on the command line are sent; anything omitted keeps its stored value, including the enrollment type unless -EnrollmentType is passed explicitly. Supply -PassThru to emit the updated profile. + + + Notes + + Repointing a profile at a different policy or issuing authority changes what future requests produce. Because certificate reuse is scoped by profile, Request-InfisicalCertificate keeps reusing certificates the profile issued previously until they fall inside their renewal window. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalCertificateProfile -ProfileId $Profile.Id -CertificatePolicyId $NewPolicy.Id + Repoints the profile at a different policy. + + + EXAMPLE 2 + Set-InfisicalCertificateProfile -ProfileId $Profile.Id -AutoRenew -RenewBeforeDays 7 -PassThru + Enables automatic renewal seven days before expiry and emits the updated profile. + + + + + + Remove-InfisicalCertificateProfile + Deletes an Infisical certificate profile. + Remove + InfisicalCertificateProfile + + + Deletes a certificate profile from a Certificate Manager project. -PassThru emits the removed identifier for logging. + + + Notes + + Destructive. Any script requesting certificates through the profile fails once it is gone, and the profile is detached from every application that referenced it. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalCertificateProfile -ProfileId $Profile.Id -Confirm:$False + Deletes the profile without prompting. + + + EXAMPLE 2 + Get-InfisicalCertificateProfile -ApplicationId $Application.Id | Remove-InfisicalCertificateProfile + Removes every profile attached to an application, prompting for each. + + + + + + New-InfisicalCertificateApplication + Creates an Infisical certificate application to group profiles and certificates. + New + InfisicalCertificateApplication + + + Creates a certificate application: the grouping the Infisical console presents profiles, members, and certificates under, and the scope Get-InfisicalCertificateProfile -ApplicationId and Get-InfisicalCertificate -ApplicationId filter by. Certificate profiles can be attached at creation with -ProfileId. + + + Notes + + Applications are served only from the organization's active Certificate Manager project. Creating one in any other cert-manager project fails, which is why -ProjectId resolves to the active project when it is not supplied. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + New-InfisicalCertificateApplication -Name 'platform' -Description 'Endpoint and workload certificates' + Creates an empty application. + + + EXAMPLE 2 + New-InfisicalCertificateApplication -Name 'platform' -ProfileId $ServerProfile.Id, $CodeSigningProfile.Id + Creates an application with two profiles already attached. + + + + + + Set-InfisicalCertificateApplication + Renames an Infisical certificate application or changes which profiles it holds. + Set + InfisicalCertificateApplication + + + Updates a certificate application. -Name and -Description change the record; -AddProfileId and -RemoveProfileId change which certificate profiles the application groups. The record and its profile attachments are separate endpoints, so supplying only profile parameters skips the record update entirely. Supply -PassThru to emit the updated application. + + + Notes + + Detaching a profile does not delete it; the profile continues to exist and issue, it is simply no longer grouped under the application. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalCertificateApplication -ApplicationId $Application.Id -AddProfileId $Profile.Id + Attaches a profile to the application. + + + EXAMPLE 2 + Set-InfisicalCertificateApplication -ApplicationId $Application.Id -Name 'endpoint-management' -RemoveProfileId $Old.Id -PassThru + Renames the application, detaches a profile, and emits the updated record. + + + + + + Remove-InfisicalCertificateApplication + Deletes an Infisical certificate application. + Remove + InfisicalCertificateApplication + + + Deletes a certificate application from a Certificate Manager project. -PassThru emits the removed identifier for logging. + + + Notes + + Destructive. The profiles the application grouped are not deleted, but scripts that locate a profile by application can no longer find it. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalCertificateApplication -ApplicationId $Application.Id -Confirm:$False + Deletes the application without prompting. + + + EXAMPLE 2 + Get-InfisicalCertificateApplication | Where-Object {($_.CertificateCount -eq 0)} | Remove-InfisicalCertificateApplication + Removes every application holding no certificates, prompting for each. + + + + + + New-InfisicalPkiSubscriber + Creates an Infisical PKI subscriber, a named enrollment identity with a fixed common name. + New + InfisicalPkiSubscriber + + + Creates a PKI subscriber: a named enrollment identity that pins one common name, an allowlist of subject alternative names, a lifetime, and the permitted key usages, so a request carries only a certificate signing request. -CommonName is the identity the subscriber issues for, and -SubjectAlternativeName is an allowlist rather than a default. + + + Notes + + A subscriber is a single identity, not a template. Infisical rejects any request whose certificate signing request names a different common name, and rejects any subject alternative name outside the allowlist, so enrolling many machines through subscribers means one subscriber per machine. Use a certificate profile for fleet enrollment. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + New-InfisicalPkiSubscriber -Name 'web01' -CommonName 'WEB01.contoso.com' -CaId $Ca.Id -Ttl '90d' + Creates a subscriber for one host. + + + EXAMPLE 2 + New-InfisicalPkiSubscriber -Name 'web01' -CommonName 'WEB01.contoso.com' -CaId $Ca.Id -Ttl '90d' -SubjectAlternativeName 'WEB01','WEB01.contoso.com' -ExtendedKeyUsage 'serverAuth','clientAuth' + Creates a subscriber that also permits two subject alternative names and restricts extended key usage. + + + + + + Set-InfisicalPkiSubscriber + Updates an Infisical PKI subscriber. + Set + InfisicalPkiSubscriber + + + Updates a PKI subscriber, addressed by its current -Name. Only the values supplied on the command line are sent; anything omitted keeps its stored value. -NewName renames the subscriber. Supply -PassThru to emit the updated record. + + + Notes + + Changing -CommonName changes the identity the subscriber issues for, so any script signing against it must present a matching certificate signing request afterwards. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalPkiSubscriber -Name 'web01' -Ttl '30d' + Shortens the lifetime of certificates issued for the subscriber. + + + EXAMPLE 2 + Set-InfisicalPkiSubscriber -Name 'web01' -SubjectAlternativeName 'WEB01','WEB01.contoso.com','www.contoso.com' -PassThru + Extends the permitted subject alternative names and emits the updated subscriber. + + + + + + Remove-InfisicalPkiSubscriber + Deletes an Infisical PKI subscriber. + Remove + InfisicalPkiSubscriber + + + Deletes a PKI subscriber from a Certificate Manager project, addressed by name. -PassThru emits the removed name for logging. + + + Notes + + Destructive. Any script signing through the subscriber fails once it is gone. Certificates already issued are unaffected. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalPkiSubscriber -Name 'web01' -Confirm:$False + Deletes the subscriber without prompting. + + + EXAMPLE 2 + Get-InfisicalPkiSubscriber | Where-Object {($_.Status -ne 'active')} | Remove-InfisicalPkiSubscriber + Removes every inactive subscriber, prompting for each. + + + diff --git a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml index 16d82b8..6a2776c 100644 --- a/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml +++ b/Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml @@ -1066,6 +1066,7 @@ $RemoveInfisicalTagResult = Remove-InfisicalTag @RemoveInfisicalTagParameters Notes + -ProjectId is optional. The Infisical console never asks which Certificate Manager project to use, because its resolver selects the single cert-manager project when an organization has exactly one; omitting -ProjectId applies the same rule and reports the resolved project on the verbose stream. Pass it explicitly when an organization has more than one, in which case the error lists the candidates. Note that a project contains applications: Get-InfisicalProject -Type cert-manager returns the project, while Get-InfisicalCertificateApplication returns the applications inside it. 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 must issue through Request-InfisicalCertificate -CertificateProfileId, which bypasses that check. EnableDirectIssuance is fixed at CA creation and appears in no Infisical update schema, so it cannot be toggled afterwards; a CA migrated from the older requireTemplateForIssuance column reads False permanently. @@ -2103,4 +2104,439 @@ $Sans = Get-InfisicalSANList @GetInfisicalSANListParameters + + + New-InfisicalCertificateAuthority + Creates an internal Infisical certificate authority, signing a subordinate with its parent. + New + InfisicalCertificateAuthority + + + Creates a root or intermediate internal certificate authority in a Certificate Manager project. A root is self-signed on creation. Infisical creates an intermediate pending a certificate and exposes no single call that completes it, so this cmdlet performs the remaining sequence itself: it reads the certificate signing request, signs it with the authority named by -ParentCaId, and imports the signed certificate and chain back, returning an authority that is ready to issue. -NotAfter defaults to ten years for a root and five for an intermediate; -MaxPathLength defaults to 1 for a root and 0 otherwise. -ProjectId is optional and resolves to the organization's Certificate Manager project. + + + Notes + + Certificate authorities created through the API always have direct issuance disabled, because Infisical's creation service sets it explicitly and exposes no way to change it afterwards. Issue through a certificate profile, which does not consult that flag. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + $Root = New-InfisicalCertificateAuthority -Name 'root-ca' -Type Root -CommonName 'Contoso Root Certificate Authority' -Organization 'Contoso' -Country 'US' + Creates a self-signed root valid for ten years. + + + EXAMPLE 2 + New-InfisicalCertificateAuthority -Name 'issuing-ca' -Type Intermediate -ParentCaId $Root.Id -CommonName 'Contoso Issuing Certificate Authority' -KeyAlgorithm 'EC_secp384r1' + Creates a subordinate, signs it with the root, and imports the signed certificate so it can issue immediately. + + + + + + Set-InfisicalCertificateAuthority + Renames an internal Infisical certificate authority or changes its status. + Set + InfisicalCertificateAuthority + + + Updates the name or status of an internal certificate authority. Infisical's update schema accepts only these two fields; subject, key algorithm, and validity are fixed when the authority is created. Supply -PassThru to emit the updated record. + + + Notes + + Disabling an authority stops it issuing without deleting it or the certificates it has already signed. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalCertificateAuthority -CaId $Ca.Id -Status disabled + Stops the authority issuing new certificates. + + + EXAMPLE 2 + Set-InfisicalCertificateAuthority -CaId $Ca.Id -Name 'retired-issuing-ca' -PassThru + Renames the authority and emits the updated record. + + + + + + Remove-InfisicalCertificateAuthority + Deletes an internal Infisical certificate authority. + Remove + InfisicalCertificateAuthority + + + Deletes an internal certificate authority from a Certificate Manager project. -PassThru emits the removed identifier for logging. + + + Notes + + Destructive. Certificates already issued by the authority stop chaining to a known issuer once it is gone, and any subordinate beneath it is orphaned. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalCertificateAuthority -CaId $Ca.Id -Confirm:$False + Deletes the authority without prompting. + + + EXAMPLE 2 + Get-InfisicalCertificateAuthority -Kind Internal | Where-Object {($_.Status -eq 'disabled')} | Remove-InfisicalCertificateAuthority + Removes every disabled authority, prompting for each. + + + + + + New-InfisicalCertificatePolicy + Creates an Infisical certificate policy that constrains what a profile may issue. + New + InfisicalCertificatePolicy + + + Creates a certificate policy: the constraints a certificate profile issues within. Subject attributes, subject alternative names, key usages, and extended key usages are each expressed as allowed, required, and denied sets, supplied as dictionaries so the nested shape stays readable. -MaxValidity caps certificate lifetime, -KeyAlgorithm and -SignatureAlgorithm restrict the cryptography. A constraint that is not supplied leaves that dimension unconstrained, which is what fleet enrollment needs so each machine can present its own name. + + + Notes + + Constraint values use Infisical's snake_case names: digital_signature, key_encipherment, server_auth, client_auth, code_signing, common_name, dns_name, ip_address. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + New-InfisicalCertificatePolicy -Name 'server-auth' -MaxValidity '90d' -KeyAlgorithm 'RSA_2048','EC_secp384r1' -KeyUsage @{ Required = @('digital_signature','key_encipherment') } -ExtendedKeyUsage @{ Required = @('server_auth','client_auth') } + Creates a policy for server and client authentication, leaving subject and SANs unconstrained. + + + EXAMPLE 2 + New-InfisicalCertificatePolicy -Name 'code-signing' -MaxValidity '365d' -ExtendedKeyUsage @{ Required = @('code_signing'); Denied = @('server_auth','client_auth') } -SubjectAlternativeName @(@{ Type = 'dns_name'; Allowed = @('*.contoso.com') }) + Creates a code signing policy that forbids TLS usage and restricts DNS names to one suffix. + + + + + + Set-InfisicalCertificatePolicy + Updates an Infisical certificate policy. + Set + InfisicalCertificatePolicy + + + Updates a certificate policy. Only the constraints supplied on the command line are sent; anything omitted keeps its stored value. Supply -PassThru to emit the updated policy. + + + Notes + + Changing a policy affects every profile bound to it, and therefore every future certificate those profiles issue. Certificates already issued are unaffected. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalCertificatePolicy -PolicyId $Policy.Id -MaxValidity '30d' + Shortens the maximum lifetime, leaving every other constraint as it was. + + + EXAMPLE 2 + Set-InfisicalCertificatePolicy -PolicyId $Policy.Id -ExtendedKeyUsage @{ Required = @('server_auth') } -PassThru + Narrows the extended key usage and emits the updated policy. + + + + + + Remove-InfisicalCertificatePolicy + Deletes an Infisical certificate policy. + Remove + InfisicalCertificatePolicy + + + Deletes a certificate policy from a Certificate Manager project. -PassThru emits the removed identifier for logging. + + + Notes + + Destructive. A profile bound to the policy cannot issue once it is gone, so remove or repoint dependent profiles first. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalCertificatePolicy -PolicyId $Policy.Id -Confirm:$False + Deletes the policy without prompting. + + + EXAMPLE 2 + Get-InfisicalCertificatePolicy | Where-Object {($_.Name -like 'test-*')} | Remove-InfisicalCertificatePolicy + Removes every policy whose name begins with test-, prompting for each. + + + + + + New-InfisicalCertificateProfile + Creates an Infisical certificate profile that binds an issuing authority to a policy. + New + InfisicalCertificateProfile + + + Creates a certificate profile: the object Request-InfisicalCertificate -CertificateProfileId issues against. A profile binds an issuing certificate authority to a certificate policy and exposes it for one enrollment type. -Slug accepts lowercase letters, numbers, and hyphens. -EnrollmentConfig carries the settings for the chosen -EnrollmentType, so EST, ACME, and SCEP settings all arrive through one parameter; for the default api type, -AutoRenew and -RenewBeforeDays are folded into it. + + + Notes + + Profile issuance is the only path that does not consult the issuing authority's direct-issuance flag, so a profile issues successfully against an authority whose EnableDirectIssuance is False. Unlike a PKI subscriber, a profile accepts a per-request common name, which is what makes it suitable for fleet enrollment. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + New-InfisicalCertificateProfile -Slug 'server-auth' -CertificatePolicyId $Policy.Id -CaId $Ca.Id + Creates an API enrollment profile bound to a policy and issuing authority. + + + EXAMPLE 2 + New-InfisicalCertificateProfile -Slug 'workload' -CertificatePolicyId $Policy.Id -CaId $Ca.Id -AutoRenew -RenewBeforeDays 14 -Defaults @{ ttlDays = 90 } + Creates a profile that renews issued certificates fourteen days before expiry and defaults to a ninety day lifetime. + + + + + + Set-InfisicalCertificateProfile + Updates an Infisical certificate profile. + Set + InfisicalCertificateProfile + + + Updates a certificate profile. Only the values supplied on the command line are sent; anything omitted keeps its stored value, including the enrollment type unless -EnrollmentType is passed explicitly. Supply -PassThru to emit the updated profile. + + + Notes + + Repointing a profile at a different policy or issuing authority changes what future requests produce. Because certificate reuse is scoped by profile, Request-InfisicalCertificate keeps reusing certificates the profile issued previously until they fall inside their renewal window. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalCertificateProfile -ProfileId $Profile.Id -CertificatePolicyId $NewPolicy.Id + Repoints the profile at a different policy. + + + EXAMPLE 2 + Set-InfisicalCertificateProfile -ProfileId $Profile.Id -AutoRenew -RenewBeforeDays 7 -PassThru + Enables automatic renewal seven days before expiry and emits the updated profile. + + + + + + Remove-InfisicalCertificateProfile + Deletes an Infisical certificate profile. + Remove + InfisicalCertificateProfile + + + Deletes a certificate profile from a Certificate Manager project. -PassThru emits the removed identifier for logging. + + + Notes + + Destructive. Any script requesting certificates through the profile fails once it is gone, and the profile is detached from every application that referenced it. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalCertificateProfile -ProfileId $Profile.Id -Confirm:$False + Deletes the profile without prompting. + + + EXAMPLE 2 + Get-InfisicalCertificateProfile -ApplicationId $Application.Id | Remove-InfisicalCertificateProfile + Removes every profile attached to an application, prompting for each. + + + + + + New-InfisicalCertificateApplication + Creates an Infisical certificate application to group profiles and certificates. + New + InfisicalCertificateApplication + + + Creates a certificate application: the grouping the Infisical console presents profiles, members, and certificates under, and the scope Get-InfisicalCertificateProfile -ApplicationId and Get-InfisicalCertificate -ApplicationId filter by. Certificate profiles can be attached at creation with -ProfileId. + + + Notes + + Applications are served only from the organization's active Certificate Manager project. Creating one in any other cert-manager project fails, which is why -ProjectId resolves to the active project when it is not supplied. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + New-InfisicalCertificateApplication -Name 'platform' -Description 'Endpoint and workload certificates' + Creates an empty application. + + + EXAMPLE 2 + New-InfisicalCertificateApplication -Name 'platform' -ProfileId $ServerProfile.Id, $CodeSigningProfile.Id + Creates an application with two profiles already attached. + + + + + + Set-InfisicalCertificateApplication + Renames an Infisical certificate application or changes which profiles it holds. + Set + InfisicalCertificateApplication + + + Updates a certificate application. -Name and -Description change the record; -AddProfileId and -RemoveProfileId change which certificate profiles the application groups. The record and its profile attachments are separate endpoints, so supplying only profile parameters skips the record update entirely. Supply -PassThru to emit the updated application. + + + Notes + + Detaching a profile does not delete it; the profile continues to exist and issue, it is simply no longer grouped under the application. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalCertificateApplication -ApplicationId $Application.Id -AddProfileId $Profile.Id + Attaches a profile to the application. + + + EXAMPLE 2 + Set-InfisicalCertificateApplication -ApplicationId $Application.Id -Name 'endpoint-management' -RemoveProfileId $Old.Id -PassThru + Renames the application, detaches a profile, and emits the updated record. + + + + + + Remove-InfisicalCertificateApplication + Deletes an Infisical certificate application. + Remove + InfisicalCertificateApplication + + + Deletes a certificate application from a Certificate Manager project. -PassThru emits the removed identifier for logging. + + + Notes + + Destructive. The profiles the application grouped are not deleted, but scripts that locate a profile by application can no longer find it. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalCertificateApplication -ApplicationId $Application.Id -Confirm:$False + Deletes the application without prompting. + + + EXAMPLE 2 + Get-InfisicalCertificateApplication | Where-Object {($_.CertificateCount -eq 0)} | Remove-InfisicalCertificateApplication + Removes every application holding no certificates, prompting for each. + + + + + + New-InfisicalPkiSubscriber + Creates an Infisical PKI subscriber, a named enrollment identity with a fixed common name. + New + InfisicalPkiSubscriber + + + Creates a PKI subscriber: a named enrollment identity that pins one common name, an allowlist of subject alternative names, a lifetime, and the permitted key usages, so a request carries only a certificate signing request. -CommonName is the identity the subscriber issues for, and -SubjectAlternativeName is an allowlist rather than a default. + + + Notes + + A subscriber is a single identity, not a template. Infisical rejects any request whose certificate signing request names a different common name, and rejects any subject alternative name outside the allowlist, so enrolling many machines through subscribers means one subscriber per machine. Use a certificate profile for fleet enrollment. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + New-InfisicalPkiSubscriber -Name 'web01' -CommonName 'WEB01.contoso.com' -CaId $Ca.Id -Ttl '90d' + Creates a subscriber for one host. + + + EXAMPLE 2 + New-InfisicalPkiSubscriber -Name 'web01' -CommonName 'WEB01.contoso.com' -CaId $Ca.Id -Ttl '90d' -SubjectAlternativeName 'WEB01','WEB01.contoso.com' -ExtendedKeyUsage 'serverAuth','clientAuth' + Creates a subscriber that also permits two subject alternative names and restricts extended key usage. + + + + + + Set-InfisicalPkiSubscriber + Updates an Infisical PKI subscriber. + Set + InfisicalPkiSubscriber + + + Updates a PKI subscriber, addressed by its current -Name. Only the values supplied on the command line are sent; anything omitted keeps its stored value. -NewName renames the subscriber. Supply -PassThru to emit the updated record. + + + Notes + + Changing -CommonName changes the identity the subscriber issues for, so any script signing against it must present a matching certificate signing request afterwards. Honors -WhatIf and -Confirm. + + + + + EXAMPLE 1 + Set-InfisicalPkiSubscriber -Name 'web01' -Ttl '30d' + Shortens the lifetime of certificates issued for the subscriber. + + + EXAMPLE 2 + Set-InfisicalPkiSubscriber -Name 'web01' -SubjectAlternativeName 'WEB01','WEB01.contoso.com','www.contoso.com' -PassThru + Extends the permitted subject alternative names and emits the updated subscriber. + + + + + + Remove-InfisicalPkiSubscriber + Deletes an Infisical PKI subscriber. + Remove + InfisicalPkiSubscriber + + + Deletes a PKI subscriber from a Certificate Manager project, addressed by name. -PassThru emits the removed name for logging. + + + Notes + + Destructive. Any script signing through the subscriber fails once it is gone. Certificates already issued are unaffected. High ConfirmImpact prompts unless -Confirm:$False is supplied. + + + + + EXAMPLE 1 + Remove-InfisicalPkiSubscriber -Name 'web01' -Confirm:$False + Deletes the subscriber without prompting. + + + EXAMPLE 2 + Get-InfisicalPkiSubscriber | Where-Object {($_.Status -ne 'active')} | Remove-InfisicalPkiSubscriber + Removes every inactive subscriber, prompting for each. + + + diff --git a/README.md b/README.md index 2ad11ed..630ef88 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Import-Module -Name .\Module\PSInfisicalAPI ## Cmdlets -The module exports 51 cmdlets. Discovery cmdlets (`Get-Infisical*`) use a `List` (default) / single-record parameter-set pair: invoking without the identity parameter returns the collection, supplying the identity parameter returns one record. +The module exports 68 cmdlets. Discovery cmdlets (`Get-Infisical*`) use a `List` (default) / single-record parameter-set pair: invoking without the identity parameter returns the collection, supplying the identity parameter returns one record. ### Session @@ -118,6 +118,44 @@ The module exports 51 cmdlets. Discovery cmdlets (`Get-Infisical*`) use a `List` | `Write-InfisicalScepMdmProfileToWmi`| Submits a SCEP MDM profile to the local MDM Bridge WMI provider to trigger enrollment. | | `Get-InfisicalSANList` | Builds a SAN candidate list (device name, `.` per adapter DNS suffix, RFC 1918 + CGNAT IPv4 addresses, IPv4/IPv6 loopback) for `Request-InfisicalCertificate -DnsName`. | +### PKI configuration + +Creating and changing the objects a Certificate Manager project is built from. `-ProjectId` is optional on all of them, and every one honours `-WhatIf`. + +| Cmdlet | Purpose | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `New-InfisicalCertificateAuthority` | Creates a root or intermediate internal CA, signing a subordinate with its parent so it can issue. | +| `Set-InfisicalCertificateAuthority` | Renames an internal CA or changes its status. | +| `Remove-InfisicalCertificateAuthority` | Deletes an internal CA. | +| `New-InfisicalCertificatePolicy` | Creates a certificate policy constraining subject, SANs, key usages, and validity. | +| `Set-InfisicalCertificatePolicy` | Updates a certificate policy; only supplied constraints are sent. | +| `Remove-InfisicalCertificatePolicy` | Deletes a certificate policy. | +| `New-InfisicalCertificateProfile` | Creates a certificate profile binding an issuing CA to a policy for enrollment. | +| `Set-InfisicalCertificateProfile` | Updates a certificate profile. | +| `Remove-InfisicalCertificateProfile` | Deletes a certificate profile. | +| `New-InfisicalCertificateApplication` | Creates a certificate application and optionally attaches profiles. | +| `Set-InfisicalCertificateApplication` | Renames an application, or attaches and detaches profiles. | +| `Remove-InfisicalCertificateApplication` | Deletes a certificate application. | +| `New-InfisicalPkiSubscriber` | Creates a PKI subscriber: one named identity with a fixed common name. | +| `Set-InfisicalPkiSubscriber` | Updates a PKI subscriber. | +| `Remove-InfisicalPkiSubscriber` | Deletes a PKI subscriber. | + +Constraint dictionaries take `Allowed`, `Required`, and `Denied` in whatever casing reads naturally — they reach the API lower-cased — and an empty list is omitted rather than sent as "allow nothing": + +```powershell +$Root = New-InfisicalCertificateAuthority -Name 'root-ca' -Type Root -CommonName 'Contoso Root CA' -Organization 'Contoso' -Country 'US' +$Ca = New-InfisicalCertificateAuthority -Name 'issuing-ca' -Type Intermediate -ParentCaId $Root.Id -CommonName 'Contoso Issuing CA' + +$Policy = New-InfisicalCertificatePolicy -Name 'server-auth' -MaxValidity '90d' ` + -KeyAlgorithm 'RSA_2048','EC_secp384r1' ` + -KeyUsage @{ Required = @('digital_signature','key_encipherment') } ` + -ExtendedKeyUsage @{ Required = @('server_auth','client_auth') } + +$CertificateProfile = New-InfisicalCertificateProfile -Slug 'server-auth' -CertificatePolicyId $Policy.Id -CaId $Ca.Id -AutoRenew -RenewBeforeDays 14 +$Application = New-InfisicalCertificateApplication -Name 'platform' -ProfileId $CertificateProfile.Id +``` + +The intermediate comes back ready to issue: Infisical creates a subordinate pending a certificate, and `New-InfisicalCertificateAuthority` performs the remaining sequence — read the CSR, sign it with `-ParentCaId`, import the result. ### Process | Cmdlet | Purpose | @@ -134,16 +172,16 @@ $secureSecret = Read-Host -AsSecureString 'Client Secret' $connection = Connect-Infisical ` -BaseUri 'https://app.infisical.com' ` -OrganizationId '00000000-0000-0000-0000-000000000000' ` - -ProjectId '11111111-1111-1111-1111-111111111111' ` - -Environment 'dev' ` -ClientId 'machine-identity-client-id' ` -ClientSecret $secureSecret ` -PassThru -Get-InfisicalSecret -SecretPath '/' +Get-InfisicalSecret -ProjectId '11111111-1111-1111-1111-111111111111' -Environment 'dev' -SecretPath '/' Disconnect-Infisical ``` +`Connect-Infisical` establishes the session; project, environment, and secret path are supplied per call. On the PKI cmdlets `-ProjectId` is optional — see [Project scoping](#project-scoping). + ## End-to-end: request and install a chained certificate Connects, selects a `cert-manager` project, sources SANs from `Get-InfisicalSANList`, requests a certificate through a certificate profile, 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. @@ -161,43 +199,20 @@ $ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specializ $Connection = Connect-Infisical @ConnectInfisicalParameters -$Project = Get-InfisicalProject -Type cert-manager | Select-Object -First 1 +$Application = Get-InfisicalCertificateApplication | Where-Object {($_.Name -ieq 'platform')} -$Project - -#region Certificate authorities. Not required for profile issuance - the profile already binds its CA - but -# useful for confirming the chain you expect to be installed. - $CAList = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal - - $RootCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $True)} | Select-Object -First 1 - - $RootCA - - $IntermediateCA = $CAList | Where-Object {([String]::IsNullOrEmpty($_.ParentCaId) -eq $False)} - - $IntermediateCA -#endregion - -$CertificateProfile = Get-InfisicalCertificateProfile -ProjectId ($Project.Id) -IncludeConfigs | - Where-Object {($_.EnrollmentType -iin @('API')) -and ($_.Slug -imatch '.*Server.*')} | - Select-Object -First 1 - -$CertificateProfile +$CertificateProfile = Get-InfisicalCertificateProfile -ApplicationId ($Application.Id) -IncludeConfigs | Where-Object {($_.EnrollmentType -ieq 'api') -and ($_.Slug -imatch 'server')} | Select-Object -First 1 $SanList = Get-InfisicalSANList -$SanList - $RequestInfisicalCertificateParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) - $RequestInfisicalCertificateParameters.ProjectId = $Project.Id $RequestInfisicalCertificateParameters.CertificateProfileId = $CertificateProfile.Id $RequestInfisicalCertificateParameters.CommonName = $Env:ComputerName.ToUpper() $RequestInfisicalCertificateParameters.DnsName = New-Object -TypeName 'System.Collections.Generic.List[System.String]' $RequestInfisicalCertificateParameters.DnsName.AddRange($SanList) $RequestInfisicalCertificateParameters.DnsName.Add('app.contoso.com') - $RequestInfisicalCertificateParameters.DnsName.Add('api.contoso.com') - $RequestInfisicalCertificateParameters.DnsName.Add('boot.contoso.com') $RequestInfisicalCertificateParameters.Ttl = '90d' + $RequestInfisicalCertificateParameters.Metadata = [Ordered]@{ Environment = 'Production'; Owner = 'Platform' } $RequestInfisicalCertificateParameters.Install = $True $RequestInfisicalCertificateParameters.InstallChain = $True $RequestInfisicalCertificateParameters.Verbose = $True @@ -207,39 +222,118 @@ $Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParamete $Null = Disconnect-Infisical -Verbose ``` -Note `$CertificateProfile` rather than `$Profile`: `$Profile` is an automatic variable in PowerShell (the path to the current profile script), and assigning to it works but shadows something the host relies on. +Four calls: find the application, pick its profile, gather SANs, request. No project lookup — `-ProjectId` resolves itself — and no CA lookup, because the profile already binds its issuing CA and `-InstallChain` installs the whole chain regardless. `-StoreName`/`-StoreLocation` are omitted deliberately — see [Where certificates get installed](#where-certificates-get-installed). +## Renewing that certificate + +Run the same request again with `-AllowRenewal`. Nothing else changes — the parameters below are the ones built above, so this is the shape to put on a schedule: + +```powershell +$RequestInfisicalCertificateParameters.AllowRenewal = $True +$RequestInfisicalCertificateParameters.RenewalThresholdDays = 30 + +$Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters +``` + +The call is safe to repeat. Outside the renewal window it finds the installed certificate and returns it untouched; inside the window it issues a replacement and installs it: + +```text +VERBOSE: Reuse search for CN=WEB01 scoped to certificate profile 'a42f8446-...' returned 1 active certificate(s). +VERBOSE: Reusing existing certificate (Thumbprint=F480A920..., NotAfter=2026-10-28 19:36:49Z). +``` + +Reuse is matched on the issuing profile *and* the requested SAN set, so the same script issues a new certificate — rather than silently returning the old one — whenever the profile changes or a name is added to `-DnsName`. `-Force` issues unconditionally, ignoring both the renewal window and any existing certificate. + +Run it daily and it does nothing until the certificate is within 30 days of expiry, then rotates it. Nothing to gate it with, and no state to keep. + +### Project scoping + +Opening **Certificate Manager** in the Infisical console never asks which project to use. The project is in the URL — `/organizations/{orgId}/projects/cert-manager/{projectId}/applications` — but it is chosen for you, and everything below it is presented as **applications**. That is because Infisical's own resolver takes the single Certificate Manager project when an organization has exactly one: + +```ts +const projects = await projectDAL.find({ orgId: actorOrgId, type: ProjectType.CertificateManager }); +if (projects.length === 1) return projects[0].id; +``` + +The PKI cmdlets follow the same rule: **`-ProjectId` is optional**. Omit it and the module resolves the organization's only Certificate Manager project, reporting the choice on the verbose stream: + +```powershell +Get-InfisicalCertificateApplication +Get-InfisicalCertificateAuthority -Kind Internal +Get-InfisicalCertificateProfile -IncludeConfigs +Get-InfisicalPkiSubscriber +Get-InfisicalCertificate -Status active +``` + +```text +VERBOSE: -ProjectId was not supplied; resolved the organization's only Certificate Manager project + 'Microsoft Endpoint Configuration Manager' (2122628e-...). +``` + +An organization with **no** Certificate Manager project is not an error either. There is nothing to list, so the PKI `Get-*` cmdlets return nothing and `-Verbose` explains why: + +```text +VERBOSE: -ProjectId was not supplied and this organization has no Certificate Manager project, so there is + nothing to resolve to. Create one in Infisical (Certificate Management), or pass -ProjectId to + target a specific project. +``` + +Several Certificate Manager projects in one organization is not an error. Infisical designates one as the organization's **active** project, and that is what resolution picks: + +```text +VERBOSE: -ProjectId was not supplied; resolved the organization's active Certificate Manager project + 'Platform PKI' (aaaa...). +``` + +If no active project is designated, the first is used and the verbose line says so; pass `-ProjectId` to target another. + +This is resolved client-side rather than left to the server because several PKI endpoints carry the project in the URL path (`/api/v1/projects/{projectId}/pki-subscribers`, `/certificates/search`) and cannot defer to the server's resolver. + +#### One project per organization, in practice + +An organization *can* hold several Certificate Manager projects, but **certificate applications are served only from the active one**. The applications router rejects anything else outright: + +```ts +if (req.internalCertManagerProjectId !== activeProjectId) { + throw new BadRequestError({ message: "Applications are only available on this organization's active Certificate Manager project." }); +} +``` + +So an application-centric workflow is single-project by design. Additional Certificate Manager projects can exist and hold their own CAs, policies, profiles, and certificates, but they are reachable only by passing `-ProjectId` explicitly, and applications will not work in them. + +#### Projects contain applications + +The two are different levels, which is worth keeping straight when reading output: + +| | What it is | Cmdlet | +| --- | --- | --- | +| **Project** | The Certificate Manager project itself. One per organization in most setups. | `Get-InfisicalProject -Type cert-manager` | +| **Application** | A grouping of profiles, members, and certificates inside that project. | `Get-InfisicalCertificateApplication` | + +So a project named `pki` can contain applications named `platform` and `endpoint-management`; listing projects returns only `pki`, because the others are not projects. Every application carries the `ProjectId` it belongs to, which is why that field is real rather than vestigial. + +Profiles can be filtered to an application, matching how the console groups them: + +```powershell +$Application = Get-InfisicalCertificateApplication | Where-Object {($_.Name -ieq 'platform')} +Get-InfisicalCertificateProfile -ApplicationId $Application.Id -IncludeConfigs +Get-InfisicalCertificate -ApplicationId $Application.Id +``` + ### Example output ```text -Id : 00000000-0000-0000-0000-000000000000 -Name : Microsoft Endpoint Configuration Manager -Slug : mecm -Description : -OrganizationId : 11111111-1111-1111-1111-111111111111 -Type : cert-manager -AutoCapitalization : False -EnvironmentSlugs : {dev, staging, prod} -CreatedAtUtc : 3/12/2026 8:32:52 PM +00:00 -UpdatedAtUtc : 6/21/2026 7:00:28 PM +00:00 - -Name : root-ca -CommonName : Contoso Root Certificate Authority -Type : internal -Status : active -KeyAlgorithm : RSA_2048 -NotAfter : 03/25/2036 00:00:00 -Id : 22222222-2222-2222-2222-222222222222 - -Name : intermediate-ca -CommonName : Contoso Intermediate Certificate Authority -Type : internal -Status : active -KeyAlgorithm : RSA_2048 -NotAfter : 03/25/2031 00:00:00 -Id : 33333333-3333-3333-3333-333333333333 +Id : 11111111-1111-1111-1111-111111111111 +ProjectId : 00000000-0000-0000-0000-000000000000 +Name : platform +Description : +ProfileCount : 3 +MemberCount : 2 +CertificateCount : 0 +CreatedAtUtc : 7/30/2026 10:05:37 PM +00:00 +UpdatedAtUtc : 7/30/2026 10:05:37 PM +00:00 Id : 44444444-4444-4444-4444-444444444444 ProjectId : 00000000-0000-0000-0000-000000000000 @@ -266,6 +360,7 @@ WEB01.contoso.com 127.0.0.1 ::1 +VERBOSE: [...] - [Information] - [GetInfisicalCertificateApplicationCmdlet] - -ProjectId was not supplied; resolved the organization's only Certificate Manager project 'Platform PKI' (00000000-0000-0000-0000-000000000000). VERBOSE: [...] - [Information] - [PkiClient] - Attempting to search Infisical certificates. Please Wait... VERBOSE: [...] - [Verbose] - [HttpClient] - Attempting HTTP POST to https://infisical.contoso.com/api/v1/projects/00000000-0000-0000-0000-000000000000/certificates/search. Please Wait... VERBOSE: [...] - [Verbose] - [HttpClient] - HTTP POST completed with status 200. @@ -412,8 +507,8 @@ Request-InfisicalCertificate @RequestInfisicalCertificateParameters -WhatIf A certificate profile binds a CA to a certificate policy. The policy constrains the subject, key usages, and extended key usages with `allowed`/`required`/`denied` lists, so the common name still varies per request while staying inside guardrails. ```powershell -Get-InfisicalCertificateProfile -ProjectId ($Project.Id) | Format-Table Id, Name, CaId -Get-InfisicalCertificatePolicy -ProjectId ($Project.Id) | Format-Table Id, Name +Get-InfisicalCertificateProfile | Format-Table Id, Slug, CaId, EnrollmentType +Get-InfisicalCertificatePolicy | Format-Table Id, Name ``` Profile issuance is the only path that does **not** consult the CA's direct-issuance flag — the service short-circuits it: @@ -427,7 +522,7 @@ So a profile issues successfully against a CA whose `EnableDirectIssuance` is `F #### Discovering subscribers ```powershell -Get-InfisicalPkiSubscriber -ProjectId ($Project.Id) | +Get-InfisicalPkiSubscriber | Format-Table Name, CommonName, Status, Ttl, CaId ``` @@ -460,10 +555,10 @@ const ca = await certificateAuthorityDAL.create({ projectId, name: resolvedCaNam So **every CA created through the API or UI has direct issuance disabled**, and nothing can turn it on afterwards. Use a certificate profile, which ignores the flag entirely. ```powershell -Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal | +Get-InfisicalCertificateAuthority -Kind Internal | Format-Table Name, CommonName, Status, EnableDirectIssuance -$Ca = Get-InfisicalCertificateAuthority -ProjectId ($Project.Id) -Kind Internal | +$Ca = Get-InfisicalCertificateAuthority -Kind Internal | Where-Object {($_.EnableDirectIssuance -eq $True)} | Select-Object -First 1 @@ -519,7 +614,7 @@ A failing item does not abort the batch: ```powershell 'web01', 'does-not-exist', 'web02' | - ForEach-Object { Get-InfisicalPkiSubscriber -ProjectId $ProjectId -Name $_ -ErrorAction SilentlyContinue } + ForEach-Object { Get-InfisicalPkiSubscriber -Name $_ -ErrorAction SilentlyContinue } # emits web01 and web02; the failure is available in $Error ``` @@ -555,16 +650,15 @@ The resolver matches case-insensitively against patterns aligned with Infisical' | Parameter | Example variable names matched | | ----------------- | ------------------------------------------------------------------------------------ | -| `BaseUri` | `INFISICAL_API_URL`, `INFISICAL_BASE_URL`, `INFISICAL_HOST` | +| `BaseUri` | `INFISICAL_API_URL`, `INFISICAL_BASE_URL`, `INFISICAL_BASE_URI`, `INFISICAL_HOST` | | `OrganizationId` | `INFISICAL_ORG_ID`, `INFISICAL_ORGANIZATION_ID` | -| `ProjectId` | `INFISICAL_PROJECT_ID`, `INFISICAL_WORKSPACE_ID` | -| `Environment` | `INFISICAL_ENVIRONMENT`, `INFISICAL_ENV`, `INFISICAL_ENV_SLUG` | | `ClientId` | `INFISICAL_CLIENT_ID`, `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` | | `ClientSecret` | `INFISICAL_CLIENT_SECRET`, `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` | | `AccessToken` | `INFISICAL_TOKEN`, `INFISICAL_ACCESS_TOKEN`, `INFISICAL_AUTH_TOKEN` | -| `SecretPath` | `INFISICAL_SECRET_PATH`, `INFISICAL_DEFAULT_SECRET_PATH` | | `ApiVersion` | `INFISICAL_API_VERSION` | +Discovery covers the connection itself. Project, environment, and secret path are per-call parameters, so they are not resolved from the environment. + Sensitive values (`ClientSecret`, `AccessToken`) are read directly into a read-only `SecureString` and never logged. ### Zero-configuration example @@ -619,7 +713,7 @@ To add a route: ### Adding a new cmdlet -Cmdlets live in `src/PSInfisicalAPI/Cmdlets/` and derive from `InfisicalCmdletBase`, which exposes `HttpClient`, `Logger`, `ResolveProjectId`, and `ThrowTerminatingForException`. Follow the consolidated discovery pattern when the cmdlet supports both list and single-record retrieval: +Cmdlets live in `src/PSInfisicalAPI/Cmdlets/` and derive from `InfisicalCmdletBase`, which exposes `HttpClient`, `Logger`, `ResolveCertManagerProjectId`, and `ThrowTerminatingForException`. Follow the consolidated discovery pattern when the cmdlet supports both list and single-record retrieval: ```csharp [Cmdlet(VerbsCommon.Get, "InfisicalPkiSubscriber", DefaultParameterSetName = "List")] @@ -644,7 +738,7 @@ After adding (or removing) a cmdlet: ```powershell $Params = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) - $Params.ProjectId = (Get-InfisicalProject | Select-Object -First 1).Id + $Params.ProjectId = (Get-InfisicalProject -Type cert-manager | Select-Object -First 1).Id $Result = Get-InfisicalPkiSubscriber @Params ``` 4. Add a `## Unreleased` entry to `CHANGELOG.md` describing the change (mark removals of public cmdlets or parameters as **BREAKING**). diff --git a/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 b/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 new file mode 100644 index 0000000..3df8d20 --- /dev/null +++ b/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 @@ -0,0 +1,1232 @@ +<# +.SYNOPSIS + Seeds an Infisical Certificate Manager project with a CA hierarchy, certificate policies, and + enrollment profiles for API, SCEP, and ACME. + +.DESCRIPTION + Stands up a complete cert-manager environment from a single declarative configuration block, so a new + Infisical instance can be brought to a known state without clicking through the UI. + + What it creates, in dependency order: + + 1. A cert-manager project. + 2. A root CA and a subordinate CA. Infisical does not sign a subordinate on creation, so the script + performs the full dance itself: create (status pending-certificate) -> GET the CSR -> sign it with + the root -> import the signed certificate and chain back onto the subordinate. + 3. Certificate policies, which constrain subject attributes, key usages, extended key usages, key + algorithms, and maximum validity. + 4. Certificate profiles, which bind a policy to an issuing CA and expose it for enrollment. + 5. A certificate application, which is what the UI and the cmdlets group profiles under. + + Two sets are seeded by default: an RSA hierarchy carrying server and client authentication for + SCCM/MECM, and an ECDSA P-384 hierarchy carrying server, client, and code signing. + + Enrollment protocols are enabled on the link between the application and each profile, which is where + they are independent of one another, so a single profile can answer several at once. Seeded by + default: API everywhere, SCEP on the RSA profile with a dynamic challenge for clients that enrol + without a token, and ACME on the EC server/client profile. Enabling SCEP or ACME makes Infisical mint + the matching endpoint, and the run prints those URLs because they cannot be derived from the + configuration. + + The script is idempotent. Every object is looked up by its natural key before creation, so a re-run + reports what already exists and creates only what is missing. It is safe to run repeatedly while + building out a configuration. + + Certificates are requested against a profile with Request-InfisicalCertificate -CertificateProfileId. + Profiles are used rather than direct CA issuance because Infisical creates every CA with direct + issuance disabled and exposes no API to enable it; profile issuance is the only path that does not + consult that flag. + +.PARAMETER BaseUri + Base URI of the Infisical instance, for example https://infisical.contoso.com. + +.PARAMETER ClientId + Universal Auth machine identity client id. + +.PARAMETER ClientSecret + Universal Auth machine identity client secret, as a SecureString. + +.PARAMETER AccessToken + An existing bearer token, as a SecureString, used instead of -ClientId and -ClientSecret. Accepts a + machine identity token or the token from a signed-in browser session, which avoids creating a machine + identity just to seed a throwaway instance. The token is proven against the API before anything is + created, so an expired one fails immediately rather than midway through building a CA hierarchy. + + To take one from a browser session: sign in to Infisical, open the developer tools Network tab, select + any request to /api/, and copy the value after "Bearer " from its Authorization header. + +.PARAMETER OrganizationId + Organization to create the project under. Optional when the identity is scoped to a single + organization, in which case it is resolved automatically. + +.PARAMETER ScepChallengePassword + Shared secret that static SCEP profiles enrol against, as a SecureString. Optional; when omitted a + random one is generated and printed once at the end of the run. Infisical does not expose the challenge + afterwards, so a generated value is only recoverable from that output. + +.PARAMETER SkipCertificateCheck + Accepts an untrusted TLS certificate on the Infisical endpoint. For lab instances only. + +.EXAMPLE + $Secret = Read-Host -AsSecureString 'Client Secret' + .\Initialize-InfisicalCertManagerEnvironment.ps1 -BaseUri 'https://infisical.contoso.com' ` + -ClientId '00000000-0000-0000-0000-000000000000' -ClientSecret $Secret -WhatIf + + Shows every object that would be created without changing anything. Run this first. + +.EXAMPLE + $Token = Read-Host -AsSecureString 'Access Token' + .\Initialize-InfisicalCertManagerEnvironment.ps1 -BaseUri 'https://infisical.contoso.com' -AccessToken $Token -WhatIf + + Plans the same run using a token pasted from a browser session, with no machine identity involved. + +.EXAMPLE + .\Initialize-InfisicalCertManagerEnvironment.ps1 -BaseUri 'https://infisical.contoso.com' ` + -ClientId '00000000-0000-0000-0000-000000000000' -ClientSecret $Secret + + Seeds the environment, then emits an object describing everything created or found. + +.NOTES + Requires no modules. PSInfisicalAPI consumes the result; it is not needed to produce it. +#> + +[CmdletBinding(SupportsShouldProcess = $True, DefaultParameterSetName = 'UniversalAuth')] +param( + [Parameter(Mandatory = $True)] + [ValidateNotNullOrEmpty()] + [String]$BaseUri, + + [Parameter(Mandatory = $True, ParameterSetName = 'UniversalAuth')] + [ValidateNotNullOrEmpty()] + [String]$ClientId, + + [Parameter(Mandatory = $True, ParameterSetName = 'UniversalAuth')] + [ValidateNotNull()] + [System.Security.SecureString]$ClientSecret, + + [Parameter(Mandatory = $True, ParameterSetName = 'Token')] + [ValidateNotNull()] + [System.Security.SecureString]$AccessToken, + + [Parameter(Mandatory = $False)] + [String]$OrganizationId, + + [Parameter(Mandatory = $False)] + [ValidateNotNull()] + [System.Security.SecureString]$ScepChallengePassword, + + [Parameter(Mandatory = $False)] + [Switch]$SkipCertificateCheck +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +#region Configuration + <# + Everything the script creates is described here. Keys map to Infisical concepts; each takes a list + of dictionaries so a set can be extended by adding an entry rather than by editing code. + + Cross-references are by name: a CertificateAuthority names its Parent, and a CertificateProfile + names its CertificateAuthority and CertificatePolicy. The script resolves those to ids at runtime, + so the block reads as a description of the environment rather than a sequence of API calls. + + Enumeration values are the ones Infisical accepts verbatim: + KeyAlgorithm RSA_2048 | RSA_3072 | RSA_4096 | EC_prime256v1 | EC_secp384r1 | EC_secp521r1 + SignatureAlgorithm RSA-SHA256 | RSA-SHA384 | RSA-SHA512 | ECDSA-SHA256 | ECDSA-SHA384 | + ECDSA-SHA512 + KeyUsages digital_signature | key_encipherment | non_repudiation | data_encipherment | + key_agreement | key_cert_sign | crl_sign | encipher_only | decipher_only + ExtendedKeyUsages client_auth | server_auth | code_signing | email_protection | ocsp_signing | + time_stamping + SubjectAttribute common_name | organization | country | state | locality | + organizational_unit + SanType dns_name | ip_address | email | uri + #> + + $Configuration = [Ordered]@{ + + <# + Used only when the organization has no Certificate Manager project yet. If one already exists it is + adopted and these values are ignored, because applications are served from a single project per + organization and a second one could not hold them. + #> + Project = [Ordered]@{ + Name = 'Certificate Manager' + Slug = 'certificate-manager' + Description = 'Seeded PKI environment for endpoint and workload certificates.' + } + + <# + The application profiles are grouped under, which is what + Get-InfisicalCertificateProfile -ApplicationId scopes to. Set Name to '' to skip. + #> + Application = [Ordered]@{ + Name = 'platform' + Description = 'Endpoint and workload certificate enrollment.' + CertificateProfiles = @('mecm-server-client-auth', 'ec-server-client-auth', 'ec-code-signing') + } + + Organization = [Ordered]@{ + Name = 'Contoso' + OrganizationalUnit = 'IT' + Country = 'US' + State = '' + Locality = '' + } + + CertificateAuthorities = @( + [Ordered]@{ + Name = 'rsa-root-ca' + Type = 'root' + Parent = $Null + CommonName = 'Contoso RSA Root Certificate Authority' + KeyAlgorithm = 'RSA_2048' + ValidityYears = 10 + MaxPathLength = 1 + } + [Ordered]@{ + Name = 'rsa-issuing-ca' + Type = 'intermediate' + Parent = 'rsa-root-ca' + CommonName = 'Contoso RSA Issuing Certificate Authority' + KeyAlgorithm = 'RSA_2048' + ValidityYears = 5 + MaxPathLength = 0 + } + [Ordered]@{ + Name = 'ec-root-ca' + Type = 'root' + Parent = $Null + CommonName = 'Contoso EC Root Certificate Authority' + KeyAlgorithm = 'EC_secp384r1' + ValidityYears = 10 + MaxPathLength = 1 + } + [Ordered]@{ + Name = 'ec-issuing-ca' + Type = 'intermediate' + Parent = 'ec-root-ca' + CommonName = 'Contoso EC Issuing Certificate Authority' + KeyAlgorithm = 'EC_secp384r1' + ValidityYears = 5 + MaxPathLength = 0 + } + ) + + CertificatePolicies = @( + <# + Every attribute a request carries must have a policy entry, or issuance is refused with + "not allowed by template policy (no subject policies defined)". An entry of Allowed = @('*') + is therefore not decoration: it is what keeps the common name and SAN list open, which is + what fleet enrollment needs, since every machine presents its own name. Narrow a value by + replacing the wildcard, for example Allowed = @('*.contoso.com'). + + SignatureAlgorithms is likewise mandatory. A policy that omits it rejects every request with + "Signature algorithm ... not defined in template". + #> + [Ordered]@{ + Name = 'mecm-server-client-auth' + Description = 'SCCM/MECM site systems and clients. RSA, server and client authentication.' + MaxValidity = '365d' + KeyAlgorithms = @('RSA_2048', 'RSA_3072', 'RSA_4096') + SignatureAlgorithms = @('RSA-SHA256', 'RSA-SHA384', 'RSA-SHA512') + KeyUsages = [Ordered]@{ Required = @('digital_signature', 'key_encipherment') } + ExtendedKeyUsages = [Ordered]@{ Required = @('server_auth', 'client_auth') } + Subject = @( + [Ordered]@{ Type = 'common_name'; Allowed = @('*') } + [Ordered]@{ Type = 'organization'; Allowed = @('*') } + [Ordered]@{ Type = 'organizational_unit'; Allowed = @('*') } + [Ordered]@{ Type = 'country'; Allowed = @('*') } + [Ordered]@{ Type = 'state'; Allowed = @('*') } + [Ordered]@{ Type = 'locality'; Allowed = @('*') } + ) + SubjectAltNames = @( + [Ordered]@{ Type = 'dns_name'; Allowed = @('*') } + [Ordered]@{ Type = 'ip_address'; Allowed = @('*') } + ) + } + [Ordered]@{ + Name = 'ec-server-client-auth' + Description = 'General workload certificates. ECDSA P-384, server and client authentication.' + MaxValidity = '90d' + KeyAlgorithms = @('EC_secp384r1') + SignatureAlgorithms = @('ECDSA-SHA384') + KeyUsages = [Ordered]@{ Required = @('digital_signature') } + ExtendedKeyUsages = [Ordered]@{ Required = @('server_auth', 'client_auth') } + Subject = @( + [Ordered]@{ Type = 'common_name'; Allowed = @('*') } + [Ordered]@{ Type = 'organization'; Allowed = @('*') } + [Ordered]@{ Type = 'organizational_unit'; Allowed = @('*') } + [Ordered]@{ Type = 'country'; Allowed = @('*') } + [Ordered]@{ Type = 'state'; Allowed = @('*') } + [Ordered]@{ Type = 'locality'; Allowed = @('*') } + ) + SubjectAltNames = @( + [Ordered]@{ Type = 'dns_name'; Allowed = @('*') } + [Ordered]@{ Type = 'ip_address'; Allowed = @('*') } + ) + } + [Ordered]@{ + Name = 'ec-code-signing' + Description = 'Code signing. ECDSA P-384, code signing only.' + MaxValidity = '365d' + KeyAlgorithms = @('EC_secp384r1') + SignatureAlgorithms = @('ECDSA-SHA384') + KeyUsages = [Ordered]@{ Required = @('digital_signature') } + ExtendedKeyUsages = [Ordered]@{ Required = @('code_signing'); Denied = @('server_auth', 'client_auth') } + Subject = @( + [Ordered]@{ Type = 'common_name'; Allowed = @('*') } + [Ordered]@{ Type = 'organization'; Allowed = @('*') } + [Ordered]@{ Type = 'organizational_unit'; Allowed = @('*') } + [Ordered]@{ Type = 'country'; Allowed = @('*') } + ) + SubjectAltNames = @() + } + ) + + <# + Enrollment is configured twice, at two different levels, and only the second one is what clients + actually use. + + The profile itself carries a single enrollmentType, and Infisical rejects a profile that mixes + them ("API enrollment type cannot have EST, ACME, or SCEP configuration"). That setting is left + at api for every profile below; it is the base the application builds on. + + The application-to-profile link is where a profile is exposed over one or more protocols, which + is what the UI means by "configure how this application will issue certificates via API, EST, + ACME, or SCEP". Those are independent, so a single profile can serve all of them at once, and + enabling one is what makes Infisical mint its endpoint - a SCEP URL and RA certificate, or an + ACME directory URL. The Enrollment block below drives that step; omit a protocol to leave it off. + + Api AutoRenew, RenewBeforeDays (1-365) + Scep ChallengeType dynamic (a one-time password per request) or static (one shared secret), + IncludeCaCertInResponse, AllowCertBasedRenewal, and for dynamic the expiry in minutes + (5-1440) and the cap on outstanding challenges (1-1000) + Acme SkipDnsOwnershipVerification, SkipEabBinding + Est a passphrase and a bootstrap CA chain, so it is left to be configured by hand + + AutoRenew is an Api setting. Neither SCEP nor ACME has an equivalent, because both protocols + have the client drive renewal on its own schedule. + + Defaults fill in what a requester leaves out. They are required whenever the policy marks a + constraint Required: Request-InfisicalCertificate does not send key usages of its own, so + without a default the request arrives empty and is refused with "Missing required key usages". + #> + CertificateProfiles = @( + <# + SCEP as well as API: it is how domain-joined Windows and mobile clients enrol without a + token, which is the NDES role SCCM/MECM would otherwise need. Dynamic challenge, so there is + no shared secret to distribute or rotate - each request collects a one-time password from + the challenge endpoint instead. + #> + [Ordered]@{ + Slug = 'mecm-server-client-auth' + Description = 'SCCM/MECM site systems and clients.' + CertificateAuthority = 'rsa-issuing-ca' + CertificatePolicy = 'mecm-server-client-auth' + Defaults = [Ordered]@{ + KeyAlgorithm = 'RSA_2048' + SignatureAlgorithm = 'RSA-SHA256' + KeyUsages = @('digital_signature', 'key_encipherment') + ExtendedKeyUsages = @('server_auth', 'client_auth') + } + Enrollment = [Ordered]@{ + Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 } + Scep = [Ordered]@{ + ChallengeType = 'dynamic' + IncludeCaCertInResponse = $True + AllowCertBasedRenewal = $True + DynamicChallengeExpiryMinutes = 60 + DynamicChallengeMaxPending = 100 + } + } + } + <# + ACME as well as API, for workloads that already speak it (cert-manager, Caddy, Traefik, + acme.sh). + + SkipDnsOwnershipVerification is on because this is an internal CA issuing internal names: + the DNS-01 challenge proves control of a public zone, which a name like host.contoso.local + cannot satisfy, so leaving it enforced makes the profile unusable rather than safer. Turn it + off if the names being issued live in a zone Infisical can actually resolve. + #> + [Ordered]@{ + Slug = 'ec-server-client-auth' + Description = 'General workload certificates.' + CertificateAuthority = 'ec-issuing-ca' + CertificatePolicy = 'ec-server-client-auth' + Defaults = [Ordered]@{ + KeyAlgorithm = 'EC_secp384r1' + SignatureAlgorithm = 'ECDSA-SHA384' + KeyUsages = @('digital_signature') + ExtendedKeyUsages = @('server_auth', 'client_auth') + } + Enrollment = [Ordered]@{ + Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 } + Acme = [Ordered]@{ SkipDnsOwnershipVerification = $True; SkipEabBinding = $False } + } + } + <# + Code signing is API only. SCEP and ACME both exist to prove control of a device or a DNS + name, and neither says anything about who may sign code. + #> + [Ordered]@{ + Slug = 'ec-code-signing' + Description = 'Code signing certificates.' + CertificateAuthority = 'ec-issuing-ca' + CertificatePolicy = 'ec-code-signing' + Defaults = [Ordered]@{ + KeyAlgorithm = 'EC_secp384r1' + SignatureAlgorithm = 'ECDSA-SHA384' + KeyUsages = @('digital_signature') + ExtendedKeyUsages = @('code_signing') + } + Enrollment = [Ordered]@{ + Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 } + } + } + ) + } +#endregion + +#region Infrastructure + $Script:BearerToken = $Null + $Script:ApiRoot = $BaseUri.TrimEnd('/') + $Script:ScepChallenge = $Null + $Script:ScepChallengeGenerated = $False + + function Write-Step { + param([String]$Message, [String]$Status = 'Info') + $prefix = switch ($Status) { + 'Created' { ' [+]' } + 'Exists' { ' [=]' } + 'WhatIf' { ' [?]' } + 'Sub' { ' ' } + default { '==>' } + } + Write-Host ("{0} {1}" -f $prefix, $Message) + } + + function Get-ApiProperty { + <# + Reads a property that may not be present. Set-StrictMode makes an absent property a terminating + error, and response shapes vary across Infisical versions and route namespaces, so every read of an + API response goes through here rather than risking a crash on a field the server did not send. + #> + param($InputObject, [Parameter(Mandatory = $True)][String]$Name) + + if ($Null -eq $InputObject) { return $Null } + + $property = $InputObject.PSObject.Properties[$Name] + if ($Null -eq $property) { return $Null } + + return $property.Value + } + + function Get-ApiCollection { + <# + Returns a list from a response, as an array. Infisical is not consistent about wrapping: the + secrets and pki routes wrap a list in a named property, while the cert-manager routes return a + bare JSON array. Named properties are tried first, then the response itself if it is already a + list, so one call site handles either shape. + #> + param($InputObject, [Parameter(Mandatory = $True)][String[]]$Name) + + foreach ($candidate in $Name) { + $value = Get-ApiProperty -InputObject $InputObject -Name $candidate + if ($Null -ne $value) { return @($value) } + } + + # A bare array carries no named property to find, so fall through to the response itself. Strings are + # excluded because they are enumerable but are never a list of resources. + if ($Null -ne $InputObject -and $InputObject -isnot [String] -and $InputObject -is [System.Collections.IEnumerable]) { + return @($InputObject) + } + + return @() + } + + function Get-ApiObject { + <# + Returns a single resource from a response, handling the same wrapped-or-bare split as + Get-ApiCollection. An unwrapped response is recognised by carrying an id of its own. + #> + param($InputObject, [Parameter(Mandatory = $True)][String[]]$Name) + + foreach ($candidate in $Name) { + $value = Get-ApiProperty -InputObject $InputObject -Name $candidate + if ($Null -ne $value) { return $value } + } + + if ($Null -ne (Get-ApiProperty -InputObject $InputObject -Name 'id')) { return $InputObject } + + return $Null + } + + function ConvertFrom-SecureStringToPlainText { + param([System.Security.SecureString]$Value) + $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($Value) + try { return [System.Runtime.InteropServices.Marshal]::PtrToStringUni($pointer) } + finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($pointer) } + } + + function Invoke-InfisicalApi { + <# + Single choke point for every call. Adds the bearer token, converts the body, and surfaces the + API's own error text, which is far more useful than the raw status line. + #> + param( + [Parameter(Mandatory = $True)][String]$Method, + [Parameter(Mandatory = $True)][String]$Path, + [Parameter(Mandatory = $False)]$Body, + [Parameter(Mandatory = $False)][Switch]$NoAuth + ) + + $uri = "$($Script:ApiRoot)$Path" + $parameters = @{ + Method = $Method + Uri = $uri + ContentType = 'application/json' + ErrorAction = 'Stop' + } + + if (-not $NoAuth.IsPresent) { + $parameters.Headers = @{ Authorization = "Bearer $($Script:BearerToken)" } + } + + if ($Null -ne $Body) { + $parameters.Body = ($Body | ConvertTo-Json -Depth 12 -Compress) + } + + if ($SkipCertificateCheck.IsPresent -and $PSVersionTable.PSVersion.Major -ge 6) { + $parameters.SkipCertificateCheck = $True + } + + try { + return Invoke-RestMethod @parameters + } + catch { + $detail = $Null + try { + $response = $_.Exception.Response + if ($Null -ne $response) { + $stream = $response.GetResponseStream() + if ($Null -ne $stream) { + $stream.Position = 0 + $detail = (New-Object System.IO.StreamReader($stream)).ReadToEnd() + } + } + } catch { } + + if ([String]::IsNullOrWhiteSpace($detail) -and $_.ErrorDetails) { $detail = $_.ErrorDetails.Message } + $message = "$Method $Path failed: $($_.Exception.Message)" + if (-not [String]::IsNullOrWhiteSpace($detail)) { $message = "$message`n API said: $detail" } + throw $message + } + } + + function Connect-InfisicalApi { + Write-Step "Authenticating to $($Script:ApiRoot)" + + if ($PSCmdlet.ParameterSetName -eq 'Token') { + # A token supplied directly is used as-is. This accepts a machine identity token or the bearer token + # from a signed-in browser session, which is the quickest way to seed a throwaway instance. + $Script:BearerToken = ConvertFrom-SecureStringToPlainText -Value $AccessToken + + # Proven before anything is created, so an expired or mistyped token fails here rather than midway + # through building a CA hierarchy. The probe is the project listing because that is the next call + # the script makes anyway, so a success proves both the token and the route. + try { + $null = Invoke-InfisicalApi -Method 'GET' -Path '/api/v1/projects' + } + catch { + $Script:BearerToken = $Null + + # Only an authentication or authorisation failure says anything about the token. Reporting a + # 404 or a connection failure as "your token was rejected" sends the reader after the wrong + # problem entirely. + if ($_.Exception.Message -match '\((401|403)\)|Unauthorized|Forbidden') { + throw "The supplied -AccessToken was rejected by $($Script:ApiRoot). If it came from a browser session it may have expired; copy a fresh one. Underlying error: $($_)" + } + + throw "Could not reach $($Script:ApiRoot) to verify the token; this is not a token problem. Underlying error: $($_)" + } + + Write-Step 'Authenticated with the supplied token.' -Status 'Sub' + return + } + + $body = @{ + clientId = $ClientId + clientSecret = (ConvertFrom-SecureStringToPlainText -Value $ClientSecret) + } + + $response = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/auth/universal-auth/login' -Body $body -NoAuth + if ($Null -eq $response -or [String]::IsNullOrWhiteSpace((Get-ApiProperty -InputObject $response -Name 'accessToken'))) { + throw 'Authentication succeeded but returned no access token.' + } + + $Script:BearerToken = Get-ApiProperty -InputObject $response -Name 'accessToken' + Write-Step 'Authenticated with universal auth.' -Status 'Sub' + } + + function Resolve-OrganizationId { + if (-not [String]::IsNullOrWhiteSpace($OrganizationId)) { return $OrganizationId } + + # The singular v1 route is the listing; /api/v2/organizations mounts only /:organizationId/* sub-routes. + $response = Invoke-InfisicalApi -Method 'GET' -Path '/api/v1/organization' + $organizations = Get-ApiCollection -InputObject $response -Name 'organizations' + if ($organizations.Count -eq 0) { throw 'No organizations are visible to this identity; pass -OrganizationId.' } + if ($organizations.Count -gt 1) { + $names = ($organizations | ForEach-Object { "$($_.name) ($($_.id))" }) -join ', ' + throw "This identity can see multiple organizations; pass -OrganizationId. Visible: $names" + } + + Write-Step "Resolved organization '$($organizations[0].name)' ($($organizations[0].id))." -Status 'Sub' + return $organizations[0].id + } +#endregion + +#region Seeding + function Get-SeededProject { + <# + Adopts the organization's existing Certificate Manager project rather than creating one whenever the + configured slug happens not to match. + + This matters more than it looks. Certificate applications are served only from the organization's + active Certificate Manager project, so a second one would be created successfully and then be unable + to hold applications at all. An organization wants one, and the script's job is to populate it. + #> + param([String]$OrgId) + + Write-Step 'Project' + + $response = Invoke-InfisicalApi -Method 'GET' -Path '/api/v1/projects' + $all = Get-ApiCollection -InputObject $response -Name 'projects','workspaces' + $certManagerProjects = @($all | Where-Object { $_.type -eq 'cert-manager' }) + + if ($certManagerProjects.Count -gt 0) { + $existing = $certManagerProjects | Where-Object { $_.slug -eq $Configuration.Project.Slug } | Select-Object -First 1 + if ($Null -eq $existing) { $existing = $certManagerProjects[0] } + + Write-Step "project '$($existing.name)' ($($existing.id))" -Status 'Exists' + if ($existing.slug -ne $Configuration.Project.Slug) { + Write-Step "adopting it rather than creating '$($Configuration.Project.Slug)'; an organization serves applications from one Certificate Manager project" -Status 'Sub' + } + if ($certManagerProjects.Count -gt 1) { + Write-Step "note: this organization has $($certManagerProjects.Count) Certificate Manager projects; applications work only in the active one" -Status 'Sub' + } + + return $existing + } + + if (-not $PSCmdlet.ShouldProcess($Configuration.Project.Name, 'Create cert-manager project')) { + Write-Step "would create project '$($Configuration.Project.Name)' (this organization has none)" -Status 'WhatIf' + return $Null + } + + $body = @{ + projectName = $Configuration.Project.Name + slug = $Configuration.Project.Slug + type = 'cert-manager' + projectDescription = $Configuration.Project.Description + } + + $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v2/workspace' -Body $body + $project = Get-ApiObject -InputObject $created -Name 'project','workspace' + Write-Step "project '$($project.name)' ($($project.id))" -Status 'Created' + return $project + } + + function Get-SeededApplication { + <# + Applications are how the console groups profiles, members, and certificates, and how + Get-InfisicalCertificateProfile -ApplicationId scopes a lookup. Seeding profiles without one leaves + an environment the documented workflow cannot navigate. + #> + param([String]$ProjectId, $Profiles) + + Write-Step 'Certificate application' + + if ([String]::IsNullOrWhiteSpace($Configuration.Application.Name)) { + Write-Step 'no application configured; skipping' -Status 'Sub' + return $Null + } + + $profileIds = @($Configuration.Application.CertificateProfiles | + Where-Object { $Profiles.Contains($_) } | + ForEach-Object { $Profiles[$_].id }) + + $existing = $Null + if (-not [String]::IsNullOrWhiteSpace($ProjectId)) { + $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/applications?projectId=$ProjectId" + $existing = (Get-ApiCollection -InputObject $response -Name 'applications') | Where-Object { $_.name -eq $Configuration.Application.Name } | Select-Object -First 1 + } + + if ($Null -ne $existing) { + Write-Step "application '$($existing.name)' ($($existing.id))" -Status 'Exists' + Add-MissingApplicationProfiles -ProjectId $ProjectId -Application $existing -ProfileIds $profileIds + return $existing + } + + if ([String]::IsNullOrWhiteSpace($ProjectId) -or + -not $PSCmdlet.ShouldProcess($Configuration.Application.Name, 'Create certificate application')) { + Write-Step "would create application '$($Configuration.Application.Name)' with $($profileIds.Count) profile(s)" -Status 'WhatIf' + return $Null + } + + $body = [Ordered]@{ + projectId = $ProjectId + name = $Configuration.Application.Name + description = $Configuration.Application.Description + } + if ($profileIds.Count -gt 0) { $body.profileIds = $profileIds } + + $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/applications' -Body $body + $application = Get-ApiObject -InputObject $created -Name 'application' + Write-Step "application '$($application.name)' ($($application.id)) with $($profileIds.Count) profile(s)" -Status 'Created' + return $application + } + + function Add-MissingApplicationProfiles { + param([String]$ProjectId, $Application, $ProfileIds) + + if (@($ProfileIds).Count -eq 0) { return } + + $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/applications/$($Application.id)/profiles?projectId=$ProjectId" + # This route returns application-to-profile join rows, which carry the profile under profileId rather + # than as an object with an id of its own. + $attached = @((Get-ApiCollection -InputObject $response -Name 'profiles') | ForEach-Object { + $attachedId = Get-ApiProperty -InputObject $_ -Name 'profileId' + if ($Null -eq $attachedId) { $attachedId = Get-ApiProperty -InputObject $_ -Name 'id' } + $attachedId + } | Where-Object { $Null -ne $_ }) + $missing = @($ProfileIds | Where-Object { $attached -notcontains $_ }) + + if ($missing.Count -eq 0) { + Write-Step "all $(@($ProfileIds).Count) configured profile(s) already attached" -Status 'Sub' + return + } + + if (-not $PSCmdlet.ShouldProcess($Application.name, "Attach $($missing.Count) profile(s)")) { + Write-Step "would attach $($missing.Count) profile(s)" -Status 'WhatIf' + return + } + + $Null = Invoke-InfisicalApi -Method 'POST' -Path "/api/v1/cert-manager/applications/$($Application.id)/profiles" -Body @{ projectId = $ProjectId; profileIds = $missing } + Write-Step "attached $($missing.Count) profile(s)" -Status 'Sub' + } + + function Set-SeededApplicationEnrollment { + <# + Turns on each protocol a profile should answer, on its link to the application. This is the step + that makes a method visible in the UI and gives Infisical something to mint an endpoint from: + enabling SCEP creates the RA certificate and the pkiclient.exe and challenge URLs, and enabling + ACME creates the directory URL. Nothing here is exclusive, so a profile can serve several. + + PUT replaces the configuration for one protocol, which makes re-running safe: the same settings + produce the same result, and a protocol left out of the Enrollment block is simply never + touched. Removing one from the block therefore does not disable it - delete it in the UI, or + send DELETE to the same route. + #> + param([String]$ProjectId, $Application, $Profiles) + + if ($Null -eq $Application -or [String]::IsNullOrWhiteSpace($ProjectId)) { return } + + Write-Step 'Enrollment methods' + + foreach ($definition in $Configuration.CertificateProfiles) { + if (-not $Profiles.Contains($definition.Slug)) { continue } + if (-not $definition.Contains('Enrollment') -or $Null -eq $definition.Enrollment) { continue } + + $profileId = $Profiles[$definition.Slug].id + + foreach ($method in @('Api', 'Scep', 'Acme')) { + if (-not $definition.Enrollment.Contains($method)) { continue } + + $settings = $definition.Enrollment[$method] + $payload = ConvertTo-EnrollmentPayload -Method $method -Settings $settings + $route = $method.ToLowerInvariant() + + if (-not $PSCmdlet.ShouldProcess("$($definition.Slug) ($route)", 'Configure enrollment')) { + Write-Step "would enable $route on '$($definition.Slug)'" -Status 'WhatIf' + continue + } + + $payload.projectId = $ProjectId + $Null = Invoke-InfisicalApi -Method 'PUT' -Body $payload ` + -Path "/api/v1/cert-manager/applications/$($Application.id)/profiles/$profileId/enrollment/$route" + + $detail = switch ($method) { + 'Scep' { " ($($settings.ChallengeType) challenge)" } + 'Api' { if ($settings.AutoRenew) { " (auto-renew $($settings.RenewBeforeDays)d)" } else { ' (no auto-renew)' } } + default { '' } + } + Write-Step "$route on '$($definition.Slug)'$detail" -Status 'Created' + } + } + } + + function Get-SeededEnrollmentSummary { + <# + Reads back what each profile ended up answering, because the endpoint URLs and the SCEP RA + certificate are generated by Infisical and are not knowable from the configuration alone. + #> + param([String]$ProjectId, $Application, $Profiles) + + $summary = [Ordered]@{} + if ($Null -eq $Application -or [String]::IsNullOrWhiteSpace($ProjectId)) { return $summary } + + foreach ($slug in $Profiles.Keys) { + $profileId = $Profiles[$slug].id + try { + $summary[$slug] = Invoke-InfisicalApi -Method 'GET' ` + -Path "/api/v1/cert-manager/applications/$($Application.id)/profiles/$profileId/enrollment?projectId=$ProjectId" + } + catch { + Write-Verbose "Could not read enrollment for '$slug': $($_.Exception.Message)" + } + } + + return $summary + } + + function ConvertTo-EnrollmentPayload { + param([String]$Method, $Settings) + + $payload = [Ordered]@{} + switch ($Method) { + 'Api' { + $payload.autoRenew = [Bool]$Settings.AutoRenew + if ($payload.autoRenew -and $Null -ne $Settings.RenewBeforeDays) { + $payload.renewBeforeDays = $Settings.RenewBeforeDays + } + } + 'Scep' { + $challengeType = if ($Settings.Contains('ChallengeType')) { $Settings.ChallengeType } else { 'dynamic' } + $payload.challengeType = $challengeType + + # A static challenge is one shared secret; a dynamic one is minted per request instead. + if ($challengeType -eq 'static') { + $payload.challengePassword = Get-ScepChallengePassword + } + else { + if ($Settings.Contains('DynamicChallengeExpiryMinutes')) { $payload.dynamicChallengeExpiryMinutes = $Settings.DynamicChallengeExpiryMinutes } + if ($Settings.Contains('DynamicChallengeMaxPending')) { $payload.dynamicChallengeMaxPending = $Settings.DynamicChallengeMaxPending } + } + + if ($Settings.Contains('IncludeCaCertInResponse')) { $payload.includeCaCertInResponse = [Bool]$Settings.IncludeCaCertInResponse } + if ($Settings.Contains('AllowCertBasedRenewal')) { $payload.allowCertBasedRenewal = [Bool]$Settings.AllowCertBasedRenewal } + } + 'Acme' { + $payload.skipDnsOwnershipVerification = [Bool]$Settings.SkipDnsOwnershipVerification + $payload.skipEabBinding = [Bool]$Settings.SkipEabBinding + } + default { throw "Unsupported enrollment method '$Method'." } + } + + return $payload + } + + function Get-SeededCertificateAuthorities { + param([String]$ProjectId) + + Write-Step 'Certificate authorities' + $resolved = [Ordered]@{} + + $existingByName = @{} + if (-not [String]::IsNullOrWhiteSpace($ProjectId)) { + $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/ca/internal?projectId=$ProjectId" + foreach ($ca in (Get-ApiCollection -InputObject $response -Name 'certificateAuthorities','cas')) { $existingByName[$ca.name] = $ca } + } + + foreach ($definition in $Configuration.CertificateAuthorities) { + if ($existingByName.ContainsKey($definition.Name)) { + $existing = $existingByName[$definition.Name] + $resolved[$definition.Name] = $existing + Write-Step "CA '$($definition.Name)' ($($existing.id)) status=$($existing.status)" -Status 'Exists' + continue + } + + if ([String]::IsNullOrWhiteSpace($ProjectId) -or + -not $PSCmdlet.ShouldProcess($definition.Name, "Create $($definition.Type) CA")) { + Write-Step "would create $($definition.Type) CA '$($definition.Name)' ($($definition.KeyAlgorithm))" -Status 'WhatIf' + continue + } + + $notAfter = [DateTime]::UtcNow.AddYears($definition.ValidityYears).ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + + $caConfiguration = [Ordered]@{ + type = $definition.Type + commonName = $definition.CommonName + organization = $Configuration.Organization.Name + ou = $Configuration.Organization.OrganizationalUnit + country = $Configuration.Organization.Country + province = $Configuration.Organization.State + locality = $Configuration.Organization.Locality + keyAlgorithm = $definition.KeyAlgorithm + } + + # Infisical only self-signs on creation for a root, and only when it is given an expiry. A + # subordinate is created pending and signed in the step below. + if ($definition.Type -eq 'root') { + $caConfiguration.notAfter = $notAfter + $caConfiguration.maxPathLength = $definition.MaxPathLength + } + + $body = @{ + projectId = $ProjectId + name = $definition.Name + status = 'active' + configuration = $caConfiguration + } + + $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/ca/internal' -Body $body + $ca = Get-ApiObject -InputObject $created -Name 'certificateAuthority','ca' + $resolved[$definition.Name] = $ca + Write-Step "$($definition.Type) CA '$($definition.Name)' ($($ca.id))" -Status 'Created' + + if ($definition.Type -eq 'intermediate') { + Complete-SubordinateCertificateAuthority -Definition $definition -Subordinate $ca -Resolved $resolved -NotAfter $notAfter + } + } + + return $resolved + } + + function Complete-SubordinateCertificateAuthority { + <# + A subordinate is created with status pending-certificate and no certificate of its own. + Infisical exposes no endpoint that creates and signs in one call, so the CSR is fetched, signed + by the parent, and imported back. + #> + param($Definition, $Subordinate, $Resolved, [String]$NotAfter) + + if ([String]::IsNullOrWhiteSpace($Definition.Parent)) { + throw "Subordinate CA '$($Definition.Name)' has no Parent configured." + } + if (-not $Resolved.Contains($Definition.Parent)) { + throw "Subordinate CA '$($Definition.Name)' names parent '$($Definition.Parent)', which was not created or found." + } + + $parent = $Resolved[$Definition.Parent] + + Write-Step "signing '$($Definition.Name)' with '$($Definition.Parent)'" -Status 'Sub' + + $csrResponse = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/pki/ca/$($Subordinate.id)/csr" + if ([String]::IsNullOrWhiteSpace((Get-ApiProperty -InputObject $csrResponse -Name 'csr'))) { + throw "CA '$($Definition.Name)' returned no CSR to sign." + } + + $signBody = @{ + csr = Get-ApiProperty -InputObject $csrResponse -Name 'csr' + notAfter = $NotAfter + maxPathLength = $Definition.MaxPathLength + } + + $signed = Invoke-InfisicalApi -Method 'POST' -Path "/api/v1/pki/ca/$($parent.id)/sign-intermediate" -Body $signBody + + $importBody = @{ + certificate = Get-ApiProperty -InputObject $signed -Name 'certificate' + certificateChain = Get-ApiProperty -InputObject $signed -Name 'certificateChain' + } + + $Null = Invoke-InfisicalApi -Method 'POST' -Path "/api/v1/pki/ca/$($Subordinate.id)/import-certificate" -Body $importBody + Write-Step "'$($Definition.Name)' signed and activated" -Status 'Sub' + } + + function Get-SeededCertificatePolicies { + param([String]$ProjectId) + + Write-Step 'Certificate policies' + $resolved = [Ordered]@{} + + $existingByName = @{} + if (-not [String]::IsNullOrWhiteSpace($ProjectId)) { + $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/certificate-policies?projectId=$ProjectId" + foreach ($policy in (Get-ApiCollection -InputObject $response -Name 'certificatePolicies','policies')) { $existingByName[$policy.name] = $policy } + } + + foreach ($definition in $Configuration.CertificatePolicies) { + if ($existingByName.ContainsKey($definition.Name)) { + $existing = $existingByName[$definition.Name] + $resolved[$definition.Name] = $existing + Write-Step "policy '$($definition.Name)' ($($existing.id))" -Status 'Exists' + continue + } + + if ([String]::IsNullOrWhiteSpace($ProjectId) -or + -not $PSCmdlet.ShouldProcess($definition.Name, 'Create certificate policy')) { + Write-Step "would create policy '$($definition.Name)'" -Status 'WhatIf' + continue + } + + $body = [Ordered]@{ + projectId = $ProjectId + name = $definition.Name + description = $definition.Description + validity = @{ max = $definition.MaxValidity } + algorithms = @{ + keyAlgorithm = @($definition.KeyAlgorithms) + signature = @($definition.SignatureAlgorithms) + } + } + + $subject = @(ConvertTo-PolicyConstraintList -Definitions $definition.Subject) + if ($subject.Count -gt 0) { $body.subject = $subject } + + $sans = @(ConvertTo-PolicyConstraintList -Definitions $definition.SubjectAltNames) + if ($sans.Count -gt 0) { $body.sans = $sans } + + $keyUsages = ConvertTo-PolicyConstraint -Definition $definition.KeyUsages + if ($Null -ne $keyUsages) { $body.keyUsages = $keyUsages } + + $extendedKeyUsages = ConvertTo-PolicyConstraint -Definition $definition.ExtendedKeyUsages + if ($Null -ne $extendedKeyUsages) { $body.extendedKeyUsages = $extendedKeyUsages } + + $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/certificate-policies' -Body $body + $policy = Get-ApiObject -InputObject $created -Name 'certificatePolicy' + $resolved[$definition.Name] = $policy + Write-Step "policy '$($definition.Name)' ($($policy.id))" -Status 'Created' + } + + return $resolved + } + + function ConvertTo-PolicyConstraint { + <# + Infisical rejects a constraint object that carries none of allowed/required/denied, so an empty + definition becomes null rather than an empty object. + #> + param($Definition) + + if ($Null -eq $Definition) { return $Null } + + $result = [Ordered]@{} + foreach ($key in @('Allowed', 'Required', 'Denied')) { + if (-not $Definition.Contains($key)) { continue } + $values = @($Definition[$key]) + if ($values.Count -eq 0) { continue } + $result[$key.ToLowerInvariant()] = $values + } + + if ($result.Count -eq 0) { return $Null } + return $result + } + + function Get-ScepChallengePassword { + <# + Returns the shared secret a static SCEP profile enrols against. One value is used for the whole + run and reported once at the end, since a caller that was not given the password cannot enrol + with it. Supply -ScepChallengePassword to set a known value instead; a generated one exists only + in this run's output, and cannot be read back out of Infisical afterwards. + #> + if ($Null -ne $Script:ScepChallenge) { return $Script:ScepChallenge } + + if ($PSBoundParameters.ContainsKey('ScepChallengePassword')) { + $Script:ScepChallenge = ConvertFrom-SecureStringToPlainText -SecureString $ScepChallengePassword + return $Script:ScepChallenge + } + + $bytes = [Byte[]]::new(24) + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + + # Base64 minus the characters that get mangled in SCEP client configuration fields. + $Script:ScepChallenge = ([Convert]::ToBase64String($bytes) -replace '[+/=]', '').Substring(0, 24) + $Script:ScepChallengeGenerated = $True + return $Script:ScepChallenge + } + + function ConvertTo-ProfileDefaults { + <# + Profile defaults are flat: scalars for the algorithms and plain string arrays for the usages, + unlike the allowed/required/denied objects a policy takes. Keys are camelCased to match the API. + #> + param($Definition) + + if ($Null -eq $Definition) { return $Null } + + $result = [Ordered]@{} + foreach ($key in @('KeyAlgorithm', 'SignatureAlgorithm')) { + if (-not $Definition.Contains($key)) { continue } + if ([String]::IsNullOrWhiteSpace($Definition[$key])) { continue } + $result[$key.Substring(0, 1).ToLowerInvariant() + $key.Substring(1)] = $Definition[$key] + } + foreach ($key in @('KeyUsages', 'ExtendedKeyUsages')) { + if (-not $Definition.Contains($key)) { continue } + $values = @($Definition[$key]) + if ($values.Count -eq 0) { continue } + $result[$key.Substring(0, 1).ToLowerInvariant() + $key.Substring(1)] = $values + } + + if ($result.Count -eq 0) { return $Null } + return $result + } + + function ConvertTo-PolicyConstraintList { + param($Definitions) + + $list = @() + foreach ($definition in @($Definitions)) { + if ($Null -eq $definition) { continue } + $constraint = ConvertTo-PolicyConstraint -Definition $definition + if ($Null -eq $constraint) { continue } + $entry = [Ordered]@{ type = $definition.Type } + foreach ($pair in $constraint.GetEnumerator()) { $entry[$pair.Key] = $pair.Value } + $list += $entry + } + + return $list + } + + function Get-SeededCertificateProfiles { + param([String]$ProjectId, $Authorities, $Policies) + + Write-Step 'Certificate profiles' + $resolved = [Ordered]@{} + + $existingBySlug = @{} + if (-not [String]::IsNullOrWhiteSpace($ProjectId)) { + $response = Invoke-InfisicalApi -Method 'GET' -Path "/api/v1/cert-manager/certificate-profiles?projectId=$ProjectId" + foreach ($existingProfile in (Get-ApiCollection -InputObject $response -Name 'certificateProfiles','profiles')) { $existingBySlug[$existingProfile.slug] = $existingProfile } + } + + foreach ($definition in $Configuration.CertificateProfiles) { + if ($existingBySlug.ContainsKey($definition.Slug)) { + $existing = $existingBySlug[$definition.Slug] + $resolved[$definition.Slug] = $existing + $existingType = Get-ApiProperty -InputObject $existing -Name 'enrollmentType' + Write-Step "profile '$($definition.Slug)' ($($existing.id)) enrollment=$existingType" -Status 'Exists' + continue + } + + $haveDependencies = $Authorities.Contains($definition.CertificateAuthority) -and + $Policies.Contains($definition.CertificatePolicy) + + if ([String]::IsNullOrWhiteSpace($ProjectId) -or -not $haveDependencies -or + -not $PSCmdlet.ShouldProcess($definition.Slug, 'Create certificate profile')) { + Write-Step "would create profile '$($definition.Slug)' (CA '$($definition.CertificateAuthority)', policy '$($definition.CertificatePolicy)')" -Status 'WhatIf' + continue + } + + <# + Always api at the profile level. The profile may only name one enrollment type, and the + protocols a client actually reaches are enabled per application-profile link further down, + where they are not mutually exclusive. + #> + $apiEnrollment = if ($definition.Enrollment.Contains('Api')) { $definition.Enrollment.Api } else { [Ordered]@{} } + $apiConfig = [Ordered]@{ autoRenew = [Bool]$apiEnrollment.AutoRenew } + if ($apiConfig.autoRenew -and $Null -ne $apiEnrollment.RenewBeforeDays) { + # The profile-level cap is 30 days, where the application-level one allows up to 365. + $apiConfig.renewBeforeDays = [Math]::Min([Int]$apiEnrollment.RenewBeforeDays, 30) + } + + $body = [Ordered]@{ + projectId = $ProjectId + slug = $definition.Slug + description = $definition.Description + caId = $Authorities[$definition.CertificateAuthority].id + certificatePolicyId = $Policies[$definition.CertificatePolicy].id + enrollmentType = 'api' + issuerType = 'ca' + apiConfig = $apiConfig + } + + $defaults = ConvertTo-ProfileDefaults -Definition $definition.Defaults + if ($Null -ne $defaults) { $body.defaults = $defaults } + + $created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/certificate-profiles' -Body $body + $createdProfile = Get-ApiObject -InputObject $created -Name 'certificateProfile' + $resolved[$definition.Slug] = $createdProfile + Write-Step "profile '$($definition.Slug)' ($($createdProfile.id))" -Status 'Created' + } + + return $resolved + } +#endregion + +#region Main + try { + Connect-InfisicalApi + $resolvedOrganizationId = Resolve-OrganizationId + + $project = Get-SeededProject -OrgId $resolvedOrganizationId + $projectId = if ($Null -ne $project) { $project.id } else { $Null } + + $authorities = Get-SeededCertificateAuthorities -ProjectId $projectId + $policies = Get-SeededCertificatePolicies -ProjectId $projectId + $profiles = Get-SeededCertificateProfiles -ProjectId $projectId -Authorities $authorities -Policies $policies + $application = Get-SeededApplication -ProjectId $projectId -Profiles $profiles + Set-SeededApplicationEnrollment -ProjectId $projectId -Application $application -Profiles $profiles + $enrollment = Get-SeededEnrollmentSummary -ProjectId $projectId -Application $application -Profiles $profiles + + Write-Host '' + Write-Step 'Done.' + + if ($Null -ne $projectId -and $profiles.Count -gt 0) { + $firstProfileSlug = @($Configuration.CertificateProfiles)[0].Slug + Write-Host '' + Write-Host 'Request a certificate against a seeded profile:' + Write-Host '' + $connectHint = if ($PSCmdlet.ParameterSetName -eq 'Token') { + " Connect-Infisical -BaseUri '$($Script:ApiRoot)' -AccessToken `$Token" + } else { + " Connect-Infisical -BaseUri '$($Script:ApiRoot)' -ClientId '$ClientId' -ClientSecret `$Secret" + } + Write-Host $connectHint + Write-Host " `$Application = Get-InfisicalCertificateApplication | Where-Object {(`$_.Name -ieq '$($Configuration.Application.Name)')}" + Write-Host " `$CertificateProfile = Get-InfisicalCertificateProfile -ApplicationId `$Application.Id | Where-Object {(`$_.Slug -ieq '$firstProfileSlug')}" + Write-Host " Request-InfisicalCertificate -CertificateProfileId `$CertificateProfile.Id ``" + Write-Host " -CommonName `$Env:ComputerName.ToUpper() -DnsName (Get-InfisicalSANList) -Install -InstallChain" + } + + # Minted by Infisical when a protocol is enabled, so they can only be reported by reading them back. + foreach ($slug in $enrollment.Keys) { + $entry = $enrollment[$slug] + $scep = Get-ApiProperty -InputObject $entry -Name 'scep' + $acme = Get-ApiProperty -InputObject $entry -Name 'acme' + if ($Null -eq $scep -and $Null -eq $acme) { continue } + + Write-Host '' + Write-Host "Enrollment endpoints for '$slug':" + if ($Null -ne $scep) { + Write-Host " SCEP $(Get-ApiProperty -InputObject $scep -Name 'scepEndpointUrl')" + Write-Host " challenge $(Get-ApiProperty -InputObject $scep -Name 'challengeEndpointUrl')" + } + if ($Null -ne $acme) { + Write-Host " ACME $(Get-ApiProperty -InputObject $acme -Name 'directoryUrl')" + } + } + + <# + Reported here because Infisical will not hand the challenge back afterwards: a static SCEP + profile is unusable to anyone who did not capture this value. Re-running the script does not + reissue it, since an existing profile is adopted rather than recreated. + #> + if ($Script:ScepChallengeGenerated) { + Write-Host '' + Write-Host 'Generated SCEP challenge password (record it now, it cannot be read back):' + Write-Host '' + Write-Host " $($Script:ScepChallenge)" + Write-Host '' + Write-Host ' Pass -ScepChallengePassword next time to set a known value instead.' + } + + [PSCustomObject]@{ + BaseUri = $Script:ApiRoot + OrganizationId = $resolvedOrganizationId + ProjectId = $projectId + CertificateAuthorities = $authorities + CertificatePolicies = $policies + CertificateProfiles = $profiles + Application = $application + Enrollment = $enrollment + ScepChallengePassword = $Script:ScepChallenge + } + } + finally { + $Script:BearerToken = $Null + } +#endregion diff --git a/build.ps1 b/build.ps1 index d0b2edb..2b5e436 100644 --- a/build.ps1 +++ b/build.ps1 @@ -156,7 +156,22 @@ function Write-Manifest { 'Write-InfisicalScepMdmProfileToWmi', 'Start-InfisicalProcess', 'Get-InfisicalEnvironmentVariable', - 'Get-InfisicalSANList' + 'Get-InfisicalSANList', + 'New-InfisicalCertificateAuthority', + 'Set-InfisicalCertificateAuthority', + 'Remove-InfisicalCertificateAuthority', + 'New-InfisicalCertificatePolicy', + 'Set-InfisicalCertificatePolicy', + 'Remove-InfisicalCertificatePolicy', + 'New-InfisicalCertificateProfile', + 'Set-InfisicalCertificateProfile', + 'Remove-InfisicalCertificateProfile', + 'New-InfisicalCertificateApplication', + 'Set-InfisicalCertificateApplication', + 'Remove-InfisicalCertificateApplication', + 'New-InfisicalPkiSubscriber', + 'Set-InfisicalPkiSubscriber', + 'Remove-InfisicalPkiSubscriber' ) AliasesToExport = @() VariablesToExport = @() @@ -178,6 +193,15 @@ function Write-Manifest { } function Update-Changelog { + <# + Promotes whatever sits under "## Unreleased" into a section for this version, leaving a fresh empty + "## Unreleased" above it. + + The release workflow builds its release body by extracting the section whose heading matches the version + it just produced. Inserting the version heading above the notes - as this previously did - left that + section containing only the build line while the actual notes stayed under "Unreleased", so every + release published an empty changelog. + #> param([string]$Version, [string]$CommitHash) if (-not $ChangelogFile.Exists) { return } @@ -185,11 +209,31 @@ function Update-Changelog { $existing = Get-Content -LiteralPath $ChangelogFile.FullName -Raw if ($existing -match [Regex]::Escape($marker)) { return } - $insertion = "## $Version`r`n`r`n- Build produced from commit $CommitHash.`r`n`r`n" - $unreleasedRegex = [regex]::new('(?m)^## Unreleased\r?$') - if (-not $unreleasedRegex.IsMatch($existing)) { return } - $updated = $unreleasedRegex.Replace($existing, "## Unreleased`r`n`r`n$insertion## Unreleased (carried forward)", 1) - [System.IO.File]::WriteAllText($ChangelogFile.FullName, $updated, [System.Text.UTF8Encoding]::new($false)) + $unreleasedRegex = [regex]::new('(?m)^## Unreleased[^\r\n]*\r?$') + $unreleasedMatch = $unreleasedRegex.Match($existing) + if (-not $unreleasedMatch.Success) { return } + + # Everything from just after the Unreleased heading to the next "## " heading is this version's notes. + $bodyStart = $unreleasedMatch.Index + $unreleasedMatch.Length + $nextHeading = [regex]::new('(?m)^## ').Match($existing, $bodyStart) + $bodyEnd = if ($nextHeading.Success) { $nextHeading.Index } else { $existing.Length } + + $notes = $existing.Substring($bodyStart, $bodyEnd - $bodyStart).Trim() + + # Italicised and last so it reads as provenance rather than as another entry in whichever section the + # notes happened to end on. + $buildLine = "_Build produced from commit $CommitHash._" + $versionBody = if ([string]::IsNullOrWhiteSpace($notes)) { $buildLine } else { "$notes`r`n`r`n$buildLine" } + + $rebuilt = New-Object System.Text.StringBuilder + [void]$rebuilt.Append($existing.Substring(0, $unreleasedMatch.Index)) + [void]$rebuilt.Append("## Unreleased`r`n`r`n") + [void]$rebuilt.Append("## $Version`r`n`r`n") + [void]$rebuilt.Append($versionBody) + [void]$rebuilt.Append("`r`n`r`n") + [void]$rebuilt.Append($existing.Substring($bodyEnd)) + + [System.IO.File]::WriteAllText($ChangelogFile.FullName, $rebuilt.ToString(), [System.Text.UTF8Encoding]::new($false)) } @@ -221,7 +265,7 @@ if (`$cmds.Count -eq 0) { throw "No cmdlets were exported by the PSInfisicalAPI module." } -`$expectedCmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecret','New-InfisicalSecret','Update-InfisicalSecret','Remove-InfisicalSecret','Copy-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets','Import-InfisicalSecret','Get-InfisicalProject','New-InfisicalProject','Update-InfisicalProject','Remove-InfisicalProject','Get-InfisicalEnvironment','New-InfisicalEnvironment','Update-InfisicalEnvironment','Remove-InfisicalEnvironment','Get-InfisicalFolder','New-InfisicalFolder','Update-InfisicalFolder','Remove-InfisicalFolder','Get-InfisicalTag','New-InfisicalTag','Update-InfisicalTag','Remove-InfisicalTag','Get-InfisicalOrganization','New-InfisicalOrganization','Update-InfisicalOrganization','Remove-InfisicalOrganization','Get-InfisicalSubOrganization','New-InfisicalSubOrganization','Update-InfisicalSubOrganization','Remove-InfisicalSubOrganization','Get-InfisicalCertificateAuthority','Get-InfisicalPkiSubscriber','Get-InfisicalCertificateProfile','Get-InfisicalCertificatePolicy','Get-InfisicalCertificate','Request-InfisicalCertificate','ConvertTo-InfisicalCertificate','Install-InfisicalCertificate','Uninstall-InfisicalCertificate','Export-InfisicalCertificate','Get-InfisicalCertificateApplication','Get-InfisicalCertificateApplicationEnrollment','New-InfisicalScepDynamicChallenge','Get-InfisicalScepMdmProfile','Export-InfisicalScepMdmProfile','Write-InfisicalScepMdmProfileToWmi','Start-InfisicalProcess','Get-InfisicalEnvironmentVariable','Get-InfisicalSANList') +`$expectedCmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecret','New-InfisicalSecret','Update-InfisicalSecret','Remove-InfisicalSecret','Copy-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets','Import-InfisicalSecret','Get-InfisicalProject','New-InfisicalProject','Update-InfisicalProject','Remove-InfisicalProject','Get-InfisicalEnvironment','New-InfisicalEnvironment','Update-InfisicalEnvironment','Remove-InfisicalEnvironment','Get-InfisicalFolder','New-InfisicalFolder','Update-InfisicalFolder','Remove-InfisicalFolder','Get-InfisicalTag','New-InfisicalTag','Update-InfisicalTag','Remove-InfisicalTag','Get-InfisicalOrganization','New-InfisicalOrganization','Update-InfisicalOrganization','Remove-InfisicalOrganization','Get-InfisicalSubOrganization','New-InfisicalSubOrganization','Update-InfisicalSubOrganization','Remove-InfisicalSubOrganization','Get-InfisicalCertificateAuthority','Get-InfisicalPkiSubscriber','Get-InfisicalCertificateProfile','Get-InfisicalCertificatePolicy','Get-InfisicalCertificate','Request-InfisicalCertificate','ConvertTo-InfisicalCertificate','Install-InfisicalCertificate','Uninstall-InfisicalCertificate','Export-InfisicalCertificate','Get-InfisicalCertificateApplication','Get-InfisicalCertificateApplicationEnrollment','New-InfisicalScepDynamicChallenge','Get-InfisicalScepMdmProfile','Export-InfisicalScepMdmProfile','Write-InfisicalScepMdmProfileToWmi','Start-InfisicalProcess','Get-InfisicalEnvironmentVariable','Get-InfisicalSANList','New-InfisicalCertificateAuthority','Set-InfisicalCertificateAuthority','Remove-InfisicalCertificateAuthority','New-InfisicalCertificatePolicy','Set-InfisicalCertificatePolicy','Remove-InfisicalCertificatePolicy','New-InfisicalCertificateProfile','Set-InfisicalCertificateProfile','Remove-InfisicalCertificateProfile','New-InfisicalCertificateApplication','Set-InfisicalCertificateApplication','Remove-InfisicalCertificateApplication','New-InfisicalPkiSubscriber','Set-InfisicalPkiSubscriber','Remove-InfisicalPkiSubscriber') foreach (`$expected in `$expectedCmds) { if (-not (Get-Command -Name `$expected -Module PSInfisicalAPI -ErrorAction SilentlyContinue)) { throw "Cmdlet not found: `$expected" diff --git a/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs b/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs index f45456b..7235131 100644 --- a/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs +++ b/src/PSInfisicalAPI.Tests/EndpointRegistryTests.cs @@ -50,7 +50,9 @@ namespace PSInfisicalAPI.Tests [InlineData(InfisicalEndpointNames.CreateSecret, "POST", "/api/v3/secrets/raw/{secretName}")] [InlineData(InfisicalEndpointNames.UpdateSecret, "PATCH", "/api/v3/secrets/raw/{secretName}")] [InlineData(InfisicalEndpointNames.DeleteSecret, "DELETE", "/api/v3/secrets/raw/{secretName}")] - [InlineData(InfisicalEndpointNames.ListProjects, "GET", "/api/v1/workspace")] + // /api/v1/workspace mounts Infisical's deprecated project router; /api/v1/projects is the current one + // and is preferred, with the deprecated route retained as a fallback candidate. + [InlineData(InfisicalEndpointNames.ListProjects, "GET", "/api/v1/projects")] [InlineData(InfisicalEndpointNames.RetrieveProject, "GET", "/api/v1/workspace/{projectId}")] [InlineData(InfisicalEndpointNames.CreateProject, "POST", "/api/v2/workspace")] [InlineData(InfisicalEndpointNames.UpdateProject, "PATCH", "/api/v1/workspace/{projectId}")] @@ -75,7 +77,9 @@ namespace PSInfisicalAPI.Tests [InlineData(InfisicalEndpointNames.BulkUpdateSecret, "PATCH", "/api/v4/secrets/batch")] [InlineData(InfisicalEndpointNames.BulkDeleteSecret, "DELETE", "/api/v4/secrets/batch")] [InlineData(InfisicalEndpointNames.DuplicateSecret, "POST", "/api/v4/secrets/duplicate")] - [InlineData(InfisicalEndpointNames.ListOrganizations, "GET", "/api/v2/organizations")] + // /api/v2/organizations has no GET / route, only /:organizationId/* sub-routes, so the listing must + // prefer the singular v1 route; the v2 one is retained only as a fallback candidate. + [InlineData(InfisicalEndpointNames.ListOrganizations, "GET", "/api/v1/organization")] [InlineData(InfisicalEndpointNames.RetrieveOrganization, "GET", "/api/v1/organization/{organizationId}")] [InlineData(InfisicalEndpointNames.CreateOrganization, "POST", "/api/v2/organizations")] [InlineData(InfisicalEndpointNames.UpdateOrganization, "PATCH", "/api/v1/organization/{organizationId}")] diff --git a/src/PSInfisicalAPI.Tests/PkiWriteCmdletTests.cs b/src/PSInfisicalAPI.Tests/PkiWriteCmdletTests.cs new file mode 100644 index 0000000..c96f773 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/PkiWriteCmdletTests.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Management.Automation; +using System.Reflection; +using Newtonsoft.Json; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + /// + /// The create/update/delete cmdlets for Certificate Manager configuration. Their bodies are built from + /// caller-supplied dictionaries, so the conversion into the exact JSON Infisical's schemas accept is what + /// these pin. + /// + public class PkiWriteCmdletTests + { + private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly; + + private static readonly string[] WriteCmdletTypes = new[] + { + "NewInfisicalCertificateAuthorityCmdlet", "SetInfisicalCertificateAuthorityCmdlet", "RemoveInfisicalCertificateAuthorityCmdlet", + "NewInfisicalCertificatePolicyCmdlet", "SetInfisicalCertificatePolicyCmdlet", "RemoveInfisicalCertificatePolicyCmdlet", + "NewInfisicalCertificateProfileCmdlet", "SetInfisicalCertificateProfileCmdlet", "RemoveInfisicalCertificateProfileCmdlet", + "NewInfisicalCertificateApplicationCmdlet", "SetInfisicalCertificateApplicationCmdlet", "RemoveInfisicalCertificateApplicationCmdlet", + "NewInfisicalPkiSubscriberCmdlet", "SetInfisicalPkiSubscriberCmdlet", "RemoveInfisicalPkiSubscriberCmdlet" + }; + + private static Dictionary ToJsonObject(IDictionary source) + { + Type baseType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.InfisicalPkiWriteCmdletBase", true); + MethodInfo method = baseType.GetMethod("ToJsonObject", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + return (Dictionary)method.Invoke(null, new object[] { source }); + } + + private static List> ToJsonObjectList(IEnumerable source) + { + Type baseType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.InfisicalPkiWriteCmdletBase", true); + MethodInfo method = baseType.GetMethod("ToJsonObjectList", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + return (List>)method.Invoke(null, new object[] { source }); + } + + [Fact] + public void Every_Write_Cmdlet_Declares_ShouldProcess() + { + List offenders = new List(); + + foreach (string typeName in WriteCmdletTypes) + { + Type type = ModuleAssembly.GetType(string.Concat("PSInfisicalAPI.Cmdlets.", typeName), true); + + bool supportsShouldProcess = false; + foreach (CustomAttributeData attribute in type.GetCustomAttributesData()) + { + if (attribute.AttributeType != typeof(CmdletAttribute)) { continue; } + foreach (CustomAttributeNamedArgument named in attribute.NamedArguments) + { + if (named.MemberName == "SupportsShouldProcess" && (bool)named.TypedValue.Value) { supportsShouldProcess = true; } + } + } + + if (!supportsShouldProcess) { offenders.Add(typeName); } + } + + Assert.Empty(offenders); + } + + [Fact] + public void Every_Remove_Cmdlet_Defaults_To_High_Confirm_Impact() + { + List offenders = new List(); + + foreach (string typeName in WriteCmdletTypes) + { + if (!typeName.StartsWith("Remove", StringComparison.Ordinal)) { continue; } + + Type type = ModuleAssembly.GetType(string.Concat("PSInfisicalAPI.Cmdlets.", typeName), true); + + bool high = false; + foreach (CustomAttributeData attribute in type.GetCustomAttributesData()) + { + if (attribute.AttributeType != typeof(CmdletAttribute)) { continue; } + foreach (CustomAttributeNamedArgument named in attribute.NamedArguments) + { + if (named.MemberName == "ConfirmImpact" && (ConfirmImpact)named.TypedValue.Value == ConfirmImpact.High) { high = true; } + } + } + + if (!high) { offenders.Add(typeName); } + } + + Assert.Empty(offenders); + } + + [Fact] + public void Every_Write_Cmdlet_Resolves_The_Project_Instead_Of_Requiring_It() + { + List offenders = new List(); + + foreach (string typeName in WriteCmdletTypes) + { + Type type = ModuleAssembly.GetType(string.Concat("PSInfisicalAPI.Cmdlets.", typeName), true); + PropertyInfo projectId = type.GetProperty("ProjectId"); + Assert.True(projectId != null, string.Concat(typeName, " has no ProjectId property")); + + foreach (CustomAttributeData attribute in projectId.GetCustomAttributesData()) + { + if (attribute.AttributeType != typeof(ParameterAttribute)) { continue; } + foreach (CustomAttributeNamedArgument named in attribute.NamedArguments) + { + if (named.MemberName == "Mandatory" && (bool)named.TypedValue.Value) { offenders.Add(typeName); } + } + } + } + + Assert.Empty(offenders); + } + + [Theory] + // PowerShell callers capitalise hashtable keys; Infisical's schema is lower case. + [InlineData("Required", "required")] + [InlineData("required", "required")] + [InlineData("Allowed", "allowed")] + [InlineData("DENIED", "denied")] + public void Constraint_Keys_Are_Emitted_In_The_Casing_The_Api_Requires(string supplied, string expected) + { + Hashtable source = new Hashtable { { supplied, new[] { "server_auth" } } }; + Dictionary result = ToJsonObject(source); + + Assert.True(result.ContainsKey(expected), string.Concat("expected key '", expected, "' but got: ", JsonConvert.SerializeObject(result))); + } + + [Fact] + public void Non_Constraint_Keys_Keep_Their_Camel_Case() + { + // Field names elsewhere in the body are camelCase and supplied verbatim; lowercasing them would + // silently drop settings the API would no longer recognise. + Hashtable source = new Hashtable { { "ttlDays", 90 }, { "keyAlgorithm", "RSA_2048" }, { "isCA", "denied" }, { "maxPathLength", 0 } }; + Dictionary result = ToJsonObject(source); + + Assert.True(result.ContainsKey("ttlDays")); + Assert.True(result.ContainsKey("keyAlgorithm")); + Assert.True(result.ContainsKey("isCA")); + Assert.True(result.ContainsKey("maxPathLength")); + } + + [Fact] + public void An_Empty_Collection_Is_Dropped_Rather_Than_Sent_As_Allow_Nothing() + { + Hashtable source = new Hashtable { { "Allowed", new string[0] }, { "Required", new[] { "server_auth" } } }; + Dictionary result = ToJsonObject(source); + + Assert.False(result.ContainsKey("allowed")); + Assert.True(result.ContainsKey("required")); + } + + [Fact] + public void A_Wholly_Empty_Constraint_Becomes_Null_So_It_Is_Omitted() + { + Assert.Null(ToJsonObject(new Hashtable())); + Assert.Null(ToJsonObject(new Hashtable { { "Allowed", new string[0] } })); + Assert.Null(ToJsonObject(null)); + } + + [Fact] + public void A_Constraint_List_Drops_Entries_That_Constrain_Nothing() + { + // An entry carrying only "type" is rejected by Infisical's refinement, and is what a caller writes + // when they meant to leave that dimension alone. + List source = new List + { + new Hashtable { { "Type", "dns_name" }, { "Allowed", new[] { "*.contoso.com" } } }, + new Hashtable { { "Type", "ip_address" }, { "Allowed", new string[0] } } + }; + + List> result = ToJsonObjectList(source); + + Dictionary only = Assert.Single(result); + Assert.Equal("dns_name", only["type"]); + Assert.True(only.ContainsKey("allowed")); + } + + [Fact] + public void Nested_Dictionaries_Survive_Conversion() + { + Hashtable source = new Hashtable + { + { "outer", new Hashtable { { "inner", new Hashtable { { "Required", new[] { "a" } } } } } } + }; + + Dictionary result = ToJsonObject(source); + string json = JsonConvert.SerializeObject(result); + + Assert.Contains("\"outer\"", json); + Assert.Contains("\"inner\"", json); + Assert.Contains("\"required\"", json); + } + + [Fact] + public void Profile_Enrollment_Config_Is_Routed_To_The_Block_For_Its_Type() + { + Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.NewInfisicalCertificateProfileCmdlet", true); + MethodInfo build = cmdletType.GetMethod("BuildRequest", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(build); + + Hashtable config = new Hashtable { { "passphrase", "secret" } }; + + object estRequest = build.Invoke(null, new object[] { "slug", null, "ca", "policy", "est", "ca", false, null, null, config, false }); + Assert.NotNull(estRequest.GetType().GetProperty("EstConfig").GetValue(estRequest)); + Assert.Null(estRequest.GetType().GetProperty("ApiConfig").GetValue(estRequest)); + + object scepRequest = build.Invoke(null, new object[] { "slug", null, "ca", "policy", "scep", "ca", false, null, null, config, false }); + Assert.NotNull(scepRequest.GetType().GetProperty("ScepConfig").GetValue(scepRequest)); + + object apiRequest = build.Invoke(null, new object[] { "slug", null, "ca", "policy", "api", "ca", true, 14, null, null, true }); + object apiConfig = apiRequest.GetType().GetProperty("ApiConfig").GetValue(apiRequest); + Assert.NotNull(apiConfig); + + Dictionary typed = (Dictionary)apiConfig; + Assert.Equal(true, typed["autoRenew"]); + Assert.Equal(14, typed["renewBeforeDays"]); + } + + [Fact] + public void Auto_Renew_Is_Only_Sent_When_The_Caller_Asked_For_It() + { + // A switch parameter is always false when absent, so sending it unconditionally would silently + // disable renewal on an update that never mentioned it. + Type cmdletType = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.NewInfisicalCertificateProfileCmdlet", true); + MethodInfo build = cmdletType.GetMethod("BuildRequest", BindingFlags.NonPublic | BindingFlags.Static); + + object unbound = build.Invoke(null, new object[] { "slug", null, "ca", "policy", "api", "ca", false, null, null, null, false }); + Assert.Null(unbound.GetType().GetProperty("ApiConfig").GetValue(unbound)); + + object bound = build.Invoke(null, new object[] { "slug", null, "ca", "policy", "api", "ca", false, null, null, null, true }); + Dictionary config = (Dictionary)bound.GetType().GetProperty("ApiConfig").GetValue(bound); + Assert.NotNull(config); + Assert.Equal(false, config["autoRenew"]); + } + + [Fact] + public void Every_Management_Endpoint_Is_Registered() + { + string[] names = new[] + { + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.CreateInternalCertificateAuthority, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.UpdateInternalCertificateAuthority, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.DeleteInternalCertificateAuthority, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.GetCertificateAuthorityCsr, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.SignIntermediateCertificateAuthority, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.ImportCertificateAuthorityCertificate, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.CreateCertificatePolicy, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.UpdateCertificatePolicy, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.DeleteCertificatePolicy, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.CreateCertificateProfile, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.UpdateCertificateProfile, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.DeleteCertificateProfile, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.CreateCertificateApplication, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.UpdateCertificateApplication, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.DeleteCertificateApplication, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.AddCertificateApplicationProfiles, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.RemoveCertificateApplicationProfile, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.CreatePkiSubscriber, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.UpdatePkiSubscriber, + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.DeletePkiSubscriber + }; + + foreach (string name in names) + { + IReadOnlyList candidates = + PSInfisicalAPI.Endpoints.InfisicalEndpointRegistry.GetCandidates(name); + + Assert.True(candidates.Count > 0, string.Concat(name, " is not registered")); + Assert.All(candidates, c => Assert.True(c.RequiresAuthorization, string.Concat(name, " should require authorization"))); + } + } + } +} diff --git a/src/PSInfisicalAPI.Tests/ProjectScopingTests.cs b/src/PSInfisicalAPI.Tests/ProjectScopingTests.cs new file mode 100644 index 0000000..979e889 --- /dev/null +++ b/src/PSInfisicalAPI.Tests/ProjectScopingTests.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Reflection; +using System.Security.Cryptography.X509Certificates; +using Xunit; + +namespace PSInfisicalAPI.Tests +{ + /// + /// The Infisical console never asks which Certificate Manager project to use when an organization has one, + /// because its resolver selects it implicitly. Requiring -ProjectId made the module stricter than the + /// service it wraps, so these pin the parameter as optional across the PKI surface. + /// + public class ProjectScopingTests + { + private static readonly Assembly ModuleAssembly = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly; + + private static readonly string[] PkiCmdletTypes = new[] + { + "PSInfisicalAPI.Cmdlets.GetInfisicalCertificateApplicationCmdlet", + "PSInfisicalAPI.Cmdlets.GetInfisicalCertificateApplicationEnrollmentCmdlet", + "PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet", + "PSInfisicalAPI.Cmdlets.GetInfisicalCertificateCmdlet", + "PSInfisicalAPI.Cmdlets.GetInfisicalCertificatePolicyCmdlet", + "PSInfisicalAPI.Cmdlets.GetInfisicalCertificateProfileCmdlet", + "PSInfisicalAPI.Cmdlets.GetInfisicalPkiSubscriberCmdlet", + "PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet" + }; + + [Fact] + public void ProjectId_Is_Optional_On_Every_Pki_Cmdlet() + { + List offenders = new List(); + + foreach (string typeName in PkiCmdletTypes) + { + Type type = ModuleAssembly.GetType(typeName, true); + PropertyInfo projectId = type.GetProperty("ProjectId"); + Assert.True(projectId != null, string.Concat(typeName, " has no ProjectId property")); + + foreach (CustomAttributeData attribute in projectId.GetCustomAttributesData()) + { + if (attribute.AttributeType != typeof(ParameterAttribute)) { continue; } + foreach (CustomAttributeNamedArgument named in attribute.NamedArguments) + { + if (named.MemberName == "Mandatory" && (bool)named.TypedValue.Value) + { + offenders.Add(type.Name); + } + } + } + } + + Assert.Empty(offenders); + } + + [Fact] + public void Every_Pki_Cmdlet_Resolves_The_Project_Rather_Than_Assuming_One() + { + // Optional without resolution would simply send an empty projectId, so confirm each cmdlet calls + // the resolver. + List missing = new List(); + + foreach (string typeName in PkiCmdletTypes) + { + Type type = ModuleAssembly.GetType(typeName, true); + MethodInfo processRecord = type.GetMethod("ProcessRecord", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + Assert.NotNull(processRecord); + + if (!GetCalledMethodNames(processRecord).Contains("ResolveCertManagerProjectId")) + { + missing.Add(type.Name); + } + } + + Assert.Empty(missing); + } + + [Fact] + public void Resolver_Is_Available_To_Cmdlets_And_Returns_A_Project_Id() + { + MethodInfo resolver = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase) + .GetMethod("ResolveCertManagerProjectId", BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.NotNull(resolver); + Assert.Equal(typeof(string), resolver.ReturnType); + + ParameterInfo[] parameters = resolver.GetParameters(); + Assert.Equal(2, parameters.Length); + Assert.Equal(typeof(PSInfisicalAPI.Connections.InfisicalConnection), parameters[0].ParameterType); + Assert.Equal(typeof(string), parameters[1].ParameterType); + } + + [Fact] + public void Resolution_Does_Not_Error_When_An_Organization_Has_Several_Projects() + { + // Certificate applications are served only from the organization's active project, so several + // Certificate Manager projects is a normal configuration rather than an ambiguity to reject. + MethodInfo resolver = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase) + .GetMethod("ResolveCertManagerProjectId", BindingFlags.NonPublic | BindingFlags.Instance); + + List called = GetCalledMethodNames(resolver); + Assert.Contains("FindActiveCertManagerProject", called); + + MethodInfo finder = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase) + .GetMethod("FindActiveCertManagerProject", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(finder); + Assert.Equal(typeof(PSInfisicalAPI.Models.InfisicalProject), finder.ReturnType); + } + + [Fact] + public void An_Organization_Without_Any_Cert_Manager_Project_Is_Not_An_Error() + { + // Nothing to list is an empty result, not a failure, so resolution returns null and each Get-* + // cmdlet returns quietly rather than surfacing "ProjectId is required" from deep in the client. + MethodInfo resolver = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase) + .GetMethod("ResolveCertManagerProjectId", BindingFlags.NonPublic | BindingFlags.Instance); + + List called = GetCalledMethodNames(resolver); + Assert.DoesNotContain("ThrowTerminatingForException", called); + Assert.DoesNotContain("WriteErrorForException", called); + + List ungarded = new List(); + foreach (string typeName in PkiCmdletTypes) + { + // Request-InfisicalCertificate can still issue through a profile without a project, so it is + // deliberately allowed to proceed. + if (typeName.EndsWith("RequestInfisicalCertificateCmdlet", StringComparison.Ordinal)) { continue; } + + Type type = ModuleAssembly.GetType(typeName, true); + MethodInfo processRecord = type.GetMethod("ProcessRecord", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly); + if (!GetCalledMethodNames(processRecord).Contains("IsNullOrEmpty")) + { + ungarded.Add(type.Name); + } + } + + Assert.Empty(ungarded); + } + + [Fact] + public void The_Organizations_Active_Cert_Manager_Project_Is_Modelled() + { + PropertyInfo property = typeof(PSInfisicalAPI.Models.InfisicalOrganization) + .GetProperty("DefaultCertManagerProjectId"); + Assert.NotNull(property); + Assert.Equal(typeof(string), property.PropertyType); + } + + [Fact] + public void Project_Listing_Prefers_The_Current_Route_Over_The_Deprecated_One() + { + // /api/v1/workspace mounts Infisical's deprecated project router; /api/v1/projects is current. + IReadOnlyList candidates = + PSInfisicalAPI.Endpoints.InfisicalEndpointRegistry.GetCandidates( + PSInfisicalAPI.Endpoints.InfisicalEndpointNames.ListProjects); + + Assert.True(candidates.Count >= 2, "both the current and deprecated routes should be registered"); + Assert.Equal("/api/v1/projects", candidates[0].Template); + Assert.Contains(candidates, c => c.Template == "/api/v1/workspace"); + } + + [Fact] + public void An_Explicit_ProjectId_Short_Circuits_Resolution() + { + // Supplying the project must never trigger a lookup, so the explicit value has to be returned + // before any client is constructed. + PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet cmdlet = + new PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet(); + + MethodInfo resolver = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase) + .GetMethod("ResolveCertManagerProjectId", BindingFlags.NonPublic | BindingFlags.Instance); + + // A null connection would throw the moment a lookup was attempted; returning cleanly proves it did not. + string result = (string)resolver.Invoke(cmdlet, new object[] { null, "explicit-project-id" }); + Assert.Equal("explicit-project-id", result); + } + + [Fact] + public void Certificate_Retrieval_By_Serial_Does_Not_Resolve_A_Project() + { + // Addressing a certificate by serial needs no project, so the Single parameter set must not pay + // for a lookup. + Type type = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.GetInfisicalCertificateCmdlet", true); + PropertyInfo serialNumber = type.GetProperty("SerialNumber"); + Assert.NotNull(serialNumber); + + bool serialIsMandatoryOnSingle = false; + foreach (CustomAttributeData attribute in serialNumber.GetCustomAttributesData()) + { + if (attribute.AttributeType != typeof(ParameterAttribute)) { continue; } + bool isSingle = false; + bool isMandatory = false; + foreach (CustomAttributeNamedArgument named in attribute.NamedArguments) + { + if (named.MemberName == "ParameterSetName" && (string)named.TypedValue.Value == "Single") { isSingle = true; } + if (named.MemberName == "Mandatory" && (bool)named.TypedValue.Value) { isMandatory = true; } + } + + if (isSingle && isMandatory) { serialIsMandatoryOnSingle = true; } + } + + Assert.True(serialIsMandatoryOnSingle); + } + + [Fact] + public void Profiles_Can_Be_Filtered_To_An_Application_The_Way_The_Console_Groups_Them() + { + Type type = ModuleAssembly.GetType("PSInfisicalAPI.Cmdlets.GetInfisicalCertificateProfileCmdlet", true); + + PropertyInfo applicationId = type.GetProperty("ApplicationId"); + Assert.NotNull(applicationId); + Assert.Equal(typeof(string), applicationId.PropertyType); + + PropertyInfo caId = type.GetProperty("CaId"); + Assert.NotNull(caId); + } + + [Fact] + public void Profile_Listing_Keeps_Its_Original_Overload_For_Existing_Callers() + { + Type clientType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalPkiClient", true); + + MethodInfo original = clientType.GetMethod( + "ListCertificateProfiles", + BindingFlags.Public | BindingFlags.Instance, + null, + new[] { typeof(PSInfisicalAPI.Connections.InfisicalConnection), typeof(string), typeof(int?), typeof(int?), typeof(bool?) }, + null); + Assert.NotNull(original); + + MethodInfo filtered = clientType.GetMethod( + "ListCertificateProfiles", + BindingFlags.Public | BindingFlags.Instance, + null, + new[] { typeof(PSInfisicalAPI.Connections.InfisicalConnection), typeof(string), typeof(int?), typeof(int?), typeof(bool?), typeof(string), typeof(string) }, + null); + Assert.NotNull(filtered); + } + + private static List GetCalledMethodNames(MethodInfo method) + { + List names = new List(); + MethodBody body = method.GetMethodBody(); + if (body == null) { return names; } + + byte[] il = body.GetILAsByteArray(); + if (il == null) { return names; } + + 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) + { + } + } + + return names; + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/CertificateApplicationWriteCmdlets.cs b/src/PSInfisicalAPI/Cmdlets/CertificateApplicationWriteCmdlets.cs new file mode 100644 index 0000000..9f75c51 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/CertificateApplicationWriteCmdlets.cs @@ -0,0 +1,159 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; + +namespace PSInfisicalAPI.Cmdlets +{ + /// + /// Creates a certificate application: the grouping the Infisical console presents profiles, members, and + /// certificates under. + /// + [Cmdlet(VerbsCommon.New, "InfisicalCertificateApplication", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalCertificateApplication))] + public sealed class NewInfisicalCertificateApplicationCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "NewInfisicalCertificateApplicationCmdlet"; + + [Parameter(Mandatory = true, Position = 0)] public string Name { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Description { get; set; } + + /// Certificate profiles to attach on creation. + [Parameter] public string[] ProfileId { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(Name, "Create certificate application")) { return; } + + InfisicalCertificateApplicationWriteRequestDto request = new InfisicalCertificateApplicationWriteRequestDto + { + Name = Name, + Description = Description, + ProfileIds = ToStringList(ProfileId) + }; + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + WriteObject(client.WriteCertificateApplication(connection, ProjectId, null, request)); + } + catch (Exception exception) + { + WriteErrorForException(Component, "CreateCertificateApplication", exception); + } + } + } + + /// + /// Renames a certificate application or changes its description, and attaches or detaches profiles. + /// + [Cmdlet(VerbsCommon.Set, "InfisicalCertificateApplication", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalCertificateApplication))] + public sealed class SetInfisicalCertificateApplicationCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "SetInfisicalCertificateApplicationCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("Id")] + public string ApplicationId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Name { get; set; } + [Parameter] public string Description { get; set; } + + /// Certificate profiles to attach. Profiles already attached are left alone. + [Parameter] public string[] AddProfileId { get; set; } + + /// Certificate profiles to detach. + [Parameter] public string[] RemoveProfileId { get; set; } + + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(ApplicationId, "Update certificate application")) { return; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + InfisicalCertificateApplication updated = null; + + // The application record and its profile attachments are separate endpoints, so the name and + // description update is skipped entirely when only profiles were supplied. + if (!string.IsNullOrEmpty(Name) || !string.IsNullOrEmpty(Description)) + { + InfisicalCertificateApplicationWriteRequestDto request = new InfisicalCertificateApplicationWriteRequestDto + { + Name = Name, + Description = Description + }; + + updated = client.WriteCertificateApplication(connection, ProjectId, ApplicationId, request); + } + + if (ToStringList(AddProfileId) != null) + { + client.AddCertificateApplicationProfiles(connection, ProjectId, ApplicationId, ToStringList(AddProfileId)); + } + + foreach (string profileId in ToStringList(RemoveProfileId) ?? new System.Collections.Generic.List()) + { + client.RemoveCertificateApplicationProfile(connection, ProjectId, ApplicationId, profileId); + } + + if (PassThru.IsPresent && updated != null) { WriteObject(updated); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "UpdateCertificateApplication", exception); + } + } + } + + /// + /// Deletes a certificate application. + /// + [Cmdlet(VerbsCommon.Remove, "InfisicalCertificateApplication", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public sealed class RemoveInfisicalCertificateApplicationCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "RemoveInfisicalCertificateApplicationCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("Id")] + public string ApplicationId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(ApplicationId, "Delete certificate application")) { return; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + client.DeleteCertificateApplication(connection, ProjectId, ApplicationId); + + if (PassThru.IsPresent) { WriteObject(ApplicationId); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "DeleteCertificateApplication", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/CertificateAuthorityWriteCmdlets.cs b/src/PSInfisicalAPI/Cmdlets/CertificateAuthorityWriteCmdlets.cs new file mode 100644 index 0000000..31f5e32 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/CertificateAuthorityWriteCmdlets.cs @@ -0,0 +1,183 @@ +using System; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; + +namespace PSInfisicalAPI.Cmdlets +{ + /// + /// Creates an internal certificate authority, signing a subordinate with its parent so it comes back ready + /// to issue. + /// + [Cmdlet(VerbsCommon.New, "InfisicalCertificateAuthority", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalCertificateAuthority))] + public sealed class NewInfisicalCertificateAuthorityCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "NewInfisicalCertificateAuthorityCmdlet"; + + [Parameter(Mandatory = true, Position = 0)] public string Name { get; set; } + + [Parameter(Mandatory = true)] + [ValidateSet("Root", "Intermediate")] + public string Type { get; set; } + + [Parameter(Mandatory = true)] public string CommonName { get; set; } + + /// Required for -Type Intermediate: the authority that signs this one. + [Parameter] public string ParentCaId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Organization { get; set; } + [Parameter] public string OrganizationalUnit { get; set; } + [Parameter] public string Country { get; set; } + [Parameter] public string State { get; set; } + [Parameter] public string Locality { get; set; } + [Parameter] public string FriendlyName { get; set; } + + [Parameter] + [ValidateSet("RSA_2048", "RSA_3072", "RSA_4096", "EC_prime256v1", "EC_secp384r1", "EC_secp521r1")] + public string KeyAlgorithm { get; set; } = "RSA_2048"; + + /// Expiry. Defaults to ten years for a root and five for a subordinate. + [Parameter] public DateTimeOffset? NotAfter { get; set; } + + [Parameter] public DateTimeOffset? NotBefore { get; set; } + + /// Subordinate authorities permitted beneath this one. Defaults to 1 for a root, 0 otherwise. + [Parameter] public int? MaxPathLength { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + bool isRoot = string.Equals(Type, "Root", StringComparison.OrdinalIgnoreCase); + if (!isRoot && string.IsNullOrEmpty(ParentCaId)) + { + throw new PSInfisicalAPI.Errors.InfisicalConfigurationException( + "-ParentCaId is required for an intermediate certificate authority; it names the authority that signs this one."); + } + + if (!ShouldProcess(Name, string.Concat("Create ", Type.ToLowerInvariant(), " certificate authority"))) { return; } + + DateTimeOffset expiry = NotAfter ?? DateTimeOffset.UtcNow.AddYears(isRoot ? 10 : 5); + int pathLength = MaxPathLength ?? (isRoot ? 1 : 0); + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + + InfisicalCertificateAuthority created = client.CreateInternalCertificateAuthority( + connection, ProjectId, Name, isRoot ? "root" : "intermediate", CommonName, + Organization, OrganizationalUnit, Country, State, Locality, KeyAlgorithm, FriendlyName, + ToApiTimestamp(NotBefore), ToApiTimestamp(expiry), pathLength); + + if (created == null) + { + throw new PSInfisicalAPI.Errors.InfisicalApiException("Creating the certificate authority returned no record."); + } + + if (!isRoot) + { + // Infisical creates a subordinate pending a certificate; without this it exists but cannot + // sign anything. + Logger.Information(Component, string.Concat("Signing '", Name, "' with parent certificate authority '", ParentCaId, "'.")); + client.CompleteSubordinateCertificateAuthority(connection, ProjectId, created.Id, ParentCaId, ToApiTimestamp(expiry), pathLength); + + InfisicalPkiClient readClient = new InfisicalPkiClient(HttpClient, Logger); + InfisicalCertificateAuthority refreshed = readClient.GetInternalCertificateAuthority(connection, created.Id, ProjectId); + if (refreshed != null) { created = refreshed; } + } + + WriteObject(created); + } + catch (Exception exception) + { + WriteErrorForException(Component, "CreateCertificateAuthority", exception); + } + } + } + + /// + /// Renames an internal certificate authority or changes its status. + /// + [Cmdlet(VerbsCommon.Set, "InfisicalCertificateAuthority", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalCertificateAuthority))] + public sealed class SetInfisicalCertificateAuthorityCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "SetInfisicalCertificateAuthorityCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("Id")] + public string CaId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Name { get; set; } + + [Parameter] + [ValidateSet("active", "disabled")] + public string Status { get; set; } + + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(CaId, "Update certificate authority")) { return; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + InfisicalCertificateAuthority updated = client.UpdateInternalCertificateAuthority(connection, ProjectId, CaId, Name, Status); + + if (PassThru.IsPresent) { WriteObject(updated); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "UpdateCertificateAuthority", exception); + } + } + } + + /// + /// Deletes an internal certificate authority. + /// + [Cmdlet(VerbsCommon.Remove, "InfisicalCertificateAuthority", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public sealed class RemoveInfisicalCertificateAuthorityCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "RemoveInfisicalCertificateAuthorityCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("Id")] + public string CaId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(CaId, "Delete certificate authority")) { return; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + client.DeleteInternalCertificateAuthority(connection, ProjectId, CaId); + + if (PassThru.IsPresent) { WriteObject(CaId); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "DeleteCertificateAuthority", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/CertificatePolicyWriteCmdlets.cs b/src/PSInfisicalAPI/Cmdlets/CertificatePolicyWriteCmdlets.cs new file mode 100644 index 0000000..67619f3 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/CertificatePolicyWriteCmdlets.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; + +namespace PSInfisicalAPI.Cmdlets +{ + /// + /// Creates a certificate policy: the constraints a profile issues within. + /// + [Cmdlet(VerbsCommon.New, "InfisicalCertificatePolicy", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalCertificatePolicy))] + public sealed class NewInfisicalCertificatePolicyCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "NewInfisicalCertificatePolicyCmdlet"; + + [Parameter(Mandatory = true, Position = 0)] public string Name { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Description { get; set; } + + /// Maximum certificate lifetime, for example '90d', '12m', or '1y'. + [Parameter] public string MaxValidity { get; set; } + + /// Permitted key algorithms, for example RSA_2048 or EC_secp384r1. + [Parameter] public string[] KeyAlgorithm { get; set; } + + /// Permitted signature algorithms. + [Parameter] public string[] SignatureAlgorithm { get; set; } + + /// Key usage constraint, as @{ Required = @('digital_signature'); Denied = @(...) }. + [Parameter] public IDictionary KeyUsage { get; set; } + + /// Extended key usage constraint, as @{ Required = @('server_auth','client_auth') }. + [Parameter] public IDictionary ExtendedKeyUsage { get; set; } + + /// Subject attribute constraints, as @( @{ Type = 'organization'; Allowed = @('Contoso') } ). + [Parameter] public IDictionary[] Subject { get; set; } + + /// Subject alternative name constraints, as @( @{ Type = 'dns_name'; Allowed = @('*.contoso.com') } ). + [Parameter] public IDictionary[] SubjectAlternativeName { get; set; } + + /// Basic constraints, as @{ isCA = 'denied'; maxPathLength = 0 }. + [Parameter] public IDictionary BasicConstraints { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(Name, "Create certificate policy")) { return; } + + InfisicalCertificatePolicyWriteRequestDto request = new InfisicalCertificatePolicyWriteRequestDto + { + Name = Name, + Description = Description, + Subject = ToJsonObjectList(Subject), + Sans = ToJsonObjectList(SubjectAlternativeName), + KeyUsages = ToJsonObject(KeyUsage), + ExtendedKeyUsages = ToJsonObject(ExtendedKeyUsage), + BasicConstraints = ToJsonObject(BasicConstraints) + }; + + if (!string.IsNullOrEmpty(MaxValidity)) + { + request.Validity = new System.Collections.Generic.Dictionary { { "max", MaxValidity } }; + } + + System.Collections.Generic.Dictionary algorithms = new System.Collections.Generic.Dictionary(); + if (ToStringList(KeyAlgorithm) != null) { algorithms["keyAlgorithm"] = ToStringList(KeyAlgorithm); } + if (ToStringList(SignatureAlgorithm) != null) { algorithms["signature"] = ToStringList(SignatureAlgorithm); } + if (algorithms.Count > 0) { request.Algorithms = algorithms; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + WriteObject(client.WriteCertificatePolicy(connection, ProjectId, null, request)); + } + catch (Exception exception) + { + WriteErrorForException(Component, "CreateCertificatePolicy", exception); + } + } + } + + /// + /// Updates a certificate policy. Only the supplied constraints are sent; the rest are left as they are. + /// + [Cmdlet(VerbsCommon.Set, "InfisicalCertificatePolicy", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalCertificatePolicy))] + public sealed class SetInfisicalCertificatePolicyCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "SetInfisicalCertificatePolicyCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("Id")] + public string PolicyId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Name { get; set; } + [Parameter] public string Description { get; set; } + [Parameter] public string MaxValidity { get; set; } + [Parameter] public string[] KeyAlgorithm { get; set; } + [Parameter] public string[] SignatureAlgorithm { get; set; } + [Parameter] public IDictionary KeyUsage { get; set; } + [Parameter] public IDictionary ExtendedKeyUsage { get; set; } + [Parameter] public IDictionary[] Subject { get; set; } + [Parameter] public IDictionary[] SubjectAlternativeName { get; set; } + [Parameter] public IDictionary BasicConstraints { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(PolicyId, "Update certificate policy")) { return; } + + InfisicalCertificatePolicyWriteRequestDto request = new InfisicalCertificatePolicyWriteRequestDto + { + Name = Name, + Description = Description, + Subject = ToJsonObjectList(Subject), + Sans = ToJsonObjectList(SubjectAlternativeName), + KeyUsages = ToJsonObject(KeyUsage), + ExtendedKeyUsages = ToJsonObject(ExtendedKeyUsage), + BasicConstraints = ToJsonObject(BasicConstraints) + }; + + if (!string.IsNullOrEmpty(MaxValidity)) + { + request.Validity = new System.Collections.Generic.Dictionary { { "max", MaxValidity } }; + } + + System.Collections.Generic.Dictionary algorithms = new System.Collections.Generic.Dictionary(); + if (ToStringList(KeyAlgorithm) != null) { algorithms["keyAlgorithm"] = ToStringList(KeyAlgorithm); } + if (ToStringList(SignatureAlgorithm) != null) { algorithms["signature"] = ToStringList(SignatureAlgorithm); } + if (algorithms.Count > 0) { request.Algorithms = algorithms; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + InfisicalCertificatePolicy updated = client.WriteCertificatePolicy(connection, ProjectId, PolicyId, request); + + if (PassThru.IsPresent) { WriteObject(updated); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "UpdateCertificatePolicy", exception); + } + } + } + + /// + /// Deletes a certificate policy. + /// + [Cmdlet(VerbsCommon.Remove, "InfisicalCertificatePolicy", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public sealed class RemoveInfisicalCertificatePolicyCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "RemoveInfisicalCertificatePolicyCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("Id")] + public string PolicyId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(PolicyId, "Delete certificate policy")) { return; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + client.DeleteCertificatePolicy(connection, ProjectId, PolicyId); + + if (PassThru.IsPresent) { WriteObject(PolicyId); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "DeleteCertificatePolicy", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/CertificateProfileWriteCmdlets.cs b/src/PSInfisicalAPI/Cmdlets/CertificateProfileWriteCmdlets.cs new file mode 100644 index 0000000..bec8835 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/CertificateProfileWriteCmdlets.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; + +namespace PSInfisicalAPI.Cmdlets +{ + /// + /// Creates a certificate profile, binding an issuing certificate authority to a certificate policy and + /// exposing it for enrollment. + /// + [Cmdlet(VerbsCommon.New, "InfisicalCertificateProfile", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalCertificateProfile))] + public sealed class NewInfisicalCertificateProfileCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "NewInfisicalCertificateProfileCmdlet"; + + /// Lowercase letters, numbers, and hyphens only. + [Parameter(Mandatory = true, Position = 0)] public string Slug { get; set; } + + [Parameter(Mandatory = true)] public string CertificatePolicyId { get; set; } + [Parameter] public string CaId { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Description { get; set; } + + [Parameter] + [ValidateSet("api", "est", "acme", "scep")] + public string EnrollmentType { get; set; } = "api"; + + [Parameter] + [ValidateSet("ca", "self-signed")] + public string IssuerType { get; set; } = "ca"; + + /// Renew issued certificates automatically. + [Parameter] public SwitchParameter AutoRenew { get; set; } + + /// Days before expiry at which automatic renewal runs, 1 to 30. + [Parameter] public int? RenewBeforeDays { get; set; } + + /// Issuance defaults, as @{ ttlDays = 90; keyAlgorithm = 'RSA_2048' }. + [Parameter] public IDictionary Defaults { get; set; } + + /// Enrollment configuration for -EnrollmentType est, acme, or scep. + [Parameter] public IDictionary EnrollmentConfig { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(Slug, "Create certificate profile")) { return; } + + InfisicalCertificateProfileWriteRequestDto request = BuildRequest( + Slug, Description, CaId, CertificatePolicyId, EnrollmentType, IssuerType, + AutoRenew.IsPresent, RenewBeforeDays, Defaults, EnrollmentConfig, + MyInvocation.BoundParameters.ContainsKey("AutoRenew")); + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + WriteObject(client.WriteCertificateProfile(connection, ProjectId, null, request)); + } + catch (Exception exception) + { + WriteErrorForException(Component, "CreateCertificateProfile", exception); + } + } + + /// + /// Routes the enrollment configuration to the block matching the enrollment type, so callers supply one + /// dictionary rather than choosing between four mutually exclusive parameters. + /// + internal static InfisicalCertificateProfileWriteRequestDto BuildRequest( + string slug, string description, string caId, string certificatePolicyId, + string enrollmentType, string issuerType, bool autoRenew, int? renewBeforeDays, + IDictionary defaults, IDictionary enrollmentConfig, bool autoRenewBound) + { + InfisicalCertificateProfileWriteRequestDto request = new InfisicalCertificateProfileWriteRequestDto + { + Slug = slug, + Description = description, + CaId = caId, + CertificatePolicyId = certificatePolicyId, + EnrollmentType = enrollmentType, + IssuerType = issuerType, + Defaults = ToJsonObject(defaults) + }; + + System.Collections.Generic.Dictionary config = ToJsonObject(enrollmentConfig); + + if (string.Equals(enrollmentType, "est", StringComparison.OrdinalIgnoreCase)) { request.EstConfig = config; } + else if (string.Equals(enrollmentType, "acme", StringComparison.OrdinalIgnoreCase)) { request.AcmeConfig = config; } + else if (string.Equals(enrollmentType, "scep", StringComparison.OrdinalIgnoreCase)) { request.ScepConfig = config; } + else + { + if (config == null && (autoRenewBound || renewBeforeDays.HasValue)) + { + config = new System.Collections.Generic.Dictionary(); + } + + if (config != null) + { + if (autoRenewBound) { config["autoRenew"] = autoRenew; } + if (renewBeforeDays.HasValue) { config["renewBeforeDays"] = renewBeforeDays.Value; } + } + + request.ApiConfig = config; + } + + return request; + } + } + + /// + /// Updates a certificate profile. Only the supplied values are sent. + /// + [Cmdlet(VerbsCommon.Set, "InfisicalCertificateProfile", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalCertificateProfile))] + public sealed class SetInfisicalCertificateProfileCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "SetInfisicalCertificateProfileCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("Id", "CertificateProfileId")] + public string ProfileId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Slug { get; set; } + [Parameter] public string Description { get; set; } + [Parameter] public string CaId { get; set; } + [Parameter] public string CertificatePolicyId { get; set; } + + [Parameter] + [ValidateSet("api", "est", "acme", "scep")] + public string EnrollmentType { get; set; } + + [Parameter] public SwitchParameter AutoRenew { get; set; } + [Parameter] public int? RenewBeforeDays { get; set; } + [Parameter] public IDictionary Defaults { get; set; } + [Parameter] public IDictionary EnrollmentConfig { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(ProfileId, "Update certificate profile")) { return; } + + InfisicalCertificateProfileWriteRequestDto request = NewInfisicalCertificateProfileCmdlet.BuildRequest( + Slug, Description, CaId, CertificatePolicyId, + string.IsNullOrEmpty(EnrollmentType) ? "api" : EnrollmentType, + null, AutoRenew.IsPresent, RenewBeforeDays, Defaults, EnrollmentConfig, + MyInvocation.BoundParameters.ContainsKey("AutoRenew")); + + // Only sent when the caller asked for it; the API keeps the stored value otherwise. + if (!MyInvocation.BoundParameters.ContainsKey("EnrollmentType")) { request.EnrollmentType = null; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + InfisicalCertificateProfile updated = client.WriteCertificateProfile(connection, ProjectId, ProfileId, request); + + if (PassThru.IsPresent) { WriteObject(updated); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "UpdateCertificateProfile", exception); + } + } + } + + /// + /// Deletes a certificate profile. + /// + [Cmdlet(VerbsCommon.Remove, "InfisicalCertificateProfile", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public sealed class RemoveInfisicalCertificateProfileCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "RemoveInfisicalCertificateProfileCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("Id", "CertificateProfileId")] + public string ProfileId { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(ProfileId, "Delete certificate profile")) { return; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + client.DeleteCertificateProfile(connection, ProjectId, ProfileId); + + if (PassThru.IsPresent) { WriteObject(ProfileId); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "DeleteCertificateProfile", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationCmdlet.cs index db11a1b..55fbf9a 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationCmdlet.cs @@ -18,7 +18,7 @@ namespace PSInfisicalAPI.Cmdlets [Alias("Name")] public string ApplicationName { get; set; } - [Parameter(Mandatory = true)] public string ProjectId { get; set; } + [Parameter] public string ProjectId { get; set; } [Parameter(ParameterSetName = "List")] public int? Limit { get; set; } @@ -29,6 +29,14 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + + // The UI never asks which Certificate Manager project to use when an organization has only + // one; -ProjectId is optional here for the same reason. Assigned back so every call below + // sees the resolved value without threading a second variable through. + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + + // No Certificate Manager project means nothing to list; that is an empty result, not a failure. + if (string.IsNullOrEmpty(ProjectId)) { return; } InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); if (string.Equals(ParameterSetName, "ById", StringComparison.Ordinal)) diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationEnrollmentCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationEnrollmentCmdlet.cs index c8d3db7..370afe0 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationEnrollmentCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateApplicationEnrollmentCmdlet.cs @@ -18,13 +18,21 @@ namespace PSInfisicalAPI.Cmdlets [Alias("CertificateProfileId")] public string ProfileId { get; set; } - [Parameter(Mandatory = true)] public string ProjectId { get; set; } + [Parameter] public string ProjectId { get; set; } protected override void ProcessRecord() { try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + + // The UI never asks which Certificate Manager project to use when an organization has only + // one; -ProjectId is optional here for the same reason. Assigned back so every call below + // sees the resolved value without threading a second variable through. + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + + // No Certificate Manager project means nothing to list; that is an empty result, not a failure. + if (string.IsNullOrEmpty(ProjectId)) { return; } InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); InfisicalCertificateApplicationEnrollment enrollment = client.GetCertificateApplicationEnrollment(connection, ApplicationId, ProfileId, ProjectId); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs index 6b161c0..b9d15ff 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateAuthorityCmdlet.cs @@ -14,7 +14,7 @@ namespace PSInfisicalAPI.Cmdlets [Alias("Id")] public string CaId { get; set; } - [Parameter(Mandatory = true)] public string ProjectId { get; set; } + [Parameter] public string ProjectId { get; set; } [Parameter(ParameterSetName = "List")] [ValidateSet("Internal", "Acme", "Any")] @@ -25,6 +25,14 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + + // The UI never asks which Certificate Manager project to use when an organization has only + // one; -ProjectId is optional here for the same reason. Assigned back so every call below + // sees the resolved value without threading a second variable through. + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + + // No Certificate Manager project means nothing to list; that is an empty result, not a failure. + if (string.IsNullOrEmpty(ProjectId)) { return; } InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); if (string.Equals(ParameterSetName, "ById", StringComparison.Ordinal)) diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateCmdlet.cs index 4ef7e24..3deb08c 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateCmdlet.cs @@ -16,7 +16,7 @@ namespace PSInfisicalAPI.Cmdlets [Alias("Id", "Identifier")] public string SerialNumber { get; set; } - [Parameter(ParameterSetName = "List", Mandatory = true)] public string ProjectId { get; set; } + [Parameter(ParameterSetName = "List")] public string ProjectId { get; set; } [Parameter(ParameterSetName = "List")] public string CommonName { get; set; } [Parameter(ParameterSetName = "List")] public string FriendlyName { get; set; } [Parameter(ParameterSetName = "List")] public string Search { get; set; } @@ -59,6 +59,7 @@ namespace PSInfisicalAPI.Cmdlets InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); + // Retrieval by serial addresses the certificate directly, so it needs no project. if (string.Equals(ParameterSetName, "Single", StringComparison.Ordinal)) { InfisicalCertificate cert = client.RetrieveCertificate(connection, SerialNumber); @@ -70,6 +71,14 @@ namespace PSInfisicalAPI.Cmdlets return; } + // The UI never asks which Certificate Manager project to use when an organization has only + // one; -ProjectId is optional here for the same reason. Assigned back so every call below + // sees the resolved value without threading a second variable through. + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + + // No Certificate Manager project means nothing to list; that is an empty result, not a failure. + if (string.IsNullOrEmpty(ProjectId)) { return; } + InfisicalCertificateSearchQuery query = new InfisicalCertificateSearchQuery { ProjectId = ProjectId, diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificatePolicyCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificatePolicyCmdlet.cs index 1046afe..4d3da67 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificatePolicyCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificatePolicyCmdlet.cs @@ -14,7 +14,7 @@ namespace PSInfisicalAPI.Cmdlets [Alias("Id", "CertificatePolicyId")] public string PolicyId { get; set; } - [Parameter(Mandatory = true)] public string ProjectId { get; set; } + [Parameter] public string ProjectId { get; set; } [Parameter(ParameterSetName = "List")] public int? Limit { get; set; } @@ -25,6 +25,14 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + + // The UI never asks which Certificate Manager project to use when an organization has only + // one; -ProjectId is optional here for the same reason. Assigned back so every call below + // sees the resolved value without threading a second variable through. + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + + // No Certificate Manager project means nothing to list; that is an empty result, not a failure. + if (string.IsNullOrEmpty(ProjectId)) { return; } InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); if (string.Equals(ParameterSetName, "ById", StringComparison.Ordinal)) diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateProfileCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateProfileCmdlet.cs index 6e14bf7..422a1fb 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateProfileCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalCertificateProfileCmdlet.cs @@ -14,7 +14,19 @@ namespace PSInfisicalAPI.Cmdlets [Alias("Id", "CertificateProfileId")] public string ProfileId { get; set; } - [Parameter(Mandatory = true)] public string ProjectId { get; set; } + [Parameter] public string ProjectId { get; set; } + + /// + /// Narrows the listing to one certificate application, which is how the Infisical console groups + /// profiles. See Get-InfisicalCertificateApplication. + /// + [Parameter(ParameterSetName = "List", ValueFromPipelineByPropertyName = true)] + public string ApplicationId { get; set; } + + /// + /// Narrows the listing to profiles issued by one certificate authority. + /// + [Parameter(ParameterSetName = "List")] public string CaId { get; set; } [Parameter(ParameterSetName = "List")] public int? Limit { get; set; } @@ -27,6 +39,14 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + + // The UI never asks which Certificate Manager project to use when an organization has only + // one; -ProjectId is optional here for the same reason. Assigned back so every call below + // sees the resolved value without threading a second variable through. + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + + // No Certificate Manager project means nothing to list; that is an empty result, not a failure. + if (string.IsNullOrEmpty(ProjectId)) { return; } InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); if (string.Equals(ParameterSetName, "ById", StringComparison.Ordinal)) @@ -41,8 +61,12 @@ namespace PSInfisicalAPI.Cmdlets } bool? includeConfigs = MyInvocation.BoundParameters.ContainsKey("IncludeConfigs") ? (bool?)IncludeConfigs.IsPresent : null; - InfisicalCertificateProfile[] all = client.ListCertificateProfiles(connection, ProjectId, Limit, Offset, includeConfigs); - Logger.Information("Get-InfisicalCertificateProfile", string.Concat("Returned ", all.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " certificate profile(s).")); + InfisicalCertificateProfile[] all = client.ListCertificateProfiles(connection, ProjectId, Limit, Offset, includeConfigs, ApplicationId, CaId); + + string scope = !string.IsNullOrEmpty(ApplicationId) + ? string.Concat(" for application '", ApplicationId, "'") + : (!string.IsNullOrEmpty(CaId) ? string.Concat(" for certificate authority '", CaId, "'") : string.Empty); + Logger.Information("Get-InfisicalCertificateProfile", string.Concat("Returned ", all.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), " certificate profile(s)", scope, ".")); foreach (InfisicalCertificateProfile profile in all) { WriteObject(profile); diff --git a/src/PSInfisicalAPI/Cmdlets/GetInfisicalPkiSubscriberCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/GetInfisicalPkiSubscriberCmdlet.cs index 5a1a37a..f2c300a 100644 --- a/src/PSInfisicalAPI/Cmdlets/GetInfisicalPkiSubscriberCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/GetInfisicalPkiSubscriberCmdlet.cs @@ -14,13 +14,21 @@ namespace PSInfisicalAPI.Cmdlets [Alias("SubscriberName", "Slug")] public string Name { get; set; } - [Parameter(Mandatory = true)] public string ProjectId { get; set; } + [Parameter] public string ProjectId { get; set; } protected override void ProcessRecord() { try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + + // The UI never asks which Certificate Manager project to use when an organization has only + // one; -ProjectId is optional here for the same reason. Assigned back so every call below + // sees the resolved value without threading a second variable through. + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + + // No Certificate Manager project means nothing to list; that is an empty result, not a failure. + if (string.IsNullOrEmpty(ProjectId)) { return; } InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); if (string.Equals(ParameterSetName, "ByName", StringComparison.Ordinal)) diff --git a/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs index 49356a8..46753a1 100644 --- a/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs +++ b/src/PSInfisicalAPI/Cmdlets/InfisicalCmdletBase.cs @@ -1,10 +1,14 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.Management.Automation; using System.Runtime.ExceptionServices; using System.Security.Cryptography.X509Certificates; using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Organizations; +using PSInfisicalAPI.Projects; using PSInfisicalAPI.Errors; using PSInfisicalAPI.Http; using PSInfisicalAPI.Logging; @@ -13,9 +17,13 @@ namespace PSInfisicalAPI.Cmdlets { public abstract class InfisicalCmdletBase : PSCmdlet { + private const string CertManagerProjectType = "cert-manager"; + private IInfisicalLogger _logger; private IInfisicalHttpClient _httpClient; private bool? _isElevated; + private string _resolvedCertManagerProjectId; + private bool _certManagerProjectResolved; protected IInfisicalLogger Logger { @@ -49,6 +57,130 @@ namespace PSInfisicalAPI.Cmdlets return current != null && current.SkipCertificateCheck; } + /// + /// Resolves the Certificate Manager project a PKI call should target. + /// + /// The Infisical UI never asks which project to use: an organization with a single Certificate Manager + /// project has it selected implicitly, which is why the project appears only in the URL and everything + /// below it is presented as applications. The API behaves the same way - its own resolver takes the + /// single cert-manager project when there is exactly one - so requiring -ProjectId on every cmdlet made + /// the module stricter than the service it wraps. + /// + /// + /// This mirrors that rule client-side, which is necessary because several PKI endpoints carry the project + /// in the URL path and cannot defer to the server's resolver. Resolved once per cmdlet instance. + /// + /// + /// + /// The project to use, or null when the organization has no Certificate Manager project. Callers + /// that cannot proceed without one should return quietly rather than failing: an organization that has + /// not set up Certificate Manager has nothing to list, which is an empty result and not an error. + /// + protected string ResolveCertManagerProjectId(InfisicalConnection connection, string explicitValue) + { + if (!string.IsNullOrEmpty(explicitValue)) { return explicitValue; } + if (_certManagerProjectResolved) { return _resolvedCertManagerProjectId; } + + _certManagerProjectResolved = true; + + InfisicalProjectClient client = new InfisicalProjectClient(HttpClient, Logger); + InfisicalProject[] projects = client.List(connection, CertManagerProjectType, false); + + List certManagerProjects = new List(); + if (projects != null) + { + foreach (InfisicalProject project in projects) + { + if (project == null) { continue; } + if (string.Equals(project.Type, CertManagerProjectType, StringComparison.OrdinalIgnoreCase)) + { + certManagerProjects.Add(project); + } + } + } + + if (certManagerProjects.Count == 0) + { + Logger.Verbose(GetType().Name, string.Concat( + "-ProjectId was not supplied and this organization has no Certificate Manager project, so there is ", + "nothing to resolve to. Create one in Infisical (Certificate Management), or pass -ProjectId to ", + "target a specific project.")); + + _resolvedCertManagerProjectId = null; + return null; + } + + InfisicalProject chosen = certManagerProjects[0]; + string reason = "the organization's only Certificate Manager project"; + + if (certManagerProjects.Count > 1) + { + // More than one is not an error. Infisical designates one of them as the organization's active + // Certificate Manager project, and certificate applications are only served from that one, so + // resolving to it is what makes an application-centric script work. + InfisicalProject active = FindActiveCertManagerProject(connection, certManagerProjects); + if (active != null) + { + chosen = active; + reason = "the organization's active Certificate Manager project"; + } + else + { + reason = string.Concat( + "the first of ", certManagerProjects.Count.ToString(CultureInfo.InvariantCulture), + " Certificate Manager projects (no active project is set on the organization; pass -ProjectId to choose another)"); + } + } + + _resolvedCertManagerProjectId = chosen.Id; + Logger.Verbose(GetType().Name, string.Concat( + "-ProjectId was not supplied; resolved ", reason, ": '", + chosen.Name ?? chosen.Slug, "' (", _resolvedCertManagerProjectId, ").")); + + return _resolvedCertManagerProjectId; + } + + /// + /// Finds the organization's active Certificate Manager project among the candidates. Certificate + /// applications are served only from this project, so when several exist it is the one a PKI call + /// should target. Returns null when the organization designates none, leaving the caller to fall back. + /// + private InfisicalProject FindActiveCertManagerProject(InfisicalConnection connection, List candidates) + { + try + { + InfisicalOrganizationClient organizationClient = new InfisicalOrganizationClient(HttpClient, Logger); + InfisicalOrganization[] organizations = organizationClient.List(connection); + if (organizations == null) { return null; } + + string organizationId = connection != null ? connection.OrganizationId : null; + foreach (InfisicalOrganization organization in organizations) + { + if (organization == null || string.IsNullOrEmpty(organization.DefaultCertManagerProjectId)) { continue; } + if (!string.IsNullOrEmpty(organizationId) + && !string.Equals(organization.Id, organizationId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + foreach (InfisicalProject candidate in candidates) + { + if (string.Equals(candidate.Id, organization.DefaultCertManagerProjectId, StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } + } + } + catch (Exception exception) + { + if (IsPipelineControlException(exception)) { throw; } + Logger.Verbose(GetType().Name, string.Concat("Could not read the organization's active Certificate Manager project (continuing): ", exception.Message)); + } + + return null; + } + /// /// Reports whether the host process is running elevated. Evaluated through the PowerShell engine rather /// than WindowsIdentity directly, because the module targets netstandard2.0 and does not carry a diff --git a/src/PSInfisicalAPI/Cmdlets/InfisicalPkiWriteCmdletBase.cs b/src/PSInfisicalAPI/Cmdlets/InfisicalPkiWriteCmdletBase.cs new file mode 100644 index 0000000..0cae310 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/InfisicalPkiWriteCmdletBase.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Management.Automation; + +namespace PSInfisicalAPI.Cmdlets +{ + /// + /// Shared plumbing for the cmdlets that create, change, or remove Certificate Manager configuration. + /// + /// Infisical's policy and profile bodies are deeply nested — allowed/required/denied constraint objects, + /// per-enrollment-type config blocks — and expressing every leaf as a parameter would produce cmdlets nobody + /// could read. Those structures are accepted as dictionaries instead, matching how -Subject and + /// -Metadata already work, and are converted here. + /// + /// + public abstract class InfisicalPkiWriteCmdletBase : InfisicalCmdletBase + { + /// + /// Converts a caller's dictionary into the plain string-keyed form the serializer emits as a JSON + /// object. Nested dictionaries and collections are converted too, so a hashtable of hashtables round + /// trips into the nested body Infisical expects. + /// + /// + /// The constraint vocabulary Infisical expects in lower case. PowerShell callers naturally capitalise + /// hashtable keys, so @{ Required = ... } has to reach the API as "required" or the request + /// is rejected for a missing constraint. Every other key is passed through untouched, since the rest of + /// the body uses camelCase field names the caller supplies verbatim. + /// + private static readonly Dictionary CanonicalKeys = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "allowed", "allowed" }, + { "required", "required" }, + { "denied", "denied" }, + { "type", "type" } + }; + + internal static Dictionary ToJsonObject(IDictionary source) + { + if (source == null) { return null; } + + Dictionary result = new Dictionary(StringComparer.Ordinal); + foreach (DictionaryEntry entry in source) + { + if (entry.Key == null) { continue; } + string key = Convert.ToString(entry.Key, CultureInfo.InvariantCulture); + if (string.IsNullOrWhiteSpace(key)) { continue; } + + key = key.Trim(); + + string canonical; + if (CanonicalKeys.TryGetValue(key, out canonical)) { key = canonical; } + + object value = ToJsonValue(entry.Value); + + // An empty list is dropped rather than sent. "allowed": [] does not read as "unconstrained" to + // Infisical, it reads as "allow nothing", which is never what a caller writing @{ Allowed = @() } + // intends; omitting the key leaves that dimension genuinely unconstrained. + List asList = value as List; + if (asList != null && asList.Count == 0) { continue; } + + result[key] = value; + } + + return result.Count > 0 ? result : null; + } + + /// + /// Converts a list of dictionaries, which is the shape Infisical uses for policy subject and SAN + /// constraints. + /// + internal static List> ToJsonObjectList(IEnumerable source) + { + if (source == null) { return null; } + + List> result = new List>(); + foreach (object item in source) + { + IDictionary dictionary = UnwrapDictionary(item); + if (dictionary == null) { continue; } + + Dictionary converted = ToJsonObject(dictionary); + if (converted == null) { continue; } + + // An entry carrying only "type" states a dimension without constraining it, which Infisical + // rejects outright. Dropping it is what the caller meant by leaving the lists empty. + bool hasConstraint = converted.ContainsKey("allowed") || converted.ContainsKey("required") || converted.ContainsKey("denied"); + if (!hasConstraint) { continue; } + + result.Add(converted); + } + + return result.Count > 0 ? result : null; + } + + private static object ToJsonValue(object value) + { + if (value == null) { return null; } + + IDictionary nested = UnwrapDictionary(value); + if (nested != null) { return ToJsonObject(nested); } + + if (value is string) { return value; } + + IEnumerable enumerable = UnwrapEnumerable(value); + if (enumerable != null) + { + List items = new List(); + foreach (object item in enumerable) { items.Add(ToJsonValue(item)); } + return items; + } + + return value; + } + + /// + /// PowerShell hands parameters over wrapped in PSObject often enough that unwrapping has to happen at + /// every level, not only at the top. + /// + private static IDictionary UnwrapDictionary(object value) + { + if (value == null) { return null; } + if (value is IDictionary direct) { return direct; } + + PSObject wrapper = value as PSObject; + if (wrapper != null && wrapper.BaseObject is IDictionary wrapped) { return wrapped; } + + return null; + } + + private static IEnumerable UnwrapEnumerable(object value) + { + if (value is IEnumerable direct) { return direct; } + + PSObject wrapper = value as PSObject; + if (wrapper != null && wrapper.BaseObject is IEnumerable wrapped) { return wrapped; } + + return null; + } + + /// + /// Formats an expiry the way Infisical's date validator accepts it. + /// + internal static string ToApiTimestamp(DateTimeOffset? value) + { + if (!value.HasValue) { return null; } + return value.Value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + } + + internal static List ToStringList(IEnumerable values) + { + if (values == null) { return null; } + + List result = new List(); + foreach (string value in values) + { + if (!string.IsNullOrWhiteSpace(value)) { result.Add(value.Trim()); } + } + + return result.Count > 0 ? result : null; + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/PkiSubscriberWriteCmdlets.cs b/src/PSInfisicalAPI/Cmdlets/PkiSubscriberWriteCmdlets.cs new file mode 100644 index 0000000..68c3b57 --- /dev/null +++ b/src/PSInfisicalAPI/Cmdlets/PkiSubscriberWriteCmdlets.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections; +using System.Management.Automation; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Pki; + +namespace PSInfisicalAPI.Cmdlets +{ + /// + /// Creates a PKI subscriber: a named enrollment identity pinning one common name, its SAN allowlist, and its + /// key usages. + /// + [Cmdlet(VerbsCommon.New, "InfisicalPkiSubscriber", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalPkiSubscriber))] + public sealed class NewInfisicalPkiSubscriberCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "NewInfisicalPkiSubscriberCmdlet"; + + [Parameter(Mandatory = true, Position = 0)] public string Name { get; set; } + + /// + /// The certificate signed for this subscriber must carry exactly this common name; Infisical rejects a + /// request whose CSR names anything else. + /// + [Parameter(Mandatory = true)] public string CommonName { get; set; } + + [Parameter(Mandatory = true)] public string CaId { get; set; } + [Parameter] public string ProjectId { get; set; } + [Parameter] public string Ttl { get; set; } + + /// Allowlist of subject alternative names. A CSR naming anything outside it is rejected. + [Parameter] public string[] SubjectAlternativeName { get; set; } + + [Parameter] public string[] KeyUsage { get; set; } + [Parameter] public string[] ExtendedKeyUsage { get; set; } + + [Parameter] + [ValidateSet("active", "disabled")] + public string Status { get; set; } = "active"; + + [Parameter] public SwitchParameter EnableAutoRenewal { get; set; } + [Parameter] public int? AutoRenewalPeriodInDays { get; set; } + + /// Additional subject attributes, as @{ organization = 'Contoso'; country = 'US' }. + [Parameter] public IDictionary Properties { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(Name, "Create PKI subscriber")) { return; } + + InfisicalPkiSubscriberWriteRequestDto request = new InfisicalPkiSubscriberWriteRequestDto + { + Name = Name, + CommonName = CommonName, + CaId = CaId, + Status = Status, + Ttl = Ttl, + SubjectAlternativeNames = ToStringList(SubjectAlternativeName), + KeyUsages = ToStringList(KeyUsage), + ExtendedKeyUsages = ToStringList(ExtendedKeyUsage), + Properties = ToJsonObject(Properties) + }; + + if (MyInvocation.BoundParameters.ContainsKey("EnableAutoRenewal")) { request.EnableAutoRenewal = EnableAutoRenewal.IsPresent; } + if (AutoRenewalPeriodInDays.HasValue) { request.AutoRenewalPeriodInDays = AutoRenewalPeriodInDays.Value; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + WriteObject(client.WritePkiSubscriber(connection, ProjectId, null, request)); + } + catch (Exception exception) + { + WriteErrorForException(Component, "CreatePkiSubscriber", exception); + } + } + } + + /// + /// Updates a PKI subscriber. Only the supplied values are sent. + /// + [Cmdlet(VerbsCommon.Set, "InfisicalPkiSubscriber", SupportsShouldProcess = true)] + [OutputType(typeof(InfisicalPkiSubscriber))] + public sealed class SetInfisicalPkiSubscriberCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "SetInfisicalPkiSubscriberCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("SubscriberName", "Slug")] + public string Name { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public string NewName { get; set; } + [Parameter] public string CommonName { get; set; } + [Parameter] public string CaId { get; set; } + [Parameter] public string Ttl { get; set; } + [Parameter] public string[] SubjectAlternativeName { get; set; } + [Parameter] public string[] KeyUsage { get; set; } + [Parameter] public string[] ExtendedKeyUsage { get; set; } + + [Parameter] + [ValidateSet("active", "disabled")] + public string Status { get; set; } + + [Parameter] public SwitchParameter EnableAutoRenewal { get; set; } + [Parameter] public int? AutoRenewalPeriodInDays { get; set; } + [Parameter] public IDictionary Properties { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(Name, "Update PKI subscriber")) { return; } + + InfisicalPkiSubscriberWriteRequestDto request = new InfisicalPkiSubscriberWriteRequestDto + { + Name = NewName, + CommonName = CommonName, + CaId = CaId, + Status = Status, + Ttl = Ttl, + SubjectAlternativeNames = ToStringList(SubjectAlternativeName), + KeyUsages = ToStringList(KeyUsage), + ExtendedKeyUsages = ToStringList(ExtendedKeyUsage), + Properties = ToJsonObject(Properties) + }; + + if (MyInvocation.BoundParameters.ContainsKey("EnableAutoRenewal")) { request.EnableAutoRenewal = EnableAutoRenewal.IsPresent; } + if (AutoRenewalPeriodInDays.HasValue) { request.AutoRenewalPeriodInDays = AutoRenewalPeriodInDays.Value; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + InfisicalPkiSubscriber updated = client.WritePkiSubscriber(connection, ProjectId, Name, request); + + if (PassThru.IsPresent) { WriteObject(updated); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "UpdatePkiSubscriber", exception); + } + } + } + + /// + /// Deletes a PKI subscriber. + /// + [Cmdlet(VerbsCommon.Remove, "InfisicalPkiSubscriber", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)] + public sealed class RemoveInfisicalPkiSubscriberCmdlet : InfisicalPkiWriteCmdletBase + { + private const string Component = "RemoveInfisicalPkiSubscriberCmdlet"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [Alias("SubscriberName", "Slug")] + public string Name { get; set; } + + [Parameter] public string ProjectId { get; set; } + [Parameter] public SwitchParameter PassThru { get; set; } + + protected override void ProcessRecord() + { + try + { + InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); + if (string.IsNullOrEmpty(ProjectId)) { return; } + + if (!ShouldProcess(Name, "Delete PKI subscriber")) { return; } + + InfisicalPkiManagementClient client = new InfisicalPkiManagementClient(HttpClient, Logger); + client.DeletePkiSubscriber(connection, ProjectId, Name); + + if (PassThru.IsPresent) { WriteObject(Name); } + } + catch (Exception exception) + { + WriteErrorForException(Component, "DeletePkiSubscriber", exception); + } + } + } +} diff --git a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs index bd45ba3..d796dc2 100644 --- a/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs +++ b/src/PSInfisicalAPI/Cmdlets/RequestInfisicalCertificateCmdlet.cs @@ -28,7 +28,7 @@ namespace PSInfisicalAPI.Cmdlets [Alias("ProfileId")] public string CertificateProfileId { get; set; } - [Parameter(Mandatory = true)] public string ProjectId { get; set; } + [Parameter] public string ProjectId { get; set; } [Parameter] public IDictionary Subject { get; set; } [Parameter] public string CommonName { get; set; } [Parameter] public string Country { get; set; } @@ -85,6 +85,11 @@ namespace PSInfisicalAPI.Cmdlets try { InfisicalConnection connection = InfisicalSessionManager.RequireCurrent(); + + // The UI never asks which Certificate Manager project to use when an organization has only + // one; -ProjectId is optional here for the same reason. Assigned back so every call below + // sees the resolved value without threading a second variable through. + ProjectId = ResolveCertManagerProjectId(connection, ProjectId); InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger); // Resolved once so reuse detection looks in the same stores the install will write to. diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs index fad8a05..812c1e1 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointNames.cs @@ -62,6 +62,31 @@ namespace PSInfisicalAPI.Endpoints public const string RetrieveCertificate = "RetrieveCertificate"; public const string GetCertificateBundle = "GetCertificateBundle"; public const string UpdateCertificateMetadata = "UpdateCertificateMetadata"; + + public const string CreateInternalCertificateAuthority = "CreateInternalCertificateAuthority"; + public const string UpdateInternalCertificateAuthority = "UpdateInternalCertificateAuthority"; + public const string DeleteInternalCertificateAuthority = "DeleteInternalCertificateAuthority"; + public const string GetCertificateAuthorityCsr = "GetCertificateAuthorityCsr"; + public const string SignIntermediateCertificateAuthority = "SignIntermediateCertificateAuthority"; + public const string ImportCertificateAuthorityCertificate = "ImportCertificateAuthorityCertificate"; + + public const string CreateCertificatePolicy = "CreateCertificatePolicy"; + public const string UpdateCertificatePolicy = "UpdateCertificatePolicy"; + public const string DeleteCertificatePolicy = "DeleteCertificatePolicy"; + + public const string CreateCertificateProfile = "CreateCertificateProfile"; + public const string UpdateCertificateProfile = "UpdateCertificateProfile"; + public const string DeleteCertificateProfile = "DeleteCertificateProfile"; + + public const string CreateCertificateApplication = "CreateCertificateApplication"; + public const string UpdateCertificateApplication = "UpdateCertificateApplication"; + public const string DeleteCertificateApplication = "DeleteCertificateApplication"; + public const string AddCertificateApplicationProfiles = "AddCertificateApplicationProfiles"; + public const string RemoveCertificateApplicationProfile = "RemoveCertificateApplicationProfile"; + + public const string CreatePkiSubscriber = "CreatePkiSubscriber"; + public const string UpdatePkiSubscriber = "UpdatePkiSubscriber"; + public const string DeletePkiSubscriber = "DeletePkiSubscriber"; public const string SignCertificateBySubscriber = "SignCertificateBySubscriber"; public const string SignCertificateByCa = "SignCertificateByCa"; public const string IssueCertificateByProfile = "IssueCertificateByProfile"; diff --git a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs index ad98ee2..11ef0ad 100644 --- a/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs +++ b/src/PSInfisicalAPI/Endpoints/InfisicalEndpointRegistry.cs @@ -288,6 +288,18 @@ namespace PSInfisicalAPI.Endpoints private static void RegisterProjects(Dictionary> map) { + // /api/v1/projects is the current route; /api/v1/workspace mounts Infisical's deprecated project + // router and is kept only as a fallback for older servers. + Add(map, new InfisicalEndpointDefinition + { + Name = InfisicalEndpointNames.ListProjects, + Resource = "Projects", + Version = "v1", + Method = "GET", + Template = "/api/v1/projects", + RequiresAuthorization = true + }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.ListProjects, @@ -500,6 +512,19 @@ namespace PSInfisicalAPI.Endpoints private static void RegisterOrganizations(Dictionary> map) { + Add(map, new InfisicalEndpointDefinition + { + // The listing lives on the singular v1 route. /api/v2/organizations mounts only + // /:organizationId/* sub-routes and has no GET /, so asking it for a list returns + // "Route GET:/api/v2/organizations not found". Kept as a fallback candidate all the same. + Name = InfisicalEndpointNames.ListOrganizations, + Resource = "Organizations", + Version = "v1", + Method = "GET", + Template = "/api/v1/organization", + RequiresAuthorization = true + }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.ListOrganizations, @@ -656,6 +681,11 @@ namespace PSInfisicalAPI.Endpoints RequiresAuthorization = true }); + // RetrieveCertificate and GetCertificateBundle deliberately lead with the older /api/v1/pki route: + // it resolves a certificate by SERIAL NUMBER, which is what callers supply. The newer + // /api/v1/cert-manager route takes a certificate ID and passes it straight through as + // getCert({ id }), so it cannot answer a serial. Both are registered so either identifier resolves; + // reordering these two would send every serial lookup to the route that cannot serve it. Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.RetrieveCertificate, @@ -698,6 +728,43 @@ namespace PSInfisicalAPI.Endpoints ContainsSecretMaterialInResponse = true }); + #region PKI configuration management + + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.CreateInternalCertificateAuthority, Resource = "Pki", Version = "v1", Method = "POST", Template = "/api/v1/cert-manager/ca/internal", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.CreateInternalCertificateAuthority, Resource = "Pki", Version = "v1", Method = "POST", Template = "/api/v1/pki/ca/internal", RequiresAuthorization = true }); + + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.UpdateInternalCertificateAuthority, Resource = "Pki", Version = "v1", Method = "PATCH", Template = "/api/v1/cert-manager/ca/internal/{caId}", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.UpdateInternalCertificateAuthority, Resource = "Pki", Version = "v1", Method = "PATCH", Template = "/api/v1/pki/ca/internal/{caId}", RequiresAuthorization = true }); + + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.DeleteInternalCertificateAuthority, Resource = "Pki", Version = "v1", Method = "DELETE", Template = "/api/v1/cert-manager/ca/internal/{caId}", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.DeleteInternalCertificateAuthority, Resource = "Pki", Version = "v1", Method = "DELETE", Template = "/api/v1/pki/ca/internal/{caId}", RequiresAuthorization = true }); + + // Standing up a subordinate takes three calls: Infisical creates it pending, then the CSR is signed + // by the parent and imported back. + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.GetCertificateAuthorityCsr, Resource = "Pki", Version = "v1", Method = "GET", Template = "/api/v1/pki/ca/{caId}/csr", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.SignIntermediateCertificateAuthority, Resource = "Pki", Version = "v1", Method = "POST", Template = "/api/v1/pki/ca/{caId}/sign-intermediate", RequiresAuthorization = true, ContainsSecretMaterialInResponse = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.ImportCertificateAuthorityCertificate, Resource = "Pki", Version = "v1", Method = "POST", Template = "/api/v1/pki/ca/{caId}/import-certificate", RequiresAuthorization = true }); + + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.CreateCertificatePolicy, Resource = "Pki", Version = "v1", Method = "POST", Template = "/api/v1/cert-manager/certificate-policies", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.UpdateCertificatePolicy, Resource = "Pki", Version = "v1", Method = "PATCH", Template = "/api/v1/cert-manager/certificate-policies/{policyId}", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.DeleteCertificatePolicy, Resource = "Pki", Version = "v1", Method = "DELETE", Template = "/api/v1/cert-manager/certificate-policies/{policyId}", RequiresAuthorization = true }); + + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.CreateCertificateProfile, Resource = "Pki", Version = "v1", Method = "POST", Template = "/api/v1/cert-manager/certificate-profiles", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.UpdateCertificateProfile, Resource = "Pki", Version = "v1", Method = "PATCH", Template = "/api/v1/cert-manager/certificate-profiles/{profileId}", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.DeleteCertificateProfile, Resource = "Pki", Version = "v1", Method = "DELETE", Template = "/api/v1/cert-manager/certificate-profiles/{profileId}", RequiresAuthorization = true }); + + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.CreateCertificateApplication, Resource = "Pki", Version = "v1", Method = "POST", Template = "/api/v1/cert-manager/applications", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.UpdateCertificateApplication, Resource = "Pki", Version = "v1", Method = "PATCH", Template = "/api/v1/cert-manager/applications/{applicationId}", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.DeleteCertificateApplication, Resource = "Pki", Version = "v1", Method = "DELETE", Template = "/api/v1/cert-manager/applications/{applicationId}", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.AddCertificateApplicationProfiles, Resource = "Pki", Version = "v1", Method = "POST", Template = "/api/v1/cert-manager/applications/{applicationId}/profiles", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.RemoveCertificateApplicationProfile, Resource = "Pki", Version = "v1", Method = "DELETE", Template = "/api/v1/cert-manager/applications/{applicationId}/profiles/{profileId}", RequiresAuthorization = true }); + + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.CreatePkiSubscriber, Resource = "Pki", Version = "v1", Method = "POST", Template = "/api/v1/pki/subscribers", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.UpdatePkiSubscriber, Resource = "Pki", Version = "v1", Method = "PATCH", Template = "/api/v1/pki/subscribers/{subscriberName}", RequiresAuthorization = true }); + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.DeletePkiSubscriber, Resource = "Pki", Version = "v1", Method = "DELETE", Template = "/api/v1/pki/subscribers/{subscriberName}", RequiresAuthorization = true }); + + #endregion + Add(map, new InfisicalEndpointDefinition { Name = InfisicalEndpointNames.UpdateCertificateMetadata, @@ -735,7 +802,7 @@ namespace PSInfisicalAPI.Endpoints Resource = "Pki", Version = "v1", Method = "POST", - Template = "/api/v1/pki/ca/{caId}/sign-certificate", + Template = "/api/v1/cert-manager/ca/{caId}/sign-certificate", RequiresAuthorization = true, ContainsSecretMaterialInResponse = true }); @@ -746,7 +813,7 @@ namespace PSInfisicalAPI.Endpoints Resource = "Pki", Version = "v1", Method = "POST", - Template = "/api/v1/cert-manager/ca/{caId}/sign-certificate", + Template = "/api/v1/pki/ca/{caId}/sign-certificate", RequiresAuthorization = true, ContainsSecretMaterialInResponse = true }); diff --git a/src/PSInfisicalAPI/Models/InfisicalOrganization.cs b/src/PSInfisicalAPI/Models/InfisicalOrganization.cs index bace43e..cac132d 100644 --- a/src/PSInfisicalAPI/Models/InfisicalOrganization.cs +++ b/src/PSInfisicalAPI/Models/InfisicalOrganization.cs @@ -9,6 +9,12 @@ namespace PSInfisicalAPI.Models public string Slug { get; set; } public string CustomerId { get; set; } public bool AuthEnforced { get; set; } + + /// + /// The organization's active Certificate Manager project. Certificate applications are only available on + /// this project, so it is what a PKI call resolves to when an organization has more than one. + /// + public string DefaultCertManagerProjectId { get; set; } public bool ScimEnabled { get; set; } public DateTimeOffset? CreatedAtUtc { get; set; } public DateTimeOffset? UpdatedAtUtc { get; set; } diff --git a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationClient.cs b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationClient.cs index 18f7d67..ed4cc23 100644 --- a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationClient.cs +++ b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationClient.cs @@ -33,7 +33,7 @@ namespace PSInfisicalAPI.Organizations try { _logger.Information(Component, "Attempting to list Infisical organizations. Please Wait..."); - InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.ListOrganizations, "ListOrganizations", null, null, null); + InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.ListOrganizations, "ListOrganizations", null, null, null); InfisicalOrganizationListResponseDto dto = _serializer.Deserialize(response.Body); response.Clear(); diff --git a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationDtos.cs b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationDtos.cs index 9122bb9..48a07f7 100644 --- a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationDtos.cs +++ b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationDtos.cs @@ -11,6 +11,7 @@ namespace PSInfisicalAPI.Organizations [JsonProperty("slug")] public string Slug { get; set; } [JsonProperty("customerId")] public string CustomerId { get; set; } [JsonProperty("authEnforced")] public bool AuthEnforced { get; set; } + [JsonProperty("defaultCertManagerProjectId", NullValueHandling = NullValueHandling.Ignore)] public string DefaultCertManagerProjectId { get; set; } [JsonProperty("scimEnabled")] public bool ScimEnabled { get; set; } [JsonProperty("createdAt")] public string CreatedAt { get; set; } [JsonProperty("updatedAt")] public string UpdatedAt { get; set; } diff --git a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationMapper.cs b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationMapper.cs index 01f986b..b82b8e2 100644 --- a/src/PSInfisicalAPI/Organizations/InfisicalOrganizationMapper.cs +++ b/src/PSInfisicalAPI/Organizations/InfisicalOrganizationMapper.cs @@ -21,6 +21,7 @@ namespace PSInfisicalAPI.Organizations Slug = dto.Slug, CustomerId = dto.CustomerId, AuthEnforced = dto.AuthEnforced, + DefaultCertManagerProjectId = dto.DefaultCertManagerProjectId, ScimEnabled = dto.ScimEnabled, CreatedAtUtc = ParseTimestamp(dto.CreatedAt), UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt) diff --git a/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs b/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs index 4830ab0..864e78a 100644 --- a/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs +++ b/src/PSInfisicalAPI/Pki/InfisicalPkiClient.cs @@ -530,6 +530,16 @@ namespace PSInfisicalAPI.Pki } public InfisicalCertificateProfile[] ListCertificateProfiles(InfisicalConnection connection, string projectId, int? limit, int? offset, bool? includeConfigs) + { + return ListCertificateProfiles(connection, projectId, limit, offset, includeConfigs, null, null); + } + + /// + /// Lists certificate profiles, optionally narrowed to one application or issuing CA. Applications are + /// how the UI groups profiles, so filtering by application is what makes a script read the way the + /// console does. + /// + public InfisicalCertificateProfile[] ListCertificateProfiles(InfisicalConnection connection, string projectId, int? limit, int? offset, bool? includeConfigs, string applicationId, string caId) { if (connection == null) { throw new ArgumentNullException(nameof(connection)); } if (string.IsNullOrEmpty(projectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } @@ -541,6 +551,8 @@ namespace PSInfisicalAPI.Pki if (limit.HasValue) { query.Add(new KeyValuePair("limit", limit.Value.ToString(CultureInfo.InvariantCulture))); } if (offset.HasValue) { query.Add(new KeyValuePair("offset", offset.Value.ToString(CultureInfo.InvariantCulture))); } if (includeConfigs.HasValue) { query.Add(new KeyValuePair("includeConfigs", includeConfigs.Value ? "true" : "false")); } + if (!string.IsNullOrEmpty(applicationId)) { query.Add(new KeyValuePair("applicationId", applicationId)); } + if (!string.IsNullOrEmpty(caId)) { query.Add(new KeyValuePair("caId", caId)); } try { diff --git a/src/PSInfisicalAPI/Pki/InfisicalPkiManagementClient.cs b/src/PSInfisicalAPI/Pki/InfisicalPkiManagementClient.cs new file mode 100644 index 0000000..0f40883 --- /dev/null +++ b/src/PSInfisicalAPI/Pki/InfisicalPkiManagementClient.cs @@ -0,0 +1,443 @@ +using System; +using System.Collections.Generic; +using PSInfisicalAPI.Connections; +using PSInfisicalAPI.Endpoints; +using PSInfisicalAPI.Errors; +using PSInfisicalAPI.Http; +using PSInfisicalAPI.Logging; +using PSInfisicalAPI.Models; +using PSInfisicalAPI.Serialization; + +namespace PSInfisicalAPI.Pki +{ + /// + /// Create, update, and delete operations for the objects that make up a Certificate Manager environment: + /// certificate authorities, policies, profiles, applications, and PKI subscribers. + /// + /// Kept apart from , which reads and issues. These are the operations that + /// change how a project is configured rather than what it has issued. + /// + /// + internal sealed class InfisicalPkiManagementClient + { + private const string Component = "PkiManagementClient"; + + private readonly InfisicalApiInvoker _invoker; + private readonly IInfisicalLogger _logger; + private readonly JsonInfisicalSerializer _serializer; + + public InfisicalPkiManagementClient(IInfisicalHttpClient httpClient, IInfisicalLogger logger) + { + if (httpClient == null) { throw new ArgumentNullException(nameof(httpClient)); } + _logger = logger ?? NullInfisicalLogger.Instance; + _invoker = new InfisicalApiInvoker(httpClient); + _serializer = new JsonInfisicalSerializer(); + } + + #region Certificate authority + + public InfisicalCertificateAuthority CreateInternalCertificateAuthority( + InfisicalConnection connection, + string projectId, + string name, + string type, + string commonName, + string organization, + string organizationalUnit, + string country, + string state, + string locality, + string keyAlgorithm, + string friendlyName, + string notBefore, + string notAfter, + int? maxPathLength) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(name)) { throw new InfisicalConfigurationException("Name is required."); } + if (string.IsNullOrEmpty(type)) { throw new InfisicalConfigurationException("Type is required."); } + + bool isRoot = string.Equals(type, "root", StringComparison.OrdinalIgnoreCase); + + InfisicalCreateInternalCaRequestDto request = new InfisicalCreateInternalCaRequestDto + { + ProjectId = projectId, + Name = name, + Status = "active", + Configuration = new InfisicalCreateInternalCaConfigurationDto + { + Type = type, + CommonName = commonName ?? string.Empty, + Organization = organization ?? string.Empty, + OrganizationalUnit = organizationalUnit ?? string.Empty, + Country = country ?? string.Empty, + State = state ?? string.Empty, + Locality = locality ?? string.Empty, + KeyAlgorithm = keyAlgorithm, + FriendlyName = friendlyName, + // Infisical self-signs on creation only for a root, and only when given an expiry. A + // subordinate is created pending a certificate and signed separately. + NotBefore = isRoot ? notBefore : null, + NotAfter = isRoot ? notAfter : null, + MaxPathLength = isRoot ? maxPathLength : null + } + }; + + return Execute( + connection, + InfisicalEndpointNames.CreateInternalCertificateAuthority, + null, + _serializer.Serialize(request), + string.Concat("create ", type, " certificate authority '", name, "'"), + body => + { + InfisicalInternalCaSingleResponseDto dto = _serializer.Deserialize(body); + InfisicalInternalCaResponseDto inner = dto != null ? (dto.CertificateAuthority ?? dto.Ca) : null; + return InfisicalCaMapper.Map(inner, projectId); + }); + } + + public InfisicalCertificateAuthority UpdateInternalCertificateAuthority(InfisicalConnection connection, string projectId, string caId, string name, string status) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(caId)) { throw new InfisicalConfigurationException("CaId is required."); } + if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(status)) + { + throw new InfisicalConfigurationException("Supply -Name or -Status; there is nothing else to update on an internal certificate authority."); + } + + InfisicalUpdateInternalCaRequestDto request = new InfisicalUpdateInternalCaRequestDto { ProjectId = projectId, Name = name, Status = status }; + + return Execute( + connection, + InfisicalEndpointNames.UpdateInternalCertificateAuthority, + new Dictionary { { "caId", caId } }, + _serializer.Serialize(request), + string.Concat("update certificate authority '", caId, "'"), + body => + { + InfisicalInternalCaSingleResponseDto dto = _serializer.Deserialize(body); + InfisicalInternalCaResponseDto inner = dto != null ? (dto.CertificateAuthority ?? dto.Ca) : null; + return InfisicalCaMapper.Map(inner, projectId); + }); + } + + public void DeleteInternalCertificateAuthority(InfisicalConnection connection, string projectId, string caId) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(caId)) { throw new InfisicalConfigurationException("CaId is required."); } + + Execute( + connection, + InfisicalEndpointNames.DeleteInternalCertificateAuthority, + new Dictionary { { "caId", caId } }, + null, + string.Concat("delete certificate authority '", caId, "'"), + body => null, + BuildProjectQuery(projectId)); + } + + /// + /// Signs a newly created subordinate with its parent and imports the result, taking it from + /// pending-certificate to active. Infisical creates a subordinate without a certificate and exposes no + /// single call that does this. + /// + public void CompleteSubordinateCertificateAuthority(InfisicalConnection connection, string projectId, string subordinateCaId, string parentCaId, string notAfter, int maxPathLength) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(subordinateCaId)) { throw new InfisicalConfigurationException("CaId is required."); } + if (string.IsNullOrEmpty(parentCaId)) { throw new InfisicalConfigurationException("ParentCaId is required."); } + if (string.IsNullOrEmpty(notAfter)) { throw new InfisicalConfigurationException("NotAfter is required to sign a subordinate certificate authority."); } + + string csr = Execute( + connection, + InfisicalEndpointNames.GetCertificateAuthorityCsr, + new Dictionary { { "caId", subordinateCaId } }, + null, + string.Concat("read the certificate signing request for '", subordinateCaId, "'"), + body => + { + InfisicalCaCsrResponseDto dto = _serializer.Deserialize(body); + return dto != null ? dto.Csr : null; + }); + + if (string.IsNullOrEmpty(csr)) + { + throw new InfisicalApiException(string.Concat("Certificate authority '", subordinateCaId, "' returned no certificate signing request to sign.")); + } + + InfisicalSignIntermediateRequestDto signRequest = new InfisicalSignIntermediateRequestDto + { + Csr = csr, + NotAfter = notAfter, + MaxPathLength = maxPathLength + }; + + InfisicalSignIntermediateResponseDto signed = Execute( + connection, + InfisicalEndpointNames.SignIntermediateCertificateAuthority, + new Dictionary { { "caId", parentCaId } }, + _serializer.Serialize(signRequest), + string.Concat("sign '", subordinateCaId, "' with '", parentCaId, "'"), + body => _serializer.Deserialize(body)); + + if (signed == null || string.IsNullOrEmpty(signed.Certificate)) + { + throw new InfisicalApiException("Signing the subordinate certificate authority returned no certificate."); + } + + InfisicalImportCaCertificateRequestDto importRequest = new InfisicalImportCaCertificateRequestDto + { + Certificate = signed.Certificate, + CertificateChain = signed.CertificateChain + }; + + Execute( + connection, + InfisicalEndpointNames.ImportCertificateAuthorityCertificate, + new Dictionary { { "caId", subordinateCaId } }, + _serializer.Serialize(importRequest), + string.Concat("import the signed certificate onto '", subordinateCaId, "'"), + body => null); + } + + #endregion + + #region Certificate policy + + public InfisicalCertificatePolicy WriteCertificatePolicy(InfisicalConnection connection, string projectId, string policyId, InfisicalCertificatePolicyWriteRequestDto request) + { + Require(connection, projectId); + if (request == null) { throw new ArgumentNullException(nameof(request)); } + + bool creating = string.IsNullOrEmpty(policyId); + request.ProjectId = projectId; + + return Execute( + connection, + creating ? InfisicalEndpointNames.CreateCertificatePolicy : InfisicalEndpointNames.UpdateCertificatePolicy, + creating ? null : new Dictionary { { "policyId", policyId } }, + _serializer.Serialize(request), + creating ? string.Concat("create certificate policy '", request.Name, "'") : string.Concat("update certificate policy '", policyId, "'"), + body => + { + InfisicalCertificatePolicySingleResponseDto dto = _serializer.Deserialize(body); + return InfisicalCertificatePolicyMapper.Map(dto != null ? dto.CertificatePolicy : null, projectId); + }); + } + + public void DeleteCertificatePolicy(InfisicalConnection connection, string projectId, string policyId) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(policyId)) { throw new InfisicalConfigurationException("PolicyId is required."); } + + Execute( + connection, + InfisicalEndpointNames.DeleteCertificatePolicy, + new Dictionary { { "policyId", policyId } }, + null, + string.Concat("delete certificate policy '", policyId, "'"), + body => null, + BuildProjectQuery(projectId)); + } + + #endregion + + #region Certificate profile + + public InfisicalCertificateProfile WriteCertificateProfile(InfisicalConnection connection, string projectId, string profileId, InfisicalCertificateProfileWriteRequestDto request) + { + Require(connection, projectId); + if (request == null) { throw new ArgumentNullException(nameof(request)); } + + bool creating = string.IsNullOrEmpty(profileId); + request.ProjectId = projectId; + + return Execute( + connection, + creating ? InfisicalEndpointNames.CreateCertificateProfile : InfisicalEndpointNames.UpdateCertificateProfile, + creating ? null : new Dictionary { { "profileId", profileId } }, + _serializer.Serialize(request), + creating ? string.Concat("create certificate profile '", request.Slug, "'") : string.Concat("update certificate profile '", profileId, "'"), + body => + { + InfisicalCertificateProfileSingleResponseDto dto = _serializer.Deserialize(body); + return InfisicalCertificateProfileMapper.Map(dto != null ? dto.CertificateProfile : null, projectId); + }); + } + + public void DeleteCertificateProfile(InfisicalConnection connection, string projectId, string profileId) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(profileId)) { throw new InfisicalConfigurationException("ProfileId is required."); } + + Execute( + connection, + InfisicalEndpointNames.DeleteCertificateProfile, + new Dictionary { { "profileId", profileId } }, + null, + string.Concat("delete certificate profile '", profileId, "'"), + body => null, + BuildProjectQuery(projectId)); + } + + #endregion + + #region Certificate application + + public InfisicalCertificateApplication WriteCertificateApplication(InfisicalConnection connection, string projectId, string applicationId, InfisicalCertificateApplicationWriteRequestDto request) + { + Require(connection, projectId); + if (request == null) { throw new ArgumentNullException(nameof(request)); } + + bool creating = string.IsNullOrEmpty(applicationId); + request.ProjectId = projectId; + + return Execute( + connection, + creating ? InfisicalEndpointNames.CreateCertificateApplication : InfisicalEndpointNames.UpdateCertificateApplication, + creating ? null : new Dictionary { { "applicationId", applicationId } }, + _serializer.Serialize(request), + creating ? string.Concat("create certificate application '", request.Name, "'") : string.Concat("update certificate application '", applicationId, "'"), + body => + { + InfisicalCertificateApplicationSingleResponseDto dto = _serializer.Deserialize(body); + return InfisicalCertificateApplicationMapper.Map(dto != null ? dto.Application : null, projectId); + }); + } + + public void DeleteCertificateApplication(InfisicalConnection connection, string projectId, string applicationId) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(applicationId)) { throw new InfisicalConfigurationException("ApplicationId is required."); } + + Execute( + connection, + InfisicalEndpointNames.DeleteCertificateApplication, + new Dictionary { { "applicationId", applicationId } }, + null, + string.Concat("delete certificate application '", applicationId, "'"), + body => null, + BuildProjectQuery(projectId)); + } + + public void AddCertificateApplicationProfiles(InfisicalConnection connection, string projectId, string applicationId, IEnumerable profileIds) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(applicationId)) { throw new InfisicalConfigurationException("ApplicationId is required."); } + + List ids = new List(); + if (profileIds != null) { foreach (string id in profileIds) { if (!string.IsNullOrEmpty(id)) { ids.Add(id); } } } + if (ids.Count == 0) { throw new InfisicalConfigurationException("At least one ProfileId is required."); } + + InfisicalCertificateApplicationWriteRequestDto request = new InfisicalCertificateApplicationWriteRequestDto { ProjectId = projectId, ProfileIds = ids }; + + Execute( + connection, + InfisicalEndpointNames.AddCertificateApplicationProfiles, + new Dictionary { { "applicationId", applicationId } }, + _serializer.Serialize(request), + string.Concat("attach ", ids.Count.ToString(System.Globalization.CultureInfo.InvariantCulture), " profile(s) to application '", applicationId, "'"), + body => null); + } + + public void RemoveCertificateApplicationProfile(InfisicalConnection connection, string projectId, string applicationId, string profileId) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(applicationId)) { throw new InfisicalConfigurationException("ApplicationId is required."); } + if (string.IsNullOrEmpty(profileId)) { throw new InfisicalConfigurationException("ProfileId is required."); } + + Execute( + connection, + InfisicalEndpointNames.RemoveCertificateApplicationProfile, + new Dictionary { { "applicationId", applicationId }, { "profileId", profileId } }, + null, + string.Concat("detach profile '", profileId, "' from application '", applicationId, "'"), + body => null, + BuildProjectQuery(projectId)); + } + + #endregion + + #region PKI subscriber + + public InfisicalPkiSubscriber WritePkiSubscriber(InfisicalConnection connection, string projectId, string subscriberName, InfisicalPkiSubscriberWriteRequestDto request) + { + Require(connection, projectId); + if (request == null) { throw new ArgumentNullException(nameof(request)); } + + bool creating = string.IsNullOrEmpty(subscriberName); + request.ProjectId = projectId; + + return Execute( + connection, + creating ? InfisicalEndpointNames.CreatePkiSubscriber : InfisicalEndpointNames.UpdatePkiSubscriber, + creating ? null : new Dictionary { { "subscriberName", subscriberName } }, + _serializer.Serialize(request), + creating ? string.Concat("create PKI subscriber '", request.Name, "'") : string.Concat("update PKI subscriber '", subscriberName, "'"), + // The subscriber routes return the record unwrapped, matching how GetPkiSubscriber reads it. + body => InfisicalPkiSubscriberMapper.Map(_serializer.Deserialize(body), projectId)); + } + + public void DeletePkiSubscriber(InfisicalConnection connection, string projectId, string subscriberName) + { + Require(connection, projectId); + if (string.IsNullOrEmpty(subscriberName)) { throw new InfisicalConfigurationException("Name is required."); } + + Execute( + connection, + InfisicalEndpointNames.DeletePkiSubscriber, + new Dictionary { { "subscriberName", subscriberName } }, + null, + string.Concat("delete PKI subscriber '", subscriberName, "'"), + body => null, + BuildProjectQuery(projectId)); + } + + #endregion + + #region Plumbing + + private static void Require(InfisicalConnection connection, string projectId) + { + if (connection == null) { throw new ArgumentNullException(nameof(connection)); } + if (string.IsNullOrEmpty(projectId)) { throw new InfisicalConfigurationException("ProjectId is required."); } + } + + private static List> BuildProjectQuery(string projectId) + { + // DELETE carries no body, so the project has to travel on the query string for Infisical's + // project-injection middleware to see it. + return new List> { new KeyValuePair("projectId", projectId) }; + } + + private T Execute( + InfisicalConnection connection, + string endpointName, + Dictionary pathParameters, + string body, + string description, + Func project, + List> query = null) + { + try + { + _logger.Information(Component, string.Concat("Attempting to ", description, ". Please Wait...")); + InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, endpointName, endpointName, pathParameters, query, body); + string payload = response.Body; + response.Clear(); + + T result = project(payload); + _logger.Information(Component, string.Concat("Infisical operation succeeded: ", description, ".")); + return result; + } + catch (Exception) + { + _logger.Error(Component, string.Concat("Infisical operation failed: ", description, ".")); + throw; + } + } + + #endregion + } +} diff --git a/src/PSInfisicalAPI/Pki/InfisicalPkiManagementDtos.cs b/src/PSInfisicalAPI/Pki/InfisicalPkiManagementDtos.cs new file mode 100644 index 0000000..545d8c6 --- /dev/null +++ b/src/PSInfisicalAPI/Pki/InfisicalPkiManagementDtos.cs @@ -0,0 +1,152 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace PSInfisicalAPI.Pki +{ + #region Certificate authority + + internal sealed class InfisicalCreateInternalCaRequestDto + { + [JsonProperty("projectId", NullValueHandling = NullValueHandling.Ignore)] public string ProjectId { get; set; } + [JsonProperty("name")] public string Name { get; set; } + [JsonProperty("status")] public string Status { get; set; } + [JsonProperty("configuration")] public InfisicalCreateInternalCaConfigurationDto Configuration { get; set; } + } + + internal sealed class InfisicalCreateInternalCaConfigurationDto + { + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("commonName")] public string CommonName { get; set; } + [JsonProperty("organization")] public string Organization { get; set; } + [JsonProperty("ou")] public string OrganizationalUnit { get; set; } + [JsonProperty("country")] public string Country { get; set; } + [JsonProperty("province")] public string State { get; set; } + [JsonProperty("locality")] public string Locality { get; set; } + [JsonProperty("keyAlgorithm")] public string KeyAlgorithm { get; set; } + [JsonProperty("friendlyName", NullValueHandling = NullValueHandling.Ignore)] public string FriendlyName { get; set; } + [JsonProperty("notBefore", NullValueHandling = NullValueHandling.Ignore)] public string NotBefore { get; set; } + [JsonProperty("notAfter", NullValueHandling = NullValueHandling.Ignore)] public string NotAfter { get; set; } + [JsonProperty("maxPathLength", NullValueHandling = NullValueHandling.Ignore)] public int? MaxPathLength { get; set; } + } + + internal sealed class InfisicalUpdateInternalCaRequestDto + { + [JsonProperty("projectId", NullValueHandling = NullValueHandling.Ignore)] public string ProjectId { get; set; } + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } + [JsonProperty("status", NullValueHandling = NullValueHandling.Ignore)] public string Status { get; set; } + } + + internal sealed class InfisicalCaCsrResponseDto + { + [JsonProperty("csr")] public string Csr { get; set; } + } + + internal sealed class InfisicalSignIntermediateRequestDto + { + [JsonProperty("csr")] public string Csr { get; set; } + [JsonProperty("notBefore", NullValueHandling = NullValueHandling.Ignore)] public string NotBefore { get; set; } + [JsonProperty("notAfter")] public string NotAfter { get; set; } + [JsonProperty("maxPathLength")] public int MaxPathLength { get; set; } + } + + internal sealed class InfisicalSignIntermediateResponseDto + { + [JsonProperty("certificate")] public string Certificate { get; set; } + [JsonProperty("certificateChain")] public string CertificateChain { get; set; } + [JsonProperty("issuingCaCertificate")] public string IssuingCaCertificate { get; set; } + [JsonProperty("serialNumber")] public string SerialNumber { get; set; } + } + + internal sealed class InfisicalImportCaCertificateRequestDto + { + [JsonProperty("certificate")] public string Certificate { get; set; } + [JsonProperty("certificateChain")] public string CertificateChain { get; set; } + } + + #endregion + + #region Certificate policy + + internal sealed class InfisicalCertificatePolicyWriteRequestDto + { + [JsonProperty("projectId", NullValueHandling = NullValueHandling.Ignore)] public string ProjectId { get; set; } + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] public string Description { get; set; } + [JsonProperty("subject", NullValueHandling = NullValueHandling.Ignore)] public List> Subject { get; set; } + [JsonProperty("sans", NullValueHandling = NullValueHandling.Ignore)] public List> Sans { get; set; } + [JsonProperty("keyUsages", NullValueHandling = NullValueHandling.Ignore)] public Dictionary KeyUsages { get; set; } + [JsonProperty("extendedKeyUsages", NullValueHandling = NullValueHandling.Ignore)] public Dictionary ExtendedKeyUsages { get; set; } + [JsonProperty("algorithms", NullValueHandling = NullValueHandling.Ignore)] public Dictionary Algorithms { get; set; } + [JsonProperty("validity", NullValueHandling = NullValueHandling.Ignore)] public Dictionary Validity { get; set; } + [JsonProperty("basicConstraints", NullValueHandling = NullValueHandling.Ignore)] public Dictionary BasicConstraints { get; set; } + } + + internal sealed class InfisicalCertificatePolicySingleResponseDto + { + [JsonProperty("certificatePolicy")] public InfisicalCertificatePolicyResponseDto CertificatePolicy { get; set; } + } + + #endregion + + #region Certificate profile + + internal sealed class InfisicalCertificateProfileWriteRequestDto + { + [JsonProperty("projectId", NullValueHandling = NullValueHandling.Ignore)] public string ProjectId { get; set; } + [JsonProperty("slug", NullValueHandling = NullValueHandling.Ignore)] public string Slug { get; set; } + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] public string Description { get; set; } + [JsonProperty("caId", NullValueHandling = NullValueHandling.Ignore)] public string CaId { get; set; } + [JsonProperty("certificatePolicyId", NullValueHandling = NullValueHandling.Ignore)] public string CertificatePolicyId { get; set; } + [JsonProperty("enrollmentType", NullValueHandling = NullValueHandling.Ignore)] public string EnrollmentType { get; set; } + [JsonProperty("issuerType", NullValueHandling = NullValueHandling.Ignore)] public string IssuerType { get; set; } + [JsonProperty("apiConfig", NullValueHandling = NullValueHandling.Ignore)] public Dictionary ApiConfig { get; set; } + [JsonProperty("scepConfig", NullValueHandling = NullValueHandling.Ignore)] public Dictionary ScepConfig { get; set; } + [JsonProperty("estConfig", NullValueHandling = NullValueHandling.Ignore)] public Dictionary EstConfig { get; set; } + [JsonProperty("acmeConfig", NullValueHandling = NullValueHandling.Ignore)] public Dictionary AcmeConfig { get; set; } + [JsonProperty("defaults", NullValueHandling = NullValueHandling.Ignore)] public Dictionary Defaults { get; set; } + } + + internal sealed class InfisicalCertificateProfileSingleResponseDto + { + [JsonProperty("certificateProfile")] public InfisicalCertificateProfileResponseDto CertificateProfile { get; set; } + } + + #endregion + + #region Certificate application + + internal sealed class InfisicalCertificateApplicationWriteRequestDto + { + [JsonProperty("projectId", NullValueHandling = NullValueHandling.Ignore)] public string ProjectId { get; set; } + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } + [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] public string Description { get; set; } + [JsonProperty("profileIds", NullValueHandling = NullValueHandling.Ignore)] public List ProfileIds { get; set; } + } + + internal sealed class InfisicalCertificateApplicationSingleResponseDto + { + [JsonProperty("application")] public InfisicalCertificateApplicationResponseDto Application { get; set; } + } + + #endregion + + #region PKI subscriber + + internal sealed class InfisicalPkiSubscriberWriteRequestDto + { + [JsonProperty("projectId", NullValueHandling = NullValueHandling.Ignore)] public string ProjectId { get; set; } + [JsonProperty("caId", NullValueHandling = NullValueHandling.Ignore)] public string CaId { get; set; } + [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] public string Name { get; set; } + [JsonProperty("commonName", NullValueHandling = NullValueHandling.Ignore)] public string CommonName { get; set; } + [JsonProperty("status", NullValueHandling = NullValueHandling.Ignore)] public string Status { get; set; } + [JsonProperty("ttl", NullValueHandling = NullValueHandling.Ignore)] public string Ttl { get; set; } + [JsonProperty("subjectAlternativeNames", NullValueHandling = NullValueHandling.Ignore)] public List SubjectAlternativeNames { get; set; } + [JsonProperty("keyUsages", NullValueHandling = NullValueHandling.Ignore)] public List KeyUsages { get; set; } + [JsonProperty("extendedKeyUsages", NullValueHandling = NullValueHandling.Ignore)] public List ExtendedKeyUsages { get; set; } + [JsonProperty("enableAutoRenewal", NullValueHandling = NullValueHandling.Ignore)] public bool? EnableAutoRenewal { get; set; } + [JsonProperty("autoRenewalPeriodInDays", NullValueHandling = NullValueHandling.Ignore)] public int? AutoRenewalPeriodInDays { get; set; } + [JsonProperty("properties", NullValueHandling = NullValueHandling.Ignore)] public Dictionary Properties { get; set; } + } + + #endregion +} diff --git a/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs b/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs index 900f29a..3f7973d 100644 --- a/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs +++ b/src/PSInfisicalAPI/Projects/InfisicalProjectClient.cs @@ -45,7 +45,7 @@ namespace PSInfisicalAPI.Projects try { _logger.Information(Component, "Attempting to list Infisical projects. Please Wait..."); - InfisicalHttpResponse response = _invoker.Invoke(connection, InfisicalEndpointNames.ListProjects, "ListProjects", null, queryParameters, null); + InfisicalHttpResponse response = _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.ListProjects, "ListProjects", null, queryParameters, null); InfisicalProjectListResponseDto dto = _serializer.Deserialize(response.Body); response.Clear();