Merge pull request #4596 from Infisical/ENG-3798

Add PKI sync AWS Certificate Manager
This commit is contained in:
carlosmonastyrski
2025-10-06 17:46:11 -03:00
committed by GitHub
46 changed files with 1415 additions and 71 deletions

View File

@@ -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
}
});

View File

@@ -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<PkiSync, (server: FastifyZodProvider) => Promise<void>> = {
[PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter
[PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter,
[PkiSync.AwsCertificateManager]: registerAwsCertificateManagerPkiSyncRouter
};

View File

@@ -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;

View File

@@ -0,0 +1,634 @@
/* 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 { 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<typeof AwsConnectionAssumeRoleCredentialsSchema>;
type TAwsAccessKeyCredentials = z.infer<typeof AwsConnectionAccessTokenCredentialsSchema>;
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";
};
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<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
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<TAppConnectionDALFactory, "findById" | "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<AWS.ACM> => {
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 deleteCertificateFromAcm = async (
acm: AWS.ACM,
certificateArn: string,
operation: string,
syncId: string,
throwOnError = false
): Promise<{ arn: string; success: boolean; error?: Error }> => {
try {
await withRateLimitRetry(() => acm.deleteCertificate({ CertificateArn: certificateArn }).promise(), {
operation,
syncId
});
return { arn: certificateArn, success: true };
} catch (error) {
const errorObj = error instanceof Error ? error : new Error("Unknown error");
if (throwOnError) {
throw new PkiSyncError({
message: `Failed to remove certificate from AWS Certificate Manager: ${errorObj.message}`,
cause: errorObj,
context: {
certificateArn,
operation
}
});
}
return {
arn: certificateArn,
success: false,
error: errorObj
};
}
};
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"],
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) {
// Certificate content cannot be imported
}
}
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) {
throw new PkiSyncError({
message: `Failed to fetch ${failedFetches.length} certificate details from AWS Certificate Manager`,
shouldRetry: true,
context: {
failedCount: failedFetches.length,
totalCount: certificateSummaries.length
}
});
}
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<SyncCertificatesResult> => {
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, certData]) => {
const { cert, privateKey, certificateChain } = certData;
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) {
throw new PkiSyncError({
message: `Certificate validation failed for ${certName}: ${validationError instanceof Error ? validationError.message : String(validationError)}`,
shouldRetry: false,
context: {
certificateName,
certName
}
});
}
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";
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) => deleteCertificateFromAcm(acm, certificateArn, "delete-certificate", pkiSync.id),
{ operation: "remove-certificates", syncId: pkiSync.id }
);
const successfulRemovals = removeResults.filter(
(result) => result.status === "fulfilled" && result.value.success
);
removedCertificates = successfulRemovals.length;
failedRemovals = removeResults.length - removedCertificates;
}
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
};
});
}
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;
}
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<RemoveCertificatesResult> => {
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) =>
deleteCertificateFromAcm(acm, certificateArn, "delete-specific-certificate", pkiSync.id, true),
{ 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
};
};

View File

@@ -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(false),
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)
});

View File

@@ -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<typeof AwsCertificateManagerPkiSyncConfigSchema>;
export type TAwsCertificateManagerPkiSync = z.infer<typeof AwsCertificateManagerPkiSyncSchema>;
export type TAwsCertificateManagerPkiSyncInput = z.infer<typeof CreateAwsCertificateManagerPkiSyncSchema>;
export type TAwsCertificateManagerPkiSyncUpdate = z.infer<typeof UpdateAwsCertificateManagerPkiSyncSchema>;
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;
}

View File

@@ -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";

View File

@@ -1,5 +1,6 @@
export enum PkiSync {
AzureKeyVault = "azure-key-vault"
AzureKeyVault = "azure-key-vault",
AwsCertificateManager = "aws-certificate-manager"
}
export enum PkiSyncStatus {

View File

@@ -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<void> => {
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)}`);
}

View File

@@ -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, string> = {
[PkiSync.AzureKeyVault]: "Azure Key Vault"
[PkiSync.AzureKeyVault]: "Azure Key Vault",
[PkiSync.AwsCertificateManager]: "AWS Certificate Manager"
};
export const PKI_SYNC_CONNECTION_MAP: Record<PkiSync, AppConnection> = {
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault,
[PkiSync.AwsCertificateManager]: AppConnection.AWS
};