diff --git a/backend/src/server/routes/v1/pki-sync-routers/aws-certificate-manager-pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/aws-certificate-manager-pki-sync-router.ts new file mode 100644 index 000000000..21bfadbac --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/aws-certificate-manager-pki-sync-router.ts @@ -0,0 +1,22 @@ +import { + AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION, + AwsCertificateManagerPkiSyncSchema, + CreateAwsCertificateManagerPkiSyncSchema, + UpdateAwsCertificateManagerPkiSyncSchema +} from "@app/services/pki-sync/aws-certificate-manager"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +import { registerSyncPkiEndpoints } from "./pki-sync-endpoints"; + +export const registerAwsCertificateManagerPkiSyncRouter = async (server: FastifyZodProvider) => + registerSyncPkiEndpoints({ + destination: PkiSync.AwsCertificateManager, + server, + responseSchema: AwsCertificateManagerPkiSyncSchema, + createSchema: CreateAwsCertificateManagerPkiSyncSchema, + updateSchema: UpdateAwsCertificateManagerPkiSyncSchema, + syncOptions: { + canImportCertificates: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION.canImportCertificates, + canRemoveCertificates: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION.canRemoveCertificates + } + }); diff --git a/backend/src/server/routes/v1/pki-sync-routers/index.ts b/backend/src/server/routes/v1/pki-sync-routers/index.ts index 326b650e9..4b81db27f 100644 --- a/backend/src/server/routes/v1/pki-sync-routers/index.ts +++ b/backend/src/server/routes/v1/pki-sync-routers/index.ts @@ -1,9 +1,11 @@ import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { registerAwsCertificateManagerPkiSyncRouter } from "./aws-certificate-manager-pki-sync-router"; import { registerAzureKeyVaultPkiSyncRouter } from "./azure-key-vault-pki-sync-router"; export * from "./pki-sync-router"; export const PKI_SYNC_REGISTER_ROUTER_MAP: Record Promise> = { - [PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter + [PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter, + [PkiSync.AwsCertificateManager]: registerAwsCertificateManagerPkiSyncRouter }; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-constants.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-constants.ts new file mode 100644 index 000000000..265e00cac --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-constants.ts @@ -0,0 +1,52 @@ +import RE2 from "re2"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +/** + * AWS Certificate Manager naming constraints for certificates + */ +export const AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING = { + /** + * Regular expression pattern for valid AWS Certificate Manager certificate names + * Must contain only alphanumeric characters, spaces, hyphens, and underscores + * Must be 1-256 characters long + */ + NAME_PATTERN: new RE2("^[a-zA-Z0-9\\s\\-_]{1,256}$"), + + /** + * String of characters that are forbidden in AWS Certificate Manager certificate names + */ + FORBIDDEN_CHARACTERS: "!@#$%^&*()+={}[]|\\:;\"'<>,.?/~`", + + /** + * Maximum length for certificate names in AWS Certificate Manager + */ + MAX_LENGTH: 256, + + /** + * Minimum length for certificate names in AWS Certificate Manager + */ + MIN_LENGTH: 1, + + /** + * String representation of the allowed character pattern (for UI display) + */ + ALLOWED_CHARACTER_PATTERN: "^[a-zA-Z0-9\\s\\-_]{1,256}$" +} as const; + +/** + * AWS Certificate Manager PKI Sync list option configuration + */ +export const AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION = { + name: "AWS Certificate Manager" as const, + connection: AppConnection.AWS, + destination: PkiSync.AwsCertificateManager, + canImportCertificates: false, + canRemoveCertificates: true, + defaultCertificateNameSchema: "Infisical-{{certificateId}}", + forbiddenCharacters: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS, + allowedCharacterPattern: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.ALLOWED_CHARACTER_PATTERN, + maxCertificateNameLength: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MAX_LENGTH, + minCertificateNameLength: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MIN_LENGTH +} as const; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts new file mode 100644 index 000000000..7a73f64ee --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts @@ -0,0 +1,687 @@ +/* eslint-disable no-await-in-loop */ +import * as AWS from "aws-sdk"; +import RE2 from "re2"; +import { z } from "zod"; + +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; +import { AwsConnectionMethod } from "@app/services/app-connection/aws/aws-connection-enums"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { + AwsConnectionAccessTokenCredentialsSchema, + AwsConnectionAssumeRoleCredentialsSchema +} from "@app/services/app-connection/aws/aws-connection-schemas"; +import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; +import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types"; + +import { PkiSyncError } from "../pki-sync-errors"; +import { TPkiSyncWithCredentials } from "../pki-sync-types"; +import { + ACMCertificateWithKey, + CertificateImportRequest, + RemoveCertificatesResult, + SyncCertificatesResult, + TAwsCertificateManagerPkiSyncConfig +} from "./aws-certificate-manager-pki-sync-types"; + +const INFISICAL_CERTIFICATE_TAG = "InfisicalCertificate"; +const AWS_CERTIFICATE_ARN_PATTERN = new RE2("^arn:aws:acm:[a-z0-9-]+:\\d{12}:certificate/[a-f0-9-]{36}$"); + +type TAwsAssumeRoleCredentials = z.infer; +type TAwsAccessKeyCredentials = z.infer; + +const AWS_RATE_LIMIT_CONFIG: RateLimitConfig = { + MAX_CONCURRENT_REQUESTS: 10, + BASE_DELAY: 1000, + MAX_DELAY: 30000, + MAX_RETRIES: 3, + RATE_LIMIT_STATUS_CODES: [429, 503] +}; + +const awsConnectionQueue = createConnectionQueue(AWS_RATE_LIMIT_CONFIG); + +const { withRateLimitRetry, executeWithConcurrencyLimit } = awsConnectionQueue; + +const validateCertificateArn = (arn: string): boolean => { + return AWS_CERTIFICATE_ARN_PATTERN.test(arn); +}; + +const extractCertificateNameFromArn = (certificateArn: string): string => { + if (!validateCertificateArn(certificateArn)) { + throw new Error(`Invalid AWS Certificate Manager ARN format: ${certificateArn}`); + } + const parts = certificateArn.split("/"); + return parts[parts.length - 1]; +}; + +const sanitizeInput = (input: string): string => { + return input.trim().replace(new RE2("[^\\w\\s-]", "g"), ""); +}; + +const validateCertificateContent = (cert: string, privateKey: string): void => { + if (!cert || cert.trim().length === 0) { + throw new Error("Certificate content is empty or missing"); + } + + if (!privateKey || privateKey.trim().length === 0) { + throw new Error("Private key content is empty or missing"); + } + + if (!cert.includes("-----BEGIN CERTIFICATE-----") || !cert.includes("-----END CERTIFICATE-----")) { + throw new Error("Certificate is not in valid PEM format"); + } + + if (!privateKey.includes("-----BEGIN") || !privateKey.includes("-----END")) { + throw new Error("Private key is not in valid PEM format"); + } +}; + +const isAwsIssuedCertificate = (certificate: AWS.ACM.CertificateSummary): boolean => { + return ( + certificate.Type === "AMAZON_ISSUED" || + certificate.KeyAlgorithm === "EC-prime256v1" || + (!!certificate.DomainName && (certificate.SubjectAlternativeNameSummaries?.length || 0) === 0) + ); +}; + +const shouldSkipCertificateExport = (certificate: AWS.ACM.CertificateSummary): boolean => { + return isAwsIssuedCertificate(certificate); +}; + +const findTagByKey = (tags: AWS.ACM.TagList | undefined, key: string): AWS.ACM.Tag | undefined => { + if (!tags || !Array.isArray(tags)) { + return undefined; + } + return tags.find((tag: AWS.ACM.Tag) => tag.Key === key && tag.Value); +}; + +const findInfisicalCertificateTag = (tags: AWS.ACM.TagList | undefined): AWS.ACM.Tag | undefined => { + return findTagByKey(tags, INFISICAL_CERTIFICATE_TAG); +}; + +const validateCertificateIdentification = ( + certName: string, + existingCert: { arn?: string; Tags?: AWS.ACM.TagList; cert?: string; privateKey?: string; certificateChain?: string } +): boolean => { + if (!existingCert?.arn || !existingCert?.Tags) { + return false; + } + + const certNameTag = findInfisicalCertificateTag(existingCert.Tags); + + if (!certNameTag || !certNameTag.Value) { + return false; + } + + return certNameTag.Value === certName; +}; + +type TAwsCertificateManagerPkiSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +const validateCertificateNameSchema = (schema: string): void => { + if (!schema.includes("{{certificateId}}")) { + throw new Error( + "Certificate name schema must include {{certificateId}} placeholder for proper certificate identification" + ); + } +}; + +const generateCertificateName = (certificateName: string, pkiSync: TPkiSyncWithCredentials): string => { + if (!certificateName || typeof certificateName !== "string") { + throw new Error("Certificate name must be a non-empty string"); + } + + const sanitizedCertificateName = sanitizeInput(certificateName); + const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const certificateNameSchema = syncOptions?.certificateNameSchema; + + if (certificateNameSchema) { + validateCertificateNameSchema(certificateNameSchema); + + let certificateId: string; + + if (sanitizedCertificateName.startsWith("Infisical-")) { + certificateId = sanitizedCertificateName.substring("Infisical-".length); + } else { + certificateId = sanitizedCertificateName; + } + + if (!certificateId || certificateId.trim().length === 0) { + throw new Error(`Certificate ID cannot be empty after processing certificate name: ${certificateName}`); + } + + const environment = "global"; + const generatedName = certificateNameSchema + .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), certificateId) + .replace(new RE2("\\{\\{environment\\}\\}", "g"), environment); + + if (generatedName.length > 256 || generatedName.length < 1) { + throw new Error( + `Generated certificate name length (${generatedName.length}) must be between 1 and 256 characters` + ); + } + + if (generatedName.includes("{{certificateId}}")) { + throw new Error("Certificate name schema failed to properly replace {{certificateId}} placeholder"); + } + + return generatedName; + } + + return sanitizedCertificateName; +}; + +const getAwsAcmClient = async ( + connectionId: string, + region: AWSRegion, + appConnectionDAL: Pick, + kmsService: Pick +): Promise => { + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AWS) { + throw new BadRequestError({ + message: `Connection '${connectionId}' is not an AWS connection (found: ${appConnection.app})` + }); + } + + const decryptedCredentials = await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials, + projectId: appConnection.projectId + }); + + let awsConnectionConfig: TAwsConnectionConfig; + switch (appConnection.method) { + case AwsConnectionMethod.AssumeRole: + awsConnectionConfig = { + app: AppConnection.AWS, + method: AwsConnectionMethod.AssumeRole, + credentials: decryptedCredentials as TAwsAssumeRoleCredentials, + orgId: appConnection.orgId + }; + break; + case AwsConnectionMethod.AccessKey: + awsConnectionConfig = { + app: AppConnection.AWS, + method: AwsConnectionMethod.AccessKey, + credentials: decryptedCredentials as TAwsAccessKeyCredentials, + orgId: appConnection.orgId + }; + break; + default: + throw new BadRequestError({ + message: `Unsupported AWS connection method: ${appConnection.method}` + }); + } + + const awsConfig = await getAwsConnectionConfig(awsConnectionConfig, region); + + return new AWS.ACM(awsConfig); +}; + +export const awsCertificateManagerPkiSyncFactory = ({ + kmsService, + appConnectionDAL +}: TAwsCertificateManagerPkiSyncFactoryDeps) => { + const $getAwsAcmCertificates = async ( + acm: AWS.ACM, + syncId = "unknown" + ): Promise<{ + acmCertificates: Record< + string, + { cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList } + >; + }> => { + const paginateAwsAcmCertificates = async () => { + const certificates: AWS.ACM.CertificateSummary[] = []; + let nextToken: string | undefined; + + do { + const listParams: AWS.ACM.ListCertificatesRequest = { + CertificateStatuses: ["ISSUED"], // Only get active certificates + NextToken: nextToken, + MaxItems: 100 + }; + + const response = await withRateLimitRetry(() => acm.listCertificates(listParams).promise(), { + operation: "list-certificates", + syncId + }); + + if (response.CertificateSummaryList) { + certificates.push(...response.CertificateSummaryList); + } + nextToken = response.NextToken; + } while (nextToken); + + return certificates; + }; + + const certificateSummaries = await paginateAwsAcmCertificates(); + + const certificateResults = await executeWithConcurrencyLimit( + certificateSummaries, + async (certSummary) => { + if (!certSummary.CertificateArn) { + throw new Error("Certificate ARN is missing"); + } + + const [certificateDetails, tagsResponse] = await Promise.all([ + acm.describeCertificate({ CertificateArn: certSummary.CertificateArn }).promise(), + acm.listTagsForCertificate({ CertificateArn: certSummary.CertificateArn }).promise() + ]); + + let certificateContent: AWS.ACM.GetCertificateResponse | undefined; + if (!shouldSkipCertificateExport(certSummary)) { + try { + certificateContent = await acm.getCertificate({ CertificateArn: certSummary.CertificateArn }).promise(); + } catch (error) { + logger.error({ certificateArn: certSummary.CertificateArn, error }, "Cannot export certificate content"); + } + } + + return { + ...certificateDetails.Certificate, + Tags: tagsResponse.Tags, + key: extractCertificateNameFromArn(certSummary.CertificateArn), + cert: certificateContent?.Certificate || "", + certificateChain: certificateContent?.CertificateChain || "", + privateKey: "", // Private keys cannot be exported from ACM + arn: certSummary.CertificateArn + }; + }, + { operation: "fetch-certificate-details", syncId } + ); + + const successfulCertificates: ACMCertificateWithKey[] = []; + certificateResults.forEach((result) => { + if (result.status === "fulfilled") { + successfulCertificates.push(result.value as ACMCertificateWithKey); + } + }); + + const failedFetches = certificateResults.filter((result) => result.status === "rejected"); + if (failedFetches.length > 0) { + logger.warn( + { + syncId, + failedCount: failedFetches.length, + totalCount: certificateSummaries.length + }, + "Some certificate details could not be fetched from AWS Certificate Manager" + ); + } + + const res: Record< + string, + { cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList } + > = successfulCertificates.reduce( + (obj, certificate) => ({ + ...obj, + [certificate.key]: { + cert: certificate.cert, + privateKey: certificate.privateKey, + certificateChain: certificate.certificateChain, + arn: certificate.CertificateArn, + Tags: certificate.Tags + } + }), + {} as Record< + string, + { cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList } + > + ); + + return { + acmCertificates: res + }; + }; + + const syncCertificates = async ( + pkiSync: TPkiSyncWithCredentials, + certificateMap: TCertificateMap + ): Promise => { + const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig; + const acm = await getAwsAcmClient( + pkiSync.connection.id, + destinationConfig.region as AWSRegion, + appConnectionDAL, + kmsService + ); + + const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); + + const setCertificates: CertificateImportRequest[] = []; + + const activeCertificateNames = Object.keys(certificateMap); + + Object.entries(certificateMap).forEach(([certName, { cert, privateKey, certificateChain }]) => { + const certificateName = generateCertificateName(certName, pkiSync); + + const existingCert = Object.values(acmCertificates).find((acmCert) => + validateCertificateIdentification(certName, acmCert) + ); + + const shouldUpdateCert = !existingCert || existingCert.cert !== cert; + + try { + validateCertificateContent(cert, privateKey); + } catch (validationError) { + logger.error( + { + syncId: pkiSync.id, + certName, + certificateName, + error: validationError + }, + "Certificate validation failed, skipping" + ); + return; + } + + if (shouldUpdateCert) { + setCertificates.push({ + key: certName, + name: certificateName, + cert, + privateKey, + certificateChain, + existingArn: existingCert?.arn + }); + } + }); + + // Identify expired/removed certificates that need to be cleaned up from ACM + const certificatesToRemove = Object.values(acmCertificates) + .filter((acmCert) => { + if (!acmCert.arn || !acmCert.Tags) { + return false; + } + + const certNameTag = findInfisicalCertificateTag(acmCert.Tags); + if (!certNameTag || !certNameTag.Value) { + return false; + } + + const isActive = activeCertificateNames.includes(certNameTag.Value); + return !isActive; + }) + .map((acmCert) => acmCert.arn!) + .filter((arn) => arn); + + const uploadResults = await executeWithConcurrencyLimit( + setCertificates, + async ({ key, name, cert, privateKey, certificateChain, existingArn }) => { + try { + const importParams: AWS.ACM.ImportCertificateRequest = { + Certificate: cert, + PrivateKey: privateKey, + Tags: [ + { + Key: INFISICAL_CERTIFICATE_TAG, + Value: key + } + ] + }; + + if (certificateChain && certificateChain.trim().length > 0) { + importParams.CertificateChain = certificateChain; + } + if (existingArn) { + importParams.CertificateArn = existingArn; + } + + const response = await withRateLimitRetry(() => acm.importCertificate(importParams).promise(), { + operation: "import-certificate", + syncId: pkiSync.id + }); + + return { key, name, success: true, response }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.error( + { + syncId: pkiSync.id, + certificateKey: key, + certificateName: name, + error, + errorMessage + }, + "Failed to import certificate to AWS Certificate Manager" + ); + + throw new PkiSyncError({ + message: `Failed to import certificate ${key} to AWS Certificate Manager: ${errorMessage}`, + cause: error instanceof Error ? error : new Error(errorMessage), + context: { + certificateKey: key, + certificateName: name, + region: destinationConfig.region + } + }); + } + }, + { operation: "import-certificates", syncId: pkiSync.id } + ); + + const results = uploadResults; + const failedUploads = results.filter((result) => result.status === "rejected"); + const successfulUploads = results.filter((result) => result.status === "fulfilled"); + + let removedCertificates = 0; + let failedRemovals = 0; + let removeResults: PromiseSettledResult<{ arn: string; success: boolean; error?: Error }>[] = []; + + if (certificatesToRemove.length > 0) { + removeResults = await executeWithConcurrencyLimit( + certificatesToRemove, + async (certificateArn) => { + try { + await withRateLimitRetry(() => acm.deleteCertificate({ CertificateArn: certificateArn }).promise(), { + operation: "delete-certificate", + syncId: pkiSync.id + }); + return { arn: certificateArn, success: true }; + } catch (error) { + logger.error( + { error, syncId: pkiSync.id, certificateArn }, + "Failed to remove expired/removed certificate from AWS Certificate Manager" + ); + + return { + arn: certificateArn, + success: false, + error: error instanceof Error ? error : new Error("Unknown error") + }; + } + }, + { operation: "remove-certificates", syncId: pkiSync.id } + ); + + const successfulRemovals = removeResults.filter( + (result) => result.status === "fulfilled" && result.value.success + ); + removedCertificates = successfulRemovals.length; + failedRemovals = removeResults.length - removedCertificates; + + if (failedRemovals > 0) { + logger.warn( + { + syncId: pkiSync.id, + failedRemovals, + successfulRemovals: removedCertificates + }, + "Some expired/removed certificates could not be removed from AWS Certificate Manager" + ); + } + } + + const details: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + } = {}; + + if (failedUploads.length > 0) { + details.failedUploads = failedUploads.map((failure, index) => { + const certificateName = setCertificates[index]?.name || "unknown"; + let errorMessage = "Unknown error"; + + if (failure.status === "rejected") { + errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error"; + } + + return { + name: certificateName, + error: errorMessage + }; + }); + + logger.error( + { + syncId: pkiSync.id, + failedUploads: details.failedUploads, + failedCount: failedUploads.length + }, + "Some certificates failed to import to AWS Certificate Manager" + ); + } + + if (failedRemovals > 0 && removeResults.length > 0) { + const actualFailedRemovals = removeResults + .map((result, index) => { + if (result.status === "rejected") { + const arn = certificatesToRemove[index] || "unknown"; + const errorMessage = result.reason instanceof Error ? result.reason.message : "Unknown error"; + return { + name: arn.includes("certificate/") ? extractCertificateNameFromArn(arn) : arn, + error: errorMessage + }; + } + return null; + }) + .filter((item): item is { name: string; error: string } => item !== null); + + details.failedRemovals = actualFailedRemovals; + + logger.warn( + { + syncId: pkiSync.id, + failedRemovals: details.failedRemovals, + successfulRemovals: removedCertificates + }, + "Some expired/removed certificates could not be removed from AWS Certificate Manager" + ); + } + + return { + uploaded: successfulUploads.length, + removed: removedCertificates, + failedRemovals, + skipped: Object.keys(certificateMap).length - setCertificates.length, + details: Object.keys(details).length > 0 ? details : undefined + }; + }; + + const removeCertificates = async ( + pkiSync: TPkiSyncWithCredentials, + certificateNames: string[] + ): Promise => { + const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig; + const acm = await getAwsAcmClient( + pkiSync.connection.id, + destinationConfig.region as AWSRegion, + appConnectionDAL, + kmsService + ); + + const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); + + const certificateArnsToRemove: string[] = []; + + for (const certName of certificateNames) { + const matchingCerts = Object.values(acmCertificates).filter((acmCert) => + validateCertificateIdentification(certName, acmCert) + ); + + for (const acmCert of matchingCerts) { + if (acmCert.arn) { + certificateArnsToRemove.push(acmCert.arn); + } + } + } + + const results = await executeWithConcurrencyLimit( + certificateArnsToRemove, + async (certificateArn) => { + try { + await withRateLimitRetry(() => acm.deleteCertificate({ CertificateArn: certificateArn }).promise(), { + operation: "delete-specific-certificate", + syncId: pkiSync.id + }); + + return { arn: certificateArn, success: true }; + } catch (error) { + logger.error( + { error, syncId: pkiSync.id, certificateArn }, + "Failed to remove specific certificate from AWS Certificate Manager" + ); + + throw new PkiSyncError({ + message: `Failed to remove certificate from AWS Certificate Manager: ${(error as Error)?.message || "Unknown error"}`, + cause: error as Error, + context: { + certificateArn, + region: (pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig).region + } + }); + } + }, + { operation: "remove-specific-certificates", syncId: pkiSync.id } + ); + + const failedRemovals = results.filter((result) => result.status === "rejected"); + + if (failedRemovals.length > 0) { + const failedReasons = failedRemovals.map((failure) => { + if (failure.status === "rejected") { + return failure.reason instanceof Error ? failure.reason.message : "Unknown error"; + } + return "Unknown error"; + }); + + throw new PkiSyncError({ + message: `Failed to remove ${failedRemovals.length} certificate(s) from AWS Certificate Manager`, + context: { + failedReasons, + totalCertificates: certificateArnsToRemove.length, + failedCount: failedRemovals.length + } + }); + } + + return { + removed: certificateArnsToRemove.length - failedRemovals.length, + failed: failedRemovals.length, + skipped: certificateNames.length - certificateArnsToRemove.length + }; + }; + + return { + syncCertificates, + removeCertificates + }; +}; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts new file mode 100644 index 000000000..2c4af94a8 --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts @@ -0,0 +1,84 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas"; + +import { AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING } from "./aws-certificate-manager-pki-sync-constants"; + +export const AwsCertificateManagerPkiSyncConfigSchema = z.object({ + region: z.nativeEnum(AWSRegion) +}); + +const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean().default(true), + canRemoveCertificates: z.boolean().default(true), + certificateNameSchema: z + .string() + .optional() + .refine( + (schema) => { + if (!schema) return true; + + // Validate that {{certificateId}} placeholder is present + if (!schema.includes("{{certificateId}}")) { + return false; + } + + const testName = schema + .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id") + .replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env"); + + const hasForbiddenChars = AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some( + (char) => testName.includes(char) + ); + + return ( + AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.NAME_PATTERN.test(testName) && + !hasForbiddenChars && + testName.length >= AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MIN_LENGTH && + testName.length <= AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MAX_LENGTH + ); + }, + { + message: + "Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, spaces, hyphens, and underscores and be 1-256 characters long when compiled for AWS Certificate Manager" + } + ) +}); + +export const AwsCertificateManagerPkiSyncSchema = PkiSyncSchema.extend({ + destination: z.literal(PkiSync.AwsCertificateManager), + destinationConfig: AwsCertificateManagerPkiSyncConfigSchema, + syncOptions: AwsCertificateManagerPkiSyncOptionsSchema +}); + +export const CreateAwsCertificateManagerPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().default(true), + destinationConfig: AwsCertificateManagerPkiSyncConfigSchema, + syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional().default({}), + subscriberId: z.string().optional(), + connectionId: z.string(), + projectId: z.string().trim().min(1) +}); + +export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64).optional(), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().optional(), + destinationConfig: AwsCertificateManagerPkiSyncConfigSchema.optional(), + syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional(), + subscriberId: z.string().optional(), + connectionId: z.string().optional() +}); + +export const AwsCertificateManagerPkiSyncListItemSchema = z.object({ + name: z.literal("AWS Certificate Manager"), + connection: z.literal(AppConnection.AWS), + destination: z.literal(PkiSync.AwsCertificateManager), + canImportCertificates: z.literal(false), + canRemoveCertificates: z.literal(true) +}); diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts new file mode 100644 index 000000000..717e86438 --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts @@ -0,0 +1,58 @@ +import * as AWS from "aws-sdk"; +import { z } from "zod"; + +import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; + +import { + AwsCertificateManagerPkiSyncConfigSchema, + AwsCertificateManagerPkiSyncSchema, + CreateAwsCertificateManagerPkiSyncSchema, + UpdateAwsCertificateManagerPkiSyncSchema +} from "./aws-certificate-manager-pki-sync-schemas"; + +export type TAwsCertificateManagerPkiSyncConfig = z.infer; + +export type TAwsCertificateManagerPkiSync = z.infer; + +export type TAwsCertificateManagerPkiSyncInput = z.infer; + +export type TAwsCertificateManagerPkiSyncUpdate = z.infer; + +export type TAwsCertificateManagerPkiSyncWithCredentials = TAwsCertificateManagerPkiSync & { + connection: TAwsConnection; +}; + +export interface ACMCertificateWithKey extends AWS.ACM.CertificateDetail { + Tags?: AWS.ACM.TagList; + key: string; + cert: string; + certificateChain: string; + privateKey: string; + arn?: string; +} + +export interface SyncCertificatesResult { + uploaded: number; + removed: number; + failedRemovals: number; + skipped: number; + details?: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + }; +} + +export interface RemoveCertificatesResult { + removed: number; + failed: number; + skipped: number; +} + +export interface CertificateImportRequest { + key: string; + name: string; + cert: string; + privateKey: string; + certificateChain?: string; + existingArn?: string; +} diff --git a/backend/src/services/pki-sync/aws-certificate-manager/index.ts b/backend/src/services/pki-sync/aws-certificate-manager/index.ts new file mode 100644 index 000000000..fb30b5c71 --- /dev/null +++ b/backend/src/services/pki-sync/aws-certificate-manager/index.ts @@ -0,0 +1,4 @@ +export * from "./aws-certificate-manager-pki-sync-constants"; +export * from "./aws-certificate-manager-pki-sync-fns"; +export * from "./aws-certificate-manager-pki-sync-schemas"; +export * from "./aws-certificate-manager-pki-sync-types"; diff --git a/backend/src/services/pki-sync/pki-sync-enums.ts b/backend/src/services/pki-sync/pki-sync-enums.ts index fadd70914..46a7fc975 100644 --- a/backend/src/services/pki-sync/pki-sync-enums.ts +++ b/backend/src/services/pki-sync/pki-sync-enums.ts @@ -1,5 +1,6 @@ export enum PkiSync { - AzureKeyVault = "azure-key-vault" + AzureKeyVault = "azure-key-vault", + AwsCertificateManager = "aws-certificate-manager" } export enum PkiSyncStatus { diff --git a/backend/src/services/pki-sync/pki-sync-fns.ts b/backend/src/services/pki-sync/pki-sync-fns.ts index 343afa626..75f312fff 100644 --- a/backend/src/services/pki-sync/pki-sync-fns.ts +++ b/backend/src/services/pki-sync/pki-sync-fns.ts @@ -6,6 +6,8 @@ import { BadRequestError } from "@app/lib/errors"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants"; +import { awsCertificateManagerPkiSyncFactory } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-fns"; import { AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION } from "./azure-key-vault/azure-key-vault-pki-sync-constants"; import { azureKeyVaultPkiSyncFactory } from "./azure-key-vault/azure-key-vault-pki-sync-fns"; import { PkiSync } from "./pki-sync-enums"; @@ -14,7 +16,8 @@ import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types"; const ENTERPRISE_PKI_SYNCS: PkiSync[] = []; const PKI_SYNC_LIST_OPTIONS = { - [PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION + [PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION, + [PkiSync.AwsCertificateManager]: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION }; export const enterprisePkiSyncCheck = async ( @@ -144,8 +147,10 @@ export const matchesCertificateNameSchema = (name: string, environment: string, return name.startsWith(prefix) && name.endsWith(suffix); }; -const isAzureKeyVaultPkiSync = (pkiSync: TPkiSyncWithCredentials): boolean => { - return pkiSync.destination === PkiSync.AzureKeyVault; +const checkPkiSyncDestination = (pkiSync: TPkiSyncWithCredentials, destination: PkiSync): void => { + if (pkiSync.destination !== destination) { + throw new Error(`Invalid PKI sync destination: ${pkiSync.destination}`); + } }; export const PkiSyncFns = { @@ -163,6 +168,11 @@ export const PkiSyncFns = { "Azure Key Vault does not support importing certificates into Infisical (private keys cannot be extracted)" ); } + case PkiSync.AwsCertificateManager: { + throw new Error( + "AWS Certificate Manager does not support importing certificates into Infisical (private keys cannot be extracted)" + ); + } default: throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); } @@ -188,12 +198,15 @@ export const PkiSyncFns = { }> => { switch (pkiSync.destination) { case PkiSync.AzureKeyVault: { - if (!isAzureKeyVaultPkiSync(pkiSync)) { - throw new Error("Invalid Azure Key Vault PKI sync configuration"); - } + checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap); } + case PkiSync.AwsCertificateManager: { + checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); + const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); + return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap); + } default: throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); } @@ -209,13 +222,17 @@ export const PkiSyncFns = { ): Promise => { switch (pkiSync.destination) { case PkiSync.AzureKeyVault: { - if (!isAzureKeyVaultPkiSync(pkiSync)) { - throw new Error("Invalid Azure Key Vault PKI sync configuration"); - } + checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames); break; } + case PkiSync.AwsCertificateManager: { + checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); + const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); + await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames); + break; + } default: throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); } diff --git a/backend/src/services/pki-sync/pki-sync-maps.ts b/backend/src/services/pki-sync/pki-sync-maps.ts index b667416c6..5c416b513 100644 --- a/backend/src/services/pki-sync/pki-sync-maps.ts +++ b/backend/src/services/pki-sync/pki-sync-maps.ts @@ -3,9 +3,11 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { PkiSync } from "./pki-sync-enums"; export const PKI_SYNC_NAME_MAP: Record = { - [PkiSync.AzureKeyVault]: "Azure Key Vault" + [PkiSync.AzureKeyVault]: "Azure Key Vault", + [PkiSync.AwsCertificateManager]: "AWS Certificate Manager" }; export const PKI_SYNC_CONNECTION_MAP: Record = { - [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault + [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault, + [PkiSync.AwsCertificateManager]: AppConnection.AWS }; diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx new file mode 100644 index 000000000..dcd58cf32 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create AWS Certificate Manager PKI Sync" +openapi: "POST /api/v1/pki/syncs/aws-certificate-manager" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx new file mode 100644 index 000000000..73fed2cdb --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete AWS Certificate Manager PKI Sync" +openapi: "DELETE /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx new file mode 100644 index 000000000..9191bbde3 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get AWS Certificate Manager PKI Sync by ID" +openapi: "GET /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx new file mode 100644 index 000000000..821ddbd61 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List AWS Certificate Manager PKI Syncs" +openapi: "GET /api/v1/pki/syncs/aws-certificate-manager" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx new file mode 100644 index 000000000..98f99e39a --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Certificates from AWS Certificate Manager" +openapi: "POST /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}/remove-certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx new file mode 100644 index 000000000..b97b7a9ab --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Certificates to AWS Certificate Manager" +openapi: "POST /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}/sync" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx new file mode 100644 index 000000000..9b7382ce8 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update AWS Certificate Manager PKI Sync" +openapi: "PATCH /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index 210c5145c..eb69cd2b8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -723,6 +723,7 @@ { "group": "Syncs", "pages": [ + "documentation/platform/pki/certificate-syncs/aws-certificate-manager", "documentation/platform/pki/certificate-syncs/azure-key-vault" ] } @@ -2523,6 +2524,18 @@ "api-reference/endpoints/pki/syncs/list", "api-reference/endpoints/pki/syncs/get-by-id", "api-reference/endpoints/pki/syncs/options", + { + "group": "AWS Certificate Manager", + "pages": [ + "api-reference/endpoints/pki/syncs/aws-certificate-manager/list", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/create", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/update", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/delete", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates", + "api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates" + ] + }, { "group": "Azure Key Vault", "pages": [ diff --git a/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx b/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx new file mode 100644 index 000000000..e40d1b84f --- /dev/null +++ b/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx @@ -0,0 +1,149 @@ +--- +title: "AWS Certificate Manager" +description: "Learn how to configure an AWS Certificate Manager Certificate Sync for Infisical PKI." +--- + +**Prerequisites:** + + - Set up and configure a [Certificate Authority](/documentation/platform/pki/overview) + - Create an [AWS Connection](/integrations/app-connections/aws) + - Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply. + + + The AWS Certificate Manager Certificate Sync requires the following ACM permissions to be set on the IAM user/role + for Infisical to sync certificates to AWS Certificate Manager: `acm:ListCertificates`, `acm:DescribeCertificate`, `acm:ImportCertificate`, `acm:DeleteCertificate`, and `acm:ListTagsForCertificate`. + + These permissions allow Infisical to list, import, tag, and manage certificates in your AWS Certificate Manager service. + + + + Certificates synced to AWS Certificate Manager will be stored as imported certificates, preserving both the certificate and private key components. + + + + + 1. Navigate to **Project** > **Integrations** and select the **Certificate Syncs** tab. Click on the **Add Sync** button. + ![Certificate Syncs Tab](/images/certificate-syncs/general/certificate-sync-tab.png) + + 2. Select the **AWS Certificate Manager** option. + ![Select ACM](/images/certificate-syncs/aws-certificate-manager/select-acm-option.png) + + 3. Configure the **Source** from where certificates should be retrieved, then click **Next**. + ![Configure Source](/images/certificate-syncs/aws-certificate-manager/acm-source.png) + + - **PKI Subscriber**: The PKI subscriber to retrieve certificates from. + + 4. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/certificate-syncs/aws-certificate-manager/acm-destination.png) + + - **AWS Connection**: The AWS Connection to authenticate with. + - **AWS Region**: The AWS region where certificates should be stored. +

+ + 5. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/certificate-syncs/aws-certificate-manager/acm-options.png) + + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced from the source PKI subscriber when changes occur. Disable to enforce manual syncing only. + - **Enable Certificate Removal**: If enabled, Infisical will remove expired certificates from the destination during sync operations. Disable this option if you intend to manage certificate cleanup manually. + - **Certificate Name Schema** (Optional): Customize how certificate tags are generated in AWS Certificate Manager. Must include `{{certificateId}}` as a placeholder for the certificate ID to ensure proper certificate identification and management. If not specified, defaults to `Infisical-{{certificateId}}`. + + + **AWS Certificate Manager Certificate Limits**: AWS Certificate Manager has limits on the number of certificates per account and region. Refer to AWS documentation for current limits. Deleted certificates count toward your quota until they are permanently purged by AWS (typically after 30 days). + + + 6. Configure the **Details** of your AWS Certificate Manager Certificate Sync, then click **Next**. + ![Configure Details](/images/certificate-syncs/aws-certificate-manager/acm-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your AWS Certificate Manager Certificate Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/certificate-syncs/aws-certificate-manager/acm-review.png) + + 8. If enabled, your AWS Certificate Manager Certificate Sync will begin syncing your certificates to the destination endpoint. + ![Sync Certificates](/images/certificate-syncs/aws-certificate-manager/acm-synced.png) + + + + To create an **AWS Certificate Manager Certificate Sync**, make an API request to the [Create AWS Certificate Manager Certificate Sync](/api-reference/endpoints/pki/syncs/aws-certificate-manager/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/pki/syncs/aws-certificate-manager \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-acm-cert-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example certificate sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "destination": "aws-certificate-manager", + "isAutoSyncEnabled": true, + "syncOptions": { + "canRemoveCertificates": true, + "certificateNameSchema": "myapp-{{certificateId}}" + }, + "destinationConfig": { + "region": "us-east-1" + } + }' + ``` + + ### Sample response + + ```json Response + { + "pkiSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-acm-cert-sync", + "description": "an example certificate sync", + "destination": "aws-certificate-manager", + "isAutoSyncEnabled": true, + "destinationConfig": { + "region": "us-east-1" + }, + "syncOptions": { + "canRemoveCertificates": true, + "certificateNameSchema": "myapp-{{certificateId}}" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-01-01T00:00:00.000Z", + "updatedAt": "2023-01-01T00:00:00.000Z" + } + } + ``` + + + +## Certificate Management + +Your AWS Certificate Manager Certificate Sync will: + +- **Automatic Deployment**: Deploy new certificates issued by your PKI subscriber to AWS Certificate Manager +- **Certificate Updates**: Update certificates in AWS Certificate Manager when renewals occur +- **Expiration Handling**: Optionally remove expired certificates from AWS Certificate Manager (if enabled) +- **Format Preservation**: Maintain certificate format and metadata during sync operations +- **Tagging**: Automatically tag certificates with an InfisicalCertificate tag for easy identification and management + + + AWS Certificate Manager Certificate Syncs support both automatic and manual synchronization modes. When auto-sync is enabled, certificates are automatically deployed as they are issued or renewed. + + +## Manual Certificate Sync + +You can manually trigger certificate synchronization from your PKI subscriber to AWS Certificate Manager using the sync certificates functionality. This is useful for: + +- Initial setup when you have existing certificates to deploy +- One-time sync of specific certificates +- Testing certificate sync configurations +- Force sync after making changes + +To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates) API endpoint or the manual sync option in the Infisical UI. + + +AWS Certificate Manager does not support importing certificates back into Infisical due to security limitations where private keys cannot be extracted from AWS Certificate Manager. Only certificates imported into ACM (not AWS-issued certificates) can be managed by the sync. + \ No newline at end of file diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png new file mode 100644 index 000000000..42a20fc99 Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png new file mode 100644 index 000000000..483cee003 Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png new file mode 100644 index 000000000..aa08b2d19 Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png new file mode 100644 index 000000000..5f7b216ad Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png new file mode 100644 index 000000000..0d92fe69e Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png new file mode 100644 index 000000000..7e1ed12c5 Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png b/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png new file mode 100644 index 000000000..79515516a Binary files /dev/null and b/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index 08ae05be7..63c1205e4 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -174,6 +174,45 @@ Infisical supports two methods for connecting to AWS. + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync certificates to AWS Certificate Manager: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCertificateManagerAccess", + "Effect": "Allow", + "Action": [ + "acm:ListCertificates", + "acm:DescribeCertificate", + "acm:GetCertificate", + "acm:ImportCertificate", + "acm:ExportCertificate", + "acm:DeleteCertificate", + "acm:AddTagsToCertificate", + "acm:RemoveTagsFromCertificate", + "acm:ListTagsForCertificate" + ], + "Resource": "*" + } + ] + } + ``` + + - **ListCertificates**: Lists all certificates in the account + - **ImportCertificate**: Imports certificates from Infisical into AWS Certificate Manager + - **ExportCertificate**: Exports certificates for synchronization (only works with imported certificates) + - **DeleteCertificate**: Removes certificates that are no longer managed by Infisical + - **DescribeCertificate** and **GetCertificate**: Retrieves certificate details for comparison during sync + - Tag-related permissions: Manages certificate tags for identification and organization + + + + @@ -351,6 +390,45 @@ Infisical supports two methods for connecting to AWS. + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync certificates to AWS Certificate Manager: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowCertificateManagerAccess", + "Effect": "Allow", + "Action": [ + "acm:ListCertificates", + "acm:DescribeCertificate", + "acm:GetCertificate", + "acm:ImportCertificate", + "acm:ExportCertificate", + "acm:DeleteCertificate", + "acm:AddTagsToCertificate", + "acm:RemoveTagsFromCertificate", + "acm:ListTagsForCertificate" + ], + "Resource": "*" + } + ] + } + ``` + + - **ListCertificates**: Lists all certificates in the account + - **ImportCertificate**: Imports certificates from Infisical into AWS Certificate Manager + - **ExportCertificate**: Exports certificates for synchronization (only works with imported certificates) + - **DeleteCertificate**: Removes certificates that are no longer managed by Infisical + - **DescribeCertificate** and **GetCertificate**: Retrieves certificate details for comparison during sync + - Tag-related permissions: Manages certificate tags for identification and organization + + + + diff --git a/frontend/src/components/pki-syncs/forms/AwsCertificateManagerPkiSyncFields.tsx b/frontend/src/components/pki-syncs/forms/AwsCertificateManagerPkiSyncFields.tsx new file mode 100644 index 000000000..83d85a35f --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/AwsCertificateManagerPkiSyncFields.tsx @@ -0,0 +1,50 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Select, SelectItem } from "@app/components/v2"; +import { AWS_REGIONS } from "@app/helpers/appConnections"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; +import { PkiSyncConnectionField } from "./PkiSyncConnectionField"; + +export const AwsCertificateManagerPkiSyncFields = () => { + const { control, setValue } = useFormContext< + TPkiSyncForm & { destination: PkiSync.AwsCertificateManager } + >(); + + return ( + <> + { + setValue("destinationConfig.region", ""); + }} + /> + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx b/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx index 87feebff7..2c4af9376 100644 --- a/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx +++ b/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx @@ -3,8 +3,8 @@ import { Controller, useFormContext } from "react-hook-form"; import { FormControl, Input } from "@app/components/v2"; import { PkiSync } from "@app/hooks/api/pkiSyncs"; +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; import { PkiSyncConnectionField } from "./PkiSyncConnectionField"; -import { TPkiSyncForm } from "./schemas"; export const AzureKeyVaultPkiSyncFields = () => { const { control, setValue } = useFormContext< diff --git a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx index 02066c1cc..132db1e98 100644 --- a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx +++ b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx @@ -12,12 +12,12 @@ import { useProject } from "@app/context"; import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PkiSync, TPkiSync, useCreatePkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; +import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas/pki-sync-schema"; import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; import { PkiSyncReviewFields } from "./PkiSyncReviewFields"; import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; -import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas"; type Props = { onComplete: (pkiSync: TPkiSync) => void; diff --git a/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx index c4407ceee..73f5bc2bc 100644 --- a/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx +++ b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx @@ -8,11 +8,11 @@ import { Button, ModalClose } from "@app/components/v2"; import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { TPkiSync, useUpdatePkiSync } from "@app/hooks/api/pkiSyncs"; +import { TUpdatePkiSyncForm, UpdatePkiSyncFormSchema } from "./schemas/pki-sync-schema"; import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; -import { TPkiSyncForm, UpdatePkiSyncFormSchema } from "./schemas"; type Props = { onComplete: (pkiSync: TPkiSync) => void; @@ -24,7 +24,7 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => { const updatePkiSync = useUpdatePkiSync(); const { name: destinationName } = PKI_SYNC_MAP[pkiSync.destination]; - const formMethods = useForm({ + const formMethods = useForm({ resolver: zodResolver(UpdatePkiSyncFormSchema), defaultValues: { ...pkiSync, @@ -33,11 +33,11 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => { id: pkiSync.connectionId, name: pkiSync.appConnectionName } - } as Partial, + } as Partial, reValidateMode: "onChange" }); - const onSubmit = async ({ connection, ...formData }: TPkiSyncForm) => { + const onSubmit = async ({ connection, ...formData }: TUpdatePkiSyncForm) => { try { const updatedPkiSync = await updatePkiSync.mutateAsync({ syncId: pkiSync.id, diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx index 95bbfc531..445f9169d 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx @@ -10,7 +10,7 @@ import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs"; import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; -import { TPkiSyncForm } from "./schemas"; +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; type Props = { onChange?: VoidFunction; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx index 4566b76e3..e7f90670d 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx @@ -2,8 +2,9 @@ import { useFormContext } from "react-hook-form"; import { PkiSync } from "@app/hooks/api/pkiSyncs"; +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; +import { AwsCertificateManagerPkiSyncFields } from "./AwsCertificateManagerPkiSyncFields"; import { AzureKeyVaultPkiSyncFields } from "./AzureKeyVaultPkiSyncFields"; -import { TPkiSyncForm } from "./schemas"; export const PkiSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -13,6 +14,8 @@ export const PkiSyncDestinationFields = () => { switch (destination) { case PkiSync.AzureKeyVault: return ; + case PkiSync.AwsCertificateManager: + return ; default: return (

diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx index d7fc282dd..110744e2c 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx @@ -2,7 +2,7 @@ import { Controller, useFormContext } from "react-hook-form"; import { FormControl, Input, TextArea } from "@app/components/v2"; -import { TPkiSyncForm } from "./schemas"; +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; export const PkiSyncDetailsFields = () => { const { control } = useFormContext(); diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx index 9043b36a3..ce581bb88 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FormControl, Input, Switch, Tooltip } from "@app/components/v2"; import { PkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; -import { TPkiSyncForm } from "../schemas"; +import { TPkiSyncForm } from "../schemas/pki-sync-schema"; type Props = { destination?: PkiSync; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncReviewFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncReviewFields.tsx index 7ddf6a514..be6b91948 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncReviewFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncReviewFields.tsx @@ -5,7 +5,7 @@ import { useProject } from "@app/context"; import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { useListWorkspacePkiSubscribers } from "@app/hooks/api"; -import { TPkiSyncForm } from "./schemas"; +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; export const PkiSyncReviewFields = () => { const { watch } = useFormContext(); diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncSourceFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncSourceFields.tsx index 80f17f4e6..ea856cfb4 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncSourceFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncSourceFields.tsx @@ -4,7 +4,7 @@ import { FilterableSelect, FormControl } from "@app/components/v2"; import { useProject } from "@app/context"; import { useListWorkspacePkiSubscribers } from "@app/hooks/api"; -import { TPkiSyncForm } from "./schemas"; +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; export const PkiSyncSourceFields = () => { const { control } = useFormContext(); diff --git a/frontend/src/components/pki-syncs/forms/index.ts b/frontend/src/components/pki-syncs/forms/index.ts index 7d12526b1..5f92b599a 100644 --- a/frontend/src/components/pki-syncs/forms/index.ts +++ b/frontend/src/components/pki-syncs/forms/index.ts @@ -5,4 +5,4 @@ export { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; export { PkiSyncOptionsFields } from "./PkiSyncOptionsFields/PkiSyncOptionsFields"; export { PkiSyncReviewFields } from "./PkiSyncReviewFields"; export { PkiSyncSourceFields } from "./PkiSyncSourceFields"; -export { PkiSyncFormSchema, type TPkiSyncForm } from "./schemas"; +export { PkiSyncFormSchema, type TPkiSyncForm } from "./schemas/pki-sync-schema"; diff --git a/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts new file mode 100644 index 000000000..aa522ef7d --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { BasePkiSyncSchema } from "./base-pki-sync-schema"; + +const AwsCertificateManagerSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean().default(false), + canRemoveCertificates: z.boolean().default(false), + certificateNameSchema: z + .string() + .optional() + .refine( + (val) => { + // For AWS Certificate Manager, {{certificateId}} is always required if certificateNameSchema is provided + if (!val) return true; + + if (!val.includes("{{certificateId}}")) { + return false; + } + + const allowedOptionalPlaceholders = ["{{environment}}"]; + const allowedPlaceholdersRegexPart = ["{{certificateId}}", ...allowedOptionalPlaceholders] + .map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) + .join("|"); + + const allowedContentRegex = new RegExp( + `^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$` + ); + + return allowedContentRegex.test(val); + }, + { + message: + "Certificate name schema must include {{certificateId}} placeholder for AWS Certificate Manager." + } + ) +}); + +export const AwsCertificateManagerPkiSyncDestinationSchema = BasePkiSyncSchema( + AwsCertificateManagerSyncOptionsSchema +).merge( + z.object({ + destination: z.literal(PkiSync.AwsCertificateManager), + destinationConfig: z.object({ + region: z.string().min(1, "AWS region is required") + }) + }) +); + +export const UpdateAwsCertificateManagerPkiSyncDestinationSchema = + AwsCertificateManagerPkiSyncDestinationSchema.partial().merge( + z.object({ + name: z + .string() + .trim() + .min(1, "Name is required") + .max(255, "Name must be less than 255 characters"), + destination: z.literal(PkiSync.AwsCertificateManager), + connection: z.object({ + id: z.string().uuid("Invalid connection ID format"), + name: z.string().max(255, "Connection name must be less than 255 characters") + }) + }) + ); diff --git a/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts new file mode 100644 index 000000000..d3300d9f2 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { BasePkiSyncSchema } from "./base-pki-sync-schema"; + +export const AzureKeyVaultPkiSyncDestinationSchema = BasePkiSyncSchema().merge( + z.object({ + destination: z.literal(PkiSync.AzureKeyVault), + destinationConfig: z.object({ + vaultBaseUrl: z.string().url("Valid URL is required") + }) + }) +); + +export const UpdateAzureKeyVaultPkiSyncDestinationSchema = + AzureKeyVaultPkiSyncDestinationSchema.partial().merge( + z.object({ + name: z + .string() + .trim() + .min(1, "Name is required") + .max(255, "Name must be less than 255 characters"), + destination: z.literal(PkiSync.AzureKeyVault), + connection: z.object({ + id: z.string().uuid("Invalid connection ID format"), + name: z.string().max(255, "Connection name must be less than 255 characters") + }) + }) + ); diff --git a/frontend/src/components/pki-syncs/forms/schemas.ts b/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts similarity index 63% rename from frontend/src/components/pki-syncs/forms/schemas.ts rename to frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts index 1c31c8c5a..f8c9d599f 100644 --- a/frontend/src/components/pki-syncs/forms/schemas.ts +++ b/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts @@ -1,25 +1,9 @@ -import { z } from "zod"; +import { AnyZodObject, z } from "zod"; -import { PkiSync } from "@app/hooks/api/pkiSyncs"; - -export const PkiSyncFormSchema = z.object({ - name: z - .string() - .trim() - .min(1, "Name is required") - .max(255, "Name must be less than 255 characters"), - description: z.string().optional(), - destination: z.nativeEnum(PkiSync), - isAutoSyncEnabled: z.boolean().default(true), - subscriberId: z.string().min(1, "PKI Subscriber is required"), - connection: z.object({ - id: z.string().uuid("Invalid connection ID format"), - name: z.string().max(255, "Connection name must be less than 255 characters") - }), - destinationConfig: z.object({ - vaultBaseUrl: z.string().url("Valid URL is required") - }), - syncOptions: z.object({ +export const BasePkiSyncSchema = ( + additionalSyncOptions?: T +) => { + const baseSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(false), certificateNameSchema: z @@ -53,22 +37,27 @@ export const PkiSyncFormSchema = z.object({ "Certificate name schema must include exactly one {{certificateId}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders." } ) - }) -}); + }); -export type TPkiSyncForm = z.infer; + const syncOptionsSchema = additionalSyncOptions + ? baseSyncOptionsSchema.merge(additionalSyncOptions) + : (baseSyncOptionsSchema as T extends AnyZodObject + ? z.ZodObject> + : typeof baseSyncOptionsSchema); -export const UpdatePkiSyncFormSchema = PkiSyncFormSchema.partial().merge( - z.object({ + return z.object({ name: z .string() .trim() .min(1, "Name is required") .max(255, "Name must be less than 255 characters"), - destination: z.nativeEnum(PkiSync), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().default(true), + subscriberId: z.string().min(1, "PKI Subscriber is required"), connection: z.object({ id: z.string().uuid("Invalid connection ID format"), name: z.string().max(255, "Connection name must be less than 255 characters") - }) - }) -); + }), + syncOptions: syncOptionsSchema + }); +}; diff --git a/frontend/src/components/pki-syncs/forms/schemas/pki-sync-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/pki-sync-schema.ts new file mode 100644 index 000000000..6efa5e24d --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/schemas/pki-sync-schema.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { + AwsCertificateManagerPkiSyncDestinationSchema, + UpdateAwsCertificateManagerPkiSyncDestinationSchema +} from "./aws-certificate-manager-pki-sync-destination-schema"; +import { + AzureKeyVaultPkiSyncDestinationSchema, + UpdateAzureKeyVaultPkiSyncDestinationSchema +} from "./azure-key-vault-pki-sync-destination-schema"; + +const PkiSyncUnionSchema = z.discriminatedUnion("destination", [ + AzureKeyVaultPkiSyncDestinationSchema, + AwsCertificateManagerPkiSyncDestinationSchema +]); + +const UpdatePkiSyncUnionSchema = z.discriminatedUnion("destination", [ + UpdateAzureKeyVaultPkiSyncDestinationSchema, + UpdateAwsCertificateManagerPkiSyncDestinationSchema +]); + +export const PkiSyncFormSchema = PkiSyncUnionSchema; + +export const UpdatePkiSyncFormSchema = UpdatePkiSyncUnionSchema; + +export type TPkiSyncForm = z.infer; + +export type TUpdatePkiSyncForm = z.infer; diff --git a/frontend/src/helpers/pkiSyncs.ts b/frontend/src/helpers/pkiSyncs.ts index 15031a198..d8c87853d 100644 --- a/frontend/src/helpers/pkiSyncs.ts +++ b/frontend/src/helpers/pkiSyncs.ts @@ -11,9 +11,14 @@ export const PKI_SYNC_MAP: Record< [PkiSync.AzureKeyVault]: { name: "Azure Key Vault", image: "Microsoft Azure.png" + }, + [PkiSync.AwsCertificateManager]: { + name: "AWS Certificate Manager", + image: "Amazon Web Services.png" } }; export const PKI_SYNC_CONNECTION_MAP: Record = { - [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault + [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault, + [PkiSync.AwsCertificateManager]: AppConnection.AWS }; diff --git a/frontend/src/hooks/api/pkiSyncs/enums.ts b/frontend/src/hooks/api/pkiSyncs/enums.ts index 25080a1ae..507f3336c 100644 --- a/frontend/src/hooks/api/pkiSyncs/enums.ts +++ b/frontend/src/hooks/api/pkiSyncs/enums.ts @@ -1,5 +1,6 @@ export enum PkiSync { - AzureKeyVault = "azure-key-vault" + AzureKeyVault = "azure-key-vault", + AwsCertificateManager = "aws-certificate-manager" } export enum PkiSyncStatus { diff --git a/frontend/src/hooks/api/pkiSyncs/types/aws-certificate-manager-sync.ts b/frontend/src/hooks/api/pkiSyncs/types/aws-certificate-manager-sync.ts new file mode 100644 index 000000000..e31ff1378 --- /dev/null +++ b/frontend/src/hooks/api/pkiSyncs/types/aws-certificate-manager-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { PkiSync } from "../enums"; +import { TRootPkiSync } from "./common"; + +export type TAwsCertificateManagerPkiSync = TRootPkiSync & { + destination: PkiSync.AwsCertificateManager; + destinationConfig: { + region: string; + }; + connection: { + app: AppConnection.AWS; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/pkiSyncs/types/index.ts b/frontend/src/hooks/api/pkiSyncs/types/index.ts index baf8a693b..90a76fc6b 100644 --- a/frontend/src/hooks/api/pkiSyncs/types/index.ts +++ b/frontend/src/hooks/api/pkiSyncs/types/index.ts @@ -1,6 +1,6 @@ import { PkiSync } from "@app/hooks/api/pkiSyncs"; -import { DiscriminativePick } from "@app/types"; +import { TAwsCertificateManagerPkiSync } from "./aws-certificate-manager-sync"; import { TAzureKeyVaultPkiSync } from "./azure-key-vault-sync"; export type TPkiSyncOption = { @@ -16,27 +16,32 @@ export type TPkiSyncOption = { minCertificateNameLength?: number; }; -export type TPkiSync = TAzureKeyVaultPkiSync; +export type TPkiSync = TAzureKeyVaultPkiSync | TAwsCertificateManagerPkiSync; export type TListPkiSyncs = { pkiSyncs: TPkiSync[] }; export type TListPkiSyncOptions = { pkiSyncOptions: TPkiSyncOption[] }; -export type TCreatePkiSyncDTO = DiscriminativePick< - TPkiSync, - | "name" - | "destinationConfig" - | "description" - | "connectionId" - | "syncOptions" - | "destination" - | "isAutoSyncEnabled" -> & { subscriberId?: string; projectId: string }; +export type TCreatePkiSyncDTO = { + name: string; + description?: string; + connectionId: string; + syncOptions: { + canImportCertificates: boolean; + canRemoveCertificates: boolean; + certificateNamePrefix?: string; + certificateNameSchema?: string; + }; + destination: PkiSync; + isAutoSyncEnabled: boolean; + subscriberId?: string; + projectId: string; + destinationConfig: Record; +}; export type TUpdatePkiSyncDTO = Partial> & { syncId: string; projectId: string; - destination: PkiSync; }; export type TDeletePkiSyncDTO = { @@ -63,4 +68,6 @@ export type TTriggerPkiSyncRemoveCertificatesDTO = { projectId: string; }; +export * from "./aws-certificate-manager-sync"; +export * from "./azure-key-vault-sync"; export * from "./common"; diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx index bb96e72f0..491b95d25 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx @@ -164,7 +164,10 @@ export const PkiSyncRow = ({
{subscriberId ? ( - + ) : (