Actually commit the seeding script, and let it authenticate with a token

The seeding script was never in the repository. .gitignore carries "scripts/"
for local helper scripts, core.ignorecase is true on Windows, so Scripts/
matched it and git add -A silently skipped the file. The commit that claimed to
add the script contained only its CHANGELOG and README entries; the script
itself existed on disk and nowhere else. It now lives in Tools/, which is not
ignored, and the ignore rule says why so this cannot swallow shipped tooling
again.

The script also gains an -AccessToken parameter set alongside
-ClientId/-ClientSecret, so a throwaway instance can be seeded with the bearer
token from a signed-in browser session rather than by first creating a machine
identity. The two are mutually exclusive parameter sets, so supplying both is
rejected at binding rather than silently preferring one.

A supplied token is proven with a single read before anything is created. An
expired browser token is the common case, and failing there is much better than
failing midway through building a CA hierarchy and leaving a half-seeded
project behind; the error says as much and suggests copying a fresh one.

Renaming the internal token variable was not cosmetic. The script held the
bearer token in $Script:AccessToken, the same name as the new parameter, so
PowerShell applied the parameter's [ValidateNotNull()] to every assignment and
both authentication paths failed with "the value $null is not a valid value for
the AccessToken variable" before reaching the network. It is now
$Script:BearerToken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 20:36:02 -04:00
parent 9f6e81607d
commit f2a1492b66
3 changed files with 826 additions and 2 deletions
+2 -1
View File
@@ -30,5 +30,6 @@ TestResults/
*.trx
*.coverage
## Local helper scripts (not part of the module)
## Local helper scripts (not part of the module). Case-insensitive on Windows, so this also matches
## Scripts/ - shipped tooling lives in Tools/ instead.
scripts/
+1 -1
View File
@@ -116,7 +116,7 @@ _Build produced from commit 62131e750109._
### Added (tooling)
- `Scripts/Initialize-InfisicalCertManagerEnvironment.ps1` seeds a Certificate Manager project, CA hierarchy, certificate policies, API enrollment profiles, and a certificate application from one declarative configuration block, taking only a base URI, client id, and client secret. Idempotent and `-WhatIf`-aware. Seeds an RSA hierarchy for SCCM/MECM server and client authentication, and an ECDSA P-384 hierarchy for server/client authentication and code signing.
- `Tools/Initialize-InfisicalCertManagerEnvironment.ps1` seeds a Certificate Manager project, CA hierarchy, certificate policies, API enrollment profiles, and a certificate application from one declarative configuration block, taking only a base URI, client id, and client secret. Idempotent and `-WhatIf`-aware. Seeds an RSA hierarchy for SCCM/MECM server and client authentication, and an ECDSA P-384 hierarchy for server/client authentication and code signing.
- The script **adopts an organization's existing Certificate Manager project** rather than creating one when the configured slug does not match. Creating a second would have succeeded and then been unable to hold applications, since they are served only from the organization's active project.
- It creates a certificate application and attaches the seeded profiles to it, so the result is navigable by `Get-InfisicalCertificateProfile -ApplicationId`.
@@ -0,0 +1,823 @@
<#
.SYNOPSIS
Seeds an Infisical Certificate Manager project with a CA hierarchy, certificate policies, and API
enrollment profiles.
.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 API enrollment.
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.
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 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)]
[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
KeyUsages digital_signature | key_encipherment | non_repudiation | data_encipherment |
key_agreement | key_cert_sign | crl_sign | encipher_only | decipher_only
ExtendedKeyUsages server_auth | client_auth | code_signing | email_protection | ocsp_signing |
time_stamping | any_purpose
SubjectAttribute common_name | organization | country | state | locality |
organizational_unit | domain_component
SanType dns_name | ip_address | email | uri | upn
#>
$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 = @(
<#
Subject and SubjectAltNames are deliberately left out below. A constraint is only sent when
it carries values, and omitting these leaves the common name and SAN list unconstrained,
which is what fleet enrollment needs: every machine presents its own name.
To constrain them, add entries carrying values, for example:
Subject = @( [Ordered]@{ Type = 'organization'; Allowed = @('Contoso') } )
SubjectAltNames = @( [Ordered]@{ Type = 'dns_name'; Allowed = @('*.contoso.com') } )
#>
[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')
KeyUsages = [Ordered]@{ Required = @('digital_signature', 'key_encipherment') }
ExtendedKeyUsages = [Ordered]@{ Required = @('server_auth', 'client_auth') }
Subject = @()
SubjectAltNames = @()
}
[Ordered]@{
Name = 'ec-server-client-auth'
Description = 'General workload certificates. ECDSA P-384, server and client authentication.'
MaxValidity = '90d'
KeyAlgorithms = @('EC_secp384r1')
KeyUsages = [Ordered]@{ Required = @('digital_signature') }
ExtendedKeyUsages = [Ordered]@{ Required = @('server_auth', 'client_auth') }
Subject = @()
SubjectAltNames = @()
}
[Ordered]@{
Name = 'ec-code-signing'
Description = 'Code signing. ECDSA P-384, code signing only.'
MaxValidity = '365d'
KeyAlgorithms = @('EC_secp384r1')
KeyUsages = [Ordered]@{ Required = @('digital_signature') }
ExtendedKeyUsages = [Ordered]@{ Required = @('code_signing'); Denied = @('server_auth', 'client_auth') }
Subject = @()
SubjectAltNames = @()
}
)
CertificateProfiles = @(
[Ordered]@{
Slug = 'mecm-server-client-auth'
Description = 'API enrollment for SCCM/MECM site systems and clients.'
CertificateAuthority = 'rsa-issuing-ca'
CertificatePolicy = 'mecm-server-client-auth'
AutoRenew = $True
RenewBeforeDays = 14
}
[Ordered]@{
Slug = 'ec-server-client-auth'
Description = 'API enrollment for general workload certificates.'
CertificateAuthority = 'ec-issuing-ca'
CertificatePolicy = 'ec-server-client-auth'
AutoRenew = $True
RenewBeforeDays = 14
}
[Ordered]@{
Slug = 'ec-code-signing'
Description = 'API enrollment for code signing certificates.'
CertificateAuthority = 'ec-issuing-ca'
CertificatePolicy = 'ec-code-signing'
AutoRenew = $False
RenewBeforeDays = $Null
}
)
}
#endregion
#region Infrastructure
$Script:BearerToken = $Null
$Script:ApiRoot = $BaseUri.TrimEnd('/')
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 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.
try {
$null = Invoke-InfisicalApi -Method 'GET' -Path '/api/v2/organizations'
}
catch {
$Script:BearerToken = $Null
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: $($_)"
}
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($response.accessToken)) {
throw 'Authentication succeeded but returned no access token.'
}
$Script:BearerToken = $response.accessToken
Write-Step 'Authenticated with universal auth.' -Status 'Sub'
}
function Resolve-OrganizationId {
if (-not [String]::IsNullOrWhiteSpace($OrganizationId)) { return $OrganizationId }
$response = Invoke-InfisicalApi -Method 'GET' -Path '/api/v2/organizations'
$organizations = @($response.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 = @($response.projects) + @($response.workspaces) | Where-Object { $Null -ne $_ }
$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 = $created.project
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 = @($response.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 = $created.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"
$attached = @($response.profiles | ForEach-Object { $_.id })
$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 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 @($response.certificateAuthorities)) { $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')
$configuration = [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') {
$configuration.notAfter = $notAfter
$configuration.maxPathLength = $definition.MaxPathLength
}
$body = @{
projectId = $ProjectId
name = $definition.Name
status = 'active'
configuration = $configuration
}
$created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/ca/internal' -Body $body
$ca = $created.certificateAuthority
$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($csrResponse.csr)) {
throw "CA '$($Definition.Name)' returned no CSR to sign."
}
$signBody = @{
csr = $csrResponse.csr
notAfter = $NotAfter
maxPathLength = $Definition.MaxPathLength
}
$signed = Invoke-InfisicalApi -Method 'POST' -Path "/api/v1/pki/ca/$($parent.id)/sign-intermediate" -Body $signBody
$importBody = @{
certificate = $signed.certificate
certificateChain = $signed.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 @($response.certificatePolicies)) { $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) }
}
$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 = $created.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 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 ($profile in @($response.certificateProfiles)) { $existingBySlug[$profile.slug] = $profile }
}
foreach ($definition in $Configuration.CertificateProfiles) {
if ($existingBySlug.ContainsKey($definition.Slug)) {
$existing = $existingBySlug[$definition.Slug]
$resolved[$definition.Slug] = $existing
Write-Step "profile '$($definition.Slug)' ($($existing.id))" -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
}
$apiConfig = [Ordered]@{ autoRenew = [Bool]$definition.AutoRenew }
if ($definition.AutoRenew -and $Null -ne $definition.RenewBeforeDays) {
$apiConfig.renewBeforeDays = $definition.RenewBeforeDays
}
$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
}
$created = Invoke-InfisicalApi -Method 'POST' -Path '/api/v1/cert-manager/certificate-profiles' -Body $body
$profile = $created.certificateProfile
$resolved[$definition.Slug] = $profile
Write-Step "profile '$($definition.Slug)' ($($profile.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
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"
}
[PSCustomObject]@{
BaseUri = $Script:ApiRoot
OrganizationId = $resolvedOrganizationId
ProjectId = $projectId
CertificateAuthorities = $authorities
CertificatePolicies = $policies
CertificateProfiles = $profiles
Application = $application
}
}
finally {
$Script:BearerToken = $Null
}
#endregion