PKI Syncs improvements

This commit is contained in:
Carlos Monastyrski
2025-11-03 08:25:27 -03:00
parent 04767a819e
commit cc52c2c713
30 changed files with 835 additions and 303 deletions
@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.CertificateSync, "externalIdentifier"))) {
await knex.schema.alterTable(TableName.CertificateSync, (t) => {
t.text("externalIdentifier").nullable();
t.index("externalIdentifier");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.CertificateSync, "externalIdentifier")) {
await knex.schema.alterTable(TableName.CertificateSync, (t) => {
t.dropIndex("externalIdentifier");
t.dropColumn("externalIdentifier");
});
}
}
@@ -14,6 +14,7 @@ export const CertificateSyncsSchema = z.object({
syncStatus: z.string().default("pending").nullable().optional(), syncStatus: z.string().default("pending").nullable().optional(),
lastSyncMessage: z.string().nullable().optional(), lastSyncMessage: z.string().nullable().optional(),
lastSyncedAt: z.date().nullable().optional(), lastSyncedAt: z.date().nullable().optional(),
externalIdentifier: z.string().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date()
}); });
@@ -426,6 +426,7 @@ export enum EventType {
SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets", SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets",
GET_PKI_SYNCS = "get-pki-syncs", GET_PKI_SYNCS = "get-pki-syncs",
GET_PKI_SYNC = "get-pki-sync", GET_PKI_SYNC = "get-pki-sync",
GET_PKI_SYNC_CERTIFICATES = "get-pki-sync-certificates",
CREATE_PKI_SYNC = "create-pki-sync", CREATE_PKI_SYNC = "create-pki-sync",
UPDATE_PKI_SYNC = "update-pki-sync", UPDATE_PKI_SYNC = "update-pki-sync",
DELETE_PKI_SYNC = "delete-pki-sync", DELETE_PKI_SYNC = "delete-pki-sync",
@@ -3161,6 +3162,16 @@ interface GetPkiSyncEvent {
}; };
} }
interface GetPkiSyncCertificatesEvent {
type: EventType.GET_PKI_SYNC_CERTIFICATES;
metadata: {
syncId: string;
count: number;
certificateIds: string[];
destination: string;
};
}
interface CreatePkiSyncEvent { interface CreatePkiSyncEvent {
type: EventType.CREATE_PKI_SYNC; type: EventType.CREATE_PKI_SYNC;
metadata: { metadata: {
@@ -4329,6 +4340,7 @@ export type Event =
| SecretSyncRemoveSecretsEvent | SecretSyncRemoveSecretsEvent
| GetPkiSyncsEvent | GetPkiSyncsEvent
| GetPkiSyncEvent | GetPkiSyncEvent
| GetPkiSyncCertificatesEvent
| CreatePkiSyncEvent | CreatePkiSyncEvent
| UpdatePkiSyncEvent | UpdatePkiSyncEvent
| DeletePkiSyncEvent | DeletePkiSyncEvent
+3 -2
View File
@@ -2193,12 +2193,13 @@ export const registerRoutes = async (
const pkiSyncService = pkiSyncServiceFactory({ const pkiSyncService = pkiSyncServiceFactory({
pkiSyncDAL, pkiSyncDAL,
certificateDAL,
certificateSyncDAL,
pkiSubscriberDAL, pkiSubscriberDAL,
appConnectionService, appConnectionService,
permissionService, permissionService,
licenseService, licenseService,
pkiSyncQueue, pkiSyncQueue
certificateSyncDAL
}); });
const pkiTemplateService = pkiTemplatesServiceFactory({ const pkiTemplateService = pkiTemplatesServiceFactory({
@@ -93,8 +93,8 @@ const PkiSyncCertificateSchema = z.object({
certificateStatus: z.string().optional(), certificateStatus: z.string().optional(),
certificateNotBefore: z.date().optional(), certificateNotBefore: z.date().optional(),
certificateNotAfter: z.date().optional(), certificateNotAfter: z.date().optional(),
certificateRenewBeforeDays: z.number().optional(), certificateRenewBeforeDays: z.number().nullish(),
certificateRenewalError: z.string().optional(), certificateRenewalError: z.string().nullish(),
pkiSyncName: z.string().optional(), pkiSyncName: z.string().optional(),
pkiSyncDestination: z.string().optional() pkiSyncDestination: z.string().optional()
}); });
@@ -233,26 +233,26 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => {
const { pkiSyncId } = req.params; const { pkiSyncId } = req.params;
const { offset, limit } = req.query; const { offset, limit } = req.query;
const result = await server.services.pkiSync.listPkiSyncCertificates( const { certificates, totalCount, pkiSyncInfo } = await server.services.pkiSync.listPkiSyncCertificates(
{ pkiSyncId, offset, limit }, { pkiSyncId, offset, limit },
req.permission req.permission
); );
const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission);
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: pkiSync.projectId, projectId: pkiSyncInfo.projectId,
event: { event: {
type: EventType.GET_PKI_SYNC, type: EventType.GET_PKI_SYNC_CERTIFICATES,
metadata: { metadata: {
syncId: pkiSyncId, syncId: pkiSyncId,
destination: pkiSync.destination destination: pkiSyncInfo.destination,
count: certificates.length,
certificateIds: certificates.map((c) => c.certificateId)
} }
} }
}); });
return result; return { certificates, totalCount };
} }
}); });
@@ -294,21 +294,19 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => {
const { pkiSyncId } = req.params; const { pkiSyncId } = req.params;
const { certificateIds } = req.body; const { certificateIds } = req.body;
const addedCertificates = await server.services.pkiSync.addCertificatesToPkiSync( const { addedCertificates, pkiSyncInfo } = await server.services.pkiSync.addCertificatesToPkiSync(
{ pkiSyncId, certificateIds }, { pkiSyncId, certificateIds },
req.permission req.permission
); );
const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission);
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: pkiSync.projectId, projectId: pkiSyncInfo.projectId,
event: { event: {
type: EventType.UPDATE_PKI_SYNC, type: EventType.UPDATE_PKI_SYNC,
metadata: { metadata: {
pkiSyncId, pkiSyncId,
name: pkiSync.name name: pkiSyncInfo.name
} }
} }
}); });
@@ -344,26 +342,24 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => {
const { pkiSyncId } = req.params; const { pkiSyncId } = req.params;
const { certificateIds } = req.body; const { certificateIds } = req.body;
const result = await server.services.pkiSync.removeCertificatesFromPkiSync( const { removedCount, pkiSyncInfo } = await server.services.pkiSync.removeCertificatesFromPkiSync(
{ pkiSyncId, certificateIds }, { pkiSyncId, certificateIds },
req.permission req.permission
); );
const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission);
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: pkiSync.projectId, projectId: pkiSyncInfo.projectId,
event: { event: {
type: EventType.UPDATE_PKI_SYNC, type: EventType.UPDATE_PKI_SYNC,
metadata: { metadata: {
pkiSyncId, pkiSyncId,
name: pkiSync.name name: pkiSyncInfo.name
} }
} }
}); });
return result; return { removedCount };
} }
}); });
}; };
@@ -192,7 +192,7 @@ export const castDbEntryToAzureAdCsCertificateAuthority = (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>> ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
): TAzureAdCsCertificateAuthority & { credentials: unknown } => { ): TAzureAdCsCertificateAuthority & { credentials: unknown } => {
if (!ca.externalCa?.id) { if (!ca.externalCa?.id) {
throw new BadRequestError({ message: "Malformed Azure AD Certificate Service certificate authority" }); throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" });
} }
if (!ca.externalCa.dnsAppConnectionId) { if (!ca.externalCa.dnsAppConnectionId) {
@@ -776,7 +776,7 @@ export const AzureAdCsCertificateAuthorityFns = ({
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) { if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) {
throw new BadRequestError({ message: "CA is not an Azure AD Certificate Service CA" }); throw new BadRequestError({ message: "CA is not an Active Directory Certificate Service CA" });
} }
const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca);
@@ -2,8 +2,8 @@ import { CaCapability, CaType } from "./certificate-authority-enums";
export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record<CaType, string> = { export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record<CaType, string> = {
[CaType.INTERNAL]: "Internal", [CaType.INTERNAL]: "Internal",
[CaType.ACME]: "ACME", [CaType.ACME]: "ACME-compatible CA",
[CaType.AZURE_AD_CS]: "Azure AD Certificate Service" [CaType.AZURE_AD_CS]: "Active Directory Certificate Service"
}; };
export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record<CaType, CaCapability[]> = { export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record<CaType, CaCapability[]> = {
@@ -72,14 +72,15 @@ export const certificateSyncDALFactory = (db: TDbClient) => {
const addCertificates = async ( const addCertificates = async (
pkiSyncId: string, pkiSyncId: string,
certificateIds: string[], certificateData: Array<{ certificateId: string; externalIdentifier?: string }>,
tx?: Knex tx?: Knex
): Promise<TCertificateSyncs[]> => { ): Promise<TCertificateSyncs[]> => {
try { try {
const insertData = certificateIds.map((certificateId) => ({ const insertData = certificateData.map(({ certificateId, externalIdentifier }) => ({
pkiSyncId, pkiSyncId,
certificateId, certificateId,
syncStatus: CertificateSyncStatus.Pending syncStatus: CertificateSyncStatus.Pending,
externalIdentifier
})); }));
const docs = await (tx || db)(TableName.CertificateSync).insert(insertData).returning("*"); const docs = await (tx || db)(TableName.CertificateSync).insert(insertData).returning("*");
@@ -184,7 +185,7 @@ export const certificateSyncDALFactory = (db: TDbClient) => {
certificateStatus?: string; certificateStatus?: string;
certificateNotBefore?: Date; certificateNotBefore?: Date;
certificateNotAfter?: Date; certificateNotAfter?: Date;
certificateRenewBeforeDays?: number; certificateRenewBeforeDays?: number | null;
certificateRenewedByCertificateId?: string; certificateRenewedByCertificateId?: string;
certificateRenewalError?: string; certificateRenewalError?: string;
pkiSyncName?: string; pkiSyncName?: string;
@@ -137,7 +137,7 @@ describe("CertificateV3Service", () => {
certificateSyncDAL: { certificateSyncDAL: {
findPkiSyncIdsByCertificateId: vi.fn().mockResolvedValue([]), findPkiSyncIdsByCertificateId: vi.fn().mockResolvedValue([]),
addCertificates: vi.fn().mockResolvedValue([]), addCertificates: vi.fn().mockResolvedValue([]),
removeCertificates: vi.fn().mockResolvedValue(0) findByPkiSyncAndCertificate: vi.fn().mockResolvedValue(null)
}, },
pkiSyncDAL: { pkiSyncDAL: {
find: vi.fn().mockResolvedValue([]) find: vi.fn().mockResolvedValue([])
@@ -78,7 +78,7 @@ type TCertificateV3ServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
certificateSyncDAL: Pick< certificateSyncDAL: Pick<
TCertificateSyncDALFactory, TCertificateSyncDALFactory,
"findPkiSyncIdsByCertificateId" | "removeCertificates" | "addCertificates" "findPkiSyncIdsByCertificateId" | "addCertificates" | "findByPkiSyncAndCertificate"
>; >;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">; pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">; pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
@@ -92,43 +92,6 @@ const shouldSkipCertificateExport = (certificate: AWS.ACM.CertificateSummary): b
return isAwsIssuedCertificate(certificate); 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 },
alternativeCertNames?: string[]
): boolean => {
if (!existingCert?.arn || !existingCert?.Tags) {
return false;
}
const certNameTag = findInfisicalCertificateTag(existingCert.Tags);
if (!certNameTag || !certNameTag.Value) {
return false;
}
if (certNameTag.Value === certName) {
return true;
}
if (alternativeCertNames && alternativeCertNames.includes(certNameTag.Value)) {
return true;
}
return false;
};
const validateCertificateNameSchema = (schema: string): void => { const validateCertificateNameSchema = (schema: string): void => {
if (!schema.includes("{{certificateId}}")) { if (!schema.includes("{{certificateId}}")) {
throw new Error( throw new Error(
@@ -185,7 +148,15 @@ const generateCertificateName = (certificateName: string, pkiSync: TPkiSyncWithC
type TAwsCertificateManagerPkiSyncFactoryDeps = { type TAwsCertificateManagerPkiSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
certificateSyncDAL: Pick<TCertificateSyncDALFactory, "removeCertificates">; certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
| "removeCertificates"
| "addCertificates"
| "findByPkiSyncAndCertificate"
| "updateSyncStatus"
| "updateById"
| "findByPkiSyncId"
>;
certificateDAL: Pick<TCertificateDALFactory, "findById">; certificateDAL: Pick<TCertificateDALFactory, "findById">;
}; };
@@ -418,16 +389,38 @@ export const awsCertificateManagerPkiSyncFactory = ({
>; >;
} = await $getAwsAcmCertificates(acm, pkiSync.id); } = await $getAwsAcmCertificates(acm, pkiSync.id);
const acmCertificatesByArn = new Map<string, (typeof acmCertificates)[string]>();
Object.values(acmCertificates).forEach((acmCert) => {
if (acmCert.arn) {
acmCertificatesByArn.set(acmCert.arn, acmCert);
}
});
const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
type SyncRecord = (typeof existingSyncRecords)[0];
const syncRecordsByCertId = new Map<string, SyncRecord>();
const syncRecordsByExternalId = new Map<string, SyncRecord>();
existingSyncRecords.forEach((record: SyncRecord) => {
if (record.certificateId) {
syncRecordsByCertId.set(record.certificateId, record);
}
if (record.externalIdentifier) {
syncRecordsByExternalId.set(record.externalIdentifier, record);
}
});
const setCertificates: CertificateImportRequest[] = []; const setCertificates: CertificateImportRequest[] = [];
const validationErrors: Array<{ name: string; error: string }> = []; const validationErrors: Array<{ name: string; error: string }> = [];
const activeCertificateNames = Object.keys(certificateMap);
const syncOptions = pkiSync.syncOptions as { preserveArn?: boolean; canRemoveCertificates?: boolean } | undefined; const syncOptions = pkiSync.syncOptions as { preserveArn?: boolean; canRemoveCertificates?: boolean } | undefined;
const preserveArn = syncOptions?.preserveArn ?? true; const preserveArn = syncOptions?.preserveArn ?? true;
const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true; const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true;
const activeExternalIdentifiers = new Set<string>();
for (const [certName, certData] of Object.entries(certificateMap)) { for (const [certName, certData] of Object.entries(certificateMap)) {
const { cert, privateKey, certificateChain, alternativeNames, certificateId } = certData; const { cert, privateKey, certificateChain, certificateId } = certData;
try { try {
validateCertificateContent(cert, privateKey); validateCertificateContent(cert, privateKey);
@@ -441,7 +434,7 @@ export const awsCertificateManagerPkiSyncFactory = ({
continue; continue;
} }
if (preserveArn && certificateId) { if (preserveArn && certificateId && typeof certificateId === "string") {
const certificate = await certificateDAL.findById(certificateId); const certificate = await certificateDAL.findById(certificateId);
if (certificate?.renewedByCertificateId) { if (certificate?.renewedByCertificateId) {
// eslint-disable-next-line no-continue // eslint-disable-next-line no-continue
@@ -451,60 +444,120 @@ export const awsCertificateManagerPkiSyncFactory = ({
const certificateName = generateCertificateName(certName, pkiSync); const certificateName = generateCertificateName(certName, pkiSync);
let existingArn: string | undefined; let targetArn: string | undefined;
let shouldCreateNew = false;
const existingCert = Object.values(acmCertificates).find((acmCert) => { if (!certificateId || typeof certificateId !== "string") {
return validateCertificateIdentification(certName, acmCert, alternativeNames); shouldCreateNew = true;
}); } else {
const currentCertificate = await certificateDAL.findById(certificateId);
const isRenewal = !!currentCertificate?.renewedFromCertificateId;
if (existingCert?.arn && preserveArn) { if (isRenewal) {
// When preserveArn is true, reuse the existing ARN const currentSyncRecord = syncRecordsByCertId.get(certificateId);
existingArn = existingCert.arn; const oldCertificateId = currentCertificate.renewedFromCertificateId;
const oldSyncRecord = oldCertificateId ? syncRecordsByCertId.get(oldCertificateId) : undefined;
if (currentSyncRecord?.externalIdentifier) {
const existingAcmCert = acmCertificatesByArn.get(currentSyncRecord.externalIdentifier);
if (existingAcmCert) {
if (!preserveArn && oldSyncRecord?.externalIdentifier === currentSyncRecord.externalIdentifier) {
shouldCreateNew = true;
} else if (preserveArn && oldSyncRecord?.externalIdentifier === currentSyncRecord.externalIdentifier) {
targetArn = currentSyncRecord.externalIdentifier;
shouldCreateNew = true;
activeExternalIdentifiers.add(targetArn);
if (oldCertificateId && oldSyncRecord) {
await certificateSyncDAL.removeCertificates(pkiSync.id, [oldCertificateId]);
}
} else {
targetArn = currentSyncRecord.externalIdentifier;
activeExternalIdentifiers.add(targetArn);
shouldCreateNew = false;
}
} else {
shouldCreateNew = true;
}
} else if (preserveArn && oldSyncRecord?.externalIdentifier) {
const existingAcmCert = acmCertificatesByArn.get(oldSyncRecord.externalIdentifier);
if (existingAcmCert) {
targetArn = oldSyncRecord.externalIdentifier;
shouldCreateNew = true;
activeExternalIdentifiers.add(targetArn);
if (oldCertificateId) {
await certificateSyncDAL.removeCertificates(pkiSync.id, [oldCertificateId]);
}
} else {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
} else {
const existingSyncRecord = syncRecordsByCertId.get(certificateId);
if (existingSyncRecord?.externalIdentifier) {
const existingAcmCert = acmCertificatesByArn.get(existingSyncRecord.externalIdentifier);
if (existingAcmCert) {
targetArn = existingSyncRecord.externalIdentifier;
activeExternalIdentifiers.add(targetArn);
shouldCreateNew = false;
} else {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
}
} }
setCertificates.push({ if (shouldCreateNew) {
key: certName, setCertificates.push({
name: certificateName, key: certName,
cert, name: certificateName,
privateKey, cert,
certificateChain, privateKey,
existingArn, certificateChain,
certificateId existingArn: targetArn,
}); certificateId: certificateId as string
});
}
if (targetArn) {
activeExternalIdentifiers.add(targetArn);
}
} }
const certificatesToRemove = canRemoveCertificates const certificatesToRemove: string[] = [];
? Object.values(acmCertificates)
.filter((acmCert) => { if (canRemoveCertificates) {
if (!acmCert.arn || !acmCert.Tags) { existingSyncRecords.forEach((syncRecord) => {
return false; if (syncRecord.externalIdentifier && !activeExternalIdentifiers.has(syncRecord.externalIdentifier)) {
const acmCert = acmCertificatesByArn.get(syncRecord.externalIdentifier);
if (acmCert?.arn) {
certificatesToRemove.push(acmCert.arn);
}
}
});
Object.values(acmCertificates).forEach((acmCert) => {
if (acmCert.arn && acmCert.Tags) {
const hasInfisicalTag = acmCert.Tags.some((tag) => tag.Key === INFISICAL_CERTIFICATE_TAG && tag.Value);
if (hasInfisicalTag) {
const isTrackedInSyncRecords = existingSyncRecords.some(
(record) => record.externalIdentifier === acmCert.arn
);
const isInActiveSet = activeExternalIdentifiers.has(acmCert.arn);
if (!isTrackedInSyncRecords && !isInActiveSet && !certificatesToRemove.includes(acmCert.arn)) {
certificatesToRemove.push(acmCert.arn);
} }
}
const certNameTag = findInfisicalCertificateTag(acmCert.Tags); }
if (!certNameTag || !certNameTag.Value) { });
return false; }
}
const isActive = activeCertificateNames.some((activeCertName) => {
const certData = certificateMap[activeCertName];
if (!certData) return false;
return validateCertificateIdentification(activeCertName, acmCert, certData.alternativeNames);
});
if (!isActive) {
return true;
}
if (!preserveArn && isActive) {
return true;
}
return false;
})
.map((acmCert) => acmCert.arn!)
.filter((arn) => arn)
: [];
const uploadResults = await executeWithConcurrencyLimit( const uploadResults = await executeWithConcurrencyLimit(
setCertificates, setCertificates,
@@ -569,10 +622,21 @@ export const awsCertificateManagerPkiSyncFactory = ({
} }
} }
if (existingArn && preserveArn && certificateId) { if (response.CertificateArn && certificateId) {
const currentCertificate = await certificateDAL.findById(certificateId); const existingCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId);
if (currentCertificate?.renewedFromCertificateId) { if (existingCertSync) {
await certificateSyncDAL.removeCertificates(pkiSync.id, [currentCertificate.renewedFromCertificateId]); await certificateSyncDAL.updateById(existingCertSync.id, {
externalIdentifier: response.CertificateArn,
syncStatus: CertificateSyncStatus.Succeeded,
lastSyncedAt: new Date()
});
} else {
await certificateSyncDAL.addCertificates(pkiSync.id, [
{
certificateId,
externalIdentifier: response.CertificateArn
}
]);
} }
} }
@@ -682,29 +746,33 @@ export const awsCertificateManagerPkiSyncFactory = ({
kmsService kmsService
); );
const { const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
acmCertificates
}: {
acmCertificates: Record<
string,
{ cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList }
>;
} = await $getAwsAcmCertificates(acm, pkiSync.id);
const certificateArnsToRemove: string[] = []; const certificateArnsToRemove: string[] = [];
const certificateIdToArnMap = new Map<string, string>();
for (const certName of certificateNames) { for (const certName of certificateNames) {
const matchingCerts = Object.values(acmCertificates).filter((acmCert) => const certificateData = deps?.certificateMap?.[certName];
validateCertificateIdentification(certName, acmCert) if (certificateData?.certificateId) {
); const { certificateId } = certificateData;
for (const acmCert of matchingCerts) { if (typeof certificateId === "string") {
if (acmCert.arn) { const syncRecord = existingSyncRecords.find((record) => record.certificateId === certificateId);
certificateArnsToRemove.push(acmCert.arn);
if (syncRecord?.externalIdentifier) {
certificateArnsToRemove.push(syncRecord.externalIdentifier);
certificateIdToArnMap.set(certificateId, syncRecord.externalIdentifier);
}
} }
} }
} }
if (certificateArnsToRemove.length === 0) {
return {
removed: 0,
failed: 0,
skipped: certificateNames.length
};
}
const results = await executeWithConcurrencyLimit( const results = await executeWithConcurrencyLimit(
certificateArnsToRemove, certificateArnsToRemove,
async (certificateArn) => async (certificateArn) =>
@@ -714,43 +782,38 @@ export const awsCertificateManagerPkiSyncFactory = ({
const failedRemovals = results.filter((result) => result.status === "rejected"); const failedRemovals = results.filter((result) => result.status === "rejected");
if (failedRemovals.length > 0 && deps?.certificateSyncDAL && deps?.certificateMap) { if (failedRemovals.length > 0 && deps?.certificateSyncDAL) {
const certificateNameToArnMap = new Map<string, string>();
for (const certName of certificateNames) {
const matchingCerts = Object.values(acmCertificates).filter((acmCert) =>
validateCertificateIdentification(certName, acmCert)
);
for (const acmCert of matchingCerts) {
if (acmCert.arn) {
certificateNameToArnMap.set(acmCert.arn, certName);
}
}
}
for (const failure of failedRemovals) { for (const failure of failedRemovals) {
if (failure.status === "rejected") { if (failure.status === "rejected") {
const failedArn = certificateArnsToRemove[results.indexOf(failure)]; const failedArn = certificateArnsToRemove[results.indexOf(failure)];
const certificateName = certificateNameToArnMap.get(failedArn); const certificateId = Array.from(certificateIdToArnMap.entries()).find(([, arn]) => arn === failedArn)?.[0];
if (certificateName && deps.certificateMap[certificateName]?.certificateId) {
const { certificateId } = deps.certificateMap[certificateName]; if (certificateId) {
if (certificateId) { const errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error";
const errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error"; await deps.certificateSyncDAL.updateSyncStatus(
try { pkiSync.id,
await deps.certificateSyncDAL.updateSyncStatus( certificateId,
pkiSync.id, CertificateSyncStatus.Failed,
certificateId, `Failed to remove from AWS: ${errorMessage}`
CertificateSyncStatus.Failed, );
`Failed to remove from AWS: ${errorMessage}`
);
} catch (updateError) {
logger.warn(`Failed to update sync status for certificate ${certificateId}:`, String(updateError));
}
}
} }
} }
} }
} }
const successfulRemovals = results.filter((result) => result.status === "fulfilled");
if (successfulRemovals.length > 0) {
const successfulArns = new Set(successfulRemovals.map((_, index) => certificateArnsToRemove[index]));
const certificateIdsToRemove = Array.from(certificateIdToArnMap.entries())
.filter(([, arn]) => successfulArns.has(arn))
.map(([certificateId]) => certificateId);
if (certificateIdsToRemove.length > 0) {
await certificateSyncDAL.removeCertificates(pkiSync.id, certificateIdsToRemove);
}
}
if (failedRemovals.length > 0) { if (failedRemovals.length > 0) {
const failedReasons = failedRemovals.map((failure) => { const failedReasons = failedRemovals.map((failure) => {
if (failure.status === "rejected") { if (failure.status === "rejected") {
@@ -6,6 +6,7 @@ import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault"; import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums";
import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue";
@@ -50,6 +51,16 @@ const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyn
type TAzureKeyVaultPkiSyncFactoryDeps = { type TAzureKeyVaultPkiSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
| "removeCertificates"
| "addCertificates"
| "findByPkiSyncAndCertificate"
| "updateById"
| "findByPkiSyncId"
| "updateSyncStatus"
>;
certificateDAL: Pick<TCertificateDALFactory, "findById">;
}; };
const parseCertificateX509Props = (certPem: string) => { const parseCertificateX509Props = (certPem: string) => {
@@ -192,7 +203,12 @@ const parseCertificateKeyProps = (certPem: string) => {
} }
}; };
export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TAzureKeyVaultPkiSyncFactoryDeps) => { export const azureKeyVaultPkiSyncFactory = ({
kmsService,
appConnectionDAL,
certificateSyncDAL,
certificateDAL
}: TAzureKeyVaultPkiSyncFactoryDeps) => {
const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => { const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => {
const paginateAzureKeyVaultCertificates = async () => { const paginateAzureKeyVaultCertificates = async () => {
let result: GetAzureKeyVaultCertificate[] = []; let result: GetAzureKeyVaultCertificate[] = [];
@@ -329,6 +345,20 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
pkiSync.id pkiSync.id
); );
const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
type SyncRecord = (typeof existingSyncRecords)[0];
const syncRecordsByCertId = new Map<string, SyncRecord>();
const syncRecordsByExternalId = new Map<string, SyncRecord>();
existingSyncRecords.forEach((record: SyncRecord) => {
if (record.certificateId) {
syncRecordsByCertId.set(record.certificateId, record);
}
if (record.externalIdentifier) {
syncRecordsByExternalId.set(record.externalIdentifier, record);
}
});
const setCertificates: { const setCertificates: {
key: string; key: string;
cert: string; cert: string;
@@ -338,48 +368,104 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
}[] = []; }[] = [];
const syncOptions = pkiSync.syncOptions as const syncOptions = pkiSync.syncOptions as
| { certificateNameSchema?: string; canRemoveCertificates?: boolean } | { certificateNameSchema?: string; canRemoveCertificates?: boolean; preserveVersion?: boolean }
| undefined; | undefined;
const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true; const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true;
const preserveVersion = syncOptions?.preserveVersion ?? true;
// Track which certificates should exist in Azure Key Vault const activeExternalIdentifiers = new Set<string>();
const activeCertificateNames = Object.keys(certificateMap);
// Iterate through certificates to sync to Azure Key Vault // Iterate through certificates to sync to Azure Key Vault
Object.entries(certificateMap).forEach(([certName, { cert, privateKey, certificateChain, certificateId }]) => { for (const [certName, { cert, privateKey, certificateChain, certificateId }] of Object.entries(certificateMap)) {
if (disabledAzureKeyVaultCertificateKeys.includes(certName)) { if (disabledAzureKeyVaultCertificateKeys.includes(certName)) {
return; // eslint-disable-next-line no-continue
continue;
} }
const existingCert = vaultCertificates[certName]; if (preserveVersion && typeof certificateId === "string") {
const shouldUpdateCert = !existingCert || existingCert.cert !== cert; const certificate = await certificateDAL.findById(certificateId);
if (certificate?.renewedByCertificateId) {
// eslint-disable-next-line no-continue
continue;
}
}
if (shouldUpdateCert) { let targetCertName = certName;
let shouldCreateNew = false;
if (typeof certificateId === "string") {
const existingSyncRecord = syncRecordsByCertId.get(certificateId);
if (existingSyncRecord?.externalIdentifier) {
const existingAzureCert = vaultCertificates[existingSyncRecord.externalIdentifier];
if (existingAzureCert && preserveVersion) {
targetCertName = existingSyncRecord.externalIdentifier;
activeExternalIdentifiers.add(targetCertName);
const shouldUpdateCert = existingAzureCert.cert !== cert;
if (shouldUpdateCert) {
shouldCreateNew = true;
}
} else if (!existingAzureCert) {
shouldCreateNew = true;
} else if (!preserveVersion) {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
if (shouldCreateNew || !vaultCertificates[targetCertName] || vaultCertificates[targetCertName].cert !== cert) {
setCertificates.push({ setCertificates.push({
key: certName, key: targetCertName,
cert, cert,
privateKey, privateKey,
certificateChain, certificateChain,
certificateId certificateId
}); });
} }
});
// Identify expired/removed certificates that need to be cleaned up from Azure Key Vault if (targetCertName) {
// Only remove certificates that were managed by Infisical (match naming schema) activeExternalIdentifiers.add(targetCertName);
const certificatesToRemove = canRemoveCertificates }
? Object.keys(vaultCertificates).filter( }
(vaultCertName) =>
isInfisicalManagedCertificate(vaultCertName, pkiSync) && const certificatesToRemove: string[] = [];
!activeCertificateNames.includes(vaultCertName) &&
!disabledAzureKeyVaultCertificateKeys.includes(vaultCertName) if (canRemoveCertificates) {
) existingSyncRecords.forEach((syncRecord) => {
: []; if (syncRecord.externalIdentifier && !activeExternalIdentifiers.has(syncRecord.externalIdentifier)) {
if (vaultCertificates[syncRecord.externalIdentifier]) {
certificatesToRemove.push(syncRecord.externalIdentifier);
}
}
});
Object.keys(vaultCertificates).forEach((certificateName) => {
const isInfisicalManaged = isInfisicalManagedCertificate(certificateName, pkiSync);
if (isInfisicalManaged) {
const isTrackedInSyncRecords = existingSyncRecords.some(
(record) => record.externalIdentifier === certificateName
);
const isInActiveSet = activeExternalIdentifiers.has(certificateName);
if (!isTrackedInSyncRecords && !isInActiveSet && !certificatesToRemove.includes(certificateName)) {
certificatesToRemove.push(certificateName);
}
}
});
}
// Upload certificates to Azure Key Vault with rate limiting // Upload certificates to Azure Key Vault with rate limiting
const uploadResults = await executeWithConcurrencyLimit( const uploadResults = await executeWithConcurrencyLimit(
setCertificates, setCertificates,
async ({ key, cert, privateKey, certificateChain }) => { async ({ key, cert, privateKey, certificateChain, certificateId }) => {
try { try {
// Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault // Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault
let combinedPem = ""; let combinedPem = "";
@@ -441,6 +527,31 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
} }
); );
if (certificateId) {
const existingCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId);
if (existingCertSync) {
await certificateSyncDAL.updateById(existingCertSync.id, {
externalIdentifier: key,
syncStatus: CertificateSyncStatus.Succeeded,
lastSyncedAt: new Date()
});
} else {
await certificateSyncDAL.addCertificates(pkiSync.id, [
{
certificateId,
externalIdentifier: key
}
]);
}
if (preserveVersion) {
const currentCertificate = await certificateDAL.findById(certificateId);
if (currentCertificate?.renewedFromCertificateId) {
await certificateSyncDAL.removeCertificates(pkiSync.id, [currentCertificate.renewedFromCertificateId]);
}
}
}
return { key, success: true, response: response.data as unknown }; return { key, success: true, response: response.data as unknown };
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
@@ -622,13 +733,33 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
// Cast destination config to Azure Key Vault config // Cast destination config to Azure Key Vault config
const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig; const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig;
// Only remove certificates that are managed by Infisical (match naming schema) const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
const infisicalManagedCertNames = certificateNames.filter((certName) => const certificateNamesToRemove: string[] = [];
isInfisicalManagedCertificate(certName, pkiSync) const certificateIdToNameMap = new Map<string, string>();
);
for (const certName of certificateNames) {
if (deps?.certificateMap?.[certName]?.certificateId) {
const { certificateId } = deps.certificateMap[certName];
const syncRecord = existingSyncRecords.find((record) => record.certificateId === certificateId);
if (syncRecord?.externalIdentifier && typeof certificateId === "string") {
certificateNamesToRemove.push(syncRecord.externalIdentifier);
certificateIdToNameMap.set(certificateId, syncRecord.externalIdentifier);
}
}
}
if (certificateNamesToRemove.length === 0) {
return {
removed: 0,
failed: 0,
skipped: certificateNames.length
};
}
const results = await executeWithConcurrencyLimit( const results = await executeWithConcurrencyLimit(
infisicalManagedCertNames, certificateNamesToRemove,
async (certName) => { async (certName) => {
try { try {
const response = await request.delete( const response = await request.delete(
@@ -663,29 +794,44 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
}, },
{ operation: "remove-specific-certificates", syncId: pkiSync.id } { operation: "remove-specific-certificates", syncId: pkiSync.id }
); );
const failedRemovals = results.filter((result) => result.status === "rejected"); const failedRemovals = results.filter((result) => result.status === "rejected");
if (failedRemovals.length > 0 && deps?.certificateSyncDAL && deps?.certificateMap) { if (failedRemovals.length > 0 && deps?.certificateSyncDAL) {
for (const failure of failedRemovals) { for (const failure of failedRemovals) {
if (failure.status === "rejected") { if (failure.status === "rejected") {
const failedIndex = results.indexOf(failure); const failedCertName = certificateNamesToRemove[results.indexOf(failure)];
const failedCertName = infisicalManagedCertNames[failedIndex];
if (failedCertName && deps.certificateMap[failedCertName]?.certificateId) { const certificateId = Array.from(certificateIdToNameMap.entries()).find(
const { certificateId } = deps.certificateMap[failedCertName]; ([, name]) => name === failedCertName
if (certificateId) { )?.[0];
const errorMessage = (failure.reason as Error)?.message || "Unknown error";
await deps.certificateSyncDAL.updateSyncStatus( if (certificateId) {
pkiSync.id, const errorMessage = (failure.reason as Error)?.message || "Unknown error";
certificateId, await deps.certificateSyncDAL.updateSyncStatus(
CertificateSyncStatus.Failed, pkiSync.id,
`Failed to remove from Azure: ${errorMessage}` certificateId,
); CertificateSyncStatus.Failed,
} `Failed to remove from Azure: ${errorMessage}`
);
} }
} }
} }
} }
const successfulRemovals = results.filter((result) => result.status === "fulfilled");
if (successfulRemovals.length > 0) {
const successfulCertNames = new Set(successfulRemovals.map((_, index) => certificateNamesToRemove[index]));
const certificateIdsToRemove = Array.from(certificateIdToNameMap.entries())
.filter(([, name]) => successfulCertNames.has(name))
.map(([certificateId]) => certificateId);
if (certificateIdsToRemove.length > 0) {
await certificateSyncDAL.removeCertificates(pkiSync.id, certificateIdsToRemove);
}
}
if (failedRemovals.length > 0) { if (failedRemovals.length > 0) {
const failedReasons = failedRemovals.map((failure) => { const failedReasons = failedRemovals.map((failure) => {
if (failure.status === "rejected") { if (failure.status === "rejected") {
@@ -698,16 +844,16 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`, message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`,
context: { context: {
failedReasons, failedReasons,
totalCertificates: infisicalManagedCertNames.length, totalCertificates: certificateNamesToRemove.length,
failedCount: failedRemovals.length failedCount: failedRemovals.length
} }
}); });
} }
return { return {
removed: infisicalManagedCertNames.length - failedRemovals.length, removed: certificateNamesToRemove.length - failedRemovals.length,
failed: failedRemovals.length, failed: failedRemovals.length,
skipped: certificateNames.length - infisicalManagedCertNames.length skipped: certificateNames.length - certificateNamesToRemove.length
}; };
}; };
@@ -14,6 +14,7 @@ export const AzureKeyVaultPkiSyncConfigSchema = z.object({
const AzureKeyVaultPkiSyncOptionsSchema = z.object({ const AzureKeyVaultPkiSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false), canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true), canRemoveCertificates: z.boolean().default(true),
preserveVersion: z.boolean().default(true),
certificateNameSchema: z certificateNameSchema: z
.string() .string()
.optional() .optional()
+12 -2
View File
@@ -204,7 +204,12 @@ export const PkiSyncFns = {
switch (pkiSync.destination) { switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: { case PkiSync.AzureKeyVault: {
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({
appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap); return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap);
} }
case PkiSync.AwsCertificateManager: { case PkiSync.AwsCertificateManager: {
@@ -236,7 +241,12 @@ export const PkiSyncFns = {
switch (pkiSync.destination) { switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: { case PkiSync.AzureKeyVault: {
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({
appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames, { await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames, {
certificateSyncDAL: dependencies.certificateSyncDAL, certificateSyncDAL: dependencies.certificateSyncDAL,
certificateMap: dependencies.certificateMap certificateMap: dependencies.certificateMap
@@ -1,6 +1,6 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType, TCertificateSyncs } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
@@ -10,6 +10,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums"; import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums";
import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal";
@@ -48,6 +49,7 @@ type TPkiSyncServiceFactoryDep = {
TPkiSyncDALFactory, TPkiSyncDALFactory,
"findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById" "findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById"
>; >;
certificateDAL: Pick<TCertificateDALFactory, "findActiveCertificatesByIds">;
certificateSyncDAL: Pick< certificateSyncDAL: Pick<
TCertificateSyncDALFactory, TCertificateSyncDALFactory,
| "findByPkiSyncId" | "findByPkiSyncId"
@@ -72,6 +74,7 @@ export type TPkiSyncServiceFactory = ReturnType<typeof pkiSyncServiceFactory>;
export const pkiSyncServiceFactory = ({ export const pkiSyncServiceFactory = ({
pkiSyncDAL, pkiSyncDAL,
certificateDAL,
certificateSyncDAL, certificateSyncDAL,
pkiSubscriberDAL, pkiSubscriberDAL,
appConnectionService, appConnectionService,
@@ -79,6 +82,26 @@ export const pkiSyncServiceFactory = ({
licenseService, licenseService,
pkiSyncQueue pkiSyncQueue
}: TPkiSyncServiceFactoryDep) => { }: TPkiSyncServiceFactoryDep) => {
const validateCertificatesProjectOwnership = async (certificateIds: string[], expectedProjectId: string) => {
if (certificateIds.length === 0) return;
const certificates = await certificateDAL.findActiveCertificatesByIds(certificateIds);
if (certificates.length !== certificateIds.length) {
const foundIds = certificates.map((cert) => cert.id);
const missingIds = certificateIds.filter((id) => !foundIds.includes(id));
throw new NotFoundError({
message: `Certificates not found or not active: ${missingIds.join(", ")}`
});
}
const invalidCertificates = certificates.filter((cert) => cert.projectId !== expectedProjectId);
if (invalidCertificates.length > 0) {
throw new BadRequestError({
message: `Certificates do not belong to the same project: ${invalidCertificates.map((cert) => cert.id).join(", ")}`
});
}
};
const createPkiSync = async ( const createPkiSync = async (
{ {
name, name,
@@ -132,6 +155,10 @@ export const pkiSyncServiceFactory = ({
...syncOptions ...syncOptions
}; };
if (certificateIds.length > 0) {
await validateCertificatesProjectOwnership(certificateIds, projectId);
}
try { try {
const pkiSync = await pkiSyncDAL.create({ const pkiSync = await pkiSyncDAL.create({
name, name,
@@ -147,7 +174,10 @@ export const pkiSyncServiceFactory = ({
}); });
if (certificateIds.length > 0) { if (certificateIds.length > 0) {
await certificateSyncDAL.addCertificates(pkiSync.id, certificateIds); await certificateSyncDAL.addCertificates(
pkiSync.id,
certificateIds.map((id) => ({ certificateId: id }))
);
} }
if (pkiSync.isAutoSyncEnabled) { if (pkiSync.isAutoSyncEnabled) {
@@ -245,9 +275,16 @@ export const pkiSyncServiceFactory = ({
} }
if (certificateIds !== undefined) { if (certificateIds !== undefined) {
if (certificateIds.length > 0) {
await validateCertificatesProjectOwnership(certificateIds, pkiSync.projectId);
}
await certificateSyncDAL.removeAllCertificatesFromSync(id); await certificateSyncDAL.removeAllCertificatesFromSync(id);
if (certificateIds.length > 0) { if (certificateIds.length > 0) {
await certificateSyncDAL.addCertificates(id, certificateIds); await certificateSyncDAL.addCertificates(
id,
certificateIds.map((certId) => ({ certificateId: certId }))
);
} }
} }
@@ -489,7 +526,10 @@ export const pkiSyncServiceFactory = ({
const addCertificatesToPkiSync = async ( const addCertificatesToPkiSync = async (
{ pkiSyncId, certificateIds }: Omit<TAddCertificatesToPkiSyncDTO, "auditLogInfo" | "projectId">, { pkiSyncId, certificateIds }: Omit<TAddCertificatesToPkiSyncDTO, "auditLogInfo" | "projectId">,
actor: OrgServiceActor actor: OrgServiceActor
) => { ): Promise<{
addedCertificates: TCertificateSyncs[];
pkiSyncInfo: { projectId: string; destination: string; name: string };
}> => {
const pkiSync = await pkiSyncDAL.findById(pkiSyncId); const pkiSync = await pkiSyncDAL.findById(pkiSyncId);
if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" });
@@ -502,31 +542,33 @@ export const pkiSyncServiceFactory = ({
projectId: pkiSync.projectId projectId: pkiSync.projectId
}); });
let subscriber; ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs);
if (pkiSync.subscriberId) {
subscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId);
}
ForbiddenError.from(permission).throwUnlessCan( await validateCertificatesProjectOwnership(certificateIds, pkiSync.projectId);
ProjectPermissionPkiSyncActions.Edit,
subscriber const addedCertificates = await certificateSyncDAL.addCertificates(
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: subscriber.name }) pkiSyncId,
: ProjectPermissionSub.PkiSyncs certificateIds.map((id) => ({ certificateId: id }))
); );
const addedCertificates = await certificateSyncDAL.addCertificates(pkiSyncId, certificateIds);
if (pkiSync.isAutoSyncEnabled) { if (pkiSync.isAutoSyncEnabled) {
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId }); await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId });
} }
return addedCertificates; return {
addedCertificates,
pkiSyncInfo: {
projectId: pkiSync.projectId,
destination: pkiSync.destination,
name: pkiSync.name
}
};
}; };
const removeCertificatesFromPkiSync = async ( const removeCertificatesFromPkiSync = async (
{ pkiSyncId, certificateIds }: Omit<TRemoveCertificatesFromPkiSyncDTO, "auditLogInfo" | "projectId">, { pkiSyncId, certificateIds }: Omit<TRemoveCertificatesFromPkiSyncDTO, "auditLogInfo" | "projectId">,
actor: OrgServiceActor actor: OrgServiceActor
) => { ): Promise<{ removedCount: number; pkiSyncInfo: { projectId: string; destination: string; name: string } }> => {
const pkiSync = await pkiSyncDAL.findById(pkiSyncId); const pkiSync = await pkiSyncDAL.findById(pkiSyncId);
if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" });
@@ -539,17 +581,7 @@ export const pkiSyncServiceFactory = ({
projectId: pkiSync.projectId projectId: pkiSync.projectId
}); });
let subscriber; ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs);
if (pkiSync.subscriberId) {
subscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId);
}
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSyncActions.Edit,
subscriber
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: subscriber.name })
: ProjectPermissionSub.PkiSyncs
);
const removedCount = await certificateSyncDAL.removeCertificates(pkiSyncId, certificateIds); const removedCount = await certificateSyncDAL.removeCertificates(pkiSyncId, certificateIds);
@@ -557,13 +589,24 @@ export const pkiSyncServiceFactory = ({
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId }); await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId });
} }
return { removedCount }; return {
removedCount,
pkiSyncInfo: {
projectId: pkiSync.projectId,
destination: pkiSync.destination,
name: pkiSync.name
}
};
}; };
const listPkiSyncCertificates = async ( const listPkiSyncCertificates = async (
{ pkiSyncId, offset = 0, limit = 20 }: Omit<TListPkiSyncCertificatesDTO, "projectId">, { pkiSyncId, offset = 0, limit = 20 }: Omit<TListPkiSyncCertificatesDTO, "projectId">,
actor: OrgServiceActor actor: OrgServiceActor
): Promise<{ certificates: TPkiSyncCertificate[]; totalCount: number }> => { ): Promise<{
certificates: TPkiSyncCertificate[];
totalCount: number;
pkiSyncInfo: { projectId: string; destination: string; name: string };
}> => {
const pkiSync = await pkiSyncDAL.findById(pkiSyncId); const pkiSync = await pkiSyncDAL.findById(pkiSyncId);
if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" });
@@ -576,17 +619,7 @@ export const pkiSyncServiceFactory = ({
projectId: pkiSync.projectId projectId: pkiSync.projectId
}); });
let subscriber; ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs);
if (pkiSync.subscriberId) {
subscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId);
}
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSyncActions.Read,
subscriber
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: subscriber.name })
: ProjectPermissionSub.PkiSyncs
);
const result = await certificateSyncDAL.findWithDetails({ const result = await certificateSyncDAL.findWithDetails({
pkiSyncId, pkiSyncId,
@@ -611,14 +644,22 @@ export const pkiSyncServiceFactory = ({
certificateNotBefore: detail.certificateNotBefore || undefined, certificateNotBefore: detail.certificateNotBefore || undefined,
certificateNotAfter: detail.certificateNotAfter || undefined, certificateNotAfter: detail.certificateNotAfter || undefined,
certificateRenewBeforeDays: !detail.certificateRenewedByCertificateId certificateRenewBeforeDays: !detail.certificateRenewedByCertificateId
? detail.certificateRenewBeforeDays ? detail.certificateRenewBeforeDays || undefined
: undefined, : undefined,
certificateRenewalError: detail.certificateRenewalError || undefined, certificateRenewalError: detail.certificateRenewalError || undefined,
pkiSyncName: detail.pkiSyncName || undefined, pkiSyncName: detail.pkiSyncName || undefined,
pkiSyncDestination: detail.pkiSyncDestination || undefined pkiSyncDestination: detail.pkiSyncDestination || undefined
})); }));
return { certificates, totalCount }; return {
certificates,
totalCount,
pkiSyncInfo: {
projectId: pkiSync.projectId,
destination: pkiSync.destination,
name: pkiSync.name
}
};
}; };
return { return {
@@ -45,12 +45,13 @@ export const triggerAutoSyncForCertificate = async (
} }
const allPkiSyncs = await dependencies.pkiSyncDAL.find({ const allPkiSyncs = await dependencies.pkiSyncDAL.find({
isAutoSyncEnabled: true isAutoSyncEnabled: true,
$in: {
id: pkiSyncIds
}
}); });
const pkiSyncs = allPkiSyncs.filter((sync) => pkiSyncIds.includes(sync.id)); const syncPromises = allPkiSyncs.map((pkiSync) =>
const syncPromises = pkiSyncs.map((pkiSync) =>
dependencies.pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }) dependencies.pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id })
); );
await Promise.all(syncPromises); await Promise.all(syncPromises);
@@ -65,7 +66,7 @@ export const addRenewedCertificateToSyncs = async (
dependencies: { dependencies: {
certificateSyncDAL: Pick< certificateSyncDAL: Pick<
TCertificateSyncDALFactory, TCertificateSyncDALFactory,
"findPkiSyncIdsByCertificateId" | "removeCertificates" | "addCertificates" "findPkiSyncIdsByCertificateId" | "addCertificates" | "findByPkiSyncAndCertificate"
>; >;
}, },
tx?: Knex tx?: Knex
@@ -78,12 +79,25 @@ export const addRenewedCertificateToSyncs = async (
} }
const addPromises = pkiSyncIds.map(async (pkiSyncId) => { const addPromises = pkiSyncIds.map(async (pkiSyncId) => {
await dependencies.certificateSyncDAL.addCertificates(pkiSyncId, [newCertificateId], tx); const oldCertificateRecord = await dependencies.certificateSyncDAL.findByPkiSyncAndCertificate(
}); pkiSyncId,
oldCertificateId
);
await dependencies.certificateSyncDAL.addCertificates(
pkiSyncId,
[
{
certificateId: newCertificateId,
externalIdentifier: oldCertificateRecord?.externalIdentifier || undefined
}
],
tx
);
});
await Promise.all(addPromises); await Promise.all(addPromises);
logger.info(`Successfully added renewed certificate ${newCertificateId} to ${pkiSyncIds.length} PKI sync(s)`); logger.info(`Successfully added renewed certificate ${newCertificateId} to PKI sync(s)`);
} catch (error) { } catch (error) {
logger.error(error, `Failed to add renewed certificate ${newCertificateId} to syncs:`); logger.error(error, `Failed to add renewed certificate ${newCertificateId} to syncs:`);
throw error; throw error;
@@ -31,7 +31,7 @@ This section walks you through the complete end-to-end process of setting up Azu
<Step title="Create New Azure ADCS Certificate Service CA"> <Step title="Create New Azure ADCS Certificate Service CA">
Click **Create CA** and configure: Click **Create CA** and configure:
- **Type**: Choose **Azure AD Certificate Service** - **Type**: Choose **Active Directory Certificate Services (AD CS)**
- **Name**: Friendly name for this CA (e.g., "Production ADCS CA") - **Name**: Friendly name for this CA (e.g., "Production ADCS CA")
- **App Connection**: Choose your ADCS connection from the dropdown - **App Connection**: Choose your ADCS connection from the dropdown
@@ -11,21 +11,24 @@ type Props = {
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
selectSync?: PkiSync | null; selectSync?: PkiSync | null;
initialData?: any;
}; };
type ContentProps = { type ContentProps = {
onComplete: (pkiSync: TPkiSync) => void; onComplete: (pkiSync: TPkiSync) => void;
selectedSync: PkiSync | null; selectedSync: PkiSync | null;
setSelectedSync: (selectedSync: PkiSync | null) => void; setSelectedSync: (selectedSync: PkiSync | null) => void;
initialData?: any;
}; };
const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => { const Content = ({ onComplete, setSelectedSync, selectedSync, initialData }: ContentProps) => {
if (selectedSync) { if (selectedSync) {
return ( return (
<CreatePkiSyncForm <CreatePkiSyncForm
onComplete={onComplete} onComplete={onComplete}
onCancel={() => setSelectedSync(null)} onCancel={() => setSelectedSync(null)}
destination={selectedSync} destination={selectedSync}
initialData={initialData}
/> />
); );
} }
@@ -33,7 +36,12 @@ const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) =>
return <PkiSyncSelect onSelect={setSelectedSync} />; return <PkiSyncSelect onSelect={setSelectedSync} />;
}; };
export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => { export const CreatePkiSyncModal = ({
onOpenChange,
selectSync = null,
initialData,
...props
}: Props) => {
const [selectedSync, setSelectedSync] = useState<PkiSync | null>(selectSync); const [selectedSync, setSelectedSync] = useState<PkiSync | null>(selectSync);
useEffect(() => { useEffect(() => {
@@ -69,6 +77,7 @@ export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }
}} }}
selectedSync={selectedSync} selectedSync={selectedSync}
setSelectedSync={setSelectedSync} setSelectedSync={setSelectedSync}
initialData={initialData}
/> />
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -23,6 +23,7 @@ type Props = {
onComplete: (pkiSync: TPkiSync) => void; onComplete: (pkiSync: TPkiSync) => void;
destination: PkiSync; destination: PkiSync;
onCancel: () => void; onCancel: () => void;
initialData?: any;
}; };
const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [ const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [
@@ -33,7 +34,7 @@ const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[]
{ name: "Review", key: "review", fields: [] } { name: "Review", key: "review", fields: [] }
]; ];
export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) => { export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialData }: Props) => {
const createPkiSync = useCreatePkiSync(); const createPkiSync = useCreatePkiSync();
const { currentProject } = useProject(); const { currentProject } = useProject();
const { name: destinationName } = PKI_SYNC_MAP[destination]; const { name: destinationName } = PKI_SYNC_MAP[destination];
@@ -55,7 +56,8 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
canRemoveCertificates: false, canRemoveCertificates: false,
preserveArn: true, preserveArn: true,
certificateNameSchema: syncOption?.defaultCertificateNameSchema certificateNameSchema: syncOption?.defaultCertificateNameSchema
} },
...initialData
} as Partial<TPkiSyncForm>, } as Partial<TPkiSyncForm>,
reValidateMode: "onChange" reValidateMode: "onChange"
}); });
@@ -27,12 +27,16 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => {
const formMethods = useForm<TUpdatePkiSyncForm>({ const formMethods = useForm<TUpdatePkiSyncForm>({
resolver: zodResolver(UpdatePkiSyncFormSchema), resolver: zodResolver(UpdatePkiSyncFormSchema),
defaultValues: { defaultValues: {
...pkiSync, name: pkiSync.name,
destination: pkiSync.destination,
description: pkiSync.description ?? "", description: pkiSync.description ?? "",
connection: { connection: {
id: pkiSync.connectionId, id: pkiSync.connectionId,
name: pkiSync.appConnectionName name: pkiSync.appConnectionName
} },
syncOptions: pkiSync.syncOptions,
destinationConfig: pkiSync.destinationConfig,
isAutoSyncEnabled: pkiSync.isAutoSyncEnabled
} as Partial<TUpdatePkiSyncForm>, } as Partial<TUpdatePkiSyncForm>,
reValidateMode: "onChange" reValidateMode: "onChange"
}); });
@@ -1,14 +1,18 @@
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { SingleValue } from "react-select";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router"; import { useRouterState } from "@tanstack/react-router";
import { AppConnectionOption } from "@app/components/app-connections";
import { FilterableSelect, FormControl } from "@app/components/v2"; import { FilterableSelect, FormControl } from "@app/components/v2";
import { ProjectPermissionSub, useProject, useProjectPermission } from "@app/context"; import { ProjectPermissionSub, useProject, useProjectPermission } from "@app/context";
import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs"; import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs";
import { usePopUp } from "@app/hooks";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { AddAppConnectionModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components";
import { TPkiSyncForm } from "./schemas/pki-sync-schema"; import { TPkiSyncForm } from "./schemas/pki-sync-schema";
@@ -18,12 +22,30 @@ type Props = {
export const PkiSyncConnectionField = ({ onChange: callback }: Props) => { export const PkiSyncConnectionField = ({ onChange: callback }: Props) => {
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const { control, watch } = useFormContext<TPkiSyncForm>(); const { control, watch, setValue } = useFormContext<TPkiSyncForm>();
const { currentProject } = useProject();
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addConnection"] as const);
const destination = watch("destination"); const destination = watch("destination");
const app = PKI_SYNC_CONNECTION_MAP[destination]; const app = PKI_SYNC_CONNECTION_MAP[destination];
const { currentProject } = useProject();
const {
location: { pathname }
} = useRouterState();
const getPkiSyncReturnUrl = () => {
if (pathname.includes("selectedTab=secret-syncs")) {
return pathname.replace("selectedTab=secret-syncs", "selectedTab=pki-syncs");
}
if (!pathname.includes("selectedTab=")) {
const separator = pathname.includes("?") ? "&" : "?";
return `${pathname}${separator}selectedTab=pki-syncs`;
}
return pathname;
};
const { data: availableConnections, isPending } = useListAvailableAppConnections( const { data: availableConnections, isPending } = useListAvailableAppConnections(
app, app,
currentProject.id currentProject.id
@@ -47,6 +69,7 @@ export const PkiSyncConnectionField = ({ onChange: callback }: Props) => {
<Controller <Controller
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
tooltipText="App Connections can be created from the Organization Settings page."
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
label={`${connectionName} Connection`} label={`${connectionName} Connection`}
@@ -54,36 +77,54 @@ export const PkiSyncConnectionField = ({ onChange: callback }: Props) => {
<FilterableSelect <FilterableSelect
value={value} value={value}
onChange={(newValue) => { onChange={(newValue) => {
if ((newValue as SingleValue<{ id: string; name: string }>)?.id === "_create") {
handlePopUpOpen("addConnection");
onChange(null);
const formData = { ...watch(), returnUrl: getPkiSyncReturnUrl() };
localStorage.setItem("pkiSyncFormData", JSON.stringify(formData));
if (callback) callback();
return;
}
onChange(newValue); onChange(newValue);
if (callback) callback(); if (callback) callback();
}} }}
isLoading={isPending} isLoading={isPending}
options={availableConnections} options={[
...(canCreateConnection ? [{ id: "_create", name: "Create Connection" }] : []),
...(availableConnections ?? [])
]}
placeholder="Select connection..." placeholder="Select connection..."
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id} getOptionValue={(option) => option.id}
components={{ Option: AppConnectionOption }}
/> />
</FormControl> </FormControl>
)} )}
control={control} control={control}
name="connection" name="connection"
/> />
{availableConnections?.length === 0 && ( {!isPending && !availableConnections?.length && !canCreateConnection && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow"> <p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} /> <FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} />
{canCreateConnection ? ( You do not have access to any {appName} Connections. Contact an admin to create one.
<>
You do not have access to any {appName} Connections. Create one from the{" "}
<Link to="/organization/app-connections" className="underline">
App Connections
</Link>{" "}
page.
</>
) : (
`You do not have access to any ${appName} Connections. Contact an admin to create one.`
)}
</p> </p>
)} )}
<AddAppConnectionModal
isOpen={popUp.addConnection.isOpen}
onOpenChange={(isOpen) => {
localStorage.removeItem("pkiSyncFormData");
handlePopUpToggle("addConnection", isOpen);
}}
projectType={currentProject.type}
projectId={currentProject.id}
app={app}
onComplete={(connection) => {
if (connection) {
setValue("connection", connection);
}
}}
/>
</> </>
); );
}; };
@@ -71,7 +71,7 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => {
isChecked={value} isChecked={value}
> >
<p> <p>
Enable Inactive Certificate Removal{" "} Enable Removal of Active/Revoked Certificates{" "}
<Tooltip <Tooltip
className="max-w-md" className="max-w-md"
content={ content={
@@ -138,6 +138,51 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => {
/> />
)} )}
{currentDestination === PkiSync.AzureKeyVault && (
<Controller
control={control}
name="syncOptions.preserveVersion"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="preserve-version"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>
Preserve Version on Renewal{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
When enabled, Infisical will create a new version of the existing
certificate in Azure Key Vault during certificate renewal syncs,
preserving the certificate name.
</p>
<p className="mt-4">
This allows consuming services to continue using the same certificate name
while automatically using the latest version without requiring manual
updates.
</p>
<p className="mt-4">
When disabled, new certificates will be created with new names, and old
certificates will be removed.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
<Controller <Controller
control={control} control={control}
name="syncOptions.certificateNameSchema" name="syncOptions.certificateNameSchema"
@@ -4,7 +4,46 @@ import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { BasePkiSyncSchema } from "./base-pki-sync-schema"; import { BasePkiSyncSchema } from "./base-pki-sync-schema";
export const AzureKeyVaultPkiSyncDestinationSchema = BasePkiSyncSchema().merge( const AzureKeyVaultSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true),
preserveVersion: z.boolean().default(true),
certificateNameSchema: z
.string()
.optional()
.refine(
(val) => {
if (!val) return true;
const allowedOptionalPlaceholders = ["{{environment}}"];
const allowedPlaceholdersRegexPart = ["{{certificateId}}", ...allowedOptionalPlaceholders]
.map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"))
.join("|");
const allowedContentRegex = new RegExp(
`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`
);
const contentIsValid = allowedContentRegex.test(val);
if (val.trim()) {
const certificateIdRegex = /\{\{certificateId\}\}/;
const certificateIdIsPresent = certificateIdRegex.test(val);
return contentIsValid && certificateIdIsPresent;
}
return contentIsValid;
},
{
message:
"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 const AzureKeyVaultPkiSyncDestinationSchema = BasePkiSyncSchema(
AzureKeyVaultSyncOptionsSchema
).merge(
z.object({ z.object({
destination: z.literal(PkiSync.AzureKeyVault), destination: z.literal(PkiSync.AzureKeyVault),
destinationConfig: z.object({ destinationConfig: z.object({
@@ -124,7 +124,7 @@ type Props = {
const caTypes = [ const caTypes = [
{ label: "ACME", value: CaType.ACME }, { label: "ACME", value: CaType.ACME },
{ label: "Azure AD Certificate Service", value: CaType.AZURE_AD_CS } { label: "Active Directory Certificate Services (AD CS)", value: CaType.AZURE_AD_CS }
]; ];
export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { faPlus, faSearch } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faSearch } from "@fortawesome/free-solid-svg-icons";
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
@@ -18,12 +19,15 @@ import {
THead, THead,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { import {
PkiSync,
useAddCertificatesToPkiSync, useAddCertificatesToPkiSync,
useListPkiSyncsWithCertificate, useListPkiSyncsWithCertificate,
useRemoveCertificatesFromPkiSync useRemoveCertificatesFromPkiSync
} from "@app/hooks/api/pkiSyncs"; } from "@app/hooks/api/pkiSyncs";
import { IntegrationsListPageTabs } from "@app/types/integrations";
type Props = { type Props = {
popUp: { popUp: {
@@ -46,6 +50,7 @@ export const CertificateManagePkiSyncsModal = ({ popUp, handlePopUpToggle }: Pro
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const { currentProject } = useProject(); const { currentProject } = useProject();
const navigate = useNavigate();
const { certificateId, commonName } = popUp.data || {}; const { certificateId, commonName } = popUp.data || {};
const { data: pkiSyncs = [], isPending } = useListPkiSyncsWithCertificate( const { data: pkiSyncs = [], isPending } = useListPkiSyncsWithCertificate(
@@ -81,6 +86,32 @@ export const CertificateManagePkiSyncsModal = ({ popUp, handlePopUpToggle }: Pro
setCurrentPage(1); setCurrentPage(1);
}; };
const handleNavigateToPkiSyncs = () => {
if (!currentProject?.id) return;
navigate({
to: ROUTE_PATHS.CertManager.IntegrationsListPage.path,
params: {
projectId: currentProject.id
},
search: {
selectedTab: IntegrationsListPageTabs.PkiSyncs
}
});
handleClose();
};
const getDestinationDisplayName = (destination: string) => {
switch (destination) {
case PkiSync.AzureKeyVault:
return "Azure Key Vault";
case PkiSync.AwsCertificateManager:
return "AWS Certificate Manager";
default:
return destination;
}
};
useEffect(() => { useEffect(() => {
if (!certificateId || !pkiSyncs || pkiSyncs.length === 0) return; if (!certificateId || !pkiSyncs || pkiSyncs.length === 0) return;
@@ -161,7 +192,7 @@ export const CertificateManagePkiSyncsModal = ({ popUp, handlePopUpToggle }: Pro
placeholder="Search PKI syncs by name..." placeholder="Search PKI syncs by name..."
/> />
</div> </div>
<div className="max-h-96 overflow-y-auto"> <div className="mt-4 max-h-96 overflow-y-auto">
{isPending && ( {isPending && (
<div className="flex h-32 items-center justify-center"> <div className="flex h-32 items-center justify-center">
<div className="text-bunker-300">Loading PKI syncs...</div> <div className="text-bunker-300">Loading PKI syncs...</div>
@@ -169,12 +200,24 @@ export const CertificateManagePkiSyncsModal = ({ popUp, handlePopUpToggle }: Pro
)} )}
{!isPending && pkiSyncs.length === 0 && ( {!isPending && pkiSyncs.length === 0 && (
<EmptyState title="No PKI syncs available" icon={faPlus}> <EmptyState title="No PKI syncs available" icon={faPlus}>
Create a PKI sync first to manage certificate syncing. <div className="mt-1">
Create a{" "}
<button
type="button"
onClick={handleNavigateToPkiSyncs}
className="cursor-pointer underline hover:text-mineshaft-300"
>
PKI sync
</button>{" "}
first to manage certificate syncing.
</div>
</EmptyState> </EmptyState>
)} )}
{!isPending && pkiSyncs.length > 0 && filteredSyncs.length === 0 && searchTerm && ( {!isPending && pkiSyncs.length > 0 && filteredSyncs.length === 0 && searchTerm && (
<EmptyState title="No PKI syncs found" icon={faSearch}> <EmptyState title="No PKI syncs found" icon={faSearch}>
No PKI syncs match your search criteria. Try a different search term. <div className="mt-1">
No PKI syncs match your search criteria. Try a different search term.
</div>
</EmptyState> </EmptyState>
)} )}
{!isPending && filteredSyncs.length > 0 && ( {!isPending && filteredSyncs.length > 0 && (
@@ -209,9 +252,9 @@ export const CertificateManagePkiSyncsModal = ({ popUp, handlePopUpToggle }: Pro
<Td className="w-1/2 max-w-0"> <Td className="w-1/2 max-w-0">
<div <div
className="truncate capitalize" className="truncate capitalize"
title={sync.destination.replace(/-/g, " ")} title={getDestinationDisplayName(sync.destination)}
> >
{sync.destination.replace(/-/g, " ")} {getDestinationDisplayName(sync.destination)}
</div> </div>
</Td> </Td>
</Tr> </Tr>
@@ -478,7 +478,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
disabled={!isAllowed} disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faLink} />} icon={<FontAwesomeIcon icon={faLink} />}
> >
PKI Syncs Manage PKI Syncs
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
@@ -12,13 +12,14 @@ import { ProjectPermissionSub, useProject } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useListPkiSyncs } from "@app/hooks/api/pkiSyncs"; import { useListPkiSyncs } from "@app/hooks/api/pkiSyncs";
import { IntegrationsListPageTabs } from "@app/types/integrations";
import { PkiSyncsTable } from "./PkiSyncTable"; import { PkiSyncsTable } from "./PkiSyncTable";
export const PkiSyncsTab = () => { export const PkiSyncsTab = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const);
const { addSync, ...search } = useSearch({ const { addSync, connectionId, connectionName, ...search } = useSearch({
from: ROUTE_PATHS.CertManager.IntegrationsListPage.id from: ROUTE_PATHS.CertManager.IntegrationsListPage.id
}); });
@@ -45,6 +46,42 @@ export const PkiSyncsTab = () => {
navigateToBase(); navigateToBase();
}, [addSync, handlePopUpOpen, navigateToBase]); }, [addSync, handlePopUpOpen, navigateToBase]);
useEffect(() => {
const storedFormData = localStorage.getItem("pkiSyncFormData");
if (storedFormData && !popUp.addSync.isOpen) {
try {
const parsedData = JSON.parse(storedFormData);
if (connectionId && connectionName) {
const initialData = {
...parsedData,
connection: { id: connectionId, name: connectionName }
};
handlePopUpOpen("addSync", { destination: parsedData.destination, initialData });
navigate({
to: ROUTE_PATHS.CertManager.IntegrationsListPage.path,
params: { projectId: currentProject?.id },
search: { selectedTab: IntegrationsListPageTabs.PkiSyncs },
replace: true
});
} else {
handlePopUpOpen("addSync", { destination: parsedData.destination });
}
localStorage.removeItem("pkiSyncFormData");
} catch (error) {
console.error("Failed to parse stored PKI sync form data:", error);
localStorage.removeItem("pkiSyncFormData");
handlePopUpOpen("addSync");
}
}
}, [
handlePopUpOpen,
popUp.addSync.isOpen,
connectionId,
connectionName,
navigate,
currentProject?.id
]);
const { data: pkiSyncs = [], isPending: isPkiSyncsPending } = useListPkiSyncs( const { data: pkiSyncs = [], isPending: isPkiSyncsPending } = useListPkiSyncs(
currentProject?.id || "", currentProject?.id || "",
{ {
@@ -94,7 +131,8 @@ export const PkiSyncsTab = () => {
<PkiSyncsTable pkiSyncs={pkiSyncs} /> <PkiSyncsTable pkiSyncs={pkiSyncs} />
</div> </div>
<CreatePkiSyncModal <CreatePkiSyncModal
selectSync={popUp.addSync.data} selectSync={popUp.addSync.data?.destination || popUp.addSync.data}
initialData={popUp.addSync.data?.initialData}
isOpen={popUp.addSync.isOpen} isOpen={popUp.addSync.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addSync", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("addSync", isOpen)}
/> />
@@ -9,7 +9,9 @@ import { IntegrationsListPage } from "./IntegrationsListPage";
const IntegrationsListPageQuerySchema = z.object({ const IntegrationsListPageQuerySchema = z.object({
selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional(), selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional(),
addSync: z.nativeEnum(PkiSync).optional() addSync: z.nativeEnum(PkiSync).optional(),
connectionId: z.string().optional(),
connectionName: z.string().optional()
}); });
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -418,7 +418,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="enrollmentType" name="enrollmentType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Enrollment Type" label="Enrollment Method"
isRequired isRequired
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
@@ -633,7 +633,9 @@ export const OAuthCallbackPage = () => {
connectionName: data.connection.name, connectionName: data.connection.name,
...(data.returnUrl.includes("integrations") ...(data.returnUrl.includes("integrations")
? { ? {
selectedTab: IntegrationsListPageTabs.SecretSyncs selectedTab: localStorage.getItem("pkiSyncFormData")
? IntegrationsListPageTabs.PkiSyncs
: IntegrationsListPageTabs.SecretSyncs
} }
: {}) : {})
} }