Skip to content

Commit db964a7

Browse files
feat(pki): add AWS ACM Public CA support (#6069)
* feat(pki): add AWS ACM Public CA support Adds a new external CA type that issues, renews, and revokes public certificates via AWS Certificate Manager with Route 53 DNS validation. - New aws-acm-public-ca service module (client, fns, schemas, validators) - Route 53 DNS provider for ACM CNAME validation records - externalMetadata jsonb column on certificates (stores ARN/region) - Issuance queue tuned for ACM: 30 attempts with fixed backoff, retryable validation-pending errors, final-attempt request FAIL hook - Pre-flight validation rejects CSR, non-DNS SANs, subject fields, custom validity, and CA certs (ACM constraints) - Profile service restricts ACM CAs to API enrollment - v1/v2 list endpoints, ExternalCaModal UI, frontend types/hooks * chore(backend): add @aws-sdk/client-acm dependency * chore(pki): remove ACM development mock client * fix(pki): surface AWS errors and fix ACM renewal polling - Wrap ACM/Route 53 preflight calls in createCertificateAuthority and updateCertificateAuthority so IAM errors surface as BadRequestError with the AWS message, instead of a generic 500. - Skip the fixed-validity TTL check on renewal — ACM sets validity itself and the TTL derived from the original cert can floor below 198 days. - Require notAfter to advance before exporting a renewed cert. ACM returns the original cert from ExportCertificate until the renewal is fully re-issued, which was causing duplicate-serial insert failures. * fix(pki): retry ACM export when renewal relation not yet ready * chore(pki): clean up ACM extras and add docs - Drop dead `calculateAcmRenewBeforeDays` clamp; profile schema already caps `renewBeforeDays` at 30, so the 198-day clamp never triggered. - Drop the redundant `basicConstraints` plumbing for the ACM validator; `certificate-v3-service.ts` already blocks CA issuance for all external CAs upstream. - Run pre-flight ACM input validation before the approval branch so bad inputs (TTL, SANs, subject fields) are rejected at submit time instead of after an approver has already approved. - Use serial-number comparison to detect a renewed cert body in ACM, instead of relying on `NotAfter` advancement (which can lag). - Persist `keyUsages` / `extendedKeyUsages` parsed from the issued cert rather than echoing the request, so DB matches what AWS actually issued. - Add docs page covering setup, IAM, auto-renewal, troubleshooting, and an FAQ; wire it into docs.json under the External CAs section. * fix(pki): make external CA revocation atomic and surface AWS errors - Call the upstream CA revoke before updating the local cert row, so a failed AWS call (e.g., a reason ACM rejects) doesn't leave the cert marked revoked locally while still active at the issuer. - Wrap the ACM RevokeCertificate call so AWS errors come back as a BadRequestError with the underlying message, instead of falling through to the generic "Something went wrong" 500. * fix(pki): preserve original region on ACM renewal and hoist AWS calls out of CA update transaction - On renewal, store the original certificate's region in externalMetadata instead of the CA's current region, so subsequent revoke/renew keep targeting the correct region-locked ARN even if the CA was edited. - In updateCertificateAuthority, run ACM ListCertificates and Route 53 GetHostedZone before opening the DB transaction, mirroring createCertificateAuthority so slow AWS calls don't pin a pool connection. * fix(pki): derive ACM signature algorithm from issued cert ACM picks the signature algorithm server-side and has no SigningAlgorithm parameter on RequestCertificate, so the caller-supplied signatureAlgorithm was being persisted without ever matching what AWS actually signed with. Parse it from the issued cert and normalize to CertSignatureAlgorithm before writing to the DB. Drop the now-dead parameter from the ACM orderCertificateFromProfile signature. * chore(pki): remove unused AwsAcmKeyAlgorithm enum * refactor(pki): generate ACM export passphrase with nanoid customAlphabet Uses nanoid's customAlphabet instead of manual modular sampling, matching the pattern used elsewhere in the codebase (e.g. dynamic-secret providers). Eliminates the modular bias where the first 8 alphabet characters appeared slightly more frequently than the others. * fix(ui): mark AWS Connection field as required in ACM external CA form Matches the sibling fields (Route 53 Connection, Hosted Zone ID, Region) which already had the required indicator. * docs(pki): clarify ACM auto-renewal and refresh screenshots Explain that AWS itself attempts managed renewal 45 days before expiry, and what Infisical's own auto-renewal does in that case (skip RenewCertificate if AWS already renewed, otherwise trigger it). Swap "export" wording for plainer "save"/"pull in". Add new setup screenshots. * docs(pki): add ACM public CA API reference pages * refactor(pki): share Route 53 helper and tidy ACM internals - Extract Route 53 into a shared dns-providers/route53.ts reused by both ACME and ACM Public CA. Adds an optional comment field so ACME keeps its original change-history strings. The ACME delete path also now applies sha256=CustomAWSHasher and useFipsEndpoint consistently with upsert. - Move the two ACM validation error classes into a dedicated -errors.ts and rename to AcmPendingError / AcmTerminalError, since they also cover renewal and export paths beyond the original DNS-validation signal. - Replace single-character regex strips (: and -) with split/join, and wrap the AWS error-message match in RE2 to match the rest of the repo. * feat(ui): pre-fill and lock TTL for ACM Public CA profiles AWS ACM Public CA issues certificates with a fixed 198-day validity and the backend rejects any other value. When the selected CA on a certificate profile is AWS ACM Public CA, the TTL field now pre-fills to 198 and is disabled, with a tooltip explaining the fixed validity. * docs(pki): expand ACM Public CA guide and document permissions on AWS connection - Rewrite the ACM Public CA overview to scope explicitly to public certificates and drop the comparison with AWS Private CA. - Expand the enrollment-method FAQ entry to explain that only API enrollment applies, because EST, SCEP, and ACME all submit a CSR and ACM generates the private key itself. - Add an AWS ACM Public CA accordion (ACM + Route 53 permissions) to both the IAM Role and IAM User sections of the AWS app connection docs so users can set up permissions alongside existing services. * fix(pki): skip AWS ACM revoke for superseded certificates When an ACM certificate is renewed, the ARN is reused for the new certificate body and the superseded cert is no longer present at AWS. Calling RevokeCertificate on that ARN would revoke the currently-active renewed cert. When revoking a cert that has renewedByCertificateId set, skip the AWS call and let the service layer mark the DB row as REVOKED on its own — matching the pattern already used in PKI syncs for superseded certificates.
1 parent 8b9cbde commit db964a7

48 files changed

Lines changed: 2705 additions & 526 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/package-lock.json

Lines changed: 467 additions & 381 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@
140140
},
141141
"dependencies": {
142142
"@ai-sdk/anthropic": "^3.0.68",
143+
"@aws-sdk/client-acm": "^3.1030.0",
143144
"@aws-sdk/client-acm-pca": "^3.992.0",
144145
"@aws-sdk/client-elasticache": "^3.637.0",
145146
"@aws-sdk/client-iam": "^3.525.0",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Knex } from "knex";
2+
3+
import { TableName } from "../schemas";
4+
5+
export async function up(knex: Knex): Promise<void> {
6+
if (await knex.schema.hasTable(TableName.Certificate)) {
7+
const hasColumn = await knex.schema.hasColumn(TableName.Certificate, "externalMetadata");
8+
if (!hasColumn) {
9+
await knex.schema.alterTable(TableName.Certificate, (t) => {
10+
t.jsonb("externalMetadata").nullable();
11+
});
12+
}
13+
}
14+
}
15+
16+
export async function down(knex: Knex): Promise<void> {
17+
if (await knex.schema.hasTable(TableName.Certificate)) {
18+
if (await knex.schema.hasColumn(TableName.Certificate, "externalMetadata")) {
19+
await knex.schema.alterTable(TableName.Certificate, (t) => {
20+
t.dropColumn("externalMetadata");
21+
});
22+
}
23+
}
24+
}

backend/src/db/schemas/certificates.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ export const CertificatesSchema = z.object({
4444
isCA: z.boolean().nullable().optional(),
4545
pathLength: z.number().nullable().optional(),
4646
source: z.string().nullable().optional(),
47-
discoveryMetadata: z.unknown().nullable().optional()
47+
discoveryMetadata: z.unknown().nullable().optional(),
48+
externalMetadata: z.unknown().nullable().optional()
4849
});
4950

5051
export type TCertificates = z.infer<typeof CertificatesSchema>;

backend/src/lib/api-docs/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2555,6 +2555,12 @@ export const CertificateAuthorities = {
25552555
certificateAuthorityArn: `The ARN of the AWS Private Certificate Authority to use for issuing certificates.`,
25562556
region: `The AWS region where the Private Certificate Authority is located.`
25572557
},
2558+
AWS_ACM_PUBLIC_CA: {
2559+
appConnectionId: `The ID of the AWS App Connection to use for authenticating with AWS Certificate Manager (ACM). This connection must have permissions to request, describe, export, renew, and delete certificates.`,
2560+
dnsAppConnectionId: `The ID of the AWS App Connection to use for creating and managing Route 53 CNAME records required for ACM domain validation.`,
2561+
hostedZoneId: `The Route 53 hosted zone ID to use for ACM DNS validation CNAME records.`,
2562+
region: `The AWS region to use for the ACM API calls.`
2563+
},
25582564
INTERNAL: {
25592565
type: "The type of CA to create.",
25602566
friendlyName: "A friendly name for the CA.",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {
2+
AwsAcmPublicCaCertificateAuthoritySchema,
3+
CreateAwsAcmPublicCaCertificateAuthoritySchema,
4+
UpdateAwsAcmPublicCaCertificateAuthoritySchema
5+
} from "@app/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-schemas";
6+
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
7+
8+
import { registerCertificateAuthorityEndpoints } from "./certificate-authority-endpoints";
9+
10+
export const registerAwsAcmPublicCaCertificateAuthorityRouter = async (server: FastifyZodProvider) => {
11+
registerCertificateAuthorityEndpoints({
12+
caType: CaType.AWS_ACM_PUBLIC_CA,
13+
server,
14+
responseSchema: AwsAcmPublicCaCertificateAuthoritySchema,
15+
createSchema: CreateAwsAcmPublicCaCertificateAuthoritySchema,
16+
updateSchema: UpdateAwsAcmPublicCaCertificateAuthoritySchema
17+
});
18+
};

backend/src/server/routes/v1/certificate-authority-routers/general-certificate-authority-router.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { readLimit } from "@app/server/config/rateLimiter";
66
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
77
import { AuthMode } from "@app/services/auth/auth-type";
88
import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas";
9+
import { AwsAcmPublicCaCertificateAuthoritySchema } from "@app/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-schemas";
910
import { AwsPcaCertificateAuthoritySchema } from "@app/services/certificate-authority/aws-pca/aws-pca-certificate-authority-schemas";
1011
import { AzureAdCsCertificateAuthoritySchema } from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas";
1112
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
@@ -15,7 +16,8 @@ const CertificateAuthoritySchema = z.discriminatedUnion("type", [
1516
InternalCertificateAuthoritySchema,
1617
AcmeCertificateAuthoritySchema,
1718
AzureAdCsCertificateAuthoritySchema,
18-
AwsPcaCertificateAuthoritySchema
19+
AwsPcaCertificateAuthoritySchema,
20+
AwsAcmPublicCaCertificateAuthoritySchema
1921
]);
2022

2123
export const registerGeneralCertificateAuthorityRouter = async (server: FastifyZodProvider) => {
@@ -73,6 +75,14 @@ export const registerGeneralCertificateAuthorityRouter = async (server: FastifyZ
7375
req.permission
7476
);
7577

78+
const awsAcmPublicCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId(
79+
{
80+
projectId: req.query.projectId,
81+
type: CaType.AWS_ACM_PUBLIC_CA
82+
},
83+
req.permission
84+
);
85+
7686
await server.services.auditLog.createAuditLog({
7787
...req.auditLogInfo,
7888
projectId: req.query.projectId,
@@ -83,7 +93,8 @@ export const registerGeneralCertificateAuthorityRouter = async (server: FastifyZ
8393
...(internalCas ?? []).map((ca) => ca.id),
8494
...(acmeCas ?? []).map((ca) => ca.id),
8595
...(azureAdCsCas ?? []).map((ca) => ca.id),
86-
...(awsPcaCas ?? []).map((ca) => ca.id)
96+
...(awsPcaCas ?? []).map((ca) => ca.id),
97+
...(awsAcmPublicCas ?? []).map((ca) => ca.id)
8798
]
8899
}
89100
}
@@ -94,7 +105,8 @@ export const registerGeneralCertificateAuthorityRouter = async (server: FastifyZ
94105
...(internalCas ?? []),
95106
...(acmeCas ?? []),
96107
...(azureAdCsCas ?? []),
97-
...(awsPcaCas ?? [])
108+
...(awsPcaCas ?? []),
109+
...(awsAcmPublicCas ?? [])
98110
]
99111
};
100112
}

backend/src/server/routes/v1/certificate-authority-routers/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
22

33
import { registerAcmeCertificateAuthorityRouter } from "./acme-certificate-authority-router";
4+
import { registerAwsAcmPublicCaCertificateAuthorityRouter } from "./aws-acm-public-ca-certificate-authority-router";
45
import { registerAwsPcaCertificateAuthorityRouter } from "./aws-pca-certificate-authority-router";
56
import { registerAzureAdCsCertificateAuthorityRouter } from "./azure-ad-cs-certificate-authority-router";
67
import { registerInternalCertificateAuthorityRouter } from "./internal-certificate-authority-router";
@@ -12,5 +13,6 @@ export const CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP: Record<CaType, (server:
1213
[CaType.INTERNAL]: registerInternalCertificateAuthorityRouter,
1314
[CaType.ACME]: registerAcmeCertificateAuthorityRouter,
1415
[CaType.AZURE_AD_CS]: registerAzureAdCsCertificateAuthorityRouter,
15-
[CaType.AWS_PCA]: registerAwsPcaCertificateAuthorityRouter
16+
[CaType.AWS_PCA]: registerAwsPcaCertificateAuthorityRouter,
17+
[CaType.AWS_ACM_PUBLIC_CA]: registerAwsAcmPublicCaCertificateAuthorityRouter
1618
};

backend/src/server/routes/v2/certificate-authority-router.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { readLimit } from "@app/server/config/rateLimiter";
66
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
77
import { AuthMode } from "@app/services/auth/auth-type";
88
import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas";
9+
import { AwsAcmPublicCaCertificateAuthoritySchema } from "@app/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-schemas";
910
import { AwsPcaCertificateAuthoritySchema } from "@app/services/certificate-authority/aws-pca/aws-pca-certificate-authority-schemas";
1011
import { AzureAdCsCertificateAuthoritySchema } from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas";
1112
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
@@ -15,7 +16,8 @@ const CertificateAuthoritySchema = z.discriminatedUnion("type", [
1516
InternalCertificateAuthoritySchema,
1617
AcmeCertificateAuthoritySchema,
1718
AzureAdCsCertificateAuthoritySchema,
18-
AwsPcaCertificateAuthoritySchema
19+
AwsPcaCertificateAuthoritySchema,
20+
AwsAcmPublicCaCertificateAuthoritySchema
1921
]);
2022

2123
export const registerCaRouter = async (server: FastifyZodProvider) => {
@@ -73,6 +75,14 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
7375
req.permission
7476
);
7577

78+
const awsAcmPublicCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId(
79+
{
80+
projectId: req.query.projectId,
81+
type: CaType.AWS_ACM_PUBLIC_CA
82+
},
83+
req.permission
84+
);
85+
7686
await server.services.auditLog.createAuditLog({
7787
...req.auditLogInfo,
7888
projectId: req.query.projectId,
@@ -83,7 +93,8 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
8393
...(internalCas ?? []).map((ca) => ca.id),
8494
...(acmeCas ?? []).map((ca) => ca.id),
8595
...(azureAdCsCas ?? []).map((ca) => ca.id),
86-
...(awsPcaCas ?? []).map((ca) => ca.id)
96+
...(awsPcaCas ?? []).map((ca) => ca.id),
97+
...(awsAcmPublicCas ?? []).map((ca) => ca.id)
8798
]
8899
}
89100
}
@@ -94,7 +105,8 @@ export const registerCaRouter = async (server: FastifyZodProvider) => {
94105
...(internalCas ?? []),
95106
...(acmeCas ?? []),
96107
...(azureAdCsCas ?? []),
97-
...(awsPcaCas ?? [])
108+
...(awsPcaCas ?? []),
109+
...(awsAcmPublicCas ?? [])
98110
]
99111
};
100112
}

backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns
4343
import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal";
4444
import { CaStatus, CaType } from "../certificate-authority-enums";
4545
import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns";
46+
import { route53DeleteRecord, route53UpsertRecord } from "../dns-providers/route53";
4647
import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal";
4748
import { AcmeDnsProvider } from "./acme-certificate-authority-enums";
4849
import { AcmeCertificateAuthorityCredentialsSchema } from "./acme-certificate-authority-schemas";
@@ -54,7 +55,6 @@ import {
5455
import { azureDnsDeleteTxtRecord, azureDnsInsertTxtRecord } from "./dns-providers/azure-dns";
5556
import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare";
5657
import { dnsMadeEasyDeleteTxtRecord, dnsMadeEasyInsertTxtRecord } from "./dns-providers/dns-made-easy";
57-
import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54";
5858

5959
const validateDnsResolver = (resolver: string): void => {
6060
const appCfg = getConfig();
@@ -422,12 +422,13 @@ export const orderCertificate = async (
422422

423423
switch (acmeCa.configuration.dnsProviderConfig.provider) {
424424
case AcmeDnsProvider.Route53: {
425-
await route53InsertTxtRecord(
426-
connection as TAwsConnection,
427-
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
428-
recordName,
429-
recordValue
430-
);
425+
await route53UpsertRecord(connection as TAwsConnection, acmeCa.configuration.dnsProviderConfig.hostedZoneId, {
426+
name: recordName,
427+
type: "TXT",
428+
value: recordValue,
429+
ttl: 30,
430+
comment: "Set ACME challenge TXT record"
431+
});
431432
break;
432433
}
433434
case AcmeDnsProvider.Cloudflare: {
@@ -478,12 +479,13 @@ export const orderCertificate = async (
478479

479480
switch (acmeCa.configuration.dnsProviderConfig.provider) {
480481
case AcmeDnsProvider.Route53: {
481-
await route53DeleteTxtRecord(
482-
connection as TAwsConnection,
483-
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
484-
recordName,
485-
recordValue
486-
);
482+
await route53DeleteRecord(connection as TAwsConnection, acmeCa.configuration.dnsProviderConfig.hostedZoneId, {
483+
name: recordName,
484+
type: "TXT",
485+
value: recordValue,
486+
ttl: 30,
487+
comment: "Delete ACME challenge TXT record"
488+
});
487489
break;
488490
}
489491
case AcmeDnsProvider.Cloudflare: {

0 commit comments

Comments
 (0)