Files
PSInfisicalAPI/Tools/Initialize-InfisicalCertManagerEnvironment.ps1
gsadmin b27fe6f002 Drop a stale variable from the profile creation log line
The line still interpolated $enrollmentType, which went away when profile
creation stopped varying by enrollment method. Every run since had adopted
existing profiles, so the create path never executed and never surfaced it.
A run against an emptied project failed there immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:34:27 -04:00

1233 lines
59 KiB
PowerShell

<#
.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