diff --git a/.gitignore b/.gitignore index 707e68d..dac35c8 100644 --- a/.gitignore +++ b/.gitignore @@ -30,5 +30,6 @@ TestResults/ *.trx *.coverage -## Local helper scripts (not part of the module) +## Local helper scripts (not part of the module). Case-insensitive on Windows, so this also matches +## Scripts/ - shipped tooling lives in Tools/ instead. scripts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c43977d..85a79c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 b/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 new file mode 100644 index 0000000..5049197 --- /dev/null +++ b/Tools/Initialize-InfisicalCertManagerEnvironment.ps1 @@ -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