From 0f98c49363636d1cc30b367bb0baffd98b69e706 Mon Sep 17 00:00:00 2001 From: Saif Date: Fri, 17 Apr 2026 08:22:13 +0530 Subject: [PATCH 01/18] 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 --- ...4_add-external-metadata-to-certificates.ts | 24 + backend/src/db/schemas/certificates.ts | 3 +- backend/src/lib/api-docs/constants.ts | 6 + ...-public-ca-certificate-authority-router.ts | 18 + .../general-certificate-authority-router.ts | 18 +- .../v1/certificate-authority-routers/index.ts | 4 +- .../routes/v2/certificate-authority-router.ts | 18 +- ...-public-ca-certificate-authority-client.ts | 247 ++++++ ...m-public-ca-certificate-authority-enums.ts | 15 + ...acm-public-ca-certificate-authority-fns.ts | 769 ++++++++++++++++++ ...public-ca-certificate-authority-schemas.ts | 47 ++ ...m-public-ca-certificate-authority-types.ts | 17 + ...lic-ca-certificate-authority-validators.ts | 160 ++++ .../dns-providers/route53.ts | 59 ++ .../certificate-authority-enums.ts | 3 +- .../certificate-authority-maps.ts | 14 +- .../certificate-authority-service.ts | 80 +- .../certificate-authority-types.ts | 10 +- .../certificate-issuance-queue.ts | 144 +++- .../external-metadata-schemas.ts | 18 + ...ificate-profile-external-config-schemas.ts | 14 +- .../certificate-profile-service.ts | 18 + .../certificate-approval-fns.ts | 25 +- .../certificate-v3/certificate-v3-service.ts | 42 +- .../certificate-v3/certificate-v3-types.ts | 1 + .../certificate/certificate-service.ts | 2 +- frontend/src/hooks/api/ca/constants.tsx | 8 +- frontend/src/hooks/api/ca/enums.tsx | 3 +- frontend/src/hooks/api/ca/queries.tsx | 30 +- frontend/src/hooks/api/ca/types.ts | 16 + .../components/ExternalCaModal.tsx | 166 +++- 31 files changed, 1954 insertions(+), 45 deletions(-) create mode 100644 backend/src/db/migrations/20260416231234_add-external-metadata-to-certificates.ts create mode 100644 backend/src/server/routes/v1/certificate-authority-routers/aws-acm-public-ca-certificate-authority-router.ts create mode 100644 backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-client.ts create mode 100644 backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-enums.ts create mode 100644 backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-fns.ts create mode 100644 backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-schemas.ts create mode 100644 backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-types.ts create mode 100644 backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-validators.ts create mode 100644 backend/src/services/certificate-authority/aws-acm-public-ca/dns-providers/route53.ts create mode 100644 backend/src/services/certificate-common/external-metadata-schemas.ts diff --git a/backend/src/db/migrations/20260416231234_add-external-metadata-to-certificates.ts b/backend/src/db/migrations/20260416231234_add-external-metadata-to-certificates.ts new file mode 100644 index 00000000000..568d30f90d0 --- /dev/null +++ b/backend/src/db/migrations/20260416231234_add-external-metadata-to-certificates.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + const hasColumn = await knex.schema.hasColumn(TableName.Certificate, "externalMetadata"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.jsonb("externalMetadata").nullable(); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + if (await knex.schema.hasColumn(TableName.Certificate, "externalMetadata")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropColumn("externalMetadata"); + }); + } + } +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 68325058faf..b69adb06017 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -44,7 +44,8 @@ export const CertificatesSchema = z.object({ isCA: z.boolean().nullable().optional(), pathLength: z.number().nullable().optional(), source: z.string().nullable().optional(), - discoveryMetadata: z.unknown().nullable().optional() + discoveryMetadata: z.unknown().nullable().optional(), + externalMetadata: z.unknown().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index b2737a7e91c..08275bb4a39 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2543,6 +2543,12 @@ export const CertificateAuthorities = { certificateAuthorityArn: `The ARN of the AWS Private Certificate Authority to use for issuing certificates.`, region: `The AWS region where the Private Certificate Authority is located.` }, + AWS_ACM_PUBLIC_CA: { + 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.`, + dnsAppConnectionId: `The ID of the AWS App Connection to use for creating and managing Route 53 CNAME records required for ACM domain validation.`, + hostedZoneId: `The Route 53 hosted zone ID to use for ACM DNS validation CNAME records.`, + region: `The AWS region to use for the ACM API calls.` + }, INTERNAL: { type: "The type of CA to create.", friendlyName: "A friendly name for the CA.", diff --git a/backend/src/server/routes/v1/certificate-authority-routers/aws-acm-public-ca-certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-routers/aws-acm-public-ca-certificate-authority-router.ts new file mode 100644 index 00000000000..d916752b210 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-routers/aws-acm-public-ca-certificate-authority-router.ts @@ -0,0 +1,18 @@ +import { + AwsAcmPublicCaCertificateAuthoritySchema, + CreateAwsAcmPublicCaCertificateAuthoritySchema, + UpdateAwsAcmPublicCaCertificateAuthoritySchema +} from "@app/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-schemas"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +import { registerCertificateAuthorityEndpoints } from "./certificate-authority-endpoints"; + +export const registerAwsAcmPublicCaCertificateAuthorityRouter = async (server: FastifyZodProvider) => { + registerCertificateAuthorityEndpoints({ + caType: CaType.AWS_ACM_PUBLIC_CA, + server, + responseSchema: AwsAcmPublicCaCertificateAuthoritySchema, + createSchema: CreateAwsAcmPublicCaCertificateAuthoritySchema, + updateSchema: UpdateAwsAcmPublicCaCertificateAuthoritySchema + }); +}; diff --git a/backend/src/server/routes/v1/certificate-authority-routers/general-certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-routers/general-certificate-authority-router.ts index 81f36c9e7a6..bcab60f57ed 100644 --- a/backend/src/server/routes/v1/certificate-authority-routers/general-certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-routers/general-certificate-authority-router.ts @@ -6,6 +6,7 @@ import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas"; +import { AwsAcmPublicCaCertificateAuthoritySchema } from "@app/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-schemas"; import { AwsPcaCertificateAuthoritySchema } from "@app/services/certificate-authority/aws-pca/aws-pca-certificate-authority-schemas"; import { AzureAdCsCertificateAuthoritySchema } from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; @@ -15,7 +16,8 @@ const CertificateAuthoritySchema = z.discriminatedUnion("type", [ InternalCertificateAuthoritySchema, AcmeCertificateAuthoritySchema, AzureAdCsCertificateAuthoritySchema, - AwsPcaCertificateAuthoritySchema + AwsPcaCertificateAuthoritySchema, + AwsAcmPublicCaCertificateAuthoritySchema ]); export const registerGeneralCertificateAuthorityRouter = async (server: FastifyZodProvider) => { @@ -73,6 +75,14 @@ export const registerGeneralCertificateAuthorityRouter = async (server: FastifyZ req.permission ); + const awsAcmPublicCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.AWS_ACM_PUBLIC_CA + }, + req.permission + ); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: req.query.projectId, @@ -83,7 +93,8 @@ export const registerGeneralCertificateAuthorityRouter = async (server: FastifyZ ...(internalCas ?? []).map((ca) => ca.id), ...(acmeCas ?? []).map((ca) => ca.id), ...(azureAdCsCas ?? []).map((ca) => ca.id), - ...(awsPcaCas ?? []).map((ca) => ca.id) + ...(awsPcaCas ?? []).map((ca) => ca.id), + ...(awsAcmPublicCas ?? []).map((ca) => ca.id) ] } } @@ -94,7 +105,8 @@ export const registerGeneralCertificateAuthorityRouter = async (server: FastifyZ ...(internalCas ?? []), ...(acmeCas ?? []), ...(azureAdCsCas ?? []), - ...(awsPcaCas ?? []) + ...(awsPcaCas ?? []), + ...(awsAcmPublicCas ?? []) ] }; } diff --git a/backend/src/server/routes/v1/certificate-authority-routers/index.ts b/backend/src/server/routes/v1/certificate-authority-routers/index.ts index 5b87322ac52..48ff71b4f53 100644 --- a/backend/src/server/routes/v1/certificate-authority-routers/index.ts +++ b/backend/src/server/routes/v1/certificate-authority-routers/index.ts @@ -1,6 +1,7 @@ import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { registerAcmeCertificateAuthorityRouter } from "./acme-certificate-authority-router"; +import { registerAwsAcmPublicCaCertificateAuthorityRouter } from "./aws-acm-public-ca-certificate-authority-router"; import { registerAwsPcaCertificateAuthorityRouter } from "./aws-pca-certificate-authority-router"; import { registerAzureAdCsCertificateAuthorityRouter } from "./azure-ad-cs-certificate-authority-router"; import { registerInternalCertificateAuthorityRouter } from "./internal-certificate-authority-router"; @@ -12,5 +13,6 @@ export const CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP: Record { @@ -73,6 +75,14 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { req.permission ); + const awsAcmPublicCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.AWS_ACM_PUBLIC_CA + }, + req.permission + ); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: req.query.projectId, @@ -83,7 +93,8 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { ...(internalCas ?? []).map((ca) => ca.id), ...(acmeCas ?? []).map((ca) => ca.id), ...(azureAdCsCas ?? []).map((ca) => ca.id), - ...(awsPcaCas ?? []).map((ca) => ca.id) + ...(awsPcaCas ?? []).map((ca) => ca.id), + ...(awsAcmPublicCas ?? []).map((ca) => ca.id) ] } } @@ -94,7 +105,8 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { ...(internalCas ?? []), ...(acmeCas ?? []), ...(azureAdCsCas ?? []), - ...(awsPcaCas ?? []) + ...(awsPcaCas ?? []), + ...(awsAcmPublicCas ?? []) ] }; } diff --git a/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-client.ts b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-client.ts new file mode 100644 index 00000000000..313252c07a2 --- /dev/null +++ b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-client.ts @@ -0,0 +1,247 @@ +import { ACMClient } from "@aws-sdk/client-acm"; +import * as x509 from "@peculiar/x509"; + +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { AWS_ACM_CERTIFICATE_VALIDITY_DAYS } from "./aws-acm-public-ca-certificate-authority-enums"; + +// ───────────────────────────────────────────────────────────────────────────── +// FAKE_ACM — START +// TODO(FAKE_ACM): strip this entire block and all `// FAKE_ACM` lines before +// committing. Search for the token FAKE_ACM to find every call site that needs +// cleanup. To opt into real AWS calls (and billing), set PKI_ACM_USE_REAL_AWS=true. +// ───────────────────────────────────────────────────────────────────────────── +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */ +export const FAKE_ACM_USE_REAL = process.env.PKI_ACM_USE_REAL_AWS === "true"; + +type FakeAcmPhase = "requested" | "issued" | "renewing" | "renewed"; +const fakeAcmStateByArn = new Map(); + +// Cache fake fixtures per ARN so retries/exports of the same cert return the +// same body (matching real ACM semantics) while different ARNs get distinct +// certs with unique serial numbers (avoids DB unique-constraint collisions). +const fakeAcmFixturesByArn = new Map(); + +const buildFakeAcmFixtures = async (arn: string) => { + const cached = fakeAcmFixturesByArn.get(arn); + if (cached) return cached; + const keys = (await crypto.nativeCrypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256" + }, + true, + ["sign", "verify"] + )) as CryptoKeyPair; + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: "CN=fake.acm.test", + notBefore: new Date(), + notAfter: new Date(Date.now() + AWS_ACM_CERTIFICATE_VALIDITY_DAYS * 24 * 60 * 60 * 1000), + keys, + signingAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } + }); + const skObj = crypto.nativeCrypto.KeyObject.from(keys.privateKey); + const fixtures = { + certificatePem: cert.toString("pem"), + chainPem: cert.toString("pem"), + unencryptedKeyPem: skObj.export({ format: "pem", type: "pkcs8" }) as string + }; + fakeAcmFixturesByArn.set(arn, fixtures); + return fixtures; +}; + +// Minimal fake ACMClient — intercepts .send() based on command class name. +// Returns canned responses that drive the real code's state machine (retry +// loop, renewal flow, export parsing, etc.) without hitting AWS. +const createFakeAcmClient = () => { + logger.warn("[FAKE_ACM] Creating fake ACMClient — no AWS calls will be made"); + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + send: async (command: any) => { + const cmdName = command?.constructor?.name as string | undefined; + const input = command?.input ?? {}; + + if (cmdName === "ListCertificatesCommand") { + logger.info("[FAKE_ACM] ListCertificates"); + return { CertificateSummaryList: [] }; + } + + if (cmdName === "RequestCertificateCommand") { + const token = (input.IdempotencyToken as string) || `nt${Date.now()}`; + const arn = `arn:aws:acm:us-east-1:000000000000:certificate/${token}`; + if (!fakeAcmStateByArn.has(arn)) { + fakeAcmStateByArn.set(arn, { + phase: "requested", + notAfter: new Date(Date.now() + AWS_ACM_CERTIFICATE_VALIDITY_DAYS * 24 * 60 * 60 * 1000) + }); + } + logger.info(`[FAKE_ACM] RequestCertificate token=${token} → ${arn}`); + return { CertificateArn: arn }; + } + + if (cmdName === "DescribeCertificateCommand") { + const arn = input.CertificateArn as string; + const state = fakeAcmStateByArn.get(arn) ?? { + phase: "issued" as FakeAcmPhase, + notAfter: new Date(Date.now() + AWS_ACM_CERTIFICATE_VALIDITY_DAYS * 24 * 60 * 60 * 1000) + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detail: any = { + CertificateArn: arn, + DomainName: "fake.acm.test", + NotAfter: state.notAfter, + DomainValidationOptions: [ + { + DomainName: "fake.acm.test", + ValidationStatus: state.phase === "requested" ? "PENDING_VALIDATION" : "SUCCESS", + ResourceRecord: { + Name: "_fake.fake.acm.test.", + Type: "CNAME", + Value: "_fake.acm-validations.aws." + } + } + ] + }; + + if (state.phase === "requested") { + detail.Status = "PENDING_VALIDATION"; + state.phase = "issued"; + } else if (state.phase === "renewing") { + detail.Status = "ISSUED"; + detail.RenewalSummary = { + RenewalStatus: "PENDING_VALIDATION", + DomainValidationOptions: detail.DomainValidationOptions, + UpdatedAt: new Date() + }; + state.phase = "renewed"; + } else if (state.phase === "renewed") { + state.notAfter = new Date(Date.now() + AWS_ACM_CERTIFICATE_VALIDITY_DAYS * 24 * 60 * 60 * 1000); + detail.Status = "ISSUED"; + detail.NotAfter = state.notAfter; + detail.RenewalSummary = { + RenewalStatus: "SUCCESS", + DomainValidationOptions: detail.DomainValidationOptions, + UpdatedAt: new Date() + }; + } else { + detail.Status = "ISSUED"; + } + + fakeAcmStateByArn.set(arn, state); + logger.info( + `[FAKE_ACM] DescribeCertificate ${arn} phase=${state.phase} status=${detail.Status} renewal=${detail.RenewalSummary?.RenewalStatus ?? "n/a"}` + ); + return { Certificate: detail }; + } + + if (cmdName === "ExportCertificateCommand") { + const fixtures = await buildFakeAcmFixtures(input.CertificateArn as string); + const passphrase = Buffer.from(input.Passphrase as Uint8Array).toString("utf8"); + const keyObj = crypto.nativeCrypto.createPrivateKey(fixtures.unencryptedKeyPem); + const encryptedKey = keyObj.export({ + format: "pem", + type: "pkcs8", + cipher: "aes-256-cbc", + passphrase + }) as string; + logger.info(`[FAKE_ACM] ExportCertificate ${input.CertificateArn as string}`); + return { + Certificate: fixtures.certificatePem, + CertificateChain: fixtures.chainPem, + PrivateKey: encryptedKey + }; + } + + if (cmdName === "RenewCertificateCommand") { + const arn = input.CertificateArn as string; + const state = fakeAcmStateByArn.get(arn); + if (state) { + state.phase = "renewing"; + fakeAcmStateByArn.set(arn, state); + } + logger.info(`[FAKE_ACM] RenewCertificate ${arn}`); + return {}; + } + + if (cmdName === "RevokeCertificateCommand") { + logger.info(`[FAKE_ACM] RevokeCertificate ${input.CertificateArn as string}`); + return {}; + } + + throw new Error(`[FAKE_ACM] Unhandled command: ${cmdName ?? "unknown"}`); + } + }; +}; +/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */ +// ───────────────────────────────────────────────────────────────────────────── +// FAKE_ACM — END +// ───────────────────────────────────────────────────────────────────────────── + +export const createAcmClient = async ({ + appConnectionId, + region, + appConnectionDAL, + kmsService +}: { + appConnectionId: string; + region: AWSRegion; + appConnectionDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" + >; +}) => { + // FAKE_ACM: short-circuit to the fake client unless PKI_ACM_USE_REAL_AWS=true. + if (!FAKE_ACM_USE_REAL) { + logger.warn( + `[FAKE_ACM] Bypassing real ACM client [appConnectionId=${appConnectionId}] [region=${region}] — set PKI_ACM_USE_REAL_AWS=true to hit AWS.` + ); + return createFakeAcmClient() as unknown as ACMClient; + } + + const appConnection = await appConnectionDAL.findById(appConnectionId); + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${appConnectionId}' not found` }); + } + + const decryptedConnection = (await decryptAppConnection(appConnection, kmsService)) as TAwsConnection; + const awsConfig = await getAwsConnectionConfig(decryptedConnection, region); + + return new ACMClient({ + sha256: CustomAWSHasher, + useFipsEndpoint: crypto.isFipsModeEnabled(), + credentials: awsConfig.credentials, + region: awsConfig.region + }); +}; + +export const resolveDnsAwsConnection = async ({ + dnsAppConnectionId, + appConnectionDAL, + kmsService +}: { + dnsAppConnectionId: string; + appConnectionDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" + >; +}) => { + const dnsAppConnection = await appConnectionDAL.findById(dnsAppConnectionId); + if (!dnsAppConnection) { + throw new NotFoundError({ message: `DNS app connection with ID '${dnsAppConnectionId}' not found` }); + } + return (await decryptAppConnection(dnsAppConnection, kmsService)) as TAwsConnection; +}; diff --git a/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-enums.ts b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-enums.ts new file mode 100644 index 00000000000..1925f60273f --- /dev/null +++ b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-enums.ts @@ -0,0 +1,15 @@ +export enum AwsAcmValidationMethod { + DNS = "DNS" +} + +export enum AwsAcmKeyAlgorithm { + RSA_2048 = "RSA_2048", + EC_prime256v1 = "EC_prime256v1", + EC_secp384r1 = "EC_secp384r1" +} + +/** + * ACM public certificates have a fixed validity period (as of 2025). + * See: https://docs.aws.amazon.com/acm/latest/userguide/managed-renewal.html + */ +export const AWS_ACM_CERTIFICATE_VALIDITY_DAYS = 198; diff --git a/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-fns.ts b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-fns.ts new file mode 100644 index 00000000000..584c2bcf3a7 --- /dev/null +++ b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-fns.ts @@ -0,0 +1,769 @@ +/* eslint-disable no-await-in-loop */ +import { + ACMClient, + CertificateExport, + CertificateStatus, + DescribeCertificateCommand, + ExportCertificateCommand, + ListCertificatesCommand, + RenewCertificateCommand, + RequestCertificateCommand, + RevocationReason, + RevokeCertificateCommand, + ValidationMethod +} from "@aws-sdk/client-acm"; +import * as x509 from "@peculiar/x509"; + +import { TableName } from "@app/db/schemas"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ProcessedPermissionRules } from "@app/lib/knex/permission-filter-utils"; +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { extractCertificateFields } from "@app/services/certificate/certificate-fns"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { + CertKeyAlgorithm, + CertStatus, + CertSubjectAlternativeNameType, + CrlReason +} from "@app/services/certificate/certificate-types"; +import { ExternalMetadataSchema } from "@app/services/certificate-common/external-metadata-schemas"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; +import { CaStatus, CaType } from "../certificate-authority-enums"; +import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal"; +import { + createAcmClient, + FAKE_ACM_USE_REAL, + resolveDnsAwsConnection +} from "./aws-acm-public-ca-certificate-authority-client"; +import { AwsAcmValidationMethod } from "./aws-acm-public-ca-certificate-authority-enums"; +import { + TAwsAcmPublicCaCertificateAuthority, + TCreateAwsAcmPublicCaCertificateAuthorityDTO, + TUpdateAwsAcmPublicCaCertificateAuthorityDTO +} from "./aws-acm-public-ca-certificate-authority-types"; +import { + acmValidationFailedError, + AcmValidationPendingError, + buildIdempotencyToken, + calculateAcmRenewBeforeDays, + generateAcmPassphrase, + mapCertKeyAlgorithmToAcm, + validateAcmIssuanceInputs +} from "./aws-acm-public-ca-certificate-authority-validators"; +import { route53GetHostedZone, route53UpsertRecord } from "./dns-providers/route53"; + +const CRL_REASON_TO_ACM_REVOCATION_REASON_MAP: Record = { + [CrlReason.UNSPECIFIED]: RevocationReason.UNSPECIFIED, + [CrlReason.KEY_COMPROMISE]: RevocationReason.KEY_COMPROMISE, + [CrlReason.CA_COMPROMISE]: RevocationReason.CA_COMPROMISE, + [CrlReason.AFFILIATION_CHANGED]: RevocationReason.AFFILIATION_CHANGED, + [CrlReason.SUPERSEDED]: RevocationReason.SUPERSEDED, + [CrlReason.CESSATION_OF_OPERATION]: RevocationReason.CESSATION_OF_OPERATION, + [CrlReason.CERTIFICATE_HOLD]: RevocationReason.CERTIFICATE_HOLD, + [CrlReason.PRIVILEGE_WITHDRAWN]: RevocationReason.PRIVILEGE_WITHDRAWN, + [CrlReason.A_A_COMPROMISE]: RevocationReason.A_A_COMPROMISE +}; + +type TAwsAcmPublicCaCertificateAuthorityFnsDeps = { + appConnectionDAL: Pick; + appConnectionService: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" + >; + externalCertificateAuthorityDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" + >; + projectDAL: Pick; + certificateProfileDAL?: Pick; +}; + +export const castDbEntryToAwsAcmPublicCaCertificateAuthority = ( + ca: Awaited> +): TAwsAcmPublicCaCertificateAuthority => { + if (!ca.externalCa?.id) { + throw new BadRequestError({ message: "Malformed AWS ACM Public Certificate Authority" }); + } + + if (!ca.externalCa.appConnectionId) { + throw new BadRequestError({ + message: "AWS app connection ID is missing from certificate authority configuration" + }); + } + + const configuration = ca.externalCa.configuration as { + dnsAppConnectionId?: string; + hostedZoneId?: string; + region: AWSRegion; + }; + + if (!configuration.region || !configuration.dnsAppConnectionId || !configuration.hostedZoneId) { + throw new BadRequestError({ + message: "AWS ACM configuration is incomplete — region, Route 53 connection, and hosted zone ID are required" + }); + } + + return { + id: ca.id, + type: CaType.AWS_ACM_PUBLIC_CA, + enableDirectIssuance: ca.enableDirectIssuance, + name: ca.name, + projectId: ca.projectId, + configuration: { + appConnectionId: ca.externalCa.appConnectionId, + dnsAppConnectionId: configuration.dnsAppConnectionId, + hostedZoneId: configuration.hostedZoneId, + region: configuration.region + }, + status: ca.status as CaStatus + }; +}; + +export const AwsAcmPublicCaCertificateAuthorityFns = ({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + certificateProfileDAL +}: TAwsAcmPublicCaCertificateAuthorityFnsDeps) => { + const validateAwsConnection = async ({ + appConnectionId, + dnsAppConnectionId, + projectId, + actor + }: { + appConnectionId: string; + dnsAppConnectionId?: string; + projectId: string; + actor: OrgServiceActor; + }) => { + const appConnection = await appConnectionDAL.findById(appConnectionId); + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${appConnectionId}' not found` }); + } + if (appConnection.app !== AppConnection.AWS) { + throw new BadRequestError({ + message: `App connection with ID '${appConnectionId}' is not an AWS connection` + }); + } + await appConnectionService.validateAppConnectionUsageById( + appConnection.app as AppConnection, + { connectionId: appConnectionId, projectId }, + actor + ); + + if (dnsAppConnectionId && dnsAppConnectionId !== appConnectionId) { + const dnsAppConnection = await appConnectionDAL.findById(dnsAppConnectionId); + if (!dnsAppConnection) { + throw new NotFoundError({ message: `DNS app connection with ID '${dnsAppConnectionId}' not found` }); + } + if (dnsAppConnection.app !== AppConnection.AWS) { + throw new BadRequestError({ + message: `DNS app connection with ID '${dnsAppConnectionId}' is not an AWS connection` + }); + } + await appConnectionService.validateAppConnectionUsageById( + dnsAppConnection.app as AppConnection, + { connectionId: dnsAppConnectionId, projectId }, + actor + ); + } + }; + + const createCertificateAuthority = async ({ + name, + projectId, + configuration, + actor, + status + }: { + status: CaStatus; + name: string; + projectId: string; + configuration: TCreateAwsAcmPublicCaCertificateAuthorityDTO["configuration"]; + actor: OrgServiceActor; + }) => { + const { appConnectionId, dnsAppConnectionId, hostedZoneId, region } = configuration; + + await validateAwsConnection({ appConnectionId, dnsAppConnectionId, projectId, actor }); + + // Smoke-test both connections up front — ACM via ListCertificates (no single "get CA" resource), + // and Route 53 via GetHostedZone so a misconfigured DNS connection / wrong zone ID fails + // synchronously here instead of mid-issuance. + const acmClient = await createAcmClient({ appConnectionId, region, appConnectionDAL, kmsService }); + await acmClient.send(new ListCertificatesCommand({ MaxItems: 1 })); + + const dnsConnection = await resolveDnsAwsConnection({ dnsAppConnectionId, appConnectionDAL, kmsService }); + await route53GetHostedZone(dnsConnection, hostedZoneId); + + const caEntity = await certificateAuthorityDAL.transaction(async (tx) => { + try { + const ca = await certificateAuthorityDAL.create( + { + projectId, + enableDirectIssuance: false, + name, + status + }, + tx + ); + + await externalCertificateAuthorityDAL.create( + { + caId: ca.id, + appConnectionId, + type: CaType.AWS_ACM_PUBLIC_CA, + configuration: { + dnsAppConnectionId, + hostedZoneId, + region + } + }, + tx + ); + + return await certificateAuthorityDAL.findByIdWithAssociatedCa(ca.id, tx); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + if ((error as any)?.error?.code === "23505") { + throw new BadRequestError({ + message: "Certificate authority with the same name already exists in your project" + }); + } + throw error; + } + }); + + if (!caEntity.externalCa?.id) { + throw new BadRequestError({ message: "Failed to create external certificate authority" }); + } + + return castDbEntryToAwsAcmPublicCaCertificateAuthority(caEntity); + }; + + const updateCertificateAuthority = async ({ + id, + status, + configuration, + actor, + name + }: { + id: string; + status?: CaStatus; + configuration: TUpdateAwsAcmPublicCaCertificateAuthorityDTO["configuration"]; + actor: OrgServiceActor; + name?: string; + }) => { + const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { + if (configuration) { + const { appConnectionId, dnsAppConnectionId, hostedZoneId, region } = configuration; + + const ca = await certificateAuthorityDAL.findById(id); + if (!ca) { + throw new NotFoundError({ message: `Could not find Certificate Authority with ID "${id}"` }); + } + + await validateAwsConnection({ appConnectionId, dnsAppConnectionId, projectId: ca.projectId, actor }); + + const acmClient = await createAcmClient({ appConnectionId, region, appConnectionDAL, kmsService }); + await acmClient.send(new ListCertificatesCommand({ MaxItems: 1 })); + + const dnsConnection = await resolveDnsAwsConnection({ dnsAppConnectionId, appConnectionDAL, kmsService }); + await route53GetHostedZone(dnsConnection, hostedZoneId); + + await externalCertificateAuthorityDAL.update( + { + caId: id, + type: CaType.AWS_ACM_PUBLIC_CA + }, + { + appConnectionId, + configuration: { + dnsAppConnectionId, + hostedZoneId, + region + } + }, + tx + ); + } + + if (name || status) { + await certificateAuthorityDAL.updateById( + id, + { + name, + status + }, + tx + ); + } + + return certificateAuthorityDAL.findByIdWithAssociatedCa(id, tx); + }); + + if (!updatedCa.externalCa?.id) { + throw new BadRequestError({ message: "Failed to update external certificate authority" }); + } + + return castDbEntryToAwsAcmPublicCaCertificateAuthority(updatedCa); + }; + + const listCertificateAuthorities = async ({ + projectId, + permissionFilters + }: { + projectId: string; + permissionFilters?: ProcessedPermissionRules; + }) => { + const cas = await certificateAuthorityDAL.findWithAssociatedCa( + { + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, + [`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.AWS_ACM_PUBLIC_CA + }, + {}, + permissionFilters + ); + + return cas.map(castDbEntryToAwsAcmPublicCaCertificateAuthority); + }; + + /** + * Issues (or renews) a certificate from AWS Certificate Manager. + * + * Idempotent via AWS's IdempotencyToken: retrying the same certificateId within + * AWS's 1-hour window returns the same certificate ARN, so we don't need to persist + * intermediate state across retries. The cert record is only created when everything + * completes, in a single DB transaction. If DNS validation is still pending, this + * function throws AcmValidationPendingError and the queue retries. + */ + const orderCertificateFromProfile = async ({ + caId, + profileId, + commonName, + altNames = [], + keyAlgorithm = CertKeyAlgorithm.RSA_2048, + signatureAlgorithm, + isRenewal, + originalCertificateId, + certificateId, + csr, + validity, + organization, + organizationalUnit, + country, + state, + locality, + keyUsages = [], + extendedKeyUsages = [] + }: { + caId: string; + profileId: string; + commonName: string; + altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>; + keyAlgorithm?: CertKeyAlgorithm; + signatureAlgorithm?: string; + isRenewal?: boolean; + originalCertificateId?: string; + certificateId: string; + csr?: string; + validity?: { ttl?: string }; + organization?: string; + organizationalUnit?: string; + country?: string; + state?: string; + locality?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + }) => { + validateAcmIssuanceInputs({ + csr, + keyAlgorithm, + altNames, + ttl: validity?.ttl, + organization, + organizationalUnit, + country, + state, + locality + }); + + if (keyUsages.length > 0 || extendedKeyUsages.length > 0) { + logger.info( + `[caId=${caId}] AWS ACM overrides caller-specified key usages and extended key usages with its own current policy.` + ); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.externalCa || ca.externalCa.type !== CaType.AWS_ACM_PUBLIC_CA) { + throw new BadRequestError({ message: "CA is not an AWS ACM Public Certificate Authority" }); + } + + const acmCa = castDbEntryToAwsAcmPublicCaCertificateAuthority(ca); + if (acmCa.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } + + const { appConnectionId, dnsAppConnectionId, hostedZoneId, region } = acmCa.configuration; + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ kmsId: certificateManagerKmsId }); + let certificateArn: string; + let acmClient: ACMClient; + + if (isRenewal && originalCertificateId) { + const originalCert = await certificateDAL.findById(originalCertificateId); + if (!originalCert) { + throw new BadRequestError({ message: `Original certificate ${originalCertificateId} not found` }); + } + const parsedMetadata = ExternalMetadataSchema.safeParse(originalCert.externalMetadata); + if ( + !parsedMetadata.success || + parsedMetadata.data.type !== CaType.AWS_ACM_PUBLIC_CA || + !parsedMetadata.data.arn + ) { + throw new BadRequestError({ + message: "Original certificate is missing AWS ACM metadata — cannot renew" + }); + } + certificateArn = parsedMetadata.data.arn; + + // ARNs are region-locked. Use the cert's stored region, not the CA's current region + // (the CA's region may have been edited since the cert was issued). + acmClient = await createAcmClient({ + appConnectionId, + region: parsedMetadata.data.region, + appConnectionDAL, + kmsService + }); + + const describe = await acmClient.send(new DescribeCertificateCommand({ CertificateArn: certificateArn })); + const detail = describe.Certificate; + if (!detail) { + throw new BadRequestError({ message: `ACM did not return details for certificate ${certificateArn}` }); + } + + const awsNotAfter = detail.NotAfter; + const storedNotAfter = originalCert.notAfter; + const alreadyRenewedByAws = awsNotAfter && storedNotAfter && awsNotAfter.getTime() > storedNotAfter.getTime(); + + if (!alreadyRenewedByAws) { + if (detail.DomainValidationOptions) { + // FAKE_ACM: skip Route 53 writes when running against the fake ACM client. + if (!FAKE_ACM_USE_REAL) { + logger.info("[FAKE_ACM] Skipping Route 53 CNAME upserts for renewal path"); + } else { + const dnsConnection = await resolveDnsAwsConnection({ + dnsAppConnectionId, + appConnectionDAL, + kmsService + }); + for (const dv of detail.DomainValidationOptions) { + if (dv.ResourceRecord?.Name && dv.ResourceRecord?.Value) { + await route53UpsertRecord(dnsConnection, hostedZoneId, { + name: dv.ResourceRecord.Name, + type: "CNAME", + value: dv.ResourceRecord.Value + }); + } + } + } + } + + const renewalInProgress = detail.RenewalSummary?.RenewalStatus === "PENDING_VALIDATION"; + if (!renewalInProgress) { + await acmClient.send(new RenewCertificateCommand({ CertificateArn: certificateArn })); + } + + const afterRenew = await acmClient.send(new DescribeCertificateCommand({ CertificateArn: certificateArn })); + const renewStatus = afterRenew.Certificate?.RenewalSummary?.RenewalStatus; + if (renewStatus === "PENDING_VALIDATION") { + throw new AcmValidationPendingError( + `AWS ACM renewal for ${certificateArn} is still pending validation — will retry` + ); + } + if (renewStatus === "FAILED") { + throw acmValidationFailedError(`AWS ACM renewal failed for ${certificateArn}`); + } + } + } else { + // New issuance — use the CA's configured region. + acmClient = await createAcmClient({ appConnectionId, region, appConnectionDAL, kmsService }); + + const domainName = commonName || (altNames.length > 0 ? altNames[0].value : ""); + if (!domainName) { + throw new BadRequestError({ message: "AWS ACM requires a DomainName (common name or first SAN)" }); + } + const subjectAlternativeNames = altNames.map((s) => s.value); + + const idempotencyToken = buildIdempotencyToken(certificateId); + + const requestResult = await acmClient.send( + new RequestCertificateCommand({ + DomainName: domainName, + SubjectAlternativeNames: subjectAlternativeNames.length > 0 ? subjectAlternativeNames : undefined, + KeyAlgorithm: mapCertKeyAlgorithmToAcm(keyAlgorithm), + ValidationMethod: ValidationMethod.DNS, + IdempotencyToken: idempotencyToken, + Options: { Export: CertificateExport.ENABLED } + }) + ); + + if (!requestResult.CertificateArn) { + throw new BadRequestError({ message: "AWS ACM did not return a certificate ARN" }); + } + certificateArn = requestResult.CertificateArn; + + const describe = await acmClient.send(new DescribeCertificateCommand({ CertificateArn: certificateArn })); + const detail = describe.Certificate; + if (!detail) { + throw new BadRequestError({ message: `ACM did not return details for certificate ${certificateArn}` }); + } + + if (detail.DomainValidationOptions) { + // FAKE_ACM: skip Route 53 writes when running against the fake ACM client. + if (!FAKE_ACM_USE_REAL) { + logger.info("[FAKE_ACM] Skipping Route 53 CNAME upserts for issuance path"); + } else { + const dnsConnection = await resolveDnsAwsConnection({ + dnsAppConnectionId, + appConnectionDAL, + kmsService + }); + for (const dv of detail.DomainValidationOptions) { + if (dv.ResourceRecord?.Name && dv.ResourceRecord?.Value) { + await route53UpsertRecord(dnsConnection, hostedZoneId, { + name: dv.ResourceRecord.Name, + type: "CNAME", + value: dv.ResourceRecord.Value + }); + } + } + } + } + + if (detail.Status === CertificateStatus.PENDING_VALIDATION) { + throw new AcmValidationPendingError( + `AWS ACM certificate ${certificateArn} is still pending DNS validation — will retry` + ); + } + if ( + detail.Status === CertificateStatus.FAILED || + detail.Status === CertificateStatus.VALIDATION_TIMED_OUT || + detail.Status === CertificateStatus.REVOKED || + detail.Status === CertificateStatus.EXPIRED + ) { + throw acmValidationFailedError(`AWS ACM certificate ${certificateArn} is in terminal status: ${detail.Status}`); + } + } + + const passphrase = generateAcmPassphrase(); + const exportResult = await acmClient.send( + new ExportCertificateCommand({ + CertificateArn: certificateArn, + Passphrase: Buffer.from(passphrase, "utf8") + }) + ); + + if (!exportResult.Certificate || !exportResult.PrivateKey) { + throw new BadRequestError({ + message: `AWS ACM ExportCertificate did not return certificate body or private key for ${certificateArn}` + }); + } + + const certificatePem = exportResult.Certificate; + const certificateChainPem = exportResult.CertificateChain || ""; + const encryptedPrivateKeyPem = exportResult.PrivateKey; + + // Decrypt AWS's encrypted private key with the ephemeral passphrase, then re-serialize as plain PKCS8. + const privateKeyObj = crypto.nativeCrypto.createPrivateKey({ + key: encryptedPrivateKeyPem, + format: "pem", + passphrase + }); + const privateKeyPem = privateKeyObj.export({ format: "pem", type: "pkcs8" }) as string; + + let certObj: x509.X509Certificate; + try { + certObj = new x509.X509Certificate(certificatePem); + } catch (error) { + throw new BadRequestError({ + message: `Failed to parse certificate from AWS ACM: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(privateKeyPem) + }); + + const parsedFields = extractCertificateFields(Buffer.from(certificatePem)); + + const externalMetadata = ExternalMetadataSchema.parse({ + type: CaType.AWS_ACM_PUBLIC_CA, + arn: certificateArn, + region, + validationMethod: AwsAcmValidationMethod.DNS + }); + + let newCertId: string; + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + profileId, + status: CertStatus.ACTIVE, + friendlyName: commonName, + commonName, + altNames: altNames.map((san) => san.value).join(","), + serialNumber: certObj.serialNumber, + notBefore: certObj.notBefore, + notAfter: certObj.notAfter, + keyAlgorithm, + signatureAlgorithm, + projectId: ca.projectId, + externalMetadata, + renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null, + ...parsedFields + }, + tx + ); + + newCertId = cert.id; + + if (isRenewal && originalCertificateId) { + await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, tx); + } + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + + if (profileId && certificateProfileDAL) { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId, tx); + if (profile) { + const finalRenewBeforeDays = calculateAcmRenewBeforeDays(profile); + if (finalRenewBeforeDays !== undefined) { + await certificateDAL.updateById(cert.id, { renewBeforeDays: finalRenewBeforeDays }, tx); + } + } + } + }); + + return { + certificate: certificatePem, + certificateChain: certificateChainPem, + privateKey: privateKeyPem, + serialNumber: certObj.serialNumber, + certificateId: newCertId!, + ca: acmCa + }; + }; + + const revokeCertificate = async ({ + caId, + serialNumber, + reason + }: { + caId: string; + serialNumber: string; + reason: CrlReason; + }) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.externalCa || ca.externalCa.type !== CaType.AWS_ACM_PUBLIC_CA) { + throw new BadRequestError({ message: "CA is not an AWS ACM Public Certificate Authority" }); + } + + const acmCa = castDbEntryToAwsAcmPublicCaCertificateAuthority(ca); + const { appConnectionId } = acmCa.configuration; + + // ACM revokes by ARN, not serial number. Look up the ARN from the cert's externalMetadata. + const cert = await certificateDAL.findOne({ caId, serialNumber }); + if (!cert) { + throw new NotFoundError({ + message: `Certificate with serial number '${serialNumber}' not found under CA '${caId}'` + }); + } + const parsedMetadata = ExternalMetadataSchema.safeParse(cert.externalMetadata); + if (!parsedMetadata.success || parsedMetadata.data.type !== CaType.AWS_ACM_PUBLIC_CA || !parsedMetadata.data.arn) { + throw new BadRequestError({ + message: `Certificate '${cert.id}' is missing AWS ACM metadata — cannot resolve ARN for revocation` + }); + } + + // ARNs are region-locked — use the cert's stored region, not the CA's current region. + const acmClient = await createAcmClient({ + appConnectionId, + region: parsedMetadata.data.region, + appConnectionDAL, + kmsService + }); + const revocationReason = CRL_REASON_TO_ACM_REVOCATION_REASON_MAP[reason]; + + const result = await acmClient.send( + new RevokeCertificateCommand({ + CertificateArn: parsedMetadata.data.arn, + RevocationReason: revocationReason + }) + ); + logger.info(result, "AWS ACM RevokeCertificate result"); + }; + + return { + createCertificateAuthority, + updateCertificateAuthority, + listCertificateAuthorities, + orderCertificateFromProfile, + revokeCertificate + }; +}; + +// Re-export for existing callers (queue, v3 service, approval fns, etc.). +export { acmValidationFailedError, AcmValidationPendingError, validateAcmIssuanceInputs }; diff --git a/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-schemas.ts new file mode 100644 index 00000000000..ebbb100c58c --- /dev/null +++ b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-schemas.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +import { CertificateAuthorities } from "@app/lib/api-docs/constants"; +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; + +import { CaType } from "../certificate-authority-enums"; +import { + BaseCertificateAuthoritySchema, + GenericCreateCertificateAuthorityFieldsSchema, + GenericUpdateCertificateAuthorityFieldsSchema +} from "../certificate-authority-schemas"; + +export const AwsAcmPublicCaCertificateAuthorityConfigurationSchema = z.object({ + appConnectionId: z + .string() + .uuid() + .trim() + .describe(CertificateAuthorities.CONFIGURATIONS.AWS_ACM_PUBLIC_CA.appConnectionId), + dnsAppConnectionId: z + .string() + .uuid() + .trim() + .describe(CertificateAuthorities.CONFIGURATIONS.AWS_ACM_PUBLIC_CA.dnsAppConnectionId), + hostedZoneId: z + .string() + .trim() + .min(1, "Hosted Zone ID is required") + .describe(CertificateAuthorities.CONFIGURATIONS.AWS_ACM_PUBLIC_CA.hostedZoneId), + region: z.nativeEnum(AWSRegion).describe(CertificateAuthorities.CONFIGURATIONS.AWS_ACM_PUBLIC_CA.region) +}); + +export const AwsAcmPublicCaCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({ + type: z.literal(CaType.AWS_ACM_PUBLIC_CA), + configuration: AwsAcmPublicCaCertificateAuthorityConfigurationSchema +}); + +export const CreateAwsAcmPublicCaCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema( + CaType.AWS_ACM_PUBLIC_CA +).extend({ + configuration: AwsAcmPublicCaCertificateAuthorityConfigurationSchema +}); + +export const UpdateAwsAcmPublicCaCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema( + CaType.AWS_ACM_PUBLIC_CA +).extend({ + configuration: AwsAcmPublicCaCertificateAuthorityConfigurationSchema.optional() +}); diff --git a/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-types.ts b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-types.ts new file mode 100644 index 00000000000..680d46e4b8a --- /dev/null +++ b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-types.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +import { + AwsAcmPublicCaCertificateAuthoritySchema, + CreateAwsAcmPublicCaCertificateAuthoritySchema, + UpdateAwsAcmPublicCaCertificateAuthoritySchema +} from "./aws-acm-public-ca-certificate-authority-schemas"; + +export type TAwsAcmPublicCaCertificateAuthority = z.infer; + +export type TCreateAwsAcmPublicCaCertificateAuthorityDTO = z.infer< + typeof CreateAwsAcmPublicCaCertificateAuthoritySchema +>; + +export type TUpdateAwsAcmPublicCaCertificateAuthorityDTO = z.infer< + typeof UpdateAwsAcmPublicCaCertificateAuthoritySchema +>; diff --git a/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-validators.ts b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-validators.ts new file mode 100644 index 00000000000..19a23ac7c97 --- /dev/null +++ b/backend/src/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-validators.ts @@ -0,0 +1,160 @@ +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { CertKeyAlgorithm, CertSubjectAlternativeNameType } from "@app/services/certificate/certificate-types"; + +import { AWS_ACM_CERTIFICATE_VALIDITY_DAYS } from "./aws-acm-public-ca-certificate-authority-enums"; + +export const ACM_ALLOWED_KEY_ALGORITHMS = new Set([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 +]); + +export const ACM_FIXED_VALIDITY_MS = AWS_ACM_CERTIFICATE_VALIDITY_DAYS * 24 * 60 * 60 * 1000; + +/** + * Pre-flight validator for ACM issuance inputs. Called both by the async fns + * (defense in depth) and synchronously by the certificate order API before + * enqueuing, so the user gets a 400 on submit rather than a FAILED request + * row a moment later. + */ +export const validateAcmIssuanceInputs = ({ + csr, + keyAlgorithm, + altNames, + ttl, + notBefore, + notAfter, + basicConstraints, + organization, + organizationalUnit, + country, + state, + locality +}: { + csr?: string; + keyAlgorithm?: string; + altNames?: Array<{ type: CertSubjectAlternativeNameType; value: string }>; + ttl?: string; + notBefore?: Date | string; + notAfter?: Date | string; + basicConstraints?: { isCA?: boolean; pathLength?: number }; + organization?: string; + organizationalUnit?: string; + country?: string; + state?: string; + locality?: string; +}) => { + if (csr) { + throw new BadRequestError({ + message: "AWS Certificate Manager does not support CSR-based issuance" + }); + } + if (keyAlgorithm && !ACM_ALLOWED_KEY_ALGORITHMS.has(keyAlgorithm)) { + throw new BadRequestError({ + message: `AWS ACM only supports RSA_2048, EC_prime256v1, and EC_secp384r1 key algorithms. Received: ${keyAlgorithm}` + }); + } + if (organization || organizationalUnit || country || state || locality) { + throw new BadRequestError({ + message: "AWS Certificate Manager does not support subject fields (O, OU, C, ST, L)" + }); + } + if (altNames) { + for (const san of altNames) { + if (san.type !== CertSubjectAlternativeNameType.DNS_NAME) { + throw new BadRequestError({ + message: `AWS Certificate Manager only supports DNS SANs. Unsupported SAN type: ${san.type}` + }); + } + } + } + if (!ttl) { + throw new BadRequestError({ + message: `AWS Certificate Manager issues certificates with a fixed validity of ${AWS_ACM_CERTIFICATE_VALIDITY_DAYS} days.` + }); + } + let ttlMs: number; + try { + ttlMs = ms(ttl); + } catch { + throw new BadRequestError({ + message: `Invalid TTL format: ${ttl}` + }); + } + if (ttlMs !== ACM_FIXED_VALIDITY_MS) { + throw new BadRequestError({ + message: `AWS Certificate Manager issues certificates with a fixed validity of ${AWS_ACM_CERTIFICATE_VALIDITY_DAYS} days.` + }); + } + if (notBefore || notAfter) { + throw new BadRequestError({ + message: `AWS Certificate Manager does not support notBefore or notAfter — validity is fixed at ${AWS_ACM_CERTIFICATE_VALIDITY_DAYS} days from issuance.` + }); + } + if (basicConstraints?.isCA) { + throw new BadRequestError({ + message: "AWS Certificate Manager does not issue CA certificates." + }); + } +}; + +export const mapCertKeyAlgorithmToAcm = (keyAlgorithm: CertKeyAlgorithm) => { + switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_2048: + return "RSA_2048"; + case CertKeyAlgorithm.ECDSA_P256: + return "EC_prime256v1"; + case CertKeyAlgorithm.ECDSA_P384: + return "EC_secp384r1"; + default: + throw new BadRequestError({ + message: `AWS ACM only supports RSA_2048, EC_prime256v1, and EC_secp384r1 key algorithms. Received: ${keyAlgorithm as string}` + }); + } +}; + +// ACM's ExportCertificate passphrase must be 4-128 chars and cannot contain #, $, or %. +const ACM_PASSPHRASE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +export const generateAcmPassphrase = (): string => { + const len = 32; + const bytes = crypto.randomBytes(len); + let out = ""; + for (let i = 0; i < len; i += 1) { + out += ACM_PASSPHRASE_ALPHABET[bytes[i] % ACM_PASSPHRASE_ALPHABET.length]; + } + return out; +}; + +// ACM validity is fixed — clamp renewBeforeDays so auto-renewal fires before expiry. +export const calculateAcmRenewBeforeDays = ( + profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined +): number | undefined => { + if (!profile?.apiConfig?.autoRenew || !profile.apiConfig.renewBeforeDays) { + return undefined; + } + const profileRenewBeforeDays = profile.apiConfig.renewBeforeDays; + if (profileRenewBeforeDays >= AWS_ACM_CERTIFICATE_VALIDITY_DAYS) { + return Math.max(1, AWS_ACM_CERTIFICATE_VALIDITY_DAYS - 1); + } + return profileRenewBeforeDays; +}; + +// Strip hyphens from the certificate UUID to produce a 32-char token that +// satisfies AWS's IdempotencyToken constraints (max 32 chars, alphanumeric). +export const buildIdempotencyToken = (certificateId: string) => certificateId.replace(/-/g, "").slice(0, 32); + +export class AcmValidationPendingError extends Error { + constructor(message: string) { + super(message); + this.name = "AcmValidationPendingError"; + } +} + +// Use the same class shape for a terminal validation failure — signal via name. +export const acmValidationFailedError = (message: string) => { + const err = new Error(message); + err.name = "AcmValidationFailedError"; + return err; +}; diff --git a/backend/src/services/certificate-authority/aws-acm-public-ca/dns-providers/route53.ts b/backend/src/services/certificate-authority/aws-acm-public-ca/dns-providers/route53.ts new file mode 100644 index 00000000000..0fdf4187db9 --- /dev/null +++ b/backend/src/services/certificate-authority/aws-acm-public-ca/dns-providers/route53.ts @@ -0,0 +1,59 @@ +import { ChangeResourceRecordSetsCommand, GetHostedZoneCommand, Route53Client } from "@aws-sdk/client-route-53"; + +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; + +export type TRoute53Record = { + name: string; + type: "CNAME" | "TXT" | "A" | "AAAA"; + value: string; + ttl?: number; +}; + +const buildClient = async (connection: TAwsConnectionConfig) => { + // Route 53 is a global service — the region passed here only affects the signer, not the data plane. + // us-east-1 is AWS's canonical region for global services. + const config = await getAwsConnectionConfig(connection, AWSRegion.US_EAST_1); + return new Route53Client({ + sha256: CustomAWSHasher, + useFipsEndpoint: crypto.isFipsModeEnabled(), + credentials: config.credentials, + region: config.region + }); +}; + +export const route53UpsertRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + record: TRoute53Record +) => { + const route53Client = await buildClient(connection); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: `Upsert ${record.type} record for ${record.name}`, + Changes: [ + { + Action: "UPSERT", + ResourceRecordSet: { + Name: record.name, + Type: record.type, + TTL: record.ttl ?? 300, + ResourceRecords: [{ Value: record.value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; + +export const route53GetHostedZone = async (connection: TAwsConnectionConfig, hostedZoneId: string) => { + const route53Client = await buildClient(connection); + await route53Client.send(new GetHostedZoneCommand({ Id: hostedZoneId })); +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-enums.ts b/backend/src/services/certificate-authority/certificate-authority-enums.ts index 7b56dc1bb0e..8919cc53d05 100644 --- a/backend/src/services/certificate-authority/certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/certificate-authority-enums.ts @@ -2,7 +2,8 @@ export enum CaType { INTERNAL = "internal", ACME = "acme", AZURE_AD_CS = "azure-ad-cs", - AWS_PCA = "aws-pca" + AWS_PCA = "aws-pca", + AWS_ACM_PUBLIC_CA = "aws-acm-public-ca" } export enum InternalCaType { diff --git a/backend/src/services/certificate-authority/certificate-authority-maps.ts b/backend/src/services/certificate-authority/certificate-authority-maps.ts index 0fc093a1a3f..77916adbe32 100644 --- a/backend/src/services/certificate-authority/certificate-authority-maps.ts +++ b/backend/src/services/certificate-authority/certificate-authority-maps.ts @@ -4,7 +4,8 @@ export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record = { [CaType.INTERNAL]: "Internal", [CaType.ACME]: "ACME-compatible CA", [CaType.AZURE_AD_CS]: "Active Directory Certificate Service", - [CaType.AWS_PCA]: "AWS Private Certificate Authority" + [CaType.AWS_PCA]: "AWS Private Certificate Authority", + [CaType.AWS_ACM_PUBLIC_CA]: "AWS ACM Public CA" }; export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record = { @@ -19,7 +20,16 @@ export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record; permissionService: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -87,7 +95,7 @@ type TCertificateAuthorityServiceFactoryDep = { pkiSubscriberDAL: Pick; pkiSyncDAL: Pick; pkiSyncQueue: Pick; - certificateProfileDAL?: Pick; + certificateProfileDAL?: Pick; }; export type TCertificateAuthorityServiceFactory = ReturnType; @@ -154,6 +162,19 @@ export const certificateAuthorityServiceFactory = ({ certificateProfileDAL }); + const awsAcmPublicCaFns = AwsAcmPublicCaCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + certificateProfileDAL + }); + const createCertificateAuthority = async ( { type, projectId, name, configuration, status }: TCreateCertificateAuthorityDTO, actor: OrgServiceActor @@ -227,6 +248,16 @@ export const certificateAuthorityServiceFactory = ({ }); } + if (type === CaType.AWS_ACM_PUBLIC_CA) { + return awsAcmPublicCaFns.createCertificateAuthority({ + name, + projectId, + configuration: configuration as TCreateAwsAcmPublicCaCertificateAuthorityDTO["configuration"], + status, + actor + }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -289,6 +320,10 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAwsPcaCertificateAuthority(certificateAuthority); } + if (type === CaType.AWS_ACM_PUBLIC_CA) { + return castDbEntryToAwsAcmPublicCaCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -356,6 +391,10 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAwsPcaCertificateAuthority(certificateAuthority); } + if (type === CaType.AWS_ACM_PUBLIC_CA) { + return castDbEntryToAwsAcmPublicCaCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -418,6 +457,10 @@ export const certificateAuthorityServiceFactory = ({ return awsPcaFns.listCertificateAuthorities({ projectId, permissionFilters }); } + if (type === CaType.AWS_ACM_PUBLIC_CA) { + return awsAcmPublicCaFns.listCertificateAuthorities({ projectId, permissionFilters }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -507,6 +550,16 @@ export const certificateAuthorityServiceFactory = ({ }); } + if (type === CaType.AWS_ACM_PUBLIC_CA) { + return awsAcmPublicCaFns.updateCertificateAuthority({ + id: certificateAuthority.id, + configuration: configuration as TUpdateAwsAcmPublicCaCertificateAuthorityDTO["configuration"], + actor, + status, + name + }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -570,6 +623,10 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAwsPcaCertificateAuthority(certificateAuthority); } + if (type === CaType.AWS_ACM_PUBLIC_CA) { + return castDbEntryToAwsAcmPublicCaCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -662,6 +719,16 @@ export const certificateAuthorityServiceFactory = ({ }); } + if (type === CaType.AWS_ACM_PUBLIC_CA) { + return awsAcmPublicCaFns.updateCertificateAuthority({ + id: certificateAuthority.id, + configuration: configuration as TUpdateAwsAcmPublicCaCertificateAuthorityDTO["configuration"], + actor, + status, + name + }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -731,6 +798,10 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAwsPcaCertificateAuthority(certificateAuthority); } + if (type === CaType.AWS_ACM_PUBLIC_CA) { + return castDbEntryToAwsAcmPublicCaCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -840,6 +911,11 @@ export const certificateAuthorityServiceFactory = ({ return; } + if (caType === CaType.AWS_ACM_PUBLIC_CA) { + await awsAcmPublicCaFns.revokeCertificate({ caId, serialNumber, reason }); + return; + } + throw new BadRequestError({ message: `Certificate revocation via CA service is not supported for CA type "${caType}"` }); diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index d3eb1257a97..fa570af41f3 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -1,4 +1,8 @@ import { TAcmeCertificateAuthority, TAcmeCertificateAuthorityInput } from "./acme/acme-certificate-authority-types"; +import { + TAwsAcmPublicCaCertificateAuthority, + TCreateAwsAcmPublicCaCertificateAuthorityDTO +} from "./aws-acm-public-ca/aws-acm-public-ca-certificate-authority-types"; import { TAwsPcaCertificateAuthority, TCreateAwsPcaCertificateAuthorityDTO @@ -17,13 +21,15 @@ export type TCertificateAuthority = | TInternalCertificateAuthority | TAcmeCertificateAuthority | TAzureAdCsCertificateAuthority - | TAwsPcaCertificateAuthority; + | TAwsPcaCertificateAuthority + | TAwsAcmPublicCaCertificateAuthority; export type TCertificateAuthorityInput = | TInternalCertificateAuthorityInput | TAcmeCertificateAuthorityInput | TCreateAzureAdCsCertificateAuthorityDTO - | TCreateAwsPcaCertificateAuthorityDTO; + | TCreateAwsPcaCertificateAuthorityDTO + | TCreateAwsAcmPublicCaCertificateAuthorityDTO; export type TCreateCertificateAuthorityDTO = Omit; diff --git a/backend/src/services/certificate-authority/certificate-issuance-queue.ts b/backend/src/services/certificate-authority/certificate-issuance-queue.ts index aae303ca7ce..42666c3aebc 100644 --- a/backend/src/services/certificate-authority/certificate-issuance-queue.ts +++ b/backend/src/services/certificate-authority/certificate-issuance-queue.ts @@ -1,4 +1,5 @@ import acme from "acme-client"; +import { UnrecoverableError } from "bullmq"; import { crypto } from "@app/lib/crypto/cryptography"; import { NotFoundError } from "@app/lib/errors"; @@ -29,6 +30,10 @@ import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { copyMetadataFromRequestToCertificate } from "../resource-metadata/resource-metadata-fns"; import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns"; +import { + AcmValidationPendingError, + AwsAcmPublicCaCertificateAuthorityFns +} from "./aws-acm-public-ca/aws-acm-public-ca-certificate-authority-fns"; import { AwsPcaCertificateAuthorityFns } from "./aws-pca/aws-pca-certificate-authority-fns"; import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; @@ -69,6 +74,7 @@ export type TIssueCertificateFromProfileJobData = { certificateId: string; profileId: string; caId: string; + caType?: CaType; commonName?: string; altNames?: Array<{ type: string; value: string }>; ttl: string; @@ -104,7 +110,7 @@ type TCertificateIssuanceQueueFactoryDep = { pkiSubscriberDAL: Pick; pkiSyncDAL: Pick; pkiSyncQueue: Pick; - certificateProfileDAL?: Pick; + certificateProfileDAL?: Pick; certificateRequestService?: Pick< TCertificateRequestServiceFactory, "attachCertificateToRequest" | "updateCertificateRequestStatus" @@ -179,6 +185,19 @@ export const certificateIssuanceQueueFactory = ({ certificateProfileDAL }); + const awsAcmPublicCaFns = AwsAcmPublicCaCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + certificateProfileDAL + }); + /** * Queue a certificate issuance job. */ @@ -186,6 +205,7 @@ export const certificateIssuanceQueueFactory = ({ certificateId, profileId, caId, + caType, commonName, altNames, ttl, @@ -207,6 +227,7 @@ export const certificateIssuanceQueueFactory = ({ certificateId, profileId, caId, + caType, commonName, altNames, ttl, @@ -225,13 +246,16 @@ export const certificateIssuanceQueueFactory = ({ locality }; + // ACM DNS validation can take 5–30 minutes; the function is fully idempotent via + // IdempotencyToken, so we poll longer with a fixed backoff instead of exponential. + const queueOpts = + caType === CaType.AWS_ACM_PUBLIC_CA + ? { attempts: 30, backoff: { type: "fixed" as const, delay: 60000 } } + : { attempts: 3, backoff: { type: "exponential" as const, delay: 5000 } }; + await queueService.queue(QueueName.CertificateIssuance, QueueJobs.CaIssueCertificateFromProfile, jobData, { jobId: `certificate-issuance-${certificateId}`, - attempts: 3, - backoff: { - type: "exponential", - delay: 5000 - } + ...queueOpts }); }; @@ -396,6 +420,62 @@ export const certificateIssuanceQueueFactory = ({ certificateId: azureResult.certificateId }); + logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`); + } catch (attachError) { + logger.error( + attachError, + `Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]` + ); + try { + await certificateRequestService.updateCertificateRequestStatus({ + certificateRequestId, + status: CertificateRequestStatus.FAILED, + errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}` + }); + } catch (statusUpdateError) { + logger.error( + statusUpdateError, + `Failed to update certificate request status [certificateRequestId=${certificateRequestId}]` + ); + } + } + } + } else if (ca.externalCa?.type === CaType.AWS_ACM_PUBLIC_CA) { + const acmParams = { + caId, + profileId, + certificateId, + commonName: commonName || "", + altNames: (altNames || []) as Array<{ type: CertSubjectAlternativeNameType; value: string }>, + keyUsages, + extendedKeyUsages, + validity: { ttl }, + signatureAlgorithm, + keyAlgorithm: keyAlgorithm as CertKeyAlgorithm, + isRenewal, + originalCertificateId, + ...(csr && { csr }), + organization, + organizationalUnit, + country, + state, + locality + }; + + const acmResult = await awsAcmPublicCaFns.orderCertificateFromProfile(acmParams); + + if (certificateRequestId && certificateRequestService && acmResult?.certificateId) { + try { + await certificateRequestService.attachCertificateToRequest({ + certificateRequestId, + certificateId: acmResult.certificateId + }); + + await copyMetadataFromRequestToCertificate(resourceMetadataDAL, { + certificateRequestId, + certificateId: acmResult.certificateId + }); + logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`); } catch (attachError) { logger.error( @@ -487,6 +567,16 @@ export const certificateIssuanceQueueFactory = ({ logger.debug("Failed to queue PKI alert event for async certificate issuance"); } } catch (error: unknown) { + // AcmValidationPendingError is a retryable signal for ACM's long-running DNS validation. + // Don't mark the request as FAILED on every poll — only after the queue exhausts attempts. + const isRetryable = error instanceof AcmValidationPendingError; + if (isRetryable) { + logger.info( + `Certificate issuance pending validation — will retry [certificateId=${certificateId}] [caId=${caId}]` + ); + throw error; + } + logger.error(error, `Certificate issuance job failed for [certificateId=${certificateId}] [caId=${caId}]`); if (certificateRequestId && certificateRequestService) { @@ -505,12 +595,52 @@ export const certificateIssuanceQueueFactory = ({ } } + // For ACM's 30-attempt queue, wrap non-retryable errors so BullMQ stops retrying immediately. + // Other CAs keep default retry behavior (3 attempts is short enough that running through them is fine). + if (data.caType === CaType.AWS_ACM_PUBLIC_CA) { + const message = error instanceof Error ? error.message : String(error); + const wrapped = new UnrecoverableError(message); + (wrapped as Error).cause = error; + throw wrapped; + } + throw error; } }; queueService.start(QueueName.CertificateIssuance, async (job) => { - await processCertificateIssuanceJobs(job.data); + try { + await processCertificateIssuanceJobs(job.data); + } catch (error) { + // AcmValidationPendingError is rethrown on every retry so BullMQ keeps polling; the in-handler + // FAILED-update branch never runs for it. On the final attempt we still need to flip the request + // row to FAILED ourselves — BullMQ will move the job to the failed state but has no hook to + // update our DB, and no queue-level "failed" listener is wired for CertificateIssuance. + if (error instanceof AcmValidationPendingError) { + const attemptsMade = job.attemptsMade ?? 0; + const maxAttempts = job.opts?.attempts ?? 1; + const isFinalAttempt = attemptsMade + 1 >= maxAttempts; + const { certificateRequestId, certificateId, caId } = job.data; + if (isFinalAttempt && certificateRequestId && certificateRequestService) { + try { + await certificateRequestService.updateCertificateRequestStatus({ + certificateRequestId, + status: CertificateRequestStatus.FAILED, + errorMessage: `AWS ACM DNS validation did not complete after ${maxAttempts} attempts: ${error.message}` + }); + logger.info( + `Marked certificate request FAILED after exhausted ACM validation retries [certificateRequestId=${certificateRequestId}] [certificateId=${certificateId}] [caId=${caId}]` + ); + } catch (updateError) { + logger.error( + updateError, + `Failed to mark certificate request FAILED after exhausted ACM retries [certificateRequestId=${certificateRequestId}]` + ); + } + } + } + throw error; + } }); return { diff --git a/backend/src/services/certificate-common/external-metadata-schemas.ts b/backend/src/services/certificate-common/external-metadata-schemas.ts new file mode 100644 index 00000000000..46e6d6e2d47 --- /dev/null +++ b/backend/src/services/certificate-common/external-metadata-schemas.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { AwsAcmValidationMethod } from "@app/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-enums"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +export const AwsAcmPublicCaExternalMetadataSchema = z.object({ + type: z.literal(CaType.AWS_ACM_PUBLIC_CA), + arn: z.string(), + region: z.nativeEnum(AWSRegion), + validationMethod: z.nativeEnum(AwsAcmValidationMethod) +}); + +export type TAwsAcmPublicCaExternalMetadata = z.infer; + +export const ExternalMetadataSchema = z.discriminatedUnion("type", [AwsAcmPublicCaExternalMetadataSchema]); + +export type TExternalMetadata = z.infer; diff --git a/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts index 7ffdc896494..e4ef6620f26 100644 --- a/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts @@ -22,6 +22,11 @@ export const AcmeExternalConfigSchema = z.object({}); */ export const AwsPcaExternalConfigSchema = z.object({}); +/** + * External configuration schema for AWS ACM Public Certificate Authority + */ +export const AwsAcmPublicCaExternalConfigSchema = z.object({}); + /** * Map of CA types to their corresponding external configuration schemas */ @@ -29,6 +34,7 @@ export const ExternalConfigSchemaMap = { [CaType.AZURE_AD_CS]: AzureAdCsExternalConfigSchema, [CaType.ACME]: AcmeExternalConfigSchema, [CaType.AWS_PCA]: AwsPcaExternalConfigSchema, + [CaType.AWS_ACM_PUBLIC_CA]: AwsAcmPublicCaExternalConfigSchema, [CaType.INTERNAL]: z.object({}).optional() // Internal CAs don't use external configs } as const; @@ -49,7 +55,13 @@ export const createExternalConfigSchema = (caType?: CaType | null) => { * Union type of all possible external configuration schemas */ export const ExternalConfigUnionSchema = z - .union([AzureAdCsExternalConfigSchema, AcmeExternalConfigSchema, AwsPcaExternalConfigSchema, z.object({})]) + .union([ + AzureAdCsExternalConfigSchema, + AcmeExternalConfigSchema, + AwsPcaExternalConfigSchema, + AwsAcmPublicCaExternalConfigSchema, + z.object({}) + ]) .nullable() .optional(); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index dc392b5c8e5..88c6565a7b3 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -99,6 +99,20 @@ const validateTemplateByExternalCaType = ( } }; +const validateAcmEnrollmentType = async ( + caId: string | null | undefined, + enrollmentType: EnrollmentType, + externalCertificateAuthorityDAL: Pick +) => { + if (!caId) return; + const externalCa = await externalCertificateAuthorityDAL.findOne({ caId }); + if (externalCa?.type === CaType.AWS_ACM_PUBLIC_CA && enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: "AWS Certificate Manager only supports API enrollment" + }); + } +}; + const validateExternalConfigs = async ( externalConfigs: Record | null | undefined, caId: string | null, @@ -351,6 +365,8 @@ export const certificateProfileServiceFactory = ({ validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null); + await validateAcmEnrollmentType(data.caId, data.enrollmentType, externalCertificateAuthorityDAL); + // Validate defaults against policy constraints if (data.defaults && data.certificatePolicyId) { const policy = await certificatePolicyDAL.findById(data.certificatePolicyId); @@ -601,6 +617,8 @@ export const certificateProfileServiceFactory = ({ validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId); + await validateAcmEnrollmentType(finalCaId, finalEnrollmentType, externalCertificateAuthorityDAL); + // Validate external configs only if they are provided in the update if (data.externalConfigs !== undefined) { await validateExternalConfigs( diff --git a/backend/src/services/certificate-v3/certificate-approval-fns.ts b/backend/src/services/certificate-v3/certificate-approval-fns.ts index ef1139dc526..8dc5e6ddb05 100644 --- a/backend/src/services/certificate-v3/certificate-approval-fns.ts +++ b/backend/src/services/certificate-v3/certificate-approval-fns.ts @@ -14,6 +14,7 @@ import { TCertificateBodyDALFactory } from "@app/services/certificate/certificat import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { CertKeyAlgorithm, CertSignatureAlgorithm, CertStatus } from "@app/services/certificate/certificate-types"; +import { validateAcmIssuanceInputs } from "@app/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-fns"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { TCertificateIssuanceQueueFactory } from "@app/services/certificate-authority/certificate-issuance-queue"; @@ -438,16 +439,38 @@ export const certificateApprovalServiceFactory = ( const caType = (targetCa.externalCa?.type as CaType) ?? CaType.INTERNAL; - if (caType !== CaType.ACME && caType !== CaType.AZURE_AD_CS && caType !== CaType.AWS_PCA) { + if ( + caType !== CaType.ACME && + caType !== CaType.AZURE_AD_CS && + caType !== CaType.AWS_PCA && + caType !== CaType.AWS_ACM_PUBLIC_CA + ) { return null; } + // Pre-flight validation for ACM — fail the approval synchronously rather than + // letting the job produce a FAILED request row after the approver already accepted. + if (caType === CaType.AWS_ACM_PUBLIC_CA) { + validateAcmIssuanceInputs({ + csr: certRequest.csr || undefined, + keyAlgorithm: certRequest.keyAlgorithm || undefined, + altNames: altNames ?? undefined, + ttl, + organization: certRequest.organization || undefined, + organizationalUnit: certRequest.organizationalUnit || undefined, + country: certRequest.country || undefined, + state: certRequest.state || undefined, + locality: certRequest.locality || undefined + }); + } + const orderId = randomUUID(); await certificateIssuanceQueue.queueCertificateIssuance({ certificateId: orderId, profileId: profile.id, caId: profile.caId || "", + caType, ttl: ttl || "1y", signatureAlgorithm: certRequest.signatureAlgorithm || "", keyAlgorithm: certRequest.keyAlgorithm || "", diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 81e2731891c..5cd2de482ad 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -34,6 +34,7 @@ import { CertSignatureAlgorithm, CertStatus } from "@app/services/certificate/certificate-types"; +import { validateAcmIssuanceInputs } from "@app/services/certificate-authority/aws-acm-public-ca/aws-acm-public-ca-certificate-authority-fns"; import { TCertificateAuthorityDALFactory, TCertificateAuthorityWithAssociatedCa @@ -276,7 +277,11 @@ const validateRenewalEligibility = ( const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; const isInternalCa = caType === CaType.INTERNAL; - const isConnectedExternalCa = caType === CaType.ACME || caType === CaType.AZURE_AD_CS || caType === CaType.AWS_PCA; + const isConnectedExternalCa = + caType === CaType.ACME || + caType === CaType.AZURE_AD_CS || + caType === CaType.AWS_PCA || + caType === CaType.AWS_ACM_PUBLIC_CA; const isImportedCertificate = certificate.pkiSubscriberId != null && !certificate.profileId; if (!isInternalCa && !isConnectedExternalCa) { @@ -1853,7 +1858,31 @@ export const certificateV3ServiceFactory = ({ }); } - if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS || caType === CaType.AWS_PCA) { + if ( + caType === CaType.ACME || + caType === CaType.AZURE_AD_CS || + caType === CaType.AWS_PCA || + caType === CaType.AWS_ACM_PUBLIC_CA + ) { + // Pre-flight validation for ACM — reject bad inputs synchronously so the user + // gets a 400 on submit rather than a FAILED request row after the job runs. + if (caType === CaType.AWS_ACM_PUBLIC_CA) { + validateAcmIssuanceInputs({ + csr: certificateOrder.csr, + keyAlgorithm: certificateOrder.keyAlgorithm, + altNames: certificateOrder.altNames, + ttl: certificateOrder.validity?.ttl, + notBefore: certificateOrder.notBefore, + notAfter: certificateOrder.notAfter, + basicConstraints: certificateOrder.basicConstraints, + organization: certificateRequest.organization, + organizationalUnit: certificateRequest.organizationalUnit, + country: certificateRequest.country, + state: certificateRequest.state, + locality: certificateRequest.locality + }); + } + const orderId = randomUUID(); const certRequest = await certificateRequestService.createCertificateRequest({ @@ -1895,6 +1924,7 @@ export const certificateV3ServiceFactory = ({ certificateId: orderId, profileId: profile.id, caId: profile.caId || "", + caType, ttl: certificateOrder.validity?.ttl || "1y", signatureAlgorithm: certificateOrder.signatureAlgorithm || "", keyAlgorithm: certificateRequest.keyAlgorithm || "", @@ -2181,7 +2211,12 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); } newCert = foundCert; - } else if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS || caType === CaType.AWS_PCA) { + } else if ( + caType === CaType.ACME || + caType === CaType.AZURE_AD_CS || + caType === CaType.AWS_PCA || + caType === CaType.AWS_ACM_PUBLIC_CA + ) { // External CA renewal - mark for async processing outside transaction return { isExternalCA: true, @@ -2361,6 +2396,7 @@ export const certificateV3ServiceFactory = ({ certificateId: renewalOrderId, profileId: profile?.id || "", caId: ca.id, + caType: (ca.externalCa?.type as CaType) ?? CaType.INTERNAL, commonName: originalCert.commonName || "", altNames: structuredAltNames, ttl, diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index 4f3ebcb1b19..b3e12320a23 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -75,6 +75,7 @@ export type TOrderCertificateFromProfileDTO = { keyAlgorithm?: string; template?: string; csr?: string; + basicConstraints?: { isCA: boolean; pathLength?: number }; organization?: string; organizationalUnit?: string; country?: string; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 0c50497be8a..93feb43d5a7 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -410,7 +410,7 @@ export const certificateServiceFactory = ({ // Note: External CA revocation handling would go here for supported CA types // Currently, only internal CAs, ACME CAs and AWS PCA (external CA) support revocation - if (ca.externalCa?.type === CaType.AWS_PCA) { + if (ca.externalCa?.type === CaType.AWS_PCA || ca.externalCa?.type === CaType.AWS_ACM_PUBLIC_CA) { await certificateAuthorityService.revokeCertificate({ caId: ca.id, serialNumber: cert.serialNumber, diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 5ab6ccad43e..17a546b7a0d 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -44,13 +44,19 @@ export const CA_TYPE_CAPABILITIES_MAP: Record = { CaCapability.ISSUE_CERTIFICATES, CaCapability.REVOKE_CERTIFICATES, CaCapability.RENEW_CERTIFICATES + ], + [CaType.AWS_ACM_PUBLIC_CA]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES ] }; export const EXTERNAL_CA_TYPE_NAME_MAP: Record = { [CaType.ACME]: "ACME", [CaType.AZURE_AD_CS]: "Active Directory Certificate Services (AD CS)", - [CaType.AWS_PCA]: "AWS Private CA (PCA)" + [CaType.AWS_PCA]: "AWS Private CA (PCA)", + [CaType.AWS_ACM_PUBLIC_CA]: "AWS ACM Public CA" }; /** diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index 27dca00c041..872e36c5316 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -2,7 +2,8 @@ export enum CaType { INTERNAL = "internal", ACME = "acme", AZURE_AD_CS = "azure-ad-cs", - AWS_PCA = "aws-pca" + AWS_PCA = "aws-pca", + AWS_ACM_PUBLIC_CA = "aws-acm-public-ca" } export enum InternalCaType { diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index c629f0fb808..097a770d579 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -82,17 +82,21 @@ export const useListExternalCasByProjectId = (projectId: string) => { return useQuery({ queryKey: caKeys.listExternalCasByProjectId(projectId), queryFn: async () => { - const [acmeResponse, azureAdCsResponse, awsPcaResponse] = await Promise.allSettled([ - apiRequest.get( - `/api/v1/cert-manager/ca/${CaType.ACME}?projectId=${projectId}` - ), - apiRequest.get( - `/api/v1/cert-manager/ca/${CaType.AZURE_AD_CS}?projectId=${projectId}` - ), - apiRequest.get( - `/api/v1/cert-manager/ca/${CaType.AWS_PCA}?projectId=${projectId}` - ) - ]); + const [acmeResponse, azureAdCsResponse, awsPcaResponse, awsAcmPublicCaResponse] = + await Promise.allSettled([ + apiRequest.get( + `/api/v1/cert-manager/ca/${CaType.ACME}?projectId=${projectId}` + ), + apiRequest.get( + `/api/v1/cert-manager/ca/${CaType.AZURE_AD_CS}?projectId=${projectId}` + ), + apiRequest.get( + `/api/v1/cert-manager/ca/${CaType.AWS_PCA}?projectId=${projectId}` + ), + apiRequest.get( + `/api/v1/cert-manager/ca/${CaType.AWS_ACM_PUBLIC_CA}?projectId=${projectId}` + ) + ]); const allCas: TUnifiedCertificateAuthority[] = []; @@ -108,6 +112,10 @@ export const useListExternalCasByProjectId = (projectId: string) => { allCas.push(...awsPcaResponse.value.data); } + if (awsAcmPublicCaResponse.status === "fulfilled") { + allCas.push(...awsAcmPublicCaResponse.value.data); + } + return allCas; } }); diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 5614d403b08..c0e7e618509 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -49,6 +49,21 @@ export type TAwsPcaCertificateAuthority = { }; }; +export type TAwsAcmPublicCaCertificateAuthority = { + id: string; + projectId: string; + type: CaType.AWS_ACM_PUBLIC_CA; + status: CaStatus; + name: string; + enableDirectIssuance: boolean; + configuration: { + appConnectionId: string; + dnsAppConnectionId: string; + hostedZoneId: string; + region: string; + }; +}; + export type TInternalCertificateAuthority = { id: string; projectId: string; @@ -80,6 +95,7 @@ export type TUnifiedCertificateAuthority = | TAcmeCertificateAuthority | TAzureAdCsCertificateAuthority | TAwsPcaCertificateAuthority + | TAwsAcmPublicCaCertificateAuthority | TInternalCertificateAuthority; export type TCreateCertificateAuthorityDTO = Omit< diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index 0595322368d..b7b603802ed 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -125,6 +125,19 @@ const awsPcaConfigurationSchema = z.object({ region: z.string().min(1, "Region is required") }); +const awsAcmPublicCaConfigurationSchema = z.object({ + awsConnection: z.object({ + id: z.string().min(1, "AWS Connection is required"), + name: z.string() + }), + dnsConnection: z.object({ + id: z.string().min(1, "Route 53 Connection is required"), + name: z.string() + }), + hostedZoneId: z.string().trim().min(1, "Hosted Zone ID is required"), + region: z.string().min(1, "Region is required") +}); + const schema = z.discriminatedUnion("type", [ baseSchema.extend({ type: z.literal(CaType.ACME), @@ -137,6 +150,10 @@ const schema = z.discriminatedUnion("type", [ baseSchema.extend({ type: z.literal(CaType.AWS_PCA), configuration: awsPcaConfigurationSchema + }), + baseSchema.extend({ + type: z.literal(CaType.AWS_ACM_PUBLIC_CA), + configuration: awsAcmPublicCaConfigurationSchema }) ]); @@ -150,7 +167,8 @@ type Props = { const caTypes = [ { label: "ACME", value: CaType.ACME }, { label: "Active Directory Certificate Services (AD CS)", value: CaType.AZURE_AD_CS }, - { label: "AWS Private CA (PCA)", value: CaType.AWS_PCA } + { label: "AWS Private CA (PCA)", value: CaType.AWS_PCA }, + { label: "AWS ACM Public CA", value: CaType.AWS_ACM_PUBLIC_CA } ]; export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { @@ -214,6 +232,24 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { region: "" } }); + } else if (initialType === CaType.AWS_ACM_PUBLIC_CA) { + reset({ + type: CaType.AWS_ACM_PUBLIC_CA, + name: "", + status: CaStatus.ACTIVE, + configuration: { + awsConnection: { + id: "", + name: "" + }, + dnsConnection: { + id: "", + name: "" + }, + hostedZoneId: "", + region: "" + } + }); } else { reset({ type: CaType.ACME, @@ -268,7 +304,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { AppConnection.AWS, currentProject.id, { - enabled: caType === CaType.AWS_PCA + enabled: caType === CaType.AWS_PCA || caType === CaType.AWS_ACM_PUBLIC_CA } ); @@ -276,7 +312,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { if (caType === CaType.AZURE_AD_CS) { return availableAzureConnections || []; } - if (caType === CaType.AWS_PCA) { + if (caType === CaType.AWS_PCA || caType === CaType.AWS_ACM_PUBLIC_CA) { return availableAwsConnections || []; } return [ @@ -301,7 +337,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { isDNSMadeEasyPending || isAzureDNSPending || (isAzurePending && caType === CaType.AZURE_AD_CS) || - (isAwsPending && caType === CaType.AWS_PCA); + (isAwsPending && (caType === CaType.AWS_PCA || caType === CaType.AWS_ACM_PUBLIC_CA)); const dnsAppConnection = caType === CaType.ACME && configuration && "dnsAppConnection" in configuration @@ -385,6 +421,35 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { region: ca.configuration.region } }); + } else if (ca.type === CaType.AWS_ACM_PUBLIC_CA && availableConnections?.length) { + const selectedConnection = availableConnections?.find( + (connection) => connection.id === ca.configuration.appConnectionId + ); + const selectedDnsConnection = ca.configuration.dnsAppConnectionId + ? availableConnections?.find( + (connection) => connection.id === ca.configuration.dnsAppConnectionId + ) + : undefined; + + reset({ + type: ca.type, + name: ca.name, + status: ca.status, + configuration: { + awsConnection: { + id: ca.configuration.appConnectionId, + name: selectedConnection?.name || "" + }, + dnsConnection: ca.configuration.dnsAppConnectionId + ? { + id: ca.configuration.dnsAppConnectionId, + name: selectedDnsConnection?.name || "" + } + : undefined, + hostedZoneId: ca.configuration.hostedZoneId || "", + region: ca.configuration.region + } + }); } } }, [ca, availableConnections, reset, isCaLoading]); @@ -419,6 +484,13 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { certificateAuthorityArn: formConfiguration.certificateAuthorityArn, region: formConfiguration.region }; + } else if (type === CaType.AWS_ACM_PUBLIC_CA && "awsConnection" in formConfiguration) { + configPayload = { + appConnectionId: formConfiguration.awsConnection.id, + dnsAppConnectionId: formConfiguration.dnsConnection.id, + hostedZoneId: formConfiguration.hostedZoneId, + region: formConfiguration.region + }; } else { throw new Error("Invalid certificate authority configuration"); } @@ -834,6 +906,92 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { /> )} + {caType === CaType.AWS_ACM_PUBLIC_CA && ( + <> + ( + + { + onChange(newValue); + }} + isLoading={isPending} + options={availableConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + components={{ Option: AppConnectionOption }} + /> + + )} + control={control} + name="configuration.awsConnection" + /> + ( + + { + onChange(newValue); + }} + isLoading={isPending} + options={availableConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + components={{ Option: AppConnectionOption }} + /> + + )} + control={control} + name="configuration.dnsConnection" + /> + ( + + + + )} + /> + ( + + onChange(v || "")} /> + + )} + /> + + )}