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

View File

@@ -0,0 +1,4 @@
---
title: "Create AWS Certificate Manager PKI Sync"
openapi: "POST /api/v1/pki/syncs/aws-certificate-manager"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete AWS Certificate Manager PKI Sync"
openapi: "DELETE /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Get AWS Certificate Manager PKI Sync by ID"
openapi: "GET /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List AWS Certificate Manager PKI Syncs"
openapi: "GET /api/v1/pki/syncs/aws-certificate-manager"
---

View File

@@ -0,0 +1,4 @@
---
title: "Remove Certificates from AWS Certificate Manager"
openapi: "POST /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}/remove-certificates"
---

View File

@@ -0,0 +1,4 @@
---
title: "Sync Certificates to AWS Certificate Manager"
openapi: "POST /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}/sync"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update AWS Certificate Manager PKI Sync"
openapi: "PATCH /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}"
---

View File

@@ -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": [

View File

@@ -0,0 +1,146 @@
---
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)
<Note>
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.
</Note>
<Note>
Certificates synced to AWS Certificate Manager will be stored as imported certificates, preserving both the certificate and private key components.
</Note>
<Tabs>
<Tab title="Infisical UI">
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}}`.
<Tip>
**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).
</Tip>
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)
</Tab>
<Tab title="API">
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"
}
}
```
</Tab>
</Tabs>
## 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)
- **Tagging**: Automatically tag certificates with an InfisicalCertificate tag for easy identification and management
<Note>
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.
</Note>
## 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.
<Note>
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.
</Note>

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

View File

@@ -177,6 +177,45 @@ Infisical supports two methods for connecting to AWS.
</Accordion>
</AccordionGroup>
</Tab>
<Tab title="PKI Sync">
<AccordionGroup>
<Accordion title="AWS Certificate Manager">
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": "*"
}
]
}
```
<Note>
- **ListCertificates**: Lists all certificates in the account
- **ImportCertificate**: Imports certificates from Infisical into AWS Certificate Manager
- **ExportCertificate**: Exports certificates for synchronization
- **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
</Note>
</Accordion>
</AccordionGroup>
</Tab>
</Tabs>
</Step>
@@ -354,6 +393,45 @@ Infisical supports two methods for connecting to AWS.
</Accordion>
</AccordionGroup>
</Tab>
<Tab title="PKI Sync">
<AccordionGroup>
<Accordion title="AWS Certificate Manager">
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": "*"
}
]
}
```
<Note>
- **ListCertificates**: Lists all certificates in the account
- **ImportCertificate**: Imports certificates from Infisical into AWS Certificate Manager
- **ExportCertificate**: Exports certificates for synchronization
- **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
</Note>
</Accordion>
</AccordionGroup>
</Tab>
</Tabs>
</Step>
<Step title="Obtain Access Key ID and Secret Access Key">

View File

@@ -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 (
<>
<PkiSyncConnectionField
onChange={() => {
setValue("destinationConfig.region", "");
}}
/>
<Controller
name="destinationConfig.region"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="AWS Region"
tooltipText="Select the AWS region where your Certificate Manager certificates should be stored."
>
<Select
value={field.value}
onValueChange={field.onChange}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select an AWS region"
>
{AWS_REGIONS.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</>
);
};

View File

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

View File

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

View File

@@ -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<TPkiSyncForm>({
const formMethods = useForm<TUpdatePkiSyncForm>({
resolver: zodResolver(UpdatePkiSyncFormSchema),
defaultValues: {
...pkiSync,
@@ -33,11 +33,11 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => {
id: pkiSync.connectionId,
name: pkiSync.appConnectionName
}
} as Partial<TPkiSyncForm>,
} as Partial<TUpdatePkiSyncForm>,
reValidateMode: "onChange"
});
const onSubmit = async ({ connection, ...formData }: TPkiSyncForm) => {
const onSubmit = async ({ connection, ...formData }: TUpdatePkiSyncForm) => {
try {
const updatedPkiSync = await updatePkiSync.mutateAsync({
syncId: pkiSync.id,

View File

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

View File

@@ -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<TPkiSyncForm>();
@@ -13,6 +14,8 @@ export const PkiSyncDestinationFields = () => {
switch (destination) {
case PkiSync.AzureKeyVault:
return <AzureKeyVaultPkiSyncFields />;
case PkiSync.AwsCertificateManager:
return <AwsCertificateManagerPkiSyncFields />;
default:
return (
<div className="flex items-center justify-center rounded-md border border-red-500 bg-red-100 p-4 text-red-700">

View File

@@ -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<TPkiSyncForm>();

View File

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

View File

@@ -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<TPkiSyncForm>();

View File

@@ -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<TPkiSyncForm>();

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
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().min(1, "Vault base URL is required").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()
.min(1, "Connection name is required")
.max(255, "Connection name must be less than 255 characters")
})
})
);

View File

@@ -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 = <T extends AnyZodObject | undefined = undefined>(
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<typeof PkiSyncFormSchema>;
const syncOptionsSchema = additionalSyncOptions
? baseSyncOptionsSchema.merge(additionalSyncOptions)
: (baseSyncOptionsSchema as T extends AnyZodObject
? z.ZodObject<z.objectUtil.MergeShapes<typeof baseSyncOptionsSchema.shape, T["shape"]>>
: 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
});
};

View File

@@ -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<typeof PkiSyncFormSchema>;
export type TUpdatePkiSyncForm = z.infer<typeof UpdatePkiSyncFormSchema>;

View File

@@ -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, AppConnection> = {
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault,
[PkiSync.AwsCertificateManager]: AppConnection.AWS
};

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

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

View File

@@ -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,38 @@ 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 };
type TCreatePkiSyncDTOBase = {
name: string;
description?: string;
connectionId: string;
syncOptions: {
canImportCertificates: boolean;
canRemoveCertificates: boolean;
certificateNamePrefix?: string;
certificateNameSchema?: string;
};
isAutoSyncEnabled: boolean;
subscriberId?: string;
projectId: string;
};
export type TCreatePkiSyncDTO = TCreatePkiSyncDTOBase & {
destination: PkiSync;
destinationConfig: {
vaultBaseUrl?: string;
region?: string;
};
};
export type TUpdatePkiSyncDTO = Partial<Omit<TCreatePkiSyncDTO, "projectId">> & {
syncId: string;
projectId: string;
destination: PkiSync;
};
export type TDeletePkiSyncDTO = {
@@ -63,4 +74,6 @@ export type TTriggerPkiSyncRemoveCertificatesDTO = {
projectId: string;
};
export * from "./aws-certificate-manager-sync";
export * from "./azure-key-vault-sync";
export * from "./common";