diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index bbc27ebc1..7ff31ed99 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -62,6 +62,9 @@ import { TCertificateSecretsUpdate, TCertificatesInsert, TCertificatesUpdate, + TCertificateSyncs, + TCertificateSyncsInsert, + TCertificateSyncsUpdate, TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, TCertificateTemplateEstConfigsUpdate, @@ -738,6 +741,11 @@ declare module "knex/types/tables" { TPkiSubscribersUpdate >; [TableName.PkiSync]: KnexOriginal.CompositeTableType; + [TableName.CertificateSync]: KnexOriginal.CompositeTableType< + TCertificateSyncs, + TCertificateSyncsInsert, + TCertificateSyncsUpdate + >; [TableName.UserGroupMembership]: KnexOriginal.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, diff --git a/backend/src/db/migrations/20251028120000_add-certificate-sync-table.ts b/backend/src/db/migrations/20251028120000_add-certificate-sync-table.ts new file mode 100644 index 000000000..14904e5bd --- /dev/null +++ b/backend/src/db/migrations/20251028120000_add-certificate-sync-table.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; +import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.CertificateSync))) { + await knex.schema.createTable(TableName.CertificateSync, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("pkiSyncId").notNullable(); + t.foreign("pkiSyncId").references("id").inTable(TableName.PkiSync).onDelete("CASCADE"); + t.uuid("certificateId").notNullable(); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.string("syncStatus").defaultTo(CertificateSyncStatus.Pending); + t.text("lastSyncMessage"); + t.datetime("lastSyncedAt"); + t.timestamps(true, true, true); + + // Ensure unique combination of pki sync and certificate + t.unique(["pkiSyncId", "certificateId"]); + + t.index("pkiSyncId"); + t.index("certificateId"); + t.index("syncStatus"); + }); + + await createOnUpdateTrigger(knex, TableName.CertificateSync); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.CertificateSync); + await dropOnUpdateTrigger(knex, TableName.CertificateSync); +} diff --git a/backend/src/db/schemas/certificate-syncs.ts b/backend/src/db/schemas/certificate-syncs.ts new file mode 100644 index 000000000..6f10e4d79 --- /dev/null +++ b/backend/src/db/schemas/certificate-syncs.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateSyncsSchema = z.object({ + id: z.string().uuid(), + pkiSyncId: z.string().uuid(), + certificateId: z.string().uuid(), + syncStatus: z.string().default("pending").nullable().optional(), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TCertificateSyncs = z.infer; +export type TCertificateSyncsInsert = Omit, TImmutableDBKeys>; +export type TCertificateSyncsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 4f0f221ff..fba195746 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -17,6 +17,7 @@ export * from "./certificate-authority-crl"; export * from "./certificate-authority-secret"; export * from "./certificate-bodies"; export * from "./certificate-secrets"; +export * from "./certificate-syncs"; export * from "./certificate-template-est-configs"; export * from "./certificate-templates"; export * from "./certificates"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 86bc929b8..e10c6dcbe 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -161,6 +161,7 @@ export enum TableName { AppConnection = "app_connections", SecretSync = "secret_syncs", PkiSync = "pki_syncs", + CertificateSync = "certificate_syncs", KmipClient = "kmip_clients", KmipOrgConfig = "kmip_org_configs", KmipOrgServerCertificates = "kmip_org_server_certificates", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c135db83e..f56026ffb 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -172,6 +172,7 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; +import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; @@ -1060,6 +1061,7 @@ export const registerRoutes = async ( const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db); + const certificateSyncDAL = certificateSyncDALFactory(db); const pkiAlertDAL = pkiAlertDALFactory(db); const pkiCollectionDAL = pkiCollectionDALFactory(db); @@ -2020,7 +2022,8 @@ export const registerRoutes = async ( certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, - certificateAuthorityCertDAL + certificateAuthorityCertDAL, + certificateSyncDAL }); const pkiSyncCleanup = pkiSyncCleanupQueueServiceFactory({ @@ -2131,6 +2134,7 @@ export const registerRoutes = async ( permissionService, pkiCollectionDAL, pkiCollectionItemDAL, + certificateSyncDAL, pkiSyncDAL, pkiSyncQueue }); @@ -2142,7 +2146,10 @@ export const registerRoutes = async ( certificateProfileDAL, certificateTemplateV2Service, internalCaService: internalCertificateAuthorityService, - permissionService + permissionService, + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue }); const certificateV3Queue = certificateV3QueueServiceFactory({ @@ -2188,7 +2195,8 @@ export const registerRoutes = async ( appConnectionService, permissionService, licenseService, - pkiSyncQueue + pkiSyncQueue, + certificateSyncDAL }); const pkiTemplateService = pkiTemplatesServiceFactory({ diff --git a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts index 3f803a1e0..6460b7d65 100644 --- a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts +++ b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-endpoints.ts @@ -26,7 +26,7 @@ export const registerSyncPkiEndpoints = ({ syncOptions?: Record; description?: string; isAutoSyncEnabled?: boolean; - subscriberId?: string; + subscriberId?: string | null; }>; updateSchema: z.ZodType<{ connectionId?: string; @@ -35,7 +35,7 @@ export const registerSyncPkiEndpoints = ({ syncOptions?: Record; description?: string; isAutoSyncEnabled?: boolean; - subscriberId?: string; + subscriberId?: string | null; }>; responseSchema: z.ZodTypeAny; syncOptions: { diff --git a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts index 158d9e4dc..ecf7da4cc 100644 --- a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts +++ b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts @@ -2,10 +2,11 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags } from "@app/lib/api-docs"; -import { readLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { AuthMode } from "@app/services/auth/auth-type"; +import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; const PkiSyncSchema = z.object({ @@ -60,7 +61,8 @@ const PkiSyncSchema = z.object({ name: z.string() }) .nullable() - .optional() + .optional(), + hasCertificate: z.boolean().optional() }); const PkiSyncOptionsSchema = z.object({ @@ -76,6 +78,24 @@ const PkiSyncOptionsSchema = z.object({ minCertificateNameLength: z.number().optional() }); +const PkiSyncCertificateSchema = z.object({ + id: z.string().uuid(), + pkiSyncId: z.string().uuid(), + certificateId: z.string().uuid(), + syncStatus: z.nativeEnum(CertificateSyncStatus), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + certificateSerialNumber: z.string().optional(), + certificateCommonName: z.string().optional(), + certificateStatus: z.string().optional(), + certificateNotBefore: z.date().optional(), + certificateNotAfter: z.date().optional(), + pkiSyncName: z.string().optional(), + pkiSyncDestination: z.string().optional() +}); + export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", @@ -111,7 +131,8 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiSyncs], description: "List all the PKI Syncs for the specified project.", querystring: z.object({ - projectId: z.string().trim().min(1) + projectId: z.string().trim().min(1), + certificateId: z.string().uuid().optional() }), response: { 200: z.object({ pkiSyncs: PkiSyncSchema.array() }) @@ -120,11 +141,11 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const { - query: { projectId }, + query: { projectId, certificateId }, permission } = req; - const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId }, permission); + const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId, certificateId }, permission); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -179,4 +200,167 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { return pkiSync; } }); + + server.route({ + method: "GET", + url: "/:pkiSyncId/certificates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "List all certificates associated with a PKI Sync.", + params: z.object({ + pkiSyncId: z.string().uuid() + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20) + }), + response: { + 200: z.object({ + certificates: PkiSyncCertificateSchema.array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + const { offset, limit } = req.query; + + const result = await server.services.pkiSync.listPkiSyncCertificates( + { pkiSyncId, offset, limit }, + req.permission + ); + + const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.GET_PKI_SYNC, + metadata: { + syncId: pkiSyncId, + destination: pkiSync.destination + } + } + }); + + return result; + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/certificates", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "Add certificates to a PKI Sync.", + params: z.object({ + pkiSyncId: z.string().uuid() + }), + body: z.object({ + certificateIds: z.array(z.string().uuid()).min(1, "At least one certificate ID is required") + }), + response: { + 200: z.object({ + addedCertificates: z.array( + z.object({ + id: z.string().uuid(), + pkiSyncId: z.string().uuid(), + certificateId: z.string().uuid(), + syncStatus: z.string().default("pending").optional().nullable(), + lastSyncMessage: z.string().optional().nullable(), + lastSyncedAt: z.date().optional().nullable(), + createdAt: z.date(), + updatedAt: z.date() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + const { certificateIds } = req.body; + + const addedCertificates = await server.services.pkiSync.addCertificatesToPkiSync( + { pkiSyncId, certificateIds }, + req.permission + ); + + const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.UPDATE_PKI_SYNC, + metadata: { + pkiSyncId, + name: pkiSync.name + } + } + }); + + return { addedCertificates }; + } + }); + + server.route({ + method: "DELETE", + url: "/:pkiSyncId/certificates", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "Remove certificates from a PKI Sync.", + params: z.object({ + pkiSyncId: z.string().uuid() + }), + body: z.object({ + certificateIds: z.array(z.string().uuid()).min(1, "At least one certificate ID is required") + }), + response: { + 200: z.object({ + removedCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + const { certificateIds } = req.body; + + const result = await server.services.pkiSync.removeCertificatesFromPkiSync( + { pkiSyncId, certificateIds }, + req.permission + ); + + const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSync.projectId, + event: { + type: EventType.UPDATE_PKI_SYNC, + metadata: { + pkiSyncId, + name: pkiSync.name + } + } + }); + + return result; + } + }); }; diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index c3bffa2fc..1054d359b 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1195,8 +1195,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { querystring: z.object({ friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName), commonName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.commonName), - offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset), - limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit) + offset: z.coerce.number().min(0).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset), + limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit), + forPkiSync: z.coerce + .boolean() + .default(false) + .optional() + .describe("Retrieve only certificates available for PKI sync") }), response: { 200: z.object({ diff --git a/backend/src/services/certificate-sync/certificate-sync-dal.ts b/backend/src/services/certificate-sync/certificate-sync-dal.ts new file mode 100644 index 000000000..278641844 --- /dev/null +++ b/backend/src/services/certificate-sync/certificate-sync-dal.ts @@ -0,0 +1,259 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TCertificateSyncs } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; + +import { CertificateSyncStatus } from "./certificate-sync-enums"; + +export type TCertificateSyncDALFactory = ReturnType; + +type CertificateSyncFindFilter = Parameters>[0]; + +export const certificateSyncDALFactory = (db: TDbClient) => { + const certificateSyncOrm = ormify(db, TableName.CertificateSync); + + const findByPkiSyncId = async (pkiSyncId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ pkiSyncId }) + .select(selectAllTableCols(TableName.CertificateSync)); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindByPkiSyncId" }); + } + }; + + const findByCertificateId = async (certificateId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ certificateId }) + .select(selectAllTableCols(TableName.CertificateSync)); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindByCertificateId" }); + } + }; + + const findByPkiSyncAndCertificate = async (pkiSyncId: string, certificateId: string, tx?: Knex) => { + try { + const doc = await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ pkiSyncId, certificateId }) + .select(selectAllTableCols(TableName.CertificateSync)) + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindByPkiSyncAndCertificate" }); + } + }; + + const findCertificateIdsByPkiSyncId = async (pkiSyncId: string, tx?: Knex): Promise => { + try { + const docs = (await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ pkiSyncId }) + .select("certificateId")) as Array<{ certificateId: string }>; + return docs.map((doc) => doc.certificateId); + } catch (error) { + throw new DatabaseError({ error, name: "FindCertificateIdsByPkiSyncId" }); + } + }; + + const findPkiSyncIdsByCertificateId = async (certificateId: string, tx?: Knex): Promise => { + try { + const docs = (await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ certificateId }) + .select("pkiSyncId")) as Array<{ pkiSyncId: string }>; + return docs.map((doc) => doc.pkiSyncId); + } catch (error) { + throw new DatabaseError({ error, name: "FindPkiSyncIdsByCertificateId" }); + } + }; + + const addCertificates = async ( + pkiSyncId: string, + certificateIds: string[], + tx?: Knex + ): Promise => { + try { + const insertData = certificateIds.map((certificateId) => ({ + pkiSyncId, + certificateId, + syncStatus: CertificateSyncStatus.Pending + })); + + const docs = await (tx || db)(TableName.CertificateSync).insert(insertData).returning("*"); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "AddCertificates" }); + } + }; + + const removeCertificates = async (pkiSyncId: string, certificateIds: string[], tx?: Knex): Promise => { + try { + const deletedCount = await (tx || db)(TableName.CertificateSync) + .where({ pkiSyncId }) + .whereIn("certificateId", certificateIds) + .del(); + + return deletedCount; + } catch (error) { + throw new DatabaseError({ error, name: "RemoveCertificates" }); + } + }; + + const removeAllCertificatesFromSync = async (pkiSyncId: string, tx?: Knex): Promise => { + try { + const deletedCount = await (tx || db)(TableName.CertificateSync).where({ pkiSyncId }).del(); + return deletedCount; + } catch (error) { + throw new DatabaseError({ error, name: "RemoveAllCertificatesFromSync" }); + } + }; + + const updateSyncStatus = async ( + pkiSyncId: string, + certificateId: string, + status: string, + message?: string, + tx?: Knex + ): Promise => { + try { + const updateData: Partial = { + syncStatus: status, + lastSyncedAt: new Date() + }; + + if (message !== undefined) { + updateData.lastSyncMessage = message; + } + + const docs = await (tx || db)(TableName.CertificateSync) + .where({ pkiSyncId, certificateId }) + .update(updateData) + .returning("*"); + + return docs[0]; + } catch (error) { + throw new DatabaseError({ error, name: "UpdateSyncStatus" }); + } + }; + + const bulkUpdateSyncStatus = async ( + updates: Array<{ + pkiSyncId: string; + certificateId: string; + status: string; + message?: string; + }>, + tx?: Knex + ): Promise => { + try { + if (tx) { + for (const update of updates) { + // eslint-disable-next-line no-await-in-loop + await updateSyncStatus(update.pkiSyncId, update.certificateId, update.status, update.message, tx); + } + } else { + await certificateSyncOrm.transaction(async (trx) => { + for (const update of updates) { + // eslint-disable-next-line no-await-in-loop + await updateSyncStatus(update.pkiSyncId, update.certificateId, update.status, update.message, trx); + } + }); + } + } catch (error) { + throw new DatabaseError({ error, name: "BulkUpdateSyncStatus" }); + } + }; + + const findWithDetails = async ( + options: { + filter?: CertificateSyncFindFilter; + pkiSyncId?: string; + offset?: number; + limit?: number; + }, + tx?: Knex + ): Promise<{ + certificateDetails: (TCertificateSyncs & { + certificateSerialNumber?: string; + certificateCommonName?: string; + certificateStatus?: string; + certificateNotBefore?: Date; + certificateNotAfter?: Date; + pkiSyncName?: string; + pkiSyncDestination?: string; + })[]; + totalCount: number; + }> => { + try { + const { filter, pkiSyncId, offset, limit } = options; + + const baseQuery = (tx || db.replicaNode())(TableName.CertificateSync) + .leftJoin(TableName.Certificate, `${TableName.CertificateSync}.certificateId`, `${TableName.Certificate}.id`) + .leftJoin(TableName.PkiSync, `${TableName.CertificateSync}.pkiSyncId`, `${TableName.PkiSync}.id`); + + if (filter) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void baseQuery.where(buildFindFilter(filter)); + } + if (pkiSyncId) { + void baseQuery.where(`${TableName.CertificateSync}.pkiSyncId`, pkiSyncId); + } + + const countResult = await baseQuery.clone().count("* as count"); + const totalCount = Number((countResult[0] as unknown as { count: string | number }).count); + + const query = baseQuery + .select(selectAllTableCols(TableName.CertificateSync)) + .select( + db.ref("serialNumber").withSchema(TableName.Certificate).as("certificateSerialNumber"), + db.ref("commonName").withSchema(TableName.Certificate).as("certificateCommonName"), + db.ref("status").withSchema(TableName.Certificate).as("certificateStatus"), + db.ref("notBefore").withSchema(TableName.Certificate).as("certificateNotBefore"), + db.ref("notAfter").withSchema(TableName.Certificate).as("certificateNotAfter"), + db.ref("name").withSchema(TableName.PkiSync).as("pkiSyncName"), + db.ref("destination").withSchema(TableName.PkiSync).as("pkiSyncDestination") + ) + .orderBy(`${TableName.CertificateSync}.createdAt`, "desc"); + + if (offset !== undefined) { + void query.offset(offset); + } + if (limit !== undefined) { + void query.limit(limit); + } + + const certificateDetails = (await query) as (TCertificateSyncs & { + certificateSerialNumber?: string; + certificateCommonName?: string; + certificateStatus?: string; + certificateNotBefore?: Date; + certificateNotAfter?: Date; + pkiSyncName?: string; + pkiSyncDestination?: string; + })[]; + + return { certificateDetails, totalCount }; + } catch (error) { + throw new DatabaseError({ error, name: "FindWithDetails" }); + } + }; + + return { + ...certificateSyncOrm, + findByPkiSyncId, + findByCertificateId, + findByPkiSyncAndCertificate, + findCertificateIdsByPkiSyncId, + findPkiSyncIdsByCertificateId, + addCertificates, + removeCertificates, + removeAllCertificatesFromSync, + updateSyncStatus, + bulkUpdateSyncStatus, + findWithDetails + }; +}; diff --git a/backend/src/services/certificate-sync/certificate-sync-enums.ts b/backend/src/services/certificate-sync/certificate-sync-enums.ts new file mode 100644 index 000000000..97c08b339 --- /dev/null +++ b/backend/src/services/certificate-sync/certificate-sync-enums.ts @@ -0,0 +1,6 @@ +export enum CertificateSyncStatus { + Pending = "pending", + Syncing = "syncing", + Succeeded = "succeeded", + Failed = "failed" +} diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index 0c70571dd..11b51ecc7 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -133,7 +133,18 @@ describe("CertificateV3Service", () => { certificateProfileDAL: mockCertificateProfileDAL, certificateTemplateV2Service: mockCertificateTemplateV2Service, internalCaService: mockInternalCaService, - permissionService: mockPermissionService + permissionService: mockPermissionService, + certificateSyncDAL: { + findPkiSyncIdsByCertificateId: vi.fn().mockResolvedValue([]), + addCertificates: vi.fn().mockResolvedValue([]), + removeCertificates: vi.fn().mockResolvedValue(0) + }, + pkiSyncDAL: { + find: vi.fn().mockResolvedValue([]) + }, + pkiSyncQueue: { + queuePkiSyncSyncCertificatesById: vi.fn().mockResolvedValue(undefined) + } }); }); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 0a721b2db..6f5870f4f 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -48,6 +48,10 @@ import { mapEnumsForValidation, normalizeDateForApi } from "../certificate-common/certificate-utils"; +import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; +import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; +import { replaceCertificateInSyncs, triggerAutoSyncForCertificate } from "../pki-sync/pki-sync-utils"; import { TCertificateFromProfileResponse, TCertificateOrderResponse, @@ -72,6 +76,12 @@ type TCertificateV3ServiceFactoryDep = { >; internalCaService: Pick; permissionService: Pick; + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + "findPkiSyncIdsByCertificateId" | "removeCertificates" | "addCertificates" + >; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TCertificateV3ServiceFactory = ReturnType; @@ -328,7 +338,10 @@ export const certificateV3ServiceFactory = ({ certificateProfileDAL, certificateTemplateV2Service, internalCaService, - permissionService + permissionService, + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue }: TCertificateV3ServiceFactoryDep) => { const issueCertificateFromProfile = async ({ profileId, @@ -872,6 +885,8 @@ export const certificateV3ServiceFactory = ({ tx ); + await replaceCertificateInSyncs(originalCert.id, newCert.id, { certificateSyncDAL }, tx); + return { certificate, certificateChain, @@ -883,6 +898,12 @@ export const certificateV3ServiceFactory = ({ }; }); + await triggerAutoSyncForCertificate(renewalResult.newCert.id, { + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue + }); + return { certificate: renewalResult.certificate, issuingCaCertificate: renewalResult.issuingCaCertificate, diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index eb40b85a5..7af79319b 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -1,3 +1,5 @@ +import RE2 from "re2"; + import { TDbClient } from "@app/db"; import { TableName, TCertificates } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -60,11 +62,13 @@ export const certificateDALFactory = (db: TDbClient) => { .where(`${TableName.Project}.id`, projectId); if (friendlyName) { - query = query.andWhere(`${TableName.Certificate}.friendlyName`, friendlyName); + const sanitizedValue = String(friendlyName).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.friendlyName`, "like", `%${sanitizedValue}%`); } if (commonName) { - query = query.andWhere(`${TableName.Certificate}.commonName`, commonName); + const sanitizedValue = String(commonName).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`); } const count = await query.count("*").first(); @@ -114,6 +118,109 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findActiveCertificatesByIds = async (certificateIds: string[]): Promise => { + try { + if (certificateIds.length === 0) { + return []; + } + + const certs = await db + .replicaNode()(TableName.Certificate) + .whereIn("id", certificateIds) + .where({ status: CertStatus.ACTIVE }) + .where("notAfter", ">", new Date()) + .orderBy("notBefore", "desc") + .select("*"); + + return certs; + } catch (error) { + throw new DatabaseError({ error, name: "Find active certificates by IDs" }); + } + }; + + const findActiveCertificatesForSync = async ( + filter: Partial, + options?: { limit?: number; offset?: number } + ): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => { + try { + let query = db + .replicaNode()(TableName.Certificate) + .leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`) + .select(selectAllTableCols(TableName.Certificate)) + .select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef")) + .where({ status: CertStatus.ACTIVE }) + .where("notAfter", ">", new Date()) + .whereNull("renewedByCertificateId"); + + Object.entries(filter).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (key === "friendlyName" || key === "commonName") { + const sanitizedValue = String(value).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.${key}`, "like", `%${sanitizedValue}%`); + } else { + query = query.andWhere(`${TableName.Certificate}.${key}`, value); + } + } + }); + + if (options?.offset) { + query = query.offset(options.offset); + } + + if (options?.limit) { + query = query.limit(options.limit); + } + + query = query.orderBy("createdAt", "desc"); + + const certs = await query; + return certs.map((cert) => ({ ...cert, hasPrivateKey: Boolean(cert.privateKeyRef) })); + } catch (error) { + throw new DatabaseError({ error, name: "Find active certificates for sync" }); + } + }; + + const countActiveCertificatesForSync = async ({ + projectId, + friendlyName, + commonName + }: { + projectId: string; + friendlyName?: string; + commonName?: string; + }) => { + try { + interface CountResult { + count: string; + } + + let query = db + .replicaNode()(TableName.Certificate) + .join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`) + .join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId) + .where(`${TableName.Certificate}.status`, CertStatus.ACTIVE) + .where(`${TableName.Certificate}.notAfter`, ">", new Date()) + .whereNull(`${TableName.Certificate}.renewedByCertificateId`); + + if (friendlyName) { + const sanitizedValue = String(friendlyName).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.friendlyName`, "like", `%${sanitizedValue}%`); + } + + if (commonName) { + const sanitizedValue = String(commonName).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`); + } + + const count = await query.count("*").first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count active certificates for sync" }); + } + }; + const findCertificatesEligibleForRenewal = async ({ limit, offset @@ -159,7 +266,7 @@ export const certificateDALFactory = (db: TDbClient) => { }; const findWithPrivateKeyInfo = async ( - filter: Partial, + filter: Partial, options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] } ): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => { try { @@ -167,8 +274,18 @@ export const certificateDALFactory = (db: TDbClient) => { .replicaNode()(TableName.Certificate) .leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`) .select(selectAllTableCols(TableName.Certificate)) - .select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef")) - .where(filter); + .select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef")); + + Object.entries(filter).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (key === "friendlyName" || key === "commonName") { + const sanitizedValue = String(value).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.${key}`, "like", `%${sanitizedValue}%`); + } else { + query = query.andWhere(`${TableName.Certificate}.${key}`, value); + } + } + }); if (options?.offset) { query = query.offset(options.offset); @@ -197,10 +314,13 @@ export const certificateDALFactory = (db: TDbClient) => { return { ...certificateOrm, countCertificatesInProject, + countActiveCertificatesForSync, countCertificatesForPkiSubscriber, findLatestActiveCertForSubscriber, findAllActiveCertsForSubscriber, findExpiredSyncedCertificates, + findActiveCertificatesByIds, + findActiveCertificatesForSync, findCertificatesEligibleForRenewal, findWithPrivateKeyInfo }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 91731c387..eb8006f00 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -18,12 +18,13 @@ import { TCertificateAuthorityDALFactory } from "@app/services/certificate-autho import { CaCapability, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { caSupportsCapability } from "@app/services/certificate-authority/certificate-authority-maps"; import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; +import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; -import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; +import { triggerAutoSyncForCertificate } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -57,6 +58,7 @@ type TCertificateServiceFactoryDep = { projectDAL: Pick; kmsService: Pick; permissionService: Pick; + certificateSyncDAL: Pick; pkiSyncDAL: Pick; pkiSyncQueue: Pick; }; @@ -76,6 +78,7 @@ export const certificateServiceFactory = ({ projectDAL, kmsService, permissionService, + certificateSyncDAL, pkiSyncDAL, pkiSyncQueue }: TCertificateServiceFactoryDep) => { @@ -166,10 +169,12 @@ export const certificateServiceFactory = ({ const deletedCert = await certificateDAL.deleteById(cert.id); - // Trigger auto sync for PKI syncs connected to this certificate's subscriber - if (cert.pkiSubscriberId) { - await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue }); - } + // Trigger auto sync for PKI syncs connected to this certificate + await triggerAutoSyncForCertificate(cert.id, { + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue + }); return { deletedCert @@ -235,10 +240,12 @@ export const certificateServiceFactory = ({ } ); - // Trigger auto sync for PKI syncs connected to this certificate's subscriber - if (cert.pkiSubscriberId) { - await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue }); - } + // Trigger auto sync for PKI syncs connected to this certificate + await triggerAutoSyncForCertificate(cert.id, { + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue + }); // Note: External CA revocation handling would go here for supported CA types // Currently, only internal CAs and ACME CAs support revocation diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts index f78bd790e..2f10afbdd 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts @@ -4,6 +4,7 @@ import RE2 from "re2"; import { z } from "zod"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; @@ -101,7 +102,8 @@ const findInfisicalCertificateTag = (tags: AWS.ACM.TagList | undefined): AWS.ACM const validateCertificateIdentification = ( certName: string, - existingCert: { arn?: string; Tags?: AWS.ACM.TagList; cert?: string; privateKey?: string; certificateChain?: string } + existingCert: { arn?: string; Tags?: AWS.ACM.TagList; cert?: string; privateKey?: string; certificateChain?: string }, + alternativeCertNames?: string[] ): boolean => { if (!existingCert?.arn || !existingCert?.Tags) { return false; @@ -113,12 +115,15 @@ const validateCertificateIdentification = ( return false; } - return certNameTag.Value === certName; -}; + if (certNameTag.Value === certName) { + return true; + } -type TAwsCertificateManagerPkiSyncFactoryDeps = { - appConnectionDAL: Pick; - kmsService: Pick; + if (alternativeCertNames && alternativeCertNames.includes(certNameTag.Value)) { + return true; + } + + return false; }; const validateCertificateNameSchema = (schema: string): void => { @@ -174,6 +179,11 @@ const generateCertificateName = (certificateName: string, pkiSync: TPkiSyncWithC return sanitizedCertificateName; }; +type TAwsCertificateManagerPkiSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + const getAwsAcmClient = async ( connectionId: string, region: AWSRegion, @@ -392,48 +402,59 @@ export const awsCertificateManagerPkiSyncFactory = ({ kmsService ); - const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); + const { + acmCertificates + }: { + acmCertificates: Record< + string, + { cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList } + >; + } = await $getAwsAcmCertificates(acm, pkiSync.id); const setCertificates: CertificateImportRequest[] = []; + const validationErrors: Array<{ name: string; error: string }> = []; const activeCertificateNames = Object.keys(certificateMap); + const syncOptions = pkiSync.syncOptions as { preserveArn?: boolean } | undefined; + const preserveArn = syncOptions?.preserveArn ?? true; 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; + const { cert, privateKey, certificateChain, alternativeNames } = certData; 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 - } + const errorMessage = validationError instanceof Error ? validationError.message : String(validationError); + validationErrors.push({ + name: certName, + error: `Certificate validation failed: ${errorMessage}` }); + return; } - if (shouldUpdateCert) { - setCertificates.push({ - key: certName, - name: certificateName, - cert, - privateKey, - certificateChain, - existingArn: existingCert?.arn - }); + const certificateName = generateCertificateName(certName, pkiSync); + + let existingArn: string | undefined; + + const existingCert = Object.values(acmCertificates).find((acmCert) => { + return validateCertificateIdentification(certName, acmCert, alternativeNames); + }); + + if (existingCert?.arn && preserveArn) { + // When preserveArn is true, reuse the existing ARN + existingArn = existingCert.arn; } + + setCertificates.push({ + key: certName, + name: certificateName, + cert, + privateKey, + certificateChain, + existingArn + }); }); - // Identify expired/removed certificates that need to be cleaned up from ACM const certificatesToRemove = Object.values(acmCertificates) .filter((acmCert) => { if (!acmCert.arn || !acmCert.Tags) { @@ -445,8 +466,22 @@ export const awsCertificateManagerPkiSyncFactory = ({ return false; } - const isActive = activeCertificateNames.includes(certNameTag.Value); - return !isActive; + 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); @@ -457,14 +492,17 @@ export const awsCertificateManagerPkiSyncFactory = ({ try { const importParams: AWS.ACM.ImportCertificateRequest = { Certificate: cert, - PrivateKey: privateKey, - Tags: [ + PrivateKey: privateKey + }; + + if (!existingArn) { + importParams.Tags = [ { Key: INFISICAL_CERTIFICATE_TAG, Value: key } - ] - }; + ]; + } if (certificateChain && certificateChain.trim().length > 0) { importParams.CertificateChain = certificateChain; @@ -478,6 +516,39 @@ export const awsCertificateManagerPkiSyncFactory = ({ syncId: pkiSync.id }); + if (existingArn && response.CertificateArn) { + try { + // Small delay to ensure AWS ACM has processed the certificate import + await new Promise((resolve) => { + setTimeout(() => resolve(), 100); + }); + + await withRateLimitRetry( + () => + acm + .addTagsToCertificate({ + CertificateArn: response.CertificateArn!, + Tags: [ + { + Key: INFISICAL_CERTIFICATE_TAG, + Value: key + } + ] + }) + .promise(), + { + operation: "add-tags-to-certificate", + syncId: pkiSync.id + } + ); + } catch (tagError) { + const errorMessage = tagError instanceof Error ? tagError.message : "Unknown tagging error"; + logger.warn( + `Failed to add tags to certificate ${key} (ARN: ${response.CertificateArn}): ${errorMessage}` + ); + } + } + return { key, name, success: true, response }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; @@ -520,15 +591,21 @@ export const awsCertificateManagerPkiSyncFactory = ({ const details: { failedUploads?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>; + validationErrors?: Array<{ name: string; error: string }>; } = {}; + if (validationErrors.length > 0) { + details.validationErrors = validationErrors; + } + if (failedUploads.length > 0) { details.failedUploads = failedUploads.map((failure, index) => { - const certificateName = setCertificates[index]?.name || "unknown"; + const certificateRequest = setCertificates[index]; + const certificateName = certificateRequest?.name || certificateRequest?.key || "unknown"; let errorMessage = "Unknown error"; if (failure.status === "rejected") { - errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error"; + errorMessage = failure.reason instanceof Error ? failure.reason.message : String(failure.reason); } return { @@ -577,7 +654,14 @@ export const awsCertificateManagerPkiSyncFactory = ({ kmsService ); - const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); + const { + acmCertificates + }: { + acmCertificates: Record< + string, + { cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList } + >; + } = await $getAwsAcmCertificates(acm, pkiSync.id); const certificateArnsToRemove: string[] = []; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts index eb9ae5444..3b9f5c881 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts @@ -14,6 +14,7 @@ export const AwsCertificateManagerPkiSyncConfigSchema = z.object({ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + preserveArn: z.boolean().default(true), certificateNameSchema: z .string() .optional() @@ -28,6 +29,9 @@ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ const testName = schema .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id") + .replace(new RE2("\\{\\{profileId\\}\\}", "g"), "test-profile-id") + .replace(new RE2("\\{\\{commonName\\}\\}", "g"), "test-common-name") + .replace(new RE2("\\{\\{friendlyName\\}\\}", "g"), "test-friendly-name") .replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env"); const hasForbiddenChars = AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some( @@ -43,7 +47,7 @@ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ }, { 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" + "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. Available placeholders: {{certificateId}}, {{profileId}}, {{commonName}}, {{friendlyName}}, {{environment}}" } ) }); @@ -60,9 +64,10 @@ export const CreateAwsCertificateManagerPkiSyncSchema = z.object({ isAutoSyncEnabled: z.boolean().default(true), destinationConfig: AwsCertificateManagerPkiSyncConfigSchema, syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional().default({}), - subscriberId: z.string().optional(), + subscriberId: z.string().nullish(), connectionId: z.string(), - projectId: z.string().trim().min(1) + projectId: z.string().trim().min(1), + certificateIds: z.array(z.string().uuid()).optional() }); export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({ @@ -71,7 +76,7 @@ export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({ isAutoSyncEnabled: z.boolean().optional(), destinationConfig: AwsCertificateManagerPkiSyncConfigSchema.optional(), syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional(), - subscriberId: z.string().optional(), + subscriberId: z.string().nullish(), connectionId: z.string().optional() }); diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts index 717e86438..c9770c5b2 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts @@ -39,6 +39,7 @@ export interface SyncCertificatesResult { details?: { failedUploads?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>; + validationErrors?: Array<{ name: string; error: string }>; }; } diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts index ef6347e82..4aac20cc0 100644 --- a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts @@ -52,7 +52,8 @@ export const CreateAzureKeyVaultPkiSyncSchema = z.object({ syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional().default({}), subscriberId: z.string().optional(), connectionId: z.string(), - projectId: z.string().trim().min(1) + projectId: z.string().trim().min(1), + certificateIds: z.array(z.string().uuid()).optional() }); export const UpdateAzureKeyVaultPkiSyncSchema = z.object({ diff --git a/backend/src/services/pki-sync/pki-sync-fns.ts b/backend/src/services/pki-sync/pki-sync-fns.ts index 75f312fff..7fbcd0773 100644 --- a/backend/src/services/pki-sync/pki-sync-fns.ts +++ b/backend/src/services/pki-sync/pki-sync-fns.ts @@ -194,6 +194,7 @@ export const PkiSyncFns = { failedUploads?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>; skippedCertificates?: Array<{ name: string; reason: string }>; + validationErrors?: Array<{ name: string; error: string }>; }; }> => { switch (pkiSync.destination) { diff --git a/backend/src/services/pki-sync/pki-sync-queue.ts b/backend/src/services/pki-sync/pki-sync-queue.ts index 5967a6c97..5889e2210 100644 --- a/backend/src/services/pki-sync/pki-sync-queue.ts +++ b/backend/src/services/pki-sync/pki-sync-queue.ts @@ -5,6 +5,7 @@ import { AxiosError } from "axios"; import { Job } from "bullmq"; import handlebars from "handlebars"; +import { TCertificates } from "@app/db/schemas"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; @@ -25,6 +26,7 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret- import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { getCaCertChain } from "../certificate-authority/certificate-authority-fns"; +import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { PkiSyncStatus } from "./pki-sync-enums"; import { PkiSyncError } from "./pki-sync-errors"; @@ -57,12 +59,21 @@ type TPkiSyncQueueFactoryDep = { licenseService: Pick; certificateDAL: Pick< TCertificateDALFactory, - "findLatestActiveCertForSubscriber" | "findAllActiveCertsForSubscriber" | "create" + | "findLatestActiveCertForSubscriber" + | "findAllActiveCertsForSubscriber" + | "findActiveCertificatesByIds" + | "create" + | "findById" + | "find" >; certificateBodyDAL: Pick; certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + "findCertificateIdsByPkiSyncId" | "updateSyncStatus" | "bulkUpdateSyncStatus" + >; }; type PkiSyncActionJob = Job< @@ -93,7 +104,8 @@ export const pkiSyncQueueFactory = ({ certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, - certificateAuthorityCertDAL + certificateAuthorityCertDAL, + certificateSyncDAL }: TPkiSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -153,25 +165,39 @@ export const pkiSyncQueueFactory = ({ const $getInfisicalCertificates = async ( pkiSync: TPkiSyncRaw | TPkiSyncWithCredentials - ): Promise => { - const { projectId, subscriberId } = pkiSync; - - if (!subscriberId) { - throw new PkiSyncError({ - message: "Invalid PKI Sync source configuration: subscriber no longer exists. Please update source subscriber.", - shouldRetry: false - }); - } + ): Promise<{ certificateMap: TCertificateMap; certificateMetadata: Map }> => { + const { projectId, subscriberId, id: pkiSyncId } = pkiSync; const certificateMap: TCertificateMap = {}; + const certificateMetadata = new Map(); + let certificates: Array<{ id: string; projectId: string; caCertId?: string | null }> = []; try { - // Get all active certificates for the subscriber (not just the latest) - const certificates = await certificateDAL.findAllActiveCertsForSubscriber({ - subscriberId - }); + if (subscriberId) { + const subscriberCertificates = await certificateDAL.findAllActiveCertsForSubscriber({ + subscriberId + }); + certificates.push(...subscriberCertificates); + } + + const certificateIds = await certificateSyncDAL.findCertificateIdsByPkiSyncId(pkiSyncId); + if (certificateIds.length > 0) { + const directCertificates = await certificateDAL.findActiveCertificatesByIds(certificateIds); + certificates.push(...directCertificates); + } + + const uniqueCertificates = certificates.filter( + (cert, index, self) => self.findIndex((c) => c.id === cert.id) === index + ); + + if (uniqueCertificates.length === 0) { + return { certificateMap, certificateMetadata }; + } + + certificates = uniqueCertificates; for (const certificate of certificates) { + const cert = certificate as TCertificates; try { // Get the certificate body and decrypt the certificate data const certBody = await certificateBodyDAL.findOne({ certId: certificate.id }); @@ -246,19 +272,44 @@ export const pkiSyncQueueFactory = ({ if (certificateNameSchema) { const environment = "global"; - certificateName = handlebars.compile(certificateNameSchema)({ + const templateData = { certificateId: certificate.id.replace(/-/g, ""), + profileId: cert.profileId?.replace(/-/g, "") || certificate.id.replace(/-/g, ""), + commonName: cert.commonName || "", + friendlyName: cert.friendlyName || "", environment - }); + }; + certificateName = handlebars.compile(certificateNameSchema)(templateData); } else { - certificateName = `Infisical-${certificate.id.replace(/-/g, "")}`; + const stableId = cert.profileId + ? `${cert.profileId.replace(/-/g, "")}-${(cert.commonName || "").replace(/[^a-zA-Z0-9]/g, "")}` + : certificate.id.replace(/-/g, ""); + certificateName = `Infisical-${stableId}`; + } + + const alternativeNames: string[] = []; + + const legacyName = `Infisical-${certificate.id.replace(/-/g, "")}`; + if (legacyName !== certificateName) { + alternativeNames.push(legacyName); + } + + if (cert.renewedFromCertificateId) { + const originalLegacyName = `Infisical-${cert.renewedFromCertificateId.replace(/-/g, "")}`; + alternativeNames.push(originalLegacyName); } certificateMap[certificateName] = { cert: certificatePem, privateKey: certPrivateKey || "", - certificateChain + certificateChain, + alternativeNames }; + + certificateMetadata.set(certificateName, { + id: certificate.id, + name: certificateName + }); } else { logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate"); } @@ -281,7 +332,7 @@ export const pkiSyncQueueFactory = ({ }); } - return certificateMap; + return { certificateMap, certificateMetadata }; }; const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) => @@ -348,12 +399,17 @@ export const pkiSyncQueueFactory = ({ try { const { - connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } + connection: { id: connectionId, orgId, projectId: appConnectionProjectId } } = pkiSync; + const appConnection = await appConnectionDAL.findById(connectionId); + if (!appConnection) { + throw new Error(`App connection not found: ${connectionId}`); + } + const credentials = await decryptAppConnectionCredentials({ orgId, - encryptedCredentials, + encryptedCredentials: appConnection.encryptedCredentials, kmsService, projectId: appConnectionProjectId }); @@ -366,7 +422,18 @@ export const pkiSyncQueueFactory = ({ } } as TPkiSyncWithCredentials; - const certificateMap = await $getInfisicalCertificates(pkiSync); + const { certificateMap, certificateMetadata } = await $getInfisicalCertificates(pkiSync); + + const statusUpdates = Array.from(certificateMetadata.entries()).map(([, metadata]) => ({ + pkiSyncId: pkiSync.id, + certificateId: metadata.id, + status: "running", + message: "Syncing certificate to destination" + })); + + if (statusUpdates.length > 0) { + await certificateSyncDAL.bulkUpdateSyncStatus(statusUpdates); + } const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, { appConnectionDAL, @@ -384,6 +451,60 @@ export const pkiSyncQueueFactory = ({ "PKI sync operation completed with certificate cleanup" ); + const postSyncUpdates: Array<{ + pkiSyncId: string; + certificateId: string; + status: string; + message?: string; + }> = []; + + for (const [, metadata] of certificateMetadata.entries()) { + postSyncUpdates.push({ + pkiSyncId: pkiSync.id, + certificateId: metadata.id, + status: "succeeded", + message: "Certificate successfully synced to destination" + }); + } + + if (syncResult.details?.validationErrors) { + for (const validationError of syncResult.details.validationErrors) { + const metadata = certificateMetadata.get(validationError.name); + if (metadata) { + const updateIndex = postSyncUpdates.findIndex((u) => u.certificateId === metadata.id); + if (updateIndex >= 0) { + postSyncUpdates[updateIndex] = { + pkiSyncId: pkiSync.id, + certificateId: metadata.id, + status: "failed", + message: `${validationError.error}` + }; + } + } + } + } + + if (syncResult.details?.failedUploads) { + for (const failure of syncResult.details.failedUploads) { + const metadata = certificateMetadata.get(failure.name); + if (metadata) { + const updateIndex = postSyncUpdates.findIndex((u) => u.certificateId === metadata.id); + if (updateIndex >= 0) { + postSyncUpdates[updateIndex] = { + pkiSyncId: pkiSync.id, + certificateId: metadata.id, + status: "failed", + message: `Failed to sync certificate: ${failure.error}` + }; + } + } + } + } + + if (postSyncUpdates.length > 0) { + await certificateSyncDAL.bulkUpdateSyncStatus(postSyncUpdates); + } + isSynced = true; } catch (err) { logger.error( @@ -550,17 +671,22 @@ export const pkiSyncQueueFactory = ({ try { const { - connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } + connection: { id: connectionId, orgId, projectId: appConnectionProjectId } } = pkiSync; + const appConnection = await appConnectionDAL.findById(connectionId); + if (!appConnection) { + throw new Error(`App connection not found: ${connectionId}`); + } + const credentials = await decryptAppConnectionCredentials({ orgId, - encryptedCredentials, + encryptedCredentials: appConnection.encryptedCredentials, kmsService, projectId: appConnectionProjectId }); - const certificateMap = await $getInfisicalCertificates(pkiSync); + const { certificateMap } = await $getInfisicalCertificates(pkiSync); await PkiSyncFns.removeCertificates( { diff --git a/backend/src/services/pki-sync/pki-sync-service.ts b/backend/src/services/pki-sync/pki-sync-service.ts index f92c9e19f..a04d27580 100644 --- a/backend/src/services/pki-sync/pki-sync-service.ts +++ b/backend/src/services/pki-sync/pki-sync-service.ts @@ -10,17 +10,23 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; +import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { PkiSync, PkiSyncStatus } from "./pki-sync-enums"; import { enterprisePkiSyncCheck, getPkiSyncProviderCapabilities, listPkiSyncOptions } from "./pki-sync-fns"; import { PKI_SYNC_CONNECTION_MAP, PKI_SYNC_NAME_MAP } from "./pki-sync-maps"; import { TPkiSyncQueueFactory } from "./pki-sync-queue"; import { + TAddCertificatesToPkiSyncDTO, TCreatePkiSyncDTO, TDeletePkiSyncDTO, TFindPkiSyncByIdDTO, + TListPkiSyncCertificatesDTO, TListPkiSyncsByProjectId, TPkiSync, + TPkiSyncCertificate, + TRemoveCertificatesFromPkiSyncDTO, TTriggerPkiSyncImportCertificatesByIdDTO, TTriggerPkiSyncRemoveCertificatesByIdDTO, TTriggerPkiSyncSyncCertificatesByIdDTO, @@ -42,6 +48,16 @@ type TPkiSyncServiceFactoryDep = { TPkiSyncDALFactory, "findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById" >; + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + | "findByPkiSyncId" + | "findByCertificateId" + | "findCertificateIdsByPkiSyncId" + | "addCertificates" + | "removeCertificates" + | "removeAllCertificatesFromSync" + | "findWithDetails" + >; pkiSubscriberDAL: Pick; appConnectionService: Pick; permissionService: Pick; @@ -56,6 +72,7 @@ export type TPkiSyncServiceFactory = ReturnType; export const pkiSyncServiceFactory = ({ pkiSyncDAL, + certificateSyncDAL, pkiSubscriberDAL, appConnectionService, permissionService, @@ -72,7 +89,8 @@ export const pkiSyncServiceFactory = ({ syncOptions = {}, subscriberId, connectionId, - projectId + projectId, + certificateIds = [] }: Omit, actor: OrgServiceActor ): Promise => { @@ -128,6 +146,10 @@ export const pkiSyncServiceFactory = ({ ...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending }) }); + if (certificateIds.length > 0) { + await certificateSyncDAL.addCertificates(pkiSync.id, certificateIds); + } + if (pkiSync.isAutoSyncEnabled) { await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); } @@ -152,7 +174,8 @@ export const pkiSyncServiceFactory = ({ destinationConfig, syncOptions, subscriberId, - connectionId + connectionId, + certificateIds }: Omit, actor: OrgServiceActor ): Promise => { @@ -221,6 +244,13 @@ export const pkiSyncServiceFactory = ({ }; } + if (certificateIds !== undefined) { + await certificateSyncDAL.removeAllCertificatesFromSync(id); + if (certificateIds.length > 0) { + await certificateSyncDAL.addCertificates(id, certificateIds); + } + } + const updatedPkiSync = await pkiSyncDAL.updateById(id, { name, description, @@ -266,7 +296,7 @@ export const pkiSyncServiceFactory = ({ }; const listPkiSyncsByProjectId = async ( - { projectId }: TListPkiSyncsByProjectId, + { projectId, certificateId }: TListPkiSyncsByProjectId, actor: OrgServiceActor ): Promise => { const { permission } = await permissionService.getProjectPermission({ @@ -282,6 +312,29 @@ export const pkiSyncServiceFactory = ({ const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId); + if (certificateId) { + const syncsWithCertificateInfo = await Promise.all( + pkiSyncsWithSubscribers.map(async (sync) => { + try { + const certificateSyncs = await certificateSyncDAL.findByPkiSyncId(sync.id); + const hasCertificate = certificateSyncs.some((certSync) => certSync.certificateId === certificateId); + + return { + ...sync, + hasCertificate + }; + } catch (error) { + return { + ...sync, + hasCertificate: false + }; + } + }) + ); + + return syncsWithCertificateInfo as TPkiSync[]; + } + return pkiSyncsWithSubscribers as TPkiSync[]; }; @@ -433,6 +486,136 @@ export const pkiSyncServiceFactory = ({ return listPkiSyncOptions(); }; + const addCertificatesToPkiSync = async ( + { pkiSyncId, certificateIds }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(pkiSyncId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let subscriber; + 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 addedCertificates = await certificateSyncDAL.addCertificates(pkiSyncId, certificateIds); + + if (pkiSync.isAutoSyncEnabled) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId }); + } + + return addedCertificates; + }; + + const removeCertificatesFromPkiSync = async ( + { pkiSyncId, certificateIds }: Omit, + actor: OrgServiceActor + ) => { + const pkiSync = await pkiSyncDAL.findById(pkiSyncId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let subscriber; + 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); + + if (pkiSync.isAutoSyncEnabled) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId }); + } + + return { removedCount }; + }; + + const listPkiSyncCertificates = async ( + { pkiSyncId, offset = 0, limit = 20 }: Omit, + actor: OrgServiceActor + ): Promise<{ certificates: TPkiSyncCertificate[]; totalCount: number }> => { + const pkiSync = await pkiSyncDAL.findById(pkiSyncId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + let subscriber; + 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({ + pkiSyncId, + offset, + limit + }); + const { certificateDetails, totalCount } = result; + + const certificates = certificateDetails.map((detail) => ({ + id: detail.id, + pkiSyncId: detail.pkiSyncId, + certificateId: detail.certificateId, + syncStatus: (detail.syncStatus as CertificateSyncStatus) || CertificateSyncStatus.Pending, + lastSyncMessage: detail.lastSyncMessage || undefined, + lastSyncedAt: detail.lastSyncedAt || undefined, + createdAt: detail.createdAt, + updatedAt: detail.updatedAt, + certificateSerialNumber: detail.certificateSerialNumber || undefined, + certificateCommonName: detail.certificateCommonName || undefined, + certificateStatus: detail.certificateStatus || undefined, + certificateNotBefore: detail.certificateNotBefore || undefined, + certificateNotAfter: detail.certificateNotAfter || undefined, + pkiSyncName: detail.pkiSyncName || undefined, + pkiSyncDestination: detail.pkiSyncDestination || undefined + })); + + return { certificates, totalCount }; + }; + return { createPkiSync, updatePkiSync, @@ -442,6 +625,9 @@ export const pkiSyncServiceFactory = ({ triggerPkiSyncSyncCertificatesById, triggerPkiSyncImportCertificatesById, triggerPkiSyncRemoveCertificatesById, - getPkiSyncOptions + getPkiSyncOptions, + addCertificatesToPkiSync, + removeCertificatesFromPkiSync, + listPkiSyncCertificates }; }; diff --git a/backend/src/services/pki-sync/pki-sync-types.ts b/backend/src/services/pki-sync/pki-sync-types.ts index bf750beee..e51facc8e 100644 --- a/backend/src/services/pki-sync/pki-sync-types.ts +++ b/backend/src/services/pki-sync/pki-sync-types.ts @@ -2,6 +2,7 @@ import { Job } from "bullmq"; import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; import { QueueJobs } from "@app/queue"; +import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; @@ -70,7 +71,10 @@ export type TPkiSyncListItem = TPkiSync & { appConnectionApp: string; }; -export type TCertificateMap = Record; +export type TCertificateMap = Record< + string, + { cert: string; privateKey: string; certificateChain?: string; alternativeNames?: string[] } +>; export type TCreatePkiSyncDTO = { name: string; @@ -79,9 +83,10 @@ export type TCreatePkiSyncDTO = { isAutoSyncEnabled?: boolean; destinationConfig: Record; syncOptions?: Record; - subscriberId?: string; + subscriberId?: string | null; connectionId: string; projectId: string; + certificateIds?: string[]; auditLogInfo: AuditLogInfo; resourceMetadata?: ResourceMetadataDTO; }; @@ -94,8 +99,9 @@ export type TUpdatePkiSyncDTO = { isAutoSyncEnabled?: boolean; destinationConfig?: Record; syncOptions?: Record; - subscriberId?: string; + subscriberId?: string | null; connectionId?: string; + certificateIds?: string[]; auditLogInfo: AuditLogInfo; resourceMetadata?: ResourceMetadataDTO; }; @@ -108,6 +114,7 @@ export type TDeletePkiSyncDTO = { export type TListPkiSyncsByProjectId = { projectId: string; + certificateId?: string; }; export type TFindPkiSyncByIdDTO = { @@ -133,6 +140,45 @@ export type TTriggerPkiSyncRemoveCertificatesByIdDTO = { auditLogInfo: AuditLogInfo; }; +export type TAddCertificatesToPkiSyncDTO = { + pkiSyncId: string; + certificateIds: string[]; + projectId?: string; + auditLogInfo: AuditLogInfo; +}; + +export type TRemoveCertificatesFromPkiSyncDTO = { + pkiSyncId: string; + certificateIds: string[]; + projectId?: string; + auditLogInfo: AuditLogInfo; +}; + +export type TListPkiSyncCertificatesDTO = { + pkiSyncId: string; + projectId?: string; + offset?: number; + limit?: number; +}; + +export type TPkiSyncCertificate = { + id: string; + pkiSyncId: string; + certificateId: string; + syncStatus: CertificateSyncStatus; + lastSyncMessage?: string; + lastSyncedAt?: Date; + createdAt: Date; + updatedAt: Date; + certificate?: { + serialNumber: string; + commonName: string; + status: string; + notBefore: Date; + notAfter: Date; + }; +}; + export type TPkiSyncRaw = NonNullable>>; export type TQueuePkiSyncSyncCertificatesByIdDTO = { diff --git a/backend/src/services/pki-sync/pki-sync-utils.ts b/backend/src/services/pki-sync/pki-sync-utils.ts index a81864a17..3a62c1d21 100644 --- a/backend/src/services/pki-sync/pki-sync-utils.ts +++ b/backend/src/services/pki-sync/pki-sync-utils.ts @@ -1,5 +1,8 @@ +import { Knex } from "knex"; + import { logger } from "@app/lib/logger"; +import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncQueueFactory } from "./pki-sync-queue"; @@ -25,3 +28,67 @@ export const triggerAutoSyncForSubscriber = async ( logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); } }; + +export const triggerAutoSyncForCertificate = async ( + certificateId: string, + dependencies: { + certificateSyncDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; + } +) => { + try { + const pkiSyncIds = await dependencies.certificateSyncDAL.findPkiSyncIdsByCertificateId(certificateId); + + if (pkiSyncIds.length === 0) { + return; + } + + const allPkiSyncs = await dependencies.pkiSyncDAL.find({ + isAutoSyncEnabled: true + }); + + const pkiSyncs = allPkiSyncs.filter((sync) => pkiSyncIds.includes(sync.id)); + + const syncPromises = pkiSyncs.map((pkiSync) => + dependencies.pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }) + ); + await Promise.all(syncPromises); + } catch (error) { + logger.error(error, `Failed to trigger auto sync for certificate ${certificateId}:`); + } +}; + +export const replaceCertificateInSyncs = async ( + oldCertificateId: string, + newCertificateId: string, + dependencies: { + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + "findPkiSyncIdsByCertificateId" | "removeCertificates" | "addCertificates" + >; + }, + tx?: Knex +) => { + try { + const pkiSyncIds = await dependencies.certificateSyncDAL.findPkiSyncIdsByCertificateId(oldCertificateId); + + if (pkiSyncIds.length === 0) { + return; + } + + const replacementPromises = pkiSyncIds.map(async (pkiSyncId) => { + await dependencies.certificateSyncDAL.removeCertificates(pkiSyncId, [oldCertificateId], tx); + await dependencies.certificateSyncDAL.addCertificates(pkiSyncId, [newCertificateId], tx); + }); + + await Promise.all(replacementPromises); + + logger.info( + `Successfully replaced certificate ${oldCertificateId} with ${newCertificateId} in ${pkiSyncIds.length} PKI sync(s)` + ); + } catch (error) { + logger.error(error, `Failed to replace certificate ${oldCertificateId} with ${newCertificateId} in syncs:`); + throw error; + } +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 05b007a6a..39538de2e 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -154,7 +154,14 @@ type TProjectServiceFactoryDep = { >; pkiSubscriberDAL: Pick; certificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + | "find" + | "countCertificatesInProject" + | "findWithPrivateKeyInfo" + | "findActiveCertificatesForSync" + | "countActiveCertificatesForSync" + >; certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; @@ -915,6 +922,7 @@ export const projectServiceFactory = ({ offset = 0, friendlyName, commonName, + forPkiSync = false, actorId, actorOrgId, actorAuthMethod, @@ -938,20 +946,35 @@ export const projectServiceFactory = ({ ProjectPermissionSub.Certificates ); - const certificates = await certificateDAL.findWithPrivateKeyInfo( - { - projectId, - ...(friendlyName && { friendlyName }), - ...(commonName && { commonName }) - }, - { offset, limit, sort: [["notAfter", "desc"]] } - ); + const certificates = forPkiSync + ? await certificateDAL.findActiveCertificatesForSync( + { + projectId, + ...(friendlyName && { friendlyName }), + ...(commonName && { commonName }) + }, + { offset, limit } + ) + : await certificateDAL.findWithPrivateKeyInfo( + { + projectId, + ...(friendlyName && { friendlyName }), + ...(commonName && { commonName }) + }, + { offset, limit, sort: [["notAfter", "desc"]] } + ); - const count = await certificateDAL.countCertificatesInProject({ - projectId, - friendlyName, - commonName - }); + const count = forPkiSync + ? await certificateDAL.countActiveCertificatesForSync({ + projectId, + friendlyName, + commonName + }) + : await certificateDAL.countCertificatesInProject({ + projectId, + friendlyName, + commonName + }); return { certificates, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 18ae74350..2b75b1bc7 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -142,6 +142,7 @@ export type TListProjectCertsDTO = { limit: number; friendlyName?: string; commonName?: string; + forPkiSync?: boolean; } & Omit; export type TListProjectAlertsDTO = TProjectPermission; diff --git a/frontend/src/components/pki-syncs/CertificateManagementModal.tsx b/frontend/src/components/pki-syncs/CertificateManagementModal.tsx new file mode 100644 index 000000000..fa199cb3a --- /dev/null +++ b/frontend/src/components/pki-syncs/CertificateManagementModal.tsx @@ -0,0 +1,434 @@ +import React, { useEffect, useState } from "react"; +import { faSearch, faX } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { + Badge, + Button, + Checkbox, + EmptyState, + Input, + Modal, + ModalContent, + Pagination, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { + CertStatus, + useAddCertificatesToPkiSync, + useListPkiSyncCertificates, + useRemoveCertificatesFromPkiSync +} from "@app/hooks/api"; +import { TPkiSync } from "@app/hooks/api/pkiSyncs"; +import { useListWorkspaceCertificates } from "@app/hooks/api/projects"; + +type Props = { + isOpen: boolean; + onClose: () => void; + pkiSync?: TPkiSync; + onCertificatesUpdated?: () => void; + selectedCertificateIds?: string[]; + onCertificateSelectionChange?: (certificateIds: string[]) => void; + title?: string; + subtitle?: string; + saveButtonText?: string; +}; + +export const CertificateManagementModal = ({ + isOpen, + onClose, + pkiSync, + onCertificatesUpdated, + selectedCertificateIds, + onCertificateSelectionChange, + title = "Manage Certificate Sync", + subtitle = "Select which certificates should be synced.", + saveButtonText = "Save Changes" +}: Props) => { + const { currentProject } = useProject(); + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const pageSize = 10; + + const isCreateMode = !pkiSync; + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + setCurrentPage(1); + }, 300); + + return () => { + clearTimeout(handler); + }; + }, [searchTerm]); + + const { data } = useListWorkspaceCertificates({ + projectId: currentProject?.id || "", + offset: (currentPage - 1) * pageSize, + limit: pageSize, + commonName: debouncedSearchTerm || undefined, + friendlyName: debouncedSearchTerm || undefined, + forPkiSync: true + }); + + const allCertificates = data?.certificates || []; + const totalCount = data?.totalCount || 0; + + const { data: syncData } = useListPkiSyncCertificates(pkiSync?.id || ""); + const syncCertificates = syncData?.certificates || []; + const addCertificatesToSync = useAddCertificatesToPkiSync(); + const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync(); + + const syncedCertificateIds = isCreateMode + ? selectedCertificateIds || [] + : syncCertificates.map((sc) => sc.certificateId); + + const totalPages = Math.ceil(totalCount / pageSize); + + const [selectedIds, setSelectedIds] = useState([]); + + React.useEffect(() => { + setSelectedIds(syncedCertificateIds); + }, [JSON.stringify(syncedCertificateIds)]); + + const handleToggleSelection = (certId: string) => { + setSelectedIds((prev) => + prev.includes(certId) ? prev.filter((id) => id !== certId) : [...prev, certId] + ); + }; + + const handleSelectAll = () => { + const currentPageIds = allCertificates.map((cert) => cert.id); + const allCurrentPageSelected = currentPageIds.every((id) => selectedIds.includes(id)); + + if (allCurrentPageSelected) { + setSelectedIds((prev) => prev.filter((id) => !currentPageIds.includes(id))); + } else { + setSelectedIds((prev) => [...new Set([...prev, ...currentPageIds])]); + } + }; + + const clearSearch = () => { + setSearchTerm(""); + setCurrentPage(1); + }; + + React.useEffect(() => { + if (isOpen) { + setCurrentPage(1); + setSearchTerm(""); + } + }, [isOpen]); + + const handleSaveCertificates = async () => { + try { + if (isCreateMode) { + if (onCertificateSelectionChange) { + onCertificateSelectionChange(selectedIds); + onClose(); + } + return; + } + + if (!pkiSync) return; + + const certificatesToAdd = selectedIds.filter((id) => !syncedCertificateIds.includes(id)); + const certificatesToRemove = syncedCertificateIds.filter((id) => !selectedIds.includes(id)); + + const invalidCertificates = certificatesToAdd + .map((id) => allCertificates.find((cert) => cert.id === id)) + .filter((cert) => { + if (!cert) return false; + const isExpired = new Date(cert.notAfter) < new Date(); + const isRevoked = cert.status === CertStatus.REVOKED; + return isExpired || isRevoked; + }); + + if (invalidCertificates.length > 0) { + const invalidNames = invalidCertificates.map((cert) => cert?.commonName).join(", "); + createNotification({ + text: `Cannot add expired or revoked certificates: ${invalidNames}`, + type: "error" + }); + return; + } + + const operations = []; + + if (certificatesToAdd.length > 0) { + operations.push( + addCertificatesToSync + .mutateAsync({ + pkiSyncId: pkiSync.id, + certificateIds: certificatesToAdd + }) + .then(() => ({ + type: "add", + count: certificatesToAdd.length, + success: true + })) + .catch((error) => ({ + type: "add", + count: certificatesToAdd.length, + success: false, + error + })) + ); + } + + if (certificatesToRemove.length > 0) { + operations.push( + removeCertificatesFromSync + .mutateAsync({ + pkiSyncId: pkiSync.id, + certificateIds: certificatesToRemove + }) + .then(() => ({ + type: "remove", + count: certificatesToRemove.length, + success: true + })) + .catch((error) => ({ + type: "remove", + count: certificatesToRemove.length, + success: false, + error + })) + ); + } + + if (operations.length === 0) { + createNotification({ + text: "No changes to save", + type: "info" + }); + onClose(); + return; + } + + const results = await Promise.all(operations); + const failures = results.filter((r) => !r.success); + const successes = results.filter((r) => r.success); + + if (failures.length === 0) { + const addCount = successes.find((r) => r.type === "add")?.count || 0; + const removeCount = successes.find((r) => r.type === "remove")?.count || 0; + + let message = "Certificate selection updated successfully"; + if (addCount > 0 && removeCount > 0) { + message = `Added ${addCount} and removed ${removeCount} certificate(s)`; + } else if (addCount > 0) { + message = `Added ${addCount} certificate(s)`; + } else if (removeCount > 0) { + message = `Removed ${removeCount} certificate(s)`; + } + + createNotification({ + text: message, + type: "success" + }); + + if (onCertificatesUpdated) { + onCertificatesUpdated(); + } + onClose(); + } else { + const partialSuccess = successes.length > 0; + console.error("Certificate sync operation failures:", failures); + + createNotification({ + text: partialSuccess + ? "Some certificate changes failed. Check console for details." + : "Failed to update certificate selection", + type: partialSuccess ? "warning" : "error" + }); + + if (partialSuccess && onCertificatesUpdated) { + onCertificatesUpdated(); + } + } + } catch (error) { + console.error("Unexpected error during certificate sync operation:", error); + createNotification({ + text: "An unexpected error occurred while updating certificates", + type: "error" + }); + } + }; + + const isLoading = addCertificatesToSync.isPending || removeCertificatesFromSync.isPending; + + return ( + !open && onClose()}> + +
+
+
+ { + setSearchTerm(e.target.value); + setCurrentPage(1); + }} + className="pl-9" + /> + + {searchTerm && ( + + )} +
+
+ + {allCertificates.length === 0 ? ( + + {searchTerm + ? "No certificates match your search criteria." + : "No certificates available for sync."} + + ) : ( + <> + + + + + + + + + + + + + {allCertificates.map((cert) => { + const isExpired = new Date(cert.notAfter) < new Date(); + const isRevoked = cert.status === CertStatus.REVOKED; + const cannotBeAdded = isExpired || isRevoked; + const isAlreadySynced = syncedCertificateIds.includes(cert.id); + + return ( + { + if (!cannotBeAdded || isAlreadySynced) { + handleToggleSelection(cert.id); + } + }} + > + + + + + + + ); + })} + +
+ 0 && + allCertificates.every((cert) => selectedIds.includes(cert.id)) + } + onCheckedChange={handleSelectAll} + /> + Common NameSerial NumberStatusExpires
+ { + if (!cannotBeAdded || isAlreadySynced) { + handleToggleSelection(cert.id); + } + }} + isDisabled={cannotBeAdded && !isAlreadySynced} + /> + +
+ {cert.commonName} +
+
+
+ {cert.serialNumber} +
+
+ + {(() => { + if (isRevoked) return "Revoked"; + if (isExpired) return "Expired"; + return cert.status === CertStatus.ACTIVE ? "Active" : cert.status; + })()} + + + + {new Date(cert.notAfter).toLocaleDateString()} + +
+
+ + {totalPages > 1 && ( +
+ setCurrentPage(page)} + onChangePerPage={() => {}} + /> +
+ )} + + )} +
+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx index 582518bd7..305fda516 100644 --- a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx +++ b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx @@ -13,11 +13,11 @@ import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PkiSync, TPkiSync, useCreatePkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas/pki-sync-schema"; +import { PkiSyncCertificatesFields } from "./PkiSyncCertificatesFields"; import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; import { PkiSyncReviewFields } from "./PkiSyncReviewFields"; -import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; type Props = { onComplete: (pkiSync: TPkiSync) => void; @@ -26,10 +26,10 @@ type Props = { }; const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [ - { name: "Source", key: "source", fields: ["subscriberId"] }, { name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] }, { name: "Sync Options", key: "options", fields: ["syncOptions"] }, { name: "Details", key: "details", fields: ["name", "description"] }, + { name: "Certificates", key: "certificates", fields: ["certificateIds"] }, { name: "Review", key: "review", fields: [] } ]; @@ -49,34 +49,46 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) defaultValues: { destination, isAutoSyncEnabled: false, + certificateIds: [], syncOptions: { canImportCertificates: false, canRemoveCertificates: false, + preserveArn: true, certificateNameSchema: syncOption?.defaultCertificateNameSchema } } as Partial, reValidateMode: "onChange" }); - const onSubmit = async ({ connection, destinationConfig, ...formData }: TPkiSyncForm) => { + const onSubmit = async ({ + connection, + destinationConfig, + certificateIds, + ...formData + }: TPkiSyncForm) => { try { const pkiSync = await createPkiSync.mutateAsync({ ...formData, connectionId: connection.id, projectId: currentProject.id, - destinationConfig + destinationConfig, + certificateIds: certificateIds || [] }); createNotification({ - text: `Successfully added ${destinationName} Certificate Sync`, + text: `Successfully created ${destinationName} Certificate Sync${ + certificateIds && certificateIds.length > 0 + ? ` with ${certificateIds.length} certificate(s)` + : "" + }`, type: "success" }); onComplete(pkiSync); } catch (err: Error | unknown) { - console.error(err); + console.error("PKI sync creation failed:", err); setShowConfirmation(false); createNotification({ - title: `Failed to add ${destinationName} Certificate Sync`, + title: `Failed to create ${destinationName} Certificate Sync`, text: err instanceof Error ? err.message : "An unknown error occurred", type: "error" }); @@ -184,9 +196,6 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) ))} - - - @@ -200,8 +209,8 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) + + + diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncCertificatesFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncCertificatesFields.tsx new file mode 100644 index 000000000..51a93f4c9 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncCertificatesFields.tsx @@ -0,0 +1,139 @@ +import { useMemo, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + EmptyState, + FormControl, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { CertStatus } from "@app/hooks/api"; +import { useListWorkspaceCertificates } from "@app/hooks/api/projects"; + +import { CertificateManagementModal } from "../CertificateManagementModal"; +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; + +export const PkiSyncCertificatesFields = () => { + const { control, watch, setValue } = useFormContext(); + const { currentProject } = useProject(); + const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); + + const certificateIds = watch("certificateIds") || []; + + const { data, isLoading } = useListWorkspaceCertificates({ + projectId: currentProject?.id || "", + offset: 0, + limit: 100, + forPkiSync: true + }); + + const certificates = data?.certificates || []; + + const activeCertificates = useMemo( + () => certificates.filter((cert) => cert.status === CertStatus.ACTIVE), + [certificates] + ); + + const selectedCertificates = useMemo( + () => activeCertificates.filter((cert) => certificateIds.includes(cert.id)), + [activeCertificates, certificateIds] + ); + + if (isLoading) { + return ( +
+
Loading certificates...
+
+ ); + } + + return ( + <> +

+ Select certificates to sync with this integration. Only active certificates can be synced. + You can modify this selection after creating the sync. +

+ + ( + +
+ + {selectedCertificates.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {selectedCertificates.map((cert) => ( + + + + + + ))} + +
Common NameSerial NumberRemove
{cert.commonName} + {cert.serialNumber} + + +
+
+
+ )} +
+
+ )} + /> + + setIsSelectionModalOpen(false)} + selectedCertificateIds={certificateIds} + onCertificateSelectionChange={(newCertificateIds) => { + setValue("certificateIds", newCertificateIds); + }} + title="Select Certificates for Sync" + subtitle="Choose which certificates you want to include in this sync. You can modify this selection after creating the sync." + saveButtonText="Update Selection" + /> + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx index ce581bb88..c646d6ca2 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx @@ -95,6 +95,49 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => { )} /> + {currentDestination === PkiSync.AwsCertificateManager && ( + ( + + +

+ Preserve ARN on Renewal{" "} + +

+ When enabled, Infisical will replace the contents of existing certificates + while preserving the same ARN during certificate renewal syncs. +

+

+ This allows consuming services like load balancers to continue using the + same ARN without requiring manual updates. +

+

+ When disabled, new certificates will be created with new ARNs, and old + certificates will be removed. +

+ + } + > + + +

+
+
+ )} + /> + )} + { const { watch } = useFormContext(); const { currentProject } = useProject(); - const { data: pkiSubscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || ""); + const { data } = useListWorkspaceCertificates({ + projectId: currentProject?.id || "", + offset: 0, + limit: 100 + }); - const getSubscriberName = (subscriberId?: string) => { - const subscriber = pkiSubscribers.find((sub) => sub.id === subscriberId); - return subscriber?.name || "Unknown"; + const certificates = data?.certificates || []; + + const getSelectedCertificates = (certificateIds?: string[]) => { + if (!certificateIds || certificateIds.length === 0) return []; + return certificates.filter((cert) => certificateIds.includes(cert.id)); }; const { name, description, connection, - subscriberId, + certificateIds, syncOptions, destination, destinationConfig, @@ -30,17 +36,28 @@ export const PkiSyncReviewFields = () => { } = watch(); const destinationName = PKI_SYNC_MAP[destination].name; + const selectedCertificates = getSelectedCertificates(certificateIds); return (
- Source + Certificates
- - {getSubscriberName(subscriberId)} - +
+ {selectedCertificates.length === 0 ? ( + No certificates selected + ) : ( +
+ {selectedCertificates.map((cert) => ( +
+ {cert.commonName} +
+ ))} +
+ )} +
diff --git a/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts index aa522ef7d..4333b6187 100644 --- a/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts +++ b/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts @@ -7,6 +7,7 @@ import { BasePkiSyncSchema } from "./base-pki-sync-schema"; const AwsCertificateManagerSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(false), + preserveArn: z.boolean().default(true), certificateNameSchema: z .string() .optional() diff --git a/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts index f8c9d599f..73da1f6af 100644 --- a/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts +++ b/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts @@ -53,7 +53,8 @@ export const BasePkiSyncSchema = { } }); }; + +export const useAddCertificatesToPkiSync = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + pkiSyncId, + certificateIds + }: { + pkiSyncId: string; + certificateIds: string[]; + }) => { + const { data } = await apiRequest.post(`/api/v1/pki/syncs/${pkiSyncId}/certificates`, { + certificateIds + }); + + return data; + }, + onSuccess: (_, { pkiSyncId }) => { + queryClient.invalidateQueries({ queryKey: pkiSyncKeys.certificates(pkiSyncId) }); + } + }); +}; + +export const useRemoveCertificatesFromPkiSync = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + pkiSyncId, + certificateIds + }: { + pkiSyncId: string; + certificateIds: string[]; + }) => { + const { data } = await apiRequest.delete(`/api/v1/pki/syncs/${pkiSyncId}/certificates`, { + data: { certificateIds } + }); + + return data; + }, + onSuccess: (_, { pkiSyncId }) => { + queryClient.invalidateQueries({ queryKey: pkiSyncKeys.certificates(pkiSyncId) }); + } + }); +}; diff --git a/frontend/src/hooks/api/pkiSyncs/queries.tsx b/frontend/src/hooks/api/pkiSyncs/queries.tsx index d0db913bd..6e7aabc42 100644 --- a/frontend/src/hooks/api/pkiSyncs/queries.tsx +++ b/frontend/src/hooks/api/pkiSyncs/queries.tsx @@ -2,14 +2,25 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { PkiSync, TPkiSyncOption } from "@app/hooks/api/pkiSyncs"; -import { TListPkiSyncOptions, TListPkiSyncs, TPkiSync } from "@app/hooks/api/pkiSyncs/types"; +import { + TListPkiSyncOptions, + TListPkiSyncs, + TPkiSync, + TPkiSyncCertificate +} from "@app/hooks/api/pkiSyncs/types"; export const pkiSyncKeys = { all: ["pki-sync"] as const, options: () => [...pkiSyncKeys.all, "options"] as const, list: (projectId: string) => [...pkiSyncKeys.all, "list", projectId] as const, + listWithCertificate: (projectId: string, certificateId: string) => + [...pkiSyncKeys.all, "list", projectId, "with-certificate", certificateId] as const, byId: (syncId: string, projectId: string) => - [...pkiSyncKeys.all, "by-id", syncId, projectId] as const + [...pkiSyncKeys.all, "by-id", syncId, projectId] as const, + certificates: (syncId: string, pagination?: { offset: number; limit: number }) => + pagination + ? ([...pkiSyncKeys.all, "certificates", syncId, pagination] as const) + : ([...pkiSyncKeys.all, "certificates", syncId] as const) }; export const usePkiSyncOptions = ( @@ -41,9 +52,14 @@ export const usePkiSyncOption = (destination: PkiSync) => { return { syncOption, isPending }; }; -export const fetchPkiSyncsByProjectId = async (projectId: string) => { +export const fetchPkiSyncsByProjectId = async (projectId: string, certificateId?: string) => { + const params: { projectId: string; certificateId?: string } = { projectId }; + if (certificateId) { + params.certificateId = certificateId; + } + const { data } = await apiRequest.get("/api/v1/pki/syncs", { - params: { projectId } + params }); return data.pkiSyncs; @@ -63,6 +79,27 @@ export const useListPkiSyncs = ( }); }; +export const useListPkiSyncsWithCertificate = ( + projectId: string, + certificateId: string, + options?: Omit< + UseQueryOptions< + TPkiSync[], + unknown, + TPkiSync[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pkiSyncKeys.listWithCertificate(projectId, certificateId), + queryFn: () => fetchPkiSyncsByProjectId(projectId, certificateId), + enabled: !!projectId && !!certificateId, + ...options + }); +}; + export const useGetPkiSync = ( { syncId, projectId }: { syncId: string; projectId: string }, options?: Omit< @@ -82,3 +119,33 @@ export const useGetPkiSync = ( ...options }); }; + +export const useListPkiSyncCertificates = ( + syncId: string, + pagination?: { offset?: number; limit?: number }, + options?: Omit< + UseQueryOptions< + { certificates: TPkiSyncCertificate[]; totalCount: number }, + unknown, + { certificates: TPkiSyncCertificate[]; totalCount: number }, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + const { offset = 0, limit = 20 } = pagination || {}; + + return useQuery({ + queryKey: pkiSyncKeys.certificates(syncId, { offset, limit }), + queryFn: async () => { + const { data } = await apiRequest.get(`/api/v1/pki/syncs/${syncId}/certificates`, { + params: { offset, limit } + }); + return { + certificates: data.certificates || [], + totalCount: data.totalCount || 0 + }; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/pkiSyncs/types/common.ts b/frontend/src/hooks/api/pkiSyncs/types/common.ts index 8cd1aef96..556cd0aa3 100644 --- a/frontend/src/hooks/api/pkiSyncs/types/common.ts +++ b/frontend/src/hooks/api/pkiSyncs/types/common.ts @@ -1,6 +1,6 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; -import { PkiSyncStatus } from "../enums"; +import { CertificateSyncStatus, PkiSyncStatus } from "../enums"; export type RootPkiSyncOptions = { canImportCertificates: boolean; @@ -43,4 +43,23 @@ export type TRootPkiSync = { } | null; appConnectionName?: string; appConnectionApp?: string; + hasCertificate?: boolean; +}; + +export type TPkiSyncCertificate = { + id: string; + pkiSyncId: string; + certificateId: string; + syncStatus?: CertificateSyncStatus | null; + lastSyncMessage?: string | null; + lastSyncedAt?: string | null; + createdAt: string; + updatedAt: string; + certificateSerialNumber?: string; + certificateCommonName?: string; + certificateStatus?: string; + certificateNotBefore?: Date; + certificateNotAfter?: Date; + pkiSyncName?: string; + pkiSyncDestination?: string; }; diff --git a/frontend/src/hooks/api/pkiSyncs/types/index.ts b/frontend/src/hooks/api/pkiSyncs/types/index.ts index 899217f52..69ec3ae6f 100644 --- a/frontend/src/hooks/api/pkiSyncs/types/index.ts +++ b/frontend/src/hooks/api/pkiSyncs/types/index.ts @@ -33,7 +33,8 @@ type TCreatePkiSyncDTOBase = { certificateNameSchema?: string; }; isAutoSyncEnabled: boolean; - subscriberId?: string; + subscriberId?: string | null; + certificateIds?: string[]; projectId: string; }; diff --git a/frontend/src/hooks/api/projects/queries.tsx b/frontend/src/hooks/api/projects/queries.tsx index 0445d5984..1613e95a9 100644 --- a/frontend/src/hooks/api/projects/queries.tsx +++ b/frontend/src/hooks/api/projects/queries.tsx @@ -664,17 +664,26 @@ export const useListWorkspaceCas = ({ export const useListWorkspaceCertificates = ({ projectId, offset, - limit + limit, + friendlyName, + commonName, + forPkiSync }: { projectId: string; offset: number; limit: number; + friendlyName?: string; + commonName?: string; + forPkiSync?: boolean; }) => { return useQuery({ queryKey: projectKeys.specificProjectCertificates({ projectId, offset, - limit + limit, + friendlyName, + commonName, + forPkiSync }), queryFn: async () => { const params = new URLSearchParams({ @@ -682,6 +691,16 @@ export const useListWorkspaceCertificates = ({ limit: String(limit) }); + if (friendlyName) { + params.append("friendlyName", friendlyName); + } + if (commonName) { + params.append("commonName", commonName); + } + if (forPkiSync) { + params.append("forPkiSync", "true"); + } + const { data: { certificates, totalCount } } = await apiRequest.get<{ certificates: TCertificate[]; totalCount: number }>( @@ -693,7 +712,8 @@ export const useListWorkspaceCertificates = ({ return { certificates, totalCount }; }, - enabled: Boolean(projectId) + enabled: Boolean(projectId), + placeholderData: (previousData) => previousData }); }; diff --git a/frontend/src/hooks/api/projects/query-keys.tsx b/frontend/src/hooks/api/projects/query-keys.tsx index 51e3db71c..04f14f90d 100644 --- a/frontend/src/hooks/api/projects/query-keys.tsx +++ b/frontend/src/hooks/api/projects/query-keys.tsx @@ -39,12 +39,22 @@ export const projectKeys = { specificProjectCertificates: ({ projectId, offset, - limit + limit, + friendlyName, + commonName, + forPkiSync }: { projectId: string; offset: number; limit: number; - }) => [...projectKeys.forProjectCertificates(projectId), { offset, limit }] as const, + friendlyName?: string; + commonName?: string; + forPkiSync?: boolean; + }) => + [ + ...projectKeys.forProjectCertificates(projectId), + { offset, limit, friendlyName, commonName, forPkiSync } + ] as const, getProjectPkiAlerts: (projectId: string) => [{ projectId }, "project-pki-alerts"] as const, getProjectPkiSubscribers: (projectId: string) => [{ projectId }, "project-pki-subscribers"] as const, diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index 2c088c293..c33821ed9 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -51,25 +51,9 @@ export const PkiManagerLayout = () => { params={{ projectId: currentProject.id }} - > - {({ isActive }) => Policies} - - {({ isActive }) => ( - - Certificates - + Certificate Management )} { - const { t } = useTranslation(); - const { permission } = useProjectPermission(); - - const canAccessPkiColl = permission.can( - ProjectPermissionActions.Read, - ProjectPermissionSub.PkiCollections - ); - const canAccessCerts = permission.can( - ProjectPermissionCertificateActions.Read, - ProjectPermissionSub.Certificates - ); - - return ( -
- - {t("common.head-title", { title: "Certificates" })} - -
- - {/* If both are false, the section does not render. This is to prevent duplicate banners. */} - {(canAccessCerts || canAccessPkiColl) && ( - - - - )} - - - -
-
- ); -}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManagePkiSyncsModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManagePkiSyncsModal.tsx new file mode 100644 index 000000000..f366ac4c6 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManagePkiSyncsModal.tsx @@ -0,0 +1,253 @@ +import { useEffect, useMemo, useState } from "react"; +import { faPlus, faSearch } from "@fortawesome/free-solid-svg-icons"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + EmptyState, + Input, + Modal, + ModalContent, + Pagination, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { + useAddCertificatesToPkiSync, + useListPkiSyncsWithCertificate, + useRemoveCertificatesFromPkiSync +} from "@app/hooks/api/pkiSyncs"; + +type Props = { + popUp: { + isOpen: boolean; + data?: { + certificateId?: string; + commonName?: string; + }; + }; + handlePopUpToggle: (popUpName: "managePkiSyncs", state?: boolean) => void; +}; + +const PER_PAGE = 10; + +export const CertificateManagePkiSyncsModal = ({ popUp, handlePopUpToggle }: Props) => { + const [selectedSyncIds, setSelectedSyncIds] = useState>(new Set()); + const [initialSyncIds, setInitialSyncIds] = useState>(new Set()); + const [isSubmitting, setIsSubmitting] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(""); + + const { currentProject } = useProject(); + const { certificateId, commonName } = popUp.data || {}; + + const { data: pkiSyncs = [], isPending } = useListPkiSyncsWithCertificate( + currentProject?.id || "", + certificateId || "", + { + enabled: !!currentProject?.id && !!certificateId + } + ); + const addCertificatesToSync = useAddCertificatesToPkiSync(); + const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync(); + + const filteredSyncs = useMemo(() => { + if (!searchTerm.trim()) return pkiSyncs; + + const searchLower = searchTerm.toLowerCase(); + return pkiSyncs.filter((sync) => sync.name.toLowerCase().includes(searchLower)); + }, [pkiSyncs, searchTerm]); + + const startIndex = (currentPage - 1) * PER_PAGE; + const endIndex = startIndex + PER_PAGE; + const paginatedSyncs = filteredSyncs.slice(startIndex, endIndex); + + useEffect(() => { + setCurrentPage(1); + }, [searchTerm]); + + const handleClose = () => { + handlePopUpToggle("managePkiSyncs", false); + setSelectedSyncIds(new Set()); + setInitialSyncIds(new Set()); + setSearchTerm(""); + setCurrentPage(1); + }; + + useEffect(() => { + if (!certificateId || !pkiSyncs || pkiSyncs.length === 0) return; + + const currentSyncIds = new Set( + pkiSyncs.filter((sync) => sync.hasCertificate).map((sync) => sync.id) + ); + setSelectedSyncIds(currentSyncIds); + setInitialSyncIds(new Set(currentSyncIds)); + }, [certificateId, pkiSyncs]); + + const handleSyncToggle = (syncId: string) => { + setSelectedSyncIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(syncId)) { + newSet.delete(syncId); + } else { + newSet.add(syncId); + } + return newSet; + }); + }; + + const handleSaveChanges = async () => { + if (!certificateId) return; + + try { + setIsSubmitting(true); + + const syncsToAdd = Array.from(selectedSyncIds).filter((id) => !initialSyncIds.has(id)); + const syncsToRemove = Array.from(initialSyncIds).filter((id) => !selectedSyncIds.has(id)); + + await Promise.all( + syncsToAdd.map((syncId) => + addCertificatesToSync.mutateAsync({ + pkiSyncId: syncId, + certificateIds: [certificateId] + }) + ) + ); + + await Promise.all( + syncsToRemove.map((syncId) => + removeCertificatesFromSync.mutateAsync({ + pkiSyncId: syncId, + certificateIds: [certificateId] + }) + ) + ); + + createNotification({ + text: `PKI sync settings updated for certificate "${commonName}"`, + type: "success" + }); + + handleClose(); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to update PKI sync settings", + type: "error" + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + +
+ setSearchTerm(e.target.value)} + placeholder="Search PKI syncs by name..." + /> +
+
+ {isPending && ( +
+
Loading PKI syncs...
+
+ )} + {!isPending && pkiSyncs.length === 0 && ( + + Create a PKI sync first to manage certificate syncing. + + )} + {!isPending && pkiSyncs.length > 0 && filteredSyncs.length === 0 && searchTerm && ( + + No PKI syncs match your search criteria. Try a different search term. + + )} + {!isPending && filteredSyncs.length > 0 && ( + + + + + + + + + + {paginatedSyncs.map((sync) => ( + handleSyncToggle(sync.id)} + > + + + + + ))} + +
+ NameDestination
+ handleSyncToggle(sync.id)} + id={`sync-${sync.id}`} + /> + +
+ {sync.name} +
+
+
+ {sync.destination.replace(/-/g, " ")} +
+
+
+ )} + {!isPending && filteredSyncs.length > PER_PAGE && ( +
+ {}} + /> +
+ )} +
+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index 4102d8ea5..b900e9aba 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -16,6 +16,7 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateImportModal } from "./CertificateImportModal"; import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; +import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal"; import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal"; import { CertificateModal } from "./CertificateModal"; import { CertificateRenewalModal } from "./CertificateRenewalModal"; @@ -37,7 +38,8 @@ export const CertificatesSection = () => { "deleteCertificate", "revokeCertificate", "manageRenewal", - "renewCertificate" + "renewCertificate", + "managePkiSyncs" ] as const); const onRemoveCertificateSubmit = async (serialNumber: string) => { @@ -105,6 +107,10 @@ export const CertificatesSection = () => { + , data?: { @@ -488,6 +491,31 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { ); })()} + {/* PKI Sync management - only for active certificates */} + {certificate.status === CertStatus.ACTIVE && ( + + {(isAllowed) => ( + + handlePopUpOpen("managePkiSyncs", { + certificateId: certificate.id, + commonName: certificate.commonName + }) + } + disabled={!isAllowed} + icon={} + > + PKI Syncs + + )} + + )} {/* Only show revoke button if CA supports revocation */} {(() => { const caType = caCapabilityMap[certificate.caId]; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/route.tsx b/frontend/src/pages/cert-manager/CertificatesPage/route.tsx deleted file mode 100644 index 68deb41b9..000000000 --- a/frontend/src/pages/cert-manager/CertificatesPage/route.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { CertificatesPage } from "./CertificatesPage"; - -export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates" -)({ - component: CertificatesPage, - beforeLoad: ({ context }) => { - return { - breadcrumbs: [ - ...context.breadcrumbs, - { - label: "Certificates" - } - ] - }; - } -}); diff --git a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx index b8235f488..063cab35d 100644 --- a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx @@ -59,9 +59,9 @@ export const PkiCollectionPage = () => { }); handlePopUpClose("deletePkiCollection"); navigate({ - to: "/projects/cert-management/$projectId/certificates", + to: "/projects/cert-management/$projectId/policies", params: { - projectId + projectId: params.projectId } }); } catch { @@ -77,9 +77,9 @@ export const PkiCollectionPage = () => { {data && (
diff --git a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/components/AddPkiCollectionItemModal.tsx b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/components/AddPkiCollectionItemModal.tsx index 3bcc3dc31..1fd4d7d99 100644 --- a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/components/AddPkiCollectionItemModal.tsx +++ b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/components/AddPkiCollectionItemModal.tsx @@ -5,13 +5,9 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { useProject } from "@app/context"; -import { - CaStatus, - useAddItemToPkiCollection, - useListWorkspaceCas, - useListWorkspaceCertificates -} from "@app/hooks/api"; +import { CaStatus, useAddItemToPkiCollection, useListWorkspaceCas } from "@app/hooks/api"; import { PkiItemType, pkiItemTypeToNameMap } from "@app/hooks/api/pkiCollections/constants"; +import { useListWorkspaceCertificates } from "@app/hooks/api/projects"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z diff --git a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/routes.tsx b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/routes.tsx index 7fef38221..712f3dedf 100644 --- a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/routes.tsx +++ b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/routes.tsx @@ -13,7 +13,7 @@ export const Route = createFileRoute( { label: "Certificate Collections", link: linkOptions({ - to: "/projects/cert-management/$projectId/certificates", + to: "/projects/cert-management/$projectId/policies", params: { projectId: params.projectId } diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx index 76cc0ec7b..b1b1b2836 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx @@ -18,10 +18,10 @@ import { IntegrationsListPageTabs } from "@app/types/integrations"; import { PkiSyncActionTriggers, PkiSyncAuditLogsSection, + PkiSyncCertificatesSection, PkiSyncDestinationSection, PkiSyncDetailsSection, - PkiSyncOptionsSection, - PkiSyncSourceSection + PkiSyncOptionsSection } from "./components"; const PageContent = () => { @@ -62,7 +62,6 @@ const PageContent = () => { const destinationDetails = PKI_SYNC_MAP[pkiSync.destination]; const handleEditDetails = () => handlePopUpOpen("editSync", PkiSyncEditFields.Details); - const handleEditSource = () => handlePopUpOpen("editSync", PkiSyncEditFields.Source); const handleEditOptions = () => handlePopUpOpen("editSync", PkiSyncEditFields.Options); const handleEditDestination = () => handlePopUpOpen("editSync", PkiSyncEditFields.Destination); @@ -103,7 +102,6 @@ const PageContent = () => {
-
@@ -111,6 +109,7 @@ const PageContent = () => { pkiSync={pkiSync} onEditDestination={handleEditDestination} /> +
diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx new file mode 100644 index 000000000..11b59b6ab --- /dev/null +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import { subject } from "@casl/ability"; +import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { CertificateManagementModal } from "@app/components/pki-syncs/CertificateManagementModal"; +import { + Badge, + EmptyState, + IconButton, + Pagination, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { useListPkiSyncCertificates, useRemoveCertificatesFromPkiSync } from "@app/hooks/api"; +import { CertificateSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: TPkiSync; +}; + +const getSyncStatusVariant = (status?: CertificateSyncStatus | null) => { + if (status === CertificateSyncStatus.Succeeded) return "success"; + if (status === CertificateSyncStatus.Failed) return "danger"; + if (status === CertificateSyncStatus.Syncing) return "primary"; + return "project"; +}; + +const getSyncStatusText = (status?: CertificateSyncStatus | null) => { + if (status === CertificateSyncStatus.Succeeded) return "Synced"; + if (status === CertificateSyncStatus.Failed) return "Failed"; + if (status === CertificateSyncStatus.Syncing) return "Syncing"; + if (status === CertificateSyncStatus.Pending) return "Pending"; + return "Unknown"; +}; + +export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => { + const [isManageModalOpen, setIsManageModalOpen] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const { data, refetch: refetchSyncCertificates } = useListPkiSyncCertificates(pkiSync.id, { + offset: (currentPage - 1) * pageSize, + limit: pageSize + }); + const syncCertificates = data?.certificates || []; + const totalCount = data?.totalCount || 0; + const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync(); + + const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, { + subscriberId: pkiSync.subscriberId || "" + }); + + const handleRemoveCertificate = async (certificateId: string) => { + try { + await removeCertificatesFromSync.mutateAsync({ + pkiSyncId: pkiSync.id, + certificateIds: [certificateId] + }); + + await refetchSyncCertificates(); + + createNotification({ + text: "Certificate removed from sync", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to remove certificate from sync", + type: "error" + }); + } + }; + + const totalPages = Math.ceil(totalCount / pageSize); + + return ( +
+
+
+

Certificates ({totalCount})

+ + {(isAllowed) => ( + setIsManageModalOpen(true)} + > + + + )} + +
+ +
+ {syncCertificates.length === 0 ? ( + + No certificates are currently synced with this PKI destination. + + ) : ( +
+ + + + + + + + + + + + + {syncCertificates.map((syncCert) => { + const isExpired = syncCert.certificateNotAfter + ? new Date(syncCert.certificateNotAfter) < new Date() + : false; + + return ( + + + + + + + + ); + })} + +
Common NameSerial NumberStatusExpiresActions
+
+ {syncCert.certificateCommonName || "Unknown"} +
+
+
+ {syncCert.certificateSerialNumber || "Unknown"} +
+
+ {syncCert.lastSyncMessage && + syncCert.syncStatus === CertificateSyncStatus.Failed ? ( + + Failed + + ) : ( + + {getSyncStatusText(syncCert.syncStatus)} + + )} + + + {syncCert.certificateNotAfter + ? new Date(syncCert.certificateNotAfter).toLocaleDateString() + : "Unknown"} + + + + {(isAllowed) => ( + handleRemoveCertificate(syncCert.certificateId)} + > + + + )} + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ setCurrentPage(page)} + onChangePerPage={() => {}} + /> +
+ )} +
+ )} +
+
+ + setIsManageModalOpen(false)} + onCertificatesUpdated={() => { + refetchSyncCertificates(); + }} + /> +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts index 06fe5181e..55a877bff 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts @@ -1,5 +1,6 @@ export { PkiSyncActionTriggers } from "./PkiSyncActionTriggers"; export { PkiSyncAuditLogsSection } from "./PkiSyncAuditLogsSection"; +export { PkiSyncCertificatesSection } from "./PkiSyncCertificatesSection"; export { PkiSyncDestinationSection } from "./PkiSyncDestinationSection"; export { PkiSyncDetailsSection } from "./PkiSyncDetailsSection"; export { PkiSyncOptionsSection } from "./PkiSyncOptionsSection"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx index f6bc792fe..58f3c11e6 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx @@ -2,17 +2,20 @@ import { useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { ProjectPermissionCan } from "@app/components/permissions"; import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; +import { useProject } from "@app/context"; import { ProjectType } from "@app/hooks/api/projects/types"; import { CertificateProfilesTab } from "./components/CertificateProfilesTab"; +import { CertificatesTab } from "./components/CertificatesTab"; import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab"; +import { PkiCollectionsTab } from "./components/PkiCollectionsTab"; enum TabSections { CertificateProfiles = "profiles", - CertificateTemplatesV2 = "templates-v2" + CertificateTemplatesV2 = "templates-v2", + Certificates = "certificates", + PkiCollections = "pki-collections" } export const PoliciesPage = () => { @@ -25,59 +28,54 @@ export const PoliciesPage = () => { } return ( - - {(isAllowed) => { - if (!isAllowed) { - return ( -
-
-

You don't have permission to access certificate policies.

-
-
- ); - } +
+ + {t("common.head-title", { title: "Certificate Management" })} + +
+ - return ( -
- - {t("common.head-title", { title: "Certificate Policies" })} - -
- + setActiveTab(value as TabSections)} + > + + + Certificate Profiles + + + Certificate Templates + + + Certificates + + + Certificate Collections + + - setActiveTab(value as TabSections)} - > - - - Certificate Profiles - - - Certificate Templates - - + + + - - - + + + - - - - -
-
- ); - }} - + + + + + + + + +
+
); }; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx new file mode 100644 index 000000000..caf9cdb2f --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx @@ -0,0 +1,5 @@ +import { CertificatesSection } from "../../../CertificatesPage/components/CertificatesSection"; + +export const CertificatesTab = () => { + return ; +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/index.ts new file mode 100644 index 000000000..277134d56 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/index.ts @@ -0,0 +1 @@ +export { CertificatesTab } from "./CertificatesTab"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/PkiCollectionsTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/PkiCollectionsTab.tsx new file mode 100644 index 000000000..29bd82559 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/PkiCollectionsTab.tsx @@ -0,0 +1,5 @@ +import { PkiCollectionSection } from "../../../AlertingPage/components/PkiCollectionSection"; + +export const PkiCollectionsTab = () => { + return ; +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/index.ts new file mode 100644 index 000000000..4297b3d5e --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/index.ts @@ -0,0 +1 @@ +export { PkiCollectionsTab } from "./PkiCollectionsTab"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts index 62809f571..a9b24b1db 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts @@ -1,2 +1,4 @@ export { CertificateProfilesTab } from "./CertificateProfilesTab"; +export { CertificatesTab } from "./CertificatesTab"; export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab"; +export { PkiCollectionsTab } from "./PkiCollectionsTab"; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx index 131da7ef3..e30c79e24 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx @@ -1,14 +1,14 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, ModalClose } from "@app/components/v2"; import { PamResourceType, TMySQLAccount } from "@app/hooks/api/pam"; import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; -import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { SqlAccountFields } from "./shared/SqlAccountFields"; +import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; type Props = { account?: TMySQLAccount; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx index 6d361877e..e7d2d902b 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx @@ -1,6 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, ModalClose } from "@app/components/v2"; @@ -12,10 +12,10 @@ import { } from "@app/hooks/api/pam"; import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; -import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; -import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { SqlAccountFields } from "./shared/SqlAccountFields"; +import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; +import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields"; type Props = { account?: TPostgresAccount; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 34c4153d6..6dc265c43 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -114,7 +114,6 @@ import { Route as kmsOverviewPageRouteImport } from './pages/kms/OverviewPage/ro import { Route as kmsKmipPageRouteImport } from './pages/kms/KmipPage/route' import { Route as certManagerSettingsPageRouteImport } from './pages/cert-manager/SettingsPage/route' import { Route as certManagerPoliciesPageRouteImport } from './pages/cert-manager/PoliciesPage/route' -import { Route as certManagerCertificatesPageRouteImport } from './pages/cert-manager/CertificatesPage/route' import { Route as certManagerCertificateAuthoritiesPageRouteImport } from './pages/cert-manager/CertificateAuthoritiesPage/route' import { Route as certManagerAlertingPageRouteImport } from './pages/cert-manager/AlertingPage/route' import { Route as organizationAppConnectionsOauthCallbackPageRouteImport } from './pages/organization/AppConnections/OauthCallbackPage/route' @@ -1203,13 +1202,6 @@ const certManagerPoliciesPageRouteRoute = getParentRoute: () => certManagerLayoutRoute, } as any) -const certManagerCertificatesPageRouteRoute = - certManagerCertificatesPageRouteImport.update({ - id: '/certificates', - path: '/certificates', - getParentRoute: () => certManagerLayoutRoute, - } as any) - const certManagerCertificateAuthoritiesPageRouteRoute = certManagerCertificateAuthoritiesPageRouteImport.update({ id: '/certificate-authorities', @@ -2786,13 +2778,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof certManagerCertificateAuthoritiesPageRouteImport parentRoute: typeof certManagerLayoutImport } - '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates': { - id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates' - path: '/certificates' - fullPath: '/projects/cert-management/$projectId/certificates' - preLoaderRoute: typeof certManagerCertificatesPageRouteImport - parentRoute: typeof certManagerLayoutImport - } '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': { id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies' path: '/policies' @@ -4126,7 +4111,6 @@ const AuthenticateInjectOrgDetailsOrgLayoutProjectsCertManagementProjectIdCertMa interface certManagerLayoutRouteChildren { certManagerAlertingPageRouteRoute: typeof certManagerAlertingPageRouteRoute certManagerCertificateAuthoritiesPageRouteRoute: typeof certManagerCertificateAuthoritiesPageRouteRoute - certManagerCertificatesPageRouteRoute: typeof certManagerCertificatesPageRouteRoute certManagerPoliciesPageRouteRoute: typeof certManagerPoliciesPageRouteRoute certManagerSettingsPageRouteRoute: typeof certManagerSettingsPageRouteRoute projectAccessControlPageRouteCertManagerRoute: typeof projectAccessControlPageRouteCertManagerRoute @@ -4147,7 +4131,6 @@ const certManagerLayoutRouteChildren: certManagerLayoutRouteChildren = { certManagerAlertingPageRouteRoute: certManagerAlertingPageRouteRoute, certManagerCertificateAuthoritiesPageRouteRoute: certManagerCertificateAuthoritiesPageRouteRoute, - certManagerCertificatesPageRouteRoute: certManagerCertificatesPageRouteRoute, certManagerPoliciesPageRouteRoute: certManagerPoliciesPageRouteRoute, certManagerSettingsPageRouteRoute: certManagerSettingsPageRouteRoute, projectAccessControlPageRouteCertManagerRoute: @@ -5061,7 +5044,6 @@ export interface FileRoutesByFullPath { '/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute '/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute - '/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute '/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute '/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute '/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute @@ -5294,7 +5276,6 @@ export interface FileRoutesByTo { '/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute '/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute - '/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute '/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute '/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute '/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute @@ -5538,7 +5519,6 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting': typeof certManagerAlertingPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute - '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates': typeof certManagerCertificatesPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': typeof certManagerPoliciesPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings': typeof certManagerSettingsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/kmip': typeof kmsKmipPageRouteRoute