mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-17 10:45:58 +00:00
Merge branch 'Infisical:main' into main
This commit is contained in:
@@ -215,6 +215,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => {
|
||||
return false;
|
||||
case ProjectType.SSH:
|
||||
return false;
|
||||
case ProjectType.PAM:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ export const constructGroupOrgMembershipRoleMappings = async ({
|
||||
let customRolesMap: Map<string, TOrgRoles> = new Map();
|
||||
if (customRoleSlugs.length > 0) {
|
||||
const customRoles = await orgRoleDAL.find({
|
||||
orgId,
|
||||
$in: {
|
||||
slug: customRoleSlugs
|
||||
}
|
||||
|
||||
+52
@@ -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;
|
||||
+634
@@ -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
|
||||
};
|
||||
};
|
||||
+84
@@ -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)
|
||||
});
|
||||
+58
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum PkiSync {
|
||||
AzureKeyVault = "azure-key-vault"
|
||||
AzureKeyVault = "azure-key-vault",
|
||||
AwsCertificateManager = "aws-certificate-manager"
|
||||
}
|
||||
|
||||
export enum PkiSyncStatus {
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -204,5 +204,19 @@ export const secretSyncDALFactory = (
|
||||
}
|
||||
};
|
||||
|
||||
return { ...secretSyncOrm, findById, findOne, find, create, updateById };
|
||||
const findByDestinationAndOrgId = async (destination: string, orgId: string, tx?: Knex) => {
|
||||
try {
|
||||
const response = await (tx || db.replicaNode())(TableName.SecretSync)
|
||||
.join(TableName.Project, `${TableName.SecretSync}.projectId`, `${TableName.Project}.id`)
|
||||
.where(`${TableName.SecretSync}.destination`, destination)
|
||||
.where(`${TableName.Project}.orgId`, orgId)
|
||||
.select(selectAllTableCols(TableName.SecretSync));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find By Destination And Org ID - Secret Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...secretSyncOrm, findById, findOne, find, create, updateById, findByDestinationAndOrgId };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { DestinationDuplicateCheckFn } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
|
||||
[SecretSync.AWSParameterStore]: "AWS Parameter Store",
|
||||
@@ -99,3 +100,104 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
|
||||
[SecretSync.Netlify]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.Bitbucket]: SecretSyncPlanType.Regular
|
||||
};
|
||||
|
||||
export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
|
||||
[SecretSync.AWSParameterStore]: [],
|
||||
[SecretSync.AWSSecretsManager]: ["mappingBehavior", "secretName"],
|
||||
[SecretSync.GitHub]: [],
|
||||
[SecretSync.GCPSecretManager]: [],
|
||||
[SecretSync.AzureKeyVault]: [],
|
||||
[SecretSync.AzureAppConfiguration]: ["label"],
|
||||
[SecretSync.AzureDevOps]: ["devopsProjectName"],
|
||||
[SecretSync.Databricks]: [],
|
||||
[SecretSync.Humanitec]: [],
|
||||
[SecretSync.TerraformCloud]: ["variableSetName", "workspaceName"],
|
||||
[SecretSync.Camunda]: [],
|
||||
[SecretSync.Vercel]: ["appName"],
|
||||
[SecretSync.Windmill]: [],
|
||||
[SecretSync.HCVault]: [],
|
||||
[SecretSync.TeamCity]: [],
|
||||
[SecretSync.OCIVault]: [],
|
||||
[SecretSync.OnePass]: ["valueLabel"],
|
||||
[SecretSync.Heroku]: ["appName"],
|
||||
[SecretSync.Render]: [],
|
||||
[SecretSync.Flyio]: [],
|
||||
[SecretSync.GitLab]: [
|
||||
"projectName",
|
||||
"shouldProtectSecrets",
|
||||
"shouldMaskSecrets",
|
||||
"shouldHideSecrets",
|
||||
"targetEnvironment",
|
||||
"groupName",
|
||||
"groupId",
|
||||
"projectId"
|
||||
],
|
||||
[SecretSync.CloudflarePages]: [],
|
||||
[SecretSync.CloudflareWorkers]: [],
|
||||
[SecretSync.Supabase]: ["projectName"],
|
||||
[SecretSync.Zabbix]: ["hostName", "macroType"],
|
||||
[SecretSync.Railway]: ["projectName", "environmentName", "serviceName"],
|
||||
[SecretSync.Checkly]: ["groupName", "accountName"],
|
||||
[SecretSync.DigitalOceanAppPlatform]: ["appName"],
|
||||
[SecretSync.Netlify]: ["accountName", "siteName"],
|
||||
[SecretSync.Bitbucket]: []
|
||||
};
|
||||
|
||||
const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true;
|
||||
|
||||
export const DESTINATION_DUPLICATE_CHECK_MAP: Record<SecretSync, DestinationDuplicateCheckFn> = {
|
||||
[SecretSync.AWSParameterStore]: defaultDuplicateCheck,
|
||||
[SecretSync.AWSSecretsManager]: defaultDuplicateCheck,
|
||||
[SecretSync.GitHub]: defaultDuplicateCheck,
|
||||
[SecretSync.GCPSecretManager]: defaultDuplicateCheck,
|
||||
[SecretSync.AzureKeyVault]: defaultDuplicateCheck,
|
||||
[SecretSync.AzureAppConfiguration]: defaultDuplicateCheck,
|
||||
[SecretSync.AzureDevOps]: defaultDuplicateCheck,
|
||||
[SecretSync.Databricks]: defaultDuplicateCheck,
|
||||
[SecretSync.Humanitec]: defaultDuplicateCheck,
|
||||
[SecretSync.TerraformCloud]: defaultDuplicateCheck,
|
||||
[SecretSync.Camunda]: defaultDuplicateCheck,
|
||||
[SecretSync.Vercel]: defaultDuplicateCheck,
|
||||
[SecretSync.Windmill]: defaultDuplicateCheck,
|
||||
[SecretSync.HCVault]: defaultDuplicateCheck,
|
||||
[SecretSync.TeamCity]: defaultDuplicateCheck,
|
||||
[SecretSync.OCIVault]: defaultDuplicateCheck,
|
||||
[SecretSync.OnePass]: defaultDuplicateCheck,
|
||||
[SecretSync.Heroku]: defaultDuplicateCheck,
|
||||
[SecretSync.Render]: defaultDuplicateCheck,
|
||||
[SecretSync.Flyio]: defaultDuplicateCheck,
|
||||
[SecretSync.GitLab]: (existingConfig, newConfig) => {
|
||||
const existingTargetEnv = existingConfig.targetEnvironment as string | undefined;
|
||||
const newTargetEnv = newConfig.targetEnvironment as string | undefined;
|
||||
|
||||
const wildcardValues = ["*", ""];
|
||||
|
||||
if (
|
||||
(newConfig.scope as string) === "group"
|
||||
? existingConfig.groupId !== newConfig.groupId
|
||||
: existingConfig.projectId !== newConfig.projectId
|
||||
)
|
||||
return false;
|
||||
|
||||
// If either has wildcard, it conflicts with any targetEnvironment
|
||||
if (
|
||||
!existingTargetEnv ||
|
||||
!newTargetEnv ||
|
||||
wildcardValues.includes(existingTargetEnv) ||
|
||||
wildcardValues.includes(newTargetEnv)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return existingTargetEnv === newTargetEnv;
|
||||
},
|
||||
[SecretSync.CloudflarePages]: defaultDuplicateCheck,
|
||||
[SecretSync.CloudflareWorkers]: defaultDuplicateCheck,
|
||||
[SecretSync.Supabase]: defaultDuplicateCheck,
|
||||
[SecretSync.Zabbix]: defaultDuplicateCheck,
|
||||
[SecretSync.Railway]: defaultDuplicateCheck,
|
||||
[SecretSync.Checkly]: defaultDuplicateCheck,
|
||||
[SecretSync.DigitalOceanAppPlatform]: defaultDuplicateCheck,
|
||||
[SecretSync.Netlify]: defaultDuplicateCheck,
|
||||
[SecretSync.Bitbucket]: defaultDuplicateCheck
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { DatabaseErrorCode } from "@app/lib/error-codes";
|
||||
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||
import { deepEqualSkipFields } from "@app/lib/fn/object";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
@@ -20,6 +21,7 @@ import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns";
|
||||
import {
|
||||
SecretSyncStatus,
|
||||
TCheckDuplicateDestinationDTO,
|
||||
TCreateSecretSyncDTO,
|
||||
TDeleteSecretSyncDTO,
|
||||
TFindSecretSyncByIdDTO,
|
||||
@@ -35,7 +37,12 @@ import {
|
||||
|
||||
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
|
||||
import { TSecretSyncDALFactory } from "./secret-sync-dal";
|
||||
import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps";
|
||||
import {
|
||||
DESTINATION_DUPLICATE_CHECK_MAP,
|
||||
SECRET_SYNC_CONNECTION_MAP,
|
||||
SECRET_SYNC_NAME_MAP,
|
||||
SECRET_SYNC_SKIP_FIELDS_MAP
|
||||
} from "./secret-sync-maps";
|
||||
import { TSecretSyncQueueFactory } from "./secret-sync-queue";
|
||||
|
||||
type TSecretSyncServiceFactoryDep = {
|
||||
@@ -696,6 +703,61 @@ export const secretSyncServiceFactory = ({
|
||||
return updatedSecretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const checkDuplicateDestination = async (
|
||||
{ destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination];
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Read,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (!destinationConfig || Object.keys(destinationConfig).length === 0) {
|
||||
return { hasDuplicate: false, duplicateProjectId: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId);
|
||||
|
||||
const duplicates = existingSyncs.filter((sync) => {
|
||||
if (sync.id === excludeSyncId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields);
|
||||
if (baseFieldsMatch) {
|
||||
return DESTINATION_DUPLICATE_CHECK_MAP[destination](
|
||||
sync.destinationConfig as Record<string, unknown>,
|
||||
destinationConfig
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const hasDuplicate = duplicates.length > 0;
|
||||
return {
|
||||
hasDuplicate,
|
||||
duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
return { hasDuplicate: false, duplicateProjectId: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
listSecretSyncOptions,
|
||||
listSecretSyncsByProjectId,
|
||||
@@ -707,6 +769,7 @@ export const secretSyncServiceFactory = ({
|
||||
deleteSecretSync,
|
||||
triggerSecretSyncSyncSecretsById,
|
||||
triggerSecretSyncImportSecretsById,
|
||||
triggerSecretSyncRemoveSecretsById
|
||||
triggerSecretSyncRemoveSecretsById,
|
||||
checkDuplicateDestination
|
||||
};
|
||||
};
|
||||
|
||||
@@ -324,6 +324,13 @@ export type TDeleteSecretSyncDTO = {
|
||||
removeSecrets: boolean;
|
||||
};
|
||||
|
||||
export type TCheckDuplicateDestinationDTO = {
|
||||
destination: SecretSync;
|
||||
destinationConfig: Record<string, unknown>;
|
||||
excludeSyncId?: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export enum SecretSyncStatus {
|
||||
Pending = "pending",
|
||||
Running = "running",
|
||||
@@ -408,3 +415,8 @@ export type TSecretMap = Record<
|
||||
secretMetadata?: ResourceMetadataDTO;
|
||||
}
|
||||
>;
|
||||
|
||||
export type DestinationDuplicateCheckFn = (
|
||||
existingConfig: Record<string, unknown>,
|
||||
newConfig: Record<string, unknown>
|
||||
) => boolean;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Heading, Section, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
|
||||
|
||||
interface AccountDeletionConfirmationTemplateProps
|
||||
extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const AccountDeletionConfirmationTemplate = ({ email, siteUrl }: AccountDeletionConfirmationTemplateProps) => {
|
||||
return (
|
||||
<BaseEmailWrapper
|
||||
title="Your Infisical Account Has Been Deleted"
|
||||
preview="Confirmation that your account and associated data have been deleted."
|
||||
siteUrl={siteUrl}
|
||||
>
|
||||
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
|
||||
Account Deleted
|
||||
</Heading>
|
||||
<Section className="px-[24px] mt-[20px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
|
||||
<Text className="text-[14px]">
|
||||
This email confirms that your Infisical account <strong>{email}</strong> has been deleted, including all
|
||||
associated data.
|
||||
</Text>
|
||||
</Section>
|
||||
</BaseEmailWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountDeletionConfirmationTemplate;
|
||||
|
||||
AccountDeletionConfirmationTemplate.PreviewProps = {
|
||||
email: "[email protected]",
|
||||
siteUrl: "https://infisical.com"
|
||||
} as AccountDeletionConfirmationTemplateProps;
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./AccessApprovalRequestTemplate";
|
||||
export * from "./AccessApprovalRequestUpdatedTemplate";
|
||||
export * from "./AccountDeletionConfirmationTemplate";
|
||||
export * from "./EmailMfaTemplate";
|
||||
export * from "./EmailVerificationTemplate";
|
||||
export * from "./ExternalImportFailedTemplate";
|
||||
|
||||
@@ -9,6 +9,7 @@ import { logger } from "@app/lib/logger";
|
||||
import {
|
||||
AccessApprovalRequestTemplate,
|
||||
AccessApprovalRequestUpdatedTemplate,
|
||||
AccountDeletionConfirmationTemplate,
|
||||
EmailMfaTemplate,
|
||||
EmailVerificationTemplate,
|
||||
ExternalImportFailedTemplate,
|
||||
@@ -83,7 +84,8 @@ export enum SmtpTemplates {
|
||||
OrgAdminBreakglassAccess = "orgAdminBreakglassAccess",
|
||||
ServiceTokenExpired = "serviceTokenExpired",
|
||||
SecretScanningV2ScanFailed = "secretScanningV2ScanFailed",
|
||||
SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected"
|
||||
SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected",
|
||||
AccountDeletionConfirmation = "accountDeletionConfirmation"
|
||||
}
|
||||
|
||||
export enum SmtpHost {
|
||||
@@ -128,7 +130,8 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
|
||||
[SmtpTemplates.SetupPassword]: PasswordSetupTemplate,
|
||||
[SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate,
|
||||
[SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate,
|
||||
[SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate
|
||||
[SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate,
|
||||
[SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate
|
||||
};
|
||||
|
||||
export const smtpServiceFactory = (cfg: TSmtpConfig) => {
|
||||
|
||||
@@ -347,6 +347,23 @@ export const userServiceFactory = ({
|
||||
|
||||
const deleteUser = async (userId: string) => {
|
||||
const user = await userDAL.deleteById(userId);
|
||||
|
||||
try {
|
||||
if (user?.email) {
|
||||
// Send email to user to confirm account deletion
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.AccountDeletionConfirmation,
|
||||
subjectLine: "Your Infisical account has been deleted",
|
||||
recipients: [user.email],
|
||||
substitutions: {
|
||||
email: user.email
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to send account deletion confirmation email to ${user.email}`);
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user