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:
+2
-1
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user