Enable enrollment protocols on the application, not by cloning profiles

The enrollment methods were not appearing in the UI because they were being set
in the wrong place. A profile's own enrollmentType is one value, but that is
only the base; what the UI lists under an application - "configure how this
application will issue certificates via API, EST, ACME, or SCEP" - is a
separate configuration on the application-to-profile link, and those are
independent. A single profile can answer all four at once.

That replaces the previous approach. The mecm-...-scep and ec-...-acme profiles
are gone; there are three profiles again, one per policy, and each declares an
Enrollment block naming the protocols it should answer. Applied with PUT to
/applications/{id}/profiles/{id}/enrollment/{api,scep,acme}, which is idempotent
by nature, so a re-run restates the same settings. The routes take PUT, not
POST, and a POST there returns 404.

SCEP now uses a dynamic challenge, so there is no shared secret to distribute
or rotate; each request collects a one-time password from the challenge
endpoint. -ScepChallengePassword is kept for anyone who sets ChallengeType to
static, and is otherwise unused.

Enabling a protocol is what makes Infisical mint its endpoint - a SCEP URL,
challenge URL and RA certificate, or an ACME directory URL - so the run reads
them back and prints them, and returns them under Enrollment. None of it can be
derived from the configuration alone.

Verified against a live instance: scepConfigured and acmeConfigured come back
true on the intended profiles, the dynamic challenge settings round-trip, and a
re-run restates without creating anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 22:28:35 -04:00
parent c40160f789
commit c75cc145cb
@@ -21,9 +21,12 @@
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.
A profile carries one enrollment method, so a policy that should be reachable over more than one gets
a profile per method. Seeded by default: API on all three policies, SCEP alongside the RSA one for
clients that enrol without a token, and ACME alongside the EC server/client one.
Enrollment protocols are enabled on the link between the application and each profile, which is where
they are independent of one another, so a single profile can answer several at once. Seeded by
default: API everywhere, SCEP on the RSA profile with a dynamic challenge for clients that enrol
without a token, and ACME on the EC server/client profile. Enabling SCEP or ACME makes Infisical mint
the matching endpoint, and the run prints those URLs because they cannot be derived from the
configuration.
The script is idempotent. Every object is looked up by its natural key before creation, so a re-run
reports what already exists and creates only what is missing. It is safe to run repeatedly while
@@ -161,13 +164,7 @@ Set-StrictMode -Version Latest
Application = [Ordered]@{
Name = 'platform'
Description = 'Endpoint and workload certificate enrollment.'
CertificateProfiles = @(
'mecm-server-client-auth'
'mecm-server-client-auth-scep'
'ec-server-client-auth'
'ec-server-client-auth-acme'
'ec-code-signing'
)
CertificateProfiles = @('mecm-server-client-auth', 'ec-server-client-auth', 'ec-code-signing')
}
Organization = [Ordered]@{
@@ -289,76 +286,65 @@ Set-StrictMode -Version Latest
)
<#
A profile carries exactly one enrollment method. Infisical rejects a profile that mixes them
("API enrollment type cannot have EST, ACME, or SCEP configuration"), so offering a policy over
more than one method means one profile per method, all pointing at the same policy and CA.
Enrollment is configured twice, at two different levels, and only the second one is what clients
actually use.
EnrollmentType is api, scep, acme, or est. Each takes only its own configuration:
api AutoRenew, RenewBeforeDays (1-30)
scep ScepChallengeType static (needs a challenge password) or dynamic (issues one per request)
acme SkipDnsOwnershipVerification, SkipEabBinding
est a passphrase and a bootstrap CA chain, so it is left to be configured by hand
The profile itself carries a single enrollmentType, and Infisical rejects a profile that mixes
them ("API enrollment type cannot have EST, ACME, or SCEP configuration"). That setting is left
at api for every profile below; it is the base the application builds on.
AutoRenew is an api-only setting. Neither the SCEP nor the ACME configuration has an equivalent,
because both protocols have the client drive renewal on its own schedule.
The application-to-profile link is where a profile is exposed over one or more protocols, which
is what the UI means by "configure how this application will issue certificates via API, EST,
ACME, or SCEP". Those are independent, so a single profile can serve all of them at once, and
enabling one is what makes Infisical mint its endpoint - a SCEP URL and RA certificate, or an
ACME directory URL. The Enrollment block below drives that step; omit a protocol to leave it off.
Api AutoRenew, RenewBeforeDays (1-365)
Scep ChallengeType dynamic (a one-time password per request) or static (one shared secret),
IncludeCaCertInResponse, AllowCertBasedRenewal, and for dynamic the expiry in minutes
(5-1440) and the cap on outstanding challenges (1-1000)
Acme SkipDnsOwnershipVerification, SkipEabBinding
Est a passphrase and a bootstrap CA chain, so it is left to be configured by hand
AutoRenew is an Api setting. Neither SCEP nor ACME has an equivalent, because both protocols
have the client drive renewal on its own schedule.
Defaults fill in what a requester leaves out. They are required whenever the policy marks a
constraint Required: Request-InfisicalCertificate does not send key usages of its own, so
without a default the request arrives empty and is refused with "Missing required key usages".
#>
CertificateProfiles = @(
[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'
EnrollmentType = 'api'
AutoRenew = $True
RenewBeforeDays = 14
Defaults = [Ordered]@{
KeyAlgorithm = 'RSA_2048'
SignatureAlgorithm = 'RSA-SHA256'
KeyUsages = @('digital_signature', 'key_encipherment')
ExtendedKeyUsages = @('server_auth', 'client_auth')
}
}
<#
SCEP alongside the API profile: it is how domain-joined Windows and mobile clients enrol
without a token, which is the NDES role SCCM/MECM would otherwise need. RSA because SCEP
client support for EC keys is patchy.
SCEP as well as API: it is how domain-joined Windows and mobile clients enrol without a
token, which is the NDES role SCCM/MECM would otherwise need. Dynamic challenge, so there is
no shared secret to distribute or rotate - each request collects a one-time password from
the challenge endpoint instead.
#>
[Ordered]@{
Slug = 'mecm-server-client-auth-scep'
Description = 'SCEP enrollment for SCCM/MECM site systems and clients.'
Slug = 'mecm-server-client-auth'
Description = 'SCCM/MECM site systems and clients.'
CertificateAuthority = 'rsa-issuing-ca'
CertificatePolicy = 'mecm-server-client-auth'
EnrollmentType = 'scep'
ScepChallengeType = 'static'
Defaults = [Ordered]@{
KeyAlgorithm = 'RSA_2048'
SignatureAlgorithm = 'RSA-SHA256'
KeyUsages = @('digital_signature', 'key_encipherment')
ExtendedKeyUsages = @('server_auth', 'client_auth')
}
}
[Ordered]@{
Slug = 'ec-server-client-auth'
Description = 'API enrollment for general workload certificates.'
CertificateAuthority = 'ec-issuing-ca'
CertificatePolicy = 'ec-server-client-auth'
EnrollmentType = 'api'
AutoRenew = $True
RenewBeforeDays = 14
Defaults = [Ordered]@{
KeyAlgorithm = 'EC_secp384r1'
SignatureAlgorithm = 'ECDSA-SHA384'
KeyUsages = @('digital_signature')
ExtendedKeyUsages = @('server_auth', 'client_auth')
Enrollment = [Ordered]@{
Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 }
Scep = [Ordered]@{
ChallengeType = 'dynamic'
IncludeCaCertInResponse = $True
AllowCertBasedRenewal = $True
DynamicChallengeExpiryMinutes = 60
DynamicChallengeMaxPending = 100
}
}
}
<#
ACME alongside the API profile, for workloads that already speak it (cert-manager, Caddy,
Traefik, acme.sh).
ACME as well as API, for workloads that already speak it (cert-manager, Caddy, Traefik,
acme.sh).
SkipDnsOwnershipVerification is on because this is an internal CA issuing internal names:
the DNS-01 challenge proves control of a public zone, which a name like host.contoso.local
@@ -366,19 +352,20 @@ Set-StrictMode -Version Latest
off if the names being issued live in a zone Infisical can actually resolve.
#>
[Ordered]@{
Slug = 'ec-server-client-auth-acme'
Description = 'ACME enrollment for general workload certificates.'
CertificateAuthority = 'ec-issuing-ca'
CertificatePolicy = 'ec-server-client-auth'
EnrollmentType = 'acme'
SkipDnsOwnershipVerification = $True
SkipEabBinding = $False
Defaults = [Ordered]@{
Slug = 'ec-server-client-auth'
Description = 'General workload certificates.'
CertificateAuthority = 'ec-issuing-ca'
CertificatePolicy = 'ec-server-client-auth'
Defaults = [Ordered]@{
KeyAlgorithm = 'EC_secp384r1'
SignatureAlgorithm = 'ECDSA-SHA384'
KeyUsages = @('digital_signature')
ExtendedKeyUsages = @('server_auth', 'client_auth')
}
Enrollment = [Ordered]@{
Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 }
Acme = [Ordered]@{ SkipDnsOwnershipVerification = $True; SkipEabBinding = $False }
}
}
<#
Code signing is API only. SCEP and ACME both exist to prove control of a device or a DNS
@@ -386,18 +373,18 @@ Set-StrictMode -Version Latest
#>
[Ordered]@{
Slug = 'ec-code-signing'
Description = 'API enrollment for code signing certificates.'
Description = 'Code signing certificates.'
CertificateAuthority = 'ec-issuing-ca'
CertificatePolicy = 'ec-code-signing'
EnrollmentType = 'api'
AutoRenew = $True
RenewBeforeDays = 14
Defaults = [Ordered]@{
KeyAlgorithm = 'EC_secp384r1'
SignatureAlgorithm = 'ECDSA-SHA384'
KeyUsages = @('digital_signature')
ExtendedKeyUsages = @('code_signing')
}
Enrollment = [Ordered]@{
Api = [Ordered]@{ AutoRenew = $True; RenewBeforeDays = 14 }
}
}
)
}
@@ -731,6 +718,117 @@ Set-StrictMode -Version Latest
Write-Step "attached $($missing.Count) profile(s)" -Status 'Sub'
}
function Set-SeededApplicationEnrollment {
<#
Turns on each protocol a profile should answer, on its link to the application. This is the step
that makes a method visible in the UI and gives Infisical something to mint an endpoint from:
enabling SCEP creates the RA certificate and the pkiclient.exe and challenge URLs, and enabling
ACME creates the directory URL. Nothing here is exclusive, so a profile can serve several.
PUT replaces the configuration for one protocol, which makes re-running safe: the same settings
produce the same result, and a protocol left out of the Enrollment block is simply never
touched. Removing one from the block therefore does not disable it - delete it in the UI, or
send DELETE to the same route.
#>
param([String]$ProjectId, $Application, $Profiles)
if ($Null -eq $Application -or [String]::IsNullOrWhiteSpace($ProjectId)) { return }
Write-Step 'Enrollment methods'
foreach ($definition in $Configuration.CertificateProfiles) {
if (-not $Profiles.Contains($definition.Slug)) { continue }
if (-not $definition.Contains('Enrollment') -or $Null -eq $definition.Enrollment) { continue }
$profileId = $Profiles[$definition.Slug].id
foreach ($method in @('Api', 'Scep', 'Acme')) {
if (-not $definition.Enrollment.Contains($method)) { continue }
$settings = $definition.Enrollment[$method]
$payload = ConvertTo-EnrollmentPayload -Method $method -Settings $settings
$route = $method.ToLowerInvariant()
if (-not $PSCmdlet.ShouldProcess("$($definition.Slug) ($route)", 'Configure enrollment')) {
Write-Step "would enable $route on '$($definition.Slug)'" -Status 'WhatIf'
continue
}
$payload.projectId = $ProjectId
$Null = Invoke-InfisicalApi -Method 'PUT' -Body $payload `
-Path "/api/v1/cert-manager/applications/$($Application.id)/profiles/$profileId/enrollment/$route"
$detail = switch ($method) {
'Scep' { " ($($settings.ChallengeType) challenge)" }
'Api' { if ($settings.AutoRenew) { " (auto-renew $($settings.RenewBeforeDays)d)" } else { ' (no auto-renew)' } }
default { '' }
}
Write-Step "$route on '$($definition.Slug)'$detail" -Status 'Created'
}
}
}
function Get-SeededEnrollmentSummary {
<#
Reads back what each profile ended up answering, because the endpoint URLs and the SCEP RA
certificate are generated by Infisical and are not knowable from the configuration alone.
#>
param([String]$ProjectId, $Application, $Profiles)
$summary = [Ordered]@{}
if ($Null -eq $Application -or [String]::IsNullOrWhiteSpace($ProjectId)) { return $summary }
foreach ($slug in $Profiles.Keys) {
$profileId = $Profiles[$slug].id
try {
$summary[$slug] = Invoke-InfisicalApi -Method 'GET' `
-Path "/api/v1/cert-manager/applications/$($Application.id)/profiles/$profileId/enrollment?projectId=$ProjectId"
}
catch {
Write-Verbose "Could not read enrollment for '$slug': $($_.Exception.Message)"
}
}
return $summary
}
function ConvertTo-EnrollmentPayload {
param([String]$Method, $Settings)
$payload = [Ordered]@{}
switch ($Method) {
'Api' {
$payload.autoRenew = [Bool]$Settings.AutoRenew
if ($payload.autoRenew -and $Null -ne $Settings.RenewBeforeDays) {
$payload.renewBeforeDays = $Settings.RenewBeforeDays
}
}
'Scep' {
$challengeType = if ($Settings.Contains('ChallengeType')) { $Settings.ChallengeType } else { 'dynamic' }
$payload.challengeType = $challengeType
# A static challenge is one shared secret; a dynamic one is minted per request instead.
if ($challengeType -eq 'static') {
$payload.challengePassword = Get-ScepChallengePassword
}
else {
if ($Settings.Contains('DynamicChallengeExpiryMinutes')) { $payload.dynamicChallengeExpiryMinutes = $Settings.DynamicChallengeExpiryMinutes }
if ($Settings.Contains('DynamicChallengeMaxPending')) { $payload.dynamicChallengeMaxPending = $Settings.DynamicChallengeMaxPending }
}
if ($Settings.Contains('IncludeCaCertInResponse')) { $payload.includeCaCertInResponse = [Bool]$Settings.IncludeCaCertInResponse }
if ($Settings.Contains('AllowCertBasedRenewal')) { $payload.allowCertBasedRenewal = [Bool]$Settings.AllowCertBasedRenewal }
}
'Acme' {
$payload.skipDnsOwnershipVerification = [Bool]$Settings.SkipDnsOwnershipVerification
$payload.skipEabBinding = [Bool]$Settings.SkipEabBinding
}
default { throw "Unsupported enrollment method '$Method'." }
}
return $payload
}
function Get-SeededCertificateAuthorities {
param([String]$ProjectId)
@@ -1013,7 +1111,17 @@ Set-StrictMode -Version Latest
continue
}
$enrollmentType = if ($definition.Contains('EnrollmentType')) { $definition.EnrollmentType } else { 'api' }
<#
Always api at the profile level. The profile may only name one enrollment type, and the
protocols a client actually reaches are enabled per application-profile link further down,
where they are not mutually exclusive.
#>
$apiEnrollment = if ($definition.Enrollment.Contains('Api')) { $definition.Enrollment.Api } else { [Ordered]@{} }
$apiConfig = [Ordered]@{ autoRenew = [Bool]$apiEnrollment.AutoRenew }
if ($apiConfig.autoRenew -and $Null -ne $apiEnrollment.RenewBeforeDays) {
# The profile-level cap is 30 days, where the application-level one allows up to 365.
$apiConfig.renewBeforeDays = [Math]::Min([Int]$apiEnrollment.RenewBeforeDays, 30)
}
$body = [Ordered]@{
projectId = $ProjectId
@@ -1021,34 +1129,9 @@ Set-StrictMode -Version Latest
description = $definition.Description
caId = $Authorities[$definition.CertificateAuthority].id
certificatePolicyId = $Policies[$definition.CertificatePolicy].id
enrollmentType = $enrollmentType
enrollmentType = 'api'
issuerType = 'ca'
}
# Only the configuration matching the enrollment type may be sent; the others are rejected.
switch ($enrollmentType) {
'api' {
$apiConfig = [Ordered]@{ autoRenew = [Bool]$definition.AutoRenew }
if ($definition.AutoRenew -and $Null -ne $definition.RenewBeforeDays) {
$apiConfig.renewBeforeDays = $definition.RenewBeforeDays
}
$body.apiConfig = $apiConfig
}
'scep' {
$challengeType = if ($definition.Contains('ScepChallengeType')) { $definition.ScepChallengeType } else { 'static' }
$scepConfig = [Ordered]@{ challengeType = $challengeType }
if ($challengeType -eq 'static') {
$scepConfig.challengePassword = Get-ScepChallengePassword
}
$body.scepConfig = $scepConfig
}
'acme' {
$body.acmeConfig = [Ordered]@{
skipDnsOwnershipVerification = [Bool]$definition.SkipDnsOwnershipVerification
skipEabBinding = [Bool]$definition.SkipEabBinding
}
}
default { throw "Profile '$($definition.Slug)' declares unsupported EnrollmentType '$enrollmentType'." }
apiConfig = $apiConfig
}
$defaults = ConvertTo-ProfileDefaults -Definition $definition.Defaults
@@ -1076,6 +1159,8 @@ Set-StrictMode -Version Latest
$policies = Get-SeededCertificatePolicies -ProjectId $projectId
$profiles = Get-SeededCertificateProfiles -ProjectId $projectId -Authorities $authorities -Policies $policies
$application = Get-SeededApplication -ProjectId $projectId -Profiles $profiles
Set-SeededApplicationEnrollment -ProjectId $projectId -Application $application -Profiles $profiles
$enrollment = Get-SeededEnrollmentSummary -ProjectId $projectId -Application $application -Profiles $profiles
Write-Host ''
Write-Step 'Done.'
@@ -1097,6 +1182,24 @@ Set-StrictMode -Version Latest
Write-Host " -CommonName `$Env:ComputerName.ToUpper() -DnsName (Get-InfisicalSANList) -Install -InstallChain"
}
# Minted by Infisical when a protocol is enabled, so they can only be reported by reading them back.
foreach ($slug in $enrollment.Keys) {
$entry = $enrollment[$slug]
$scep = Get-ApiProperty -InputObject $entry -Name 'scep'
$acme = Get-ApiProperty -InputObject $entry -Name 'acme'
if ($Null -eq $scep -and $Null -eq $acme) { continue }
Write-Host ''
Write-Host "Enrollment endpoints for '$slug':"
if ($Null -ne $scep) {
Write-Host " SCEP $(Get-ApiProperty -InputObject $scep -Name 'scepEndpointUrl')"
Write-Host " challenge $(Get-ApiProperty -InputObject $scep -Name 'challengeEndpointUrl')"
}
if ($Null -ne $acme) {
Write-Host " ACME $(Get-ApiProperty -InputObject $acme -Name 'directoryUrl')"
}
}
<#
Reported here because Infisical will not hand the challenge back afterwards: a static SCEP
profile is unusable to anyone who did not capture this value. Re-running the script does not
@@ -1119,6 +1222,7 @@ Set-StrictMode -Version Latest
CertificatePolicies = $policies
CertificateProfiles = $profiles
Application = $application
Enrollment = $enrollment
ScepChallengePassword = $Script:ScepChallenge
}
}