gsadmin 139d1f3a05 Add create, update, and delete for Certificate Manager configuration
The module was read-only for PKI configuration, which is why standing up an
environment meant raw REST rather than cmdlets. Adds 15: New-, Set-, and Remove-
for certificate authorities, policies, profiles, applications, and PKI
subscribers.

New-InfisicalCertificateAuthority -Type Intermediate returns an authority that
can actually issue. Infisical creates a subordinate with status
pending-certificate and never invokes generateIntermediateCaCertificate from the
create path, so the cmdlet performs the remaining sequence: read the certificate
signing request, sign it with -ParentCaId, import the signed certificate and
chain back.

Policy and profile bodies are deeply nested, so the constraint objects and
per-enrollment-type config blocks are taken as dictionaries rather than as
dozens of parameters, matching how -Subject and -Metadata already work. Two
conversion details matter and are now pinned by tests:

  - PowerShell callers capitalise hashtable keys, and @{ Required = ... } was
    reaching the API as "Required", which its schema does not recognise. The
    constraint vocabulary is emitted lower-cased while every other key keeps its
    camelCase, since those are API field names supplied verbatim and lowercasing
    ttlDays or isCA would silently drop them.
  - An empty collection is omitted rather than sent. "allowed": [] reads to
    Infisical as "allow nothing", never what @{ Allowed = @() } was meant to
    express, and an entry carrying only "type" is rejected outright.

-EnrollmentConfig routes to the block matching -EnrollmentType so EST, ACME, and
SCEP settings arrive through one parameter. -AutoRenew is sent only when bound,
because a switch is false when absent and sending it unconditionally would
disable renewal on an update that never mentioned it.

-ProjectId is optional on all 15 and resolves as it does elsewhere. Every one
supports -WhatIf; Remove- cmdlets default to high confirm impact.

Also fixes Update-Changelog, which inserted the version heading above the notes
so the section the release workflow extracts held only the build line while the
notes stayed under Unreleased - every release published an empty changelog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:41:13 -04:00
2026-06-02 15:51:28 +00:00

PSInfisicalAPI

A C# binary PowerShell module for interacting with the Infisical REST API. It provides cmdlets for authentication, secret retrieval, structured export, and includes automatic environment-variable discovery so connections can be established with little or no inline configuration.

  • License: AGPL-3.0
  • Author: Grace Solutions
  • Target framework: .NET Standard 2.0 (compatible with Windows PowerShell 5.1 and PowerShell 7+)

Installation

Install-Module -Name PSInfisicalAPI -Scope CurrentUser
Import-Module -Name PSInfisicalAPI

From source

git clone https://prod.git.gracesolution.info/gsadmin/PSInfisicalAPI.git
cd PSInfisicalAPI
pwsh -NoProfile -ExecutionPolicy Bypass -File .\build.ps1 -RunTests
Import-Module -Name .\Module\PSInfisicalAPI

Cmdlets

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

Cmdlet Purpose
Connect-Infisical Establishes an authenticated session with an Infisical server and stores it for use by subsequent cmdlets.
Disconnect-Infisical Clears the current Infisical session from the module-level session manager.

Secrets

Cmdlet Purpose
Get-InfisicalSecret Lists or retrieves Infisical secrets within a project, environment, and optional folder path.
New-InfisicalSecret Creates a new Infisical secret, with support for SecureString values and bulk creation.
Update-InfisicalSecret Updates an existing Infisical secret value, comment, name, or tags.
Remove-InfisicalSecret Deletes one or many Infisical secrets by name.
Copy-InfisicalSecret Duplicates one or more secrets into a different environment or secret path.
ConvertTo-InfisicalSecretDictionary Converts a stream of InfisicalSecret objects into a name-keyed Dictionary of SecureString or plain text values.
Export-InfisicalSecrets Exports InfisicalSecret objects to disk or environment variables in a chosen file format.

Organizations

Cmdlet Purpose
Get-InfisicalOrganization Lists or retrieves Infisical organizations accessible to the current identity.
New-InfisicalOrganization Creates a new Infisical organization.
Update-InfisicalOrganization Updates the name or slug of an existing Infisical organization.
Remove-InfisicalOrganization Deletes an Infisical organization.

Sub-Organizations

Cmdlet Purpose
Get-InfisicalSubOrganization Lists or retrieves Infisical sub-organizations, with optional search, paging, and ordering filters.
New-InfisicalSubOrganization Creates a new Infisical sub-organization.
Update-InfisicalSubOrganization Updates the name or slug of an existing Infisical sub-organization.
Remove-InfisicalSubOrganization Deletes an Infisical sub-organization.

Projects

Cmdlet Purpose
Get-InfisicalProject Lists or retrieves Infisical projects accessible to the current identity.
New-InfisicalProject Creates a new Infisical project in the active organization.
Update-InfisicalProject Updates the name, description, or auto-capitalization flag on an existing project.
Remove-InfisicalProject Deletes an Infisical project.

Environments

Cmdlet Purpose
Get-InfisicalEnvironment Lists or retrieves Infisical environments defined on a project.
New-InfisicalEnvironment Creates a new environment on an Infisical project.
Update-InfisicalEnvironment Updates the name, slug, or sort order of an existing Infisical environment.
Remove-InfisicalEnvironment Deletes an Infisical environment from a project.

Folders

Cmdlet Purpose
Get-InfisicalFolder Lists or retrieves Infisical folders at a given secret path.
New-InfisicalFolder Creates a new Infisical folder under the supplied parent path.
Update-InfisicalFolder Renames an existing Infisical folder.
Remove-InfisicalFolder Deletes an Infisical folder and all secrets it contains.

Tags

Cmdlet Purpose
Get-InfisicalTag Lists or retrieves Infisical tags defined on a project.
New-InfisicalTag Creates a new Infisical tag on a project.
Update-InfisicalTag Updates the slug, name, or color of an existing Infisical tag.
Remove-InfisicalTag Deletes an Infisical tag from a project.

PKI

Cmdlet Purpose
Get-InfisicalCertificateAuthority Lists or retrieves Infisical internal Certificate Authorities.
Get-InfisicalPkiSubscriber Lists or retrieves Infisical PKI subscribers in a project.
Get-InfisicalCertificate Lists or retrieves Infisical certificates in a project, with optional filters and automatic paging.
Request-InfisicalCertificate Requests a new Infisical certificate (local CSR + sign) or reuses a still-valid existing one.
ConvertTo-InfisicalCertificate Materializes an X509Certificate2 from an Infisical certificate record, bundle, or serial number.
Install-InfisicalCertificate Installs an Infisical certificate (and optional chain) into a Windows certificate store.
Uninstall-InfisicalCertificate Removes a certificate from a Windows certificate store by thumbprint, subject, or pipeline input.
Export-InfisicalCertificate Exports an Infisical certificate to disk in PEM, PFX, or CER format.
Get-InfisicalScepMdmProfile Projects an Infisical certificate profile into a Windows SCEP MDM profile model.
Export-InfisicalScepMdmProfile Writes a SCEP MDM profile to disk as a SyncML payload suitable for MDM delivery.
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, <device>.<suffix> 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":

$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
Start-InfisicalProcess Launches a child process with Infisical secrets injected directly into its environment block, capturing stdout/stderr and validating the exit code.

Use Get-Help <Cmdlet> -Full for parameter details and Get-Help about_PSInfisicalAPI for the module overview.

Quick start

$secureSecret = Read-Host -AsSecureString 'Client Secret'

$connection = Connect-Infisical `
    -BaseUri        'https://app.infisical.com' `
    -OrganizationId '00000000-0000-0000-0000-000000000000' `
    -ClientId       'machine-identity-client-id' `
    -ClientSecret   $secureSecret `
    -PassThru

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.

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.

This is the shape to use for fleet enrollment, where each machine needs its own common name. See Choosing an issuance path — a PKI subscriber is not the right tool for this, because it pins one fixed common name.

$ConnectInfisicalParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $ConnectInfisicalParameters.BaseUri        = 'https://app.infisical.com'
    $ConnectInfisicalParameters.OrganizationId = '00000000-0000-0000-0000-000000000000'
    $ConnectInfisicalParameters.ClientId       = 'machine-identity-client-id'
    $ConnectInfisicalParameters.ClientSecret   = ConvertTo-SecureString -String 'ClientSecret' -AsPlainText -Force
    $ConnectInfisicalParameters.PassThru       = $True
    $ConnectInfisicalParameters.Verbose        = $True

$Connection = Connect-Infisical @ConnectInfisicalParameters

$Application = Get-InfisicalCertificateApplication | Where-Object {($_.Name -ieq 'platform')}

$CertificateProfile = Get-InfisicalCertificateProfile -ApplicationId ($Application.Id) -IncludeConfigs | Where-Object {($_.EnrollmentType -ieq 'api') -and ($_.Slug -imatch 'server')} | Select-Object -First 1

$SanList = Get-InfisicalSANList

$RequestInfisicalCertificateParameters = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $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.Ttl                  = '90d'
    $RequestInfisicalCertificateParameters.Metadata             = [Ordered]@{ Environment = 'Production'; Owner = 'Platform' }
    $RequestInfisicalCertificateParameters.Install              = $True
    $RequestInfisicalCertificateParameters.InstallChain         = $True
    $RequestInfisicalCertificateParameters.Verbose              = $True

$Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters

$Null = Disconnect-Infisical -Verbose

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.

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:

$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:

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:

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:

Get-InfisicalCertificateApplication
Get-InfisicalCertificateAuthority -Kind Internal
Get-InfisicalCertificateProfile -IncludeConfigs
Get-InfisicalPkiSubscriber
Get-InfisicalCertificate -Status active
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:

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:

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:

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:

$Application = Get-InfisicalCertificateApplication | Where-Object {($_.Name -ieq 'platform')}
Get-InfisicalCertificateProfile -ApplicationId $Application.Id -IncludeConfigs
Get-InfisicalCertificate -ApplicationId $Application.Id

Example output

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
CaId                 : 33333333-3333-3333-3333-333333333333
CertificatePolicyId  : 55555555-5555-5555-5555-555555555555
Slug                 : serverauthentication
Description          :
EnrollmentType       : api
IssuerType           : ca
EstConfigId          :
ApiConfigId          : 66666666-6666-6666-6666-666666666666
AcmeConfigId         :
ScepConfigId         :
CreatedAtUtc         : 3/25/2026 4:53:53 PM +00:00
UpdatedAtUtc         : 3/25/2026 4:53:53 PM +00:00
Defaults             : PSInfisicalAPI.Models.InfisicalCertificateProfileDefaults
CertificateAuthority : PSInfisicalAPI.Models.InfisicalCertificateAuthoritySummary
CertificatePolicy    :
ApiConfig            : PSInfisicalAPI.Models.InfisicalCertificateProfileApiConfig

WEB01
10.20.30.40
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.
VERBOSE: [...] - [Information] - [PkiClient] - Infisical certificate search was successful.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Process is elevated; defaulting -StoreLocation to LocalMachine. Pass -StoreLocation explicitly to override.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing via certificate profile '44444444-4444-4444-4444-444444444444' in project '00000000-0000-0000-0000-000000000000'.
VERBOSE: Performing the operation "Request new certificate" on target "certificate profile '44444444-4444-4444-4444-444444444444' for CN=WEB01".
VERBOSE: [...] - [Information] - [PkiClient] - Attempting to issue certificate via profile '44444444-4444-4444-4444-444444444444'. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - Attempting HTTP POST to https://infisical.contoso.com/api/v1/cert-manager/certificates. Please Wait...
VERBOSE: [...] - [Verbose] - [HttpClient] - HTTP POST completed with status 200.
VERBOSE: [...] - [Information] - [PkiClient] - Infisical certificate issuance (profile) was successful.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\My [F480A920DFB41EA8EE3E9178C1BC6A5EC7055B96].
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\CertificateAuthority [89A486A532D94EFE4391BEF2EA7F5E7E2B654AB0].
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Installed certificate to LocalMachine\Root [1F3A77B0C2D45E6819AB3C7D0E5F2A9B4C81D6E7].

Where certificates get installed

When -StoreLocation is not supplied, the cmdlet picks it from the process's elevation, and says which it chose on the verbose stream:

Session Leaf Intermediates Roots
Elevated LocalMachine\My LocalMachine\CertificateAuthority LocalMachine\Root
Not elevated CurrentUser\My CurrentUser\CertificateAuthority CurrentUser\Root

The routing split is deliberate:

  • The leaf honours -StoreName (default My) and lands in the resolved location.
  • Chain members ignore -StoreName and are routed by what they are — a self-signed certificate is a root and goes to the trusted-root store; anything with an issuer above it is a subordinate CA and goes to the intermediate store. A chain of any depth is walked, so an issuing subordinate installs alongside every CA above it up to the root.

-StoreLocation still applies to all of them, so an elevated run puts the whole chain machine-wide and a non-elevated run puts it all under the user.

Issuers are installed before the leaf, so the certificate is chainable the moment it appears in the store. Afterwards the chain is validated against the machine's own stores, and an incomplete result is called out by name rather than left to be discovered in certmgr:

WARNING: [...] Certificate chain is incomplete (PartialChain). The highest certificate installed is
'CN=Contoso Intermediate Certificate Authority, OU=IT, O=Contoso, C=US', whose issuer
'CN=Contoso Root Certificate Authority, OU=IT, O=Contoso, C=US' is not present in the trusted stores.
Windows will report "The issuer of this certificate could not be found" until that issuer is installed.

The installed certificate's Windows friendly name defaults to the common name in upper case (WEB01), which is what shows in certmgr. Pass -FriendlyName on any parameter set to override it; on the -CertificateAuthorityId path the same value is also forwarded to Infisical as the issued certificate's friendlyName.

Metadata

-Metadata attaches key/value pairs to the certificate in Infisical, and accepts any IDictionary — a hashtable, an [Ordered] dictionary, or a generic Dictionary[String,String]:

$RequestInfisicalCertificateParameters.Metadata = [Ordered]@{
    Environment = 'Production'
    Owner       = 'Platform Engineering'
    ManagedBy   = 'Invoke-SecretStaging'
    Site        = 'HQ'
}

Only the supplied keys are reconciled. Keys already on the certificate that this call does not mention are left alone, so several callers can each own their own keys without clobbering each other:

Request-InfisicalCertificate @Parameters -Metadata @{ Owner = 'Platform' }   # certificate now has Owner
Request-InfisicalCertificate @Parameters -Metadata @{ Site  = 'HQ' }         # Owner survives; Site added
Request-InfisicalCertificate @Parameters -Metadata @{ Owner = 'Security' }   # Owner updated; Site survives

This is done client-side. Infisical's PATCH /certificates/{id} replaces a certificate's metadata wholesale — the service deletes every row before inserting what it was sent — so the module reads the current set, merges the supplied keys over it, and writes back the union. When nothing would change, no request is sent at all.

Reconciliation runs on the reuse path too, so a metadata change lands without forcing reissuance. Values are flattened to strings (443 becomes "443", $True becomes "True", $Null becomes ""), keys are trimmed and compared case-insensitively, and blank keys are dropped.

The result carries the certificate's metadata after reconciliation:

$Certificate = Request-InfisicalCertificate @RequestInfisicalCertificateParameters
$Certificate.Metadata['Environment']    # Production

Metadata never fails an issuance that otherwise succeeded. By the time it is applied the certificate exists and may already be installed, so a failure is reported as a warning and the certificate is still returned.

Metadata is also a search filter — Get-InfisicalCertificate accepts -Metadata to find certificates by the keys you stamped on them.

Reuse and renewal

A second run does not issue a new certificate if a still-valid one is already installed. That check is scoped to the issuer you asked for, not just the common name: the reuse search is filtered by -CertificateProfileId or -CertificateAuthorityId, so switching profiles issues a new certificate rather than handing back the old one.

This matters when two profiles over the same CA differ in key usage. Requesting from a client-authentication profile on a host that already holds a server-authentication certificate for the same name issues a new certificate, because a name match alone would return one with the wrong EKUs:

VERBOSE: Reuse search for CN=WEB01 scoped to certificate profile 'a42f8446-...' returned 0 active certificate(s).

Reuse also requires the existing certificate to carry every name being requested. Adding an entry to -DnsName and re-running issues a new certificate rather than returning one that would fail validation for the name you just added:

VERBOSE: An existing certificate for CN=WEB01 does not carry the requested name DNS:api.contoso.com;
         requesting a new certificate rather than reusing one that would fail validation for it.

The rule is coverage, not equality — a certificate carrying more names than requested still satisfies the request. DNS names compare case-insensitively and IP addresses are normalized, so ::1 matches 0:0:0:0:0:0:0:1. Removing a name from the request therefore reuses the existing certificate; use -Force when you need the SAN set trimmed rather than extended.

-Force issues unconditionally, and -AllowRenewal with -RenewalThresholdDays rotates a certificate that is inside its renewal window.

If Infisical cannot be reached, the reuse check cannot confirm which certificates belong to which issuer and falls back to matching on the common name alone. That is announced as a warning, since it can return a certificate from a different profile.

When the resolved location is LocalMachine and -KeyStorageFlags was not supplied, the private key is written to the machine key store. Without that the key lands in the calling user's profile while the certificate sits in LocalMachine\My, which is the usual cause of an installed certificate that reports no usable private key to a service.

Non-elevated root installs prompt. Adding a root to CurrentUser\Root makes Windows raise a modal trust dialog, and the call blocks until it is answered — if the dialog is hidden or the session is non-interactive (a scheduled task, an MECM task sequence), the cmdlet appears to hang indefinitely. It warns before blocking. Run elevated, or pass -StoreLocation LocalMachine, to install machine-wide with no prompt.

Choosing an issuance path

Request-InfisicalCertificate has three mutually exclusive issuance parameter sets. The deciding question is whether the common name varies per request:

Parameter Common name Use when
-CertificateProfileId Per request, constrained by policy Fleet enrollment — many machines, each with its own CN. Works on any CA.
-CertificateAuthorityId Per request, unconstrained Rarely usable — needs direct issuance, which cannot be enabled (see below).
-PkiSubscriberSlug Fixed by the subscriber record One named identity — a specific service or host, provisioned in advance.

A PKI subscriber is a per-identity object, not a fleet template. signSubscriberCert rejects any CSR whose CN differs from the subscriber's:

Common name (CN) in the CSR does not match the subscriber's common name

It also allowlists SANs — every dNSName/email SAN in the CSR must appear in the subscriber's subjectAlternativeNames — and requires CSR key usages to be a subset of the subscriber's. (IP SANs are not covered by that check.) Enrolling N machines through subscribers therefore means creating N subscribers. Prefer a profile.

There is no -CertificateTemplateId parameter. Infisical's REST API exposes no template-based issuance route — templates are consumed internally by EST and subscribers — so when the API says "Certificate template or subscriber is required for issuance", the reachable answers are a profile, a subscriber, or direct issuance.

The cmdlet resolves and reports the issuer before generating a keypair, so -Verbose tells you exactly what will sign the request:

VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing via certificate profile 'a1b2c3d4-...' in project '2122628e-...'.
VERBOSE: [...] - [Information] - [RequestInfisicalCertificateCmdlet] - Issuing directly via certificate authority 'intermediate-ca' (bf661d78-...); direct issuance is enabled.

-WhatIf names the same issuer without issuing anything:

Request-InfisicalCertificate @RequestInfisicalCertificateParameters -WhatIf
# What if: Performing the operation "Request new certificate" on target
#          "certificate profile 'a1b2c3d4-...' for CN=WEB01".

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.

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:

if (!isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw ... }

So a profile issues successfully against a CA whose EnableDirectIssuance is False. If a project has no profiles, create a policy then a profile under Certificate Management in the Infisical UI.

Discovering subscribers

Get-InfisicalPkiSubscriber |
    Format-Table Name, CommonName, Status, Ttl, CaId

Pass the subscriber's Name to -PkiSubscriberSlug, and set -CommonName to exactly that subscriber's CommonName. Because the subscriber owns the lifetime and usage policy, -Ttl, -KeyUsage, and -ExtendedKeyUsage are not accepted on this parameter set — set them on the subscriber in Infisical instead.

An empty result means the project has no subscribers; create one under Certificate Management > Subscribers. This module is read-only for subscribers, so creation is UI or raw API (POST /api/v1/pki/subscribers).

Direct issuance on a CA

Direct issuance lets a CA sign a bare CSR with no profile, subscriber, or template in front of it. When it is off, Infisical rejects the request with 400 Certificate template or subscriber is required for issuance; this module catches that before building a CSR.

There is no UI toggle or API field for this. enableDirectIssuance appears in no create or update schema — the generic CA schemas accept only name and status. It is set at CA creation (the column defaults to true) and is not editable afterwards through the public API.

A CA can therefore read False for a reason that is not obvious. Migration 20250521110635_add-external-ca-pki.ts renamed the older requireTemplateForIssuance column to enableDirectIssuance and inverted every existing value:

t.renameColumn("requireTemplateForIssuance", "enableDirectIssuance");
...
.update({ name: slugifiedName, enableDirectIssuance: !ca.enableDirectIssuance });

Any CA created before that migration with "require template for issuance" enabled now reads EnableDirectIssuance = False permanently.

Creating a new CA does not help either. Although the database column defaults to true, the creation service passes false explicitly:

const ca = await certificateAuthorityDAL.create({ projectId, name: resolvedCaName, status, enableDirectIssuance: false }, tx);

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.

Get-InfisicalCertificateAuthority -Kind Internal |
    Format-Table Name, CommonName, Status, EnableDirectIssuance

$Ca = Get-InfisicalCertificateAuthority -Kind Internal |
    Where-Object {($_.EnableDirectIssuance -eq $True)} |
    Select-Object -First 1

$RequestInfisicalCertificateParameters.CertificateAuthorityId = $Ca.Id
$RequestInfisicalCertificateParameters.Ttl                    = '90d'   # required by the CA path

Subject and SAN handling

  • -CommonName takes the bare value (WEB01.contoso.com), not an RDN. CN=WEB01 is accepted and normalized, since the CSR builder adds the CN= prefix itself.
  • Get-InfisicalSANList returns DNS names and IP addresses in one list. Passing the whole list to -DnsName is fine: IP literals are detected and emitted as iPAddress SAN entries rather than malformed dNSName entries.
  • -Ttl (or -NotAfter) applies to the -CertificateAuthorityId and -CertificateProfileId paths. Subscriber-issued certificates take their lifetime from the subscriber definition.

Diagnostics and error handling

Every cmdlet derives from PSCmdlet, so the full set of common parameters is bound: -Verbose, -Debug, -ErrorAction, -ErrorVariable, -WarningAction, -WarningVariable, -InformationAction, -InformationVariable, -OutVariable, -PipelineVariable, and -WhatIf/-Confirm on the cmdlets that declare SupportsShouldProcess.

Output is routed by stream so those parameters mean what they say:

Stream Carries Controlled by
Error The failure itself, once, as a non-terminating ErrorRecord -ErrorAction, -ErrorVariable, 2>
Warning Genuine advisories that are not failures (e.g. issuance returned no certificate) -WarningAction, -WarningVariable
Verbose Request/response trace and the diagnostic trail leading up to a failure -Verbose
Debug Low-level detail -Debug

A failed call surfaces exactly one error. The [Error]-tagged diagnostic lines that precede it are on the verbose stream, so they appear only under -Verbose and never compete with the ErrorRecord:

# One error, no warning noise.
Request-InfisicalCertificate @Parameters -ErrorVariable Failure -ErrorAction SilentlyContinue

# The ErrorRecord carries the API detail; no log scraping required.
$Failure[0].Exception.StatusCode      # 400
$Failure[0].Exception.ApiErrorCode    # BadRequest
$Failure[0].Exception.ApiErrorMessage # Certificate template or subscriber is required for issuance
$Failure[0].Exception.ApiRequestId    # req-SSPFN1gc2zHvkV

-ErrorAction decides the outcome

Operation failures are reported as non-terminating errors, so -ErrorAction (or $ErrorActionPreference) governs what happens, exactly as it does for built-in cmdlets:

-ErrorAction Behavior
Continue (default) Error is written; a pipeline keeps processing its remaining input
SilentlyContinue Nothing is printed; the error is still in $Error and -ErrorVariable
Ignore Nothing is printed and nothing is recorded in $Error
Stop Promoted to a terminating error that try/catch catches
Inquire Prompts

A failing item does not abort the batch:

'web01', 'does-not-exist', 'web02' |
    ForEach-Object { Get-InfisicalPkiSubscriber -Name $_ -ErrorAction SilentlyContinue }
# emits web01 and web02; the failure is available in $Error

To catch failures you must ask for it with -ErrorAction Stop or $ErrorActionPreference = 'Stop':

try {
    $Certificate = Request-InfisicalCertificate @Parameters -ErrorAction Stop
} catch [PSInfisicalAPI.Errors.InfisicalApiException] {
    Write-Warning "Issuance failed with HTTP $($_.Exception.StatusCode): $($_.Exception.ApiErrorMessage)"
}

Breaking change. Failures were previously terminating, so try/catch caught them without -ErrorAction Stop. Existing try/catch blocks need -ErrorAction Stop added (or $ErrorActionPreference = 'Stop' set) to keep catching.

Exception types are InfisicalApiException, InfisicalAuthenticationException, InfisicalHttpException, InfisicalSerializationException, InfisicalConfigurationException, InfisicalExportException, and InfisicalImportException, all deriving from InfisicalException.

Automatic environment-variable discovery

When Connect-Infisical is invoked with one or more parameters missing (or set to whitespace/empty), the cmdlet searches environment variables and uses the first value it finds. This makes invocation as simple as Connect-Infisical when variables are set up in advance.

Scope precedence

Scopes are searched in order; the first matching variable with a non-blank value wins:

  1. Process
  2. User
  3. Machine

Patterns

The resolver matches case-insensitively against patterns aligned with Infisical's CLI defaults plus common variants such as CLOUDINIT_INFISICAL_* and custom-prefixed names (e.g., myapp_infisical_client_id).

Parameter Example variable names matched
BaseUri INFISICAL_API_URL, INFISICAL_BASE_URL, INFISICAL_BASE_URI, INFISICAL_HOST
OrganizationId INFISICAL_ORG_ID, INFISICAL_ORGANIZATION_ID
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
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

[Environment]::SetEnvironmentVariable('INFISICAL_API_URL',       'https://app.infisical.com',          'User')
[Environment]::SetEnvironmentVariable('INFISICAL_ORG_ID',        '00000000-0000-0000-0000-000000000000', 'User')
[Environment]::SetEnvironmentVariable('INFISICAL_PROJECT_ID',    '11111111-1111-1111-1111-111111111111', 'User')
[Environment]::SetEnvironmentVariable('INFISICAL_ENVIRONMENT',   'dev',                                  'User')
[Environment]::SetEnvironmentVariable('INFISICAL_CLIENT_ID',     'machine-identity-client-id',           'User')
[Environment]::SetEnvironmentVariable('INFISICAL_CLIENT_SECRET', 'super-secret-value',                   'User')

Connect-Infisical
Get-InfisicalSecret

Mixed example (explicit values override discovery)

Explicit parameters always win over discovered values; blank/whitespace explicit values trigger discovery.

Connect-Infisical -Environment 'prod'   # everything else discovered from environment

Logging

The resolver emits a single verbose line announcing the scan and one informational line per discovered variable (variable name and scope; values are never logged). Use -Verbose to see the scan announcement.

Building

pwsh -NoProfile -ExecutionPolicy Bypass -File .\build.ps1 -RunTests

The script builds the binary, runs unit tests, publishes binaries into Module/PSInfisicalAPI/bin/, regenerates the manifest, and validates that the module imports.

Extending the module

Adding a new API endpoint

All HTTP routes live in two files under src/PSInfisicalAPI/Endpoints/:

  • InfisicalEndpointNames.cs declares a const string identifier for each endpoint.
  • InfisicalEndpointRegistry.cs maps each identifier to one or more InfisicalEndpointDefinition records grouped by resource (RegisterAuthentication, RegisterSecrets, RegisterPki, etc.).

To add a route:

  1. Add a constant in InfisicalEndpointNames.cs (e.g., public const string ListPkiSubscribers = "ListPkiSubscribers";).
  2. In the matching Register<Resource> method, call Add(map, new InfisicalEndpointDefinition { ... }) with Name, Resource, Version, Method, Template, and the RequiresAuthorization / ContainsSecretMaterialInRequest / ContainsSecretMaterialInResponse flags. Use {placeholder} tokens in Template; they are substituted from the pathParameters dictionary passed by the caller.
  3. If the same logical operation has more than one upstream path (legacy + current), register both definitions under the same NameInvokeWithCandidateFallback tries each in order until one succeeds.
  4. Invoke the endpoint from the appropriate client (InfisicalPkiClient, InfisicalSecretsClient, etc.) via _invoker.InvokeWithCandidateFallback(connection, InfisicalEndpointNames.XYZ, "XYZ", pathParameters, query, body).

Adding a new cmdlet

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:

[Cmdlet(VerbsCommon.Get, "InfisicalPkiSubscriber", DefaultParameterSetName = "List")]
[OutputType(typeof(InfisicalPkiSubscriber))]
public sealed class GetInfisicalPkiSubscriberCmdlet : InfisicalCmdletBase
{
    [Parameter(ParameterSetName = "ByName", Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
    [Alias("SubscriberName", "Slug")]
    public string Name { get; set; }

    [Parameter] public string ProjectId { get; set; }

    protected override void ProcessRecord() { /* dispatch on ParameterSetName */ }
}

After adding (or removing) a cmdlet:

  1. Update build.ps1 in two places — the CmdletsToExport array inside the generated manifest block, and the $expectedCmds array used by Test-ModuleImports. Both must list the same cmdlets; the build fails fast if they drift.

  2. Add a <command:command> entry in Module/PSInfisicalAPI/en-US/PSInfisicalAPI.dll-Help.xml. Each entry must include a non-empty <maml:description> synopsis (do not let it start with the cmdlet name — the validation gate rejects PowerShell's auto-generated fallback), a non-empty <maml:description> body, and at least one <command:example> with a non-empty <dev:code> block.

  3. For consolidated List / single-record cmdlets, ship three examples: two straight-line invocations (one per parameter set) and one OrderedDictionary splat. The splat must construct the dictionary with OrdinalIgnoreCase so parameter names round-trip case-insensitively:

    $Params = New-Object -TypeName 'System.Collections.Specialized.OrderedDictionary' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $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).

  5. Run ./build.ps1 -RunTests. The script enforces the cmdlet list, runs the xUnit suite, and verifies that every exported cmdlet has a valid synopsis, description, and at least one non-empty example.

Committing source and build artifacts in lockstep

The embedded BuildCommitHash in Module/PSInfisicalAPI/PSInfisicalAPI.psd1 and the bundled DLL is captured from git rev-parse HEAD at build time. To keep the embedded hash truthful, commit source and build artifacts as two ordered commits:

  1. Stage and commit your source changes first. Suppose this produces commit S.
  2. Run ./build.ps1 -RunTests -CommitArtifacts. The build picks up S as HEAD, embeds it as BuildCommitHash, then stages and commits only the build outputs (Module/PSInfisicalAPI/bin/**, Module/PSInfisicalAPI/PSInfisicalAPI.psd1, and the CHANGELOG.md build-stamp insertion). The commit message references S so the binary commit always traces back to its source.
  3. git push.

-CommitArtifacts only touches the three artifact paths above; any other dirty files in your working tree are left alone. Use the older -CommitOnSuccess switch only when you intentionally want a single commit covering everything (git add -A + git commit -m "Build <version>"); the two switches are mutually exclusive.

Continuous integration

.gitea/workflows/publish-psgallery.yml publishes the module to the PowerShell Gallery whenever a pull request is merged into main. The workflow expects a repository secret named PSGALLERY_API_KEY containing a valid Gallery API key.

License

Distributed under the GNU Affero General Public License v3.0. See LICENSE.

S
Description
PSInfisicalAPI is a C# binary PowerShell module for the Infisical REST API, providing cmdlets for authentication, secret retrieval, and export with automatic environment-variable discovery across Process, User, and Machine scopes.
https://www.powershellgallery.com/packages/PSInfisicalAPI
Readme AGPL-3.0 11 MiB
2026-08-01 02:50:33 +00:00
Languages
C# 92.2%
PowerShell 7.8%