From 5bfff46c169387e71745b67442bfba788adfaf8e Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 16 Sep 2025 01:36:06 -0300 Subject: [PATCH 1/8] Add Certificate Syncs --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 4 + .../db/migrations/20250910193000_pki-sync.ts | 47 + backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + backend/src/db/schemas/pki-syncs.ts | 40 + .../ee/services/audit-log/audit-log-types.ts | 87 ++ .../ee/services/permission/default-roles.ts | 28 + .../services/permission/project-permission.ts | 35 + backend/src/keystore/keystore.ts | 1 + backend/src/queue/queue-service.ts | 29 + backend/src/server/routes/index.ts | 65 +- backend/src/server/routes/v1/index.ts | 2 + .../src/server/routes/v1/pki-sync-router.ts | 540 +++++++++ .../services/certificate/certificate-dal.ts | 17 +- .../certificate/certificate-service.ts | 42 +- .../pki-subscriber/pki-subscriber-service.ts | 42 +- .../azure-key-vault-pki-sync-fns.ts | 521 +++++++++ .../azure-key-vault-pki-sync-types.ts | 29 + backend/src/services/pki-sync/pki-sync-dal.ts | 181 +++ .../src/services/pki-sync/pki-sync-enums.ts | 22 + .../src/services/pki-sync/pki-sync-errors.ts | 25 + backend/src/services/pki-sync/pki-sync-fns.ts | 121 ++ .../src/services/pki-sync/pki-sync-maps.ts | 11 + .../src/services/pki-sync/pki-sync-queue.ts | 1032 +++++++++++++++++ .../src/services/pki-sync/pki-sync-schemas.ts | 37 + .../src/services/pki-sync/pki-sync-service.ts | 384 ++++++ .../src/services/pki-sync/pki-sync-types.ts | 183 +++ .../pki-syncs/CreatePkiSyncModal.tsx | 76 ++ .../pki-syncs/DeletePkiSyncModal.tsx | 60 + .../components/pki-syncs/EditPkiSyncModal.tsx | 29 + .../PkiSyncImportCertificatesModal.tsx | 94 ++ .../pki-syncs/PkiSyncImportStatusBadge.tsx | 112 ++ .../pki-syncs/PkiSyncModalHeader.tsx | 49 + .../PkiSyncRemoveCertificatesModal.tsx | 86 ++ .../pki-syncs/PkiSyncRemoveStatusBadge.tsx | 112 ++ .../components/pki-syncs/PkiSyncSelect.tsx | 155 +++ .../pki-syncs/PkiSyncStatusBadge.tsx | 55 + .../src/components/pki-syncs/PkiSyncTable.tsx | 121 ++ .../forms/AzureKeyVaultPkiSyncFields.tsx | 37 + .../pki-syncs/forms/CreatePkiSyncForm.tsx | 243 ++++ .../pki-syncs/forms/EditPkiSyncForm.tsx | 107 ++ .../forms/PkiSyncConnectionField.tsx | 86 ++ .../forms/PkiSyncDestinationFields.tsx | 19 + .../pki-syncs/forms/PkiSyncDetailsFields.tsx | 51 + .../PkiSyncOptionsFields.tsx | 89 ++ .../forms/PkiSyncOptionsFields/index.tsx | 1 + .../pki-syncs/forms/PkiSyncReviewFields.tsx | 100 ++ .../pki-syncs/forms/PkiSyncSourceFields.tsx | 53 + .../src/components/pki-syncs/forms/index.ts | 8 + .../src/components/pki-syncs/forms/schemas.ts | 35 + frontend/src/components/pki-syncs/index.ts | 9 + .../src/components/pki-syncs/types/index.ts | 6 + frontend/src/const/routes.ts | 8 + .../context/ProjectPermissionContext/types.ts | 23 + frontend/src/helpers/pkiSyncs.ts | 19 + .../src/hooks/api/appConnections/index.ts | 1 + .../src/hooks/api/auditLogs/constants.tsx | 8 + frontend/src/hooks/api/auditLogs/enums.tsx | 8 + frontend/src/hooks/api/pkiSyncs/enums.ts | 10 + frontend/src/hooks/api/pkiSyncs/index.ts | 7 + frontend/src/hooks/api/pkiSyncs/mutations.tsx | 125 ++ frontend/src/hooks/api/pkiSyncs/queries.tsx | 88 ++ .../pkiSyncs/types/azure-key-vault-sync.ts | 16 + .../src/hooks/api/pkiSyncs/types/common.ts | 44 + .../src/hooks/api/pkiSyncs/types/index.ts | 57 + .../PkiManagerLayout/PkiManagerLayout.tsx | 18 + .../IntegrationsListPage.tsx | 69 ++ .../PkiSyncDestinationCol.tsx | 14 + .../PkiSyncDestinationCol/index.ts | 1 + .../PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx | 398 +++++++ .../PkiSyncTable/PkiSyncTableCell.tsx | 71 ++ .../PkiSyncTable/PkiSyncsTable.tsx | 473 ++++++++ .../PkiSyncsTab/PkiSyncTable/helpers.ts | 13 + .../PkiSyncsTab/PkiSyncTable/index.ts | 1 + .../components/PkiSyncsTab/PkiSyncsTab.tsx | 110 ++ .../components/PkiSyncsTab/index.ts | 1 + .../IntegrationsListPage/components/index.ts | 1 + .../IntegrationsListPage/index.ts | 1 + .../IntegrationsListPage/route.tsx | 30 + .../PkiSyncDetailsByIDPage.tsx | 146 +++ .../components/PkiSyncActionTriggers.tsx | 318 +++++ .../components/PkiSyncAuditLogsSection.tsx | 85 ++ .../components/PkiSyncDestinationSection.tsx | 73 ++ ...AzureKeyVaultPkiSyncDestinationSection.tsx | 21 + .../PkiSyncDestinationSection/index.ts | 1 + .../components/PkiSyncDetailsSection.tsx | 101 ++ .../PkiSyncOptionsSection.tsx | 62 + .../components/PkiSyncOptionsSection/index.ts | 1 + .../components/PkiSyncSourceSection.tsx | 75 ++ .../components/index.ts | 6 + .../PkiSyncDetailsByIDPage/index.tsx | 1 + .../PkiSyncDetailsByIDPage/route.tsx | 31 + .../components/ConditionsFields.tsx | 18 +- .../GeneralPermissionConditions.tsx | 3 +- .../ProjectRoleModifySection.utils.tsx | 79 ++ .../IntegrationsListPage.tsx | 4 +- frontend/src/routeTree.gen.ts | 106 ++ frontend/src/routes.ts | 4 + frontend/src/types/integrations.ts | 3 +- 100 files changed, 8044 insertions(+), 31 deletions(-) create mode 100644 backend/src/db/migrations/20250910193000_pki-sync.ts create mode 100644 backend/src/db/schemas/pki-syncs.ts create mode 100644 backend/src/server/routes/v1/pki-sync-router.ts create mode 100644 backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts create mode 100644 backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts create mode 100644 backend/src/services/pki-sync/pki-sync-dal.ts create mode 100644 backend/src/services/pki-sync/pki-sync-enums.ts create mode 100644 backend/src/services/pki-sync/pki-sync-errors.ts create mode 100644 backend/src/services/pki-sync/pki-sync-fns.ts create mode 100644 backend/src/services/pki-sync/pki-sync-maps.ts create mode 100644 backend/src/services/pki-sync/pki-sync-queue.ts create mode 100644 backend/src/services/pki-sync/pki-sync-schemas.ts create mode 100644 backend/src/services/pki-sync/pki-sync-service.ts create mode 100644 backend/src/services/pki-sync/pki-sync-types.ts create mode 100644 frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx create mode 100644 frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx create mode 100644 frontend/src/components/pki-syncs/EditPkiSyncModal.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncSelect.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncTable.tsx create mode 100644 frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx create mode 100644 frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/index.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncReviewFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncSourceFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/index.ts create mode 100644 frontend/src/components/pki-syncs/forms/schemas.ts create mode 100644 frontend/src/components/pki-syncs/index.ts create mode 100644 frontend/src/components/pki-syncs/types/index.ts create mode 100644 frontend/src/helpers/pkiSyncs.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/enums.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/index.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/mutations.tsx create mode 100644 frontend/src/hooks/api/pkiSyncs/queries.tsx create mode 100644 frontend/src/hooks/api/pkiSyncs/types/azure-key-vault-sync.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/types/common.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/types/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncDestinationCol/PkiSyncDestinationCol.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncDestinationCol/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncTableCell.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncsTable.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/helpers.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncsTab.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncAuditLogsSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/AzureKeyVaultPkiSyncDestinationSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/index.ts create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/index.ts create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/index.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 8ca4288b5..e30d55d8c 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -93,6 +93,7 @@ import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-servi import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -267,6 +268,7 @@ declare module "fastify" { certificateEst: TCertificateEstServiceFactory; pkiCollection: TPkiCollectionServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory; + pkiSync: TPkiSyncServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 75a358341..b6bde44bc 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -263,6 +263,9 @@ import { TPkiSubscribers, TPkiSubscribersInsert, TPkiSubscribersUpdate, + TPkiSyncs, + TPkiSyncsInsert, + TPkiSyncsUpdate, TProjectBots, TProjectBotsInsert, TProjectBotsUpdate, @@ -680,6 +683,7 @@ declare module "knex/types/tables" { TPkiSubscribersInsert, TPkiSubscribersUpdate >; + [TableName.PkiSync]: KnexOriginal.CompositeTableType; [TableName.UserGroupMembership]: KnexOriginal.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, diff --git a/backend/src/db/migrations/20250910193000_pki-sync.ts b/backend/src/db/migrations/20250910193000_pki-sync.ts new file mode 100644 index 000000000..73dc620d9 --- /dev/null +++ b/backend/src/db/migrations/20250910193000_pki-sync.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.PkiSync))) { + await knex.schema.createTable(TableName.PkiSync, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("description"); + t.string("destination").notNullable(); + t.boolean("isAutoSyncEnabled").notNullable().defaultTo(true); + t.integer("version").defaultTo(1).notNullable(); + t.jsonb("destinationConfig").notNullable(); + t.jsonb("syncOptions").notNullable(); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("subscriberId"); + t.foreign("subscriberId").references("id").inTable(TableName.PkiSubscriber).onDelete("SET NULL"); + t.uuid("connectionId").notNullable(); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + t.timestamps(true, true, true); + t.string("syncStatus"); + t.string("lastSyncJobId"); + t.string("lastSyncMessage"); + t.datetime("lastSyncedAt"); + t.string("importStatus"); + t.string("lastImportJobId"); + t.string("lastImportMessage"); + t.datetime("lastImportedAt"); + t.string("removeStatus"); + t.string("lastRemoveJobId"); + t.string("lastRemoveMessage"); + t.datetime("lastRemovedAt"); + + t.unique(["name", "projectId"], { indexName: "pki_syncs_name_project_id_unique" }); + }); + + await createOnUpdateTrigger(knex, TableName.PkiSync); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.PkiSync); + await dropOnUpdateTrigger(knex, TableName.PkiSync); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index f09e3c263..8cbaa00bb 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -87,6 +87,7 @@ export * from "./pki-alerts"; export * from "./pki-collection-items"; export * from "./pki-collections"; export * from "./pki-subscribers"; +export * from "./pki-syncs"; export * from "./project-bots"; export * from "./project-environments"; export * from "./project-gateways"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index a4585972f..385a328b6 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -156,6 +156,7 @@ export enum TableName { ProjectSlackConfigs = "project_slack_configs", AppConnection = "app_connections", SecretSync = "secret_syncs", + PkiSync = "pki_syncs", KmipClient = "kmip_clients", KmipOrgConfig = "kmip_org_configs", KmipOrgServerCertificates = "kmip_org_server_certificates", diff --git a/backend/src/db/schemas/pki-syncs.ts b/backend/src/db/schemas/pki-syncs.ts new file mode 100644 index 000000000..d4ef2ed6d --- /dev/null +++ b/backend/src/db/schemas/pki-syncs.ts @@ -0,0 +1,40 @@ +// 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 PkiSyncsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + destination: z.string(), + isAutoSyncEnabled: z.boolean().default(true), + version: z.number().default(1), + destinationConfig: z.unknown(), + syncOptions: z.unknown(), + projectId: z.string(), + subscriberId: z.string().uuid().nullable().optional(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + syncStatus: z.string().nullable().optional(), + lastSyncJobId: z.string().nullable().optional(), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + importStatus: z.string().nullable().optional(), + lastImportJobId: z.string().nullable().optional(), + lastImportMessage: z.string().nullable().optional(), + lastImportedAt: z.date().nullable().optional(), + removeStatus: z.string().nullable().optional(), + lastRemoveJobId: z.string().nullable().optional(), + lastRemoveMessage: z.string().nullable().optional(), + lastRemovedAt: z.date().nullable().optional() +}); + +export type TPkiSyncs = z.infer; +export type TPkiSyncsInsert = Omit, TImmutableDBKeys>; +export type TPkiSyncsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 10c84cc4a..daa3cc962 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -404,6 +404,14 @@ export enum EventType { SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets", SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets", SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets", + GET_PKI_SYNCS = "get-pki-syncs", + GET_PKI_SYNC = "get-pki-sync", + CREATE_PKI_SYNC = "create-pki-sync", + UPDATE_PKI_SYNC = "update-pki-sync", + DELETE_PKI_SYNC = "delete-pki-sync", + PKI_SYNC_SYNC_CERTIFICATES = "pki-sync-sync-certificates", + PKI_SYNC_IMPORT_CERTIFICATES = "pki-sync-import-certificates", + PKI_SYNC_REMOVE_CERTIFICATES = "pki-sync-remove-certificates", OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER = "oidc-group-membership-mapping-assign-user", OIDC_GROUP_MEMBERSHIP_MAPPING_REMOVE_USER = "oidc-group-membership-mapping-remove-user", CREATE_KMIP_CLIENT = "create-kmip-client", @@ -2908,6 +2916,77 @@ interface SecretSyncRemoveSecretsEvent { }; } +interface GetPkiSyncsEvent { + type: EventType.GET_PKI_SYNCS; + metadata: { + projectId: string; + }; +} + +interface GetPkiSyncEvent { + type: EventType.GET_PKI_SYNC; + metadata: { + destination: string; + syncId: string; + }; +} + +interface CreatePkiSyncEvent { + type: EventType.CREATE_PKI_SYNC; + metadata: { + pkiSyncId: string; + name: string; + destination: string; + }; +} + +interface UpdatePkiSyncEvent { + type: EventType.UPDATE_PKI_SYNC; + metadata: { + pkiSyncId: string; + name: string; + }; +} + +interface DeletePkiSyncEvent { + type: EventType.DELETE_PKI_SYNC; + metadata: { + pkiSyncId: string; + name: string; + destination: string; + }; +} + +interface PkiSyncSyncCertificatesEvent { + type: EventType.PKI_SYNC_SYNC_CERTIFICATES; + metadata: { + syncId: string; + syncMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + +interface PkiSyncImportCertificatesEvent { + type: EventType.PKI_SYNC_IMPORT_CERTIFICATES; + metadata: { + syncId: string; + importMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + +interface PkiSyncRemoveCertificatesEvent { + type: EventType.PKI_SYNC_REMOVE_CERTIFICATES; + metadata: { + syncId: string; + removeMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + interface OidcGroupMembershipMappingAssignUserEvent { type: EventType.OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER; metadata: { @@ -3715,6 +3794,14 @@ export type Event = | SecretSyncSyncSecretsEvent | SecretSyncImportSecretsEvent | SecretSyncRemoveSecretsEvent + | GetPkiSyncsEvent + | GetPkiSyncEvent + | CreatePkiSyncEvent + | UpdatePkiSyncEvent + | DeletePkiSyncEvent + | PkiSyncSyncCertificatesEvent + | PkiSyncImportCertificatesEvent + | PkiSyncRemoveCertificatesEvent | OidcGroupMembershipMappingAssignUserEvent | OidcGroupMembershipMappingRemoveUserEvent | CreateKmipClientEvent diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 9329c3c7f..45a5ce88a 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -12,6 +12,7 @@ import { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSecretEventActions, @@ -208,6 +209,19 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretSyncs ); + can( + [ + ProjectPermissionPkiSyncActions.Create, + ProjectPermissionPkiSyncActions.Edit, + ProjectPermissionPkiSyncActions.Delete, + ProjectPermissionPkiSyncActions.Read, + ProjectPermissionPkiSyncActions.SyncCertificates, + ProjectPermissionPkiSyncActions.ImportCertificates, + ProjectPermissionPkiSyncActions.RemoveCertificates + ], + ProjectPermissionSub.PkiSyncs + ); + can( [ ProjectPermissionKmipActions.CreateClients, @@ -450,6 +464,19 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretSyncs ); + can( + [ + ProjectPermissionPkiSyncActions.Create, + ProjectPermissionPkiSyncActions.Edit, + ProjectPermissionPkiSyncActions.Delete, + ProjectPermissionPkiSyncActions.Read, + ProjectPermissionPkiSyncActions.SyncCertificates, + ProjectPermissionPkiSyncActions.ImportCertificates, + ProjectPermissionPkiSyncActions.RemoveCertificates + ], + ProjectPermissionSub.PkiSyncs + ); + can( [ ProjectPermissionSecretScanningDataSourceActions.Read, @@ -512,6 +539,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); + can(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs); can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); can( diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 20b4344d3..8a41ce53a 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -120,6 +120,16 @@ export enum ProjectPermissionSecretSyncActions { RemoveSecrets = "remove-secrets" } +export enum ProjectPermissionPkiSyncActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + SyncCertificates = "sync-certificates", + ImportCertificates = "import-certificates", + RemoveCertificates = "remove-certificates" +} + export enum ProjectPermissionSecretRotationActions { Read = "read", ReadGeneratedCredentials = "read-generated-credentials", @@ -204,6 +214,7 @@ export enum ProjectPermissionSub { Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", + PkiSyncs = "pki-syncs", Kmip = "kmip", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", @@ -235,6 +246,10 @@ export type SecretSyncSubjectFields = { secretPath: string; }; +export type PkiSyncSubjectFields = { + projectId: string; +}; + export type DynamicSecretSubjectFields = { environment: string; secretPath: string; @@ -295,6 +310,10 @@ export type ProjectPermissionSet = ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs | (ForcedSubject & SecretSyncSubjectFields) ] + | [ + ProjectPermissionPkiSyncActions, + ProjectPermissionSub.PkiSyncs | (ForcedSubject & PkiSyncSubjectFields) + ] | [ ProjectPermissionActions, ( @@ -460,6 +479,12 @@ const SecretSyncConditionV2Schema = z }) .partial(); +const PkiSyncConditionSchema = z + .object({ + projectId: z.string() + }) + .partial(); + const SecretImportConditionSchema = z .object({ environment: z.union([ @@ -898,6 +923,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.PkiSyncs).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiSyncActions).describe( + "Describe what action an entity can take." + ), + conditions: PkiSyncConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), z.object({ subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."), inverted: z.boolean().optional().describe("Whether rule allows or forbids."), diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 8b72ce464..04ba8428b 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -47,6 +47,7 @@ export const KeyStorePrefixes = { SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, + PkiSyncLock: (syncId: string) => `pki-sync-mutex-${syncId}` as const, AppConnectionConcurrentJobs: (connectionId: string) => `app-connection-concurrency-${connectionId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 5a7c92f22..159735053 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -24,6 +24,12 @@ import { QueueWorkerProfile } from "@app/lib/types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types"; import { TCreateUserNotificationDTO } from "@app/services/notification/notification-types"; +import { + TQueuePkiSyncImportCertificatesByIdDTO, + TQueuePkiSyncRemoveCertificatesByIdDTO, + TQueuePkiSyncSyncCertificatesByIdDTO, + TQueueSendPkiSyncActionFailedNotificationsDTO +} from "@app/services/pki-sync/pki-sync-types"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -58,6 +64,7 @@ export enum QueueName { CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber SecretReplication = "secret-replication", SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication + PkiSync = "pki-sync", ProjectV3Migration = "project-v3-migration", AccessTokenStatusUpdate = "access-token-status-update", ImportSecretsFromExternalSource = "import-secrets-from-external-source", @@ -91,6 +98,7 @@ export enum QueueJobs { CaCrlRotation = "ca-crl-rotation-job", SecretReplication = "secret-replication", SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication + PkiSync = "pki-sync", ProjectV3Migration = "project-v3-migration", IdentityAccessTokenStatusUpdate = "identity-access-token-status-update", ServiceTokenStatusUpdate = "service-token-status-update", @@ -99,6 +107,10 @@ export enum QueueJobs { SecretSyncImportSecrets = "secret-sync-import-secrets", SecretSyncRemoveSecrets = "secret-sync-remove-secrets", SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications", + PkiSyncSyncCertificates = "pki-sync-sync-certificates", + PkiSyncImportCertificates = "pki-sync-import-certificates", + PkiSyncRemoveCertificates = "pki-sync-remove-certificates", + PkiSyncSendActionFailedNotifications = "pki-sync-send-action-failed-notifications", SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations", SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", @@ -218,6 +230,23 @@ export type TQueueJobTypes = { name: QueueJobs.SecretSync; payload: TSyncSecretsDTO; }; + [QueueName.PkiSync]: + | { + name: QueueJobs.PkiSyncSyncCertificates; + payload: TQueuePkiSyncSyncCertificatesByIdDTO; + } + | { + name: QueueJobs.PkiSyncImportCertificates; + payload: TQueuePkiSyncImportCertificatesByIdDTO; + } + | { + name: QueueJobs.PkiSyncRemoveCertificates; + payload: TQueuePkiSyncRemoveCertificatesByIdDTO; + } + | { + name: QueueJobs.PkiSyncSendActionFailedNotifications; + payload: TQueueSendPkiSyncActionFailedNotificationsDTO; + }; [QueueName.ProjectV3Migration]: { name: QueueJobs.ProjectV3Migration; payload: { projectId: string }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index eccad2956..ebadff15d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -248,6 +248,9 @@ import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-co import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { pkiSubscriberQueueServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-queue"; import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { pkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { pkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { pkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; import { pkiTemplatesDALFactory } from "@app/services/pki-templates/pki-templates-dal"; import { pkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { projectDALFactory } from "@app/services/project/project-dal"; @@ -975,6 +978,7 @@ export const registerRoutes = async ( const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db); const pkiSubscriberDAL = pkiSubscriberDALFactory(db); + const pkiSyncDAL = pkiSyncDALFactory(db); const pkiTemplatesDAL = pkiTemplatesDALFactory(db); const instanceRelayConfigDAL = instanceRelayConfigDalFactory(db); @@ -984,21 +988,6 @@ export const registerRoutes = async ( const orgGatewayConfigV2DAL = orgGatewayConfigV2DalFactory(db); - const certificateService = certificateServiceFactory({ - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthorityCrlDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService, - permissionService, - pkiCollectionDAL, - pkiCollectionItemDAL - }); - const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({ sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, @@ -1977,6 +1966,38 @@ export const registerRoutes = async ( internalCaFns }); + const pkiSyncQueue = pkiSyncQueueFactory({ + queueService, + kmsService, + appConnectionDAL, + keyStore, + pkiSyncDAL, + auditLogService, + projectMembershipDAL, + projectDAL, + licenseService, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL + }); + + const certificateService = certificateServiceFactory({ + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService, + pkiCollectionDAL, + pkiCollectionItemDAL, + pkiSyncDAL, + pkiSyncQueue + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, @@ -1990,7 +2011,18 @@ export const registerRoutes = async ( kmsService, permissionService, certificateAuthorityQueue, - internalCaFns + internalCaFns, + pkiSyncDAL, + pkiSyncQueue + }); + + const pkiSyncService = pkiSyncServiceFactory({ + pkiSyncDAL, + pkiSubscriberDAL, + appConnectionService, + permissionService, + licenseService, + pkiSyncQueue }); const pkiTemplateService = pkiTemplatesServiceFactory({ @@ -2136,6 +2168,7 @@ export const registerRoutes = async ( pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, pkiSubscriber: pkiSubscriberService, + pkiSync: pkiSyncService, pkiTemplate: pkiTemplateService, secretScanning: secretScanningService, license: licenseService, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 6108be32b..2360496f3 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -40,6 +40,7 @@ import { registerPasswordRouter } from "./password-router"; import { registerPkiAlertRouter } from "./pki-alert-router"; import { registerPkiCollectionRouter } from "./pki-collection-router"; import { registerPkiSubscriberRouter } from "./pki-subscriber-router"; +import { registerPkiSyncRouter } from "./pki-sync-router"; import { registerProjectEnvRouter } from "./project-env-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; @@ -137,6 +138,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); await server.register(registerWebhookRouter, { prefix: "/webhooks" }); await server.register(registerIdentityRouter, { prefix: "/identities" }); + await server.register(registerPkiSyncRouter, { prefix: "/pki-syncs" }); await server.register( async (secretSharingRouter) => { diff --git a/backend/src/server/routes/v1/pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-router.ts new file mode 100644 index 000000000..547acf75d --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-router.ts @@ -0,0 +1,540 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { logger } from "@app/lib/logger"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { AzureKeyVaultPkiSyncConfigSchema } from "@app/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PkiSyncDetailsSchema, PkiSyncListItemSchema, PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas"; +import { TCreatePkiSyncDTO, TUpdatePkiSyncDTO } from "@app/services/pki-sync/pki-sync-types"; + +const CreatePkiSyncRequestBodySchema = z.object({ + name: z.string().trim().min(1).max(64), + description: z.string().optional(), + destination: z.nativeEnum(PkiSync), + isAutoSyncEnabled: z.boolean().default(true), + destinationConfig: z + .discriminatedUnion("destination", [ + z.object({ + destination: z.literal(PkiSync.AzureKeyVault), + config: AzureKeyVaultPkiSyncConfigSchema + }) + ]) + .transform(({ config }) => config), + syncOptions: z.record(z.unknown()).default({}), + subscriberId: z.string().optional(), + connectionId: z.string(), + projectId: z.string().trim().min(1) +}); + +const UpdatePkiSyncRequestBodySchema = z.object({ + name: z.string().trim().min(1).max(64).optional(), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().optional(), + destinationConfig: z.record(z.unknown()).optional(), + syncOptions: z.record(z.unknown()).optional(), + subscriberId: z.string().optional(), + connectionId: z.string().optional() +}); + +export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PKI sync options", + security: [ + { + bearerAuth: [] + } + ], + response: { + 200: { + description: "PKI sync options retrieved successfully", + content: { + "application/json": { + schema: z.object({ + pkiSyncOptions: z.array( + z.object({ + name: z.string(), + destination: z.nativeEnum(PkiSync), + canImportCertificates: z.boolean(), + canRemoveCertificates: z.boolean(), + enterprise: z.boolean().optional() + }) + ) + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async () => { + const pkiSyncOptions = [ + { + name: "Azure Key Vault", + destination: PkiSync.AzureKeyVault, + canImportCertificates: true, + canRemoveCertificates: true, + enterprise: false + } + ]; + + return { pkiSyncOptions }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "Create PKI sync", + security: [ + { + bearerAuth: [] + } + ], + requestBody: { + content: { + "application/json": { + schema: CreatePkiSyncRequestBodySchema + } + } + }, + response: { + 200: { + description: "PKI sync created successfully", + content: { + "application/json": { + schema: z.object({ + pkiSync: PkiSyncSchema + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const requestBody = CreatePkiSyncRequestBodySchema.parse(req.body); + const createData: Omit = requestBody; + + try { + const pkiSync = await server.services.pkiSync.createPkiSync(createData, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: createData.projectId, + event: { + type: EventType.CREATE_PKI_SYNC, + metadata: { + pkiSyncId: pkiSync.id, + name: pkiSync.name, + destination: pkiSync.destination + } + } + }); + + return { pkiSync }; + } catch (error) { + logger.error("Failed to create PKI sync"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PKI syncs", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI syncs retrieved successfully", + content: { + "application/json": { + schema: z.object({ + pkiSyncs: z.array(PkiSyncListItemSchema) + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId( + { + projectId: req.query.projectId + }, + req.permission + ); + + return { pkiSyncs }; + } + }); + + server.route({ + method: "GET", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PKI sync by ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync retrieved successfully", + content: { + "application/json": { + schema: z.object({ + pkiSync: PkiSyncDetailsSchema + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const pkiSync = await server.services.pkiSync.findPkiSyncById( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + return { pkiSync }; + } + }); + + server.route({ + method: "PATCH", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Update PKI sync", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + requestBody: { + content: { + "application/json": { + schema: UpdatePkiSyncRequestBodySchema + } + } + }, + response: { + 200: { + description: "PKI sync updated successfully", + content: { + "application/json": { + schema: z.object({ + pkiSync: PkiSyncSchema + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const requestBody = UpdatePkiSyncRequestBodySchema.parse(req.body); + const updateData: Omit = { + id: req.params.pkiSyncId, + projectId: req.query.projectId, + ...requestBody + }; + + try { + const pkiSync = await server.services.pkiSync.updatePkiSync(updateData, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.UPDATE_PKI_SYNC, + metadata: { + pkiSyncId: pkiSync.id, + name: pkiSync.name + } + } + }); + + return { pkiSync }; + } catch (error) { + logger.error("Failed to update PKI sync"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "DELETE", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Delete PKI sync", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync deleted successfully", + content: { + "application/json": { + schema: z.object({ + pkiSync: z.object({ + id: z.string(), + name: z.string(), + destination: z.nativeEnum(PkiSync) + }) + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + try { + const pkiSync = await server.services.pkiSync.deletePkiSync( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.DELETE_PKI_SYNC, + metadata: { + pkiSyncId: pkiSync.id, + name: pkiSync.name, + destination: pkiSync.destination + } + } + }); + + return { pkiSync }; + } catch (error) { + logger.error("Failed to delete PKI sync"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/sync", + config: { + rateLimit: readLimit + }, + schema: { + description: "Trigger PKI sync", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync triggered successfully", + content: { + "application/json": { + schema: z.object({ + message: z.string() + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + try { + const result = await server.services.pkiSync.triggerPkiSyncSyncCertificatesById( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + return result; + } catch (error) { + logger.error("Failed to trigger PKI sync"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/import", + config: { + rateLimit: readLimit + }, + schema: { + description: "Import certificates from PKI sync destination", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync import triggered successfully", + content: { + "application/json": { + schema: z.object({ + message: z.string() + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + try { + const result = await server.services.pkiSync.triggerPkiSyncImportCertificatesById( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + return result; + } catch (error) { + logger.error("Failed to trigger PKI sync import certificates"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/remove", + config: { + rateLimit: readLimit + }, + schema: { + description: "Remove certificates from PKI sync destination", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync remove triggered successfully", + content: { + "application/json": { + schema: z.object({ + message: z.string() + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + try { + const result = await server.services.pkiSync.triggerPkiSyncRemoveCertificatesById( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + return result; + } catch (error) { + logger.error("Failed to trigger PKI sync remove certificates"); + logger.error(error); + throw error; + } + } + }); +}; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 9db473236..377563ced 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -25,6 +25,20 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findAllActiveCertsForSubscriber = async ({ subscriberId }: { subscriberId: string }) => { + try { + const certs = await db + .replicaNode()(TableName.Certificate) + .where({ pkiSubscriberId: subscriberId, status: CertStatus.ACTIVE }) + .where("notAfter", ">", new Date()) + .orderBy("notBefore", "desc"); + + return certs; + } catch (error) { + throw new DatabaseError({ error, name: "Find all active certificates for subscriber" }); + } + }; + const countCertificatesInProject = async ({ projectId, friendlyName, @@ -83,6 +97,7 @@ export const certificateDALFactory = (db: TDbClient) => { ...certificateOrm, countCertificatesInProject, countCertificatesForPkiSubscriber, - findLatestActiveCertForSubscriber + findLatestActiveCertForSubscriber, + findAllActiveCertsForSubscriber }; }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 7adc60d52..f20f69949 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; @@ -10,6 +11,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; @@ -20,6 +22,8 @@ import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate 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 { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -53,6 +57,8 @@ type TCertificateServiceFactoryDep = { projectDAL: Pick; kmsService: Pick; permissionService: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TCertificateServiceFactory = ReturnType; @@ -69,8 +75,32 @@ export const certificateServiceFactory = ({ pkiCollectionItemDAL, projectDAL, kmsService, - permissionService + permissionService, + pkiSyncDAL, + pkiSyncQueue }: TCertificateServiceFactoryDep) => { + /** + * Trigger auto sync for PKI syncs connected to a PKI subscriber when certificates are issued/revoked/deleted + */ + const triggerAutoSyncForSubscriber = async (subscriberId: string) => { + try { + // Find all PKI syncs that are connected to this subscriber and have auto sync enabled + const pkiSyncs = await pkiSyncDAL.find({ + subscriberId, + isAutoSyncEnabled: true + }); + + // Queue sync jobs for each auto sync enabled PKI sync + for (const pkiSync of pkiSyncs) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); + } + } catch (error) { + // Don't throw error to avoid breaking the main certificate operation + // Just log the auto sync failure + logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); + } + }; + /** * Return details for certificate with serial number [serialNumber] */ @@ -158,6 +188,11 @@ 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); + } + return { deletedCert }; @@ -222,6 +257,11 @@ export const certificateServiceFactory = ({ } ); + // Trigger auto sync for PKI syncs connected to this certificate's subscriber + if (cert.pkiSubscriberId) { + await triggerAutoSyncForSubscriber(cert.pkiSubscriberId); + } + // 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-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index 391b5b2a8..43250c8c3 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ /* eslint-disable no-bitwise */ import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; @@ -12,6 +13,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { ms } from "@app/lib/ms"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; @@ -36,6 +38,8 @@ import { import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -79,6 +83,8 @@ type TPkiSubscriberServiceFactoryDep = { kmsService: Pick; permissionService: Pick; internalCaFns: ReturnType; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TPkiSubscriberServiceFactory = ReturnType; @@ -96,8 +102,32 @@ export const pkiSubscriberServiceFactory = ({ kmsService, permissionService, certificateAuthorityQueue, - internalCaFns + internalCaFns, + pkiSyncDAL, + pkiSyncQueue }: TPkiSubscriberServiceFactoryDep) => { + /** + * Trigger auto sync for PKI syncs connected to a PKI subscriber when certificates are issued + */ + const triggerAutoSyncForSubscriber = async (subscriberId: string) => { + try { + // Find all PKI syncs that are connected to this subscriber and have auto sync enabled + const pkiSyncs = await pkiSyncDAL.find({ + subscriberId, + isAutoSyncEnabled: true + }); + + // Queue sync jobs for each auto sync enabled PKI sync + for (const pkiSync of pkiSyncs) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); + } + } catch (error) { + // Don't throw error to avoid breaking the main certificate operation + // Just log the auto sync failure + logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); + } + }; + const createSubscriber = async ({ name, commonName, @@ -413,7 +443,12 @@ export const pkiSubscriberServiceFactory = ({ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); if (ca.internalCa?.id) { - return internalCaFns.issueCertificate(subscriber, ca); + const result = await internalCaFns.issueCertificate(subscriber, ca); + + // Trigger auto sync for PKI syncs connected to this subscriber after certificate issuance + await triggerAutoSyncForSubscriber(subscriber.id); + + return result; } throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" }); @@ -671,6 +706,9 @@ export const pkiSubscriberServiceFactory = ({ return cert; }); + // Trigger auto sync for PKI syncs connected to this subscriber after certificate signing + await triggerAutoSyncForSubscriber(subscriber.id); + return { certificate: leafCert.toString("pem"), certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts new file mode 100644 index 000000000..da308bcdb --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts @@ -0,0 +1,521 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { logger } from "@app/lib/logger"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types"; + +import { PkiSyncError } from "../pki-sync-errors"; +import { GetAzureKeyVaultCertificate, TAzureKeyVaultPkiSyncWithCredentials } from "./azure-key-vault-pki-sync-types"; + +type TAzureKeyVaultPkiSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TAzureKeyVaultPkiSyncFactoryDeps) => { + const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string) => { + const paginateAzureKeyVaultCertificates = async () => { + let result: GetAzureKeyVaultCertificate[] = []; + + let currentUrl = `${vaultBaseUrl}/certificates?api-version=7.4`; + + while (currentUrl) { + const res = await request.get<{ value: GetAzureKeyVaultCertificate[]; nextLink: string }>(currentUrl, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + result = result.concat(res.data.value); + currentUrl = res.data.nextLink; + } + + return result; + }; + + const getAzureKeyVaultCertificates = await paginateAzureKeyVaultCertificates(); + + const enabledAzureKeyVaultCertificates = getAzureKeyVaultCertificates.filter((cert) => cert.attributes.enabled); + + // disabled certificates to skip sending updates to + const disabledAzureKeyVaultCertificateKeys = getAzureKeyVaultCertificates + .filter(({ attributes }) => !attributes.enabled) + .map((getAzureKeyVaultCertificate) => { + return getAzureKeyVaultCertificate.id.substring(getAzureKeyVaultCertificate.id.lastIndexOf("/") + 1); + }); + + let lastSlashIndex: number; + const res = ( + await Promise.all( + enabledAzureKeyVaultCertificates.map(async (getAzureKeyVaultCertificate) => { + if (!lastSlashIndex) { + lastSlashIndex = getAzureKeyVaultCertificate.id.lastIndexOf("/"); + } + + // Get the certificate details + const azureKeyVaultCertificate = await request.get( + `${getAzureKeyVaultCertificate.id}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + // Convert base64 certificate to PEM format if available + let certPem = ""; + if (azureKeyVaultCertificate.data.cer) { + try { + // Azure Key Vault stores certificate in base64 DER format + // We need to convert it to PEM format with proper headers + const base64Cert = azureKeyVaultCertificate.data.cer; + certPem = `-----BEGIN CERTIFICATE-----\n${base64Cert.match(/.{1,64}/g)?.join("\n")}\n-----END CERTIFICATE-----`; + } catch (error) { + // If conversion fails, assume it's already in PEM format + certPem = azureKeyVaultCertificate.data.cer; + } + } + + return { + ...azureKeyVaultCertificate.data, + key: getAzureKeyVaultCertificate.id.substring(lastSlashIndex + 1), + cert: certPem, + privateKey: "" // Private keys cannot be extracted from Azure Key Vault for security reasons + }; + }) + ) + ).reduce( + (obj, certificate) => ({ + ...obj, + [certificate.key]: { + cert: certificate.cert, + privateKey: certificate.privateKey + } + }), + {} as Record + ); + + return { + vaultCertificates: res, + disabledAzureKeyVaultCertificateKeys + }; + }; + + const syncCertificates = async (pkiSync: TAzureKeyVaultPkiSyncWithCredentials, certificateMap: TCertificateMap) => { + logger.info( + { + syncId: pkiSync.id, + vaultUrl: pkiSync.destinationConfig.vaultBaseUrl, + certificateCount: Object.keys(certificateMap).length + }, + "Starting Azure Key Vault certificate sync" + ); + + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + const { vaultCertificates, disabledAzureKeyVaultCertificateKeys } = await $getAzureKeyVaultCertificates( + accessToken, + pkiSync.destinationConfig.vaultBaseUrl + ); + + logger.info( + { + syncId: pkiSync.id, + existingCertCount: Object.keys(vaultCertificates).length, + disabledCertCount: disabledAzureKeyVaultCertificateKeys.length + }, + "Retrieved existing certificates from Azure Key Vault" + ); + + const setCertificates: { + key: string; + cert: string; + privateKey: string; + }[] = []; + + // Track which certificates should exist in Azure Key Vault + const activeCertificateNames = Object.keys(certificateMap); + + // Iterate through certificates to sync to Azure Key Vault + Object.entries(certificateMap).forEach(([certName, { cert, privateKey }]) => { + if (disabledAzureKeyVaultCertificateKeys.includes(certName)) { + logger.debug( + { syncId: pkiSync.id, certificateName: certName }, + "Skipping disabled certificate in Azure Key Vault" + ); + return; + } + + const existingCert = vaultCertificates[certName]; + const shouldUpdateCert = !existingCert || existingCert.cert !== cert; + + if (shouldUpdateCert) { + setCertificates.push({ + key: certName, + cert, + privateKey + }); + logger.debug( + { syncId: pkiSync.id, certificateName: certName, isUpdate: !!existingCert }, + "Certificate will be uploaded to Azure Key Vault" + ); + } else { + logger.debug( + { syncId: pkiSync.id, certificateName: certName }, + "Certificate already up to date in Azure Key Vault" + ); + } + }); + + // Identify expired/removed certificates that need to be cleaned up from Azure Key Vault + // Only remove certificates that were managed by Infisical (start with 'Infisical-') + const certificatesToRemove = Object.keys(vaultCertificates).filter( + (vaultCertName) => + vaultCertName.startsWith("Infisical-") && + !activeCertificateNames.includes(vaultCertName) && + !disabledAzureKeyVaultCertificateKeys.includes(vaultCertName) + ); + + logger.info( + { + syncId: pkiSync.id, + certificatesToUpload: setCertificates.length, + certificatesToRemove: certificatesToRemove.length, + totalCertificates: Object.keys(certificateMap).length + }, + "Determined certificates to upload and remove from Azure Key Vault" + ); + + // Upload certificates to Azure Key Vault + const uploadPromises = setCertificates.map(async ({ key, cert, privateKey }) => { + try { + // Combine certificate and private key in PEM format for Azure Key Vault + // Azure Key Vault accepts PEM format with both cert and private key + let combinedPem = cert; + if (privateKey) { + combinedPem = `${privateKey}\n${cert}`; + } + + // Convert to base64 for Azure Key Vault import + const base64Cert = Buffer.from(combinedPem).toString("base64"); + + const importData = { + value: base64Cert, + policy: { + key_props: { + exportable: true, + key_size: 2048, + kty: "RSA", + reuse_key: false + }, + secret_props: { + contentType: "application/x-pem-file" + }, + x509_props: { + subject: "", + sans: { + dns_names: [], + emails: [], + upns: [] + } + } + } + }; + + const response = await request.post( + `${pkiSync.destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(key)}/import?api-version=7.4`, + importData, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + + logger.info( + { syncId: pkiSync.id, certificateName: key }, + "Successfully uploaded certificate to Azure Key Vault" + ); + + return { key, success: true, response: response.data as unknown }; + } catch (error) { + if (error instanceof AxiosError) { + const errorMessage = + error.response?.data && typeof error.response.data === "object" && "error" in error.response.data + ? (error.response.data as { error?: { message?: string } }).error?.message || error.message + : error.message; + + // Check if the error is due to certificate in deleted but recoverable state + const isDeletedButRecoverable = + errorMessage.includes("deleted but recoverable state") || errorMessage.includes("name cannot be reused"); + + if (isDeletedButRecoverable) { + logger.warn( + { certificateKey: key, syncId: pkiSync.id }, + "Certificate exists in deleted but recoverable state in Azure Key Vault - skipping upload" + ); + // Return a successful result to avoid failing the entire sync + return { key, success: false, skipped: true, reason: "Certificate in deleted but recoverable state" }; + } + + throw new PkiSyncError({ + message: `Failed to upload certificate ${key} to Azure Key Vault: ${errorMessage}`, + cause: error, + context: { + certificateKey: key, + statusCode: error.response?.status, + responseData: error.response?.data + } + }); + } + throw error; + } + }); + + const results = await Promise.allSettled(uploadPromises); + const failedUploads = results.filter((result) => result.status === "rejected"); + const fulfilledResults = results.filter((result) => result.status === "fulfilled"); + + // Separate successful uploads from skipped certificates + const successfulUploads = fulfilledResults.filter( + (result) => result.status === "fulfilled" && result.value.success + ); + const skippedUploads = fulfilledResults.filter((result) => result.status === "fulfilled" && result.value.skipped); + + // Remove expired/removed certificates from Azure Key Vault + let removedCertificates = 0; + let failedRemovals = 0; + + if (certificatesToRemove.length > 0) { + logger.info( + { + syncId: pkiSync.id, + certificatesToRemove: certificatesToRemove.length + }, + "Removing expired/removed certificates from Azure Key Vault" + ); + + const removePromises = certificatesToRemove.map(async (certName) => { + try { + await request.delete( + `${pkiSync.destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(certName)}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + logger.info( + { syncId: pkiSync.id, certificateName: certName }, + "Successfully removed expired/removed certificate from Azure Key Vault" + ); + + return { key: certName, success: true }; + } catch (error) { + // If certificate doesn't exist (404), consider it as successfully removed + if (error instanceof AxiosError && error.response?.status === 404) { + logger.info( + { syncId: pkiSync.id, certificateName: certName }, + "Certificate not found in Azure Key Vault during sync cleanup - considering removal successful" + ); + return { key: certName, success: true, alreadyRemoved: true }; + } + + logger.error( + { error, syncId: pkiSync.id, certificateName: certName }, + "Failed to remove expired/removed certificate from Azure Key Vault" + ); + + // Don't throw here - we want to continue with other operations + return { key: certName, success: false, error: error as Error }; + } + }); + + const removeResults = await Promise.allSettled(removePromises); + const successfulRemovals = removeResults.filter( + (result) => result.status === "fulfilled" && result.value.success + ); + removedCertificates = successfulRemovals.length; + failedRemovals = removeResults.length - removedCertificates; + + if (failedRemovals > 0) { + logger.warn( + { + syncId: pkiSync.id, + failedRemovals, + successfulRemovals: removedCertificates + }, + "Some expired/removed certificates could not be removed from Azure Key Vault" + ); + } + } + + // Log skipped certificates for transparency + if (skippedUploads.length > 0) { + const skippedNames = skippedUploads.map((result) => + result.status === "fulfilled" ? result.value.key : "unknown" + ); + logger.info( + { + syncId: pkiSync.id, + skippedCertificates: skippedNames, + skippedCount: skippedUploads.length + }, + "Some certificates were skipped due to Azure Key Vault constraints" + ); + } + + logger.info( + { + syncId: pkiSync.id, + successfulUploads: successfulUploads.length, + failedUploads: failedUploads.length, + skippedUploads: skippedUploads.length, + removedCertificates, + failedRemovals, + skippedCertificates: Object.keys(certificateMap).length - setCertificates.length + }, + "Azure Key Vault certificate sync completed" + ); + + if (failedUploads.length > 0) { + const failedReasons = failedUploads.map((failure) => { + if (failure.status === "rejected") { + return (failure.reason as Error)?.message || "Unknown error"; + } + return "Unknown error"; + }); + + logger.error( + { + syncId: pkiSync.id, + failedReasons, + failedCount: failedUploads.length + }, + "Some certificates failed to upload to Azure Key Vault" + ); + + throw new PkiSyncError({ + message: `Failed to upload ${failedUploads.length} certificate(s) to Azure Key Vault`, + context: { + failedReasons, + totalCertificates: setCertificates.length, + failedCount: failedUploads.length + } + }); + } + + return { + uploaded: setCertificates.length, + removed: removedCertificates, + failedRemovals, + skipped: Object.keys(certificateMap).length - setCertificates.length + }; + }; + + const importCertificates = async (pkiSync: TAzureKeyVaultPkiSyncWithCredentials): Promise => { + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + const { vaultCertificates } = await $getAzureKeyVaultCertificates( + accessToken, + pkiSync.destinationConfig.vaultBaseUrl + ); + + return vaultCertificates; + }; + + const removeCertificates = async (pkiSync: TAzureKeyVaultPkiSyncWithCredentials, certificateNames: string[]) => { + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + // Only remove certificates that are managed by Infisical (start with 'Infisical-' prefix) + const infisicalManagedCertNames = certificateNames.filter((certName) => certName.startsWith("Infisical-")); + + if (infisicalManagedCertNames.length < certificateNames.length) { + logger.debug( + { + syncId: pkiSync.id, + totalRequested: certificateNames.length, + infisicalManaged: infisicalManagedCertNames.length, + skipped: certificateNames.length - infisicalManagedCertNames.length + }, + "Filtered out non-Infisical certificates from removal request" + ); + } + + const removePromises = infisicalManagedCertNames.map(async (certName) => { + try { + const response = await request.delete( + `${pkiSync.destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(certName)}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + return { key: certName, success: true, response: response.data as unknown }; + } catch (error) { + if (error instanceof AxiosError) { + // If certificate doesn't exist (404), consider it as successfully removed + if (error.response?.status === 404) { + logger.info( + { syncId: pkiSync.id, certificateName: certName }, + "Certificate not found in Azure Key Vault - considering removal successful" + ); + return { key: certName, success: true, alreadyRemoved: true }; + } + + throw new PkiSyncError({ + message: `Failed to remove certificate ${certName} from Azure Key Vault`, + cause: error, + context: { + certificateKey: certName, + statusCode: error.response?.status, + responseData: error.response?.data + } + }); + } + throw error; + } + }); + + const results = await Promise.allSettled(removePromises); + const failedRemovals = results.filter((result) => result.status === "rejected"); + + if (failedRemovals.length > 0) { + const failedReasons = failedRemovals.map((failure) => { + if (failure.status === "rejected") { + return (failure.reason as Error)?.message || "Unknown error"; + } + return "Unknown error"; + }); + + throw new PkiSyncError({ + message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`, + context: { + failedReasons, + totalCertificates: infisicalManagedCertNames.length, + failedCount: failedRemovals.length + } + }); + } + + return { + removed: infisicalManagedCertNames.length - failedRemovals.length, + failed: failedRemovals.length, + skipped: certificateNames.length - infisicalManagedCertNames.length + }; + }; + + return { + syncCertificates, + importCertificates, + removeCertificates + }; +}; diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts new file mode 100644 index 000000000..4549896f3 --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { TPkiSyncWithCredentials } from "../pki-sync-types"; + +export type GetAzureKeyVaultCertificate = { + id: string; + value: string; + attributes: { + enabled: boolean; + created: number; + updated: number; + recoveryLevel: string; + tags?: Record; + }; + x5t?: string; + contentType?: string; + key?: string; + cer?: string; +}; + +export const AzureKeyVaultPkiSyncConfigSchema = z.object({ + vaultBaseUrl: z.string().url() +}); + +export type TAzureKeyVaultPkiSyncConfig = z.infer; + +export type TAzureKeyVaultPkiSyncWithCredentials = TPkiSyncWithCredentials & { + destinationConfig: TAzureKeyVaultPkiSyncConfig; +}; diff --git a/backend/src/services/pki-sync/pki-sync-dal.ts b/backend/src/services/pki-sync/pki-sync-dal.ts new file mode 100644 index 000000000..151d93f77 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-dal.ts @@ -0,0 +1,181 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiSyncs } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; + +import { PkiSync } from "./pki-sync-enums"; + +export type TPkiSyncDALFactory = ReturnType; + +type PkiSyncFindFilter = Parameters>[0]; + +const basePkiSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: PkiSyncFindFilter; tx?: Knex }) => { + const query = (tx || db.replicaNode())(TableName.PkiSync) + .leftJoin(TableName.AppConnection, `${TableName.PkiSync}.connectionId`, `${TableName.AppConnection}.id`) + .select(selectAllTableCols(TableName.PkiSync)) + .select( + // app connection fields + db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"), + db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"), + db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"), + db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"), + db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("appConnectionIsPlatformManagedCredentials"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials") + ); + + if (filter) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PkiSync, filter))); + } + + return query; +}; + +const expandPkiSync = (pkiSync: Awaited>[number]) => { + const { + appConnectionName, + appConnectionApp, + appConnectionEncryptedCredentials, + appConnectionOrgId, + appConnectionMethod, + appConnectionDescription, + appConnectionVersion, + appConnectionGatewayId, + appConnectionCreatedAt, + appConnectionUpdatedAt, + appConnectionIsPlatformManagedCredentials, + ...el + } = pkiSync; + + return { + ...el, + destination: el.destination as PkiSync, + destinationConfig: el.destinationConfig as Record, + syncOptions: el.syncOptions as Record, + appConnectionName, + appConnectionApp, + connection: { + id: el.connectionId, + name: appConnectionName, + app: appConnectionApp, + encryptedCredentials: appConnectionEncryptedCredentials, + orgId: appConnectionOrgId, + method: appConnectionMethod, + description: appConnectionDescription, + version: appConnectionVersion, + gatewayId: appConnectionGatewayId, + createdAt: appConnectionCreatedAt, + updatedAt: appConnectionUpdatedAt, + isPlatformManagedCredentials: appConnectionIsPlatformManagedCredentials + } + }; +}; + +export const pkiSyncDALFactory = (db: TDbClient) => { + const pkiSyncOrm = ormify(db, TableName.PkiSync); + + const findByProjectId = async (projectId: string, tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncQuery({ filter: { projectId }, db, tx }); + return pkiSyncs.map(expandPkiSync); + } catch (error) { + throw new DatabaseError({ error, name: "Find By Project ID - PKI Sync" }); + } + }; + + const findBySubscriberId = async (subscriberId: string, tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncQuery({ filter: { subscriberId }, db, tx }); + return pkiSyncs.map(expandPkiSync); + } catch (error) { + throw new DatabaseError({ error, name: "Find By Subscriber ID - PKI Sync" }); + } + }; + + const findByIdAndProjectId = async (id: string, projectId: string, tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter: { id, projectId }, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find By ID and Project ID - PKI Sync" }); + } + }; + + const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter: { name, projectId }, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find By Name and Project ID - PKI Sync" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter: { id }, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find By ID - PKI Sync" }); + } + }; + + const findOne = async (filter: Parameters<(typeof pkiSyncOrm)["findOne"]>[0], tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find One - PKI Sync" }); + } + }; + + const find = async (filter: Parameters<(typeof pkiSyncOrm)["find"]>[0], tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncQuery({ filter, db, tx }); + return pkiSyncs.map(expandPkiSync); + } catch (error) { + throw new DatabaseError({ error, name: "Find - PKI Sync" }); + } + }; + + const create = async (data: Parameters<(typeof pkiSyncOrm)["create"]>[0]) => { + const pkiSync = (await pkiSyncOrm.transaction(async (tx) => { + const sync = await pkiSyncOrm.create(data, tx); + return basePkiSyncQuery({ filter: { id: sync.id }, db, tx }).first(); + }))!; + + return expandPkiSync(pkiSync); + }; + + const updateById = async (syncId: string, data: Parameters<(typeof pkiSyncOrm)["updateById"]>[1]) => { + const pkiSync = (await pkiSyncOrm.transaction(async (tx) => { + const sync = await pkiSyncOrm.updateById(syncId, data, tx); + return basePkiSyncQuery({ filter: { id: sync.id }, db, tx }).first(); + }))!; + + return expandPkiSync(pkiSync); + }; + + return { + ...pkiSyncOrm, + findByProjectId, + findBySubscriberId, + findByIdAndProjectId, + findByNameAndProjectId, + findById, + findOne, + find, + create, + updateById + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-enums.ts b/backend/src/services/pki-sync/pki-sync-enums.ts new file mode 100644 index 000000000..6382d4784 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-enums.ts @@ -0,0 +1,22 @@ +export enum PkiSync { + AzureKeyVault = "azure-key-vault" +} + +export enum PkiSyncStatus { + Pending = "PENDING", + Running = "RUNNING", + Success = "SUCCESS", + Failed = "FAILED" +} + +export enum PkiSyncImportBehavior { + ImportAllSecrets = "IMPORT_ALL_SECRETS", + PreferInfisicalSecrets = "PREFER_INFISICAL_SECRETS", + PreferExternalSecrets = "PREFER_EXTERNAL_SECRETS" +} + +export enum PkiSyncAction { + SyncCertificates = "sync-certificates", + ImportCertificates = "import-certificates", + RemoveCertificates = "remove-certificates" +} diff --git a/backend/src/services/pki-sync/pki-sync-errors.ts b/backend/src/services/pki-sync/pki-sync-errors.ts new file mode 100644 index 000000000..80877d13b --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-errors.ts @@ -0,0 +1,25 @@ +export class PkiSyncError extends Error { + public context?: Record; + + public cause?: Error; + + public shouldRetry: boolean; + + constructor({ + message, + cause, + context, + shouldRetry = true + }: { + message: string; + cause?: Error; + context?: Record; + shouldRetry?: boolean; + }) { + super(message); + this.name = "PkiSyncError"; + this.cause = cause; + this.context = context; + this.shouldRetry = shouldRetry; + } +} diff --git a/backend/src/services/pki-sync/pki-sync-fns.ts b/backend/src/services/pki-sync/pki-sync-fns.ts new file mode 100644 index 000000000..a6027177b --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-fns.ts @@ -0,0 +1,121 @@ +import { z, ZodSchema } from "zod"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { BadRequestError } from "@app/lib/errors"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { PkiSync } from "./pki-sync-enums"; +import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types"; + +const ENTERPRISE_PKI_SYNCS: PkiSync[] = []; + +export const enterprisePkiSyncCheck = async ( + licenseService: Pick, + orgId: string, + pkiSyncDestination: PkiSync, + errorMessage?: string +) => { + const plan = await licenseService.getPlan(orgId); + + if (!plan.enterpriseSecretSyncs && ENTERPRISE_PKI_SYNCS.includes(pkiSyncDestination)) { + throw new BadRequestError({ + message: errorMessage || "Failed to create PKI sync due to plan restriction. Upgrade plan to create PKI sync." + }); + } +}; + +export const listPkiSyncOptions = () => { + return Object.values(PkiSync); +}; + +export const matchesSchema = (schema: T, data: unknown): data is z.infer => { + return schema.safeParse(data).success; +}; + +export const parsePkiSyncErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + return "An unknown error occurred during PKI sync operation"; +}; + +export const PkiSyncFns = { + getCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + const { azureKeyVaultPkiSyncFactory } = await import("./azure-key-vault/azure-key-vault-pki-sync-fns"); + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + // Type assertion needed due to destinationConfig type differences + return azureKeyVaultPkiSync.importCertificates( + pkiSync as unknown as import("./azure-key-vault/azure-key-vault-pki-sync-types").TAzureKeyVaultPkiSyncWithCredentials + ); + } + default: + throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); + } + }, + + syncCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + certificateMap: TCertificateMap, + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise<{ + uploaded: number; + removed?: number; + failedRemovals?: number; + skipped: number; + }> => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + const { azureKeyVaultPkiSyncFactory } = await import("./azure-key-vault/azure-key-vault-pki-sync-fns"); + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + // Type assertion needed due to destinationConfig type differences + return azureKeyVaultPkiSync.syncCertificates( + pkiSync as unknown as import("./azure-key-vault/azure-key-vault-pki-sync-types").TAzureKeyVaultPkiSyncWithCredentials, + certificateMap + ); + } + default: + throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); + } + }, + + removeCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + certificateNames: string[], + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + const { azureKeyVaultPkiSyncFactory } = await import("./azure-key-vault/azure-key-vault-pki-sync-fns"); + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + // Type assertion needed due to destinationConfig type differences + await azureKeyVaultPkiSync.removeCertificates( + pkiSync as unknown as import("./azure-key-vault/azure-key-vault-pki-sync-types").TAzureKeyVaultPkiSyncWithCredentials, + certificateNames + ); + break; + } + default: + throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); + } + } +}; diff --git a/backend/src/services/pki-sync/pki-sync-maps.ts b/backend/src/services/pki-sync/pki-sync-maps.ts new file mode 100644 index 000000000..b667416c6 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-maps.ts @@ -0,0 +1,11 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { PkiSync } from "./pki-sync-enums"; + +export const PKI_SYNC_NAME_MAP: Record = { + [PkiSync.AzureKeyVault]: "Azure Key Vault" +}; + +export const PKI_SYNC_CONNECTION_MAP: Record = { + [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault +}; diff --git a/backend/src/services/pki-sync/pki-sync-queue.ts b/backend/src/services/pki-sync/pki-sync-queue.ts new file mode 100644 index 000000000..caec59f48 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-queue.ts @@ -0,0 +1,1032 @@ +/* eslint-disable no-await-in-loop */ +import opentelemetry from "@opentelemetry/api"; +import * as x509 from "@peculiar/x509"; +import { AxiosError } from "axios"; +import { Job } from "bullmq"; + +import { ProjectMembershipRole } 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"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { getCertificateCredentials } from "../certificate/certificate-fns"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { CertStatus } from "../certificate/certificate-types"; +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSyncAction } from "./pki-sync-enums"; +import { PkiSyncError } from "./pki-sync-errors"; +import { enterprisePkiSyncCheck, parsePkiSyncErrorMessage, PkiSyncFns } from "./pki-sync-fns"; +import { + PkiSyncStatus, + TCertificateMap, + TPkiSyncImportCertificatesDTO, + TPkiSyncRaw, + TPkiSyncRemoveCertificatesDTO, + TPkiSyncSyncCertificatesDTO, + TPkiSyncWithCredentials, + TQueuePkiSyncImportCertificatesByIdDTO, + TQueuePkiSyncRemoveCertificatesByIdDTO, + TQueuePkiSyncSyncCertificatesByIdDTO, + TQueueSendPkiSyncActionFailedNotificationsDTO, + TSendPkiSyncFailedNotificationsJobDTO +} from "./pki-sync-types"; + +export type TPkiSyncQueueFactory = ReturnType; + +type TPkiSyncQueueFactoryDep = { + queueService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "createCipherPairWithDataKey" | "decryptWithKmsKey" | "generateKmsKey" | "encryptWithKmsKey" + >; + appConnectionDAL: Pick; + keyStore: Pick; + pkiSyncDAL: Pick; + auditLogService: Pick; + projectMembershipDAL: Pick; + projectDAL: TProjectDALFactory; + licenseService: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "findLatestActiveCertForSubscriber" | "findAllActiveCertsForSubscriber" | "create" + >; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; +}; + +type PkiSyncActionJob = Job< + TQueuePkiSyncSyncCertificatesByIdDTO | TQueuePkiSyncImportCertificatesByIdDTO | TQueuePkiSyncRemoveCertificatesByIdDTO +>; + +const JITTER_MS = 10 * 1000; +const REQUEUE_MS = 30 * 1000; +const REQUEUE_LIMIT = 30; +const CONNECTION_CONCURRENCY_LIMIT = 3; + +const getRequeueDelay = (failureCount?: number) => { + const jitter = Math.random() * JITTER_MS; + if (!failureCount) return jitter; + return REQUEUE_MS + jitter; +}; + +export const pkiSyncQueueFactory = ({ + queueService, + kmsService, + appConnectionDAL, + keyStore, + pkiSyncDAL, + auditLogService, + projectMembershipDAL, + projectDAL, + licenseService, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL +}: TPkiSyncQueueFactoryDep) => { + const appCfg = getConfig(); + + const integrationMeter = opentelemetry.metrics.getMeter("PkiSyncs"); + const syncCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_sync_certificates_errors", { + description: "PKI Sync - sync certificates errors", + unit: "1" + }); + const importCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_import_certificates_errors", { + description: "PKI Sync - import certificates errors", + unit: "1" + }); + const removeCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_remove_certificates_errors", { + description: "PKI Sync - remove certificates errors", + unit: "1" + }); + + const $isConnectionConcurrencyLimitReached = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + if (!concurrencyCount) return false; + + const count = Number.parseInt(concurrencyCount, 10); + + if (Number.isNaN(count)) return false; + + return count >= CONNECTION_CONCURRENCY_LIMIT; + }; + + const $incrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const incrementedCount = Number.isNaN(currentCount) ? 1 : currentCount + 1; + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + incrementedCount + ); + }; + + const $decrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const decrementedCount = Math.max(0, Number.isNaN(currentCount) ? 0 : currentCount - 1); + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + decrementedCount + ); + }; + + const $createCertificatesInSubscriber = async ( + pkiSync: TPkiSyncWithCredentials, + certificatesToCreate: Array<{ + name: string; + certificate: string; + privateKey?: string; + }> + ) => { + const { projectId, subscriberId } = pkiSync; + + if (!subscriberId) { + throw new Error("PKI Sync subscriber ID is required for certificate creation"); + } + + logger.info(`Creating ${certificatesToCreate.length} certificates in PKI subscriber ${subscriberId}`); + + for (const certData of certificatesToCreate) { + try { + // Validate certificate data + if (!certData.certificate || certData.certificate.trim() === "") { + logger.error(`Skipping certificate ${certData.name}: empty certificate data`); + return; + } + + // Parse certificate to extract metadata + const cert = new x509.X509Certificate(certData.certificate); + const { serialNumber } = cert; + const { notBefore } = cert; + const { notAfter } = cert; + const commonName = + cert.subject + .split(",") + .find((part) => part.trim().startsWith("CN=")) + ?.split("=")[1] + ?.trim() || certData.name; + + // Get KMS key for encryption + const kmsKeyId = await getProjectKmsCertificateKeyId({ projectId, projectDAL, kmsService }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: kmsKeyId + }); + + // Create certificate record + const createdCert = await certificateDAL.create({ + pkiSubscriberId: subscriberId, + status: CertStatus.ACTIVE, + serialNumber, + notBefore, + notAfter, + commonName, + friendlyName: certData.name, + projectId + }); + + // Create certificate body record with encrypted certificate + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(certData.certificate) + }); + + await certificateBodyDAL.create({ + certId: createdCert.id, + encryptedCertificate + }); + + // Create certificate secret record with encrypted private key (if available) + if (certData.privateKey) { + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(certData.privateKey) + }); + + await certificateSecretDAL.create({ + certId: createdCert.id, + encryptedPrivateKey + }); + } + + logger.info(`Successfully created certificate ${certData.name} with ID ${createdCert.id}`); + } catch (error) { + logger.error(`Failed to create certificate ${certData.name}: ${String(error)}`); + // Continue with other certificates even if one fails + } + } + }; + + 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 + }); + } + + const certificateMap: TCertificateMap = {}; + + try { + // Get all active certificates for the subscriber (not just the latest) + const certificates = await certificateDAL.findAllActiveCertsForSubscriber({ + subscriberId + }); + + logger.info( + { subscriberId, certificateCount: certificates.length }, + "Found active certificates for PKI sync subscriber" + ); + + for (const certificate of certificates) { + try { + // Only sync certificates issued by Infisical (not imported ones) + // Imported certificates don't have caId and certificateTemplateId + if (!certificate.caId) { + logger.debug( + { certificateId: certificate.id, subscriberId }, + "Skipping imported certificate - not syncing to destination" + ); + // eslint-disable-next-line no-continue + continue; + } + + // Check if certificate is expired + const now = new Date(); + if (certificate.notAfter < now) { + logger.debug( + { certificateId: certificate.id, subscriberId, expiredAt: certificate.notAfter }, + "Skipping expired certificate" + ); + // eslint-disable-next-line no-continue + continue; + } + + // Get the certificate body and decrypt the certificate data + const certBody = await certificateBodyDAL.findOne({ certId: certificate.id }); + + if (certBody) { + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: certificate.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + const certificatePem = certObj.toString("pem"); + + // Get private key using getCertificateCredentials - handle cases where private key doesn't exist + let certPrivateKey: string | undefined; + try { + const credentials = await getCertificateCredentials({ + certId: certificate.id, + projectId: certificate.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + certPrivateKey = credentials.certPrivateKey; + } catch (credError) { + logger.warn( + { certificateId: certificate.id, subscriberId, error: credError }, + "Certificate private key not found - certificate may be imported or key was not stored" + ); + // Continue without private key - some providers may only need the certificate + certPrivateKey = undefined; + } + + // Use Infisical-prefixed ID for clear identification in destination + // Azure Key Vault doesn't allow underscores, so use hyphens and remove UUID hyphens + const certificateName = `Infisical-${certificate.id.replace(/-/g, "")}`; + + certificateMap[certificateName] = { + cert: certificatePem, + privateKey: certPrivateKey || "" + }; + + logger.info( + { certificateId: certificate.id, certificateName, subscriberId }, + "Successfully prepared certificate for PKI sync" + ); + } else { + logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate"); + } + } catch (error) { + logger.error( + { error, subscriberId, certificateId: certificate.id }, + "Failed to decrypt certificate for PKI sync" + ); + // Continue with other certificates + } + } + } catch (error) { + logger.error( + error, + `Failed to fetch certificate for subscriber [subscriberId=${subscriberId}] [projectId=${projectId}]` + ); + throw new PkiSyncError({ + message: `Failed to fetch certificate for PKI subscriber: ${error instanceof Error ? error.message : String(error)}`, + shouldRetry: true + }); + } + + return certificateMap; + }; + + const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) => + queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncSyncCertificates, payload, { + delay: getRequeueDelay(payload.failedToAcquireLockCount), // this is for delaying re-queued jobs if sync is locked + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const queuePkiSyncImportCertificatesById = async (payload: TQueuePkiSyncImportCertificatesByIdDTO) => + queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncImportCertificates, payload, { + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const queuePkiSyncRemoveCertificatesById = async (payload: TQueuePkiSyncRemoveCertificatesByIdDTO) => + queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncRemoveCertificates, payload, { + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const $queueSendPkiSyncFailedNotifications = async (payload: TQueueSendPkiSyncActionFailedNotificationsDTO) => { + if (!appCfg.isSmtpConfigured) return; + + await queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncSendActionFailedNotifications, payload, { + jobId: `pki-sync-${payload.pkiSync.id}-failed-notifications`, + attempts: 5, + delay: 1000 * 60, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnFail: true, + removeOnComplete: true + }); + }; + + const $importCertificates = async (pkiSync: TPkiSyncWithCredentials): Promise => { + const { + projectId, + destination, + connection: { orgId } + } = pkiSync; + + await enterprisePkiSyncCheck( + licenseService, + orgId, + destination, + "Failed to import certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs." + ); + + if (!projectId) { + throw new Error("Invalid PKI Sync source configuration: project no longer exists."); + } + + const importedCertificates = await PkiSyncFns.getCertificates(pkiSync, { + appConnectionDAL, + kmsService + }); + + if (!Object.keys(importedCertificates).length) return {}; + + const importedCertificateMap: TCertificateMap = {}; + + const certificateMap = await $getInfisicalCertificates(pkiSync); + + // Compare existing certificates with imported ones and determine which need to be created/updated + const certificatesToCreate: Array<{ + name: string; + certificate: string; + privateKey?: string; + }> = []; + + Object.entries(importedCertificates).forEach(([name, certificateData]) => { + const { cert: certificate, privateKey } = certificateData; + + if (!Object.prototype.hasOwnProperty.call(certificateMap, name)) { + // Certificate doesn't exist in Infisical, create it + certificatesToCreate.push({ + name, + certificate, + privateKey + }); + importedCertificateMap[name] = certificateData; + } else { + // Certificate exists - could compare and update if needed + // For now, we'll skip updating existing certificates to avoid conflicts + importedCertificateMap[name] = certificateData; + } + }); + + // Create new certificates in Infisical + if (certificatesToCreate.length > 0) { + logger.info(`PKI Sync Import: Creating ${certificatesToCreate.length} new certificates`); + await $createCertificatesInSubscriber(pkiSync, certificatesToCreate); + } + + return importedCertificateMap; + }; + + const $handleSyncCertificatesJob = async (job: TPkiSyncSyncCertificatesDTO, pkiSync: TPkiSyncRaw) => { + const { + data: { syncId, auditLogInfo } + } = job; + + await enterprisePkiSyncCheck( + licenseService, + pkiSync.connection.orgId, + pkiSync.destination, + "Failed to sync certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs." + ); + + await pkiSyncDAL.updateById(syncId, { + syncStatus: PkiSyncStatus.Running + }); + + logger.info( + `PkiSync Sync [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + let isSynced = false; + let syncMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService + }); + + const pkiSyncWithCredentials = { + ...pkiSync, + connection: { + ...pkiSync.connection, + credentials + } + } as TPkiSyncWithCredentials; + + const certificateMap = await $getInfisicalCertificates(pkiSync); + + const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, { + appConnectionDAL, + kmsService + }); + + logger.info( + { + syncId: pkiSync.id, + uploaded: syncResult.uploaded || 0, + removed: syncResult.removed || 0, + failedRemovals: syncResult.failedRemovals || 0, + skipped: syncResult.skipped || 0 + }, + "PKI sync operation completed with certificate cleanup" + ); + + isSynced = true; + } catch (err) { + logger.error( + err, + `PkiSync Sync Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + syncCertificatesErrorHistogram.record(1, { + version: 1, + destination: pkiSync.destination, + syncId: pkiSync.id, + projectId: pkiSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + syncMessage = parsePkiSyncErrorMessage(err); + + if (err instanceof PkiSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + // re-throw so job fails + throw err; + } + } finally { + const ranAt = new Date(); + const syncStatus = isSynced ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: pkiSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.PKI_SYNC_SYNC_CERTIFICATES, + metadata: { + syncId: pkiSync.id, + syncMessage, + jobId: job.id!, + jobRanAt: ranAt + } + } + }); + + if (isSynced || isFinalAttempt) { + const updatedPkiSync = await pkiSyncDAL.updateById(pkiSync.id, { + syncStatus, + lastSyncJobId: job.id, + lastSyncMessage: syncMessage, + lastSyncedAt: isSynced ? ranAt : undefined + }); + + if (!isSynced) { + await $queueSendPkiSyncFailedNotifications({ + pkiSync: updatedPkiSync, + action: PkiSyncAction.SyncCertificates, + auditLogInfo + }); + } + } + } + + logger.info("PkiSync Sync Job with ID %s Completed", job.id); + }; + + const $handleImportCertificatesJob = async (job: TPkiSyncImportCertificatesDTO, pkiSync: TPkiSyncRaw) => { + const { + data: { syncId, auditLogInfo } + } = job; + + await pkiSyncDAL.updateById(syncId, { + importStatus: PkiSyncStatus.Running + }); + + logger.info( + `PkiSync Import [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + let isSuccess = false; + let importMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService + }); + + await $importCertificates({ + ...pkiSync, + connection: { + ...pkiSync.connection, + credentials + } + } as TPkiSyncWithCredentials); + + isSuccess = true; + } catch (err) { + logger.error( + err, + `PkiSync Import Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + importCertificatesErrorHistogram.record(1, { + version: 1, + destination: pkiSync.destination, + syncId: pkiSync.id, + projectId: pkiSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + importMessage = parsePkiSyncErrorMessage(err); + + if (err instanceof PkiSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + // re-throw so job fails + throw err; + } + } finally { + const ranAt = new Date(); + const importStatus = isSuccess ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: pkiSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.PKI_SYNC_IMPORT_CERTIFICATES, + metadata: { + syncId: pkiSync.id, + importMessage, + jobId: job.id!, + jobRanAt: ranAt + } + } + }); + + if (isSuccess || isFinalAttempt) { + const updatedPkiSync = await pkiSyncDAL.updateById(pkiSync.id, { + importStatus, + lastImportJobId: job.id, + lastImportMessage: importMessage, + lastImportedAt: isSuccess ? ranAt : undefined + }); + + if (!isSuccess) { + await $queueSendPkiSyncFailedNotifications({ + pkiSync: updatedPkiSync, + action: PkiSyncAction.ImportCertificates, + auditLogInfo + }); + } + } + } + + logger.info("PkiSync Import Job with ID %s Completed", job.id); + }; + + const $handleRemoveCertificatesJob = async (job: TPkiSyncRemoveCertificatesDTO, pkiSync: TPkiSyncRaw) => { + const { + data: { syncId, auditLogInfo, deleteSyncOnComplete } + } = job; + + await enterprisePkiSyncCheck( + licenseService, + pkiSync.connection.orgId, + pkiSync.destination, + "Failed to remove certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs." + ); + + await pkiSyncDAL.updateById(syncId, { + removeStatus: PkiSyncStatus.Running + }); + + logger.info( + `PkiSync Remove [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + let isSuccess = false; + let removeMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService + }); + + const certificateMap = await $getInfisicalCertificates(pkiSync); + + await PkiSyncFns.removeCertificates( + { + ...pkiSync, + connection: { + ...pkiSync.connection, + credentials + } + } as TPkiSyncWithCredentials, + Object.keys(certificateMap), + { + appConnectionDAL, + kmsService + } + ); + + isSuccess = true; + } catch (err) { + logger.error( + err, + `PkiSync Remove Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + removeCertificatesErrorHistogram.record(1, { + version: 1, + destination: pkiSync.destination, + syncId: pkiSync.id, + projectId: pkiSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + removeMessage = parsePkiSyncErrorMessage(err); + + if (err instanceof PkiSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + // re-throw so job fails + throw err; + } + } finally { + const ranAt = new Date(); + const removeStatus = isSuccess ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: pkiSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.PKI_SYNC_REMOVE_CERTIFICATES, + metadata: { + syncId: pkiSync.id, + removeMessage, + jobId: job.id!, + jobRanAt: ranAt + } + } + }); + + if (isSuccess || isFinalAttempt) { + if (isSuccess && deleteSyncOnComplete) { + await pkiSyncDAL.deleteById(pkiSync.id); + } else { + const updatedPkiSync = await pkiSyncDAL.updateById(pkiSync.id, { + removeStatus, + lastRemoveJobId: job.id, + lastRemoveMessage: removeMessage, + lastRemovedAt: isSuccess ? ranAt : undefined + }); + + if (!isSuccess) { + await $queueSendPkiSyncFailedNotifications({ + pkiSync: updatedPkiSync, + action: PkiSyncAction.RemoveCertificates, + auditLogInfo + }); + } + } + } + } + + logger.info("PkiSync Remove Job with ID %s Completed", job.id); + }; + + const $sendPkiSyncFailedNotifications = async (job: TSendPkiSyncFailedNotificationsJobDTO) => { + const { + data: { pkiSync, auditLogInfo, action } + } = job; + + const { projectId, name, lastSyncMessage, lastRemoveMessage, lastImportMessage } = pkiSync; + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const project = await projectDAL.findById(projectId); + + // Filter for project admins similar to secret sync + let projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + const triggeredByUserId = auditLogInfo?.actor?.type === ActorType.USER ? auditLogInfo.actor.metadata?.userId : null; + + if (triggeredByUserId) { + // Don't send notification to the user who triggered the action + projectAdmins = projectAdmins.filter((member) => member.user.id !== triggeredByUserId); + } + + // Get appropriate error message based on action type + let errorMessage: string | null = null; + if (action === PkiSyncAction.SyncCertificates) { + errorMessage = lastSyncMessage || null; + } else if (action === PkiSyncAction.ImportCertificates) { + errorMessage = lastImportMessage || null; + } else { + errorMessage = lastRemoveMessage || null; + } + + // Log notification for now - actual email sending would require SMTP configuration + if (projectAdmins.length > 0) { + logger.info( + `PKI Sync ${action} failure notification would be sent to ${projectAdmins.length} admin(s) for sync "${name}" in project "${project.name}". Error: ${errorMessage}` + ); + } else { + logger.info( + `PKI Sync ${action} failure occurred for sync "${name}" in project "${project.name}" but no admins to notify. Error: ${errorMessage}` + ); + } + }; + + const $handleAcquireLockFailure = async (job: PkiSyncActionJob) => { + const { syncId, auditLogInfo } = job.data; + + switch (job.name) { + case QueueJobs.PkiSyncSyncCertificates: { + const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueuePkiSyncSyncCertificatesByIdDTO; + + if (failedToAcquireLockCount < REQUEUE_LIMIT) { + await queuePkiSyncSyncCertificatesById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 }); + return; + } + + const pkiSync = await pkiSyncDAL.updateById(syncId, { + syncStatus: PkiSyncStatus.Failed, + lastSyncMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastSyncJobId: job.id + }); + + await $queueSendPkiSyncFailedNotifications({ + pkiSync, + action: PkiSyncAction.SyncCertificates, + auditLogInfo + }); + + break; + } + case QueueJobs.PkiSyncImportCertificates: { + const pkiSync = await pkiSyncDAL.updateById(syncId, { + importStatus: PkiSyncStatus.Failed, + lastImportMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastImportJobId: job.id + }); + + await $queueSendPkiSyncFailedNotifications({ + pkiSync, + action: PkiSyncAction.ImportCertificates, + auditLogInfo + }); + + break; + } + case QueueJobs.PkiSyncRemoveCertificates: { + const pkiSync = await pkiSyncDAL.updateById(syncId, { + removeStatus: PkiSyncStatus.Failed, + lastRemoveMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastRemoveJobId: job.id + }); + + await $queueSendPkiSyncFailedNotifications({ + pkiSync, + action: PkiSyncAction.RemoveCertificates, + auditLogInfo + }); + + break; + } + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled PKI Sync Job ${job.name}`); + } + }; + + queueService.start(QueueName.PkiSync, async (job) => { + if (job.name === QueueJobs.PkiSyncSendActionFailedNotifications) { + await $sendPkiSyncFailedNotifications(job as TSendPkiSyncFailedNotificationsJobDTO); + return; + } + + const { syncId } = job.data as + | TQueuePkiSyncSyncCertificatesByIdDTO + | TQueuePkiSyncImportCertificatesByIdDTO + | TQueuePkiSyncRemoveCertificatesByIdDTO; + + const pkiSync = await pkiSyncDAL.findById(syncId); + + if (!pkiSync) throw new Error(`Cannot find PKI sync with ID ${syncId}`); + + const { connectionId } = pkiSync; + + if (job.name === QueueJobs.PkiSyncSyncCertificates) { + const isConcurrentLimitReached = await $isConnectionConcurrencyLimitReached(connectionId); + + if (isConcurrentLimitReached) { + logger.info( + `PkiSync Concurrency limit reached [syncId=${syncId}] [job=${job.name}] [connectionId=${connectionId}]` + ); + + await $handleAcquireLockFailure(job as PkiSyncActionJob); + + return; + } + } + + let lock: Awaited>; + + try { + lock = await keyStore.acquireLock( + [KeyStorePrefixes.PkiSyncLock(syncId)], + // PKI syncs can take excessive amounts of time so we need to keep it locked + 5 * 60 * 1000 + ); + } catch (e) { + logger.info(`PkiSync Failed to acquire lock [syncId=${syncId}] [job=${job.name}]`); + + await $handleAcquireLockFailure(job as PkiSyncActionJob); + + return; + } + + try { + switch (job.name) { + case QueueJobs.PkiSyncSyncCertificates: { + await $incrementConnectionConcurrencyCount(connectionId); + await $handleSyncCertificatesJob(job as TPkiSyncSyncCertificatesDTO, pkiSync); + break; + } + case QueueJobs.PkiSyncImportCertificates: + await $handleImportCertificatesJob(job as TPkiSyncImportCertificatesDTO, pkiSync); + break; + case QueueJobs.PkiSyncRemoveCertificates: + await $handleRemoveCertificatesJob(job as TPkiSyncRemoveCertificatesDTO, pkiSync); + break; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled PKI Sync Job ${job.name}`); + } + } finally { + if (job.name === QueueJobs.PkiSyncSyncCertificates) { + await $decrementConnectionConcurrencyCount(connectionId); + } + + await lock.release(); + } + }); + + return { + queuePkiSyncSyncCertificatesById, + queuePkiSyncImportCertificatesById, + queuePkiSyncRemoveCertificatesById + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-schemas.ts b/backend/src/services/pki-sync/pki-sync-schemas.ts new file mode 100644 index 000000000..c7b7656b6 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-schemas.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { PkiSync } from "./pki-sync-enums"; + +// Base PKI sync schema for API responses +export const PkiSyncSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + destination: z.nativeEnum(PkiSync), + isAutoSyncEnabled: z.boolean(), + destinationConfig: z.record(z.unknown()), + syncOptions: z.record(z.unknown()), + projectId: z.string(), + subscriberId: z.string().nullable().optional(), + connectionId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + syncStatus: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional() +}); + +// Schema for PKI sync list items (includes app connection info) +export const PkiSyncListItemSchema = PkiSyncSchema.extend({ + appConnectionName: z.string(), + appConnectionApp: z.string() +}); + +// Schema for PKI sync details (includes app connection info) +export const PkiSyncDetailsSchema = PkiSyncSchema.extend({ + appConnectionName: z.string(), + appConnectionApp: z.string() +}); + +export type TPkiSyncSchema = z.infer; +export type TPkiSyncListItemSchema = z.infer; +export type TPkiSyncDetailsSchema = z.infer; diff --git a/backend/src/services/pki-sync/pki-sync-service.ts b/backend/src/services/pki-sync/pki-sync-service.ts new file mode 100644 index 000000000..fdb1e2361 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-service.ts @@ -0,0 +1,384 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +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 { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSync } from "./pki-sync-enums"; +import { enterprisePkiSyncCheck, listPkiSyncOptions } from "./pki-sync-fns"; +import { TPkiSyncQueueFactory } from "./pki-sync-queue"; +import { + PkiSyncStatus, + TCreatePkiSyncDTO, + TDeletePkiSyncDTO, + TFindPkiSyncByIdDTO, + TFindPkiSyncByNameDTO, + TListPkiSyncsByProjectId, + TListPkiSyncsBySubscriberId, + TPkiSync, + TTriggerPkiSyncImportCertificatesByIdDTO, + TTriggerPkiSyncRemoveCertificatesByIdDTO, + TTriggerPkiSyncSyncCertificatesByIdDTO, + TUpdatePkiSyncDTO +} from "./pki-sync-types"; + +type TPkiSyncServiceFactoryDep = { + pkiSyncDAL: TPkiSyncDALFactory; + pkiSubscriberDAL: Pick; + appConnectionService: Pick; + permissionService: Pick; + licenseService: Pick; + pkiSyncQueue: Pick< + TPkiSyncQueueFactory, + "queuePkiSyncSyncCertificatesById" | "queuePkiSyncImportCertificatesById" | "queuePkiSyncRemoveCertificatesById" + >; +}; + +export type TPkiSyncServiceFactory = ReturnType; + +export const pkiSyncServiceFactory = ({ + pkiSyncDAL, + pkiSubscriberDAL, + appConnectionService, + permissionService, + licenseService, + pkiSyncQueue +}: TPkiSyncServiceFactoryDep) => { + const createPkiSync = async ( + { + name, + description, + destination, + isAutoSyncEnabled = true, + destinationConfig, + syncOptions = {}, + subscriberId, + connectionId, + projectId + }: Omit, + actor: OrgServiceActor + ): Promise => { + await enterprisePkiSyncCheck(licenseService, actor.orgId, destination); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Create, + subject(ProjectPermissionSub.PkiSyncs, { projectId }) + ); + + if (subscriberId) { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber || subscriber.projectId !== projectId) { + throw new NotFoundError({ message: "PKI subscriber not found" }); + } + } + + // Get the destination app type based on PKI sync destination + const destinationApp = destination === PkiSync.AzureKeyVault ? AppConnection.AzureKeyVault : destination; + + // Validates permission to connect and app is valid for sync destination + await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor); + + try { + const pkiSync = await pkiSyncDAL.create({ + name, + description, + destination, + isAutoSyncEnabled, + destinationConfig, + syncOptions, + subscriberId, + connectionId, + projectId, + ...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending }) + }); + + if (pkiSync.isAutoSyncEnabled) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); + } + + return pkiSync as TPkiSync; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === "23505") { + throw new BadRequestError({ + message: `A PKI Sync with the name "${name}" already exists for the project with ID "${projectId}"` + }); + } + throw err; + } + }; + + const updatePkiSync = async ( + { + id, + projectId, + name, + description, + isAutoSyncEnabled, + destinationConfig, + syncOptions, + subscriberId, + connectionId + }: Omit, + actor: OrgServiceActor + ): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Edit, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + if (name && name !== pkiSync.name) { + const existingPkiSync = await pkiSyncDAL.findByNameAndProjectId(name, projectId); + if (existingPkiSync) { + throw new BadRequestError({ message: "PKI sync with this name already exists" }); + } + } + + if (subscriberId) { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber || subscriber.projectId !== projectId) { + throw new NotFoundError({ message: "PKI subscriber not found" }); + } + } + + if (connectionId && connectionId !== pkiSync.connectionId) { + const destinationApp = + pkiSync.destination === PkiSync.AzureKeyVault + ? AppConnection.AzureKeyVault + : (pkiSync.destination as AppConnection); + await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor); + } + + const updatedPkiSync = await pkiSyncDAL.updateById(id, { + name, + description, + isAutoSyncEnabled, + destinationConfig, + syncOptions, + subscriberId, + connectionId + }); + + return updatedPkiSync as TPkiSync; + }; + + const deletePkiSync = async ( + { id, projectId }: Omit, + actor: OrgServiceActor + ): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Delete, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + const deletedPkiSync = await pkiSyncDAL.deleteById(id); + return deletedPkiSync as TPkiSync; + }; + + const listPkiSyncsByProjectId = async ({ projectId }: TListPkiSyncsByProjectId, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Read, + subject(ProjectPermissionSub.PkiSyncs, { projectId }) + ); + + const pkiSyncs = await pkiSyncDAL.findByProjectId(projectId); + return pkiSyncs; + }; + + const listPkiSyncsBySubscriberId = async ({ subscriberId }: TListPkiSyncsBySubscriberId) => { + const pkiSyncs = await pkiSyncDAL.findBySubscriberId(subscriberId); + return pkiSyncs; + }; + + const findPkiSyncById = async ({ id, projectId }: TFindPkiSyncByIdDTO, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) + throw new NotFoundError({ + message: `Could not find PKI Sync with ID "${id}"` + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Read, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + return pkiSync; + }; + + const findPkiSyncByName = async ({ name, projectId }: TFindPkiSyncByNameDTO) => { + const pkiSync = await pkiSyncDAL.findByNameAndProjectId(name, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + return pkiSync; + }; + + const triggerPkiSyncSyncCertificatesById = async ( + { id, projectId }: Omit, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.SyncCertificates, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: id }); + + return { message: "PKI sync job added to queue successfully" }; + }; + + const triggerPkiSyncImportCertificatesById = async ( + { id, projectId }: Omit, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.ImportCertificates, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + await pkiSyncQueue.queuePkiSyncImportCertificatesById({ syncId: id }); + + return { message: "PKI sync import job added to queue successfully" }; + }; + + const triggerPkiSyncRemoveCertificatesById = async ( + { id, projectId }: Omit, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.RemoveCertificates, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + await pkiSyncQueue.queuePkiSyncRemoveCertificatesById({ syncId: id }); + + return { message: "PKI sync remove job added to queue successfully" }; + }; + + const getPkiSyncOptions = () => { + return listPkiSyncOptions(); + }; + + return { + createPkiSync, + updatePkiSync, + deletePkiSync, + listPkiSyncsByProjectId, + listPkiSyncsBySubscriberId, + findPkiSyncById, + findPkiSyncByName, + triggerPkiSyncSyncCertificatesById, + triggerPkiSyncImportCertificatesById, + triggerPkiSyncRemoveCertificatesById, + getPkiSyncOptions + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-types.ts b/backend/src/services/pki-sync/pki-sync-types.ts new file mode 100644 index 000000000..676ea441f --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-types.ts @@ -0,0 +1,183 @@ +import { Job } from "bullmq"; + +import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; +import { QueueJobs } from "@app/queue"; +import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; + +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSync } from "./pki-sync-enums"; + +export type TPkiSync = { + id: string; + name: string; + description?: string; + destination: PkiSync; + isAutoSyncEnabled: boolean; + version: number; + destinationConfig: Record; + syncOptions: Record; + projectId: string; + subscriberId?: string; + connectionId: string; + createdAt: Date; + updatedAt: Date; + syncStatus?: string; + lastSyncJobId?: string; + lastSyncMessage?: string; + lastSyncedAt?: Date; + importStatus?: string; + lastImportJobId?: string; + lastImportMessage?: string; + lastImportedAt?: Date; + removeStatus?: string; + lastRemoveJobId?: string; + lastRemoveMessage?: string; + lastRemovedAt?: Date; +}; + +export type TPkiSyncListItem = TPkiSync & { + appConnectionName: string; + appConnectionApp: string; +}; + +export type TPkiSyncWithCredentials = TPkiSync & { + connection: { + id: string; + name: string; + app: string; + credentials: Record; + orgId: string; + }; +}; + +export type TCertificateMap = Record; + +export type TCreatePkiSyncDTO = { + name: string; + description?: string; + destination: PkiSync; + isAutoSyncEnabled?: boolean; + destinationConfig: Record; + syncOptions?: Record; + subscriberId?: string; + connectionId: string; + projectId: string; + auditLogInfo: AuditLogInfo; + resourceMetadata?: ResourceMetadataDTO; +}; + +export type TUpdatePkiSyncDTO = { + id: string; + projectId: string; + name?: string; + description?: string; + isAutoSyncEnabled?: boolean; + destinationConfig?: Record; + syncOptions?: Record; + subscriberId?: string; + connectionId?: string; + auditLogInfo: AuditLogInfo; + resourceMetadata?: ResourceMetadataDTO; +}; + +export type TDeletePkiSyncDTO = { + id: string; + projectId: string; + auditLogInfo: AuditLogInfo; +}; + +export type TListPkiSyncsByProjectId = { + projectId: string; +}; + +export type TListPkiSyncsBySubscriberId = { + subscriberId: string; +}; + +export type TFindPkiSyncByIdDTO = { + id: string; + projectId: string; +}; + +export type TFindPkiSyncByNameDTO = { + name: string; + projectId: string; +}; + +export type TTriggerPkiSyncSyncCertificatesByIdDTO = { + id: string; + projectId: string; + auditLogInfo: AuditLogInfo; +}; + +export type TTriggerPkiSyncImportCertificatesByIdDTO = { + id: string; + projectId: string; + auditLogInfo: AuditLogInfo; +}; + +export type TTriggerPkiSyncRemoveCertificatesByIdDTO = { + id: string; + projectId: string; + auditLogInfo: AuditLogInfo; +}; + +export enum PkiSyncStatus { + Pending = "pending", + Running = "running", + Succeeded = "succeeded", + Failed = "failed" +} + +export enum PkiSyncAction { + SyncCertificates = "sync-certificates", + ImportCertificates = "import-certificates", + RemoveCertificates = "remove-certificates" +} + +export type TPkiSyncRaw = NonNullable>>; + +export type TQueuePkiSyncSyncCertificatesByIdDTO = { + syncId: string; + failedToAcquireLockCount?: number; + auditLogInfo?: AuditLogInfo; +}; + +export type TQueuePkiSyncImportCertificatesByIdDTO = { + syncId: string; + auditLogInfo?: AuditLogInfo; +}; + +export type TQueuePkiSyncRemoveCertificatesByIdDTO = { + syncId: string; + auditLogInfo?: AuditLogInfo; + deleteSyncOnComplete?: boolean; +}; + +export type TQueueSendPkiSyncActionFailedNotificationsDTO = { + pkiSync: TPkiSyncRaw; + auditLogInfo?: AuditLogInfo; + action: PkiSyncAction; +}; + +export type TPkiSyncSyncCertificatesDTO = Job< + TQueuePkiSyncSyncCertificatesByIdDTO, + void, + QueueJobs.PkiSyncSyncCertificates +>; +export type TPkiSyncImportCertificatesDTO = Job< + TQueuePkiSyncImportCertificatesByIdDTO, + void, + QueueJobs.PkiSyncImportCertificates +>; +export type TPkiSyncRemoveCertificatesDTO = Job< + TQueuePkiSyncRemoveCertificatesByIdDTO, + void, + QueueJobs.PkiSyncRemoveCertificates +>; + +export type TSendPkiSyncFailedNotificationsJobDTO = Job< + TQueueSendPkiSyncActionFailedNotificationsDTO, + void, + QueueJobs.PkiSyncSendActionFailedNotifications +>; diff --git a/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx new file mode 100644 index 000000000..5b5e81ba3 --- /dev/null +++ b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs"; + +import { CreatePkiSyncForm } from "./forms"; +import { PkiSyncModalHeader } from "./PkiSyncModalHeader"; +import { PkiSyncSelect } from "./PkiSyncSelect"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + selectSync?: PkiSync | null; +}; + +type ContentProps = { + onComplete: (pkiSync: TPkiSync) => void; + selectedSync: PkiSync | null; + setSelectedSync: (selectedSync: PkiSync | null) => void; +}; + +const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => { + if (selectedSync) { + return ( + setSelectedSync(null)} + destination={selectedSync} + /> + ); + } + + return ; +}; + +export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => { + const [selectedSync, setSelectedSync] = useState(selectSync); + + useEffect(() => { + setSelectedSync(selectSync); + }, [selectSync]); + + return ( + { + if (!isOpen) setSelectedSync(null); + onOpenChange(isOpen); + }} + > + + ) : ( + "Add Sync" + ) + } + className="max-w-2xl" + bodyClassName="overflow-visible" + subTitle={ + selectedSync ? undefined : "Select a third-party service to sync certificates to." + } + > + { + setSelectedSync(null); + onOpenChange(false); + }} + selectedSync={selectedSync} + setSelectedSync={setSelectedSync} + /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx b/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx new file mode 100644 index 000000000..4a71b4dee --- /dev/null +++ b/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx @@ -0,0 +1,60 @@ +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useDeletePkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync?: TPkiSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; +}; + +export const DeletePkiSyncModal = ({ isOpen, onOpenChange, pkiSync, onComplete }: Props) => { + const deleteSync = useDeletePkiSync(); + + if (!pkiSync) return null; + + const { id: syncId, name, destination, projectId } = pkiSync; + + const handleDeletePkiSync = async () => { + const destinationName = PKI_SYNC_MAP[destination].name; + + try { + await deleteSync.mutateAsync({ + syncId, + projectId + }); + + createNotification({ + text: `Successfully deleted ${destinationName} PKI Sync`, + type: "success" + }); + + if (onComplete) onComplete(); + onOpenChange(false); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to delete ${destinationName} PKI Sync`, + type: "error" + }); + } + }; + + return ( + +

+ This action will also remove all certificates that were synced by this configuration from + the {PKI_SYNC_MAP[destination].name} destination. +

+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx b/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx new file mode 100644 index 000000000..db5745aad --- /dev/null +++ b/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx @@ -0,0 +1,29 @@ +import { PkiSyncEditFields } from "@app/components/pki-syncs/types"; +import { Modal, ModalContent } from "@app/components/v2"; +import { TPkiSync } from "@app/hooks/api/pkiSyncs"; + +import { EditPkiSyncForm } from "./forms"; +import { PkiSyncModalHeader } from "./PkiSyncModalHeader"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + pkiSync?: TPkiSync; + fields: PkiSyncEditFields; +}; + +export const EditPkiSyncModal = ({ pkiSync, onOpenChange, fields, ...props }: Props) => { + if (!pkiSync) return null; + + return ( + + } + className="max-w-2xl" + bodyClassName="overflow-visible" + > + onOpenChange(false)} fields={fields} pkiSync={pkiSync} /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx b/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx new file mode 100644 index 000000000..39895c6ca --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx @@ -0,0 +1,94 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useTriggerPkiSyncImportCertificates } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync?: TPkiSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + pkiSync: TPkiSync; + onComplete: () => void; +}; + +const Content = ({ pkiSync, onComplete }: ContentProps) => { + const { id: syncId, destination, projectId } = pkiSync; + const destinationName = PKI_SYNC_MAP[destination].name; + + const triggerImportCertificates = useTriggerPkiSyncImportCertificates(); + + const handleTriggerImportCertificates = async () => { + try { + await triggerImportCertificates.mutateAsync({ + syncId, + projectId + }); + + createNotification({ + text: `Successfully triggered certificate import for ${destinationName} Sync`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to trigger certificate import for ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( +
{ + e.preventDefault(); + handleTriggerImportCertificates(); + }} + > +

+ Are you sure you want to import certificates from this {destinationName} destination into + Infisical? +

+

+ This operation will retrieve certificates from {destinationName} and make them available in + your PKI collection. Only certificates that are not already imported will be processed. +

+
+ + + + +
+
+ ); +}; + +export const PkiSyncImportCertificatesModal = ({ isOpen, onOpenChange, pkiSync }: Props) => { + if (!pkiSync) return null; + + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx b/frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx new file mode 100644 index 000000000..fbdc91c36 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx @@ -0,0 +1,112 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { + faCheck, + faDownload, + faTriangleExclamation, + faXmark, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { differenceInSeconds } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSyncData, PkiSyncStatus } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: PkiSyncData; + className?: string; + mini?: boolean; +}; + +export const PkiSyncImportStatusBadge = ({ pkiSync, className, mini }: Props) => { + const { importStatus, lastImportMessage, lastImportedAt, destination } = pkiSync; + const [hide, setHide] = useState(importStatus === PkiSyncStatus.Succeeded); + const destinationName = PKI_SYNC_MAP[destination].name; + + useEffect(() => { + if (importStatus === PkiSyncStatus.Succeeded) { + setTimeout(() => setHide(true), 3000); + } else { + setHide(false); + } + }, [importStatus]); + + const failureMessage = useMemo(() => { + if (importStatus === PkiSyncStatus.Failed) { + if (lastImportMessage) + try { + return JSON.stringify(JSON.parse(lastImportMessage), null, 2); + } catch { + return lastImportMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [importStatus, lastImportMessage]); + + if (!importStatus || hide) return null; + + let variant: BadgeProps["variant"]; + let label: string; + let icon: IconDefinition; + let tooltipContent: ReactNode; + + switch (importStatus) { + case PkiSyncStatus.Pending: + case PkiSyncStatus.Running: + variant = "primary"; + label = "Importing Certificates..."; + tooltipContent = `Importing certificates from ${destinationName}. This may take a moment.`; + icon = faDownload; + + break; + case PkiSyncStatus.Failed: + variant = "danger"; + label = "Failed to Import Certificates"; + icon = faTriangleExclamation; + tooltipContent = ( +
+ {failureMessage && ( +
+
+ +
+ {mini ? "Failed to Import Certificates" : "Failure Reason"} +
+
+
{failureMessage}
+
+ )} +
+ ); + + break; + case PkiSyncStatus.Succeeded: + default: + // only show success for a bit... + if (lastImportedAt && differenceInSeconds(new Date(), lastImportedAt) > 15) return null; + + tooltipContent = "Successfully imported certificates."; + variant = "success"; + label = "Certificates Imported"; + icon = faCheck; + } + + return ( + +
+ + + {!mini && {label}} + +
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx b/frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx new file mode 100644 index 000000000..917d221f6 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx @@ -0,0 +1,49 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + destination: PkiSync; + isConfigured: boolean; +}; + +export const PkiSyncModalHeader = ({ destination, isConfigured }: Props) => { + const destinationDetails = PKI_SYNC_MAP[destination]; + + return ( +
+ {`${destinationDetails.name} +
+
+ {destinationDetails.name} Certificate Sync + +
+ + Docs + +
+
+
+

+ {isConfigured + ? `Edit ${destinationDetails.name} Certificate Sync` + : `Sync certificates to ${destinationDetails.name}`} +

+
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx b/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx new file mode 100644 index 000000000..2a96bd902 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx @@ -0,0 +1,86 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useTriggerPkiSyncRemoveCertificates } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync?: TPkiSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + pkiSync: TPkiSync; + onComplete: () => void; +}; + +const Content = ({ pkiSync, onComplete }: ContentProps) => { + const { id: syncId, destination, projectId } = pkiSync; + const destinationName = PKI_SYNC_MAP[destination].name; + + const triggerRemoveCertificates = useTriggerPkiSyncRemoveCertificates(); + + const handleTriggerRemoveCertificates = async () => { + try { + await triggerRemoveCertificates.mutateAsync({ + syncId, + projectId + }); + + createNotification({ + text: `Successfully triggered certificate removal for ${destinationName} Sync`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to trigger certificate removal for ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( +
{ + e.preventDefault(); + handleTriggerRemoveCertificates(); + }} + > +

+ Are you sure you want to remove certificates synced by Infisical from this {destinationName}{" "} + destination? +

+
+ + + + +
+
+ ); +}; + +export const PkiSyncRemoveCertificatesModal = ({ isOpen, onOpenChange, pkiSync }: Props) => { + if (!pkiSync) return null; + + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx b/frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx new file mode 100644 index 000000000..ed92f70f0 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx @@ -0,0 +1,112 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { + faCheck, + faEraser, + faTriangleExclamation, + faXmark, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { differenceInSeconds } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSyncData, PkiSyncStatus } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: PkiSyncData; + className?: string; + mini?: boolean; +}; + +export const PkiSyncRemoveStatusBadge = ({ pkiSync, className, mini }: Props) => { + const { removeStatus, lastRemoveMessage, lastRemovedAt, destination } = pkiSync; + const [hide, setHide] = useState(removeStatus === PkiSyncStatus.Succeeded); + const destinationName = PKI_SYNC_MAP[destination].name; + + useEffect(() => { + if (removeStatus === PkiSyncStatus.Succeeded) { + setTimeout(() => setHide(true), 3000); + } else { + setHide(false); + } + }, [removeStatus]); + + const failureMessage = useMemo(() => { + if (removeStatus === PkiSyncStatus.Failed) { + if (lastRemoveMessage) + try { + return JSON.stringify(JSON.parse(lastRemoveMessage), null, 2); + } catch { + return lastRemoveMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [removeStatus, lastRemoveMessage]); + + if (!removeStatus || hide) return null; + + let variant: BadgeProps["variant"]; + let label: string; + let icon: IconDefinition; + let tooltipContent: ReactNode; + + switch (removeStatus) { + case PkiSyncStatus.Pending: + case PkiSyncStatus.Running: + variant = "primary"; + label = "Removing Certificates..."; + tooltipContent = `Removing certificates from ${destinationName}. This may take a moment.`; + icon = faEraser; + + break; + case PkiSyncStatus.Failed: + variant = "danger"; + label = "Failed to Remove Certificates"; + icon = faTriangleExclamation; + tooltipContent = ( +
+ {failureMessage && ( +
+
+ +
+ {mini ? "Failed to Remove Certificates" : "Failure Reason"} +
+
+
{failureMessage}
+
+ )} +
+ ); + + break; + case PkiSyncStatus.Succeeded: + default: + // only show success for a bit... + if (lastRemovedAt && differenceInSeconds(new Date(), lastRemovedAt) > 15) return null; + + tooltipContent = "Successfully removed certificates."; + variant = "success"; + label = "Certificates Removed"; + icon = faCheck; + } + + return ( + +
+ + + {!mini && {label}} + +
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncSelect.tsx b/frontend/src/components/pki-syncs/PkiSyncSelect.tsx new file mode 100644 index 000000000..5a004823d --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncSelect.tsx @@ -0,0 +1,155 @@ +import { useMemo } from "react"; +import { faInfoCircle, faMagnifyingGlass, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { EmptyState, Input, Pagination, Spinner, Tooltip } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { PkiSync, usePkiSyncOptions } from "@app/hooks/api/pkiSyncs"; + +import { UpgradePlanModal } from "../license/UpgradePlanModal"; + +type Props = { + onSelect: (destination: PkiSync) => void; +}; + +export const PkiSyncSelect = ({ onSelect }: Props) => { + const { subscription } = useSubscription(); + const { isPending, data: pkiSyncOptions } = usePkiSyncOptions(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + + const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", { + initPerPage: 16 + }); + + const filteredOptions = useMemo( + () => + pkiSyncOptions?.filter(({ destination }) => { + const { name } = PKI_SYNC_MAP[destination]; + return ( + name?.toLowerCase().includes(search.trim().toLowerCase()) || + destination.toLowerCase().includes(search.toLowerCase()) + ); + }) ?? [], + [pkiSyncOptions, search] + ); + + useResetPageHelper({ + totalCount: filteredOptions.length, + offset, + setPage + }); + + if (isPending) { + return ( +
+ +

Loading options...

+
+ ); + } + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search options..." + className="bg-mineshaft-800 placeholder:text-mineshaft-400" + /> +
+ {filteredOptions.slice(offset, perPage * page)?.map(({ destination, enterprise }) => { + const { image, name } = PKI_SYNC_MAP[destination]; + return ( + + ); + })} + {!filteredOptions?.length && ( + + )} +
+ {Boolean(filteredOptions.length) && ( + +

Infisical is constantly adding support for more services.

+

+ {`If you don't see the third-party + service you're looking for,`}{" "} + + let us know on Slack + {" "} + or{" "} + + make a request on GitHub + + . +

+ + } + > +
+ + Don't see the third-party service you're looking for? + + +
+ + } + count={filteredOptions.length} + page={page} + perPage={perPage} + onChangePage={setPage} + onChangePerPage={setPerPage} + perPageList={[16]} + /> + )} + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use every Certificate Sync if you switch to Infisical's Enterprise plan." + /> +
+ ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx b/frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx new file mode 100644 index 000000000..a177227fa --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx @@ -0,0 +1,55 @@ +import { + faCheck, + faExclamationTriangle, + faHourglass, + faRotate, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge"; +import { PkiSyncStatus } from "@app/hooks/api/pkiSyncs"; + +type Props = { + status: PkiSyncStatus; +} & Omit; + +export const PkiSyncStatusBadge = ({ status }: Props) => { + let variant: BadgeProps["variant"]; + let text: string; + let icon: IconDefinition; + + switch (status) { + case PkiSyncStatus.Failed: + variant = "danger"; + text = "Failed to Sync"; + icon = faExclamationTriangle; + break; + case PkiSyncStatus.Succeeded: + variant = "success"; + text = "Synced"; + icon = faCheck; + break; + case PkiSyncStatus.Pending: + variant = "primary"; + text = "Queued"; + icon = faHourglass; + break; + case PkiSyncStatus.Running: + default: + variant = "primary"; + text = "Syncing"; + icon = faRotate; + break; + } + + return ( + + + {text} + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncTable.tsx b/frontend/src/components/pki-syncs/PkiSyncTable.tsx new file mode 100644 index 000000000..0be92028e --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncTable.tsx @@ -0,0 +1,121 @@ +import { faPlug, faRefresh, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Badge, + EmptyState, + IconButton, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { PkiSyncData } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSyncs: PkiSyncData[]; + onEdit: (pkiSync: PkiSyncData) => void; + onDelete: (pkiSync: PkiSyncData) => void; + onTrigger: (pkiSync: PkiSyncData) => void; +}; + +const getSyncStatusBadge = (status?: string) => { + switch (status) { + case "SUCCESS": + return Success; + case "FAILED": + return Failed; + case "RUNNING": + return Running; + case "PENDING": + default: + return Pending; + } +}; + +export const PkiSyncTable = ({ pkiSyncs, onEdit, onDelete, onTrigger }: Props) => { + if (!pkiSyncs.length) { + return ( +
+ + Start by creating a PKI sync to synchronize certificates with external services + +
+ ); + } + + return ( + + + + + + + + + + + + + + {pkiSyncs.map((pkiSync) => ( + onEdit(pkiSync)} + > + + + + + + + + + ))} + +
NameDestinationConnectionAuto SyncStatusLast Sync +
{pkiSync.name} +
+ {pkiSync.destination} +
+
{pkiSync.appConnectionName || "Unknown"} + + {pkiSync.isAutoSyncEnabled ? "Enabled" : "Disabled"} + + {getSyncStatusBadge(pkiSync.syncStatus ?? undefined)} + {pkiSync.lastSyncedAt + ? new Date(pkiSync.lastSyncedAt).toLocaleDateString() + : "Never"} + +
+ { + e.stopPropagation(); + onTrigger(pkiSync); + }} + > + + + { + e.stopPropagation(); + onDelete(pkiSync); + }} + > + + +
+
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx b/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx new file mode 100644 index 000000000..87feebff7 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx @@ -0,0 +1,37 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input } from "@app/components/v2"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { PkiSyncConnectionField } from "./PkiSyncConnectionField"; +import { TPkiSyncForm } from "./schemas"; + +export const AzureKeyVaultPkiSyncFields = () => { + const { control, setValue } = useFormContext< + TPkiSyncForm & { destination: PkiSync.AzureKeyVault } + >(); + + return ( + <> + { + setValue("destinationConfig.vaultBaseUrl", ""); + }} + /> + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx new file mode 100644 index 000000000..d30927978 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx @@ -0,0 +1,243 @@ +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tab } from "@headlessui/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Switch } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSync, TPkiSync, useCreatePkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; + +import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; +import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; +import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; +import { PkiSyncReviewFields } from "./PkiSyncReviewFields"; +import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; +import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas"; + +type Props = { + onComplete: (pkiSync: TPkiSync) => void; + destination: PkiSync; + onCancel: () => void; +}; + +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: "Review", key: "review", fields: [] } +]; + +export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) => { + const createPkiSync = useCreatePkiSync(); + const { currentWorkspace } = useWorkspace(); + const { name: destinationName } = PKI_SYNC_MAP[destination]; + + const [showConfirmation, setShowConfirmation] = useState(false); + + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const { syncOption } = usePkiSyncOption(destination); + + const formMethods = useForm({ + resolver: zodResolver(PkiSyncFormSchema), + defaultValues: { + destination, + isAutoSyncEnabled: true, + syncOptions: { + canImportCertificates: syncOption?.canImportCertificates ?? true, + canRemoveCertificates: syncOption?.canRemoveCertificates ?? true + } + } as Partial, + reValidateMode: "onChange" + }); + + const onSubmit = async ({ connection, ...formData }: TPkiSyncForm) => { + try { + const pkiSync = await createPkiSync.mutateAsync({ + ...formData, + connectionId: connection.id, + projectId: currentWorkspace.id + }); + + createNotification({ + text: `Successfully added ${destinationName} Certificate Sync`, + type: "success" + }); + onComplete(pkiSync); + } catch (err: any) { + console.error(err); + setShowConfirmation(false); + createNotification({ + title: `Failed to add ${destinationName} Certificate Sync`, + text: err.message, + type: "error" + }); + } + }; + + const handlePrev = () => { + if (selectedTabIndex === 0) { + onCancel(); + return; + } + + setSelectedTabIndex((prev) => prev - 1); + }; + + const { handleSubmit, trigger, control } = formMethods; + + const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields); + + const isFinalStep = selectedTabIndex === FORM_TABS.length - 1; + + const handleNext = async () => { + if (isFinalStep) { + setShowConfirmation(true); + return; + } + + const isValid = await isStepValid(selectedTabIndex); + + if (!isValid) return; + + setSelectedTabIndex((prev) => prev + 1); + }; + + const isTabEnabled = async (index: number) => { + let isEnabled = true; + for (let i = index - 1; i >= 0; i -= 1) { + // eslint-disable-next-line no-await-in-loop + isEnabled = isEnabled && (await isStepValid(i)); + } + + return isEnabled; + }; + + if (showConfirmation) + return ( + <> +
+
+ + Certificate Sync Behavior +
+

+ Certificate Syncs are the source of truth for connected third-party services. Any + certificate, including associated data, not present or imported in Infisical before + syncing will be overwritten, and changes made directly in the connected service outside + of infisical may also be overwritten by future syncs. +

+
+
+ + + +
+ + ); + + return ( +
+ + + + {FORM_TABS.map((tab, index) => ( + { + e.preventDefault(); + const isEnabled = await isTabEnabled(index); + setSelectedTabIndex((prev) => (isEnabled ? index : prev)); + }} + className={({ selected }) => + `w-30 -mb-[0.14rem] ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + key={tab.key} + > + {index + 1}. {tab.name} + + ))} + + + + + + + + + + + { + return ( + + +

Auto-Sync {value ? "Enabled" : "Disabled"}

+
+
+ ); + }} + /> +
+ + + + + + +
+
+
+ +
+ + {selectedTabIndex > 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx new file mode 100644 index 000000000..58fcc512c --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx @@ -0,0 +1,107 @@ +import { ReactNode } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { createNotification } from "@app/components/notifications"; +import { PkiSyncEditFields } from "@app/components/pki-syncs/types"; +import { Button, ModalClose } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useUpdatePkiSync } from "@app/hooks/api/pkiSyncs"; + +import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; +import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; +import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; +import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; +import { TPkiSyncForm, UpdatePkiSyncFormSchema } from "./schemas"; + +type Props = { + onComplete: (pkiSync: TPkiSync) => void; + pkiSync: TPkiSync; + fields: PkiSyncEditFields; +}; + +export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => { + const updatePkiSync = useUpdatePkiSync(); + const { name: destinationName } = PKI_SYNC_MAP[pkiSync.destination]; + + const formMethods = useForm({ + resolver: zodResolver(UpdatePkiSyncFormSchema), + defaultValues: { + ...pkiSync, + description: pkiSync.description ?? "", + connection: { + id: pkiSync.connectionId, + name: pkiSync.appConnectionName + } + } as Partial, + reValidateMode: "onChange" + }); + + const onSubmit = async ({ connection, ...formData }: TPkiSyncForm) => { + try { + const updatedPkiSync = await updatePkiSync.mutateAsync({ + syncId: pkiSync.id, + ...formData, + connectionId: connection.id, + projectId: pkiSync.projectId + }); + + createNotification({ + text: `Successfully updated ${destinationName} PKI Sync`, + type: "success" + }); + onComplete(updatedPkiSync); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${destinationName} PKI Sync`, + text: err.message, + type: "error" + }); + } + }; + + let Component: ReactNode; + + switch (fields) { + case PkiSyncEditFields.Destination: + Component = ; + break; + case PkiSyncEditFields.Options: + Component = ; + break; + case PkiSyncEditFields.Source: + Component = ; + break; + case PkiSyncEditFields.Details: + default: + Component = ; + break; + } + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = formMethods; + + return ( +
+ {Component} +
+ + + + +
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx new file mode 100644 index 000000000..ffcf6ca7d --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx @@ -0,0 +1,86 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { OrgPermissionSubjects, useOrgPermission } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; + +import { TPkiSyncForm } from "./schemas"; + +type Props = { + onChange?: VoidFunction; +}; + +export const PkiSyncConnectionField = ({ onChange: callback }: Props) => { + const { permission } = useOrgPermission(); + const { control, watch } = useFormContext(); + + const destination = watch("destination"); + const app = PKI_SYNC_CONNECTION_MAP[destination]; + + const { data: availableConnections, isPending } = useListAvailableAppConnections(app); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); + + const appName = APP_CONNECTION_MAP[PKI_SYNC_CONNECTION_MAP[destination]].name; + + return ( + <> +

+ Specify the {appName} Connection to use to connect to {connectionName} and configure + destination parameters. +

+ ( + + { + onChange(newValue); + if (callback) callback(); + }} + isLoading={isPending} + options={availableConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + control={control} + name="connection" + /> + {availableConnections?.length === 0 && ( +

+ + {canCreateConnection ? ( + <> + You do not have access to any {appName} Connections. Create one from the{" "} + + App Connections + {" "} + page. + + ) : ( + `You do not have access to any ${appName} Connections. Contact an admin to create one.` + )} +

+ )} + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx new file mode 100644 index 000000000..5b89d23e4 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx @@ -0,0 +1,19 @@ +import { useFormContext } from "react-hook-form"; + +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { AzureKeyVaultPkiSyncFields } from "./AzureKeyVaultPkiSyncFields"; +import { TPkiSyncForm } from "./schemas"; + +export const PkiSyncDestinationFields = () => { + const { watch } = useFormContext(); + + const destination = watch("destination"); + + switch (destination) { + case PkiSync.AzureKeyVault: + return ; + default: + throw new Error(`Unhandled Destination Config Field: ${destination}`); + } +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx new file mode 100644 index 000000000..d7fc282dd --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx @@ -0,0 +1,51 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; + +import { TPkiSyncForm } from "./schemas"; + +export const PkiSyncDetailsFields = () => { + const { control } = useFormContext(); + + return ( + <> +

+ Provide a name and description for this Certificate Sync. +

+ ( + + + + )} + control={control} + name="name" + /> + ( + +